WebGL・WebAssembly・Web Audioと色々書き散らかしている感じになっているが、覚え書きなのでご容赦いただきたい。 ただ、WebAssemblyとWeb Audioは私の中では一応つながっていて、ひとつの大きな目的に向かって色々やっている感じである。
ここではWeb Audioでその場で生成した波形を延々と鳴らす話題を扱う。 実は音や画像を扱う仕事を長くやってきたのだが、このあたりをきちんと理解して書けるエンジニアというのは本当に少ない。 音声データの基本的な知識(サンプリング周波数やら何やら)は持っていることを前提とする。
JavaScriptで「その場で」波形データを生成して鳴らすには、AudioWorkletを使う。 MDNをちゃんと読めば必要な情報は一応集まる。 iOSだけクセがあるのできちんと確認したほうがいい。 ただ、Web Audioは安全なコンテキストのみで動くので、ローカルネットワークでの確認がまことにやりにくい。 httpsじゃないと動かないのを忘れていてよくハマる。
const audio = new AudioContext();
AudioContextのaudioWorklet.addModule() で登録する。
fetchできる必要がある。
AudioWorkletProcessor クラスを継承したクラスをひとつ作り、それをregisterProcessor で登録しておく。
以前やった時には例のbuildClassは使えなくて、普通にextendでクラスを作らなければダメだった覚えがある。
processという関数をオーバーライドしておく。引数はinputs・outputs・parametersの3個。
inputsとoutputsは入力と出力の配列で、その要素はチャンネルの配列になっており、さらにその要素は音声の各サンプルになっている。
最初の出力の最初のチャンネルの最初のサンプルはoutputs[0][0][0]になる。
ステレオならoutputs[0][1][0]が右チャンネルの最初のサンプルになるだろう。
outputs[0][0].lengthを見たほうがよい。
データフォーマットはフルスケール±1の浮動小数。
Math.random()で乱数を返す、つまり、(擬似)ホワイトノイズを生成するようになっている。
ただ、最大音量で鳴らすとうるさいので、±0.5フルスケールにしてある。
registerProcessorの第1引数に指定した名前でモジュールを識別する。
この名前をAudioWorkletNodeのコンストラクタに指定することになる。
audioWorklet.addModule()にはJavaScriptのファイル名、AudioWorkletNodeのコンストラクタにはJavaScriptの中でregisterProcessorに指定したモジュール名を指定する。
audioWorklet.addModule() はプロミスを返すので、そのthenの中でAudioWorkletNode を作る。
AudioWorkletNodeはもちろんAudioNodeの一種なので、これをWeb Audioコンテキストのdestinationにconnectでつなぐ。
connectした瞬間に)音が鳴り出してしまうので、一度suspendしている。
resumeを叩けば音が鳴る。
プロミスを見て、再生の準備ができた時にボタンを操作可能にしている。
世の中にはフェーズジェネレータ(位相発振器)という概念があり、波形一周期分で0から1(あるいは0から2\(\pi\))まで増加してこれを繰り返す一種の発振器がある。 これをそのまま出せば基本的にはのこぎり波になる。
周波数\(f\)の音を出したいのなら、基本的にはサンプリング周波数を\(f_s\)とすると、1サンプルごとに\(f/f_s\)を積み重ねていって、1以上になったら1を引く、の繰り返しになる。 ただ、誤差を避けるためにブレゼンハムアルゴリズムと似たようなことをやっていて、具体的には全体の式を分母である\(f_s\)倍してしまい、最後に\(f_s\)で割るようにする。 したがって、\(f\)をコツコツ積み重ねていって、\(f_s\)以上になったら\(f_s\)を引き、結果を\(f_s\)で割ったものが0〜1の範囲に収まる位相値になる。 もし\(f\)も\(f_s\)も整数なら、まったく誤差なく計算できることになる。
このサンプルではパラメータの渡し方も2種類試している。
この演算を行うためにはまず\(f_s\)を知る必要があるが、AudioWorkletProcessorから直接サンプリング周波数を知る術がないようである。
サンプリング周波数はシステムが決まれば決まる定数なので、このサンプルではコンストラクタで渡している。
AudioWorkletNodeコンストラクタの第3引数にはprocessorというメンバーを設定することができ、これがAudioWorkletProcessorのコンストラクタの引数のprocessorとして見えるので、fsとしてAudioContextのsampleRateを渡している。
サンプルを公開してから気がついたのだが、AudioWorkletProcessorはAudioWorkletGlobalScope上のスレッドで実行されるが、そのグローバルスコープにsampleRateという変数があり、サンプリング周波数をHz単位で保持している。
修正するのが面倒だったので、このサンプルではとりあえずログで表示するにとどめ、それ以外の部分は以前のまま放置してある。
一方、再生する周波数の方は再生中に変更したい用途もあるだろう。
Web Audioには値を補間してくれる機能がある。
GainNodeのgainなどがこの機能に対応しており、直接値を設定するのではなく、AudioParamというインターフェースになっている。
exponentialなどの関数を使って値と変化にかける時間を設定すると、勝手に値を補間してくれる。
補間の頻度にはa-rateとk-rateの2種類があり、a-rateは1サンプルにつき1度、補間の計算が行われる。
k-rateはAudioWorkletProcessorのprocessの呼び出し1回につき1度、つまり128サンプルにつき1度の計算になる。
でもk-rateのkってなんだろう。
キーフレームのkeyとかと同じイメージなんだろうか。
音量などは適当に計算するとプチプチとノイズがなってしまうので、GainNode.gainはa-rateである。
この機能はAudioWorkletProcessorでも利用でき、そのためにはAudioWorkletProcessorクラスにstaticなプロパティparameterを作っておく必要がある。
内容はサンプルを見れば大体わかるだろう。
デフォルト値を設定できるようになっているが、デフォルト値と違う値を設定したい場合はAudioWorkletNodeのコンストラクタの第3引数にparameterDataというメンバーを作り、その中にキー・バリューペアを放り込んでおけばよい。
ややこしいのでもう一度繰り返して書くと、processorOptionsはコンストラクタ引数に対する設定で、parameterDataは補間可能なパラメータに対する設定である。
値の設定はAudioWorkletNodeのparametersにAudioParamのマップがあるのでこれを使えばよいのだが、これ、オブジェクトではなくマップである。
つまり、キー・バリューペアには変わりないのだが、node.という書き方はできなくて、node.と書く必要がある。
毎回、エラーになってしまって「なんでだろう?」と15分くらい悩む。
補間結果はAudioWorkletProcessorのprocess関数の第3引数で得られる。
こちらはマップではなく普通にオブジェクトなので、freqというメンバーを見ればよい。
値は常に配列になっている。
先にk-rateを説明すると、k-rateの場合は1要素の配列になっており、freq[0]のように最初の要素を使えばよい。
a-rateの場合は普通は入出力サンプル数と同じ要素数になっているが、期間中に値に変化がないとk-rateと同様、1要素の配列になっていることがある。
おそらくfreq[i] ?? freq[0]と書いてしまうのが一番簡単である。
実際に実行すると440Hzののこぎり波が鳴る。 「bend up」ボタンを押すと約4kHzまで10秒かけて周波数が上がっていく。 この補間はk-rateで自動的にやらせている。 周波数は位相データに積算するものなので、k-rateで十分である。
耳のいい人は「なんか音が汚い」と感じるだろう。 まったく誤差なく計算できるんじゃなかったんかい。 しかも、周波数を高くしていってるのに、最終的に最初と同じ音が鳴ってるやん、と思った人はサンプリング周波数が44.1kHzのシステムを使っている人である。
これは周波数や位相の計算誤差ではなく、折り返し雑音である。 矩形波・三角波・のこぎり波といった波形は多かれ少なかれ無限の周波数まで倍音成分を含んでいる。 これを適当にサンプリングすると、サンプリング定理にしたがって、ナイキスト周波数以上の成分が折り返して耳に聞こえるようになる。 最後の音が最初の音と同じような周波数になったのは偶然ではなく、そうなるように仕向けている。 つまり、わざわざ最後の周波数を4000Hzではなく3969Hzという中途半端な値にしたのは、第11倍音が43659Hzとなり、折り返してきた周波数が441Hzになる(44100-43659=441)からである。 理論って面白いね。
きちんとしたのこぎり波を再生したいのであれば、理想ののこぎり波を周波数制限してやればよい。 のこぎり波はフーリエ級数展開すれば以下のようになることが分かっている。
つまり、基音を1とすると、第2倍音は1/2、第3倍音は1/3…というように、第\(N\)倍音が\(1/N\)の割合で含まれていることになる。 これを愚直にナイキスト周波数\(f_s/2\)まで足せばよい。
HTMLのコードはモジュールスクリプトのファイル名を変更しただけで、あとはまったく同じである。
実行してみるときれいさっぱり、折り返しノイズが消えていることが分かるだろう。 矩形波も三角波も理屈は同じである。 意外と知らないエンジニアは多いだろう。
この方法の欠点は周波数が低くなるとものすごい高次の倍音まで計算しなければならないことである。 例えば、440HzのA音でも第50倍音まで計算しなければならないし、88鍵のピアノの一番低い鍵盤の音はその4オクターブ下、周波数で1/16なので、この16倍の倍音まで計算する必要があり、第800倍音くらいまで計算しなければならない。
さすがにこれを計算するのは大変なので、実際にはどういう用途で使うかによって計算する倍音を制限した方がいい。 音楽的なハーモニーが分かればよいのなら、第16倍音とか第32倍音くらいまで計算すれば十分である。 ソプラノの上のEsに対してバスが下のGをハモらせるなら、ソプラノの第5倍音とバスの第32倍音が同じ周波数になればいいからだ。 第32倍音は基音に対して音量1/32、つまり-30dBになるし、10セントのズレで20Hz近くになるので、実際に耳で聞いてうなりを確認するのはかなり難しいだろう。
三角関数には加法定理があるから、
とすると、加法定理から、
したがって、初項、 \(\cos\delta\) 、 \(\sin\delta\) を求めておけば、あとは次々と一定間隔の三角関数を計算できる。 初項の \(\alpha\) は \(\delta\) の整数倍でなくともよいことに注意。 しかし、 \(\delta\) が小さな数だと \(\cos\delta\) は1に近くなり、浮動小数表現の性質から精度の面で不利である。
ここでちょっと姑息な手段を使って式を変形する。 とりあえず複素指数関数で書き換える。
ここで \(j\) は虚数単位 \(j^2=-1\) である。
となるので、実部・虚部を計算すると、
実虚をそれぞれ比較すれば、
となることが分かる。 三項間漸化式になるが、 \(2\sin\delta\) だけ分かっていれば次々と三角関数を計算できる。 乗算が2回少ないことにも注意。
参考: FFTの概略と設計法 / Cooley-Tukey型FFT / 性能比較とルーチン設計
8ビットの組み込みなどでは大いに役に立つだろう。 JavaScriptの場合、三角関数はネイティブで計算されるのに対し、この数列はスクリプト側での計算になるので、負荷が軽くなるかは正直微妙かもしれない。 HTMLはモジュールファイル名を書き換えただけ。
脳内で実装しただけなので以下は妄想である。
低い方の周波数で演算量を改善したいなら、リサンプリングを応用する方法もある。 アナログ的に理想のこぎり波に理想的なローパスフィルタをかけて、それをサンプリングすればいいわけだ。 しかし、アナログでの理想的なローパスフィルタは無限長の畳み込み積分である。 そしてその積分の結果は先に書いた倍音成分になるので、これでは意味がない。 デジタル的にリサンプルする必要があるが、デジタル的に理想的なのこぎり波を実現できないから困っているわけで…
実は周波数がサンプリング周波数の整数分の1の波形はエイリアシングの影響を受けない。 正確にはエイリアシングの影響は受けるのだが、折り返してきた周波数成分が元々ある周波数成分にピッタリ重なるため、ノイズとして知覚できない。 例えば、440Hzではなく441Hzならばその51倍音は22491Hzとなってナイキスト周波数である22050Hzを超えるが、折り返してきた成分は44100-22491=21609Hzとなって、これは第49倍音に重なってしまう。
そこで、例えば442Hzの音が欲しいなら、441Hz@44.1kHzの波形をゴニョゴニョして442Hz@44.1kHzの波形を作ればよい。 そのためには441Hzの音が442Hzに見えるようにしなければならないが、波形データはそのままにしてサンプリング周波数を変えてしまえばよい。 44.1kHzを442/441倍して、44.2kHzだと思えば、同じ波形データが442Hzに見えるわけだ。 あとはこれにFIRフィルタをかけてサンプル点を変えればいい。
FIRのかけ方にもコツがあって、出力したいサンプル点にsinc関数の原点を持ってきて、入力側(44.2kHz側)のサンプルがある点を対象に畳み込み和を計算する。 整数比の場合には何周期か計算するとsinc関数の原点が元に戻るので、あらかじめフィルタ係数を計算してテーブルにしておくことができる。 しかし、例えば平均律の場合には無理数比が出てくるので、sinc関数の原点は毎回必ず異なる位置になる。 その場でsinc関数を計算して、窓関数も乗じる必要がある。
したがって、この方法が有利なのは、ナイキスト周波数までの倍音計算にかかる時間より、sinc関数と窓関数の計算の時間の方が短く済む場合である。 また、理想的なのこぎり波をサンプリングした波形に比べて、折り返し雑音とフィルタの遷移特性の分だけ誤差が存在することになる。
実はエイリアスの影響のないこぎり波を再生したいだけならばOscillatorNodeを使えばいい。
わざわざ自分でのこぎり波を生成のには理由があって、他の波形に位相を同期させて鳴らしたい。
位相情報を生成する専用のノードを用意して、発振器の方は入力ノードに位相情報を入れると、出力ノードに波形データが出てくる感じである。
位相情報は0から1まで周期的に増加するノコギリ波で、これはエイリアスのことは考えないで素直に生成する。
正弦波を出したければMath.sin(2 * Math.PI * input[0][0][i])をループでぐるぐる回せばいいわけだ。
あとはこれをNode.でつないで再生すればよい。
のこぎり波の場合、倍音上限を計算するためにサンプリング周波数と再生音の周波数が必要である。
サンプリング周波数はコンストラクタで渡すとして、再生音の周波数が問題である。
Phaseノードから取得するのが一番なのだが、そういうサイドチャンネルを設けることができない。
サイドチャンネルが作れないなら、普通にチャンネルを作ってしまえばよいではないか。
ということで、Phaseの出力数を2にしてしまい、ふたつ目の出力に周波数を入れてしまう。
foutがそれだが、オプショナルチェーンで変なことをしている。
foutは代入左辺になるため、nullになることが許されない。
出力数が1だった場合は空の配列を設定してループの中をスッキリさせている。
これを受けるのこぎり波の方はこう。
throwをERR関数でラップしているのはthrowを式として書きたかったから。
式として書ければnull合体演算子のオペランドとして使える。
コンストラクタで設定しているmaxというのは発生させる倍音の最大値である。
freqが0だと倍音合成が無限ループになってしまうのでそれを防ぐ役割もある。
再生用のHTMLはこんな感じ。
フェーズジェネレータの出力チャンネル数が増え、connectがひとつ増えている。
倍音を合成して音色を作りたい時や、純正律のような音律を作りたいときは周波数比が正確に整数比になっていないといけない。 そのためにはフェーズジェネレータの出力を整数倍、あるいは整数で割ればよい。 整数倍する方は簡単である。 普通に掛け算して、整数部を捨てればいい。
問題は割る方である。 例えば周波数を1/5にすると位相の進みが1/5になり、周期が5倍になる。 ところが、入力の位相値は0〜1の繰り返しだから、単純に5で割るだけだと0〜0.2を繰り返すだけで周期が変わらない。 つまり、捨ててしまった整数部を復元しないといけない。 生成周波数がサンプリング周波数を超えなければ、位相値は単調増加なので、位相値が直前の値よりも小さくなった場合は整数部に桁上げがあったということである。 整数部を復元してから割り算を行い、整数部は除数以上になったら0に戻せばよい。
% 1というのが出てくる。
JavaScriptでは浮動小数でも剰余演算子が使える。
1で割ったあまりなのだから、小数点以下の値だけが残る。
x - Math.floor(x)と同じである。
周波数比2:3で二つののこぎり波を鳴らすとドとソでハモった音になる。
ミックスは複数の出力をひとつのノードの入力に何回もconnectすればいいらしい。
周波数を整数比にしたふたつのOcillatorでもいいのだが、浮動小数でピッタリ表現できない値の場合はわずかな誤差がうなりの発生の原因になる可能性がある。
JavaScriptの浮動小数は64ビットで仮数部が53ビットあるので、実際にはほぼ影響がないとは思う。
もうひとつ、必ず位相が同期していることも重要である。 音色は倍音の音量だけで決まり、位相は関係ないことになっているが、周波数比が整数になっているふたつの波形を足す場合は位相が重要になってくる。 例えば、あるのこぎり波に対し、周波数が2倍、ゲインが-1/2倍で、初期位相が同じのこぎり波を加算すると、なんと矩形波になってしまう。 これは時間領域でグラフを書いてみても明らかだし、周波数が2倍、ゲインが1/2倍ののこぎり波の倍音成分は、元ののこぎり波の基音に対して2倍・4倍・6倍…の周波数の成分が-1/2倍・-1/4倍・-1/6倍…で含まれているのだから、足し算したらきれいさっぱり偶数倍音が消えてしまう。 奇数倍音( \(2N+1\) 倍音)が \(1/(2N+1)\) の割合で含まれている波形は矩形波になるのであった。
つまり、音色に位相は関係ないのだが、周波数比が整数の場合はふたつの波形の位相が変わると加算結果の倍音成分の量自体が変わってしまうので、音色に影響出る。
同期していない発振器の場合は開始位相を制御できないので、音を出すごとに音色が変わってしまうことになる。
発振器を同期させていれば常に同じ音色で再生される。
試しにOscillatorで実装するとこうなる。
ひとつ目の音を再生したまま、ふたつ目の音を何度も再生すると、再生するたびに音色が変わるのが分かるだろう。
基本的にAudioとAudioは直接データをやり取りすることができない。
どちらもportというMessageオブジェクトがあり、これを通してやり取りする。
どちら向きにもやり取り可能である。
受け側のport.onmessageかport.addでハンドラを設定して、送り側でport.postを叩けばよい。
addを使う場合はport.startを呼んでおく必要があるので、onmessageを使った方が楽だ。
受け側のイベントハンドラではパラメータにMessage型のオブジェクトが設定され、そのdataプロパティに送り側でpostMessageを引っ叩いた時に指定したデータが設定されている。
基本的なデータならば大体渡せるが、Arrayのようなものはpostを叩くときに第2引数のtransferパラメータに一覧を配列にして指定してやる必要がある。
指定したデータは所有権が受け側に渡り、送り側からは読めなくなる。
例によって結果はコンソールに出る。
pingボタンを押すとpongメッセージが返ってくる。
ちなみに、SafariではAudio側のログが出ない。
先ほどはexponentialを使った。
他にこれの直線版、指数減数関数、自由折線などがある。
指定の時間に値を設定する関数もある。
とりあえずイベント機能を使って指定間隔で実際の値を取ってくるだけのAudioを作る。
色々試したいので、共通部分を.jsファイルに分けておく。
Audioが報告してきた値をキャンバスに書いている。
draw関数が受け取っているeventは配列で、要素はさらに2要素の配列になっている。
最初の要素はms単位の時刻で、次の要素はその時刻に実行するラムダ式である。
ラムダ式が受け取っているparam引数はAudioオブジェクトである。
このオブジェクトの値がキャンバスに書き出される。
音を鳴らす必要がないので、Offlineを使っている。
これは波形の生成などに便利なクラスで、startを呼び出すと一気にデータの生成が行われる。
同期を取りたい時はsuspendとresumeを使うが、普通のAudioとは扱いが違う。
suspendには時刻の指定が必要で、プロミスを返す。
指定の時刻までレンダリングが進むとレンダリングを一時停止して、プロミスが解決する。
何か作業をしたらresumueを呼び出す、という仕掛けである。
resumeして、自分自身を呼び出して、またsuspendという器用な使い方をしているが、自分自身を呼び出しているのはthenの中なので連続実行されたりはしない。
これでちゃんと動く。
まぁasync使った方が楽なのかもしんないけど。
最後に入っている余計なsetは一度UIスレッドを回すためで、こうしないと前の描画が完了する前にグラフィックコンテキストの設定が書き変わってしまう(キメラな図形ができてしまう)ので入れてある。
0.5秒の位置で値を0.5に設定、3.5秒の位置で値が1になるように直線的に増加させ、さらに4.5秒の位置で0になるように直線的に減少させてみた。 これらの設定はすべて時刻0で行なっている。 つまり、変化を予約できるわけだ。
これを、1.5秒経過した時に「3秒の位置にかけて0にしよう」と思って処理を加えるとこうなる。
おやおやぁ? なんか想像と違う動きをしている。 すでに予約した変化の前に割り込むように変化を追加すると、元からその変化があったかのように現在値が計算し直されるらしい。 この場合、元は0.5秒から3.5秒にかけて0.5から1になるはずだったものが、1.5秒の時点で3秒までの変化が割り込んできている。 割り込んだ変化の初期値は割り込んだ時点での値ではなく、割り込まれた変化の初期値と同じになる。 したがって、0.5秒の位置で0.5、3秒の位置で0という直線で計算されることになり、1.5秒の位置で値が飛ぶ。 割り込まれた方の初期値はは割り込んだ変化の終了値になり、変化の開始も割り込んできた変化の変化終了位置になる。
この場合は一度将来のイベントをキャンセルする必要がある。
キャンセルにしようする関数にはcancelとcancelのふたつがある。
どちらも指定時刻以降のイベントをキャンセルするが、cancelはこの関数を実行した時点もしくはそれ以前のイベントで確定した値を保持し、新たにイベントを追加すると、キャンセルされずに残っているイベントを使って値を計算する。
何言ってんだお前、と言われそうだが、ChromeとSafariで動作が違う(ぉぃ)。
一方、cancelは指定された時刻の値を保持し、新たにイベントを追加すると、この値を使って値を計算する。
1秒の時点で1.5秒以降のイベントをキャンセルしている。
赤線(下側を通る線)はcancelによるもので、Chromeでは関数を実行した1秒の時点の値を保持している。
Safariではその前のイベントである、0.5秒で指定した値0.5を保持している。
これはSafariの方が正しい解釈な気がする。
青線(上側を通る線)はcancelによるもので、関数の引数で指定した1.5秒の値を保持している。
これはChromeもSafariも同じ動きをする。
その後、2秒の時点で3秒の位置で0になるようなスロープを指定している。
cancelは最初に設定した0.5秒・値0.5が始点になっている。
これに対してcancelはキャンセル時に指定した時刻と値が始点になっている。
どちらかといえば後者の方が使いやすいが、どちらも新たにイベントを追加した時刻が始点になっていない点には注意。
とにかく、微妙に使いにくい。
値の変化には指数関数のカーブもある。
exponential
と
set
である。
前者はいわゆるデシベル直線の変化をする。
linear
の指数関数版なので変化終了点を指定するが、指数関数なので終了値を0にはできない。
デシベルでの0倍は
\(-\infty \mathrm{dB}\)
なのと同じ理由である。
後者はexponentialという名前が入っていないが、いわゆる指数減衰カーブである。 こちらは変化の開始時刻と目標値、時定数を指定する。 目標値に制限はないが、目標値に近づくようなカーブになるため、永遠に目標値にはならないことに注意。 時定数は目標値までの誤差が \(1/e\) になる時間で、値が小さいほど変化が急になる。
CRフィルタのような波形だ。 というかそのものだ。
2秒の時点で4秒までの直線補間を加えるとこうなる。
2秒の時点の値から、4秒まで直線変化する。
これを1秒より前の時点で実行するとこうなる。
指数減衰部分がなくなってしまった。 指数減衰部分は開始の時刻を指定していて、直線補間部分は終了の時刻を指定している。 直線補間部分の開始時刻はというと、その前に指定されたなんらかの時刻のうち一番近いものになるので、指数減衰が始まる前に直線補間をスケジュールしてしまうと、指数減衰の開始時刻が直線補間の開始時刻として使われ、指数減衰は開始時刻=終了時刻の状態になってしまう。 つまり、指数減衰部分が全くなくなってしまう。
2秒まで指数減衰、2秒から直線補間と開始時点で指定したいのなら、2秒の時点にもうひとつ余計に指数減衰を入れればいい。 2秒の時点の値から指数減衰しようとするが、その後に直線補間があってすぐ終わってしまう。 しかし、この時刻が直線補間の開始時刻になり、値もその時点での値になるわけだ。 時刻しか使わないので、値と時定数はなんでもいい。 このように指数減衰は新たに基準点を作りたい時にも使える。
24 Feb 2025: 新規作成
ご意見・ご要望の送り先は あかもず仮店舗 の末尾をご覧ください。
Copyright (C) 2025 akamoz.jp