Speee DEVELOPER BLOG

Speee開発陣による技術情報発信ブログです。 メディア開発・運用、スマートフォンアプリ開発、Webマーケティング、アドテクなどで培った技術ノウハウを発信していきます!

Reactでパターンシーケンサを作った話

こんにちは、Speeeエンジニアの二社谷(nishaya)です。

先日開催されたSpeeeKaigi(詳細は以下の記事を参照)にて、Reactで使ったパターンシーケンサの発表を行いました。

tech.speee.jp

今回作ったもの

DEMO
ソースコード

  • ブラウザで動くマルチトラックのステップシーケンサです
  • 音楽の知識がなくても、なんとなく触っているだけでそれっぽい音が出ます
  • サンプラーもついてます

f:id:nishaya:20170904095922p:plain

使ったもの

  • React/Redux
  • FlowType
  • Web Audio API/MediaDevices

発表資料

speakerdeck.com

モチベーションと課題

前回のSpeeeKaigiではReactでシンセサイザーを作ったのですが、自分は楽器を弾けるわけではないので、ブラウザを使って音楽を楽しむところまでは到達できませんでした。
そこで、下記の3点を押さえたモノを作れば、楽器を弾けない自分でも音楽を楽しむことができるのでは?と考え、シーケンサを制作することにしました。

  • 指定したタイミング通りに音を鳴らす
  • 演奏パターンを生成する
  • 合成した音だけでなく、録音した音も鳴らせるようにする

課題1: 指定したタイミング通りに音を鳴らす

一定間隔で発音を制御しようとしたとき、最初に思いつくのが setTimeoutsetInterval を使う方法です。
ループが巡ってくる度に発音タイミングかどうかを調べ、発音タイミングなら OscillatorNode.start() で音を鳴らすことで、指定のタイミングに沿って音を鳴らすことができそうです。

しかし、その方法だと下記の2点の問題がありました

  • ループのインターバル未満のタイミングで発音できない
  • 同時発音数が多くなるとnodeの初期化などの負荷が高くなって発音タイミングがズレる

そこで、setTimeout() を適度なタイミング(今回は16分音符1つ分の1/3に設定)で回し、 向こう一定期間分の発音スケジュールを算出し、OscillatorNode.start() の引数として渡しています。

スケジュールしてから発音までには時間差があるので、その間にnodeの初期化などの処理が完了し、指定された時刻に一斉に音が鳴ります。
これによって、発音数が多くなってもズレることなく発音することができるようになりました。 また、シャッフルによってハネたリズムや、細かく刻んだリズムなど、微妙なタイミングでの発音も可能になりました。

f:id:nishaya:20170904100052p:plain

課題2: 演奏パターンを生成する

音符を置いてメロディを書いていくのは敷居が高いと思ったため、単純な音の羅列を幾重にも変化させてパターンを生成することを試みました。

パターンは fragment と呼ぶモジュールを通ると別のパターンになり、次のfragmentに渡され、それを繰り返して最終的に演奏されるパターンが生成されます。
各fragmentは、以下のようにパターンを変化させるfunctionをラップしていて、Reactのpropsを通じて次のfragmentにパターンを渡していきます。

transform(steps: Steps): Steps {
  const newList = steps.list.map((step) => {
    let note = step.note + this.state.transpose
    if (note > 127) {
      note = 127
    } else if (note < 0) { note = 0 }
    return { ...step, note }
  })
  return { ...steps, list: newList }
}

fragmentsの紹介

デモには下記のようなfragmentを実装しました。

f:id:nishaya:20170904100203p:plain

repeat

指定した回数分パターンを繰り返します。

stairs

指定した音程で階段状の変化を与えます。

transpose

指定した分だけ音を上げたり下げたりします。

scale

指定したスケールに音階を固定します。
適当に生成したパターン同士でもスケールを合わせるとなんとなく調和する(ように自分には聞こえる)ので気に入っています。

stretch

指定した倍率にパターンを引き延ばします。
これによって16分音符のn倍以外での発音が発生しますが、前述のタイミング制御により、そういった微妙なタイミングでの発音も問題なく吸収できています。

limit

上下に音階の制限を設けます。 制限を超えた音は制限値に丸められます。

課題3: 録音した音を鳴らせるようにする

スネアやシンバル、ストリングスといった音色は基本波形から合成するのが難しいため、サンプリングされた音源を使えるようにしました。

圧縮された音源ファイルは AudioContext.decodeAudioData() を用いてデコードすることで AudioBuffer が得られるのでこれを AudioBufferSourceNode にセットして再生します。

録音の実装

用意されたサンプリング音源を鳴らせるだけでは面白くないので、録音した音声データも使えるようにしました。

デバイスに内蔵(or接続)されたマイクにアクセスするには MediaDevices.getUserMedia() を用います。
このメソッドを呼び出すとブラウザではデバイスの使用許可を求めるダイアログが表示され、ユーザが許可をすると MediaStream が得られます。

navigator.mediaDevices.getUserMedia({ audio: true, video: false })
  .then((stream: MediaStream) => // do something. )

f:id:nishaya:20170904100239p:plain

今回はこのstreamを MediaRecorder でキャプチャし、得られたblobを AudioContext.decodeAudioData() でAudioBufferにデコードしてから、音源としてアサインできるようにしています。

サンプルの編集機能

  • 録音したままだと前後に余分な音が入るのでカットしたい
  • 長く伸ばして発音させるためにループさせたい
  • 録音したそのままの音でなく、高く/低く再生できるよう調整したい

録音機能を実装したところ、上記のような不満を感じたので、さらにサンプルの編集機能を実装しました。

f:id:nishaya:20170904100425p:plain

UIによって編集されたサンプルデータは、以下の形式で管理されています(Flowtypeのアノテーション)。
録音された音声データそのもの(AudioBuffer)と再生に必要な情報(ループ区間や音程などのメタデータ)をまとめてあるので、これをシンセサイザーのプリセットやドラムの音に割り当てることで、どこで使う場合にも編集したとおりに鳴らせるようにしています。

export type Sample = {
  buffer: AudioBuffer,
  id: string,
  name: string,
  offset: number,
  loop: boolean,
  loopStart: number,
  loopEnd: number,
  transpose: number,
}

サンプルデータは、Reduxのstateを通じてサンプルを扱うコンポーネントに同期され、ドラムや楽器のソースとして選択できるようになっています。

所感

  • 今回作ったfragmentのチェインのように、内部データのフローとその可視化を同期させたい場合コンポーネントという概念がしっくりくる
  • パターンや発音情報、サンプリングデータなど、特定の形式のデータを扱う上でFlowtypeが役に立った
  • 通常はブラウザでやらないようなことをブラウザでやってみると、Web Audio APIやMediaDevicesなど、普段の開発で触れることのないAPIを知るきっかけになる。