Web MIDI

 MIDIもJavaScriptで簡単に扱えるが、どうもSafariは対応していないようだ。iOSのChromeのエンジンもSafariと同じWebKitなので、iOS上ではChromeもダメなはず。 ただ、EUがWebKit以外のエンジンも許可すべし、という命令をAppleに対して出しているので、そのうち状況は変わるかもしれない。

 以下、シンセサイザーの構造やMIDIの基本的な知識があることを前提とする。

MIDI入力を見てみる

 意外と簡単。

 結果はconsole.logで出しているので、開発コンソールを開いて見てください。

 わざわざ一度Arrayにしているのは、見やすいように16進2桁に変換したかったから。 Uint8Arrayにもmapはあるが、Uint8Arrayへの変換しかできない。 Array.mapならば文字列の配列に変換できる。 MIDIにはランニングステータスという仕様があるが、「Running status is not allowed in the data, as underlying systems may not support it.」と書かれているので、ランニングステータスはすべて展開されていると考えていいようだ。

音を鳴らす

 Web Audioの成果を使う。 フェーズジェネレータはそのまま使っている。 フェーズマルチプライヤはオクターブシフトをかけられるようになっている。 JavaScriptの数値は浮動小数で、2の累乗倍は誤差なく計算できるため、別に扱った方が都合がよい、という判断である。

 このため、どこか1オクターブ分の周波数比を決めておき、あとはオクターブシフトでオクターブを決めればよい。 このとき、下の方のオクターブを基準にしてオクターブを上げる方向にすると、これは左シフトに相当し、最終的に整数部は捨てられてしまうから、LSB側にどんどん0が入ることになる。 上の方のオクターブならば、フェーズジェネレーターの整数部を復元してから右シフトになるので、MSB側から小数点以下にビットが入り込んでくる。 したがって、上の方のオクターブを使った方が精度的には有利だが、あとで分かるように同期を考えると下のオクターブを使った方がよい。

 とりあえずひとつだけ音が鳴ればいいや、ってことで、MIDIまわりはかなりいい加減。 1オクターブ分12音の周波数比を配列で用意しておいて、ノートオンが来たらノートナンバーから配列のインデックスとオクターブを求めて、のこぎり波を再生しているだけである。 周波数比は平均律ではなくピタゴラス音律で用意している。

 ノートオフは0x80で来る場合と、0x90のベロシティ0で来る場合があるが、これは対応している。 ただ、ノートオフのノートナンバーは見てないので、C押す→D押す→C離す、とやるとCが鳴り、Cが消えてDが鳴り、Dが止まる。 本当はここでDが止まってはいけない。 あと、チャンネルも一切見てない(オムニオンに近い)。

 ノートオン・ノートオフ時にノイズが出ないようにするためには若干工夫が必要である。 いわゆるエンベロープジェネレータが必要である。 これはGainNodeで実現できる。 ノートオンが来たらアタックタイムでゲインを直線的に増加させ、ノートオフが来たらリリースレートでつまり指数関数的に減衰させるようなエンベロープにしよう。 アッタクタイムの次元は時間で、JavaScriptではms単位で扱うことが多いが、Web Audioでは秒で扱うことが多いので、単位は秒にしておこう。

 Web AudioにはAudioParamの変化を「予約」できるような関数群があり、これを使うと比較的簡単にできる。 アッタクはlinearRampToValueAtTimeを使えばよい。 リリースはsetTargetAtTimeが使える。 この関数はどこにも「指数変化」という名前が入っていないが、指定した時定数で指数変化する。 詳しくはWeb Audioの方に書いてある。 リリースレートをdB/sと定義するのなら、これを時定数に変換する必要がある。 リリースの場合、目的値は0なので、現在の値を \(v_0\) 、時定数を \(\tau\) とすると、時刻 \(t\) における計算値 \(v\) は

\begin{align*} v=v_0\cdot e^{-t/\tau} \end{align*}

になる。 つまり、時刻 \(t\) におけるゲインをdBで表すと、

\begin{align*} 20\log_{10} e^{-t/\tau} &= \frac{20\log_e e^{-t/\tau}}{\log_e 10} \eol &= -\frac{20t/\tau}{\log_e 10} \eol &= -\frac{20t}{\tau\log_e 10} \end{align*}

になるので、これを \(t\) で割れば[dB/s]になる。

\begin{align*} R = -\frac{20}{\tau\log_e 10} \end{align*}

したがって、逆に \(R\) から \(\tau\) を求めると、

\begin{align*} \tau&=-\frac{20}{R\log_e 10}\eol&\neareq -8.686/R \end{align*}

となる。

 あるいは、リリースが短い場合は例えば 1/100になるのにかかる時間、のような定義の仕方の方が分かりやすいだろう。

\begin{align*} v&=v_0\cdot e^{-t/\tau}\eol \frac{v}{v_0}&=e^{-t/\tau}\eol \log_e\frac{v}{v_0}&=-\frac{t}{\tau}\eol \tau&=-\frac{t}{\log_e(v/v_0)} \end{align*}

となるので、 \(v/v_0\) が1/100になるのにかかる時間が \(t\) だとすれば、時定数 \(\tau\) は

\begin{align*} \tau&=-\frac{t}{\log_e(v/v_0)}\eol &=-\frac{t}{\log_e(0.01)}\eol &\neareq-\frac{t}{-4.605}\eol &\neareq0.2171\cdot t \end{align*}

である。

 あと、setTargetAtTimeはいわゆる「指数減衰」のような変化をするため、数学的には永遠に目的値にたどりつかない。 このため時定数の10倍に達したところでlinearRampToValueAtTimeで0にしている。

 そして、音が出ている時にノードを再利用したい場合は、ノイズが出ないように音を消してから、新しい音をノートオンする必要がある。 この「ノイズが出ないように音を消す」ことを「ダンプ」と言ったりする。 今検索してみたらバルクダンプが上位に来るけど、これとは違う(完全に業界内部用語か、これ)。

  cancelAndHoldAtTime なんかを駆使してゴニョゴニョやるわけだが、深い闇があることはWeb Audioに書いた。 ノートオンがいつ来るか分からないので、ダンプはいつ起こるか分からない。 こんな簡単なエンベロープジェネレータでも、アタック時・サスティン時・リリース時の3パターンについて考えなければいけない。 結論から書くとこういうコートになる。

cancelAndHoldAtTime でイベントをキャンセルし、 linearRampToValueAtTime で0にするが、Web Audioに書いたように何も考えずに書いてしまうと変なところが変化の起点になってしまうので、一度 setTargetAtTime を置いてから linearRampToValueAtTime をスケジュールしている。

 これらの値は値が必要となった時点で「理想的な」値を求めることになっている。 なんでこんなことを書いたのかというと、a-rateとk-rateが混在する場合に問題が起きることがあるから。 今回のコードでは音量はa-rateだが、周波数はk-rateにしている。 ノイズを出さないためには、まず音量を0にし、ついで周波数を変更し、アタックを始める、という手順が必要である。

 音量を0にしてすぐにアタックを始めると音量はサンプル単位でゲインが増加していく。 しかし、k-rateはprocessブロックの頭のサンプルの時刻が \(t\) として採用されるので、音量が0になった時刻がぴったりブロックの頭ではない限り、音量が0になる時刻と周波数が変わる時刻がずれてしまう。 少なくとも1ブロック分は音量が0の時間を保つ必要がある。 これをやっているのが this.keyOnDelay を足している部分。

 さらに面倒なことに、今はブロックサイズは128サンプル固定なのだが、将来的に変わる可能性があり、しかも状況によって変化することがある、とも書かれている。 しかし、ブロックサイズを取得する機能は現状ではないっぽいんだな。 最新のWeb Audioの仕様書を見るとBaseAudioContext.renderQuantumSizeというのが載っているから、そのうち実装されるんだろうけど。

 先のリストの cancelEnvelope がだいたいこういう実装になっている。 currentTime を使っているが、ブラウザによってはいわゆるフィンガープリント対策で精度が落とされている場合がある。 ちょっと困る。

和音を鳴らす

 いわゆる「キーアサイナ」というのが必要になってくる。 「いわゆる」と書いたが、Google先生も教えてくれない専門用語だなこれ(そういう業界にいたことがバレる…)。 ノートオンのたびにSawtoothNodeを作る手もあるが、負荷の関係から同時発音数に上限は設けておきたいし、ガベコレとかメモリリークもあるので、固定の数だけノードを確保してしまい、それを使い回すことにする。 組み込み音源では一般的に用いられている手法である。

 ノートオンの数がノードの数を超えた場合は鳴らない音が出てくる。 主に先着優先のアルゴリズムと後着優先のアルゴリズムがある。 前者はノードが足りなくなったらそれ以上新しい音は出さない。 後者はノードが足りなくなったら一番古い音を消す。 前者の方が簡単である。 後者はノートオンの順序を覚えておく必要がある。

 ノートオンとノートオフの順序は同じとは限らないため、ノートオフを受けたらノートナンバーが一致するノードだけ音を止める必要がある。 このため、ノードとノートナンバーの関係をどこかに記録しておく必要がある。 後着優先の場合、ノートオンの順序と、ノートナンバーごとのノードのふたつの情報を覚えておく必要があり、なおかつ、整合が取れるようにメンテしなければならない。 これが意外に面倒である。 面倒なので今回は先着優先にしている。

 チャンネルは相変わらず見てない。 まぁ当面見なくても困らないだろう。

 ピタゴラス音律で同期発振器を使っているので、完全5度はうなることなく完全にハモる。 ピタゴラスコンマが放り込まれているGis-Es間は完全5度ではなく減6度である(ものすごい音痴に聞こえるはず)。

位相の同期

 ノートオンごとに音色が変わらないように同期発振器を使っているが実際にはまだ音色が変わる。 基準になっているAと他の音は大丈夫だが、Aから離れた、例えばCis-GisなどはCisを発音したままGisを繰り返し発音すると、発音のたびに音色が変わるのが分かる。 これは分周する場合に整数部が必要になるが、前段のジェネレータからは整数部の情報が来ない。 分周側で整数部を復元することになるが、整数部を0にするタイミングは小数部が0になったタイミングのどこかになる。 例えば5分周するなら五つあるうちのどれかになる。 整数部が0になる時刻が合わないと位相がずれるわけである。

 分周が必要なければ、つまり逓倍だけならば、言い換えるとジェネレータの1周期にマルチプライヤの波形が整数周期でぴったり収まるならば、位相関係はひとつしかなく、音色も変わらない。 整数倍で基本的かつ一番簡単なのはオクターブである。 12個のジェネレータを同期した状態で常に稼働させておき、オシレータはそれをオクターブ上げるだけにすれば、音色の変わらない純正和音の音源ができる。

  ScaleGenerator というのが各音のフェーズジェネレータである。 のこぎり波同期発振器の方はちょっと手を加えてあって、オクターブシフトとゲインが付いている。 オクターブは先ほどの理由で付けてある。 ゲインの変化自体はa-rateの補間に任せることができるので、ゲインを抱き込んでもパラメータがひとつ増え、乗算がひとつ増えるだけである。 こうした理由は後で書く。

 ノート音の前にダンプするところまでは大体一緒だが、今度は音程を決定するために、オシレータの入力ノードを、音程に対応するフェーズジェネレータにつなぎ直さないといけない。 今まではパラメータだけで済んでいたので補間機能だけでできていたが、今度はダンプしたあとゲインがゼロになったことを検出し、ノードをつなぎ変えてからアタックを始める必要がある。 この検出のため、自分で作った AudioWorkletProcessor の方にゲインを抱き込んだのである。

 メインスレッドでパラメータを設定してダンプを始めたら、オシレータの方にダンプ開始のメッセージを送る( cancelEnvelope )。 受け取ったオシレータ側はゲインが0になったところでメインスレッドにダンプ完了のメッセージを返す。 メインスレッドはこのメッセージを受け取って(メッセージハンドラ)、 cancelEnvelope に指定した関数を呼び返す。 この関数でノードをつなぎ変え、オクターブを設定してアタックを始める。 audio.currentTime はメッセージを受け取った時にはすでに過去の時刻になっているので、オクターブを設定する前にさらに this.keyOnDelay を足している。


24 Feb 2025: 新規作成

ご意見・ご要望の送り先は あかもず仮店舗 の末尾をご覧ください。

Copyright (C) 2025 akamoz.jp