このサイトに関して
このサイト (ドキュメント) の目的は, Web Music, その中核となる Web Audio API について解説しますが, W3C
が公開している仕様のすべてを解説するわけではありません. また, JavaScript の言語仕様の解説は, サイトの目的ではないこともご了承ください (ただし, Web
Audio API を使う上で, 必要となってくるクライアントサイド JavaScript API に関しては必要に応じて解説をします (例. File API
,
Fetch API
など).
このサイトは W3C が公開している仕様にとって代わるものではなく, Web Audio API の仕様の理解を補助するリファレンスサイトと位置づけてください.
デスクトップブラウザでは少なくなりましたが, モバイルブラウザでは仕様とブラウザの実装に差異があり,
仕様では定義されているのに動作しないということもあります. その場合には, 開発者ツールなどを活用して,
実装されているプロパティやメソッドを確認してみてください.
解説の JavaScript コードに関して
ECMAScript 2015 以降の仕様に準拠したコードで記載します. また, ビルドツールなどを必要としないように, TypeScript
での記述やモジュール分割などもしません (端的には, コピペすればブラウザコンソールなどで実行できるようなサンプルコード, あるいは,
コード片を記載します). 具体的には, 以下のような構文を使います.
const
, let
による変数宣言
Template Strings
アロー関数
クラス
Promise
, または, async
/await
Web Audio API のコードも仕様で推奨されているコードを基本的に記載します (例えば, AudioNode
インスタンスを生成する場合,
コンストラクタ形式が推奨されているので, そちらを使います). ただし, 現時点であまりにも実装の乖離が大きい場合は, フォールバック的な解説として,
実装として動作するコードを記載します.
前提知識と経験
前提知識としては, ECMAScript 2015 以降の JavaScript の言語仕様を理解していることと, Web ブラウザを実行環境にした JavaScript による Web
アプリケーションを実装した経験ぐらいです. Web Audio API は, ユースケースにおいて想定されるオーディオ信号処理を抽象化しているので,
オーディオ信号処理に対する理解がなくても, それなりのアプリケーションは制作できます (アプリケーションの仕様しだいでは不要になるぐらいです). もちろん,
オーディオ信号処理の理解や Web 以外のプラットフォームでのオーディオプログラミングの経験 (特に, GUI
で必要なリアルタイム性のオーディオプログラミングの経験) があれば, それは Web Audio API を理解するうえで活きますし, Web Audio API
が標準でサポートしないようなオーディオ処理を実現したいケースではむしろ必要になります.
また, 音楽理論に対する知識も不要です. Web Audio API はユースケースとして, 音楽用途に限定していないからです. したがって, このサイトでは,
アプリケーションによっては必要になるドメイン知識として位置づけます (もちろん, ユースケースとして, 音楽用途も想定されているので, Web
をプラットフォームにした音楽アプリケーションを制作する場合には必要となるケースが多いでしょう).
このサイトでは, オーディオ信号処理や音楽理論など必要に応じて解説します. Web Audio API が解説の中心ではありますが, Web Music
アプリケーションを制作するための標準ドキュメントとなることを目指すからです (オーディオ信号処理や音楽理論を深入りする場合は,
それぞれ最適なドキュメントや書籍がたくさんあるのでそちらを参考にしてください).
Web Audio API に対する懐疑的な意見
Web Audio API は他のプラットフォームのオーディオ API と比較すると, やや奇怪な API 設計であったり, 仕様策定されたころの JavaScript の事情と, 現代の
JavaScript の事情が様変わりしたことから, 懐疑的な意見もあります (参考
WebAudioは何故あんな事になっているのか ).
しかしながら, この記事でも述べられているように,
実はWebAudioはオーディオAPIのオープンスタンダードとしては唯一生き残っている存在と言える。
これはたしかで, その点において学ぶ意義はありますし, 音楽アプリケーションとして Web をプラットフォームにする必要がある場合は必須となるでしょう.
Issue と Pull Requests
プロローグの最後に, このサイト (ドキュメント) はオープンソースとして
GitHub に公開しています.
このサイトのオーナーも完璧に理解しているわけではないので, 間違いもあるかと思います. その場合には, GitHub に
issue を作成したり,
Pull Requests
を送っていただいたりすると大変ありがたいです.
それでは, Web Music の未来を一緒に開拓していきましょう !
Getting Started
AudioContext
Web Audio API を使うためには, AudioContext
クラスのコンストラクタを呼び出して,
AudioContext
インスタンスを生成する必要があります. AudioContext
インスタンスが Web Audio API
で可能なオーディオ処理の起点になるからです. AudioContext
インスタンスを生成することで, Web Audio API
が定義するプロパティやメソッドにアクセス可能になるわけです.
const context = new AudioContext();
何らかの理由で, レガシーブラウザ (特に, モバイルブラウザ) もサポートしなければならない場合, ベンダープレフィックスつきの
webkitAudioContext
もフォールバックとして設定しておくとよいでしょう (少なくとも, デスクトップブラウザでは不要な処理で,
これから将来においては確実に不要になる処理ではありますが).
window.AudioContext = window.AudioContext || window.webkitAudioContext;
const context = new AudioContext();
AudioContext
インスタンスをコンソールにダンプしてみます.
const context = new AudioContext();
console.dir(context);
AudioContext
インスタンスに様々なプロパティやメソッドが実装されていることがわかるかと思います. このドキュメントではこれらを
(すべてではありませんが) メインに解説していくことになります. また, このように実装を把握することで, 仕様と実装の乖離を調査することにも役立ちます.
Web Audio API でオーディオ処理を実装するうえで意識することはほとんどありませんが, AudioContext
は BaseAudioContext
を拡張
(継承) したクラスであることもわかります.
Autoplay Policy 対策
Web Audio API に限ったことではないですが, ページが開いたときに, ユーザーが意図しない音を聞かせるのはよくないという観点から (つまり, UX
上好ましくないという観点から), ブラウザでオーディオを再生する場合,
Autoplay Policy
という制限がかかります. これを解除するためには, ユーザーインタラクティブなイベント 発火後に
AudioContext
インスタンスを生成するか, もしくは, AudioContext
インスタンスの resume
メソッドを実行して
AudioContextState
を 'running'
に変更する必要があります. これをしないと, オーディオを鳴らすことができません.
また, decodeAudioData
など一部のメソッドが Autoplay Policy 解除まで実行されなくなります. ユーザーインタラクティブなイベントとは,
click
, mousedown
や touchstart
などユーザーが明示的に操作することによって発火するイベントのことです.
したがって, load
イベントや mousemove
など, 多くのケースにおいてユーザが明示的に操作するわけではないようなイベントでは
Autoplay Policy の制限を解除することはできません.
document.addEventListener('click', () => {
const context = new AudioContext();
});
resume
メソッドで解除する場合 (この場合, コンソールには警告メッセージが表示されますが, Autoplay Policy
は解除できるので無視して問題ありません).
const context = new AudioContext();
document.addEventListener('click', async () => {
await context.resume();
});
これ以降のセクションでは, 本質的なコードを表記したいので, Autoplay Policy は解除されている状態を前提とします.
AudioNode
Web Audio API におけるオーディオ処理の基本は, AudioNode
クラスのインスタンス生成と AudioNode
がもつ
connect
メソッドで AudioNode
インスタンスを接続していくことです. AudioNode
クラスは,
それ自身のインスタンスを生成することはできず, AudioNode
を拡張 (継承) したサブクラスのインスタンスを生成して, オーディオ処理に使います.
AudioNode
はその役割を大きく 3 つに分類することができます.
サウンドの入力点となる AudioNode
のサブクラス (OscillatorNode
, AudioBufferSourceNode
など)
サウンドの出力点となる AudioNode
のサブクラス (AudioDestinationNode
)
音響特徴量を変化させる AudioNode
のサブクラス (GainNode
, DelayNode
, BiquadFilterNode
など)
現実世界のオーディオ機器に例えると, サウンドの入力点に相当する AudioNode
のサブクラスが, マイクロフォンや楽器, 楽曲データなどに相当,
サウンドの出力点に相当する AudioNode
のサブクラスが. スピーカーやイヤホンなどに相当, そして, 音響特徴量を変化させる
AudioNode
のサブクラスがエフェクターやボイスチェンジャーなどが相当します.
これらの, AudioNode
のサブクラスを使うためには, コンストラクタ呼び出し , または,
AudioContext
インスタンスに実装されているファクトリメソッド 呼び出す必要があります (ただし, サウンドの出力点となる
AudioDestinationNode
は AudioContext
インスタンスの destination
プロパティでインスタンスとして使えるので,
コンストラクタ呼び出しやファクトリメソッドは定義されていません).
例えば, 入力として, オシレーター (OscillatorNode
) を使う場合, コンストラクタ呼び出しの実装だと以下のようになります.
const context = new AudioContext();
const oscillator = new OscillatorNode(context);
インスタンス生成時には, その AudioNode
のサブクラスに定義されているパラメータ (OscillatorNode
の場合,
OscillatorOptions
) を指定することも可能です.
const context = new AudioContext();
const oscillator = new OscillatorNode(context, { type: 'sawtooth', frequency: 880 });
ファクトリメソッドでインスタンス生成する場合, 以下のようになります.
const context = new AudioContext();
const oscillator = context.createOscillator();
コンストラクタ呼び出しによる, AudioNode
のサブクラスのインスタンス生成は, Web Audio API の初期には仕様策定されておらず,
AudioContext
インスタンスに実装されているファクトリメソッド呼び出す実装のみでした. インスタンス生成時に,
パラメータを変更可能なことから, どちらかと言えば, コンストラクタ呼び出しによるインスタンス生成が推奨されているぐらいですが,
ファクトリメソッドが将来非推奨になることはなく, また, 初期の仕様には仕様策定されていなかったことから,
レガシーブラウザの場合, コンストラクタ呼び出しが実装されていない場合もあります . したがって, サポートするブラウザが多い場合は,
ファクトリメソッドを, サポートするブラウザが限定的であれば, コンストラクタ呼び出しを使うのが現実解と言えるでしょう.
connect メソッド (AudioNode の接続)
現実世界の音響機器では, 入力と出力, あるいは, 音響変化も接続することで, その機能を果たします. 例えば, エレキギターであれば,
サウンド入力を担うギターとサウンド出力を担うアンプ (厳密にはスピーカー) は, 単体ではその機能を果たしません.
シールド線などで接続することによって機能します.
このことは, Web Audio API の世界も同じです. (AudioContext
インスタンスを生成して,) サウンド入力点となる
AudioNode
のサブクラスのインスタンス (先ほどのコード例だと, OscillatorNode
インスタンス) と, サウンド出力点となる
AudioDestinationNode
インスタンスを生成しただけではその機能を果たしません. 少なくとも,
サウンド入力点と出力点を接続する処理が必要となります (さらに, Web Audio API が定義する様々なノードと接続することで, 高度なオーディオ処理を実現する
API として真価を発揮します).
Web Audio API のアーキテクチャは, 現実世界における音響機器のアーキテクチャと似ています. このことは, Web Audio API
の理解を進めていくとなんとなく実感できるようになると思います.
Web Audio APIにおいて「接続」の役割を担うのが, AudioNode
がもつ connect
メソッド です. 実装としては,
AudioNode
サブクラスのインスタンスの, connect
メソッドを呼び出します. このメソッドの第 1 引数には, 接続先となる
AudioNode
のサブクラスのインスタンスを指定します.
const context = new AudioContext();
const oscillator = new OscillatorNode(context);
// OscillatorNode (Input) -> AudioDestinationNode (Output)
oscillator.connect(context.destination);
サウンドの入力点と出力点を接続し, 最小の構成を実装できました. しかし, まだ音は出せません. なぜなら,
サウンドを開始するための音源スイッチをオンにしていないからです. 現実世界の音響機器も同じです. 現実世界がそうであるように, Web Audio API
においても, 音源のスイッチをオン, オフする必要があります. そのためには, OscillatorNode
クラスがもつ
start
メソッド , stop
メソッド を呼び出します.
const context = new AudioContext();
const oscillator = new OscillatorNode(context);
// OscillatorNode (Input) -> AudioDestinationNode (Output)
oscillator.connect(context.destination);
// Start immediately
oscillator.start(0);
// Stop after 2.5 sec
oscillator.stop(context.currentTime + 2.5);
start
メソッドの引数に 0
を指定していますが, これはメソッドが呼ばれたら, 即時にサウンドを開始します.
stop
メソッドの引数には, AudioContext
インスタンスの currentTime
プロパティに
2.5
を加算した値を指定していますが, これは, stop
メソッドを実行してから, 2.5
秒後に停止することをスケジューリングしています (詳細は, のちほどのセクションで Web Audio API におけるスケジューリングとして解説しますが,
AudioContext
インスタンスの currentTime
は,
AudioContext
インスタンスが生成されてからの経過時間を秒単位で計測した値 が格納されています). stop
メソッドの引数も
0
を指定すれば即時にサウンドを停止します. ちなみに, start
メソッド, stop
メソッドもデフォルト値は
0
なので, 引数を省略して呼び出した場合, 即時にサウンドを開始, 停止します.
これで, とりあえず, ブラウザ (Web) で音を鳴らすことができました !
AudioParam
サウンドの入力点と出力点を生成して, それらを接続するだけでは, 元の入力音をそのまま出力するだけなので高度なオーディオ処理はできません. むしろ, Web
Audio API において重要なのは, この入力と出力の間に, 音響変化をさせる AudioNode
を接続することです. 音響変化をさせるためには,
音響変化のためのパラメータを取得・設定したり, 周期的に変化させたり (LFO) できる必要があります. Web Audio API において, その役割を担うのが
AudioParam
クラスです. AudioNode
が現実世界の音響機器と例えをしましたが, それに従うと,
AudioParam
クラスはノブやスライダーなど音響機器のパラメータを設定するコントローラーのようなものです.
AudioParam
クラスは直接インスタンス化することはありません. AudioNode
のプロパティとして,
AudioNode
のサブクラスのインスタンスを生成した時点でインスタンス化されているのでプロパティアクセスで参照することが可能です.
AudioParam
では, 単純なパラメータの取得や設定だけでなく, そのパラメータを周期的に変化させたり (LFO), スケジューリングによって変化させる
(エンベロープジェネレーターなど) ことが可能です (ここはオーナーの経験からですが, Web Audio API で高度なオーディオ処理を実装するためには,
AudioParam
を理解して音響パラメータを制御できるようになるかが非常に重要になっていると思います).
GainNode
AudioParam
の詳細は, のちほどのセクションで解説しますので, このセクションでは, 最初のステップとして,
GainNode
を使って, パラメータの取得・設定を実装します. GainNode
はその命名のとおり,
ゲイン (増幅率 ), つまり, 入力に対する出力の比率 (入力を 1
としたときに出力の値) を制御するための
AudioNode
で, Web Audio API におけるオーディオ処理で頻繁に使うことになります. このセクションでは, 単純に, GainNode
の
gain
プロパティ (AudioParam
インスタンス) を参照して, そのパラメータを取得・設定してみます (このセクションでは,
音量の制御と考えても問題ありません).
GainNode
も AudioNode
のサブクラスなので, コンストラクタ呼び出し, または, ファクトリメソッドで
GainNode
インスタンスを生成できます.
const context = new AudioContext();
const gain = new GainNode(context);
コンストラクタ呼び出しで生成する場合, 初期パラメータ (GainOptions
) を指定することも可能です.
const context = new AudioContext();
const gain = new GainNode(context, { gain: 0.5 });
ファクトリメソッドで生成する場合.
const context = new AudioContext();
const gain = context.createGain();
GainNode
インスタンスを生成したら, OscillatorNode
と AudioDestinationNode
の間に接続します.
const context = new AudioContext();
const oscillator = new OscillatorNode(context);
const gain = new GainNode(context, { gain: 0.5 });
// OscillatorNode (Input) -> GainNode -> AudioDestinationNode (Output)
oscillator.connect(gain);
gain.connect(context.destination);
// Start immediately
oscillator.start(0);
// Stop after 2.5 sec
oscillator.stop(context.currentTime + 2.5);
これで実際にサウンドを発生させると, 音の大きさが小さく聴こえるはずです.
このコードだと, 初期値を変更しているだけなので, 例えば, ユーザー操作によって変更するといったことができないので,
インスタンス生成時以外でパラメータを設定したり, 取得したりする場合は, GainNode
の gain
プロパティを参照します. これは,
先ほども記載したように, AudioParam
インスタンスです. パラメータの取得や設定をするには, その
value
プロパティにアクセスします.
簡単な UI として, 以下の HTML があるとします.
<label for="range-gain">gain</label>
<input type="range" id="range-gain" value="1" min="0" max="1" step="0.05" />
<span id="print-gain-value">1</span>
この input[type="range"]
のイベントリスナーで, input[type="range"]
で入力された値 (JavaScript の
number
型) を gain
(AudioParam
インスタンス) の value
プロパティに設定し, また,
その値を取得して, HTML に動的に表示します.
const context = new AudioContext();
const oscillator = new OscillatorNode(context);
const gain = new GainNode(context);
// OscillatorNode (Input) -> GainNode -> AudioDestinationNode (Output)
oscillator.connect(gain);
gain.connect(context.destination);
// Start immediately
oscillator.start(0);
const spanElement = document.getElementById('print-gain-value');
document.getElementById('range-gain').addEventListener('input', (event) => {
gain.value = event.currentTarget.valueAsNumber;
spanElement.textContent = gain.value;
});
AudioParam
のパラメータの取得や設定は, このように, JavaScript のオブジェクトに対するプロパティの getter や setter
と同じなので特に違和感なく理解できるのではないでしょうか.
このセクションでは, Web Audio API の設計の基本となる ((Web Audio API のアーキテクチャを決定づけている), AudioContext
,
AudioNode
, AudioParam
の関係性とそのパラメータの取得・設定の実装のを解説しました. 以降のセクションでは,
ユースケースに応じて, これら 3 つのクラスの詳細についても解説を追加していきます.
OscillatorNode
Web Audio API のアーキテクチャを解説するうえで, OscillatorNode
は少し説明しましたが, このセクションでは, Web Audio API
におけるサウンド生成・合成のベースとなる, OscillatorNode
についてその詳細を解説します.
シンセサイザーの基本波形の生成・合成, モジュレーション系エフェクターで必須となる LFO (Low-Frequency Oscillator) など, Web Audio API
において用途の広い, コアとなる AudioNode
です. LFO に関しては, エフェクターのセクションで解説するので,
このセクションでは基本波形の生成・合成に関して解説します.
type プロパティ (OscillatorOptions)
ただし, 'custom'
のみは特殊で, 直接値を設定するとエラーが発生します. これは, OscillatorNode
の
setPeriodicWave
メソッドによって, 自動的に 'custom'
に設定されます. また, その引数として,
AudioContext
の createPeriodicWave
メソッドで波形テーブルを生成する必要があります. 波形テーブルの生成は,
スペクトルや倍音などオーディオ信号処理の知識が必要になるので, 別のセクションで解説します.
frequency プロパティ (AudioParam) / detune プロパティ (AudioParam)
周波数を制御して音の高さを変更します. frequency プロパティ
と detune
プロパティを合わせて算出される周波数 ($f_{\mathrm{computed}}$ ) は, 仕様では以下のように決定されます.
$f_{\mathrm{computed}} = \mathrm{frequency} \cdot \mathrm{pow}\left(2, \left(\mathrm{detune} / 1200 \right)\right)$
この数式は, frequency
は物理的な視点 (Hz) で周波数を制御, detune
は音楽的な視点 (cent)
で周波数を制御することを意味しています.
start メソッド / stop メソッド
OscillatorNode
のプロパティを設定して音の高さや音色を制御することはそれほど難しくないかと思います. また, 発音し続けるか, 1 度だけ発音
(start
メソッド)・停止 (stop
メソッド) する場合も直感的に実装可能です. おそらく, 多くの場合, ハマってしまうのが,
OscillatorNode
の発音と停止を繰り返す場合です.
OscillatorNode
インスタンスは, 言わば使い捨てなので, 一度発音・停止した OscillatorNode
インスタンスは再度, 発音 (停止)
することはできません. 例えば, ユーザーインタラクティブな操作で発音・停止を繰り返すような場合, OscillatorNode
インスタンスを再生成して,
再度 AudioDestinationNode
に接続して, start
メソッド (stop
メソッド) を実行する必要があります.
例えば, 以下のコードはボタンをクリックするたびに, 発音・停止することを期待していますが, 2 回目のクリック以降は, 発音されずエラーが発生します.
<button type="button">start</button>
const context = new AudioContext();
const oscillator = new OscillatorNode(context);
// OscillatorNode (Input) -> AudioDestinationNode (Output)
oscillator.connect(context.destination);
const buttonElement = document.querySelector('button[type="button"]');
buttonElement.addEventListener('mousedown', (event) => {
// Start immediately
// But, cannot start since the second times ...
oscillator.start(0);
buttonElement.textContent = 'stop';
});
buttonElement.addEventListener('mouseup', (event) => {
// Stop immediately
oscillator.stop(0);
buttonElement.textContent = 'start';
});
期待する動作, つまり, 発音・停止を繰り返すするには, 一度 start
・stop
した
OscillatorNode
インスタンスは破棄して, 再度 OscillatorNode
インスタンスを生成します.
const context = new AudioContext();
let oscillator = null;
const buttonElement = document.querySelector('button[type="button"]');
buttonElement.addEventListener('mousedown', (event) => {
if (oscillator !== null) {
return;
}
oscillator = new OscillatorNode(context);
// OscillatorNode (Input) -> AudioDestinationNode (Output)
oscillator.connect(context.destination);
// Start immediately
oscillator.start(0);
buttonElement.textContent = 'stop';
});
buttonElement.addEventListener('mouseup', (event) => {
if (oscillator === null) {
return;
}
// Stop immediately
oscillator.stop(0);
// GC (Garbage Collection)
oscillator = null;
buttonElement.textContent = 'start';
});
このような仕様なので, start
メソッドを続けて呼んだり, stop
メソッドを続けて呼んだりしても, エラーが発生します.
start
メソッドと stop
メソッドは一対 という仕様は, さまざまなプラットフォームのオーディオ API のなかでも Web Audio
API 独自の仕様で, ハマりやすい仕様なので注意してください (そもそも, Web ではないプラットフォームのオーディオ API はここまで抽象化されている API
すら少ないと思います).
基本波形の合成
基本波形の合成, すなわち, Web Audio API における OscillatorNode
の合成は直感的で, 必要なだけ
OscillatorNode
インスタンスを生成して, (最後の) 接続先として AudioDestinationNode
を指定するだけです.
ただし, そのまま合成 (接続) してしまうと, 振幅が大きくなりすぎて, 音割れが発生してしまうので, GainNode
を接続して振幅を調整しています
(逆に, この音割れ (クリッピング) をエフェクトとして使うのが歪み系エフェクターです). もしくは,
DynamicsCompressorNode
を接続して振幅を制御して, 意図しない音割れを防ぐこともできます (ただし, 厳密には,
コンプレッサーは振幅の小さい音も相対的に大きくするので, 物理的にはまったく同じではありません).
const context = new AudioContext();
// C major chord
let oscillatorC = null;
let oscillatorE = null;
let oscillatorG = null;
const buttonElement = document.querySelector('button[type="button"]');
buttonElement.addEventListener('mousedown', (event) => {
if ((oscillatorC !== null) || (oscillatorE !== null) || (oscillatorG !== null)) {
return;
}
oscillatorC = new OscillatorNode(context, { frequency: 261.6255653005991 });
oscillatorE = new OscillatorNode(context, { frequency: 329.6275569128705 });
oscillatorG = new OscillatorNode(context, { frequency: 391.9954359817500 });
const gain = new GainNode(context, { gain: 0.25 });
// OscillatorNode (Input) -> GainNode -> AudioDestinationNode (Output)
oscillatorC.connect(gain);
oscillatorE.connect(gain);
oscillatorG.connect(gain);
gain.connect(context.destination);
// Start immediately
oscillatorC.start(0);
oscillatorE.start(0);
oscillatorG.start(0);
buttonElement.textContent = 'stop';
});
buttonElement.addEventListener('mouseup', (event) => {
if ((oscillatorC === null) || (oscillatorE === null) || (oscillatorG === null)) {
return;
}
// Stop immediately
oscillatorC.stop(0);
oscillatorE.stop(0);
oscillatorG.stop(0);
// GC (Garbage Collection)
oscillatorC = null;
oscillatorE = null;
oscillatorG = null;
buttonElement.textContent = 'start';
});
AudioBufferSourceNode
AudioBufferSourceNode
は, ワンショットオーディオの再生 を目的に利用します. ワンショットオーディオとは,
ピアノやギターなど実際の楽器の音源を収録した WAVE ファイルや MP3 ファイルのことです. Web Audio API の仕様では, ユースケースとして,
楽曲データに関しては, MediaElementAudioSourceNode
を利用することを想定しているので, この点は注意が必要です. ただし,
AudioBufferSourceNode
を楽曲データの再生に使うこともできます. 現実解としてユースケースに反した利用をすることも多いです (これは,
AudioBufferSourceNode
がオーディオデータの実体である AudioBuffer
インスタンスをもつので,
オーディオ信号処理が適用しやすいことが理由として考えられます).
このセクションでは, 仕様上のユースケースであるワンショットオーディオの再生を目的に, AudioBufferSourceNode
を解説します.
ところで, ワンショットオーディオの再生であれば, 同じことは HTMLAudioElement
(audio
タグ) でも可能な場合もあります. 事実, Web
Audio API が仕様策定される以前は, そのようなユースケースも想定して, Audio
コンストラクタが定義されています. しかしながら,
HTMLAudioElement
(Audio
コンストラクタ) によるワンショットオーディオの再生は以下のような問題があります.
JavaScript のタイマー (setInterval
や setTimeout
) では, 正確なスケジュールングが難しい
HTMLAudioElement
のイベントハンドラでも精度が粗く, 正確なスケジュールングが難しい
同時発音数の制限
ワンショットオーディオに対して, さらにオーディオ処理を付加したいユースケース
これらの問題を, ある程度容易に解決してくれるのが AudioBufferSourceNode
です (もっとも, AudioBufferSourceNode
を利用しても,
コンピュータのリソースは有限なので, 計算量が多い場合や他のプロセスがリソースを多く消費している場合などは,
少なからずスケジューリングも正確でなくなります).
buffer プロパティ
AudioBufferSourceNode
において, 最も重要と言えるのが, buffer
プロパティであり, これは,
AudioBuffer
インスタンスを参照します. AudioBuffer
とは, オーディオデータの実体 (を抽象化するクラス) です.
AudioBuffer
AudioBuffer
クラスは, オーディオデータの実体ですが, 直接的にアクセスすることはできません. そのためのメソッドや,
デジタル化されたオーディオデータに必要なパラメータ (サンプリングレートやチャンネル数, オーディオデータ全体のサイズなど) を定義しています.
sampleRate プロパティ
サンプリング周波数です. これは, AudioContext
インスタンスのsampleRate
プロパティと同じ値です. つまり,
注意しておきたいのは, オーディオデータのサンプリグ周波数ではなということです (ちなみに,
なぜこのような仕様なのかこのサイトのオーナーも理解できていません. 直感的にはオーディオデータのサンプリング周波数に思いますが).
length プロパティ
1 チャネルにおける, オーディオデータのサイズです. つまり, sampleRate
プロパティの逆数であるサンプリング周期
と
length
プロパティを乗算した値が, オーディオデータの再生時間となります (次に解説する,
duration
プロパティの値と同じになります).
$\mathrm{duration} = \frac{\mathrm{length}}{\mathrm{sampleRate}}$
duration プロパティ
オーディオデータの再生時間 (単位は sec
) です. 先ほど解説したように, sampleRate
プロパティと
length
プロパティと関連している値となります.
numberOfChannels プロパティ
オーディオデータのチャンネル数です. 例えば, モノラルであれば 1
, ステレオであれば 2
, 5.1 チャンネルであれば
6
になります. 次に解説する, getChannelData
メソッドの引数の上限を決めている値になっています.
getChannelData メソッド
getChannelData
メソッドで引数で指定したチャンネルのオーディオデータを Float32Array
として取得することが可能です.
引数となるチャンネルの指定は 0
から numberOfChannels - 1
までです. 例えば, ステレオ (numberOfChannels
が
2
)であれば, getChannelData(0)
で左チャンネルのオーディオをデータを Float32Array
で取得し,
getChannelData(1)
で右チャンネルのオーディオデータをFloat32Array
で取得することができます.
copyFromChannel メソッド / copyToChannel メソッド
他に, AudioBuffer
をコピーするためのメソッドがあります. ワンショットオーディオの再生においてはおそらく使うことはないので,
必要であれば, 仕様や MDN などを参考にしてください.
AudioBuffer の生成
AudioBuffer
クラスに関して簡単に解説しましたが, 肝心なのは
AudioBuffer
インスタンスをどうやって生成するのかということだと思います. Web Audio API では,
decodeAudioData
メソッドを利用するか, createBuffer
メソッドを利用することによって,
AudioBuffer
インスタンスを生成可能です.
もっとも, ワンショットオーディオ再生目的であれば, createBuffer
メソッドを利用することはおそらくなく,
ArrayBuffer
インスタンスから AudioBuffer
インスタンスを生成する
decodeAudioData
メソッドを利用することになると思います. したがって, まずは,
ArrayBuffer
インスタンスの取得に関して解説します (これは Web Audio API の解説というよりは, JavaScript で
ArrayBuffer
インスタンスを取得する方法なので, すでにご存知の場合はスキップして問題ないです).
ArrayBuffer の取得と decodeAudioData メソッド
クライアントサイド JavaScript で ArrayBuffer
を取得するには, Web にあるリソースであれば, Fetch API
(もしくは,
XMLHttpRequest
), ユーザーのファイルシステムから選択するのであれば File API
と
FileReader API
を使うことになります.
ワンショットオーディオ再生の場合, アプリケーション側であらかじめオーディオデータを Web にアップロードしているケースがほとんどなので,
このセクションでは, Fetch API
で ArrayBuffer
を取得する実装を解説します.
Fetch API
は, fetch
関数, Headers
オブジェクト, Request
オブジェクト,
Response
オブジェクトの総称ですが, ほとんどのケースで明示的に利用するのは, fetch
関数の呼び出しです.
fetch('./assets/one-shots/piano-C.mp3')
.then((response) => {
return response.arrayBuffer();
})
.then((arrayBuffer) => {
// TODO: Create instance of `ArrayBuffer` by calling `decodeAudioData`
})
.catch((error) => {
// error handling
});
fetch
関数のデフォルトの HTTP メソッドは GET なので, ワンショットオーディオの取得であれば, そのリソースの URL
を指定すればよいでしょう. あとは, 取得した Response
オブジェクトの arrayBuffer
メソッドを呼び出して,
ArrayBuffer
インスタンスを取得するだけです. いずれの関数・メソッドも, Promise
を返します. 可読性重視などであれば,
async
/await
で実装してもよいでしょう.
ArrayBuffer
インスタンスが取得できたら, AudioContext
インスタンスの decodeAudioData
メソッドの第 1
引数に, ArrayBuffer
インスタンスを指定して, 第 2 引数に, 成功時のコールバック関数を指定します. このコールバック関数の引数に,
AudioBuffer
インスタンスが渡されます. 失敗した場合, 第 3 引数のコールバック関数が実行されます. このコールバック関数の引数には,
DOMException
インスタンスが渡されます.
const context = new AudioContext();
fetch('./assets/one-shots/piano-C.mp3')
.then((response) => {
return response.arrayBuffer();
})
.then((arrayBuffer) => {
const successCallback = (audioBuffer) => {
// Create instance of `AudioBufferSourceNode`
};
const errorCallback = (error) => {
// error handling
};
context.decodeAudioData(arrayBuffer, successCallback, errorCallback);
})
.catch((error) => {
// error handling
});
初期の頃は上記のような仕様でしたが, 最新の仕様では, 成功時は Promise<AudioBuffer>
を返すので, 戻り値から
AudioBuffer
インスタンスを取得することも可能です.
decodeAudioData
メソッドの実行で 1 つ注意しなければならないのは, decodeAudioData
メソッドも
Autoplay Policy の影響を受けるということです. したがって,
ユーザーインタラクティブなイベント発生後に実行する必要があります.
createBuffer メソッド
AudioBuffer
インスタンスを生成するには, AudioContext
インスタンスの
createBuffer
メソッドを利用することでも可能です. 引数は, 第 1 引数にチャンネル数, 第 2 引数に 1
チャンネルのオーディオデータのサイズ, 第 3 引数にサンプリング周波数を指定します. しかしながら, インスタンスは生成できるものの,
オーディオデータをもっているわけではないので, ワンショットオーディオの再生において利用することはないでしょう. ユースケースとしては,
オーディオデータから生成した AudioBuffer
インスタンスからコピー (copyFromChannel
メソッドや
copyToChannel
メソッドが必要なケース) が考えられます.
これで, ワンショットオーディオを再生する最低限の処理ができているので, あとは AudioBufferSourceNode
のインスタンスを生成します
(ファクトリメソッドで生成する場合, createBufferSource
メソッドを利用します).
const context = new AudioContext();
fetch('./assets/one-shots/piano-C.mp3')
.then((response) => {
return response.arrayBuffer();
})
.then((arrayBuffer) => {
const successCallback = (audioBuffer) => {
const source = new AudioBufferSourceNode(context, { buffer: audioBuffer });
// If use `createBufferSource`
// const source = context.createBufferSource();
//
// source.buffer = audioBuffer;
};
const errorCallback = (error) => {
// error handling
};
context.decodeAudioData(arrayBuffer, successCallback, errorCallback);
})
.catch((error) => {
// error handling
});
playbackRate プロパティ / detune プロパティ
音楽用途でワンショットオーディオを使う場合, 対応するピッチの数だけ, ワンショットオーディオデータを作成するのは大変ですし, また, HTTP
リクエストの送受信や decodeAudioData
メソッドの実行も多くなってしまうのでパフォーマンス的にもよくありません. それを解決するのが,
playbackRate
プロパティと detune
プロパティです. これらは, 音の物理的な性質, つまり,
再生速度を変化させるとピッチも変化する という性質を利用して, ピッチ (と再生時間) を変更します. 例えば, playbackRate
を
2
に設定すれば, ピッチも 2 倍, つまり, 1 オクターブ高いピッチのオーディオデータの再生を同一の
AudioBuffer
インスタンスから可能です. detune
は, cent 単位でピッチを変更します. ピッチを変更すると,
再生時間も変わりますが, ワンショットオーディオは再生時間が短時間なので, この点が問題になることはほとんどないでしょう. いずれも,
AudioParam
インスタンスなので, 値を取得したり, 設定する場合は, value
プロパティにアクセスします.
playbackRate
プロパティと detune
プロパティを考慮した, 実際の再生速度
$p_{\mathrm{computed}}$ は, 仕様では以下のように決定されます.
$p_{\mathrm{computed}} = \mathrm{playbackRate} \cdot \mathrm{pow}\left(2, \left(\mathrm{detune} / 1200 \right)\right)$
loop プロパティ / loopStart プロパティ / loopEnd プロパティ
ワンショットオーディオをループ再生させたい場合, loop
プロパティを true
に設定します. また, loop
プロパティを
true
に設定することで, loopStart
プロパティと loopEnd
プロパティが有効になります. これらのプロパティは,
ループ再生するオーディオデータの開始位置, 終了位置を秒単位で指定します.
start メソッド / stop メソッド
AudioBufferSourceNode
インスタンスは, 言わば使い捨てなので, 一度発音・停止した AudioBufferSourceNode
インスタンスは再度,
発音 (停止) することはできません. 例えば, ユーザーインタラクティブな操作で発音・停止を繰り返すような場合,
AudioBufferSourceNode
インスタンスを再生成して, 再度 AudioDestinationNode
に接続して, start
メソッド (stop
メソッド) を実行する必要があります. この仕様は, OscillatorNode
とまったく同じです (ただし,
AudioBuffer
インスタンスは使い回すことが可能です).
例えば, 以下のコードはボタンをクリックするたびに, 再生・停止することを期待していますが, 2 回目のクリック以降は, 再生されずエラーが発生します.
<button type="button">start</button>
const context = new AudioContext();
const source = new AudioBufferSourceNode(context);
// AudioBufferSourceNode (Input) -> AudioDestinationNode (Output)
source.connect(context.destination);
const buttonElement = document.querySelector('button[type="button"]');
buttonElement.addEventListener('mousedown', (event) => {
if (source.buffer === null) {
return;
}
// Start immediately
// But, cannot start since the second times ...
source.start(0);
buttonElement.textContent = 'stop';
});
buttonElement.addEventListener('mouseup', (event) => {
if (source.buffer === null) {
return;
}
// Stop immediately
source.stop(0);
buttonElement.textContent = 'start';
});
fetch('./assets/one-shots/piano-C.mp3')
.then((response) => {
return response.arrayBuffer();
})
.then((arrayBuffer) => {
const successCallback = (audioBuffer) => {
source.buffer = audioBuffer;
};
const errorCallback = (error) => {
// error handling
};
context.decodeAudioData(arrayBuffer, successCallback, errorCallback);
})
.catch((error) => {
// error handling
});
期待する動作, つまり, 再生・停止を繰り返すには, 一度 start
・stop
した (あるいは, duration
まで再生した)
AudioBufferSourceNode
インスタンスは破棄して, 再度 AudioBufferSourceNode
インスタンスを生成します.
const context = new AudioContext();
let source = null;
let buffer = null;
const buttonElement = document.querySelector('button[type="button"]');
buttonElement.addEventListener('mousedown', (event) => {
if (buffer === null) {
return;
}
source = new AudioBufferSourceNode(context, { buffer });
// AudioBufferSourceNode (Input) -> AudioDestinationNode (Output)
source.connect(context.destination);
// Start immediately
source.start(0);
buttonElement.textContent = 'stop';
});
buttonElement.addEventListener('mouseup', (event) => {
if ((buffer === null) || (source === null)) {
return;
}
// Stop immediately
source.stop(0);
buttonElement.textContent = 'start';
});
fetch('./assets/one-shots/piano-C.mp3')
.then((response) => {
return response.arrayBuffer();
})
.then((arrayBuffer) => {
const successCallback = (audioBuffer) => {
buffer = audioBuffer;
};
const errorCallback = (error) => {
// error handling
};
context.decodeAudioData(arrayBuffer, successCallback, errorCallback);
})
.catch((error) => {
// error handling
});
ワンショットオーディオも, 複数の AudioBufferSourceNode
インスタンスを AudioDestinationNode
に接続することで合成が可能です
(そのまま合成 (接続) してしまうと, 振幅が大きくなりすぎて, 音割れが発生してしまうので, GainNode
を接続して振幅を調整しています).
また, 3 つの AudioBufferSourceNode
インスタンスで, それぞれ detune
プロパティの値を調整して, C
メジャーコードを再生しています.
const context = new AudioContext();
// C major chord
let sourceC = null;
let sourceE = null;
let sourceG = null;
let buffer = null;
const buttonElement = document.querySelector('button[type="button"]');
buttonElement.addEventListener('mousedown', (event) => {
if (buffer === null) {
return;
}
sourceC = new AudioBufferSourceNode(context, { buffer });
sourceE = new AudioBufferSourceNode(context, { buffer });
sourceG = new AudioBufferSourceNode(context, { buffer });
sourceC.detune.value = 0;
sourceE.detune.value = 400;
sourceG.detune.value = 700;
const gain = new GainNode(context, { gain: 0.25 });
// AudioBufferSourceNode (Input) -> GainNode -> AudioDestinationNode (Output)
sourceC.connect(gain);
sourceE.connect(gain);
sourceG.connect(gain);
gain.connect(context.destination);
// Start immediately
sourceC.start(0);
sourceE.start(0);
sourceG.start(0);
buttonElement.textContent = 'stop';
});
buttonElement.addEventListener('mouseup', (event) => {
if ((buffer === null) || (sourceC === null) || (sourceE === null) || (sourceG === null)) {
return;
}
// Stop immediately
sourceC.stop(0);
sourceE.stop(0);
sourceG.stop(0);
buttonElement.textContent = 'start';
});
fetch('./assets/one-shots/piano-C.mp3')
.then((response) => {
return response.arrayBuffer();
})
.then((arrayBuffer) => {
const successCallback = (audioBuffer) => {
buffer = audioBuffer;
};
const errorCallback = (error) => {
// error handling
};
context.decodeAudioData(arrayBuffer, successCallback, errorCallback);
})
.catch((error) => {
// error handling
});
AudioBufferSourceNode
でも, start
メソッドと stop
メソッドは一対 という仕様は,
さまざまなプラットフォームのオーディオ API のなかでも Web Audio API 独自の仕様で, ハマりやすい仕様なので注意してください (そもそも, Web
ではないプラットフォームのオーディオ API はここまで抽象化されている API すら少ないと思います).
MediaElementAudioSourceNode
Web Audio API において, 楽曲データに対してなんらかのオーディオ信号処理を適用したい場合に利用するのが
MediaElementAudioSourceNode
です. 具体的には, HTMLMediaElement
(HTMLAudioElement
や
HTMLVideoElement
) のオーディオデータに対するオーディオ信号処理を適用する場合に利用します.
HTMLMediaElement
を音源にするので, MediaElementAudioSourceNode
コンストラクタの第 2 引数 (MediaElementAudioSourceOptions
型) の mediaElement
プロパティ (もしくは, ファクトリメソッドの createMediaElementSource
の引数) に,
HTMLMediaElement
を指定します.
また, コンストラクタやファクトリメソッドに指定する HTMLMediaElement
が HTML パース時点で,
src
属性に指定しているメディアファイルが同一オリジンでない場合, クロスオリジン制限にかかってしまうので,
crossorigin
属性に 'anonymous'
を設定 しておく必要あります. この属性と値の設定によって,
オリジン間リソース共有 (CORS : Cross-Origin Resources Sharing ) が可能となります (HTMLMediaElement
のみで再生する場合は不要です ).
<!-- シューベルト 交響曲 第8番 ロ短調 D759 「未完成」 第1楽章 (余談ですが, X JAPAN の「ART OF LIFE」のモチーフになっている楽曲です) -->
<audio src="https://korilakkuma.github.io/Web-Music-Documentation/assets/medias/Schubert-Symphony-No8-Unfinished-1st-2020-VR.mp3" crossorigin="anonymous" controls />
const context = new AudioContext();
const audioElement = document.querySelector('audio');
const source = new MediaElementAudioSourceNode(context, { mediaElement: audioElement });
// If use `createMediaElementSource`
// const source = context.createMediaElementSource(audioElement);
// MediaElementAudioSourceNode (Input) -> AudioDestinationNode (Output)
source.connect(context.destination);
MediaElementAudioSourceNode
インスタンス生成には 2 点注意すべき点があります. 上記のサンプルコードのように,
HTMLMediaElement
に HTML パース時点で, src
属性にメディアファイルが指定されている場合は, 特に問題ありませんが,
インタラクティブに, 例えば, ユーザーのファイルシステムからメディアファイルを選択するような場合,
HTMLMediaElement
の loadstart
イベント発火以降にインスタンスを生成する必要があります (逆に, HTML パース時点で
src
属性にメディアファイルを指定している場合, loadstart
イベントは発火しないので注意が必要です).
loadstart
イベント以降に発火するイベントであればよいので, canplaythrough
イベントハンドラなどで
MediaElementAudioSourceNode
インスタンスを生成してもよいでしょう.
<input type="file" />
<audio controls />
const context = new AudioContext();
const inputElement = document.querySelector('input[type="file"]');
const audioElement = document.querySelector('audio');
inputElement.addEventListener('change', (event) => {
const file = event.currentTarget.files[0];
audioElement.src = window.URL.createObjectURL(file);
});
audioElement.addEventListener('loadstart', () => {
const source = new MediaElementAudioSourceNode(context, { mediaElement: audioElement });
// MediaElementAudioSourceNode (Input) -> AudioDestinationNode (Output)
source.connect(context.destination);
});
もう 1 点は, 1 つの HTMLMediaElement
に対して 1 つの MediaElementAudioSourceNode
インスタンスが対応しているという点です.
例えば, HTMLMediaElement
の src
属性のみを変更する場合,
MediaElementAudioSourceNode
インスタンスを再度生成するとエラーが発生します (逆に, 別のオブジェクトとなる
HTMLMediaElement
を指定する場合, MediaElementAudioSourceNode
インスタンスを生成する必要があります).
したがって, 先ほどのサンプルコードだと, 2 回以上, ファイルを選択してしまうと, 同じ HTMLAudioElement
に対して, 複数回
MediaElementAudioSourceNode
インスタンスが生成されてエラーが発生してしまうので, 以下のように変更します.
また, File API
から選択した楽曲データを, HTMLMediaElement
の src
属性に指定する場合, Object URL を利用します
(FileReader API
を使って Data URL を利用しても可能ですが, 実装が増えるだけなので, なんらかの理由がなければ
createObjectURL
を利用して Object URL を設定するのがよいでしょう).
<input type="file" />
<audio controls />
const context = new AudioContext();
const inputElement = document.querySelector('input[type="file"]');
const audioElement = document.querySelector('audio');
let source = null;
inputElement.addEventListener('change', (event) => {
const file = event.currentTarget.files[0];
audioElement.src = window.URL.createObjectURL(file);
// If use Data URL,
//
// const reader = new FileReader();
//
// reader.onload = () => {
// audioElement.src = reader.result;
// };
//
// reader.readAsDataURL(file);
});
audioElement.addEventListener('loadstart', () => {
if (source === null) {
source = new MediaElementAudioSourceNode(context, { mediaElement: audioElement });
}
// MediaElementAudioSourceNode (Input) -> AudioDestinationNode (Output)
source.connect(context.destination);
});
HTMLMediaElement と MediaElementAudioSourceNode
すでにサンプルコードを実行して, お気づきになったかもしれませんが,
HTMLMediaElement
のプロパティやイベントハンドラはすべて利用することが可能です. volume
や muted
,
playbackRate
は再生する楽曲データそのものに影響します. autoplay
や loop
は再生における UX に影響します. また,
実際のプロダクトでは, loadedmetadata
イベント, canplaythrough
イベント, timeupdate
イベント,
ended
イベントなどで, UI を更新するイベントハンドラを実行することも多いでしょう. このドキュメントですべてを解説することはできないので,
HTMLMediaElement
の仕様などを参考にしてください.
よくある実装として, loadedmetadata
イベントで duration
プロパティ (トータルの再生時間秒数) を取得,
timeupdate
イベントで currentTime
プロパティ (現在の再生位置) を更新,
ended
イベントで初期表示に戻すというのは Web Audio API に直接関係はありませんが, メディアデータをあつかう Web
アプリケーションでは必須になるような実装なので理解しておいて損はないでしょう. また, MediaElementAudioSourceNode
の解説に着目するために
HTMLMediaElement
の controls
属性での UI で再生・一時停止を実装していましたが,
再生・停止ボタンも実装したサンプルコードです. コードをご覧になると理解できるかもしれませんが, Web Audio API
のコードは変更されていないことにも着目してみてください.
<button type="button">play</button>
<span id="print-current-time">00 : 00</span> / <span id="print-duration">00 : 00</span>
<input type="file" />
<label for="range-cutoff">cutoff</label>
<input type="range" id="range-cutoff" value="4000" min="350" max="8000" step="1" />
<span id="print-cutoff-value">4000 Hz</span>
<audio />
const context = new AudioContext();
const buttonElement = document.querySelector('button[type="button"]');
const inputElement = document.querySelector('input[type="file"]');
const audioElement = document.querySelector('audio');
const spanCurrentTimeElement = document.getElementById('print-current-time');
const spanDurationElement = document.getElementById('print-duration');
const inputCutoffElement = document.getElementById('range-cutoff');
const spanCutoffElement = document.getElementById('print-cutoff-value');
let source = null;
const lowpass = new BiquadFilterNode(context, { type: 'lowpass', frequency: 4000 });
inputElement.addEventListener('change', (event) => {
const file = event.currentTarget.files[0];
audioElement.src = window.URL.createObjectURL(file);
// If use Data URL,
//
// const reader = new FileReader();
//
// reader.onload = () => {
// audioElement.src = reader.result;
// };
//
// reader.readAsDataURL(file);
});
inputCutoffElement.addEventListener('input', (event) => {
lowpass.frequency.value = event.currentTarget.valueAsNumber;
spanCutoffElement.textContent = `${lowpass.frequency.value} Hz`;
});
audioElement.addEventListener('loadstart', () => {
if (source === null) {
source = new MediaElementAudioSourceNode(context, { mediaElement: audioElement });
}
// MediaElementAudioSourceNode (Input) -> BiquadFilterNode (Low-Pass Filter) -> AudioDestinationNode (Output)
source.connect(lowpass);
lowpass.connect(context.destination);
});
audioElement.addEventListener('loadedmetadata', () => {
spanDurationElement.textContent = `${Math.trunc(audioElement.duration / 60).toString(10).slice(0, 2).padStart(2, '0')} : ${(Math.trunc(audioElement.duration) % 60).toString(10).slice(0, 2).padStart(2, '0')}`;
});
audioElement.addEventListener('timeupdate', () => {
spanCurrentTimeElement.textContent = `${Math.trunc(audioElement.currentTime / 60).toString(10).slice(0, 2).padStart(2, '0')} : ${(Math.trunc(audioElement.currentTime) % 60).toString(10).slice(0, 2).padStart(2, '0')}`;
});
audioElement.addEventListener('ended', () => {
spanCurrentTimeElement.textContent = '00 : 00';
});
buttonElement.addEventListener('click', async () => {
if (audioElement.paused) {
await audioElement.play();
buttonElement.textContent = 'pause';
} else {
audioElement.pause();
buttonElement.textContent = 'play';
}
});
MediaStreamAudioSourceNode
Web Audio API において, マイクロフォンやオーディオインターフェースに入力されたサウンドデータに対して,
なんらかのオーディオ信号処理を適用したい場合に利用するのが MediaStreamAudioSourceNode
です. もっと言ってしまえば,
WebRTC (MediaDevices
の getUserMedia
メソッドで取得できる MediaStream
インスタンス)
で取得したサウンドデータに対するオーディオ信号処理を適用する場合に利用します.
WebRTC の仕様は Web Audio API と同等かそれ以上に膨大ですが, Web Audio API との関係で言えば, MediaDevices
の
getUserMedia
メソッドを理解すれば問題ないでしょう.
getUserMedia
メソッドの引数には
MediaStreamConstraints
を指定します (少なくとも, Web Audio API で利用することを想定するので, audio
は true
にしておきます). 初回実行時は,
マイクロフォン (もしくは, 選択したオーディオインターフェース) に対するアクセス許可を求めるダイアログが表示されます. アクセスを許可して問題なければ,
戻り値の Promise
が fulfilled
状態になります. 成功時の Promise
のコールバック関数の引数に
MediaStream
インスタンスが渡されるので, そのインスタンスを MediaElementAudioSourceNode
コンストラクタ
(もしくはファクトリメソッドの createMediaStreamSource
の引数) に指定します
あとは, MediaStreamAudioSourceNode
インスタンスを AudioDestinationNode
に接続すれば, WebRTC
からのサウンドデータを出力することが可能です.
const context = new AudioContext();
const constraints = {
audio: true
};
navigator.mediaDevices.getUserMedia(constraints)
.then((stream) => {
const source = new MediaStreamAudioSourceNode(context, { mediaStream: stream });
// If use `createMediaStreamSource`
// const source = context.createMediaStreamSource(stream);
// MediaStreamAudioSourceNode (Input) -> AudioDestinationNode (Output)
source.connect(context.destination);
})
.catch((error) => {
// error handling
});
もちろん, オーディオ信号処理を適用しないのであれば, WebRTC だけを利用するほうが合理的なので, 簡易例として,
オーディオ信号処理を適用していることがわかるように, BiquadFilterNode
を利用して Low-Pass Filter (低域通過フィルタ)
を使ったサンプルコードです. カットオフ周波数を変更すると, 音の輪郭が変わることを確認してみてください (BiquadFilterNode
に関しては,
別のセクションで詳細を解説します).
const context = new AudioContext();
const constraints = {
audio: true
};
navigator.mediaDevices.getUserMedia(constraints)
.then((stream) => {
const source = new MediaStreamAudioSourceNode(context, { mediaStream: stream });
// If use `createMediaStreamSource`
// const source = context.createMediaStreamSource(stream);
const lowpass = new BiquadFilterNode(context, { type: 'lowpass', frequency: 4000 });
// MediaStreamAudioSourceNode (Input) -> BiquadFilterNode (Low-Pass Filter) -> AudioDestinationNode (Output)
source.connect(lowpass);
lowpass.connect(context.destination);
})
.catch((error) => {
// error handling
});
AudioWorklet
Web Audio API には, 基本波形のサウンド生成やエフェクター, サウンドの視覚化など高度なサウンド処理をより簡単に実装するために, 様々な
AudioNode
が定義されています. これらの AudioNode
があるおかげで, 内部で実行されているオーディオ信号処理の詳細を知らなくても,
高度なサウンド機能の実装が簡単にできるわけです. 例えば, 正弦波の数式を知らなくても,
OscillatorNode
によって正弦波を生成することができました.
Web Audio API が定義する多くの AudioNode
は, サウンドデータの実体にアクセスする機能をもちません. なぜなら, AudioNode
(と,
AudioNode
がもつ AudioParam
) は, サウンド処理を抽象化する, つまり, 抽象度の高い API として定義されているからです.
しかしながら, その代償として, AudioNode
の接続と AudioParam
の制御では不可能なオーディオ処理も存在してしまいます. 具体的に,
現状の仕様では, ノイズ生成, ノイズサプレッサー, ボーカルキャンセラ, ピッチシフターなどは AudioNode
の接続と
AudioParam
の制御のみでは実装できないので, 直接サウンドデータにアクセスできる必要があります.
直接サウンドデータにアクセスすることを可能にするのが, (広義の) AudioWorklet
です (狭義には
AudioWorklet
クラスを意味するので). AudioWorklet
は複数の API で構成されており, メインスレッドで
AudioNode
を継承する AudioWorkletNode
, オーディオスレッド (AudioWorkletGlobalScope
) で直接サウンドデータにアクセスすることを可能にする AudioWorkletProcessor
, メインスレッドからオーディオスレッドのファイルをロードする AudioContext
インスタンスがもつ
AudioWorklet
インスタンスです.
AudioWorkletNode
メインスレッドで定義されていて, AudioNode
クラスを継承しています. AudioWorkletNode
を
AudioNode
に接続することで, AudioWorkletProcessor
(の process
メソッド)
で実装したオーディオ信号処理が適用されて, 次に接続している AudioNode
への入力として出力されます.また, AudioNode
を
AudioWorkletNode
に接続することで, AudioWorkletProcessor
に入力サウンドデータとして渡して,
オーディオ信号処理を適用することも可能です.
AudioWorkletNode
コンストラクタの第 1 引数には, AudioContext
インスタンスを指定し, 第 2 引数には,
AudioWorkletGlobalScope
(オーディオスレッド) で registerProcessor
メソッドで指定した文字列を指定します.
AudioWorkletNode
インスタンスを生成するのは, AudioWorklet
インスタンスの
addModule
メソッド成功後に実行する必要があります (また, 後発な API であるので,
ファクトリメソッドによるインスタンス生成も仕様定義されていないことに注意してください).
const context = new AudioContext();
// './audio-worklets/processor.js' is URL that has subclass that extends `AudioWorkletProcessor`
context.audioWorklet.addModule('./audio-worklets/processor.js')
.then(() => {
const processor = new AudioWorkletNode(context, 'NoiseGeneratorProcessor');
// AudioWorkletNode (Input) -> AudioDestinationNode (Output)
processor.connect(context.destination);
})
.catch((error) => {
// error handling
});
AudioWorkletGlobalScope
AudioWorklet はその API の仕様設計上, メインスレッドとは別のオーディオスレッドを専用に生成することになります.
このオーディオスレッドのグローバルスコープが AudioWorkletGlobalScope
です. つまり, メインスレッドにおける
Window
に相当します. メインスレッドとは別の世界なので, 直接 DOM にはアクセスできなかったり,
メインスレッドで使えるようなクライアントサイド JavaScript API が利用できなかったりします. AudioWorkletGlobalScope
には,
sampleRate
プロパティや currentTime
プロパティが定義されていますが, これはメインスレッドの
AudioContext
インスタンスと同値です.
registerProcessor メソッド
AudioWorkletGlobalScope
で定義されている, 最も重要なメソッドが registerProcessor メソッドです. メインスレッドの
AudioWorkletNode
と, オーディオスレッドの AudioWorkletProcessor
を継承したクラスを関連づける役割をもっているからです. 第
1 引数に, AudioWorkletNode
のコンストラクタに関連づける文字列を, 第 2 引数に,
AudioWorkletProcessor
を継承したクラスを指定します (インスタンスではないので注意してください).
// Filename is './audio-worklets/processor.js'
class NoiseGeneratorProcessor extends AudioWorkletProcessor {
constructor() {
super();
}
process(inputs, outputs, parameters) {
// TODO: Audio Signal Processing
return true;
}
}
registerProcessor('NoiseGeneratorProcessor', NoiseGeneratorProcessor);
AudioWorkletProcessor
AudioWorkletProcessor の継承クラス
AudioWorklet
を構成する API で, 実際にオーディオ信号処理を実行するのが,
AudioWorkletProcessor
クラスを継承したサブクラスです (AudioWorkletProcessor
を継承したサブクラスを).
AudioWorkletProcessor
クラスで最も重要な API が process
メソッドです.
AudioWorkletProcessor
を継承するサブクラスは process
メソッドを必ずオーバライドする必要があります .
また, 実用的なことを考慮すると, MessagePort
インスタンスであるport
プロパティも事実上, 必須と言えます.
AudioWorkletGlobalScope
に定義されている AudioWorkletProcessor
(を継承したサブクラス) は, メインスレッドで設定された値
(例えば, input[type="range"]
で設定された値) を直接的に取得することができません. そこで,
MessagePort
インススタンスの messssage
イベントハンドラや postMessage
メソッドを利用して,
いわゆる メッセージパッシング (Message Passing ) でメインスレッドとデータを送受信する必要があります.
process メソッド (AudioWorkletProcessCallback)
process
メソッドの 第 1 引数には入力サウンドデータとなる Float32Array
, 第 2 引数には出力サウンドデータとなる
Float32Array
, 第 3 引数には, 独自に AudioParam
を定義する場合にパラメータとなる
Float32Array
がそれぞれ渡されます. これらの Float32Array
のサイズは, すべて 128
(サンプル) です
(しかしながら,
Web Audio API 1.1 以降では必ずしも 128
サンプルではなくなる可能性があります ). また, 第 1 引数と第 2 引数 (入力サウンドデータと出力サウンドデータ) は, 実際には,
FrozenArray<FrozenArray<Float32Array>>
と配列の入れ子になっています (仕様上の定義として
FrozenArray
ですが, 実装上は Array
です. 1 つ内側の Array
がチャンネルごとの
Float32Array
を格納するためです).
AudioWorkletNode
に接続している AudioNode
がなければ第 1 引数は不要です. よって, 必須となるのは,
出力サウンドデータである, 128
サンプルの Float32Array
にオーディオ信号処理を適用した値を格納していくことです.
そのミニマムな実装例として, ホワイトノイズ (白色雑音) を生成する process
メソッドのサンプルコードを記載します.
ちなみに, 2 つの Array
(FrozenArray
) の入れ子になっているので, 形式的に, 入力サウンドデータ, 出力サウンドデータともに,
0 番目の Array<Float32Array>
(FrozenArray<Float32Array>
) を取得すると理解していただいて問題ないでしょう
(なぜこのような仕様になっているのかは, オーナーは理解できていません).
// Filename is './audio-worklets/processor.js'
class NoiseGeneratorProcessor extends AudioWorkletProcessor {
constructor() {
super();
}
process(inputs, outputs, parameters) {
// channel number is 0, 1, 2 ...
// `output` is `[Float32Array, Float32Array, Float32Array ...]`
const output = outputs[0];
for (let channelNumber = 0, numberOfChannels = output.length; channelNumber < numberOfChannels; channelNumber++) {
const bufferSize = output[channelNumber].length;
for (let n = 0; n < bufferSize; n++) {
output[channelNumber][n] = (2 * Math.random()) - 1;
}
}
return true;
}
}
registerProcessor('NoiseGeneratorProcessor', NoiseGeneratorProcessor);
ちなみに, process
メソッドの戻り値は boolean
ですが, ここは形式的に,
true
を返す と理解していただいて問題ないでしょう.
true
を返すことで, 128
サンプルごとに process
メソッドが繰り返し実行されるからです . 1 度でも
false
を返した AudioWorkletProcessor
は破棄されるような仕様になっているので, 戻り値を切り替える (true
or
false
) ユースケースがほとんどないからです.
Web Audio API 1.1 以降における render quantum size
Web Audio API 1.0 まで render quantum size は 128
サンプルの固定値でしたが, Web Audio API 1.1 の仕様では,
AudioContextRenderSizeCategory
列挙型の値
(AudioContextOptions
の renderSizeHint
オプションの値) を,
'hardware'
に設定した場合, ユーザーの環境によって最適な render quantum size が選択されて, 必ずしも
128
サンプルでなくなる可能性があります. したがって, 実装においても,
128
サンプルをマジックナンバーとして定義するよりは, 上記のコード例のように, チャンネルごとの Float32Array
の
length
プロパティから render quantum size を取得するほうが, 今後の仕様に合わせてコードを変更する必要がなくなります.
もっとも, AudioContextRenderSizeCategory
を 'default'
に設定している場合 (デフォルト値も
'default'
なので, renderSizeHint
オプションの値を変更しなければ,
128
サンプルと決められているので, 制作する Web Music アプリケーションに応じて, マジックナンバーとして定義しても問題ないでしょう
(ライブラリやフレームワークのような汎用的なコードの場合は, render quantum size は, チャンネルごとの Float32Array
の
length
プロパティから render quantum size を取得するか, AudioContext
インスタンスの
renderQuantumSize
(ただし, Web Audio API 1.1 公開日時点で実装されているブラウザはありません ) を参照するのが安全と言えます).
このあたりは, 制作するプロダクトの仕様に応じた判断の問題 と言えそうです.
port プロパティ (MessagePort インスタンス)
MessagePort
の仕様は Web Audio API の仕様とは別にある (Web Audio API に依存している API ではない) のですが,
実用上必須となるので簡単に解説しておきます (理解されている場合はスキップしてください).
メインスレッドから postMessage
されたデータを受信するには messssage
イベントハンドラの
MessageEvent
イベントオブジェクトにアクセスする必要がありますが, AudioWorkletProcessor
(を継承したサブクラス)
で呼び出されるのは, コンストラクタと process
メソッドのみです. イベントハンドラは, 一度設定してしまえばいいので,
コンストラクタで設定するという実装が定石となります.
<select>
<option value="whitenoise" selected>White Noise</option>
<option value="pinknoise">Pink Noise</option>
<option value="browniannoise">Brownian Noise</option>
</select>
const context = new AudioContext();
// './audio-worklets/processor.js' is URL that has subclass that extends `AudioWorkletProcessor`
context.audioWorklet.addModule('./audio-worklets/processor.js')
.then(() => {
const processor = new AudioWorkletNode(context, 'NoiseGeneratorProcessor');
// AudioWorkletNode (Input) -> AudioDestinationNode (Output)
processor.connect(context.destination);
document.querySelector('select').addEventListener('change', (event) => {
processor.port.postMessage({ type: event.currentTarget.value });
});
})
.catch((error) => {
// error handling
});
// Filename is './audio-worklets/processor.js'
class NoiseGeneratorProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.type = 'whitenoise';
this.b0 = 0;
this.b1 = 0;
this.b2 = 0;
this.b3 = 0;
this.b4 = 0;
this.b5 = 0;
this.b6 = 0;
this.lastOut = 0;
this.port.onmessage = (event) => {
if (event.data.type) {
this.type = event.data.type;
}
};
}
process(inputs, outputs, parameters) {
// channel number is 0, 1, 2 ...
// `output` is `[Float32Array, Float32Array, Float32Array ...]`
const output = outputs[0];
switch (this.type) {
case 'whitenoise': {
for (let channelNumber = 0, numberOfChannels = output.length; channelNumber < numberOfChannels; channelNumber++) {
const bufferSize = output[channelNumber].length;
for (let n = 0; n < bufferSize; n++) {
output[channelNumber][n] = (2 * Math.random()) - 1;
}
}
break;
}
case 'pinknoise': {
for (let channelNumber = 0, numberOfChannels = output.length; channelNumber < numberOfChannels; channelNumber++) {
const bufferSize = output[channelNumber].length;
for (let n = 0; n < bufferSize; n++) {
const white = (2 * Math.random()) - 1;
this.b0 = (0.99886 * this.b0) + (white * 0.0555179);
this.b1 = (0.99332 * this.b1) + (white * 0.0750759);
this.b2 = (0.96900 * this.b2) + (white * 0.1538520);
this.b3 = (0.86650 * this.b3) + (white * 0.3104856);
this.b4 = (0.55000 * this.b4) + (white * 0.5329522);
this.b5 = (-0.7616 * this.b5) - (white * 0.0168980);
output[channelNumber][n] = this.b0 + this.b1 + this.b2 + this.b3 + this.b4 + this.b5 + this.b6 + (white * 0.5362);
output[channelNumber][n] *= 0.11;
this.b6 = white * 0.115926;
}
}
break;
}
case 'browniannoise': {
for (let channelNumber = 0, numberOfChannels = output.length; channelNumber < numberOfChannels; channelNumber++) {
const bufferSize = output[channelNumber].length;
for (let n = 0; n < bufferSize; n++) {
const white = (2 * Math.random()) - 1;
output[channelNumber][n] = (this.lastOut + (0.02 * white)) / 1.02;
this.lastOut = output[channelNumber][n];
output[channelNumber][n] *= 3.5;
}
}
break;
}
}
return true;
}
}
registerProcessor('NoiseGeneratorProcessor', NoiseGeneratorProcessor);
また, MessagePort
は相互に送受信することができるので, オーディオスレッド (AudioWorkletGlobalScope
)
からメインスレッドにデータを送信することも可能です. その場合, AudioWorkletNode
の port
プロパティが
MessagePort
インスタンスとなるので, 同様に messssage
イベントハンドラをメインスレッドで実装すれば,
MessageEvent
イベントオブジェクトから, postMessage
されたデータを受信することが可能になります.
const context = new AudioContext();
// './audio-worklets/message.js' is URL that has subclass that extends `AudioWorkletProcessor`
context.audioWorklet.addModule('./audio-worklets/message.js')
.then(() => {
const oscillator = new OscillatorNode(context);
const processor = new AudioWorkletNode(context, 'MessageProcessor');
// OscillatorNode (Input) -> AudioWorkletNode (Bypass) -> AudioDestinationNode (Output)
oscillator.connect(processor);
processor.connect(context.destination);
oscillator.start(0);
processor.port.onmessage = (event) => {
if (event.data) {
console.log(event.data);
}
};
})
.catch((error) => {
// error handling
});
// Filename is './audio-worklets/message.js'
class MessageProcessor extends AudioWorkletProcessor {
constructor() {
super();
}
process(inputs, outputs) {
const input = inputs[0];
const output = outputs[0];
for (let channelNumber = 0, numberOfChannels = input.length; channelNumber < numberOfChannels; channelNumber++) {
output[channelNumber].set(input[channelNumber]);
}
this.port.postMessage({ messaage: 'Bypass samples' });
return true;
}
}
registerProcessor('MessageProcessor', MessageProcessor);
parameterDescriptors メソッド
parameterDescriptors
メソッドは, AudioWorkletProcessor
で独自の
AudioParam
を実装したい場合に使います. process
メソッドや port
プロパティのように (事実上)
必須の実装というわけではないので, ユースケースとして不要であればスキップしてください.
parameterDescriptors
メソッドは Getter です. AudioParamDescriptor
の配列を返すように実装します.
AudioParamDescriptor
で定義できるプロパティは, name
, defaultValue
, minValue
,
maxValue
, automationRate
の 5 つで, このなかで, name
プロパティのみは必須で定義する必要があります .
これは, メインスレッドで AudioParamMap
インスタンスである AudioWorkletNode
の
parameters
プロパティから, キーとして対象の AudioParam
(AudioParamDescriptor
)
を取得するのに必要となるからです. それ以外のプロパティについてはデフォルト値が設定されています (defaultValue
は 0
,
minValue
と maxValue
がそれぞれ, -3.4028235e38
と 3.4028235e38
,
automationRate
は 'a-rate'
です.
// Filename is './audio-worklets/a-rate.js'
class NoiseGeneratorProcessor extends AudioWorkletProcessor {
static get parameterDescriptors() {
return [{
name : 'noiseGain',
defaultValue : 1,
minValue : 0,
maxValue : 1,
automationRate: 'a-rate'
}];
}
constructor() {
super();
}
process(inputs, outputs, parameters) {
const output = outputs[0];
for (let channelNumber = 0, numberOfChannels = output.length; channelNumber < numberOfChannels; channelNumber++) {
const bufferSize = output[channelNumber].length;
for (let n = 0; n < bufferSize; n++) {
output[channelNumber][n] = (2 * Math.random()) - 1;
}
}
return true;
}
}
registerProcessor('NoiseGeneratorProcessor', NoiseGeneratorProcessor);
parameterDescriptors
メソッドを実装すると, process
メソッドの第 3 引数が渡されるようになります.
name
で指定した文字列をプロパティにして, パラメータの Float32Array
を取得します. automation
が
'a-rate'
の場合, 128
サンプル (render quantum size) ごとに異なる値が格納されているので,
インデックスを走査します. 'k-rate'
の場合, 128
サンプル (render quantum size) ごとに固定値なので,
Float32Array
のインデックス 0
の値を利用します.
// Filename is './audio-worklets/a-rate.js'
class NoiseGeneratorProcessor extends AudioWorkletProcessor {
static get parameterDescriptors() {
return [{
name : 'noiseGain',
defaultValue : 1,
minValue : 0,
maxValue : 1,
automationRate: 'a-rate'
}];
}
constructor() {
super();
}
process(inputs, outputs, parameters) {
const output = outputs[0];
const gain = parameters.noiseGain;
for (let channelNumber = 0, numberOfChannels = output.length; channelNumber < numberOfChannels; channelNumber++) {
const bufferSize = output[channelNumber].length;
for (let n = 0; n < bufferSize; n++) {
output[channelNumber][n] = ((gain.length > 1) ? gain[n] : gain[0]) * ((2 * Math.random()) - 1);
}
}
return true;
}
}
registerProcessor('NoiseGeneratorProcessor', NoiseGeneratorProcessor);
automationRate
が 'k-rate' の場合,
// Filename is './audio-worklets/k-rate.js'
class NoiseGeneratorProcessor extends AudioWorkletProcessor {
static get parameterDescriptors() {
return [{
name : 'noiseGain',
defaultValue : 1,
minValue : 0,
maxValue : 1,
automationRate: 'k-rate'
}];
}
constructor() {
super();
}
process(inputs, outputs, parameters) {
const output = outputs[0];
const gain = parameters.noiseGain;
for (let channelNumber = 0, numberOfChannels = output.length; channelNumber < numberOfChannels; channelNumber++) {
const bufferSize = output[channelNumber].length;
for (let n = 0; n < bufferSize; n++) {
output[channelNumber][n] = gain[0] * ((2 * Math.random()) - 1);
}
}
return true;
}
}
registerProcessor('NoiseGeneratorProcessor', NoiseGeneratorProcessor);
メインスレッドで, 定義した AudioParamDescriptor
を AudioParam
インスタンスとして取得するには,
parameters
プロパティで AudioParamMap
を取得して, name
プロパティをキーにして取得します. これは
AudioParam
インスタンスなので,
オートメーションメソッド を利用することでパラメータをスケジューリングすることが可能になります.
const context = new AudioContext();
// './audio-worklets/a-rate.js' is URL that has subclass that extends `AudioWorkletProcessor`
context.audioWorklet.addModule('./audio-worklets/a-rate.js')
.then(() => {
const processor = new AudioWorkletNode(context, 'NoiseGeneratorProcessor');
// AudioWorkletNode (Input) -> AudioDestinationNode (Output)
processor.connect(context.destination);
const audioParamMap = processor.parameters;
const noiseGain = audioParamMap.get('noiseGain');
// do something for scheduling parameter ...
const t0 = context.currentTime;
const t1 = t0 + 2.5;
noiseGain.setValueAtTime(0, t0);
noiseGain.linearRampToValueAtTime(1, t1);
})
.catch((error) => {
// error handling
});
AudioWorklet
すでに, サンプルコードでは利用していますが, AudioContext
には audioWorklet
プロパティが定義されており, これは
(狭義の) AudioWorklet
インスタンスです. 責務としては, addModule
メソッドを呼び出して,
AudioWorkletProcessor
のサブクラスを定義したスクリプト (Worklet ファイル) をロードすることです.
addModule
メソッドは, Web Audio API に依存した仕様ではなく, JavaScript で使える Worklet (例えば, CSS.paintWorklet
) が,
指定したスクリプト (Worklet ファイル) をロードするために定義されています. addModule
メソッドの第 1 引数は スクリプト (Worklet ファイル)
の URL 文字列で必須です. また, 第 2 引数は任意で, Fetch API
の
Request の
credentials mode
のオブジェクトを指定できます. 戻り値は Promise
です. 成功時のコールバック関数の引数はありません.
AudioWorklet によるオーディオ信号処理
AudioWorklet
は複数の API で構成されているので, 抽象的な解説だけだと理解が難しいかもしれません. このセクションでは,
AudioWorklet
のユースケースとして想定されるオーディオ信号処理の実装例を紹介して理解のサポートになることを目指します.
ノイズ生成
すでに, 解説のサンプルコードとして記載していますが, Web Audio API でホワイトノイズやピンクノイズを生成する場合,
AudioWorklet
を利用する必要があります.
ノイズの生成は, 乱数生成がベースになっています. 特に, ホワイトノイズは乱数そのままであり (振幅の調整だけしている),
その振幅スペクトルはすべての周波数成分を一様に含んでいます (ノイズ生成の詳細に関しては,
How to Generate Noise with the Web Audio API
を参考にしてください).
実際に, Web アプリケーションとして実装する場合, ユーザーインタラクティブな操作によって, 発音・停止をさせるケースが多くなるでしょう. そのために,
AudioWorkletProcessor
を継承したサブクラスで, 発音・停止のフラグを実装しておき, メインスレッドからの
postMessage
で切り替えるようにします. すでに解説しましたが, process
メソッドで, false
を返してしまうと,
その AudioWorkletProcessor
は破棄されるような仕様になっているので, 常に true
を返し, 停止中の場合, 出力となる
Float32Array
にデータを格納しないという実装で停止を実装しています.
<button type="button">start</button>
<select>
<option value="whitenoise" selected>White Noise</option>
<option value="pinknoise">Pink Noise</option>
<option value="browniannoise">Brownian Noise</option>
</select>
const context = new AudioContext();
// './audio-worklets/noise-generator.js' is URL that has subclass that extends `AudioWorkletProcessor`
context.audioWorklet.addModule('./audio-worklets/noise-generator.js')
.then(() => {
const processor = new AudioWorkletNode(context, 'NoiseGeneratorProcessor');
// AudioWorkletNode (Input) -> AudioDestinationNode (Output)
processor.connect(context.destination);
document.querySelector('button[type="button"]').addEventListener('mousedown', (event) => {
processor.port.postMessage({ processing: true });
event.currentTarget.textContent = 'stop';
});
document.querySelector('button[type="button"]').addEventListener('mouseup', (event) => {
processor.port.postMessage({ processing: false });
event.currentTarget.textContent = 'start';
});
document.querySelector('select').addEventListener('change', (event) => {
processor.port.postMessage({ type: event.currentTarget.value });
});
})
.catch((error) => {
// error handling
});
// Filename is './audio-worklets/noise-generator.js'
class NoiseGeneratorProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.processing = false;
this.type = 'whitenoise';
this.b0 = 0;
this.b1 = 0;
this.b2 = 0;
this.b3 = 0;
this.b4 = 0;
this.b5 = 0;
this.b6 = 0;
this.lastOut = 0;
this.port.onmessage = (event) => {
if (typeof event.data.processing === 'boolean') {
this.processing = event.data.processing;
}
if (event.data.type) {
this.type = event.data.type;
}
};
}
process(inputs, outputs, parameters) {
if (!this.processing) {
return true;
}
// channel number is 0, 1, 2 ...
// `output` is `[Float32Array, Float32Array, Float32Array ...]`
const output = outputs[0];
switch (this.type) {
case 'whitenoise': {
for (let channelNumber = 0, numberOfChannels = output.length; channelNumber < numberOfChannels; channelNumber++) {
const bufferSize = output[channelNumber].length;
for (let n = 0; n < bufferSize; n++) {
output[channelNumber][n] = (2 * Math.random()) - 1;
}
}
break;
}
case 'pinknoise': {
for (let channelNumber = 0, numberOfChannels = output.length; channelNumber < numberOfChannels; channelNumber++) {
const bufferSize = output[channelNumber].length;
for (let n = 0; n < bufferSize; n++) {
const white = (2 * Math.random()) - 1;
this.b0 = (0.99886 * this.b0) + (white * 0.0555179);
this.b1 = (0.99332 * this.b1) + (white * 0.0750759);
this.b2 = (0.96900 * this.b2) + (white * 0.1538520);
this.b3 = (0.86650 * this.b3) + (white * 0.3104856);
this.b4 = (0.55000 * this.b4) + (white * 0.5329522);
this.b5 = (-0.7616 * this.b5) - (white * 0.0168980);
output[channelNumber][n] = this.b0 + this.b1 + this.b2 + this.b3 + this.b4 + this.b5 + this.b6 + (white * 0.5362);
output[channelNumber][n] *= 0.11;
this.b6 = white * 0.115926;
}
}
break;
}
case 'browniannoise': {
for (let channelNumber = 0, numberOfChannels = output.length; channelNumber < numberOfChannels; channelNumber++) {
const bufferSize = output[channelNumber].length;
for (let n = 0; n < bufferSize; n++) {
const white = (2 * Math.random()) - 1;
output[channelNumber][n] = (this.lastOut + (0.02 * white)) / 1.02;
this.lastOut = output[channelNumber][n];
output[channelNumber][n] *= 3.5;
}
}
break;
}
}
return true;
}
}
registerProcessor('NoiseGeneratorProcessor', NoiseGeneratorProcessor);
基本波形生成
基本波形の生成は, OscillatorNode
を利用すれば可能ですが, OscillatorNode
による波形生成は, いわゆる,
フーリエ級数展開 にもとづいた波形生成, つまり, 周波数の異なる sin 波の合成によって, 矩形波やノコギリ波, 三角波が生成されています
(その証拠として, 波形 を確認すると Gibbs の現象 が発生していることが確認できます).
実は, 基本波形は, 直線を組み合わせることによって (近似した波形を) 生成することも可能です (その場合, Gibbs の現象は発生しなくなります).
<button type="button">start</button>
<select>
<option value="sine" selected>Sine</option>
<option value="square">Square</option>
<option value="sawtooth">Sawtooth</option>
<option value="triangle">Triangle</option>
</select>
<label for="range-frequency">frequency</label>
<input type="range" id="range-frequency" value="440" min="20" max="8000" step="1" />
<span id="print-frequency-value">440 Hz</span>
const context = new AudioContext();
// './audio-worklets/oscillator.js' is URL that has subclass that extends `AudioWorkletProcessor`
context.audioWorklet.addModule('./audio-worklets/oscillator.js')
.then(() => {
const processor = new AudioWorkletNode(context, 'OscillatorProcessor');
// AudioWorkletNode (Input) -> AudioDestinationNode (Output)
processor.connect(context.destination);
document.querySelector('button[type="button"]').addEventListener('mousedown', (event) => {
processor.port.postMessage({ processing: true });
event.currentTarget.textContent = 'stop';
});
document.querySelector('button[type="button"]').addEventListener('mouseup', (event) => {
processor.port.postMessage({ processing: false });
event.currentTarget.textContent = 'start';
});
document.querySelector('select').addEventListener('change', (event) => {
processor.port.postMessage({ type: event.currentTarget.value });
});
document.querySelector('input[type="range"]').addEventListener('input', (event) => {
processor.port.postMessage({ frequency: event.currentTarget.valueAsNumber });
document.getElementById('print-frequency-value').textContent = `${event.currentTarget.value} Hz`;
});
})
.catch((error) => {
// error handling
});
// Filename is './audio-worklets/oscillator.js'
class OscillatorProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.processing = false;
this.numberOfProcessedSamples = 0;
this.type = 'sine';
this.frequency = 440;
this.port.onmessage = (event) => {
if (typeof event.data.processing === 'boolean') {
this.processing = event.data.processing;
}
if ((typeof event.data.frequency === 'number') && (event.data.frequency > 0)) {
this.frequency = event.data.frequency;
}
if (event.data.type) {
this.type = event.data.type;
}
};
}
process(inputs, outputs, parameters) {
if (!this.processing) {
return true;
}
// channel number is 0, 1, 2 ...
// `output` is `[Float32Array, Float32Array, Float32Array ...]`
const output = outputs[0];
const numberOfSamplesPer1Hz = sampleRate / this.frequency;
for (let channelNumber = 0, numberOfChannels = output.length; channelNumber < numberOfChannels; channelNumber++) {
const bufferSize = output[channelNumber].length;
for (let n = 0; n < bufferSize; n++) {
switch (this.type) {
case 'sine': {
output[channelNumber][n] = Math.sin((2 * Math.PI * this.frequency * this.numberOfProcessedSamples) / sampleRate);
break;
}
case 'square': {
output[channelNumber][n] = (this.numberOfProcessedSamples < (numberOfSamplesPer1Hz / 2)) ? 1 : -1;
break;
}
case 'sawtooth': {
const a = (2 * this.numberOfProcessedSamples) / numberOfSamplesPer1Hz;
output[channelNumber][n] = a - 1;
break;
}
case 'triangle': {
const a = (4 * this.numberOfProcessedSamples) / numberOfSamplesPer1Hz;
output[channelNumber][n] = (this.numberOfProcessedSamples < (numberOfSamplesPer1Hz / 2)) ? (-1 + a) : (3 - a);
break;
}
}
if (++this.numberOfProcessedSamples >= numberOfSamplesPer1Hz) {
this.numberOfProcessedSamples = 0;
}
}
}
return true;
}
}
registerProcessor('OscillatorProcessor', OscillatorProcessor);
リバースチャンネル
左チャンネルからのサウンド出力と右チャンネルからのサウンド出力を反転する単純なエフェクターです (ただし, その原理から,
左右チャンネルのサウンドデータが異なる場合にしか効果がありません). 一般的には, オーディオデータに対して適用するので, オーディオデータの準備として
AudioBufferSourceNode
か MediaElementAudioSourceNode
を入力ノードとして,
AudioWorkletNode
に接続しておきます.
<div>
<input type="file" />
<label>Reverse <input type="checkbox" /></label>
</div>
<audio controls />
const context = new AudioContext();
// './audio-worklets/channel-reverser.js' is URL that has subclass that extends `AudioWorkletProcessor`
context.audioWorklet.addModule('./audio-worklets/channel-reverser.js')
.then(() => {
const reverser = new AudioWorkletNode(context, 'ChannelReverserProcessor');
const inputElement = document.querySelector('input[type="file"]');
const checkboxElement = document.querySelector('input[type="checkbox"]');
const audioElement = document.querySelector('audio');
let source = null;
inputElement.addEventListener('change', (event) => {
const file = event.currentTarget.files[0];
audioElement.src = window.URL.createObjectURL(file);
});
checkboxElement.addEventListener('click', (event) => {
reverser.port.postMessage({ reversing: event.currentTarget.checked });
});
audioElement.addEventListener('loadstart', () => {
if (source === null) {
source = new MediaElementAudioSourceNode(context, { mediaElement: audioElement });
}
// MediaElementAudioSourceNode (Input) -> AudioWorkletNode (Channel Reverser) -> AudioDestinationNode (Output)
source.connect(reverser);
reverser.connect(context.destination);
});
})
.catch((error) => {
// error handling
});
// Filename is './audio-worklets/channel-reverser.js'
class ChannelReverserProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.reversing = false;
this.port.onmessage = (event) => {
if (typeof event.data.reversing === 'boolean') {
this.reversing = event.data.reversing;
}
};
}
process(inputs, outputs) {
const input = inputs[0];
const output = outputs[0];
if ((input.length === 0) || (output.length === 0)) {
return true;
}
if ((input.length !== 2) || (output.length !== 2)) {
output[0].set(input[0]);
return true;
}
if (this.reversing) {
output[0].set(input[1]);
output[1].set(input[0]);
} else {
output[0].set(input[0]);
output[1].set(input[1]);
}
return true;
}
}
registerProcessor('ChannelReverserProcessor', ChannelReverserProcessor);
ボーカルキャンセラ
一般的に, 音楽のオーディオデータはステレオで, 臨場感あるサウンドになるように, 左チャンネルと右チャンネルの音の大きさを調整しています.
ボーカルの聴こえる位置は中央になるように左右のチャンネルを同じ音の大きさで録音し, ギターなどのボーカル以外の楽器音は,
左右どちらかの音の大きさを大きくして, 聴こえる位置が左右のどちらかになるように録音されています. このように録音されたオーディオデータであれば,
左右のチャンネルのサウンドデータの差分をとることにより, 左右均等に録音されているボーカルの音を取り除くことが可能になります. これが,
ボーカルキャンセラです (ただし, その原理から, ドラムなど中央に位置する楽器音も取り除かれてしまいます).
<div>
<input type="file" />
<label>Depth <input type="range" id="range-depth" value="0" min="0" max="1" step="0.05" /><label>
</div>
<audio controls />
const context = new AudioContext();
// './audio-worklets/vocal-canceler.js' is URL that has subclass that extends `AudioWorkletProcessor`
context.audioWorklet.addModule('./audio-worklets/vocal-canceler.js')
.then(() => {
const canceler = new AudioWorkletNode(context, 'VocalCancelerProcessor');
const inputElement = document.querySelector('input[type="file"]');
const rangeElement = document.querySelector('input[type="range"]');
const audioElement = document.querySelector('audio');
let source = null;
inputElement.addEventListener('change', (event) => {
const file = event.currentTarget.files[0];
audioElement.src = window.URL.createObjectURL(file);
});
rangeElement.addEventListener('input', (event) => {
canceler.port.postMessage({ depth: event.currentTarget.valueAsNumber });
});
audioElement.addEventListener('loadstart', () => {
if (source === null) {
source = new MediaElementAudioSourceNode(context, { mediaElement: audioElement });
}
// MediaElementAudioSourceNode (Input) -> AudioWorkletNode (Vocal Canceler) -> AudioDestinationNode (Output)
source.connect(canceler);
canceler.connect(context.destination);
});
})
.catch((error) => {
// error handling
});
// Filename is './audio-worklets/vocal-canceler.js'
class VocalCancelerProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.depth = 0;
this.port.onmessage = (event) => {
if ((typeof event.data.depth === 'number') && (event.data.depth >= 0)) {
this.depth = event.data.depth;
}
};
}
process(inputs, outputs) {
const input = inputs[0];
const output = outputs[0];
if ((input.length === 0) || (output.length === 0)) {
return true;
}
if ((input.length !== 2) || (output.length !== 2)) {
output[0].set(input[0]);
return true;
}
const bufferSize = input[0].length;
for (let n = 0; n < bufferSize; n++) {
output[0][n] = input[0][n] - (this.depth * input[1][n]);
output[1][n] = input[1][n] - (this.depth * input[0][n]);
}
return true;
}
}
registerProcessor('VocalCancelerProcessor', VocalCancelerProcessor);
ScriptProcessorNode
AudioWorklet
は初期の Web Audio API の仕様定義にはなく, ScriptProcessorNode
が仕様定義されていました.
メインスレッド で実行されるイベントハンドラ によって, サウンド入出力する以外は AudioWorklet
と考え方は同じです. しかし,
メインスレッドで実行されるので, 不自然な音切れ (いわゆる, プチプチ音) が発生するグリッチ (glitch ) や UI
がスムーズに動作しなくなる現象であるジャンク (jank ), イベントハンドラで実行されることによる遅延 (latency ) など API
自体の根本的な問題がありました.
Web Audio API の歴史的には, ScriptProcessorNode
の問題を API 設計から解決するために, 後発的に仕様策定されて実装されているのが
AudioWorklet
です. ScriptProcessorNode
は将来的に仕様からも削除される予定なので, 新規に実装するのであればわざわざ
ScriptProcessorNode
を利用する必要はありませんが, 既存のコードを読む必要があるかもしれないので,
正弦波とホワイトノイズをミックスするサンプルコードを記載します.
const context = new AudioContext();
const oscillator = new OscillatorNode(context);
const processor = context.createScriptProcessor(0, 2, 2);
oscillator.connect(processor);
processor.connect(context.destination);
oscillator.start(0);
const bufferSize = processor.bufferSize;
processor.onaudioprocess = (event) => {
const inputLs = event.inputBuffer.getChannelData(0);
const inputRs = event.inputBuffer.getChannelData(1);
const outputLs = event.outputBuffer.getChannelData(0);
const outputRs = event.outputBuffer.getChannelData(1);
for (let n = 0; n < bufferSize; n++) {
outputLs[n] = (0.5 * inputLs[n]) + (0.25 * ((2 * Math.random()) - 1));
outputRs[n] = (0.5 * inputRs[n]) + (0.25 * ((2 * Math.random()) - 1));
}
};
スケジューリング
AudioContext の currentTime プロパティ
Web Audio API におけるスケジューリング (OscillatorNode
や AudioBufferSourceNode
の start
/
stop
メソッドのスケジューリング, AudioParam
のスケジューリング) にはおいて, 基本となる時間が
AudioContext
インスタンスの currentTime
プロパティ です. このプロパティは,
AudioContext
インスタンスが生成されてからの経過時間を秒単位で表現します (したがって, 参照するだけの
readonly
プロパティです). OscillatorNode
や AudioBufferSourceNode
を即時に発音・停止するために,
start
/ stop
メソッドの第 1 引数に 0
を指定していましたが, 実は, 即時に停止するであれば
AudioContext
インスタンスの currentTime
を指定することでも可能です (即時に発音・停止するのは頻繁にあることなので,
デフォルト値 0
で即時に発音・停止するように仕様定義されています). つまり, AudioContext
インスタンスの
currentTime
プロパティを基準に, 未来の時間を指定すれば (加算すれば), スケジューリングが可能になるということです.
仕様上の詳細を解説をすると, OscillatorNode
や AudioBufferSourceNode
は AudioNode
クラスを継承した
AudioScheduledSourceNode
クラスを継承しており, start
/ stop
メソッドはこのクラスに定義されているメソッドです
JavaScript における時間
Date.now
UNIX 時間 (タイムスタンプとも呼ばれます). 1970 年 1 月 1 日 00 : 00 からの経過時間をミリ秒単位で表します. 音楽のような,
時間管理の精度が高く要求されるようなユースケースでは不向きと言えます.
performance.now
DOMHighResTimeStamp
型の時間 (64 bit
浮動小数点数なので, 実体は number
型です) を表現します.
詳細な仕様は他のドキュメントを参考にするほうがよいですが (実行コンテキストによって計測時刻が異なるので), ざっくり言うと, 対象の Web
ページにアクセスしてからの経過時間をミリ秒単位で表します. hls.js など動画ストリーミングライブラリでは使われているなど (例
ABR: Adaptive BitRate streaming ), Date.now
と比較すると, 精度の高い時間と言えます.
OscillatorNode のスケジューリング
OscillatorNode
のスケジューリングを設定することで, 和音をアルペジオ (分散和音) のように発音・停止するサンプルコードにしてみました.
AudioContext
インスタンスの currentTime
プロパティの値を基準に, スケジュールしたい時間を加算した値を引数に渡すことで,
AudioContext
インスタンスが生成されてからの経過時間が指定した時間に達すると, 発音・停止します.
const context = new AudioContext();
// C major chord
let oscillatorC = null;
let oscillatorE = null;
let oscillatorG = null;
const buttonElement = document.querySelector('button[type="button"]');
buttonElement.addEventListener('mousedown', (event) => {
if ((oscillatorC !== null) || (oscillatorE !== null) || (oscillatorG !== null)) {
return;
}
oscillatorC = new OscillatorNode(context, { frequency: 261.6255653005991 });
oscillatorE = new OscillatorNode(context, { frequency: 329.6275569128705 });
oscillatorG = new OscillatorNode(context, { frequency: 391.9954359817500 });
const gain = new GainNode(context, { gain: 0.25 });
// OscillatorNode (Input) -> GainNode -> AudioDestinationNode (Output)
oscillatorC.connect(gain);
oscillatorE.connect(gain);
oscillatorG.connect(gain);
gain.connect(context.destination);
// Schedule oscillator start
oscillatorC.start(context.currentTime + 0.0);
oscillatorE.start(context.currentTime + 0.1);
oscillatorG.start(context.currentTime + 0.2);
buttonElement.textContent = 'stop';
});
buttonElement.addEventListener('mouseup', (event) => {
if ((oscillatorC === null) || (oscillatorE === null) || (oscillatorG === null)) {
return;
}
// Schedule oscillator stop
oscillatorC.stop(context.currentTime + 0.0);
oscillatorE.stop(context.currentTime + 0.1);
oscillatorG.stop(context.currentTime + 0.2);
// GC (Garbage Collection)
oscillatorC = null;
oscillatorE = null;
oscillatorG = null;
buttonElement.textContent = 'start';
});
AudioBufferSourceNode のスケジューリング
OscillatorNode
の場合と同様に, スケジューリングを設定することで, 和音をアルペジオのように発音・停止するサンプルコードにしてみました.
AudioContext
インスタンスの currentTime
プロパティの値を基準に, スケジュールしたい時間を加算した値を引数に渡すことで,
AudioContext
インスタンスが生成されてからの経過時間が指定した時間に達すると, 発音・停止します. 1 点異なるのは,
AudioBufferSourceNode
の start
メソッドはオーバライドされています . もし,
オーディオデータの再生開始位置を指定したい場合は, 第 2 引数に再生開始位置を秒単位で指定, 再生時間を指定する場合は, 第 3 引数に秒単位で指定します (再生速度 を変更している場合でも影響は受けない ので, 第 2 引数, 第 3 引数は再生速度を 1
とした場合の値を指定します). どちらも, 任意の引数なので不要であれば省略可能です.
const context = new AudioContext();
// C major chord
let sourceC = null;
let sourceE = null;
let sourceG = null;
let buffer = null;
const buttonElement = document.querySelector('button[type="button"]');
buttonElement.addEventListener('mousedown', (event) => {
if (buffer === null) {
return;
}
sourceC = new AudioBufferSourceNode(context, { buffer });
sourceE = new AudioBufferSourceNode(context, { buffer });
sourceG = new AudioBufferSourceNode(context, { buffer });
sourceC.detune.value = 0;
sourceE.detune.value = 400;
sourceG.detune.value = 700;
const gain = new GainNode(context, { gain: 0.25 });
// AudioBufferSourceNode (Input) -> GainNode -> AudioDestinationNode (Output)
sourceC.connect(gain);
sourceE.connect(gain);
sourceG.connect(gain);
gain.connect(context.destination);
// Schedule one-shot audio start
sourceC.start((context.currentTime + 0.0), 0, sourceC.buffer.duration);
sourceE.start((context.currentTime + 0.1), 0, sourceE.buffer.duration);
sourceG.start((context.currentTime + 0.2), 0, sourceG.buffer.duration);
buttonElement.textContent = 'stop';
});
buttonElement.addEventListener('mouseup', (event) => {
if ((buffer === null) || (sourceC === null) || (sourceE === null) || (sourceG === null)) {
return;
}
// Schedule one-shot audio stop
sourceC.stop(context.currentTime + 0.0);
sourceE.stop(context.currentTime + 0.1);
sourceG.stop(context.currentTime + 0.2);
buttonElement.textContent = 'start';
});
fetch('./assets/one-shots/piano-C.mp3')
.then((response) => {
return response.arrayBuffer();
})
.then((arrayBuffer) => {
const successCallback = (audioBuffer) => {
buffer = audioBuffer;
};
const errorCallback = (error) => {
// error handling
};
context.decodeAudioData(arrayBuffer, successCallback, errorCallback);
})
.catch((error) => {
// error handling
});
AudioParam のスケジューリング (パラメータのオートメーション)
AudioParam
には, パラメータをスケジュールするための, パラメータのオートメーションメソッド が仕様定義されています.
このセクションでは, その最適なユースケースと言える, エンペロープジェネレーターを例にそれらを解説します.
AudioParam
で定義されている, パラメータのオートメーションメソッドを以下のリストに記載します.
setValueAtTime(value, startTime)
startTime
にパラメータを value
に設定する
linearRampToValueAtTime(value, endTime)
endTime
にパラメータが value
になるように線形的に (直線的に) 変化させる
exponentialRampToValueAtTime(value, endTime)
endTime
にパラメータが value
になるように指数関数的に変化させる
setTargetAtTime(target, startTime, timeConstant)
startTime
になったら, パラメータを target
に向けて, timeConstant
の時間をかけて変化させる (より正確には,
target
の約 63.2% ($1 - \frac{1}{\mathrm{exp}}$ ) まで変化するのに,
timeConstant
の時間を要する)
setValueCurveAtTime(values, startTime, duration)
startTime
になったら, Float32Array
の values
の値にしたがって,
duration
の時間をかけて変化させる
cancelScheduledValues(cancelTime)
cancelTime
以降のスケジューリングを解除する
cancelAndHoldAtTime(cancelTime)
cancelTime
以降のスケジューリングを解除する (cancelTime
時点の値を保持する)
エンベロープジェネレーター
エンベロープとは ?
エンベロープ とは, 波形の概形のことです. テキストによる解説よりは, イラストによる解説が一目瞭然なので, 例として以下のような波形で説明します.
時間領域の波形
振幅エンベロープ
上記のエンベロープは, 振幅に対する波形の概形なので, 振幅エンベロープ と呼びます. 振幅エンベロープを,
時間的に制御するオーディオ処理をエンベロープジェネレーター と呼びます.
エンベロープジェネレーターの実装において, パラメータのスケジュールの対象になるの GainNode
インスタンスの
gain
プロパティです. gain
プロパティは, AudioParam
インスタンスであり,
AudioParam
で仕様定義されているパラメータのオートメーションメソッドを利用することで, パラメータをスケジュールすることが可能です
(同様に, OscillatorNode
インスタンスの frequency
プロパティや, DelayNode
インスタンスの
delayTime
プロパティ, BiquadFilterNode
の frequency
プロパティ ... など,
AudioParam
インスタンスであれば利用可能です. したがって, パラメータのオートメーションメソッドを理解することで,
さまざまなエフェクトが試行錯誤可能になります).
gain プロパティの初期化
まず, 初期化処理として, setValueAtTime
メソッドを実行します. 初期値として, 値を
0
に, また即時に初期化するように, AudioContext
インスタンスの currentTime
プロパティ (t0
)
を指定します.
const context = new AudioContext();
const buttonElement = document.querySelector('button[type="button"]');
const envelopegenerator = new GainNode(context);
let oscillator = null;
buttonElement.addEventListener('mousedown', () => {
if (oscillator !== null) {
return;
}
oscillator = new OscillatorNode(context);
// OscillatorNode (Input) -> GainNode (Envelope Generator) -> AudioDestinationNode (Output)
oscillator.connect(envelopegenerator);
envelopegenerator.connect(context.destination);
const t0 = context.currentTime;
envelopegenerator.gain.setValueAtTime(0, t0);
oscillator.start(0);
buttonElement.textContent = 'stop';
})
attack
attack (アタック) は, ゲインが最大値, すなわち, 1
になるまでに要する時間です. そこで, attack の実装には,
linearRampToValueAtTime
メソッドを利用します (一般的には,
線形的に変化させますが, 指数関数的に変化させたい場合,
exponentialRampToValueAtTime
メソッドを使ってみてもよいでしょう). 注意が必要なのは, 第 2 引数です. attack time の値をそのまま指定してしまうとうまくいきません. なぜなら,
時間ではなく時刻 を指定する必要があるからです. したがって, サウンド開始時刻 (変数 t0
) に attack を加算した値 (変数
t1
) を第 2 引数に指定します.
attack は, もう少しくだいて表現すれば, 音の立ち上がりの速さを決定するパラメータと言えます. 楽器で具体例をあげると,
ピアノやギターは比較的音の立ち上がりが速い楽器で, バイオリンやフルートなどは比較的音の立ち上がりが遅い楽器です. すなわち,
アタックを短くするとピアノやギターのように音の立ち上がりが速くなり, アタックを長くするとバイオリンやフルートのように音の立ち上がりが遅くなります.
ちなみに, 音の立ち上がりが比較的速い (アタックが短い) エレキギターでは, バイオリン奏法と呼ばれる奏法があります. これは, ピッキング時に,
ギターのボリュームを0にすることによって, ピッキングした瞬間の音 (アタック音) を消し, そのあとに, ボリュームを増加させるという奏法です.
エレキギターであるのに, まるでバイオリンのような音色を奏でることができます.
const context = new AudioContext();
const buttonElement = document.querySelector('button[type="button"]');
const envelopegenerator = new GainNode(context);
const attack = 0.01;
let oscillator = null;
buttonElement.addEventListener('mousedown', () => {
if (oscillator !== null) {
return;
}
oscillator = new OscillatorNode(context);
// OscillatorNode (Input) -> GainNode (Envelope Generator) -> AudioDestinationNode (Output)
oscillator.connect(envelopegenerator);
envelopegenerator.connect(context.destination);
const t0 = context.currentTime;
const t1 = t0 + attack;
envelopegenerator.gain.setValueAtTime(0, t0);
envelopegenerator.gain.linearRampToValueAtTime(1, t1);
oscillator.start(0);
buttonElement.textContent = 'stop';
});
decay / sustain
decay (ディケイ) は, ゲインが最大値 1
から sustain (サステイン) にまで減衰する時間です.
setTargetAtTime
メソッドを利用することで実装できます. 注意が必要なのは, 第 2
引数と第 3 引数です. 第 2 引数にはパラメータが変化を開始する時刻を指定し, 第 3 引数にはパラメータが第 1 引数で指定した値 (の約 63.2%)
まで変化するのに要する時間を指定します. したがって, 第 2 引数は gain
プロパティが 1
となる時刻 (減衰開始時刻) である変数
t1
を指定し, 第 3 引数は decay time である変数 t2
を指定します. そして, 第 1 引数は
gain
プロパティが収束する値である sustain level (サステインレベル) を指定します.
attack, decay, release は物理量が時間 なのに対して, sustain のみゲイン
なので注意してください.
const context = new AudioContext();
const buttonElement = document.querySelector('button[type="button"]');
const envelopegenerator = new GainNode(context);
const attack = 0.01;
const decay = 0.3;
const sustain = 0.5;
let oscillator = null;
buttonElement.addEventListener('mousedown', () => {
if (oscillator !== null) {
return;
}
oscillator = new OscillatorNode(context);
// OscillatorNode (Input) -> GainNode (Envelope Generator) -> AudioDestinationNode (Output)
oscillator.connect(envelopegenerator);
envelopegenerator.connect(context.destination);
const t0 = context.currentTime;
const t1 = t0 + attack;
const t2 = decay;
const t2Level = sustain;
envelopegenerator.gain.setValueAtTime(0, t0);
envelopegenerator.gain.linearRampToValueAtTime(1, t1);
envelopegenerator.gain.setTargetAtTime(t2Level, t1, t2);
oscillator.start(0);
buttonElement.textContent = 'stop';
});
release
release (リリース) は, ゲインが sustain から最小値 0
に変化するまでの時間です. decay / sustain と同じく,
setTargetAtTime
メソッドを利用することで実装できます.
gain
プロパティを 0
に近づけていくので, 第 1 引数には 0
を指定します. 第 2
引数に指定するリリースの開始時刻は, AudioContext
インスタンスの currentTime
プロパティ値です. また, 第 3
引数には変化に要する時間, すなわち, release time (リリースタイム) を指定します.
ドラムのような音の余韻が短い楽器をシミュレートしたり, スタッカート (音を短く切って演奏する楽譜の記号) を実現したりする場合は release を短く, 逆に,
ダンパーペダルを踏んだピアノの音や, フェルマータ (音を長く伸ばして演奏する楽譜の記号) を実現したりする場合は release を長くします.
const context = new AudioContext();
const buttonElement = document.querySelector('button[type="button"]');
const envelopegenerator = new GainNode(context);
const attack = 0.01;
const decay = 0.3;
const sustain = 0.5;
const release = 1.0;
let oscillator = null;
buttonElement.addEventListener('mousedown', () => {
if (oscillator !== null) {
return;
}
oscillator = new OscillatorNode(context);
// OscillatorNode (Input) -> GainNode (Envelope Generator) -> AudioDestinationNode (Output)
oscillator.connect(envelopegenerator);
envelopegenerator.connect(context.destination);
const t0 = context.currentTime;
const t1 = t0 + attack;
const t2 = decay;
const t2Level = sustain;
envelopegenerator.gain.setValueAtTime(0, t0);
envelopegenerator.gain.linearRampToValueAtTime(1, t1);
envelopegenerator.gain.setTargetAtTime(t2Level, t1, t2);
oscillator.start(0);
buttonElement.textContent = 'stop';
});
buttonElement.addEventListener('mouseup', () => {
if (oscillator === null) {
return;
}
const t3 = context.currentTime;
const t4 = release;
envelopegenerator.gain.setTargetAtTime(0, t3, t4);
buttonElement.textContent = 'start';
});
リリースを実装する場合は, OscillatorNode
インスタンスの stop
メソッドの即時実行は不要です. その理由は,
stop
メソッドを即時実行すると, その時点で音が停止してしまうので, 音に余韻が生まれません. といっても, このままでは,
start
メソッドの多重呼び出しになります. すなわち, start
メソッドと
stop
メソッドは一対ということが順守できていません.
そこで, タイマー処理で gain
プロパティをチェックして, 停止とみなせる値 になれば, stop
メソッドを実行します.
ここで, 最小値である 0
と表現しなかったのは理由があります. 確かに, 理論上は, 停止とみなせる値は 0
ですが, 実装上では,
(原因はわかりませんが) 半永久的に 0
にはなりません. したがって, 停止とみなせる値を 0.001
未満と設定しています.
また, 停止とみなせる値になる前に, 再度, mousedown
した場合は, OscillatorNode
を即時停止します.
const context = new AudioContext();
const buttonElement = document.querySelector('button[type="button"]');
const envelopegenerator = new GainNode(context);
const attack = 0.01;
const decay = 0.3;
const sustain = 0.5;
const release = 1.0;
let oscillator = null;
let intervalid = null;
buttonElement.addEventListener('mousedown', () => {
if (oscillator !== null) {
oscillator.stop(0);
oscillator = null;
}
oscillator = new OscillatorNode(context);
// OscillatorNode (Input) -> GainNode (Envelope Generator) -> AudioDestinationNode (Output)
oscillator.connect(envelopegenerator);
envelopegenerator.connect(context.destination);
const t0 = context.currentTime;
const t1 = t0 + attack;
const t2 = decay;
const t2Level = sustain;
envelopegenerator.gain.setValueAtTime(0, t0);
envelopegenerator.gain.linearRampToValueAtTime(1, t1);
envelopegenerator.gain.setTargetAtTime(t2Level, t1, t2);
oscillator.start(0);
buttonElement.textContent = 'stop';
});
buttonElement.addEventListener('mouseup', () => {
if (oscillator === null) {
return;
}
const t3 = context.currentTime;
const t4 = release;
envelopegenerator.gain.setTargetAtTime(0, t3, t4);
buttonElement.textContent = 'start';
intervalid = window.setInterval(() => {
if (envelopegenerator.gain.value >= 1e-3) {
return;
}
// Stop sound (If use `OscillatorNode`)
oscillator.stop(0);
oscillator = null;
if (intervalid !== null) {
window.clearInterval(intervalid);
intervalid = null;
}
}, 0);
});
これで, 完成しました ... と言いたいところですが, 1 つ問題点があります. もし, attack time もしくは decay time が経過する前に,
mouseup
イベントが発生するとどうなるでしょう ? attack, decay のゲイン変化のスケジューリングと,
release におけるゲイン変化のスケジューリングが混在してしまいますね. つまり, 上記のコードだと,
意図したスケジューリングにならない可能性があるという問題点があります. これを解決するには,
イベント発生時にスケジューリングをすべて解除すれば解決します. そして, スケジューリングの解除には,
cancelScheduledValues メソッド, もしくは, 値をそのまま保持しておきたい場合は,
cancelAndHoldAtTime メソッドを利用します.
具体的には, mouseup
時は, 値を保持しておきたいので, cancelAndHoldAtTime
メソッドでスケジューリングを解除します. また,
ボタンが連打された場合に不要なスケジューリングが解除されるように, mousedown
時は,
cancelScheduledValues
メソッドでスケジューリングを解除します (そのあと setValueAtTime
メソッドで
0
に初期化されるので値を保持する必要がないので).
const context = new AudioContext();
const buttonElement = document.querySelector('button[type="button"]');
const envelopegenerator = new GainNode(context);
const attack = 0.01;
const decay = 0.3;
const sustain = 0.5;
const release = 1.0;
let oscillator = null;
let intervalid = null;
buttonElement.addEventListener('mousedown', () => {
if (oscillator !== null) {
oscillator.stop(0);
oscillator = null;
}
oscillator = new OscillatorNode(context);
// OscillatorNode (Input) -> GainNode (Envelope Generator) -> AudioDestinationNode (Output)
oscillator.connect(envelopegenerator);
envelopegenerator.connect(context.destination);
const t0 = context.currentTime;
const t1 = t0 + attack;
const t2 = decay;
const t2Level = sustain;
envelopegenerator.gain.cancelScheduledValues(t0);
envelopegenerator.gain.setValueAtTime(0, t0);
envelopegenerator.gain.linearRampToValueAtTime(1, t1);
envelopegenerator.gain.setTargetAtTime(t2Level, t1, t2);
oscillator.start(0);
buttonElement.textContent = 'stop';
});
buttonElement.addEventListener('mouseup', () => {
if (oscillator === null) {
return;
}
const t3 = context.currentTime;
const t4 = release;
envelopegenerator.gain.cancelAndHoldAtTime(t3);
envelopegenerator.gain.setTargetAtTime(0, t3, t4);
buttonElement.textContent = 'start';
intervalid = window.setInterval(() => {
if (envelopegenerator.gain.value >= 1e-3) {
return;
}
// Stop sound (If use `OscillatorNode`)
oscillator.stop(0);
oscillator = null;
if (intervalid !== null) {
window.clearInterval(intervalid);
intervalid = null;
}
}, 0);
});
Firefox での cancelAndHoldAtTime
の実装状況とポリフィル
Firefox 125 の時点では, cancelAndHoldAtTime
が実装されていません. しかしながら, cancelScheduledValues
と
setValueAtTime
を使うことでポリフィルを実装することは可能です.
if (typeof envelopegenerator.gain.cancelAndHoldAtTime === 'function') {
envelopegenerator.gain.cancelAndHoldAtTime(t3);
} else {
const value = envelopegenerator.gain.value;
envelopegenerator.gain.cancelScheduledValues(t3);
envelopegenerator.gain.setValueAtTime(value, t3);
}
エンベロープジェネレーターの応用
エンベロープジェネレーターの実装, すなわち, gain
プロパティのスケジューリングは, オーディオソースに依存したことではないので,
AudioBufferSourceNode
をオーディオソースとして利用することで,
ワンショットオーディオにもエンベロープジェネレーターを適用することが可能になります. また, attack と release を楽曲データの再生に適用することで,
フェードイン・フェードアウトの実装も可能になります.
このセクションのまとめとして, エンベロープジェネレーターの制御となる gain
プロパティの値を視覚化するデモとなります. attack, decay,
sustain, release の値を変えてみて, gain
プロパティの値の変化や, それにともなう音色の変化を体感してみてください.
a-rate
と k-rate
(AutomationRate
)
AudioParam
には, automationRate
プロパティがあり, これは 'a-rate'
か
'k-rate'
の AutomationRate
型で列挙されるどちらかの値が設定されています.
'a-rate'
は, 1
サンプルごとに値を適用することができる AudioParam
です .
'k-rate'
は,
128
サンプル単位 (render quantum size) で値を適用することができる AudioParam
です . AudioParam
ごとに,
AutomationRate
が仕様設定されているので, 重要度としては低くなりますが,
'a-rate'
のほうがパラメータを変化させるコストはやや高いぐらいに認識しておくとよいかもしれません (実装イメージ . 'k-rate'
の場合, 128
サンプルのパラメータの 0
番目だけ適用すればよいので最適化しやすい). また,
AudioWorkletProcessor
クラスで, AudioParam
を定義する場合 (parameterDescriptors
プロパティ),
適切に選択する必要がある場合もあります (デフォルトは, 'a-rate'
).
もっとも, AudioParam
のほとんどは 'a-rate'
です. 現在の仕様では, 以下のリストにある AudioParam
が
'k-rate'
です.
AudioBufferSourceNode
playbackRate
, detune
DynamicsCompressorNode
threshold
, knee
, ratio
, attack
, release
PannerNode
panningModel
が 'HRTF'
の場合, 'k-rate'
のようにふるまう
128
サンプルというのは, Web Audio API における, オーディオ処理のバッファ単位です (仕様では,
render quantum size という用語が使われています). 例えば, AudioWorkletProcessor
では, 128
サンプルごとの入力に対して
(必要があれば, オーディオ処理を適用して), 128
サンプル出力します. リアルタイム性が要求されるようなオーディオ API では, 多くは,
このような, 仕様で定義されているバッファサイズごとにオーディオ処理を適用する (そして, それを繰り返す) という API になっています (Web Audio API 1.1 以降では, 必ずしも 128
サンプルではないことに注意してください ).
Web Audio API におけるガベージコレクション
Web Audio API においてはこのセクションで解説したようなスケジューリングや,
DelayNode
などを利用した時に発生する遅延オーディオデータなどがあるので, JavaScript
の仕様上のガベージコレクションの対象となるオブジェクトに追加して, いくつかの条件があります.
参照が残っていない
処理すべきサウンドデータが残っていない
ノードが接続されていない
サウンドが停止している
スケジューリングが設定されていない
上記 5 つの条件すべてを満たすオブジェクトが, ガベージコレクションの対象となります. ざっくり説明すれば,
なにかしらで利用されているオブジェクトはガベージコレクションの対象にならないということです.
参照が残っていない
これに関しては, Web Audio API に限らず, JavaScript, あるいは, ガベージコレクションが実装されているあらゆるプログラミング言語一般的なことです.
処理すべきサウンドデータが残っていない
処理すべきサウンドデータが意図せずに残るケースとして, DelayNode
や ConvolverNode
を利用して,
エフェクターであるディレイやリバーブを実装した場合が考えられますが, 実装的には対処する必要はありません. 処理すべきサウンドデータがある場合に,
サウンドデータを完了状態にするのは, DelayNode
や ConvolverNode
の役割であるのと, そもそも,
このような場合に処理が残っているサウンドデータを破棄するなどの手段が現状の仕様では存在しないからです.
ノードが接続されていない
不要になった AudioNode
インスタンスは, disconnect
メソッドでノードの接続を解除しておくのが律儀ではありますが,
参照を破棄することで, 同時にノードの接続も解除されるので, 明示的に実装する必要はありません. ちなみに,
disconnect
メソッドのユースケースとしては, 例えば,
ユーザーインタラクティブな操作などで動的にノードの接続を解除する必要がある場合ぐらいです.
以下のコードは, ノード接続状態のまま, 参照を破棄していますが, 同時にノード接続も解除されるのでメモリリークに陥ることはありません.
const context = new AudioContext();
let oscillator = null;
window.setInterval(() => {
oscillator = new OscillatorNode(context);
// OscillatorNode (Input) -> AudioDestinationNode (Output)
oscillator.connect(context.destination);
}, 10);
サウンドが停止している
以下のコードは, サウンドが発音状態なので, ガベージコレクションが実行されず, メモリがしだいに不足していく例です. その理由は,
コールバック関数実行のたびに, 以前のインスタンスへの参照は破棄されますが, それに対応するサウンドが停止していないからです.
const context = new AudioContext();
let oscillator = null;
window.setInterval(() => {
oscillator = new OscillatorNode(context);
// OscillatorNode (Input) -> AudioDestinationNode (Output)
oscillator.connect(context.destination);
oscillator.start(0);
}, 10);
スケジューリングが設定されていない
以下のコードは, 参照を破棄して, サウンドを停止状態にしていますが, 時間が経過するほど,
サウンドの開始が少しずつ遅延するようにサウンドスケジューリングしているので, ガベージコレクションの実行もそれにともなって遅れるので,
メモリが不足していきます.
const context = new AudioContext();
let oscillator = null;
let counter = 0;
window.setInterval(() => {
oscillator = new OscillatorNode(context);
// OscillatorNode (Input) -> AudioDestinationNode (Output)
oscillator.connect(context.destination);
const startTime = context.currentTime + counter;
const stopTime = startTime + 10;
oscillator.start(startTime);
oscillator.stop(stopTime);
++counter;
}, 10);
エフェクター
このサイトのオーナーはエレキギターを弾くので, オーナー個人的には, オーディオプログラミングの最大の楽しみはエフェクターを実装することだと思っています.
Web Audio API のユースケースとしても, エフェクターは考慮されており, GainNode
, DelayNode
, BiquadFilterNode
,
WaveShaperNode
, DynamicsCompressorNode
などによって,
エフェクターの原理さえ簡単に理解していれば実装が容易なぐらいに抽象化されています (エフェクターのためにここまで抽象化されているオーディオ API は,
現時点でおそらく他にありません).
エフェクター実装の基本
一方で, 抽象化されているがゆえに, Web Audio API でエフェクターを実装する場合に理解しておくべきことが 2 つあります.
LFO (Low Frequency Oscillator) の実装
AudioParam
への接続
LFO (Low Frequency Oscillator)
エフェクターにはいくつかの種類があり, モジュレーション系と呼ばれるエフェクター (コーラス, フランジャー, フェイザー, トレモロ, ワウなど)
を実装するためには, 特定のパラメータを時間経過とともに周期的に変化させる必要があります . 具体的には, コーラス / フランジャーは,
ディレイタイム (遅延時間) を時間経過とともに周期的に変化させることによって実装可能です. そして,
特定のパラメータを時間経過とともに周期的に変化させる機能が LFO (Low Frequency Oscillator ) です.
LFO の実装は Web Audio API に限ったことではないのですが, OscillatorNode
を利用して LFO を実装する場合には, Web Audio API
特有のことをもう 1 つ理解している必要があります. それが, AudioParam
への接続です.
AudioParam への接続
結論から記載すると, AudioNode
の connect
メソッドはオーバーロードされており, 第 1 引数には
AudioNode
インスタンスだけでなく, AudioParam
インスタンスを指定することも可能です. すなわち,
OscillatorNode
の接続先を AudioParam
にすることで,
対象のパラメータを時間経過とともに周期的に変化させることが可能になります.
また, LFO のソースとなる OscillatorNode
に GainNode
を接続することで, パラメータの変化量を調整することが可能になります.
一般的なエフェクターのパラメータの, Depth は GainNode
の gain
プロパティの値に, Rate は
OscillatorNode
の frequency
プロパティの値と detune
プロパティの値に相当しますプロパティの値に相当します.
LFO の実装例 (ビブラート)
LFO と AudioParam
への接続, Depth / Rate の制御を具体的に理解するために, 簡易的なビブラートを実装を記載します. (OscillatorNode
の frequency
プロパティのデフォルト値である) 440 Hz
を基準に, Depth で設定した値が Rate の周期で変化することになります.
初期値で言うと, 440 Hz
± 10 Hz
の範囲で, frequency
プロパティの値が,
1 sec
の間に変化することになります.
<button type="button">start</button>
<label for="range-lfo-depth">Depth</label>
<input type="range" id="range-lfo-depth" value="10" min="0" max="50" step="1" />
<span id="print-lfo-depth-value">10</span>
<label for="range-lfo-rate">Rate</label>
<input type="range" id="range-lfo-rate" value="1" min="1" max="10" step="1" />
<span id="print-lfo-rate-value">1</span>
const context = new AudioContext();
let oscillator = null;
let lfo = null;
let depth = null;
let depthValue = 10;
let rateValue = 1;
const buttonElement = document.querySelector('button[type="button"]');
const rangeDepthElement = document.getElementById('range-lfo-depth');
const rangeRateElement = document.getElementById('range-lfo-rate');
const spanPrintDepthElement = document.getElementById('print-lfo-depth-value');
const spanPrintRateElement = document.getElementById('print-lfo-rate-value');
buttonElement.addEventListener('mousedown', (event) => {
if ((oscillator !== null) || (lfo !== null)) {
return;
}
oscillator = new OscillatorNode(context, { frequency: 440 });
lfo = new OscillatorNode(context, { frequency: rateValue });
depth = new GainNode(context, { gain: depthValue });
// OscillatorNode (Input) -> AudioDestinationNode (Output)
oscillator.connect(context.destination);
// OscillatorNode (LFO) -> GainNode (Depth) -> OscillatorNode.frequency (AudioParam)
// 440 Hz +- ${depthValue} Hz
lfo.connect(depth);
depth.connect(oscillator.frequency);
// Start immediately
oscillator.start(0);
// Start LFO
lfo.start(0);
buttonElement.textContent = 'stop';
});
buttonElement.addEventListener('mouseup', (event) => {
if ((oscillator === null) || (lfo === null)) {
return;
}
// Stop immediately
oscillator.stop(0);
lfo.stop(0);
// GC (Garbage Collection)
oscillator = null;
lfo = null;
depth = null;
buttonElement.textContent = 'start';
});
rangeDepthElement.addEventListener('input', (event) => {
depthValue = event.currentTarget.valueAsNumber;
if (depth) {
depth.gain.value = depthValue;
}
spanPrintDepthElement.textContent = Math.trunc(depthValue).toString(10);
});
rangeRateElement.addEventListener('input', (event) => {
rateValue = event.currentTarget.valueAsNumber;
if (lfo) {
lfo.frequency.value = rateValue;
}
spanPrintRateElement.textContent = Math.trunc(rateValue).toString(10);
});
汎用的な LFO と Depth の制御
基準値と Depth の関係から, パラメータ変化の最小値を考慮しておく必要があるのは, LFO の実装として汎用性に欠けます (先ほどのビブラートの実装だと,
基準値を 27.5 Hz
にした場合, Depth の値によっては, 負数の周波数になってしまいます). より汎用的な LFO にするために,
パラメータの変化量を直接 Depth に設定するのではなく, 基準値に対する変化割合を格納する変数を追加して, その比率と基準値から実際の Depth
を算出します. このような実装にすることで, 基準値に関わらず, Depth の値は 0
から 1
(0 %
から
100 %
) になるのでより汎用的な実装になります, また, 各 AudioParam
のパラメータの値の範囲 (AudioParam
の
minValue
/ maxValue
プロパティにそれぞれ, 最小値と最大値が設定されています)
を意図せずに超えてしまうバグも防ぐことができます.
start
OscillatorNode frequency
440 Hz
Depth
0.1
Rate
1
<button type="button">start</button>
<label for="range-oscillator-frequency">OscillatorNode frequency</label>
<input type="range" id="range-oscillator-frequency" value="440" min="27.5" max="4000" step="0.5" />
<span id="print-oscillator-frequency-value">440</span>
<label for="range-lfo-depth">Depth</label>
<input type="range" id="range-lfo-depth" value="0.1" min="0" max="1" step="0.05" />
<span id="print-lfo-depth-value">10</span>
<label for="range-lfo-rate">Rate</label>
<input type="range" id="range-lfo-rate" value="1" min="1" max="10" step="1" />
<span id="print-lfo-rate-value">1</span>
const context = new AudioContext();
let oscillator = null;
let lfo = null;
let depth = null;
let frequency = 440;
let depthRate = 0.1;
let rateValue = 1;
const buttonElement = document.querySelector('button[type="button"]');
const rangeFrequencyElement = document.getElementById('range-oscillator-frequency');
const rangeDepthElement = document.getElementById('range-lfo-depth');
const rangeRateElement = document.getElementById('range-lfo-rate');
const spanPrintFrequencyElement = document.getElementById('print-oscillator-frequency-value');
const spanPrintDepthElement = document.getElementById('print-lfo-depth-value');
const spanPrintRateElement = document.getElementById('print-lfo-rate-value');
buttonElement.addEventListener('mousedown', (event) => {
if ((oscillator !== null) || (lfo !== null)) {
return;
}
oscillator = new OscillatorNode(context, { frequency });
lfo = new OscillatorNode(context, { frequency: rateValue });
depth = new GainNode(context, { gain: oscillator.frequency.value * depthRate });
// OscillatorNode (Input) -> AudioDestinationNode (Output)
oscillator.connect(context.destination);
// OscillatorNode (LFO) -> GainNode (Depth) -> OscillatorNode.frequency (AudioParam)
lfo.connect(depth);
depth.connect(oscillator.frequency);
// Start immediately
oscillator.start(0);
// Start LFO
lfo.start(0);
buttonElement.textContent = 'stop';
});
buttonElement.addEventListener('mouseup', (event) => {
if ((oscillator === null) || (lfo === null)) {
return;
}
// Stop immediately
oscillator.stop(0);
lfo.stop(0);
// GC (Garbage Collection)
oscillator = null;
lfo = null;
depth = null;
buttonElement.textContent = 'start';
});
rangeFrequencyElement.addEventListener('input', (event) => {
frequency = event.currentTarget.valueAsNumber;
if (oscillator && depth) {
oscillator.frequency.value = frequency;
depth.gain.value = oscillator.frequency.value * depthRate;
}
spanPrintFrequencyElement.textContent = `${(Math.trunc(frequency * 10) / 10)} Hz`;
});
rangeDepthElement.addEventListener('input', (event) => {
depthRate = event.currentTarget.valueAsNumber;
if (oscillator && depth) {
depth.gain.value = oscillator.frequency.value * depthRate;
}
spanPrintDepthElement.textContent = depthRate.toString(10);
});
rangeRateElement.addEventListener('input', (event) => {
rateValue = event.currentTarget.valueAsNumber;
if (lfo) {
lfo.frequency.value = rateValue;
}
spanPrintRateElement.textContent = Math.trunc(rateValue).toString(10);
});
LFO の波形
ここまでの解説やコードでは, LFO の波形は OscillatorNode
のデフォルト値である sin 波 ('sine'
) を使っていました.
LFO としてはこれで十分機能しますが, 波形 (OscillatorNode
の type
プロパティ (OscillatorOptions
))
をノコギリ波や三角波にしても LFO として機能します. また, ランダムなパラメータ変化をさせるためにホワイトノイズを使う場合もあります.
もし必要であれば, LFO の波形も選択できるようにすると, エフェクトにバリエーションが出せるかもしれません.
ディレイ・リバーブ
ディレイ・リバーブがどんなエフェクターかを簡単に表現すると, ディレイはやまびこ現象を再現するエフェクター, リバーブはコンサートホールなどの (主に,
室内の) 音の響きを再現するエフェクターとなるでしょう.
表現上はまったく別のエフェクターのように思えますが, その原理は同じです (また, ディレイのパラメータの設定しだいでは,
簡易的なリバーブを再現することも可能です). ディレイ・リバーブともに, FIR フィルタ , つまり,
加算・乗算・遅延というデジタルフィルタにおける基本処理で実装可能な点です.
ディレイ
ディレイを実装するために必要な処理は, DelayNode
の接続とフィードバックです.
DelayNode
遅延処理を (抽象化して) 実装するために定義されているのが, DelayNode
です. コンストラクタの第 2 引数 (DelayOptions
の maxDelayTime
プロパティ) には (ファクトリメソッドの場合, 第 1 引数), 遅延時間 (ディレイタイム)
の最大値を秒単位で指定します. 省略した場合のデフォルト値は 1 sec
です.
const context = new AudioContext();
const delay = new DelayNode(context, { maxDelayTime: 5 }); // 5 sec
// If use `createDelay`
// const delay = context.createDelay(5); // 5 sec
DelayNode
インスタンスには, AudioParam
である delayTime
プロパティが定義されています. これが,
遅延時間 (ディレイタイム) を決定づけるプロパティです. 最小値は 0 sec
, 最大値はインスタンス生成時に指定した値 (sec) です.
遅延した音を生成するには, サウンドの入力点となるノード (OscillatorNode
など) を DelayNode
に接続します.
以下のコードは, サウンド出力点である AudioDestinationNode
に対して 2 つの入力ノードが接続されています. 1 つは, 原音を出力するため,
そして, もう 1 つは遅延音を出力するためです. このように, 複数のノードを入力ノードとして接続することで,
それぞれ入力された音をミックスすることが可能になります. これは, ディレイだけではなく他のエフェクターを実装する場合においても,
原音とエフェクト音をミックスする という処理は必要になります.
const context = new AudioContext();
const delay = new DelayNode(context);
// If use `createDelay`
// const delay = context.createDelay();
delay.delayTime.value = 0.5;
const oscillator = new OscillatorNode(context);
// Connect nodes for original sound
// OscillatorNode (Input) -> AudioDestinationNode (Output)
oscillator.connect(context.destination);
// Connect nodes for delay sound
// OscillatorNode (Input) -> DelayNode (Delay) -> AudioDestinationNode (Output)
oscillator.connect(delay);
delay.connect(context.destination);
oscillator.start(0);
oscillator.stop(context.currentTime + 2);
これで, ディレイが実装できました ... と言いたいところですが, このコードでは, エフェクターとしてのディレイ は実現できていません.
エフェクターとしての という意味は, 上記のコードでも遅延した音は発生します. しかしながら, やまびこ現象のように,
遅延音が少しずつ減衰しながら何度も繰り返し生成することが実装できていません.
Web Audio API の設計思想からの観点で説明すると,
DelayNode
は, 指定された遅延時間で遅延音を 1 つだけ生成することだからです . すなわち,
エフェクターのディレイを実現するという役割までは担いません.
そこで, エフェクターとしてのディレイを実装するには,
DelayNode
の接続と次のセクションで解説するフィードバック という処理が必要になります.
フィードバック
フィードバック とは, 出力された音を入力音として利用することです. つまり, DelayNode によって出力された遅延音を,
再び入力音とすればディレイを実現することが可能です. これを, Web Audio APIで実装するには,
フィードバックのための GainNode
を接続して, その入出力に同じ DelayNode
を接続します. また,
フィードバックの実装は, ディレイに限らず, 様々なエフェクターで必要となります.
ちなみに, エレキギターではフィードバック奏法と呼ばれる奏法があります. この奏法は, アンプから出力された音で弦を振動させて,
それをピックアップが拾い, 再びアンプから出力させることで, 理論上, 永遠の音の伸びを奏でる奏法です.
const context = new AudioContext();
const delay = new DelayNode(context);
// If use `createDelay`
// const delay = context.createDelay();
delay.delayTime.value = 0.5;
const feedback = new GainNode(context, { gain: 0.5 });
const oscillator = new OscillatorNode(context);
// Connect nodes for original sound
// OscillatorNode (Input) -> AudioDestinationNode (Output)
oscillator.connect(context.destination);
// Connect nodes for delay sound
// OscillatorNode (Input) -> DelayNode (Delay) -> AudioDestinationNode (Output)
oscillator.connect(delay);
delay.connect(context.destination);
// Connect nodes for feedback
// (OscillatorNode (Input) ->) DelayNode (Delay) -> GainNode (Feedback) -> DelayNode (Delay) -> GainNode (Feedback) -> ...
delay.connect(feedback);
feedback.connect(delay);
oscillator.start(0);
oscillator.stop(context.currentTime + 2);
上記のコードでは, 大きく 3 つの接続ができました.
原音を出力する接続
エフェクト音 (遅延音) を出力する接続
フィードバック (エフェクト音を再び入力する) のための接続
フィードバック (エフェクト音を再び入力する) の接続によって, 遅延音が少しずつ減衰しながら何度も繰り返し生成されます. 具体例として, 原音のゲインを
1
, フィードバッグのゲインが 0.5
とすると, 1 つめの遅延音のゲインは, 0.5
(1 x 0.5
), 2
つめの遅延音のゲインは, 0.25
(0.5 x 0.5
), 3 つめの遅延音のゲインは, 0.125
(0.25 x 0.5
) ...
といった繰り返しで, 遅延音が少しずつ減衰しながら生成されます. つまり, フィードバックの GainNode
の
gain
プロパティを適切に設定すれば, エフェクターとしてのディレイにバリエーションが出せるというこでもあります.
ただし, フィードバックの値は 1
未満にする必要があります . これは, 直感的な説明をすれば,
1
以上にすると減衰しない状態 (無限ループのような状態) になってしまうからです. 数学的・工学的な厳密性で説明すると,
絶対可積分 を満たさなくなり, (のちのセクションで解説する) FIR
フィルタが安定しないフィルタとなるからです.
フィードバックのイメージ
start
Dry / Wet
Dry / Wet とは, 原音とエフェクト音のゲインを調整するパラメータ (または, そのような機能) のことです.
現実世界のエフェクターにおいても, Dry (原音) / Wet (エフェクト音) としてコントロール可能になっているものが多いので,
このドキュメントでもそれに従って Dry / Wet (あるいは, それらを合わせる Mix) と呼ぶことにします.
const context = new AudioContext();
const delay = new DelayNode(context, { delayTime: 0.5 });
// If use `createDelay`
// const delay = context.createDelay();
// delay.delayTime.value = 0.5;
const dry = new GainNode(context, { gain: 0.7 }); // for gain of original sound
const wet = new GainNode(context, { gain: 0.3 }); // for gain of delay sound
const feedback = new GainNode(context, { gain: 0.5 }); // for feedback
const oscillator = new OscillatorNode(context);
// Connect nodes for original sound
// OscillatorNode (Input) -> GainNode (Dry) -> AudioDestinationNode (Output)
oscillator.connect(dry);
dry.connect(context.destination);
// Connect nodes for delay sound
// OscillatorNode (Input) -> DelayNode (Delay) -> GainNode (Wet) -> AudioDestinationNode (Output)
oscillator.connect(delay);
delay.connect(wet);
wet.connect(context.destination);
// Connect nodes for feedback
// (OscillatorNode (Input) ->) DelayNode (Delay) -> GainNode (Feedback) -> DelayNode (Delay) -> GainNode (Feedback) -> ...
delay.connect(feedback);
feedback.connect(delay);
oscillator.start(0);
oscillator.stop(context.currentTime + 2);
上記のコードは, Dry / Wet のための GainNode
を接続して, ディレイの実装完成コードです. Dry / Wet の制御を可能にすることで,
原音とエフェクト音のゲインを調整するだけで, ディレイにさらなるバリエーションが生まれます. さらに, ノード接続を変更することなくエフェクターのオン
/ オフを切り替えることが可能になります. 例えば, Dry を 1
, Wet を 0
にすれば, エフェクターオフ (つまり, 原音のみ)
のサウンドになります.
ディレイのノード接続図
フィードバックと同様に, Dry / Web (あるいは, Mix) のパラメータ制御は, ディレイだけではなく, 他のエフェクターでも利用されるので, まずは,
ノード接続が比較的単純なディレイの実装でそれらを理解しておくとよいでしょう.
遅延音の実装とリングバッファ
ディレイはエフェクター実装における基本を理解するためにも最適なエフェクターですが, ディレイのコアとなる,
遅延音はどのようにして実装するのでしょうか ? (Web Audio API では,
DelayNode
がそれを抽象化しているので気にすることはほとんどないと思いますが). 結論的には,
リングバッファ に入力された音を格納する (enqueue) ことで, 過去の入力音をリングバッファのサイズだけ蓄積する事が可能です (DelayNode
コンストラクタで指定する遅延時間の最大値は, このリングバッファのサイズを決定するためにあります). 指定した
delayTime
の値が経過したら, リングバッファに格納した過去の入力音を, 原音 (現在時刻の音) と合成して出力します.
加算・乗算・遅延はデジタルフィルタを構成する基本処理なので, 遅延に関しても,
ローレイヤーでどのように実装されているかを理解しておくと応用が利くはずです. 実装言語は C++ になりますが, より詳細な解説は,
ディレイの実装例 | C++でVST作り を参考にするとよいと思います.
リバーブ
簡易的なリバーブであれば, ディレイのパラメータを適切に設定することで実装することは可能です. しかしながら, 現実世界のエフェクターのリバーブは,
ディレイと実装は異なり, シミュレートしたい音響空間のインパルス応答 (RIR : Room Impulse Response )
と呼ばれるオーディオデータを利用します. インパルス応答の詳細に関しては, 後半のセクションで解説しますので, とりあえず,
リバーブを実装してみましょう.
ConvolverNode
インパルス応答のオーディオデータをエフェクターとして利用するには, ConvolverNode
を利用します. ConvolverNode
は,
コンボリューション積分 (畳み込み積分 , 合成積 ) という数学的な演算を抽象化する AudioNode
です
(デジタルフィルタの視点では, FIR フィルタ を抽象化します. のちほど解説しますが, 見立ての違いであり, 本質的には, コンボリューション積分も
FIR フィルタも同じです. コンボリューション積分も FIR フィルタも次のセクション以降で解説しています).
const context = new AudioContext();
const convolver = new ConvolverNode(context);
// If use `createConvolver`
// const convolver = context.createConvolver();
ConvolverNode
には, buffer
プロパティが定義されており, この buffer
プロパティに, インパルス応答
(RIR) のオーディオデータの AudioBuffer
インスタンスを設定します (コンストラクタ生成であれば,
ConvolverOptions
の buffer
プロパティで設定することも可能です).
AudioBuffer
インスタンスの生成は, ワンショットオーディオ と同じです.
const context = new AudioContext();
fetch('./assets/rirs/rir.mp3')
.then((response) => {
return response.arrayBuffer();
})
.then((arrayBuffer) => {
const successCallback = (audioBuffer) => {
const convolver = new ConvolverNode(context);
// If use `ConvolverOptions`
// const convolver = new ConvolverNode(context, { buffer: audioBuffer });
// If use `createConvolver`
// const convolver = context.createConvolver();
convolver.buffer = audioBuffer;
};
const errorCallback = (error) => {
// error handling
};
context.decodeAudioData(arrayBuffer, successCallback, errorCallback);
})
.catch((error) => {
// error handling
});
サウンドの入力点となるノード (OscillatorNode
など) を ConvolverNode
に接続して, ConvolverNode
を
AudioDestinationNode
に接続します.
const context = new AudioContext();
fetch('./assets/rirs/rir.mp3')
.then((response) => {
return response.arrayBuffer();
})
.then((arrayBuffer) => {
const successCallback = (audioBuffer) => {
const convolver = new ConvolverNode(context);
// If use `ConvolverOptions`
// const convolver = new ConvolverNode(context, { buffer: audioBuffer });
// If use `createConvolver`
// const convolver = context.createConvolver();
convolver.buffer = audioBuffer;
const dry = new GainNode(context, { gain: 0.6 }); // for gain of original sound
const wet = new GainNode(context, { gain: 0.4 }); // for gain of reverb sound
const oscillator = new OscillatorNode(context);
// Connect nodes for original sound
// OscillatorNode (Input) -> GainNode (Dry) -> AudioDestinationNode (Output)
oscillator.connect(dry);
dry.connect(context.destination);
// Connect nodes for reverb sound
// OscillatorNode (Input) -> ConvolverNode (Reverb) -> GainNode (Wet) -> AudioDestinationNode (Output)
oscillator.connect(convolver);
convolver.connect(wet);
wet.connect(context.destination);
oscillator.start(0);
oscillator.stop(context.currentTime + 5);
};
const errorCallback = (error) => {
// error handling
};
context.decodeAudioData(arrayBuffer, successCallback, errorCallback);
})
.catch((error) => {
// error handling
});
ディレイの場合と同じように, 原音とエフェクト音の接続, そして, Dry / Wet の GainNode
も接続しています.
遅延音の生成処理と遅延音に対する演算処理は, ConvolverNode
が抽象化しているので, フィードバックのための接続は不要です.
リバーブのノード接続図
以上で, リバーブが完成しました. ところで, リバーブを利用するためには, インパルス応答 (RIR) のオーディオファイルが必要です.
楽曲やワンショットオーディオファイルはあっても, インパルス応答のオーディオデータをもっている人は少ないと思います.
機材をもっていれば実際に測定するのも可能ですが, そこまでするのはちょっとめんどうです. となると, Web
上で公開されているファイルを利用することになるのですが, 利用条件や著作権の関係から無償で自由に利用できるのは意外とありません. とりあえず, 1
つ紹介するのは,
Concert Hall Impulse Responses - Pori, Finland
です. readme.txt の 5.
Copyright のセクションに, 「The data are provided free for noncommercial purposes, provided the authors are cited when the data are used in any research application. 」と記載されているので, 非商用利用であれば, 自身が開発されている Web アプリケーションに利用しても問題なさそうです.
インパルス応答
インパルス応答を理解するには, まずは, インパルス音 について理解する必要があります. インパルス音とは,
ごく短時間において瞬間的に発生する音です. 具体的には, ピストルの音や風船が破裂するときの音がインパルス音と言えるでしょう.
インパルス音のイメージは以下のグラフのようになります. このような物理現象を数式でモデリングするための最適な関数が, デルタ関数 です.
インパルス音 (デルタ関数) のグラフ表現
以下は, デルタ関数の定義です. デルタ関数は, $t = t_{r}$ において, 横幅が
$\lim_{\mathrm{w}\to 0}$ , 高さが $\lim_{\mathrm{h}\to \infty}$ となる関数
(数学での関数をより一般化した, 超関数 に分類されます) なので, 無限の区間において積分すると, その値 (つまり, 面積) が
1
となります.
$
\begin{flalign}
&\delta\left(t - t_{r}\right)dt =
\begin{cases}
\infty & (\mathrm{if} \quad t = t_{r}) \\
0 & (\mathrm{if} \quad t \neq t_{r})
\end{cases}
\end{flalign}
$
$
\begin{flalign}
&\int_{-\infty}^{\infty}\delta\left(t - t_{r}\right)dt = 1 \\
\end{flalign}
$
インパルス音を室内で発生させると, 音が天井や壁などに反射して, 音の響き, すなわち, 残響 が発生します. カラオケが好きなかたであれば,
エコー のような効果と考えてもよいでしょう.
直接音以外にも反射音 (初期反射音) や何度も反射して聴こえる残響音 (後期残響音) が室内では発生します
インパルス応答のイメージ
start
これが, インパルス応答 です. つまり, インパルス音を音響空間に対する入力としたときの出力 (応答) ということです. そして, その出力が音響空間における, 残響特性を表すことになるので , インパルス応答を利用することによって,
コンサートホールなどの音響空間の音の響きをシミュレートするエフェクターであるリバーブが実現できるというわけです. ちなみに,
室内でのインパルス応答をシミュレートする事が多いので, RIR (Room Impulse Response) と表現されることもあります.
実際の RIR の波形
start
また, この残響が, ディレイにおけるフィードバックと類似した音響効果を発生させることになります
(フィードバックのアニメーションとインパルス応答のアニメーションが類似していることに気付いたかもしれません).
イメージでインパルス応答は理解できるかと思いますが, それをコンピュータで実現する場合, 数式でモデリングできる必要があります.
ConvolverNode
の命名 (Convolve : 畳み込む) が表すように, その演算処理がコンボリューション積分 (畳み込み積分 ,
合成積 ) です. 言い換えると, ConvolverNode
は, コンボリューション積分を抽象化した AudioNode
です.
コンボリューション積分
コンボリューション積分 とは, 以下の数式で定義されるように, 信号の遅延と乗算, それらの無限区間の積分によって構成されています.
$x\left(t\right)$ は入力信号, $y\left(t\right)$ は出力信号,
$h\left(t_{r}\right)$ は, インパルス応答の信号 (ConvolverNode
の
buffer
プロパティに設定する, AudioBuffer
のオーディオデータと考えてもよいでしょう) です.
$
\begin{flalign}
&y\left(t\right) = \int_{0}^{\infty}x\left(t - t_{r}\right)h\left(t_{r}\right)dt
\end{flalign}
$
コンピュータでは, 連続信号をあつかうことはできないので, サンプリングされた離散信号の遅延と乗算, それらの加算となります. また,
無限の加算はできないので, 有界な値 (サンプル数) $N$ となります.
$
\begin{flalign}
&y\left(n\right) = \sum_{m = 0}^{N}x\left(n - m\right)h\left(m\right)
\end{flalign}
$
具体的に理解するために, $N = 5$ として, 展開してみます.
$
\begin{flalign}
&y\left(n\right) = x\left(n\right)h\left(0\right) + x\left(n - 1\right)h\left(1\right) + x\left(n - 2\right)h\left(2\right) + x\left(n - 3\right)h\left(3\right) + x\left(n - 4\right)h\left(4\right) + x\left(n - 5\right)h\left(5\right)
\end{flalign}
$
理解しやすいように, 入力信号 $x\left(n\right)$ は, 振幅が 1
のパルス列とします. また,
出力信号の実際の値を算出するために, インパルス応答の信号は, 以下の値とします.
$
\begin{flalign}
&h\left(0\right) = 1.0 \\
&h\left(1\right) = 0.75 \\
&h\left(2\right) = 0.5 \\
&h\left(3\right) = 0.25 \\
&h\left(4\right) = 0.125 \\
&h\left(5\right) = 0.0625 \\
\end{flalign}
$
これらの信号のコンボリューション積分をイラストにすると, 以下のようなグラフになります.
コンボリューション積分
上部が入力信号のパルス列 ($x\left(n\right)$ ), 中央がインパルス応答 ($h\left(m\right)$ ), 下部がコンボリューション積分結果の出力信号 ($y\left(n\right)$ ) となります.
出力信号の振幅のみスケールが異なることに注意してください. ここで, $n = 3$ に相当する時刻までの値を,
先ほどの展開したコンボリューション積分に適用して実際の値を算出すると,
$
\begin{flalign}
&y\left(0\right) = x\left(0\right)h\left(0\right) + x\left(-1\right)h\left(1\right) + x\left(-2\right)h\left(2\right) + x\left(-3\right)h\left(3\right) + x\left(-4\right)h\left(4\right) + x\left(-5\right)h\left(5\right) \\
&y\left(1\right) = x\left(1\right)h\left(0\right) + x\left(0\right)h\left(1\right) + x\left(-1\right)h\left(2\right) + x\left(-2\right)h\left(3\right) + x\left(-3\right)h\left(4\right) + x\left(-4\right)h\left(5\right) \\
&y\left(2\right) = x\left(2\right)h\left(0\right) + x\left(1\right)h\left(1\right) + x\left(0\right)h\left(2\right) + x\left(-1\right)h\left(3\right) + x\left(-2\right)h\left(4\right) + x\left(-3\right)h\left(5\right) \\
&y\left(3\right) = x\left(3\right)h\left(0\right) + x\left(2\right)h\left(1\right) + x\left(1\right)h\left(2\right) + x\left(0\right)h\left(3\right) + x\left(-1\right)h\left(4\right) + x\left(-2\right)h\left(5\right) \\
\end{flalign}
$
負数に相当する時刻は, 振幅が 0
とみなせるので, $n \geq 0$ の項のみ記述すると,
$
\begin{flalign}
&y\left(0\right) = x\left(0\right)h\left(0\right) \\
&y\left(1\right) = x\left(1\right)h\left(0\right) + x\left(0\right)h\left(1\right) \\
&y\left(2\right) = x\left(2\right)h\left(0\right) + x\left(1\right)h\left(1\right) + x\left(0\right)h\left(2\right) \\
&y\left(3\right) = x\left(3\right)h\left(0\right) + x\left(2\right)h\left(1\right) + x\left(1\right)h\left(2\right) + x\left(0\right)h\left(3\right) \\
\end{flalign}
$
また, 入力信号は, 振幅 1
のパルス列なので, $x\left(n\right) = 1$ となるので,
$
\begin{flalign}
&y\left(0\right) = h\left(0\right) \\
&y\left(1\right) = h\left(0\right) + h\left(1\right) \\
&y\left(2\right) = h\left(0\right) + h\left(1\right) + h\left(2\right) \\
&y\left(3\right) = h\left(0\right) + h\left(1\right) + h\left(2\right) + h\left(3\right) \\
\end{flalign}
$
最後に, $h\left(m\right)$ の実際の値を適用すれば, 出力信号 $y\left(n\right)$ の
$n = 3$ までの値が算出できます.
$
\begin{flalign}
&y\left(0\right) = 1.0 \\
&y\left(1\right) = 1.0 + 0.75 = 1.75 \\
&y\left(2\right) = 1.0 + 0.75 + 0.5 = 2.25 \\
&y\left(3\right) = 1.0 + 0.75 + 0.5 + 0.25 = 2.5 \\
\end{flalign}
$
目視レベルではありますが, グラフで表示されている出力信号と (大きく) 値の相違がないことが確認できます. 計算結果を抽象化すると,
コンボリューション積分で生成された出力信号は, 現在の時刻の信号だけではなく, 過去の信号の影響も受ける ということですということです (逆に,
それを, 厳密に数式で定義したのがコンボリューション積分と言えます).
$N = 5$ , $n = 3$ の場合の, コンボリューション積分のイメージ
start
入力信号やインパルス応答が, 現実世界のようにより複雑になると, 計算自体も複雑になりますが, コンボリューション積分がどうのような演算か, そして,
ConvolverNode
が抽象化している演算 のイメージを理解するのに役立てばと思います.
もっとも, 数学・物理的に理解しようとすると少し難しく感じるかもしれませんが, プログラミング言語で実装すれば, 2
重ループと積和演算で構成される単純な処理です.
// コンボリューション積分のコード片
const x = new Float32Array(2400);
const h = new Float32Array(1200);
const y = new Float32Array(2400);
// 入力信号
for (let n = 0; n < x.length; n++) {
x[n] = Math.sin((2 * Math.PI * n * 2) / 1200);
}
// インパルス応答
for (let m = 0; m < h.length; m++) {
h[m] = 0.5 * Math.exp(-m);
}
// 出力信号
for (let n = 0; n < x.length; n++) {
for (let m = 0; m < h.length; m++) {
if ((n - m) >= 0) {
y[n] += (x[n - m] * h[m]);
}
}
}
巡回畳み込み
コンボリューション積分は, 周波数領域においては乗算 となります. したがって, 時間領域の入力信号
$x\left(n\right)$ を (離散) フーリエ変換した信号を $X\left(k\right)$ ,
インパルス応答を (離散) フーリエ変換した信号を $H\left(k\right)$ , 出力信号
$y\left(n\right)$ を (離散) フーリエ変換した信号を
$Y\left(k\right)$ と定義すると, コンボリューション積分は以下のように定義することもできます,
$F$ はフーリエ変換 (離散フーリエ変換), $F^{-1}$ は逆フーリエ変換
(逆離散フーリエ変換) です.
$
\begin{flalign}
&y\left(n\right) = \sum_{m = 0}^{N}x\left(n - m\right)h\left(m\right) = F^{-1}\left[Y\left(k\right)\right] = F^{-1}\left[X\left(k\right)H\left(k\right)\right]
\end{flalign}
$
この性質を利用して, 時間領域ではコンボリューション積分になるオーディオ信号処理を, 周波数領域に変換して, 乗算のみで信号処理を適用して,
時間領域に変換する巡回畳み込み という処理 (ある種のテクニック的な処理です) があります.
インパルス応答も数学的には, デルタ関数のフーリエ変換と周波数領域で定義される伝達関数 の乗算の逆フーリエ変換で定義することもできます (以下,
簡単にですが, 導出を記載しておきます).
$
\begin{flalign}
&h\left(n\right) = \sum_{m = 0}^{\infty}\delta\left(n - m\right)h\left(m\right) = F^{-1}\left[F\left[\delta\left(n\right)\right]H\left(k\right)\right]
\end{flalign}
$
ここで, デルタ関数のフーリエ変換を導出すると (導出の理解が難しければ, とりあえず
$F\left[\delta\left(t\right)\right] = 1$ と覚えてしまっていいでしょう. 他に覚えやすいフーリエ変換関係だと,
矩形関数とシンク関数 ($\frac{\sin\pi x}{\pi x}$ ) は, 互いにフーリエ変換の関係にあります),
$
\begin{flalign}
&F\left[\delta\left(t\right)\right] = \int_{-\infty}^{\infty}\delta\left(t\right)e^{-j2 \pi ft}dt
\end{flalign}
$
デルタ関数の定義より, 上記のフーリエ変換において, $t = 0$ 以外の積分区間では
$\delta\left(t\right) = 0$ となるので,
$
\begin{flalign}
&F\left[\delta\left(t\right)\right] = \int_{-\infty}^{\infty}\delta\left(0\right)e^{-j2 \pi f \cdot 0}dt = \int_{-\infty}^{\infty}\delta\left(0\right)dt \cdot e^{0} = 1 \cdot 1 = 1
\end{flalign}
$
(数学的な厳密性は欠いてしまいますが, 同じように離散信号にも適用すると) デルタ関数のフーリエ変換は 1
です. つまり,
$F\left[\delta\left(n\right)\right] = 1$ なので,
$
\begin{flalign}
&h\left(n\right) = \sum_{m = 0}^{\infty}\delta\left(n - m\right)h\left(m\right) = F^{-1}\left[F\left[\delta\left(n\right)\right]H\left(k\right)\right] = F^{-1}\left[H\left(k\right)\right]
\end{flalign}
$
Web Audio API は (他のオーディオ API と比較すると) 抽象度が高いので, 巡回畳み込みまで駆使するケースはほとんどないかもしれませんが,
一応知っておくと実装のヒントになることがあるかもしれません.
FIR フィルタ
デジタルフィルタを数学的な厳密性まで含めて解説すると, それだけで 1 冊の書籍になるぐらいの解説になるので, FIR
フィルタをディレイ・リバーブの観点で解説します.
FIR フィルタ (Finite Impulse Response filter ) は, 以下の数式で定義されるデジタルフィルタです.
$
\begin{flalign}
&y\left(n\right) = \sum_{m = 0}^{N}x\left(n - m\right)h\left(m\right)
\end{flalign}
$
数式的には, コンボリューション積分と同じです. すなわち, 見立ての違い でしかありません. 数学的にはコンボリューション積分, 工学的には FIR
フィルタと表現しています. 言い換えると, コンボリューション積分を実装に落とし込んだのが FIR フィルタということです.
具体的に, $N = 3$ (乗算器の数 $N + 1$ )
の加算器・乗算器・遅延器の要素を利用してブロック図として表現します (遅延器の $z^{-1}$ の表記は
$z$ 変換 に由来しますが, ブロック図としては, 1 サンプル分遅延させる要素 (素子) の理解で問題ありません).
FIR フィルタ
FIR フィルタの図から, 逆に, コンボリューション積分の数式を導出することが可能なこともわかります.
乗算器の数 (遅延器の数もそれに比例) と係数は, ディレイは制御可能なパラメータで決定できますが, リバーブは室内の特性によって決定されます. すなわち,
乗算器の数と係数の算出がディレイとリバーブの実装に違いに表れます .
ディレイは遅延時間とフィードバックよって乗算器の数と係数を決定する のに対して,
リバーブはインパルス応答 (RIR) のオーディオデータから乗算器の数と係数が決定されます .
Schroeder Reverberator (シュレーダーリバーブ)
コムフィルタ (遅延音を生成) と All-Pass Filter (位相変化によって, 遅延音の間を補間して残響音の密度を高める) を駆使して, 人工的にリバーブ
(人工インパルス応答) を生成する実装が知られています. 実装言語は Python になりますが,
シュレーダーリバーブ(人工残響エフェクタ)のPython実装と試聴デモ などが参考になります.
コーラス・フランジャー
コーラス は, 音に揺らぎを与えるエフェクターです. 合唱では, どんなに歌唱力の高い人が集まって歌っても多少なりともピッチのずれは生じてしまいます.
しかし, この微妙なずれが合唱らしさを生み出している要因でもあります. コーラスでは, この微妙なピッチのずれをオーディオ信号処理で再現します.
フランジャー は, ジェット機のエンジン音のように, 音に強烈なうねりを与えるエフェクターです.
このように, コーラスとフランジャーは, エフェクターとしてはまったく異なるように感じますし, 現実世界のエフェクターでも,
コーラスとフランジャーはそれぞれ別に存在していますが, その原理は同じです. ディレイタイム (遅延時間) を周期的に変化させる ことによって,
FM 変調 を発生させている点です. 原理が同じであるにも関わらず, 別のエフェクターとして感じるのは,
パラメータの設定値やフィードバックの有無が影響しています (また, 音楽的に目的が異なるので, それぞれ,
コーラス・フランジャーとして別になっていると思います).
コーラス・フランジャーはディレイが基本となっているので, ディレイの実装がよくわからないという場合は, (前のセクション解説しているので)
ディレイの実装を理解してから, このセクションを進めてください.
コーラス
コーラスは, ディレイタイムを周期的に変化させたエフェクト音 を原音とミックスすることにより実装できます.
ディレイタイムを周期的に変化させることが実装のポイントになりますが, ここで LFO を利用することで, ディレイタイムを周期的に変化させることができます.
つまり, Web Audio API においては, LFO の接続先を DelayNode
の AudioParam である
delayTime
プロパティに接続すれば実装完了です.
まず, 原音の出力の接続と, エフェクト音の出力の接続 (DelayNode
の接続) のみを実装します.
const context = new AudioContext();
const delay = new DelayNode(context, { delayTime: 0.02 });
const oscillator = new OscillatorNode(context);
// Connect nodes for original sound
// OscillatorNode (Input) -> AudioDestinationNode (Output)
oscillator.connect(context.destination);
// Connect nodes for delay sound
// OscillatorNode (Input) -> DelayNode (Delay) -> AudioDestinationNode (Output)
oscillator.connect(delay);
delay.connect(context.destination);
oscillator.start(0);
oscillator.stop(context.currentTime + 2);
そして, LFO の実装で解説したように, LFO のための OscillatorNode
インスタンスと
GainNode
インスタンス (Depth パラメータ) を生成して, DelayNode
の delayTime
プロパティ に接続します.
const context = new AudioContext();
const baseDelayTime = 0.020;
const depthValue = 0.005;
const rateValue = 1;
const delay = new DelayNode(context, { delayTime: baseDelayTime });
const oscillator = new OscillatorNode(context);
const lfo = new OscillatorNode(context, { frequency: rateValue });
const depth = new GainNode(context, { gain: depthValue });
// Connect nodes for original sound
// OscillatorNode (Input) -> AudioDestinationNode (Output)
oscillator.connect(context.destination);
// Connect nodes for delay sound
// OscillatorNode (Input) -> DelayNode (Delay) -> AudioDestinationNode (Output)
oscillator.connect(delay);
delay.connect(context.destination);
// Connect nodes for LFO that changes delay time periodically
// OscillatorNode (LFO) -> GainNode (Depth) -> delayTime (AudioParam)
lfo.connect(depth);
depth.connect(delay.delayTime);
// Start oscillator and LFO
oscillator.start(0);
lfo.start(0);
// Stop oscillator and LFO
oscillator.stop(context.currentTime + 5);
lfo.stop(context.currentTime + 5);
ディレイのノード接続からフィードバックを除いて, LFO を DelayNode
の delayTime
プロパティ (AudioParam
)
に接続したノード接続と同じです. パラメータに関しては, コーラスの場合, 基準となるディレイタイムを 20 - 30 msec
にして,
± 5 ~ 10 msec
, Rate はゆっくりと 1 Hz
ぐらいがよいでしょう. (もっとも, 実際のプロダクトでは,
ある程度自由度高く設定できるように, 汎用的な LFO になるように実装することになるでしょう).
コーラスの原理的な実装としてはこれで完了ですが, エフェクターとしてはまだコーラスっぽくありません.
原音とエフェクト音が同じゲインで合成されているので, 原音とエフェクト音が別々に出力されているように聴こえると思います.
原音が少し揺れているぐらいにエフェクト音を合成するとコーラスらしくなるので, Dry / Wet のための GainNode
を接続して,
原音とエフェクト音のゲインを調整します.
const context = new AudioContext();
const baseDelayTime = 0.020;
const depthValue = 0.005;
const rateValue = 1;
const delay = new DelayNode(context, { delayTime: baseDelayTime });
const oscillator = new OscillatorNode(context);
const lfo = new OscillatorNode(context, { frequency: rateValue });
const depth = new GainNode(context, { gain: depthValue });
const dry = new GainNode(context, { gain: 0.7 }); // for gain of original sound
const wet = new GainNode(context, { gain: 0.3 }); // for gain of chorus sound
// Connect nodes for original sound
// OscillatorNode (Input) -> GainNode (Dry) -> AudioDestinationNode (Output)
oscillator.connect(dry);
dry.connect(context.destination);
// Connect nodes for delay sound
// OscillatorNode (Input) -> DelayNode (Delay) -> GainNode (Wet) -> AudioDestinationNode (Output)
oscillator.connect(delay);
delay.connect(wet);
wet.connect(context.destination);
// Connect nodes for LFO that changes delay time periodically
// OscillatorNode (LFO) -> GainNode (Depth) -> delayTime (AudioParam)
lfo.connect(depth);
depth.connect(delay.delayTime);
// Start oscillator and LFO
oscillator.start(0);
lfo.start(0);
// Stop oscillator and LFO
oscillator.stop(context.currentTime + 5);
lfo.stop(context.currentTime + 5);
コーラスのノード接続図
以上で, コーラスの実装は完了です. ハードコーディングしているパラメータが多いので, ある程度実際のアプリケーションを想定して, UI
からパラメータ設定を可能にすると以下のようなコードとなるでしょう (Dry / Wet は同時に設定する Mix としています).
<button type="button">start</button>
<label for="range-chorus-delay-time">Delay time</label>
<input type="range" id="range-chorus-delay-time" value="0" min="0" max="50" step="1" />
<span id="print-chorus-delay-time-value">0 msec</span>
<label for="range-chorus-depth">Depth</label>
<input type="range" id="range-chorus-depth" value="0" min="0" max="1" step="0.05" />
<span id="print-chorus-depth-value">0</span>
<label for="range-chorus-rate">Rate</label>
<input type="range" id="range-chorus-rate" value="0" min="0" max="1" step="0.05" />
<span id="print-chorus-rate-value">0</span>
<label for="range-chorus-mix">Mix</label>
<input type="range" id="range-chorus-mix" value="0" min="0" max="1" step="0.05" />
<span id="print-chorus-mix-value">0</span>
const context = new AudioContext();
let oscillator = null;
let lfo = null;
let depthRate = 0;
let rateValue = 0;
let mixValue = 0;
const delay = new DelayNode(context);
const depth = new GainNode(context, { gain: delay.delayTime.value * depthRate });
const dry = new GainNode(context, { gain: 1 - mixValue });
const wet = new GainNode(context, { gain: mixValue });
const buttonElement = document.querySelector('button[type="button"]');
const rangeDelayTimeElement = document.getElementById('range-chorus-delay-time');
const rangeDepthElement = document.getElementById('range-chorus-depth');
const rangeRateElement = document.getElementById('range-chorus-rate');
const rangeMixElement = document.getElementById('range-chorus-mix');
const spanPrintDelayTimeElement = document.getElementById('print-chorus-delay-time-value');
const spanPrintDepthElement = document.getElementById('print-chorus-depth-value');
const spanPrintRateElement = document.getElementById('print-chorus-rate-value');
const spanPrintMixElement = document.getElementById('print-chorus-mix-value');
buttonElement.addEventListener('mousedown', (event) => {
if ((oscillator !== null) || (lfo !== null)) {
return;
}
oscillator = new OscillatorNode(context);
lfo = new OscillatorNode(context, { frequency: rateValue });
// Connect nodes for original sound
// OscillatorNode (Input) -> GainNode (Dry) -> AudioDestinationNode (Output)
oscillator.connect(dry);
dry.connect(context.destination);
// Connect nodes for delay sound
// OscillatorNode (Input) -> DelayNode (Delay) -> GainNode (Wet) -> AudioDestinationNode (Output)
oscillator.connect(delay);
delay.connect(wet);
wet.connect(context.destination);
// Connect nodes for LFO that changes delay time periodically
// OscillatorNode (LFO) -> GainNode (Depth) -> delayTime (AudioParam)
lfo.connect(depth);
depth.connect(delay.delayTime);
// Start oscillator and LFO immediately
oscillator.start(0);
lfo.start(0);
buttonElement.textContent = 'stop';
});
buttonElement.addEventListener('mouseup', (event) => {
if ((oscillator === null) || (lfo === null)) {
return;
}
// Stop immediately
oscillator.stop(0);
lfo.stop(0);
// GC (Garbage Collection)
oscillator = null;
lfo = null;
buttonElement.textContent = 'start';
});
rangeDelayTimeElement.addEventListener('input', (event) => {
delay.delayTime.value = event.currentTarget.valueAsNumber * 0.001;
depth.gain.value = delay.delayTime.value * depthRate;
spanPrintDelayTimeElement.textContent = `${Math.trunc(delay.delayTime.value * 1000)} msec`;
});
rangeDepthElement.addEventListener('input', (event) => {
depthRate = event.currentTarget.valueAsNumber;
depth.gain.value = delay.delayTime.value * depthRate;
spanPrintDepthElement.textContent = depthRate.toString(10);
});
rangeRateElement.addEventListener('input', (event) => {
rateValue = event.currentTarget.valueAsNumber;
if (lfo) {
lfo.frequency.value = rateValue;
}
spanPrintRateElement.textContent = rateValue.toString(10);
});
rangeMixElement.addEventListener('input', (event) => {
mixValue = event.currentTarget.valueAsNumber;
dry.gain.value = 1 - mixValue;
wet.gain.value = mixValue;
spanPrintMixElement.textContent = mixValue.toString(10);
});
フランジャー
フランジャー は, コーラスの実装にエフェクト音のフィードバックの接続を追加するだけです (ディレイの実装に LFO を追加して,
ディレイタイムを周期的に変化させるとも言えます). つまり, コーラスの実装をより汎用的にした実装となります. パラメータしだいで, フランジャーになり,
コーラスにもなります. 原理的には, 同じなのでこのあたりの区別は, 音楽的な感覚による違いでしかありません.
フランジャーのノード接続図
<button type="button">start</button>
<label for="range-flanger-delay-time">Delay time</label>
<input type="range" id="range-flanger-delay-time" value="0" min="0" max="50" step="1" />
<span id="print-flanger-delay-time-value">0 msec</span>
<label for="range-flanger-depth">Depth</label>
<input type="range" id="range-flanger-depth" value="0" min="0" max="1" step="0.05" />
<span id="print-flanger-depth-value">0</span>
<label for="range-flanger-rate">Rate</label>
<input type="range" id="range-flanger-rate" value="0" min="0" max="10" step="0.5" />
<span id="print-flanger-rate-value">0</span>
<label for="range-flanger-mix">Mix</label>
<input type="range" id="range-flanger-mix" value="0" min="0" max="1" step="0.05" />
<span id="print-flanger-mix-value">0</span>
<label for="range-flanger-feedback">Mix</label>
<input type="range" id="range-flanger-feedback" value="0" min="0" max="0.9" step="0.05" />
<span id="print-flanger-feedback-value">0</span>
const context = new AudioContext();
let oscillator = null;
let lfo = null;
let depthRate = 0;
let rateValue = 0;
let mixValue = 0;
const delay = new DelayNode(context);
const depth = new GainNode(context, { gain: delay.delayTime.value * depthRate });
const dry = new GainNode(context, { gain: 1 - mixValue });
const wet = new GainNode(context, { gain: mixValue });
const feedback = new GainNode(context, { gain: 0 });
const buttonElement = document.querySelector('button[type="button"]');
const rangeDelayTimeElement = document.getElementById('range-flanger-delay-time');
const rangeDepthElement = document.getElementById('range-flanger-depth');
const rangeRateElement = document.getElementById('range-flanger-rate');
const rangeMixElement = document.getElementById('range-flanger-mix');
const rangeFeedbackElement = document.getElementById('range-flanger-feedback');
const spanPrintDelayTimeElement = document.getElementById('print-flanger-delay-time-value');
const spanPrintDepthElement = document.getElementById('print-flanger-depth-value');
const spanPrintRateElement = document.getElementById('print-flanger-rate-value');
const spanPrintMixElement = document.getElementById('print-flanger-mix-value');
const spanPrintFeedbackElement = document.getElementById('print-flanger-feedback-value');
buttonElement.addEventListener('mousedown', (event) => {
if ((oscillator !== null) || (lfo !== null)) {
return;
}
oscillator = new OscillatorNode(context);
lfo = new OscillatorNode(context, { frequency: rateValue });
// Connect nodes for original sound
// OscillatorNode (Input) -> GainNode (Dry) -> AudioDestinationNode (Output)
oscillator.connect(dry);
dry.connect(context.destination);
// Connect nodes for delay sound
// OscillatorNode (Input) -> DelayNode (Delay) -> GainNode (Wet) -> AudioDestinationNode (Output)
oscillator.connect(delay);
delay.connect(wet);
wet.connect(context.destination);
// Connect nodes for feedback
// (OscillatorNode (Input) ->) DelayNode (Delay) -> GainNode (Feedback) -> DelayNode (Delay) -> GainNode (Feedback) -> ...
delay.connect(feedback);
feedback.connect(delay);
// Connect nodes for LFO that changes delay time periodically
// OscillatorNode (LFO) -> GainNode (Depth) -> delayTime (AudioParam)
lfo.connect(depth);
depth.connect(delay.delayTime);
// Start oscillator and LFO immediately
oscillator.start(0);
lfo.start(0);
buttonElement.textContent = 'stop';
});
buttonElement.addEventListener('mouseup', (event) => {
if ((oscillator === null) || (lfo === null)) {
return;
}
// Stop immediately
oscillator.stop(0);
lfo.stop(0);
// GC (Garbage Collection)
oscillator = null;
lfo = null;
buttonElement.textContent = 'start';
});
rangeDelayTimeElement.addEventListener('input', (event) => {
delay.delayTime.value = event.currentTarget.valueAsNumber * 0.001;
depth.gain.value = delay.delayTime.value * depthRate;
spanPrintDelayTimeElement.textContent = `${Math.trunc(delay.delayTime.value * 1000)} msec`;
});
rangeDepthElement.addEventListener('input', (event) => {
depthRate = event.currentTarget.valueAsNumber;
depth.gain.value = delay.delayTime.value * depthRate;
spanPrintDepthElement.textContent = depthRate.toString(10);
});
rangeRateElement.addEventListener('input', (event) => {
rateValue = event.currentTarget.valueAsNumber;
if (lfo) {
lfo.frequency.value = rateValue;
}
spanPrintRateElement.textContent = rateValue.toString(10);
});
rangeMixElement.addEventListener('input', (event) => {
mixValue = event.currentTarget.valueAsNumber;
dry.gain.value = 1 - mixValue;
wet.gain.value = mixValue;
spanPrintMixElement.textContent = mixValue.toString(10);
});
rangeFeedbackElement.addEventListener('input', (event) => {
const feedbackValue = event.currentTarget.valueAsNumber;
feedback.gain.value = feedbackValue;
spanPrintFeedbackElement.textContent = feedbackValue.toString(10);
});
ディレイタイムの周期的な変化とFM 変調
FM 変調 (Frequency Modulation ) とは, 時間の経過とともに信号の周波数を変化させることです.
Time Domain
Frequency Domain (Spectrum)
FM 変調のイメージ (スペクトルのピークが 880 Hz ± 440 Hz
の間で変調します)
start
コーラス・フランジャーは, 結果的に FM 変調を発生させていると解説しましたが, これに対して疑問に思うことがあるかもしれません.
周波数を周期的に変化させるために, なぜ, ディレイタイムを周期的に変化させているのかということです
(ディレイタイムの周期的な変化が原理となっているかということです) LFO の実装例 で解説したように,
直接的に周波数を周期的に変化させればよいはずです. しかしながら, 基本波形のように, 基本周波数が明確な場合はそれで問題ないのですが, アンサンブル
(つまり, 楽曲) や音声において, 一般的に, 基本周波数を (精度高く) 推定するアルゴリズムは複雑になりますし, それにともなって計算量も多くなります
(ちなみに, $f_{0}$ 推定などと呼ばれます). つまり, 汎用的なコーラス・フランジャーを実装するとなると,
直接的に周波数を変化させることは難しくなります.
ここで, コンボリューション積分とフーリエ変換の性質から,
時間領域における遅延は, 周波数領域においては周波数成分の変化となります (数学的な詳細を知る必要はないですが,
巡回畳み込み のセクションが参考になると思います). すなわち,
時間領域においてディレイタイムを周期的に変化させることは, 周波数領域において各周波数成分を周期的に変化させる こととなり, 結果として
(汎用的な) FM 変調となります.
ちなみに, すでにサンプルコードを実行して気づいたかもしれませんが,
コーラス・フランジャーで, エフェクト音のみの出力にした場合, ビブラートとなります . ビブラートはまさに FM 変調であり, 言い換えれば,
コーラス・フランジャーは, ビブラートをベースにしたエフェクターであり, 汎用的な実装とパラメータ設定でビブラートにすることも可能ということです.
フェイザー
フェイザー は, (テキストでは表現しにくいですが) シュワシュワという独特な感じのエフェクトを与えます. パラメータの設定しだいでは,
フランジャーっぽい感じにもなります. 実際, 楽曲を聴くと, フェイザーかフランジャーを使っているかは判断ができないぐらい似ているエフェクターです.
フランジャーに似ているエフェクトでありながら, その原理はまったく異なります. フェイザーは, 特定の周波数帯域の音の位相 を周期的に変化させて,
原音と合成して, 音を干渉 させることによって実装できるエフェクターです. ちなみに, フェイザーの正式な名称は, フェイズ・シフター であり,
まさに, 名称がその原理を表していると言えます.
位相
位相 とは, 時間領域における波の位置のことです (数学的には, ある時刻の複素数平面における偏角と考えることもできます). したがって,
周期性をもつ波の場合, 位相はその周期内の値だけを考慮すれば事足ります (正弦波の場合,
$\sin\theta = \sin\left(\theta + 2\pi\right) = \sin\left(\theta + 4\pi\right) = \cdots = \sin\left(\theta + 2n\pi\right)$
となるので, $2\pi$ の区間の位相を考慮すればよいことになります). また, 正弦波と余弦波は位相の違いでしかありません
($\sin\theta = \cos\left(\theta - \frac{\pi}{2}\right)$ . これは, 位相で考えなくても,
加法定理で数式から導出できることでもあります).
位相 (例: $\theta = \frac{\pi}{4}$ の場合)
位相と周期性から, 位相の変化をイメージすると, 横軸を位相 (単位は radian
: ラジアン) としたときに, 以下のような変化になります.
位相変化のイメージ
start
$+\frac{\pi}{2}$
正弦波の場合, $\frac{\pi}{2}$ シフトすると反転した余弦波,
$\pi$ シフトすると反転した自身の波 (反転した正弦波),
$\frac{3\pi}{2}$ シフトすると余弦波, $2\pi$ シフトすると元の正弦波に戻ります.
フェイザーの原理を理解するうえで, この位相変化のイメージは重要になるのでおさえておいてください.
All-Pass Filter
フェイザーは, 位相を変化させる周波数帯域を周期的に変化させて, 原音と合成して音を干渉させることによって実装できます. つまり,
位相を変化させる ことがフェイザーにとって重要な処理となりますが, All-Pass Filter を使うことで,
対象の周波数成分の位相を変化させることができます (他のフィルタと異なり, すべての周波数成分のゲイン (振幅) を変化させずに通過させるので,
オールパス という名前がついています).
Web Audio API では, BiquadFilterNode
インスタンスの type
プロパティに 'allpass'
を設定することで,
簡単に All-Pass Filter を使うことができます.
const context = new AudioContext();
const oscillator = new OscillatorNode(context, { type: 'sawtooth' });
const allpass = new BiquadFilterNode(context, { type: 'allpass', frequency: 880 });
oscillator.connect(allpass);
allpass.connect(context.destination);
oscillator.start(0);
oscillator.stop(context.currentTime + 2);
位相変化と干渉
ところで, All-Pass Filter を通過させた音, つまり, 位相を変化させただけの音というのは, 特に変化を知覚することはできません.
スペクトル のセクションでも記載しましたが, 人間の聴覚というのは位相の違いには鈍感 だからです. では, なぜ,
フェイザーはエフェクトとして知覚することができるのでしょうか ?
フェイザーは, 位相の変化を知覚しているのではなく, 位相を変化させたエフェクト音と原音を合成することで音波を干渉 させて, 結果として発生する,
振幅の増減やうねり を知覚しているからです.
位相変化と干渉のイメージ
start
$+\frac{\pi}{2}$
上記の正弦波で説明すると, 開始時刻において, 原音とエフェクト音 (ともに, 半透明の青色の波) は, 同じ位相にあります. ここで,
$\theta = \frac{\pi}{2}$ の位相の点に着目すると, 同じ位相なので, 音波が重なり, 合成された結果, 出力音 (マゼンタ色の波) は
$0.5 \cdot \sin\left(\frac{\pi}{2}\right) + 0.5 \cdot \sin\left(\frac{\pi}{2}\right) = 1$
となって振幅が増幅します. エフェクト音の位相を $\frac{\pi}{2}$ ずらすと, 反転した余弦波となり, 位相
$\theta = \frac{\pi}{2}$ でのエフェクト音は
$-0.5 \cdot \cos\left(\frac{\pi}{2}\right) = 0$ となるので, 合成された結果, 出力音の振幅値は,
$0.5 \cdot \sin\left(\frac{\pi}{2}\right) - 0.5\cdot \cos\left(\frac{\pi}{2}\right) = 0.5$ となります. さらに,
エフェクト音の位相を $\frac{\pi}{2}$ (開始時刻を基準にすると, $\pi$ ) ずらすと,
反転した正弦波となり, 位相 $\theta = \frac{\pi}{2}$ でのエフェクト音は
$-0.5 \cdot \sin\left(\frac{\pi}{2}\right) = -0.5$ となるので, 合成された結果の振幅値は,
$0.5 \cdot \sin\left(\frac{\pi}{2}\right) - 0.5 \cdot \sin\left(\frac{\pi}{2}\right) = 0$ となります (位相
$\theta = \frac{\pi}{2}$ に限らず, 反転した正弦波と合成するので, すべての位相においてその振幅は
0
であり, すなわち, 無音となります). そして, エフェクト音の位相を 1 周期分, つまり, $2\pi$ ずらすと,
エフェクト音は再び元の位相に戻るので, 開始時刻と同様の出力音となります.
周期関数なので, 1 周期分以上の位相の変化 ($2\pi$ 以上の位相の変化) においては, 同様の振幅の増減の現象が繰り返し発生します.
このように, 複数の音波を合成した結果, 生じる振幅の増減現象を干渉 と呼びます (実は, これまでも, エフェクターの解説で実装した,
原音とエフェクト音を合成する処理というのも, 物理的な視点では, 音波の干渉です. 音楽的には, このような音を重ねる処理を, 合成, あるいは,
ミキシングと呼ぶので, 合成という用語を優先して使いました).
また, 位相がわずかに異なる時点で音波を重ねることをうねり (うなりとも呼びます) と呼び, 音楽的な効果が高いことが知られています (同様に,
周波数をわずかにずらした音波を重ねた場合でもうねり現象は発生します. 実は, これが原始的なコーラスでもあります).
したがって, 位相を変化させたエフェクト音と原音を合成するような AudioNode
の接続を実装すると以下のようになります.
const context = new AudioContext();
const oscillator = new OscillatorNode(context, { type: 'sawtooth' });
const allpass = new BiquadFilterNode(context, { type: 'allpass', frequency: 880 });
// Connect nodes for original sound
// OscillatorNode (Input) -> AudioDestinationNode (Output)
oscillator.connect(context.destination);
// Connect nodes for shifting phase
// OscillatorNode (Input) -> BiquadFilterNode (All-Pass Filter) -> AudioDestinationNode (Output)
oscillator.connect(allpass);
allpass.connect(context.destination);
oscillator.start(0);
oscillator.stop(context.currentTime + 2);
ところが, この実装では, 位相を変化する周波数成分が固定されたままなので, 干渉による振幅の増減変化が知覚できるほど発生しないので,
フェイザーとして聴こえません. 位相変化させる周波数成分を周期的に変化させるように, LFO を All-Pass Filter の frequency
プロパティ
(AudioParam
) に接続します.
const context = new AudioContext();
const baseFrequency = 880;
const depthValue = 220;
const rateValue = 1;
const oscillator = new OscillatorNode(context, { type: 'sawtooth' });
const allpass = new BiquadFilterNode(context, { type: 'allpass', frequency: baseFrequency });
const lfo = new OscillatorNode(context, { frequency: rateValue });
const depth = new GainNode(context, { gain: depthValue });
// Connect nodes for original sound
// OscillatorNode (Input) -> AudioDestinationNode (Output)
oscillator.connect(context.destination);
// Connect nodes for shifting phase
// OscillatorNode (Input) -> BiquadFilterNode (All-Pass Filter) -> AudioDestinationNode (Output)
oscillator.connect(allpass);
allpass.connect(context.destination);
// Connect nodes for LFO that changes All-Pass Filter frequency periodically
// OscillatorNode (LFO) -> GainNode (Depth) -> frequency (AudioParam)
lfo.connect(depth);
depth.connect(allpass.frequency);
// Start oscillator and LFO
oscillator.start(0);
lfo.start(0);
// Stop oscillator and LFO
oscillator.stop(context.currentTime + 5);
lfo.stop(context.currentTime + 5);
これで, 位相を変化させた音と干渉によって生じる振幅の増減, つまり, フェイザーのエフェクト音を知覚できるようになったと思います. あとは,
コーラスやフランジャーと同様に, 原音とエフェクト音のゲインを制御できるように, Dry / Wet のための GainNode
を接続します.
const context = new AudioContext();
const baseFrequency = 880;
const depthValue = 220;
const rateValue = 1;
const oscillator = new OscillatorNode(context, { type: 'sawtooth' });
const allpass = new BiquadFilterNode(context, { type: 'allpass', frequency: baseFrequency });
const lfo = new OscillatorNode(context, { frequency: rateValue });
const depth = new GainNode(context, { gain: depthValue });
const dry = new GainNode(context, { gain: 0.5 }); // for gain of original sound
const wet = new GainNode(context, { gain: 0.5 }); // for gain of phaser sound
// Connect nodes for original sound
// OscillatorNode (Input) -> GainNode (Dry) -> AudioDestinationNode (Output)
oscillator.connect(dry);
dry.connect(context.destination);
// Connect nodes for shifting phase
// OscillatorNode (Input) -> BiquadFilterNode (All-Pass Filter) -> GainNode (Wet) -> AudioDestinationNode (Output)
oscillator.connect(allpass);
allpass.connect(wet);
wet.connect(context.destination);
// Connect nodes for LFO that changes All-Pass Filter frequency periodically
// OscillatorNode (LFO) -> GainNode (Depth) -> frequency (AudioParam)
lfo.connect(depth);
depth.connect(allpass.frequency);
// Start oscillator and LFO
oscillator.start(0);
lfo.start(0);
// Stop oscillator and LFO
oscillator.stop(context.currentTime + 5);
lfo.stop(context.currentTime + 5);
現実世界のフェイザーは, よりアグレッシブな干渉を発生させるために, All-Pass Filter を複数接続します. 2 個, 4 個, 12 個, 24
個の接続可能なフェイザーが多く, それらは, All-Pass Filter の接続数 $n$ 個によって,
$n$ 段フェイザー と呼ばれることもあります.
以下は, All-Pass Filter を 4 つ接続したフェイザー (4 段フェイザー) の実装です. 1 つだけの接続の場合より, 干渉による変化が大きく聴こえると思います.
const context = new AudioContext();
const baseFrequency = 880;
const depthValue = 220;
const rateValue = 1;
const oscillator = new OscillatorNode(context, { type: 'sawtooth' });
const allpasses = [
new BiquadFilterNode(context, { type: 'allpass', frequency: baseFrequency }),
new BiquadFilterNode(context, { type: 'allpass', frequency: baseFrequency }),
new BiquadFilterNode(context, { type: 'allpass', frequency: baseFrequency }),
new BiquadFilterNode(context, { type: 'allpass', frequency: baseFrequency })
];
const lfo = new OscillatorNode(context, { frequency: rateValue });
const depth = new GainNode(context, { gain: depthValue });
const dry = new GainNode(context, { gain: 0.5 }); // for gain of original sound
const wet = new GainNode(context, { gain: 0.5 }); // for gain of phaser sound
// Connect nodes for original sound
// OscillatorNode (Input) -> GainNode (Dry) -> AudioDestinationNode (Output)
oscillator.connect(dry);
dry.connect(context.destination);
// Connect nodes for shifting phase
// OscillatorNode (Input) -> BiquadFilterNode (All-Pass Filter) x 4 -> GainNode (Wet) -> AudioDestinationNode (Output)
oscillator.connect(allpasses[0]);
for (let i = 0; i < 3; i++) {
allpasses[i].connect(allpasses[i + 1]);
}
allpasses[3].connect(wet);
wet.connect(context.destination);
// Connect nodes for LFO that changes All-Pass Filter frequency periodically
// OscillatorNode (LFO) -> GainNode (Depth) -> frequency (AudioParam)
lfo.connect(depth);
for (let i = 0; i < 4; i++) {
depth.connect(allpasses[i].frequency);
}
// Start oscillator and LFO
oscillator.start(0);
lfo.start(0);
// Stop oscillator and LFO
oscillator.stop(context.currentTime + 5);
lfo.stop(context.currentTime + 5);
フェイザーのノード接続図 (4 段フェイザー)
さらに, アグレッシブなエフェクトを発生させたい場合は, フィードバック接続を追加することも考えられます (ただし, フェイザーにおいて,
フィードバックは一般的に必須というわけではありません). また, レゾナンス (BiquadFilterNode
の Q
プロパティ
(AudioParam
)) を変更可能にすると, フェイザーによりバリエーションを付加することができます (BiquadFilterNode
の
Q
プロパティは, type
プロパティ (フィルタの種類) によって制御しているフィルタの特性が異なるので, 詳細はフィルタのセクション で解説します).
以下は, 実際のアプリケーションを想定して, ユーザーインタラクティブに, All-Pass Filter の接続数や,
位相変化させる周波数成分などフェイザーに関わるパラメータを制御できるようにしたコード例です. フェイザーはその原理から,
エフェクト音のみでは変化を得ることができないので, Mix の値が 1
未満になるように上限を設定していることにも着目してください.
<button type="button">start</button>
<select id="select-phaser-stages">
<option value="2">2 stages</option>
<option value="4" selected >4 stages</option>
<option value="8">8 stages</option>
<option value="12">12 stages</option>
<option value="24">24 stages</option>
</select>
<label for="range-phaser-frequency">Frequency</label>
<input type="range" id="range-phaser-frequency" value="880" min="100" max="4000" step="1" />
<span id="print-phaser-frequency-value">880 Hz</span>
<label for="range-phaser-depth">Depth</label>
<input type="range" id="range-phaser-depth" value="0" min="0" max="1" step="0.05" />
<span id="print-phaser-depth-value">0</span>
<label for="range-phaser-rate">Rate</label>
<input type="range" id="range-phaser-rate" value="0" min="0" max="10" step="0.5" />
<span id="print-phaser-rate-value">0</span>
<label for="range-phaser-resonance">Resonance</label>
<input type="range" id="range-phaser-resonance" value="1" min="1" max="20" step="1" />
<span id="print-phaser-resonance-value">1</span>
<label for="range-phaser-mix">Mix</label>
<input type="range" id="range-phaser-mix" value="0" min="0" max="0.9" step="0.05" />
<span id="print-phaser-mix-value">0</span>
const context = new AudioContext();
let oscillator = null;
let lfo = null;
let numberOfStages = 4;
let baseFrequency = 880;
let depthRate = 0;
let rateValue = 0;
let resonance = 1;
let mixValue = 0;
const allpasses = [
new BiquadFilterNode(context, { type: 'allpass', frequency: baseFrequency }),
new BiquadFilterNode(context, { type: 'allpass', frequency: baseFrequency }),
new BiquadFilterNode(context, { type: 'allpass', frequency: baseFrequency }),
new BiquadFilterNode(context, { type: 'allpass', frequency: baseFrequency })
];
const depth = new GainNode(context, { gain: baseFrequency * depthRate });
const dry = new GainNode(context, { gain: 1 - mixValue });
const wet = new GainNode(context, { gain: mixValue });
const buttonElement = document.querySelector('button[type="button"]');
const selectPhaserStagesElement = document.getElementById('select-phaser-stages');
const rangeFrequencyElement = document.getElementById('range-phaser-frequency');
const rangeDepthElement = document.getElementById('range-phaser-depth');
const rangeRateElement = document.getElementById('range-phaser-rate');
const rangeResonanceElement = document.getElementById('range-phaser-resonance');
const rangeMixElement = document.getElementById('range-phaser-mix');
const spanPrintFrequencyElement = document.getElementById('print-phaser-frequency-value');
const spanPrintDepthElement = document.getElementById('print-phaser-depth-value');
const spanPrintRateElement = document.getElementById('print-phaser-rate-value');
const spanPrintResonanceElement = document.getElementById('print-phaser-resonance-value');
const spanPrintMixElement = document.getElementById('print-phaser-mix-value');
buttonElement.addEventListener('mousedown', (event) => {
if ((oscillator !== null) || (lfo !== null)) {
return;
}
oscillator = new OscillatorNode(context, { type: 'sawtooth' });
lfo = new OscillatorNode(context, { frequency: rateValue });
// Connect nodes for original sound
// OscillatorNode (Input) -> GainNode (Dry) -> AudioDestinationNode (Output)
oscillator.connect(dry);
dry.connect(context.destination);
// Connect nodes for shifting phase
// OscillatorNode (Input) -> BiquadFilterNode (All-Pass Filter) x N -> GainNode (Wet) -> AudioDestinationNode (Output)
oscillator.connect(allpasses[0]);
for (let i = 0; i < (numberOfStages - 1); i++) {
allpasses[i].connect(allpasses[i + 1]);
}
allpasses[numberOfStages - 1].connect(wet);
wet.connect(context.destination);
// Connect nodes for LFO that changes All-Pass Filter frequency periodically
// OscillatorNode (LFO) -> GainNode (Depth) -> frequency (AudioParam)
lfo.connect(depth);
for (let i = 0; i < numberOfStages; i++) {
depth.connect(allpasses[i].frequency);
}
// Start oscillator and LFO immediately
oscillator.start(0);
lfo.start(0);
buttonElement.textContent = 'stop';
});
buttonElement.addEventListener('mouseup', (event) => {
if ((oscillator === null) || (lfo === null)) {
return;
}
// Stop immediately
oscillator.stop(0);
lfo.stop(0);
// GC (Garbage Collection)
oscillator = null;
lfo = null;
buttonElement.textContent = 'start';
});
selectPhaserStagesElement.addEventListener('change', (event) => {
numberOfStages = Number(event.currentTarget.value);
for (let i = 0, len = allpasses.length; i < len; i++) {
allpasses[i].disconnect(0);
}
for (let i = 0; i < numberOfStages; i++) {
allpasses[i] = new BiquadFilterNode(context, { type: 'allpass', frequency: baseFrequency });
}
if (oscillator !== null) {
oscillator.connect(allpasses[0]);
}
for (let i = 0; i < (numberOfStages - 1); i++) {
allpasses[i].connect(allpasses[i + 1]);
}
allpasses[numberOfStages - 1].connect(wet);
wet.connect(context.destination);
for (let i = 0; i < numberOfStages; i++) {
depth.connect(allpasses[i].frequency);
}
});
rangeFrequencyElement.addEventListener('input', (event) => {
baseFrequency = event.currentTarget.valueAsNumber;
for (let i = 0; i < numberOfStages; i++) {
allpasses[i].frequency.value = baseFrequency;
}
spanPrintFrequencyElement.textContent = `${Math.trunc(baseFrequency)} Hz`;
});
rangeDepthElement.addEventListener('input', (event) => {
depthRate = event.currentTarget.valueAsNumber;
depth.gain.value = baseFrequency * depthRate;
spanPrintDepthElement.textContent = depthRate.toString(10);
});
rangeRateElement.addEventListener('input', (event) => {
rateValue = event.currentTarget.valueAsNumber;
if (lfo) {
lfo.frequency.value = rateValue;
}
spanPrintRateElement.textContent = rateValue.toString(10);
});
rangeResonanceElement.addEventListener('input', (event) => {
resonance = event.currentTarget.valueAsNumber;
for (let i = 0; i < numberOfStages; i++) {
allpasses[i].Q.value = resonance;
}
spanPrintResonanceElement.textContent = resonance.toString(10);
});
rangeMixElement.addEventListener('input', (event) => {
mixValue = event.currentTarget.valueAsNumber;
dry.gain.value = 1 - mixValue;
wet.gain.value = mixValue;
spanPrintMixElement.textContent = mixValue.toString(10);
});
トレモロ・リングモジュレーター
トレモロ は, 音の大きさに揺らぎを与えるエフェクターです. ギターでは素早くピッキングを繰り返して,
音が震えているように奏でるトレモロ奏法があります. トレモロは, トレモロ奏法をオーディオ信号処理によって実現するエフェクターとも言えます (ギターでは,
もう 1 つトレモロという名称がついているものがあります. ストラトキャスター (タイプ) のギターに搭載されているトレモロアームです. しかし,
トレモロアームは, ビブラートの効果を与えるものなので, 同じトレモロという名称ですが, 効果としては別なものです).
リングモジュレーター は, 金属的な音色に変化させるエフェクトです. 例えば, ピアノにリングモジュレーターをかけると, 鐘のような音色に変化します.
エフェクターとして, トレモロとリングモジュレーターはかなり感じが違いますが, 原理は共通しています. その原理とは,
AM 変調 を利用したエフェクターであることです.
トレモロ
トレモロは振幅 (音の大きさ) を周期的に変化させることによって, 実装することができます. つまり, LFO を, GainNode
の
gain
プロパティ (AudioParam
) に接続することによって実装できます. トレモロは, これまで解説したディレイ・リバーブ,
コーラス・フランジャー, フェイザーなどと異なり, 原音を変化させるので, AudioNode
の接続も実装も非常にシンプルです (トレモロのようのな,
原音を直接変化させるエフェクターをインサートエフェクト と呼ぶことがあります).
トレモロのノード接続図
const context = new AudioContext();
const depthValue = 0.25;
const rateValue = 2.5;
const amplitude = new GainNode(context, { gain: 0.5 }); // 0.5 +- ${depthValue}
const oscillator = new OscillatorNode(context);
const lfo = new OscillatorNode(context, { frequency: rateValue });
const depth = new GainNode(context, { gain: depthValue });
// Connect nodes
// OscillatorNode (Input) -> GainNode (Amplitude) -> AudioDestinationNode (Output)
oscillator.connect(amplitude);
amplitude.connect(context.destination);
// Connect nodes for LFO that changes gain periodically
// OscillatorNode (LFO) -> GainNode (Depth) -> gain (AudioParam)
lfo.connect(depth);
depth.connect(amplitude.gain);
// Start oscillator and LFO
oscillator.start(0);
lfo.start(0);
// Stop oscillator and LFO
oscillator.stop(context.currentTime + 5);
lfo.stop(context.currentTime + 5);
トレモロは, 振幅 1
を基準に値が変化するように定義されています ($f_{s}$ はサンプリング周波数).
しかしながら, そのまま実装すると, 実際には音割れ (クリッピング) が発生してしまうので, 実装では, 0.5
を基準に, Depth
の値が増減するようにしています.
$y\left(n\right) = \left(1 + \mathrm{depth} \cdot \sin\left(\frac{2\pi \cdot \mathrm{rate} \cdot n}{f_{s}}\right)\right) \cdot x\left(n\right)$
以下は, 実際のアプリケーションを想定して, ユーザーインタラクティブに, トレモロに関わるパラメータを制御できるようにしたコード例です.
トレモロのような (他には, コンプレッサーやディストーションなど) インサートエフェクトでは, パラメータの制御のみでエフェクターを OFF
にする場合が難しい場合もあるので, コード例のように, フラグなどに応じて, AudioNode
の接続自体を切り替えるような実装が必要になります
(もっとも, トレモロの場合, Depth を 0
に設定することで, 原音をそのまま出力することが可能です).
<button type="button">start</button>
<label>
<input type="checkbox" id="checkbox-tremolo" checked />
<span id="print-checked-tremolo">ON</span>
</label>
<label for="range-tremolo-depth">Depth</label>
<input type="range" id="range-tremolo-depth" value="0" min="0" max="1" step="0.05" />
<span id="print-tremolo-depth-value">0</span>
<label for="range-tremolo-rate">Rate</label>
<input type="range" id="range-tremolo-rate" value="0" min="0" max="10" step="0.5" />
<span id="print-tremolo-rate-value">0</span>
const context = new AudioContext();
let depthRate = 0;
let rateValue = 0;
let oscillator = new OscillatorNode(context);
let lfo = new OscillatorNode(context, { frequency: rateValue });
let isStop = true;
const amplitude = new GainNode(context, { gain: 0.5 }); // 0.5 +- ${depthValue}
const depth = new GainNode(context, { gain: amplitude.gain.value * depthRate });
const buttonElement = document.querySelector('button[type="button"]');
const checkboxElement = document.querySelector('input[type="checkbox"]');
const rangeDepthElement = document.getElementById('range-tremolo-depth');
const rangeRateElement = document.getElementById('range-tremolo-rate');
const spanPrintCheckedElement = document.getElementById('print-checked-tremolo');
const spanPrintDepthElement = document.getElementById('print-tremolo-depth-value');
const spanPrintRateElement = document.getElementById('print-tremolo-rate-value');
checkboxElement.addEventListener('click', () => {
oscillator.disconnect(0);
amplitude.disconnect(0);
lfo.disconnect(0);
if (checkboxElement.checked) {
// Connect nodes
// OscillatorNode (Input) -> GainNode (Amplitude) -> AudioDestinationNode (Output)
oscillator.connect(amplitude);
amplitude.connect(context.destination);
// Connect nodes for LFO that changes gain periodically
// OscillatorNode (LFO) -> GainNode (Depth) -> gain (AudioParam)
lfo.connect(depth);
depth.connect(amplitude.gain);
spanPrintCheckedElement.textContent = 'ON';
} else {
// OscillatorNode (Input) -> AudioDestinationNode (Output)
oscillator.connect(context.destination);
spanPrintCheckedElement.textContent = 'OFF';
}
});
buttonElement.addEventListener('mousedown', () => {
if (!isStop) {
return;
}
if (checkboxElement.checked) {
// Connect nodes
// OscillatorNode (Input) -> GainNode (Amplitude) -> AudioDestinationNode (Output)
oscillator.connect(amplitude);
amplitude.connect(context.destination);
// Start oscillator
oscillator.start(0);
} else {
amplitude.disconnect(0);
// Connect nodes (Tremolo OFF)
// OscillatorNode (Input) -> AudioDestinationNode (Output)
oscillator.connect(context.destination);
// Start oscillator
oscillator.start(0);
}
// Connect nodes for LFO that changes gain periodically
// OscillatorNode (LFO) -> GainNode (Depth) -> gain (AudioParam)
lfo.connect(depth);
depth.connect(amplitude.gain);
lfo.start(0);
isStop = false;
buttonElement.textContent = 'stop';
});
buttonElement.addEventListener('mouseup', () => {
if (isStop) {
return;
}
// Stop immediately
oscillator.stop(0);
lfo.stop(0);
oscillator = new OscillatorNode(context);
lfo = new OscillatorNode(context, { frequency: rateValue });
isStop = true;
buttonElement.textContent = 'start';
});
rangeDepthElement.addEventListener('input', (event) => {
depthRate = event.currentTarget.valueAsNumber;
depth.gain.value = amplitude.gain.value * depthRate;
spanPrintDepthElement.textContent = depthRate.toString(10);
});
rangeRateElement.addEventListener('input', (event) => {
rateValue = event.currentTarget.valueAsNumber;
if (lfo) {
lfo.frequency.value = rateValue;
}
spanPrintRateElement.textContent = rateValue.toString(10);
});
リンクモジュレーター
リングモジュレーター は, AudioNode
の接続としてはトレモロと同じです. LFO の Rate (変調の周波数)を, およそ
100 Hz
以上にしていくと, 原音の周波数成分とは異なる周波数成分が発生するようになります.
この周波数成分が金属的な音を生み出す要因となって, 原理は同じながらも, トレモロとは異なるエフェクトを得ることができます.
リングモジュレーターのノード接続図
リングモジュレーターは, 原音の振幅を正弦波で変調するように定義されているので, トレモロと異なり, 基準となる gain
プロパティの値は
0
を設定しています.
$y\left(n\right) = \left(\mathrm{depth} \cdot \sin\left(\frac{2\pi \cdot \mathrm{rate} \cdot n}{f_{s}}\right)\right) \cdot x\left(n\right)$
以下は, 同様に実際のアプリケーションを想定して, ユーザーインタラクティブに,
リングモジュレーターに関わるパラメータを制御できるようにしたコード例です. 定義式にしたがって, 基準となる gain
プロパティの値を
0
にしていること, また, Rate がトレモロより高い値に設定できるようにしていることに着目してください.
<button type="button">start</button>
<label>
<input type="checkbox" id="checkbox-ringmodulator" checked />
<span id="print-checked-ringmodulator">ON</span>
</label>
<label for="range-ringmodulator-depth">Depth</label>
<input type="range" id="range-ringmodulator-depth" value="1" min="0" max="1" step="0.05" />
<span id="print-ringmodulator-depth-value">1</span>
<label for="range-ringmodulator-rate">Rate</label>
<input type="range" id="range-ringmodulator-rate" value="1000" min="0" max="2000" step="100" />
<span id="print-ringmodulator-rate-value">1000</span>
const context = new AudioContext();
let depthRate = 1;
let rateValue = 1000;
let oscillator = new OscillatorNode(context);
let lfo = new OscillatorNode(context, { frequency: rateValue });
let isStop = true;
const amplitude = new GainNode(context, { gain: 0 }); // 0 +- ${depthValue}
const depth = new GainNode(context, { gain: depthRate });
const buttonElement = document.querySelector('button[type="button"]');
const checkboxElement = document.querySelector('input[type="checkbox"]');
const rangeDepthElement = document.getElementById('range-ringmodulator-depth');
const rangeRateElement = document.getElementById('range-ringmodulator-rate');
const spanPrintCheckedElement = document.getElementById('print-checked-ringmodulator');
const spanPrintDepthElement = document.getElementById('print-ringmodulator-depth-value');
const spanPrintRateElement = document.getElementById('print-ringmodulator-rate-value');
checkboxElement.addEventListener('click', () => {
oscillator.disconnect(0);
amplitude.disconnect(0);
lfo.disconnect(0);
if (checkboxElement.checked) {
// Connect nodes
// OscillatorNode (Input) -> GainNode (Amplitude) -> AudioDestinationNode (Output)
oscillator.connect(amplitude);
amplitude.connect(context.destination);
// Connect nodes for LFO that changes gain periodically
// OscillatorNode (LFO) -> GainNode (Depth) -> gain (AudioParam)
lfo.connect(depth);
depth.connect(amplitude.gain);
spanPrintCheckedElement.textContent = 'ON';
} else {
// OscillatorNode (Input) -> AudioDestinationNode (Output)
oscillator.connect(context.destination);
spanPrintCheckedElement.textContent = 'OFF';
}
});
buttonElement.addEventListener('mousedown', () => {
if (!isStop) {
return;
}
if (checkboxElement.checked) {
// Connect nodes
// OscillatorNode (Input) -> GainNode (Amplitude) -> AudioDestinationNode (Output)
oscillator.connect(amplitude);
amplitude.connect(context.destination);
// Start oscillator
oscillator.start(0);
} else {
amplitude.disconnect(0);
// Connect nodes (Ring Modulator OFF)
// OscillatorNode (Input) -> AudioDestinationNode (Output)
oscillator.connect(context.destination);
// Start oscillator
oscillator.start(0);
}
// Connect nodes for LFO that changes gain periodically
// OscillatorNode (LFO) -> GainNode (Depth) -> gain (AudioParam)
lfo.connect(depth);
depth.connect(amplitude.gain);
lfo.start(0);
isStop = false;
buttonElement.textContent = 'stop';
});
buttonElement.addEventListener('mouseup', () => {
if (isStop) {
return;
}
// Stop immediately
oscillator.stop(0);
lfo.stop(0);
oscillator = new OscillatorNode(context);
lfo = new OscillatorNode(context, { frequency: rateValue });
isStop = true;
buttonElement.textContent = 'start';
});
rangeDepthElement.addEventListener('input', (event) => {
depthRate = event.currentTarget.valueAsNumber;
depth.gain.value = depthRate;
spanPrintDepthElement.textContent = depthRate.toString(10);
});
rangeRateElement.addEventListener('input', (event) => {
rateValue = event.currentTarget.valueAsNumber;
if (lfo) {
lfo.frequency.value = rateValue;
}
spanPrintRateElement.textContent = rateValue.toString(10);
});
AM 変調
AM 変調 (Amplitude Modulation ) とは, 時間の経過とともに信号の振幅を変化させることです.
Time Domain
Frequency Domain (Spectrum)
AM 変調のイメージ
start
Rate
1 Hz
変調の周期を短くしていくと (LFO の Rate を高くしていくと), 原音の周波数成分だけではなく, LFO の周波数も周波数成分として発生します. これは,
原音の波形がキャリア (搬送波 ) となって, LFO の正弦波がモジュレーター となってエンベロープを形成して周波数成分となるからです
(出力音のエンベロープが正弦波になっていることに着目してください).
この仕組みを発展させた音合成が, FM シンセサイザー (FM 音源) で, キャリア (搬送波) とモジュレーターの波形を正弦波として,
それらを合成する正弦波によって定義されます ($A$ はキャリアの振幅,
$f_{c}$ はキャリアの周波数, $\beta$ は変調指数 (LFO の Depth に相当),
$f_{m}$ はモジュレーターの周波数 (LFO の Rate に相当)).
$y\left(n\right) = A \cdot \sin\left(\frac{2\pi \cdot f_{c} \cdot n}{f_{s}} + \left(\beta \cdot \sin\left(\frac{2\pi \cdot f_{m} \cdot
n}{f_{s}}\right)\right)\right)$
(命名的に混同しますが) リングモジュレーター自体の原理は AM 変調であり, それが, FM シンセサイザーの原理になっているということです.
フィルタ
フィルタという言葉は日常生活でも使われますし, コンピューターサイエンスにおいても, UNIX 系 OS でパイプとフィルタがあります. フィルタの概念としては,
ある結果を遮断して, ある結果を通過させるということでしょう.
オーディオ信号処理におけるフィルタ も同様に, ある周波数成分の音を通過・遮断, あるいは, 増幅・減衰させて, 周波数特性を変化させます.
フィルタだけをエフェクターとして使うことはあまりなく, すでに解説したフェイザーや, このあとのセクションで解説する,
イコライザーやワウなどフィルタ系のエフェクターで使われることが多いです.
また, 音響特徴量はスペクトル, つまり, 周波数成分として表れることが多いので, 音の加工においてもフィルタを理解することは重要となります.
デシベル
フィルタの解説において, 仕様上, デシベル (dB ) という単位が使われるので (BiquadFilterNode
の
gain
プロパティやフィルタの特性グラフなど), フィルタに限ったことではないですが, ここで解説をします.
デシベルとは, 端的には, 音圧レベル を表す単位です. 音圧 とは, 音の実体である媒体の振動によって伝わる圧力 (力) のことです.
ここで, 基準の音圧 ($P_{0} = 2 \cdot 10^{-5} \mathrm{[Pa]}$ ) を基準 (ちなみに, この基準の音圧は,
1 KHz
における可聴な最小の音圧とされています) に, 対象の音の音圧 $P$ の比率の対数をとった値
(以下の定義式. 上は時間領域, 下は周波数領域での定義式. 多くのケースにおいて, 音圧パワー (音圧の 2 乗) の比を算出することが有用なので,
各音圧の 2 乗の比で定義しています) が音圧レベルとなります.
$
\begin{flalign}
&10\log_{10}\left(\frac{P}{P_{0}}\right)^{2} = 20\log_{10}\left(\frac{P}{P_{0}}\right) \quad \left(P_{0} = 2 \cdot 10^{-5} \mathrm{[Pa]}\right) \\
& \\
&10\log_{10}\left|X\left(k\right)\right|^{2} = 20\log_{10}\left|X\left(k\right)\right| \\
& \\
\end{flalign}
$
対数で表す理由は大きく 2 つあります.
音圧は非常に広範囲な値となるので, 人間の感覚とうまく対応ない
フェヒナーの法則 という心理学の理論で, 人間の感覚量は刺激強度の対数に比例する という法則が適用できる
(以下の表を参考にして) 6 dB
音圧レベルが大きくなれば音圧は約 2 倍 ($20\log_{10}2$ ) になります.
20 dB
が大きくなれば 10 倍 ($20\log_{10}10$ ), 40 dB
大きくなれば 100 倍 ($20\log_{10}100$ ) ... という関係で, 本来であれば広範囲におよぶ値を対数をとることによって解決しています.
デシベル差と倍率
Difference decibel
Magnification
Example
0 dB
1 倍
人間の聴力の限界
6 dB
2 倍
10 dB
3 倍
20 dB
10 倍
木の葉のふれあう音
40 dB
100 倍
図書館
60 dB
1,000 倍
会話
80 dB
10,000 倍
目覚まし時計
100 dB
100,000 倍
電車のガード下
120 dB
1,000,000 倍
飛行機のエンジン付近
フィルタの特性を表すグラフでは, 0 dB
を基準に見ていただくのがよいのですが, これは,
0 dB
が入力音と出力音の振幅比が変わらない (つまり, そのまま通過させる) ことを意味しているからです ($20\log_{10}1 = 0$ ). これを理解しておくと, BiquadFilterNode
の gain
プロパティが (特定のフィルタの種類において)
ある周波数成分を増幅させたり, 減衰させたりすることも理解できると思います.
BiquadFilterNode
Web Audio API において, 様々なフィルタを簡単に利用するには BiquadFilterNode
を使うのが最適です. Biquad とは,
双 2 次 という意味で, BiquadFilterNode
の次数は 2 次となります (以下の伝達関数で定義されています). したがって,
BiquadFilterNode
では実装できないフィルタ, 具体的には, 奇数次
のフィルタを使いたい場合, あとのセクションで解説する
IIRFilterNode
を使う必要があります (BiquadFilterNode
は 2 次の IIR フィルタです).
BiquadFilterNode
では, フィルタの特性に関わるプロパティとして, type
プロパティ (BiquadFilterType
), frequency
/ detune
プロパティ (どちらも AudioParam
), Q
プロパティ
(AudioParam
), gain
プロパティ (AudioParam
) が定義されています. type
プロパティ以外は,
type
プロパティ (すなわち, フィルタの種類) によって, 制御するフィルタの特性が異なったり, あるいは, そもそも無効だったりするので,
BiquadFilterNode
で使える 8 つのフィルタの種類ごとに解説を進めます.
フィルタの種類に関わらず, BiquadFilterNode
は, そのインスタンスを接続するだけで機能します (また, コンストラクタ形式であれば,
インスタンス生成時に, 第 2 引数に BiquadFilterOptions を指定して, 初期値を変更することも可能です).
const context = new AudioContext();
const oscillator = new OscillatorNode(context, { type: 'sawtooth' });
const filter = new BiquadFilterNode(context);
// If use `createBiquadFilter`
// const filter = context.createBiquadFilter();
// OscillatorNode (Input) -> BiquadFilterNode -> AudioDestinationNode (Output)
oscillator.connect(filter);
filter.connect(context.destination);
oscillator.start(0);
frequency / detune プロパティ
フィルタの種類に関わらず, frequency
/ detune
プロパティはすべてのフィルタにおいて有効になります (ただし,
フィルタの特性への影響はフィルタの種類ごとに異なります). OscillatorNode
などと同じように, frequency
プロパティと
detune
プロパティを合わせて算出される周波数 ($f_{\mathrm{computed}}\left(t\right)$ )
は以下のように定義されています.
$f_{\mathrm{computed}}\left(t\right) = \mathrm{frequency}\left(t\right) \cdot \mathrm{pow}\left(2, \left(\mathrm{detune}\left(t\right) / 1200
\right)\right)$
frequency
/ detune
プロパティ, Q
プロパティ, gain
プロパティは, すべて
AudioParam
なので, オートメーションさせたり, LFO を接続したりすることが可能です.
BiquadFilterNode の定義式
時間領域での BiquadFilterNode
の定義式 (2 次の IIR フィルタ) は, 以下のように定義されています.
$a_{0}y\left(n\right) + a_{1}y\left(n - 1\right) + a_{2}y\left(n - 2\right) = b_{0}x\left(n\right) + b_{1}x\left(n - 1\right) + b_{2}x\left(n -
2\right)$
これを $z$ 変換すると, 伝達関数 (周波数領域での BiquadFilterNode
の定義式) は,
以下のように定義されます (時間領域の遅延は, $z$ 変換の次数となります).
$
\begin{flalign}
&H\left(z\right) = \frac{\frac{b_{0}}{a_{0}} + \frac{b_{1}}{a_{0}}z^{-1} + \frac{b_{2}}{a_{0}}z^{-2}}{1 + \frac{a_{1}}{a_{0}}z^{-1} + \frac{a_{2}}{a_{0}}z^{-2}}
\end{flalign}
$
Biquad Filter (双 2 次フィルタ) はオーディオ信号処理で頻繁に利用される, Robert Bristow-Johnson 氏が書いた有名な設計手法の解説, Audio-EQ-Cookbook
というのがあり,
W3C のドキュメント としても公開されています. Web
Audio API の BiquadFilterNode
も Audio EQ Cookbook をベースにした実装になっています (厳密には, 多少の改変がされています).
IIR フィルタに関してはあとのセクションで解説します.
Low-Pass Filter
Low-Pass Filter (低域通過フィルタ ) とは, カットオフ周波数 ($f_{\mathrm{computed}}$ )
付近までの周波数成分を通過させ, それより大きい周波数成分を遮断するフィルタです. すでに解説しましたが, サンプリング定理のために, A/D 変換や D/A
変換で使われたり, エフェクターのワウで使われたり, エフェクト音のトーンを設定したり, おそらく最も使用頻度の高いフィルタになります
(おそらくその理由で, デフォルト値になっていると思われます).
Low-Pass Filter における, Q
プロパティ (クオリティファクタ , または, レゾナンス と呼ばれることが多いです) は,
カットオフ周波数付近の急峻を変化させます. 正の値にすると, 急峻が鋭くなって, カットオフ周波数付近の周波数成分を増幅させます (これは,
ワウの実装において重要になる点です). 負の値を設定すると, カットオフ周波数付近の周波数成分を減衰させるフィルタ特性になります.
Low-Pass Filter においては, gain
プロパティは無効で, フィルタ特性に影響を与えることはありません.
frequency
350 Hz
detune
0 cent
Q
1 dB
Low-Pass Filter のフィルタ特性
High-Pass Filter
High-Pass Filter (高域通過フィルタ ) とは, Low-Pass Filter と逆で, カットオフ周波数 ($f_{\mathrm{computed}}$ ) 付近までの周波数成分を遮断して, それより大きい周波数成分を通過させるフィルタです. Low-Pass Filter と比較すると, 使用頻度は低いですが,
プリアンプ (アンプシミュレーター) や歪み系のエフェクターの実装では重要なフィルタとなります.
High-Pass Filter における, Q
プロパティは, Low-Pass Filter と同様に, カットオフ周波数付近の急峻を変化させます.
正の値にすると, 急峻が鋭くなり, カットオフ周波数付近の周波数成分を増幅させます. 負の値を設定すると,
カットオフ周波数付近の周波数成分を減衰させるフィルタ特性になります.
High-Pass Filter においても, gain
プロパティは無効で, フィルタ特性に影響を与えることはありません.
frequency
350 Hz
detune
0 cent
Q
1 dB
High-Pass Filter のフィルタ特性
Low-Shelving Filter
Low-Shelving Filter とは, カットオフ周波数 ($f_{\mathrm{computed}}$ ) 付近までの周波数成分を増幅,
または, 減衰させ, それより大きい周波数成分をそのまま通過させるフィルタです. Low-Shelving Filter における, gain
プロパティが,
増幅, または, 減衰の値を決定します. 単位は, デシベル (dB
) です.
Low-Shelving Filter においては, Q
プロパティは無効で, フィルタ特性に影響を与えることはありません (一般的な, Biquad Filter
においては, フィルタ特性に影響しますが, Web Audio API の BiquadFilterNode
でやや実装が改変されている点の 1 つです).
frequency
350 Hz
detune
0 cent
gain
0 dB
Low-Shelving Filter のフィルタ特性
High-Shelving Filter
High-Shelving Filter とは, カットオフ周波数 ($f_{\mathrm{computed}}$ )
付近までの周波数成分をそのまま通過させ, それより大きい周波数成分を増幅, または, 減衰させるフィルタです. High-Shelving Filter における,
gain
プロパティが, 増幅, または, 減衰の値を決定します. 単位は, デシベル (dB
) です.
High-Shelving Filter においても, Q
プロパティは無効で, フィルタ特性に影響を与えることはありません (一般的な, Biquad Filter
においては, フィルタ特性に影響しますが, Web Audio API の BiquadFilterNode
でやや実装が改変されている点の 1 つです).
frequency
350 Hz
detune
0 cent
gain
0 dB
High-Shelving Filter のフィルタ特性
Peaking Filter
Peaking Filter とは, 中心周波数 ($f_{\mathrm{computed}}$ ) 付近の周波数成分を増幅, または,
減衰させ, それ以外の周波数成分をそのまま通過させるフィルタです. Peaking Filter における, gain
プロパティが, 増幅, または,
減衰の値を決定します. 単位は, デシベル (dB
) です.
Peaking Filter における, Q
プロパティは, Band-Pass Filter と同様に, 中心周波数を基準にした帯域幅に影響を与えます.
Q
プロパティの値を大きくするほど, 中心周波数付近の帯域幅が狭くなります (急峻になります).
0 以下の値を設定すると, 中心周波数として機能しなくなるので, 正の値を指定するようにします .
frequency
350 Hz
detune
0 cent
Q
1
gain
0 dB
Peaking Filter のフィルタ特性
Notch Filter
Notch Filter (帯域除去フィルタ ) とは, 中心周波数 ($f_{\mathrm{computed}}$ )
付近の周波数成分を遮断して, それ以外の周波数成分を通過させるフィルタです. (厳密には, その定義が異なる点はありますが)
Band-Elimination Filter (帯域阻止フィルタ ) と呼ばれることもあります. また, 実装的には, Low-Pass Filter と High-Pass Filter
を組み合わせることでも実装は可能です.
Notch Filter における, Q
プロパティは, Band-Pass Filter と同様に, 中心周波数を基準にした帯域幅に影響を与えます.
Q
プロパティの値を大きくするほど, 中心周波数付近の帯域幅が狭くなります (急峻になります).
0 以下の値を設定すると, 中心周波数として機能しなくなるので, 正の値を指定するようにします .
対となる Band-Pass Filter と比較すると, 同じ Q
プロパティの値でも, 中心周波数付近の帯域幅が狭くなっています.
Notch Filter においても, gain
プロパティは無効で, フィルタ特性に影響を与えることはありません.
frequency
350 Hz
detune
0 cent
Q
1
Notch Filter のフィルタ特性
All-Pass Filter
All-Pass Filter (全域通過フィルタ ) とは, 振幅特性は変化させずに, 中心周波数 ($f_{\mathrm{computed}}$ ) 付近の周波数成分の位相特性を変化させるフィルタです . したがって, フィルタ特性のグラフも, All-Pass Filter のみは,
位相スペクトル (縦軸が, 位相で単位は radian
) となっています (振幅特性が変わらないので, 振幅スペクトルで表示すると,
パラメータを変化させてもフィルタ特性は変わりません).
中心周波数では, $\pm \pi$ で最も位相が変化し ($\pm \pi$ 位相変化すると,
逆位相となります), 中心周波数から離れる周波数成分ほど, ほとんど位相は変化しなくなります.
All-Pass Filter における, Q
プロパティは, 中心周波数付近の急峻に影響を与えます.
Q
プロパティの値を大きくするほど, 中心周波数付近のフィルタ特性が急峻になって,
それ以外の周波数成分の位相特性に影響を与えなくなります. つまり, 位相特性を変化させる周波数帯域をより狭くします.
0 以下の値を設定すると, 中心周波数として機能しなくなるので, 正の値を指定するようにします .
All-Pass Filter においては, gain
プロパティは無効で, フィルタ特性に影響を与えることはありません.
frequency
350 Hz
detune
0 cent
Q
1
All-Pass Filter のフィルタ特性 (位相スペクトル)
IIRFilterNode
BiquadFilterNode
では実装できない IIR フィルタを実装する場合, 次の手段としては,
IIRFilterNode
クラスを利用することです (最後の手段は, AudioWorklet で実装することです).
IIRFilterNode
では, BiquadFilterNode
でフィルタの特性に影響を与えていた, frequency
プロパティや
Q
プロパティ, gain
プロパティなどは, リアルタイムに変化させることができなくなる点には注意してください.
IIRFilterNode
に与えるパラメータは, AudioParam
ではないからです.
実装としては, IIRFilterNode
コンストラクタの第 2 引数に, IIRFilterOptions
として,
フィルタの係数の配列を設定します. IIRFilterOptions
オブジェクトの feedforward
プロパティは,
IIR フィルタの伝達関数の分子となる係数 (以下の伝達関数の $b_{m}$ ),
feedback
プロパティは, IIR フィルタの伝達関数の分母となる係数 (以下の伝達関数の
$a_{n}$ ) をそれぞれ設定します (ファクトリメソッドの場合, 第 1 引数に feedforward
, 第 2 引数に
feedback
を指定します). IIRFilterNode
の伝達関数は以下の定義式となります.
BiquadFilterNode
の伝達関数と異なり, フィルタの次数を自由に設定できる点に着目してください.
$
\begin{flalign}
&H\left(z\right) = \frac{\sum_{m=0}^{M}b_{m}z^{-m}}{\sum_{n=0}^{N}a_{n}z^{-n}}
\end{flalign}
$
ただし, まったく制約がないわけではなく, 0 次のフィルタはエラーとなります (それ以外にも, $a_{0}$ は,
0
以外の値である必要があったり, 係数がすべて 0
の feedforward
はエラーとなったりします). また, 実装上,
20 次までのフィルタが上限となります .
簡易的ではありますが, 1 次の IIR フィルタによる, Low-Pass Filter と High-Pass Filter の実装例です.
const context = new AudioContext();
const cutoff = 1000; // 1000 Hz
const b = (cutoff / context.sampleRate) * Math.PI;
const b0 = b;
const b1 = b;
const a0 = 1 + b;
const a1 = -1 + b;
const feedforward = new Float64Array([b0, b1]);
const feedback = new Float64Array([a0, a1]);
const oscillator = new OscillatorNode(context, { type: 'sawtooth' });
const filter = new IIRFilterNode(context, { feedforward, feedback });
// If use `createIIRFilter`
// const filter = context.createIIRFilter(feedforward, feedback);
// OscillatorNode (Input) -> IIRFilterNode (Low-Pass Filter) -> AudioDestinationNode (Output)
oscillator.connect(filter);
filter.connect(context.destination);
oscillator.start(0);
const context = new AudioContext();
const cutoff = 4000; // 4000 Hz
const a = (cutoff / context.sampleRate) * Math.PI;
const b0 = 1;
const b1 = -1;
const a0 = 1 + a;
const a1 = -1 + a;
const feedforward = new Float64Array([b0, b1]);
const feedback = new Float64Array([a0, a1]);
const oscillator = new OscillatorNode(context, { type: 'sawtooth' });
const filter = new IIRFilterNode(context, { feedforward, feedback });
// If use `createIIRFilter`
// const filter = context.createIIRFilter(feedforward, feedback);
// OscillatorNode (Input) -> IIRFilterNode (High-Pass Filter) -> AudioDestinationNode (Output)
oscillator.connect(filter);
filter.connect(context.destination);
oscillator.start(0);
IIR フィルタ
IIR フィルタ (Infinite Impulse Response filter ) は, 以下の数式で定義されるデジタルフィルタです.
$
\begin{flalign}
&y\left(n\right) = \sum_{m = 0}^{J}b\left(m\right)x\left(n - m\right) - \sum_{m = 1}^{I}a\left(m\right)y\left(n - m\right)
\end{flalign}
$
フィルタを通過した音が再度フィルタを通ることになる, フィードバックがある ことが, FIR フィルタと大きく異なる点です.
定義式上は無限にインパルス応答が続くことになるので, Infinite (無限の) と命名されています (もちろん,
コンピュータでは無限のフィルタを実装することはできないので, 有限の次数でうちきる必要があります. IIRFilterNode
の場合, その上限が
20
ということです). また, フィードバックの項 ($a\left(m\right)$ ) は,
$m = 1$ から始まっている点に着目してください.
フィードバックがある利点は, 次数の低いフィルタでも性能のよいフィルタ, つまり, 通過する周波数成分と遮断する周波数成分を可能な限りはっきりと分ける
(理想フィルタ に近づける) フィルタが低次数で実装できます (実際, BiquadFilterNode
の次数は 2 次です).
具体的に, 2 次の IIR フィルタ ($J = I = 2$ ) を加算器・乗算器・遅延器の要素を利用してブロック図として表現します.
IIR フィルタ
理想フィルタと遷移帯域幅
アナログフィルタでは, カットオフ周波数や中心周波数を境に, 通過する周波数成分と阻止される周波数成分がはっきりと分離されます. しかし,
BiquadFilterNode
のセクションで表示しているフィルタ特性のように, デジタルフィルタにおいては, はっきりと分離されることなく,
曖昧な帯域が存在することがわかるかと思います. これは, アナログフィルタにおいては, 無限の区間で定義されるフィルタを,
コンピュータでは無限の区間をあつかうことはできないので, 有限の区間でうちきる必要があるからです. その処理によって,
どうしても曖昧な帯域が発生してしまいます. これを遷移帯域幅 と言います. つまり, デジタルフィルタで, アナログフィルタのような,
遷移帯域幅のない理想フィルタ を実装することはコンピュータの原理上, 不可能となります. しかしながら, 可能な限り遷移帯域幅を小さくして,
アナログフィルタ (理想フィルタ) に近づけることは可能であり,
理想フィルタに近いフィルタが, デジタルフィルタにおいて性能のよいフィルタの重要な指標となります . そして, IIR フィルタは低次数で,
性能のよいフィルタ, すなわち, 理想フィルタに近いフィルタを実装することが可能です.
FIR フィルタと IIR フィルタの伝達関数
これまで, 伝達関数 という用語をそれとなく使っていましたが, ここで詳細を解説します. 伝達関数とは, その名のとおり,
伝わりやすさ を数学の関数として定義したものです. オーディオ信号処理に限定して定義すると,
入力音のスペクトルに対する出力音のスペクトルの比の関数 で定義できます.
FIR フィルタの場合, $x\left(n\right)$ , $y\left(n\right)$ ,
$b\left(m\right)$ をそれぞれ離散フーリエ変換した関数を $X\left(k\right)$ ,
$Y\left(k\right)$ , $H\left(k\right)$ とすると,
$
\begin{flalign}
&y\left(n\right) = \sum_{m = 0}^{N}b\left(m\right)x\left(n - m\right)
\end{flalign}
$
時間領域でのコンボリューション積分は周波数領域では乗算となるので,
$
\begin{flalign}
&Y\left(k\right) = H\left(k\right)X\left(k\right)
\end{flalign}
$
$H\left(k\right)$ が伝達関数となるので, 式を変形すると, 入出力スペクトルの比になることがわかります.
$
\begin{flalign}
&H\left(k\right) = \frac{Y\left(k\right)}{X\left(k\right)}
\end{flalign}
$
IIR フィルタも同様に, $x\left(n\right)$ , $y\left(n\right)$ ,
$b\left(m\right)$ , $a\left(m\right)$ をそれぞれ離散フーリエ変換した関数を
$X\left(k\right)$ , $Y\left(k\right)$ ,
$B\left(k\right)$ , $A\left(k\right)$ とすると,
$
\begin{flalign}
&y\left(n\right) = \sum_{m = 0}^{J}b\left(m\right)x\left(n - m\right) - \sum_{m = 1}^{I}a\left(m\right)y\left(n - m\right)
\end{flalign}
$
時間領域でのコンボリューション積分は周波数領域では乗算となるので,
$
\begin{flalign}
&Y\left(k\right) = B\left(k\right)X\left(k\right) - A\left(k\right)Y\left(k\right)
\end{flalign}
$
ここで, IIR フィルタの伝達関数を $H\left(k\right)$ として, 式変形すると,
$
\begin{flalign}
&H\left(k\right) = \frac{Y\left(k\right)}{X\left(k\right)} = \frac{B\left(k\right)}{1 + A\left(k\right)}
\end{flalign}
$
FIR フィルタと異なり, フィードバックがあるので, その伝達関数は分数式として表現されます (IIRFilterNode
の
$a_{0}$ が 0
以外の値でなければならない理由です).
ところで, 伝達関数をオーディオ信号処理に限らずに, 数学的に一般化すると (複素平面へ拡張すると), 離散フーリエ変換ではなく,
$z$ 変換 したあるシステムへの入出力比の関数となります (例えば, RIR は, ある室内をシステムとみなして,
システムへの入力をインパルス音とした場合の出力ということに特化して説明できます).
$z$ 変換での FIR フィルタ, IIR フィルタの伝達関数は以下のようになります.
$
\begin{flalign}
&H\left(z\right) = \frac{Y\left(z\right)}{X\left(z\right)} \quad (FIR) \\
&H\left(z\right) = \frac{B\left(z\right)}{1 + A\left(z\right)} \quad (IIR) \\
\end{flalign}
$
言い換えると, $z$ 変換での伝達関数を, 物理的な音のスペクトル比に特化すると,
離散フーリエ変換での入出力比が伝達関数になると言えます.
また, 音の伝達特性 (周波数特性) は, RIR 以外にも, 声道フィルタや HRTF (頭部伝達関数), スピーカーキャビネットの響きなどがあります.
イコライザー
フィルタの組み合わせのみでできるエフェクターとして, イコライザー があります. 元々は, アナログ方式で録音されていた時代に,
振幅が大きくなってしまう低音域や振幅が小さくなってしまう高音域を等しくする (equalize) 用途で使われていましたが, 現在では,
積極的に音を加工するエフェクターとして使われています. 楽器演奏や音楽制作ではもちろんですが, 音楽プレイヤーでもイコライザーは標準的に実装されており,
音楽を聴く場合にもバリエーションを与えています.
音楽プレイヤーのイコライザー (macOS Music アプリ イコライザー)
イコライザーにはいくつか種類がありますが, 頻繁に使われるイコライザーとして, 低音域・中音域・高音域の 3 つの帯域を強調・減衰可能な
3 バンドイコライザー (ギターアンプなどでは, 超高音域が追加されているイコライザーも多くあります) と, 10
帯域ぐらいをきめ細かく強調・減衰可能なグラフィックイコライザー があります.
このセクションでは, BiquadFilterNode
を組み合わせて, 3 バンドイコライザーとグラフィックイコライザーの実装を解説します. また,
イコライザーでは, 特定の周波数帯域を強調することをブースト , 減衰させることをカット と呼ぶことが多いので,
これ以降はこれらの用語を使うことにします.
3 バンドイコライザー
3 バンドイコライザー は, 低音域・中音域・高音域の 3 つの帯域をブースト・カット可能なイコライザーですが, 実装としては, Low-Shelving Filter,
Peaking Filter, High-Shelving Filter を使うだけで実装できます. つまり, 3 つの帯域を制御する BiquadFilterNode
インスタンスを生成して,
接続することで実装可能です. このとき, それぞれのフィルタの
$f_{\mathrm{computed}}$ は厳密に決まっているわけではありませんが, 以下のような値が設定されることが多いようです.
Low-Shelving Filter (低音域)
250 Hz
~ 500 Hz
Peaking Filter (中音域)
1000 Hz
~ 2000 Hz
High-Shelving Filter (高音域)
4000 Hz
~ 8000 Hz
また, Peaking Filter のみ, Q
プロパティの値を設定可能ですが, これも厳密に決まっているわけではありませんが, コード例としては
$\frac{1}{\sqrt{2}}$ を設定しています.
3 バンドイコライザーのノード接続図
現実世界のイコライザーでは, それぞれ 3 つのフィルタの gain
プロパティの値を変更して, ブースト・カットします. また, それらのパラメータ
(gain
プロパティの値) は, 低音域は Bass , 中音域は Middle , 高音域は
Treble として制御可能になっているイコライザーがほとんどです (ちなみに, 超高音域がある場合, Presence となっています).
Bass
0 dB
Middle
0 dB
Treble
0 dB
3 バンドイコライザーのフィルタ特性
以下は, 上記のフィルタ特性となる 3 バンドイコライザーを実際のアプリケーションを想定して, ユーザーインタラクティブに, 3
つの周波数帯域をブースト・カットできるようにしたコード例です. 原音を直接変化させるインサートエフェクトなので, 本質的な実装は, 3 つの帯域を制御する
BiquadFilterNode
インスタンスの接続処理です. 現実世界の 3 バンドイコライザーでは, ユーザーが操作できるパラメーターではありませんが,
$f_{\mathrm{computed}}$ の値や Peaking Filter の Q
プロパティの値なども変更してみて,
好みの値を探索してみるのもよいと思います.
<button type="button">start</button>
<label>
<input type="checkbox" id="checkbox-3-bands-equalizer" checked />
<span id="print-checked-3-bands-equalizer">ON</span>
</label>
<label>
<span>OscillatorNode frequency</span>
<input type="range" id="range-3-bands-equalizer-oscillator-frequency" value="440" min="27.5" max="4000" step="0.5" />
<span id="print-3-bands-equalizer-oscillator-frequency-value">440 Hz</span>
</label>
<label for="range-3-bands-equalizer-bass">Bass</label>
<input type="range" id="range-3-bands-equalizer-bass" value="0" min="-24" max="24" step="1" />
<span id="print-3-bands-equalizer-bass-value">0 dB</span>
<label for="range-3-bands-equalizer-middle">Middle</label>
<input type="range" id="range-3-bands-equalizer-middle" value="0" min="-24" max="24" step="1" />
<span id="print-3-bands-equalizer-middle-value">0 dB</span>
<label for="range-3-bands-equalizer-treble">Treble</label>
<input type="range" id="range-3-bands-equalizer-treble" value="0" min="-24" max="24" step="1" />
<span id="print-3-bands-equalizer-treble-value">0 dB</span>
const context = new AudioContext();
let frequency = 440;
let oscillator = new OscillatorNode(context, { type: 'sawtooth', frequency });
let isStop = true;
const bass = new BiquadFilterNode(context, { type: 'lowshelf', frequency: 250 });
const middle = new BiquadFilterNode(context, { type: 'peaking', frequency: 1000, Q: Math.SQRT1_2 });
const treble = new BiquadFilterNode(context, { type: 'highshelf', frequency: 4000 });
const buttonElement = document.querySelector('button[type="button"]');
const checkboxElement = document.querySelector('input[type="checkbox"]');
const rangeBassElement = document.getElementById('range-3-bands-equalizer-bass');
const rangeMiddleElement = document.getElementById('range-3-bands-equalizer-middle');
const rangeTrebleElement = document.getElementById('range-3-bands-equalizer-treble');
const spanPrintCheckedElement = document.getElementById('print-checked-3-bands-equalizer');
const spanPrintBassElement = document.getElementById('print-3-bands-equalizer-bass-value');
const spanPrintMiddleElement = document.getElementById('print-3-bands-equalizer-middle-value');
const spanPrintTrebleElement = document.getElementById('print-3-bands-equalizer-treble-value');
const rangeOscillatorFrequencyElement = document.getElementById('range-3-bands-equalizer-oscillator-frequency');
const spanPrintOscillatorFrequencyElement = document.getElementById('print-3-bands-equalizer-oscillator-frequency-value');
checkboxElement.addEventListener('click', () => {
oscillator.disconnect(0);
if (checkboxElement.checked) {
// OscillatorNode (Input) -> Equalizer (Low-Shelving Filter -> Peaking Filter -> High-Shelving Filter) -> AudioDestinationNode (Output)
oscillator.connect(bass);
bass.connect(middle);
middle.connect(treble);
treble.connect(context.destination);
spanPrintCheckedElement.textContent = 'ON'
} else {
// OscillatorNode (Input) -> AudioDestinationNode (Output)
oscillator.connect(context.destination);
spanPrintCheckedElement.textContent = 'OFF'
}
});
buttonElement.addEventListener('mousedown', () => {
if (!isStop) {
return;
}
if (checkboxElement.checked) {
// Connect nodes (Equalizer ON)
// OscillatorNode (Input) -> Equalizer (Low-Shelving Filter -> Peaking Filter -> High-Shelving Filter) -> AudioDestinationNode (Output)
oscillator.connect(bass);
bass.connect(middle);
middle.connect(treble);
treble.connect(context.destination);
} else {
// Connect nodes (Equalizer OFF)
// OscillatorNode (Input) -> AudioDestinationNode (Output)
oscillator.connect(context.destination);
}
// Start oscillator
oscillator.start(0);
isStop = false;
buttonElement.textContent = 'stop';
});
buttonElement.addEventListener('mouseup', () => {
if (isStop) {
return;
}
// Stop immediately
oscillator.stop(0);
oscillator = new OscillatorNode(context, { type: 'sawtooth', frequency });
isStop = true;
buttonElement.textContent = 'start';
});
rangeOscillatorFrequencyElement.addEventListener('input', (event) => {
frequency = event.currentTarget.valueAsNumber;
if (oscillator) {
oscillator.frequency.value = frequency
}
spanPrintOscillatorFrequencyElement.textContent = `${frequency} Hz`;
});
rangeBassElement.addEventListener('input', (event) => {
const gain = event.currentTarget.valueAsNumber;
bass.gain.value = gain;
spanPrintBassElement.textContent = `${gain} dB`;
});
rangeMiddleElement.addEventListener('input', (event) => {
const gain = event.currentTarget.valueAsNumber;
middle.gain.value = gain;
spanPrintMiddleElement.textContent = `${gain} dB`;
});
rangeTrebleElement.addEventListener('input', (event) => {
const gain = event.currentTarget.valueAsNumber;
treble.gain.value = gain;
spanPrintTrebleElement.textContent = `${gain} dB`;
});
グラフィックイコライザー
グラフィックイコライザー は, 10 帯域ほどの周波数成分をブースト・カットできるイコライザーですが, 実装としては, Peaking Filter
を制御したい周波数帯域の数だけ接続するだけです. $f_{\mathrm{computed}}$ を制御したい周波数帯域に設定します.
グラフィックイコライザーのノード接続図
グラフィックイコライザーのフィルタ特性
以下は, 上記のフィルタ特性となるグラフィックイコライザーを実際のアプリケーションを想定して, ユーザーインタラクティブに,
各周波数帯域をブースト・カットできるようにしたコード例です.
<button type="button">start</button>
<label>
<input type="checkbox" id="checkbox-graphic-equalizer" checked />
<span id="print-checked-graphic-equalizer">ON</span>
</label>
<label>
<span>OscillatorNode frequency</span>
<input type="range" id="range-graphic-equalizer-oscillator-frequency" value="440" min="27.5" max="4000" step="0.5" />
<span id="print-graphic-equalizer-oscillator-frequency-value">440 Hz</span>
</label>
<label for="range-graphic-equalizer-32Hz">32 Hz</label>
<input type="range" id="range-graphic-equalizer-32Hz" value="0" min="-24" max="24" step="1" />
<span id="print-graphic-equalizer-32Hz-value">0 dB</span>
<label for="range-graphic-equalizer-62Hz">62.5 Hz</label>
<input type="range" id="range-graphic-equalizer-62Hz" value="0" min="-24" max="24" step="1" />
<span id="print-graphic-equalizer-62Hz-value">0 dB</span>
<label for="range-graphic-equalizer-125Hz">125 Hz</label>
<input type="range" id="range-graphic-equalizer-125Hz" value="0" min="-24" max="24" step="1" />
<span id="print-graphic-equalizer-125Hz-value">0 dB</span>
<label for="range-graphic-equalizer-250Hz">250 Hz</label>
<input type="range" id="range-graphic-equalizer-250Hz" value="0" min="-24" max="24" step="1" />
<span id="print-graphic-equalizer-250Hz-value">0 dB</span>
<label for="range-graphic-equalizer-500Hz">500 Hz</label>
<input type="range" id="range-graphic-equalizer-500Hz" value="0" min="-24" max="24" step="1" />
<span id="print-graphic-equalizer-500Hz-value">0 dB</span>
<label for="range-graphic-equalizer-1000Hz">1000 Hz</label>
<input type="range" id="range-graphic-equalizer-1000Hz" value="0" min="-24" max="24" step="1" />
<span id="print-graphic-equalizer-1000Hz-value">0 dB</span>
<label for="range-graphic-equalizer-2000Hz">2000 Hz</label>
<input type="range" id="range-graphic-equalizer-2000Hz" value="0" min="-24" max="24" step="1" />
<span id="print-graphic-equalizer-2000Hz-value">0 dB</span>
<label for="range-graphic-equalizer-4000Hz">4000 Hz</label>
<input type="range" id="range-graphic-equalizer-4000Hz" value="0" min="-24" max="24" step="1" />
<span id="print-graphic-equalizer-4000Hz-value">0 dB</span>
<label for="range-graphic-equalizer-8000Hz">8000 Hz</label>
<input type="range" id="range-graphic-equalizer-8000Hz" value="0" min="-24" max="24" step="1" />
<span id="print-graphic-equalizer-8000Hz-value">0 dB</span>
<label for="range-graphic-equalizer-16000Hz">1.6 kHz</label>
<input type="range" id="range-graphic-equalizer-16000Hz" value="0" min="-24" max="24" step="1" />
<span id="print-graphic-equalizer-16000Hz-value">0 dB</span>
const context = new AudioContext();
let frequency = 440;
let oscillator = new OscillatorNode(context, { type: 'sawtooth', frequency });
let isStop = true;
const buttonElement = document.querySelector('button[type="button"]');
const checkboxElement = document.querySelector('input[type="checkbox"]');
const spanPrintCheckedElement = document.getElementById('print-checked-graphic-equalizer');
const rangeOscillatorFrequencyElement = document.getElementById('range-graphic-equalizer-oscillator-frequency');
const spanPrintOscillatorFrequencyElement = document.getElementById('print-graphic-equalizer-oscillator-frequency-value');
const centerFrequencies = [32, 62.5, 125, 250, 500, 1000, 2000, 4000, 8000, 16000];
const peakingFilters = centerFrequencies.map((frequency) => {
return new BiquadFilterNode(context, { type: 'peaking', frequency, Q: Math.SQRT1_2 });
});
centerFrequencies.forEach((frequency, index) => {
document.getElementById(`range-graphic-equalizer-${Math.trunc(frequency)}Hz`).addEventListener('input', (event) => {
const peakingFilter = peakingFilters[index];
peakingFilter.gain.value = event.currentTarget.valueAsNumber;
document.getElementById(`print-graphic-equalizer-${Math.trunc(frequency)}Hz-value`).textContent = `${peakingFilter.gain.value} dB`;
});
});
checkboxElement.addEventListener('click', () => {
oscillator.disconnect(0);
if (checkboxElement.checked) {
oscillator.connect(peakingFilters[0]);
for (let i = 0, len = peakingFilters.length - 1; i < len; i++) {
peakingFilters[i].connect(peakingFilters[i + 1]);
}
peakingFilters[peakingFilters.length - 1].connect(context.destination);
spanPrintCheckedElement.textContent = 'ON';
} else {
oscillator.connect(context.destination);
spanPrintCheckedElement.textContent = 'OFF';
}
});
buttonElement.addEventListener('mousedown', () => {
if (!isStop) {
return;
}
if (checkboxElement.checked) {
oscillator.connect(peakingFilters[0]);
for (let i = 0, len = peakingFilters.length - 1; i < len; i++) {
peakingFilters[i].connect(peakingFilters[i + 1]);
}
peakingFilters[peakingFilters.length - 1].connect(context.destination);
} else {
oscillator.connect(context.destination);
}
oscillator.start(0);
isStop = false;
buttonElement.textContent = 'stop'
});
buttonElement.addEventListener('mouseup', () => {
if (isStop) {
return;
}
oscillator.stop(0);
oscillator = new OscillatorNode(context, { type: 'sawtooth', frequency });
isStop = true;
buttonElement.textContent = 'start';
});
rangeOscillatorFrequencyElement.addEventListener('input', (event) => {
frequency = event.currentTarget.valueAsNumber;
if (oscillator) {
oscillator.frequency.value = frequency;
}
spanPrintOscillatorFrequencyElement.textContent = `${frequency} Hz`;
});
ワウ
フィルタを利用したよく使われるエフェクターとして, ワウ があります. 原理は, Low-Pass Filter のカットオフ周波数, もしくは, Band-Pass Filter
の中心周波数 ($f_{\mathrm{computed}}$ ) を時間経過とともに変化させることによって,
まさに「ワ」「ウ」と発声しているようなエフェクト音を生成することができます (エレキギターなどでは, いわゆる飛び道具的なエフェクターとして使われます).
音声分析合成技術である, ボコーダー (Vocoder : Voice Coder ) と似ているエフェクト音ですが, ワウの発明起源は諸説あるようです (また,
ボコーダーから, さらに音楽用途に改良して使われるようになったエフェクターとしては, フェーズボコーダ があります).
ワウを実装するためには, $f_{\mathrm{computed}}$ (Low-Pass Filter のカットオフ周波数, もしくは, Band-Pass Filter
の中心周波数) を時間経過とともに変化させる必要があります. これをワウペダル (参考:
VOX 社のワウペダル )
と呼ばれるコントローラーで変化させるするのがペダルワウ , 楽器の発音時の振幅の変化 (打弦や撥弦の強弱)
に応じて変化させるのがオートワウ となります .
ワウの原理 ($f_{\mathrm{computed}}$ が時間経過とともに変化することに着目してください)
start
ワウは原音を直接変化させるエフェクターなので, インサートエフェクトとなります.
ペダルワウ
Web ブラウザでペダルワウを実装するには, ハードウェア的な制約があるので, このセクションでは,
LFO を擬似的なワウペダルとして ペダルワウの実装を解説します (もし, Web MIDI API の理解があり, MIDI 機器があれば, ベロシティなどに応じて
$f_{\mathrm{computed}}$ を変化させるようにすると, よりペダルワウに近い体験ができると思います).
原理をそのまま実装に落とし込めば, ペダルワウの実装はトレモロなどと同じです. BiquadFilterNode
を接続して,
frequency
プロパティ (AudioParam
) に LFO を接続して時間経過とともに
$f_{\mathrm{computed}}$ を変化させることで実装可能です.
Low-Pass Filter を使う場合, カットオフ周波数付近の急峻を鋭くしておく必要があるので , Q
プロパティの値は 10
~
20
程度に設定しておく必要があります (Band-Pass Filter の場合は, デフォルト値で問題ありません).
IIRFilterNode
を使っても実装は可能ですが, その場合, カットオフ周波数は AudioParam
ではないので, LFO も AudioWorklet
を利用して実装する必要が生じます. 特にフィルタのチューニングが必要なければ (奇数次の IIR フィルタを使わなければならない理由があるなど),
BiquadFilterNode
を使うほうが実装は簡潔になります.
ペダルワウのノード接続図
const context = new AudioContext();
const cutoff = 880;
const depthRate = 0.5;
const rateValue = 0.5;
const resonance = 10;
const lowpass = new BiquadFilterNode(context, { type: 'lowpass', frequency: cutoff, Q: resonance });
const oscillator = new OscillatorNode(context, { type: 'sawtooth', frequency: 440 });
const lfo = new OscillatorNode(context, { frequency: rateValue });
const depth = new GainNode(context, { gain: cutoff * depthRate });
// Connect nodes
// OscillatorNode (Input) -> BiquadFilterNode (Low-Pass Filter) -> AudioDestinationNode (Output)
oscillator.connect(lowpass);
lowpass.connect(context.destination);
// Connect nodes for LFO that changes Low-Pass Filter's frequency periodically
// OscillatorNode (LFO) -> GainNode (Depth) -> delayTime (AudioParam)
lfo.connect(depth);
depth.connect(lowpass.frequency);
// Start oscillator and LFO
oscillator.start(0);
lfo.start(0);
// Stop oscillator and LFO
oscillator.stop(context.currentTime + 10);
lfo.stop(context.currentTime + 10);
以下は, 実際のアプリケーションを想定して, ユーザーインタラクティブに, ペダルワウに関わるパラメータを制御できるようにしたコード例です.
一般的なペダルワウでは, ワウペダルのプレッシャーレベルに応じて, カットオフ周波数 ($f_{\mathrm{computed}}$ )
が変化するようになっていますが, LFO を擬似的なワウペダルとした実装なので, 基準となる $f_{\mathrm{computed}}$ と
Depth でワウペダルのプレッシャーレベルを, Rate でペダルの動きを擬似的に実装しています.
<button type="button">start</button>
<label>
<input type="checkbox" id="checkbox-pedal-wah" checked />
<span id="print-checked-pedal-wah">ON</span>
</label>
<label for="range-pedal-wah-cutoff">Cutoff Frequency</label>
<input type="range" id="range-pedal-wah-cutoff" value="1000" min="1000" max="2000" step="1" />
<span id="print-pedal-wah-cutoff-value">1000 Hz</span>
<label for="range-pedal-wah-depth">Depth</label>
<input type="range" id="range-pedal-wah-depth" value="0" min="0" max="1" step="0.05" />
<span id="print-pedal-wah-depth-value">0</span>
<label for="range-pedal-wah-rate">Rate</label>
<input type="range" id="range-pedal-wah-rate" value="0" min="0" max="10" step="0.5" />
<span id="print-pedal-wah-rate-value">0</span>
<label for="range-pedal-wah-resonance">Resonance</label>
<input type="range" id="range-pedal-wah-resonance" value="1" min="1" max="20" step="1" />
<span id="print-pedal-wah-resonance-value">1</span>
const context = new AudioContext();
let cutoff = 1000;
let depthRate = 0;
let rateValue = 0;
let resonance = 1;
let oscillator = new OscillatorNode(context, { type: 'sawtooth', frequency: 440 });
let lfo = new OscillatorNode(context, { frequency: rateValue });
let isStop = true;
const lowpass = new BiquadFilterNode(context, { type: 'lowpass', frequency: cutoff, Q: resonance });
const depth = new GainNode(context, { gain: lowpass.frequency.value * depthRate });
const buttonElement = document.querySelector('button[type="button"]');
const checkboxElement = document.querySelector('input[type="checkbox"]');
const rangeCutoffElement = document.getElementById('range-pedal-wah-cutoff');
const rangeDepthElement = document.getElementById('range-pedal-wah-depth');
const rangeRateElement = document.getElementById('range-pedal-wah-rate');
const rangeResonanceElement = document.getElementById('range-pedal-wah-resonance');
const spanPrintCheckedElement = document.getElementById('print-checked-pedal-wah');
const spanPrintCutoffElement = document.getElementById('print-pedal-wah-cutoff-value');
const spanPrintDepthElement = document.getElementById('print-pedal-wah-depth-value');
const spanPrintRateElement = document.getElementById('print-pedal-wah-rate-value');
const spanPrintResonanceElement = document.getElementById('print-pedal-wah-resonance-value');
checkboxElement.addEventListener('click', () => {
oscillator.disconnect(0);
lowpass.disconnect(0);
lfo.disconnect(0);
if (checkboxElement.checked) {
// Connect nodes
// OscillatorNode (Input) -> BiquadFilterNode (Low-Pass Filter) -> AudioDestinationNode (Output)
oscillator.connect(lowpass);
lowpass.connect(context.destination);
// Connect nodes for LFO that changes Low-Pass Filter's frequency periodically
// OscillatorNode (LFO) -> GainNode (Depth) -> frequency (AudioParam)
lfo.connect(depth);
depth.connect(lowpass.frequency);
spanPrintCheckedElement.textContent = 'ON';
} else {
// OscillatorNode (Input) -> AudioDestinationNode (Output)
oscillator.connect(context.destination);
spanPrintCheckedElement.textContent = 'OFF';
}
});
buttonElement.addEventListener('mousedown', () => {
if (!isStop) {
return;
}
if (checkboxElement.checked) {
// Connect nodes
// OscillatorNode (Input) -> BiquadFilterNode (Low-Pass Filter) -> AudioDestinationNode (Output)
oscillator.connect(lowpass);
lowpass.connect(context.destination);
// Start oscillator
oscillator.start(0);
} else {
lowpass.disconnect(0);
// Connect nodes (Pedal Wah OFF)
// OscillatorNode (Input) -> AudioDestinationNode (Output)
oscillator.connect(context.destination);
// Start oscillator
oscillator.start(0);
}
// Connect nodes for LFO that changes Low-Pass Filter's frequency periodically
// OscillatorNode (LFO) -> GainNode (Depth) -> frequency (AudioParam)
lfo.connect(depth);
depth.connect(lowpass.frequency);
// Start LFO
lfo.start(0);
isStop = false;
buttonElement.textContent = 'stop';
});
buttonElement.addEventListener('mouseup', () => {
if (isStop) {
return;
}
// Stop immediately
oscillator.stop(0);
lfo.stop(0);
oscillator = new OscillatorNode(context, { type: 'sawtooth', frequency: 440 });
lfo = new OscillatorNode(context, { frequency: rateValue });
isStop = true;
buttonElement.textContent = 'start';
});
rangeCutoffElement.addEventListener('input', (event) => {
cutoff = event.currentTarget.valueAsNumber;
lowpass.frequency.value = cutoff;
spanPrintCutoffElement.textContent = `${cutoff.toString(10)} Hz`;
});
rangeDepthElement.addEventListener('input', (event) => {
depthRate = event.currentTarget.valueAsNumber;
depth.gain.value = lowpass.frequency.value * depthRate;
spanPrintDepthElement.textContent = depthRate.toString(10);
});
rangeRateElement.addEventListener('input', (event) => {
rateValue = event.currentTarget.valueAsNumber;
if (lfo) {
lfo.frequency.value = rateValue;
}
spanPrintRateElement.textContent = rateValue.toString(10);
});
rangeResonanceElement.addEventListener('input', (event) => {
resonance = event.currentTarget.valueAsNumber;
lowpass.Q.value = resonance;
spanPrintResonanceElement.textContent = resonance.toString(10);
});
オートワウ
汎用的なオートワウを実装するためには, WaveShaperNode
クラスの理解が必要であったり, ノード接続が複雑であったりするので,
このセクションでは, すでに解説した BiquadFilterNode
の frequency
プロパティ (AudioParam
)
のオートメーションメソッドを利用した簡易的な実装を解説します (これは, アナログシンセサイザーの VCF (Voltage Controlled Filter :
電圧制御フィルタ ) の実装でもあります).
エンベロープジェネレーターと同様のオートメーションを BiquadFilterNode
の frequency
プロパティに実装することです. そして,
それに対応するように, GainNode
の gain
プロパティにもオートメーションを適用すれば, 同様のスケジューリングで,
振幅に応じて $f_{\mathrm{computed}}$ も変化することになるので, オートワウが実装できます. 現実的には,
オートワウが有用なのは, このような, ユーザーインタラクティブな操作に応じて, 振幅の変化とともに,
$f_{\mathrm{computed}}$ を変化させればよいケースがほとんどなので, 簡易的なオートワウでも十分なことが多いでしょう.
const context = new AudioContext();
const cutoff = 1000;
const targetCutoff = 2000;
const resonance = 10;
const attack = 1.0;
const decay = 0.5;
const sustain = 0.5;
const release = 1.0;
const envelopegenerator = new GainNode(context);
let oscillator = new OscillatorNode(context, { type: 'sawtooth', frequency: 440 });
let isStop = true;
const lowpass = new BiquadFilterNode(context, { type: 'lowpass', frequency: cutoff, Q: resonance });
const buttonElement = document.querySelector('button[type="button"]');
buttonElement.addEventListener('mousedown', () => {
if (!isStop) {
return;
}
// Connect nodes
// OscillatorNode (Input) -> GainNode (Envelope Generator) -> BiquadFilterNode (Low-Pass Filter) -> AudioDestinationNode (Output)
oscillator.connect(envelopegenerator);
envelopegenerator.connect(lowpass);
lowpass.connect(context.destination);
const t0 = context.currentTime;
const t1 = t0 + attack;
const t2 = decay;
const t2Level = lowpass.frequency.value * sustain;
// Attack -> Decay -> Sustain
envelopegenerator.gain.cancelScheduledValues(t0);
envelopegenerator.gain.setValueAtTime(0, t0);
envelopegenerator.gain.linearRampToValueAtTime(1, t1);
envelopegenerator.gain.setTargetAtTime(sustain, t1, t2);
lowpass.frequency.cancelScheduledValues(t0);
lowpass.frequency.setValueAtTime(cutoff, t0);
lowpass.frequency.exponentialRampToValueAtTime(targetCutoff, t1);
lowpass.frequency.setTargetAtTime(t2Level, t1, t2);
// Start oscillator
oscillator.start(0);
isStop = false;
buttonElement.textContent = 'stop';
});
buttonElement.addEventListener('mouseup', () => {
if (isStop) {
return;
}
const t3 = context.currentTime;
const t4 = release;
// Sustain -> Release
envelopegenerator.gain.cancelScheduledValues(t3);
envelopegenerator.gain.setTargetAtTime(0, t3, t4);
lowpass.frequency.cancelScheduledValues(t3);
lowpass.frequency.setTargetAtTime(cutoff, t3, t4);
// Stop after Release
oscillator.stop(t3 + t4);
oscillator = new OscillatorNode(context, { type: 'sawtooth', frequency: 440 });
isStop = true;
buttonElement.textContent = 'start';
});
ワウは飛び道具的なエフェクターなので, linearRampToValueAtTime
メソッドではなく,
exponentialRampToValueAtTime
メソッドで指数関数的に変化させてみました (もちろん, 線形的に変化させる
linearRampToValueAtTime
メソッドでもオートワウとしては機能します).
以下は, 実際のアプリケーションを想定して, ユーザーインタラクティブに, (パラメータのオートメーションによる)
オートワウに関わるパラメータを制御できるようにしたコード例です.
<button type="button">start</button>
<label for="range-vcf-auto-wah-attack">attack</label>
<input type="range" id="range-vcf-auto-wah-attack" value="1" min="0" max="1" step="0.01" />
<span id="print-vcf-auto-wah-attack-value">1</span>
<label for="range-vcf-auto-wah-decay">decay</label>
<input type="range" id="range-vcf-auto-wah-decay" value="0.3" min="0" max="1" step="0.01" />
<span id="print-vcf-auto-wah-decay-value">0.3</span>
<label for="range-vcf-auto-wah-sustain">sustain</label>
<input type="range" id="range-vcf-auto-wah-sustain" value="0.5" min="0" max="1" step="0.01" />
<span id="print-vcf-auto-wah-sustain-value">0.5</span>
<label for="range-vcf-auto-wah-release">release</label>
<input type="range" id="range-vcf-auto-wah-release" value="1" min="0" max="1" step="0.01" />
<span id="print-vcf-auto-wah-release-value">1</span>
const context = new AudioContext();
const cutoff = 1000;
const targetCutoff = 2000;
const resonance = 10;
let attack = 1.0;
let decay = 0.5;
let sustain = 0.5;
let release = 1.0;
const envelopegenerator = new GainNode(context);
let oscillator = new OscillatorNode(context, { type: 'sawtooth', frequency: 440 });
let isStop = true;
const lowpass = new BiquadFilterNode(context, { type: 'lowpass', frequency: cutoff, Q: resonance });
const buttonElement = document.querySelector('button[type="button"]');
const rangeAttackElement = document.getElementById('range-vcf-auto-wah-attack');
const rangeDecayElement = document.getElementById('range-vcf-auto-wah-decay');
const rangeSustainElement = document.getElementById('range-vcf-auto-wah-sustain');
const rangeReleaseElement = document.getElementById('range-vcf-auto-wah-release');
const spanPrintAttackElement = document.getElementById('print-vcf-auto-wah-attack-value');
const spanPrintDecayElement = document.getElementById('print-vcf-auto-wah-decay-value');
const spanPrintSutainElement = document.getElementById('print-vcf-auto-wah-sustain-value');
const spanPrintReleaseElement = document.getElementById('print-vcf-auto-wah-release-value');
buttonElement.addEventListener('mousedown', () => {
if (!isStop) {
return;
}
// Connect nodes
// OscillatorNode (Input) -> GainNode (Envelope Generator) -> BiquadFilterNode (Low-Pass Filter) -> AudioDestinationNode (Output)
oscillator.connect(envelopegenerator);
envelopegenerator.connect(lowpass);
lowpass.connect(context.destination);
const t0 = context.currentTime;
const t1 = t0 + attack;
const t2 = decay;
const t2Level = lowpass.frequency.value * sustain;
// Attack -> Decay -> Sustain
envelopegenerator.gain.cancelScheduledValues(t0);
envelopegenerator.gain.setValueAtTime(0, t0);
envelopegenerator.gain.linearRampToValueAtTime(1, t1);
envelopegenerator.gain.setTargetAtTime(sustain, t1, t2);
lowpass.frequency.cancelScheduledValues(t0);
lowpass.frequency.setValueAtTime(cutoff, t0);
lowpass.frequency.exponentialRampToValueAtTime(targetCutoff, t1);
lowpass.frequency.setTargetAtTime(t2Level, t1, t2);
// Start oscillator
oscillator.start(0);
isStop = false;
buttonElement.textContent = 'stop';
});
buttonElement.addEventListener('mouseup', () => {
if (isStop) {
return;
}
const t3 = context.currentTime;
const t4 = release;
// Sustain -> Release
envelopegenerator.gain.cancelAndHoldAtTime(t3);
envelopegenerator.gain.setTargetAtTime(0, t3, t4);
lowpass.frequency.cancelAndHoldAtTime(t3);
lowpass.frequency.setTargetAtTime(cutoff, t3, t4);
// Stop after Release (+ until nearly zero audio data)
oscillator.stop(t3 + t4 + 5);
oscillator = new OscillatorNode(context, { type: 'sawtooth', frequency: 440 });
isStop = true;
buttonElement.textContent = 'start';
});
rangeAttackElement.addEventListener('input', (event) => {
attack = event.currentTarget.valueAsNumber;
spanPrintAttackElement.textContent = attack.toString(10);
});
rangeDecayElement.addEventListener('input', (event) => {
decay = event.currentTarget.valueAsNumber;
spanPrintDecayElement.textContent = decay.toString(10);
});
rangeSustainElement.addEventListener('input', (event) => {
sustain = event.currentTarget.valueAsNumber;
spanPrintSutainElement.textContent = sustain.toString(10);
});
rangeReleaseElement.addEventListener('input', (event) => {
release = event.currentTarget.valueAsNumber;
spanPrintReleaseElement.textContent = release.toString(10);
});
汎用的なオートワウ
汎用的なオートワウを実装するためには, WaveShaperNode
クラスを理解して, エンベロープフォロワー を実装する必要があるので,
ここでは, とりあえず形式的に (以下のように実装すればオートワウができる程度に) 理解していただければだいじょうぶです (WaveShaperNode
クラスやエンベロープフォロワーに関しては,
ディストーション (歪み系エフェクター) のセクション で詳細を解説します).
const context = new AudioContext();
const depthValue = 0.25;
const rateValue = 1;
const envelopeFollower = new WaveShaperNode(context, { buffer: new Float32Array([1, 0, 1]) });
const sensitivity = new BiquadFilterNode(context, { type: 'lowpass', frequency: 2000, Q: 10 });
const lowpass = new BiquadFilterNode(context, { type: 'lowpass', frequency: 2, Q: 1 });
const oscillator = new OscillatorNode(context, { type: 'sawtooth', frequency: 440 });
const lfo = new OscillatorNode(context, { frequency: rateValue });
const depth = new GainNode(context, { gain: 1000 });
const lfoDepth = new GainNode(context, { gain: depthValue });
const amplitude = new GainNode(context, { gain: 0.5 }); // 0.5 +- ${depthValue}
// Connect nodes
// OscillatorNode (Input) -> GainNode (Tremolo) -> BiquadFilterNode (Sensitivity) -> GainNode (Output)
oscillator.connect(amplitude);
amplitude.connect(sensitivity);
sensitivity.connect(context.destination);
// OscillatorNode (Input) -> WaveShaperNode (Envelope Follower) -> WaveShaperNode (Envelope Follower) -> BiquadFilterNode (Low-Pass Filter) -> GainNode (Depth) -> frequency (AudioParam)
oscillator.connect(envelopeFollower);
envelopeFollower.connect(lowpass);
lowpass.connect(depth);
depth.connect(sensitivity.frequency);
lfo.connect(lfoDepth);
lfoDepth.connect(amplitude.gain);
// Start oscillator
oscillator.start(0);
lfo.start(0);
// Stop oscillator
oscillator.stop(context.currentTime + 5);
lfo.stop(context.currentTime + 5);
音源の振幅の変化をシミュレートするために, トレモロを使っています (したがって, ここでのトレモロは, オートワウの実装には直接的に関係ありません).
ノード接続は, 大きく 2 つで構成されています. 1 つは, 音源と振幅に対する感度のための Low-Pass Filter, そして
AudioDestinationNode
の接続と, もう 1 つは, 音源の振幅をエンベロープフォロワー に接続することによって,
全波整流 (端的には, 振幅の絶対値に波形を整形する処理) を施し, それをもとに, 感度の Low-Pass Filter の
frequency
プロパティを振幅に応じて変化させるための (AudioParam
への) 接続です.
オートワウのノード接続図
全波整流した原音を, カットオフ周波数が非常に低い Low-Pass Filter を通過させることで, 波形 (エンベロープ) ではなく, 振幅の変化を出力とします.
あとは, 振幅に応じて Depth の値が変化するようにすれば, Sensitivity の Low-Pass Filter の
$f_{\mathrm{computed}}$ が原音の振幅の変化に応じて変化することになり, (汎用的な) オートワウが実装できます.
以下は, 実際のアプリケーションを想定して, ユーザーインタラクティブに, オートワウに関わるパラメータを制御できるようにしたコード例です.
原音はハードコーディングしていますが, OscillatorNode
だけでなく, ワンショットオーディオや
MediaStreamAudioSourceNode
をオーディオソースとして音声に適用するのもおもしろいかもしれません.
<button type="button">start</button>
<label>
<input type="checkbox" id="checkbox-auto-wah" checked />
<span id="print-checked-auto-wah">ON</span>
</label>
<label for="range-auto-wah-sensitivity">Sensitivity</label>
<input type="range" id="range-auto-wah-sensitivity" value="1000" min="1000" max="2000" step="1" />
<span id="print-auto-wah-sensitivity-value">1000 Hz</span>
<label for="range-auto-wah-depth">Depth</label>
<input type="range" id="range-auto-wah-depth" value="0.5" min="0" max="1" step="0.05" />
<span id="print-auto-wah-depth-value">0.5</span>
<label for="range-auto-wah-resonance">Resonance</label>
<input type="range" id="range-auto-wah-resonance" value="10" min="1" max="40" step="1" />
<span id="print-auto-wah-resonance-value">10</span>
const context = new AudioContext();
let sensitivityFrequency = 2000;
let sensitivityDepthRate = 0.5;
let sensitivityResonance = 10;
const tremoloDepthValue = 0.25;
const tremoloRateValue = 1;
let oscillator = new OscillatorNode(context, { type: 'sawtooth', frequency: 440 });
let lfo = new OscillatorNode(context, { frequency: tremoloRateValue });
// for Tremolo (simulates amplitude modulation)
const lfoDepth = new GainNode(context, { gain: tremoloDepthValue });
const amplitude = new GainNode(context, { gain: 0.5 }); // 0.5 +- ${tremoloDepthValue}
lfo.connect(lfoDepth);
lfoDepth.connect(amplitude.gain);
// Start LFO (simulates amplitude modulation)
lfo.start(0);
const envelopeFollower = new WaveShaperNode(context, { buffer: new Float32Array([1, 0, 1]) });
const sensitivity = new BiquadFilterNode(context, { type: 'lowpass', frequency: sensitivityFrequency, Q: sensitivityResonance });
const lowpass = new BiquadFilterNode(context, { type: 'lowpass', frequency: 2, Q: 1 });
const depth = new GainNode(context, { gain: sensitivity.frequency.value * sensitivityDepthRate });
let isStop = true;
const buttonElement = document.querySelector('button[type="button"]');
const checkboxElement = document.querySelector('input[type="checkbox"]');
const rangeSensitivityFrequencyElement = document.getElementById('range-auto-wah-sensitivity');
const rangeSensitivityDepthElement = document.getElementById('range-auto-wah-depth');
const rangeResonanceElement = document.getElementById('range-auto-wah-resonance');
const spanPrintCheckedElement = document.getElementById('print-checked-auto-wah');
const spanPrintSensitivityFrequencyElement = document.getElementById('print-auto-wah-sensitivity-value');
const spanPrintDepthElement = document.getElementById('print-auto-wah-depth-value');
const spanPrintResonanceElement = document.getElementById('print-auto-wah-resonance-value');
checkboxElement.addEventListener('click', () => {
oscillator.disconnect(0);
sensitivity.disconnect(0);
envelopeFollower.disconnect(0);
lowpass.disconnect(0);
depth.disconnect(0);
if (checkboxElement.checked) {
// Connect nodes
// OscillatorNode (Input) -> GainNode (Tremolo) -> BiquadFilterNode (Sensitivity) -> GainNode (Output)
oscillator.connect(amplitude);
amplitude.connect(sensitivity);
sensitivity.connect(context.destination);
// OscillatorNode (Input) -> WaveShaperNode (Envelope Follower) -> BiquadFilterNode (Low-Pass Filter) -> GainNode (Depth) -> frequency (AudioParam)
oscillator.connect(envelopeFollower);
envelopeFollower.connect(lowpass);
lowpass.connect(depth);
depth.connect(sensitivity.frequency);
spanPrintCheckedElement.textContent = 'ON';
} else {
// OscillatorNode (Input) -> AudioDestinationNode (Output)
oscillator.connect(context.destination);
spanPrintCheckedElement.textContent = 'OFF';
}
});
buttonElement.addEventListener('mousedown', () => {
if (!isStop) {
return;
}
if (checkboxElement.checked) {
// Connect nodes
// OscillatorNode (Input) -> GainNode (Tremolo) -> BiquadFilterNode (Sensitivity) -> GainNode (Output)
oscillator.connect(amplitude);
amplitude.connect(sensitivity);
sensitivity.connect(context.destination);
// OscillatorNode (Input) -> WaveShaperNode (Envelope Follower) -> BiquadFilterNode (Low-Pass Filter) -> GainNode (Depth) -> frequency (AudioParam)
oscillator.connect(envelopeFollower);
envelopeFollower.connect(lowpass);
lowpass.connect(depth);
depth.connect(sensitivity.frequency);
// Start oscillator
oscillator.start(0);
} else {
oscillator.disconnect(0);
// Connect nodes (Auto Wah OFF)
// OscillatorNode (Input) -> AudioDestinationNode (Output)
oscillator.connect(context.destination);
// Start oscillator
oscillator.start(0);
}
isStop = false;
buttonElement.textContent = 'stop';
});
buttonElement.addEventListener('mouseup', () => {
if (isStop) {
return;
}
// Stop immediately
oscillator.stop(0);
oscillator = new OscillatorNode(context, { type: 'sawtooth', frequency: 440 });
isStop = true;
buttonElement.textContent = 'start';
});
rangeSensitivityFrequencyElement.addEventListener('input', (event) => {
sensitivityFrequency = event.currentTarget.valueAsNumber;
sensitivity.frequency.value = sensitivityFrequency;
spanPrintSensitivityFrequencyElement.textContent = `${sensitivityFrequency.toString(10)} Hz`;
});
rangeSensitivityDepthElement.addEventListener('input', (event) => {
sensitivityDepthRate = event.currentTarget.valueAsNumber;
depth.gain.value = sensitivityFrequency * sensitivityDepthRate;
spanPrintDepthElement.textContent = sensitivityDepthRate.toString(10);
});
rangeResonanceElement.addEventListener('input', (event) => {
sensitivityResonance = event.currentTarget.valueAsNumber;
sensitivity.Q.value = sensitivityResonance;
spanPrintResonanceElement.textContent = sensitivityResonance.toString(10);
});
ディストーション (歪み系エフェクター)
楽器に限らず, オーディオ機器において歪み というのは本来避けるべき現象であり, そのため, 音割れが発生しないように, 振幅の調整 (基本波形の合成 セクションのデモなどを参照してください) をしたり, コンプレッサーなどを使って歪みが発生しないようにします. 現在では,
歪みを積極的に使うエレキギターでも, 元々のアンプはあくまで振幅 (ゲイン) を増幅させる機器であり, クリーンサウンドを使っていたと言われています.
しかし, あるギタリストが真空管アンプのボリュームを最大にして演奏した歪みが偶然にもよかったことから, 歪み系の音である (広義の)
ディストーションサウンド (広義というのは, 歪み系のサウンドの総称というニュアンスです) が使われるようになったと言われています
(このときの歪みは, (狭義の) ディストーションというよりは, ウォームでナチュラルな歪みであるオーバードライブ (Overdrive )
だったそうです).
ここから, よりハイゲインなディストーションサウンドを生成できるような真空管アンプが開発されたり,
ローランド社の伝説的なオーバードライブコンパクトエフェクターとなった BOSS
OD-1 Overdrive
が開発されたりと (現在でも中古市場で, コンパクトエフェクターにしてはかなりの高額で取引されるほどです), 現在では, 技術の進歩にともなって, アンプ
(シミュレーター) でも, エフェクターでも, 様々な種類のディストーションサウンドを得ることができます.
もっとも, アンプによるディストーションサウンドの生成と, エフェクターによるディストーションサウンドの生成は仕組みとしてはやや異なります. また,
ギタリストの多くはエフェクターによるディストーションサウンドより, (真空管) アンプのディストーションサウンドを基本に音づくりをする傾向にあるようです.
歴史的な経緯としても, 先にアンプシミュレーターによるディストーションサウンドの実装を解説したいところですが, Web Audio API においては,
どちらのディストーションサウンドにおいても, WaveShaperNode
クラスを使うことが基本になるのと, 実装としては,
エフェクターによるディストーションサウンドのほうが簡潔で理解しやすいと思うので, まずは, Crunch (クランチ ),
Overdrive (オーバードライブ ), Distortion (ディストーション ), Fuzz (ファズ )
に分類されるエフェクターによるディストーションサウンドの実装に関して解説します.
WaveShaperNode
ディストーションサウンドの原理は, 周波数領域でみると, 原音には存在しない周波数成分を発生させる非線形処理 が原理となっています. これを,
時間領域の波形でみると, 意図的な音割れを発生させるクリッピング が原理となっています.
クリッピング
波形を単純にクリッピングしただけでは, ディストーションサウンドではなく, 音割れっぽくなってしまうことや, 非線形処理によって折り返し歪み
(エイリアス歪み) が発生するのを防ぐためのオーバーサンプル処理 などを抽象化するのが, WaveShaperNode
クラスです.
curve プロパティ
波形のクリッピングを決定するための Float32Array
を指定します. curve
プロパティで設定される
Float32Array
は, 入力の振幅に対する出力の振幅を表しています . つまり, new Float32Array([0, 1])
であれば,
入出力の振幅比は 1 : 1 となり, クリッピングカーブの形状は直線となります. すなわち, これは, 線形処理 となるので,
curve
プロパティを指定しないのと同じです (curve
プロパティのデフォルト値は undefined
です).
curve
プロパティに設定する Float32Array
のサイズの上限は仕様では決められていませんが,
下限は 2
です (これは最低でも直線を表現しなければならないことを考慮すると直感的でもあります).
サンプルとサンプルの間は線形補間が適用される ので, 計算コストも考慮すると現実的な上限は
8192
サンプルぐらいまでで十分と言えます (あまりに少ないと, 線形補間の間が大きくなるので,
カーブではなく直線をつないだような形状となってしまい, クリッピングの精度が悪くなるので, ディストーションカーブとして使う場合は,
512
サンプル程度は下限としておいたほうがよいでしょう).
もっとも, 手動的にクリッピングカーブとなる Float32Array
の値を設定することはあまりなく (エンベロープフォロワーなどを除いて),
ディストーションサウンドとしてのクリッピングカーブを指定する場合, 定石となるようなクリッピングカーブが知られているので,
それを利用してクリッピングカーブを生成するのがよいでしょう (例えば, シグモイド関数 ($\sigma_{a}\left(x\right) = \frac{1}{1 + e^{-ax}}$ ) やオーバードライブカーブ など).
curve
プロパティに指定されたクリッピングカーブによって, 非線形処理 が適用されて, 入出力の振幅比が歪み ,
倍音成分が発生してディストーションサウンドとなります.
クリッピングカーブ
oversample プロパティ
ディストーションサウンドの生成において必須ではありませんが, クリッピング処理によって原音には存在しない周波数成分が発生しますが,
ナイキスト周波数以上の周波数成分が発生する可能性, すなわち, 折り返し歪み (エイリアス歪み) が発生してしまう可能性があります . この歪みは,
ノイズとなって知覚されてしまうので, それを防ぐために,
プリプロセス処理 (クリッピング前の処理) として, アップサンプリングによってクリッピング対象の信号のサンプル数を増やし, ポストプロセス処理
(クリッピング後の処理) として, ダウンサンプリングによって元のサンプル数に戻すという処理
(オーバーサンプル処理 ) が知られています.
オーバーサンプル処理の, アップサンプリングのサンプリング周波数を設定するのが, oversample
プロパティです. もっとも,
直接アップサンプリング周波数を設定するのではなく, '2x'
, または, '4x'
の文字列 (OverSampleType
列挙型. デフォルト値は, オーバーサンプル処理をしない 'none'
です) を指定することによって, サンプリング周波数の 2 倍,
または, 4 倍にアップサンプリングします. ディストーションサウンドの音質だけを考えるなら, 常に
'4x'
を指定しておくのがよいですが, オーバーサンプル処理によって,
サンプル数が増えるほどパフォーマンスは低下してしまいます. したがって, Crunch のような軽い歪みやエンベロープフォロワーのような場合には
'none'
, Distortion や Fuzz のような激しい歪みの場合には '4x'
, Overdrive の場合には
'2x'
というように, 歪みの深さや用途に応じて設定を変えるほうが, 音質とパフォーマンス (CPU リソースやレイテンシー)
の観点からは理想と言えます.
curve
プロパティと oversample
プロパティが理解できれば, WaveShaperNode
インスタンス生成して接続するだけです
(ファクトリメソッドでインスタンスを生成する場合は, AudioContext
インスタンスの
createWaveShaper
メソッドを呼び出します).
WaveShaperNode
のノード接続図
const context = new AudioContext();
const curve = new Float32Array(4096);
const amount = 0.7;
const k = (2 * amount) / (1 - amount);
for (let n = 0, numberOfSamples = curve.length; n < numberOfSamples; n++) {
const x = (((n - 0) * (1 - (-1))) / (numberOfSamples - 0)) + (-1);
const y = ((1 + k) * x) / (1 + (k * Math.abs(x)));
curve[n] = y;
}
const shaper = new WaveShaperNode(context, { curve, oversample: '2x' });
// If use `createWaveShaper`
// const shaper = context.createWaveShaper();
//
// shaper.curve = curve;
// shaper.oversample = '2x';
const oscillator = new OscillatorNode(context);
// Connect nodes
// OscillatorNode (Input) -> WaveShaperNode (Clipping) -> AudioDestinationNode (Output)
oscillator.connect(shaper);
shaper.connect(context.destination);
// Start oscillator immediately
oscillator.start(0);
// Stop oscillator after 10 sec
oscillator.stop(context.currentTime + 10);
非対称クリッピング
クリッピングカーブでも紹介したオーバードライブカーブ でも Overdrive を実装することは可能ですが, Overdrive によっては,
クリッピング後の波形が非対称となる非対称クリッピング で実装されていることも多いのでこのセクションで解説します (ちなみに, BOSS
OD-1 Overdrive
も非対称クリッピング回路が利用されています).
非対称クリッピングすることによって, 奇数次の倍音成分だけではなく, 偶数次の倍音成分も発生するので (端的には, 倍音成分が増えるので),
ハイゲインよりの Overdrive を生成することが可能となります.
非対称クリッピング
したがって,
オーバードライブカーブ をもとに, 非対称のクリッピングになるように, 正負の判定を追加して非対称になるように設定します. 非対称クリッピングカーブの形状は,
クリッピングカーブ で Curve Type
を
Overdrive (Asymmetrical)
に選択すると確認できます.
const context = new AudioContext();
const curve = new Float32Array(4096);
const amount = 0.7;
const k = (2 * amount) / (1 - amount);
for (let n = 0, numberOfSamples = curve.length; n < numberOfSamples; n++) {
const x = (((n - 0) * (1 - (-1))) / (numberOfSamples - 0)) + (-1);
const y = ((1 + k) * x) / (1 + (k * Math.abs(x)));
// Asymmetrical clipping curve
curve[n] = (y > 0) ? y : ((1 - ((amount > 0.5) ? 0.5 : amount)) * y);
}
const shaper = new WaveShaperNode(context, { curve, oversample: '2x' });
// If use `createWaveShaper`
// const shaper = context.createWaveShaper();
//
// shaper.curve = curve;
// shaper.oversample = '2x';
const oscillator = new OscillatorNode(context);
// Connect nodes
// OscillatorNode (Input) -> WaveShaperNode (Clipping) -> AudioDestinationNode (Output)
oscillator.connect(shaper);
shaper.connect(context.destination);
// Start oscillator immediately
oscillator.start(0);
// Stop oscillator after 10 sec
oscillator.stop(context.currentTime + 10);
また, 対称性に関わらず, Overdrive のクリッピングカーブは滑らかな曲線を設定することが多く, ソフトクリッピング された波形となります (逆に,
Distortion や Fuzz などは, 急峻に変化する曲線や非線形に直線を組み合わせた設定にすることが多く, ハードクリッピング された波形となります).
ソフトクリッピング (左) とハードクリッピング (右)
全波整流・半波整流
最も激しい歪みに分類される Fuzz では, クリッピングだけでなく, 絶対値をとる全波整流 , あるいは,
負数となる振幅を 0
にする半波整流 が実装されていることが多いです (ちなみに,
「整流器 」を英語に訳すと「Rectifier 」ですが, ハイゲイン真空管アンプで有名な Mesa/Boogie 社の
Dual Rectifier
などはこれに由来します)
整流 (左が全波整流, 右が半波整流)
整流のクリッピングカーブ (curve
プロパティの値) は直線の組み合わせなので, 手動的に設定できます. 全波整流であれば
new Float32Array([1, 0, 1])
, 半波整流であれば new Float32Array([0, 0, 1])
を設定します (全波整流は,
オートワウのセクションで解説した, エンベロープフォロワー でもあります). クリッピングカーブの形状は,
クリッピングカーブ で Curve Type
を Full Wave Rectifier
, または,
Half Wave Rectifier
に選択すると確認できます.
Fuzz では, 整流とハードクリッピングを組み合わせるので, 整流のクリッピングカーブの Float32Array
の
1
となっている値をハードクリッピングする値に設定します.
Fuzz (整流とハードクリッピング)
例えば, 0.5
でハードクリッピングする場合, 全波整流であれば new Float32Array([0.5, 0, 0.5])
, 半波整流であれば
new Float32Array([0, 0, 0.5])
) に設定すれば, 整流とハードクリッピングを組み合わせたクリッピングカーブとなります.
クリッピングカーブの形状は, クリッピングカーブ で Curve Type
を
Full Wave Rectifier + Hard Clipping
, または, Half Wave Rectifier + Hard Clipping
に選択して,
Clipping Level
の値を変更することで確認できます.
const context = new AudioContext();
const level = 0.5;
// Full Wave Rectifier
const curve = new Float32Array([level, 0, level]);
// If use Half Wave Rectifier
// const curve = new Float32Array([0, 0, level]);
const shaper = new WaveShaperNode(context, { curve, oversample: '4x' });
// If use `createWaveShaper`
// const shaper = context.createWaveShaper();
//
// shaper.curve = curve;
// shaper.oversample = '4x';
const oscillator = new OscillatorNode(context);
// Connect nodes
// OscillatorNode (Input) -> WaveShaperNode (Clipping) -> AudioDestinationNode (Output)
oscillator.connect(shaper);
shaper.connect(context.destination);
// Start oscillator immediately
oscillator.start(0);
// Stop oscillator after 10 sec
oscillator.stop(context.currentTime + 10);
歪み系エフェクターによるディストーションサウンドのインタラクティブなコード例は, アンプミュレーターと合わせたいので,
アンプシミュレーターと歪み系エフェクターによるディストーションサウンド のセクションで記載しています.
アンプシミュレーター
ギターアンプにおけるアンプとは, プリアンプ , パワーアンプ , キャビネットとスピーカー (コンボアンプやスモールアンプでは,
キャビネットはなくて, スピーカーのみであることが多いですが, アンプ本来の音をできるだけ忠実に再現することを目的に解説するので,
キャビネット内にスピーカーが内蔵されているようなスタックアンプをイメージしていただくのがよいかもしれません) で構成されています.
パワーアンプは振幅を増幅させるのが主な役割なので,
アンプ独特のディストーションサウンドは, プリアンプとキャビネットとスピーカーによってつくられます
(アンプシミュレーターやマルチエフェクターによっては, プリアンプの設定として, キャビネットとスピーカー構成の設定も含まれています).
特に, プリアンプの実装が, アンプによるディストーションサウンドにおいて重要となります . アンプシミュレーターを実装するとなると,
モデリングしたいアンプの数だけ実装が存在することになりますが, エフェクターによるディストーションサウンドと比較して,
ほとんどのプリアンプで共通して異なる点は, クリッピングの前後にフィルタ処理 があること, そして,
プリアンプの個性となる多段のクリッピングとゲインです .
もっとも, モデリングするアンプの数だけ実装を解説することはできないので, このセクションでは,
WEB AMP like Marshall
を参考に, ほとんどのスタジオで設置されている Marshall (ライクな) アンプシミュレーターの実装を解説します (さらに, Marshall といっても,
年代ごとに特色があり,
古くは, JTM45, JMP (主に, 1960 年代後半から 70 年代のモデル. 特に, 有名なのが Marshall 1959 Super Lead Plexi), JCM (800, 900, 2000), 最近だと DSL
や JVM など様々です
(Marshall 1959 Super Lead Plexi の詳細).
あくまでこのセクションでの実装は Marshall ライクな実装と捉えてください).
プリアンプ
Marshall ライクなプリアンプの特徴として, 2 つのクリッピング処理による歪み と, クリッピングのプリプロセスとなる High-Pass Filter と Low-Pass Filter によるフィルタ処理 , また, 2 つのクリッピングの間にもフィルタ処理があること, そして, クリッピングのポストプロセスとしてポストイコライザー があることです.
さらに, 中低域のゲインコントロールと, 高音域のゲインコントロールがあります.
まずは, プリプロセスとなるフィルタ処理を実装します.
const context = new AudioContext();
const preLowpass = new BiquadFilterNode(context, { type: 'lowpass', frequency: 3200, Q: -3 });
const preHighpass1 = new BiquadFilterNode(context, { type: 'highpass', frequency: 80, Q: -3 });
const preHighpass2 = new BiquadFilterNode(context, { type: 'highpass', frequency: 640, Q: -3 });
const preHighpass3 = new BiquadFilterNode(context, { type: 'highpass', frequency: 160, Q: -3 });
const middleAndBassGain = new GainNode(context, { gain: 0.5 });
const highTrebleGain = new GainNode(context, { gain: 0.5 });
// (AudioSourceNode ->) GainNode (Input) -> BiquadFilterNode (High-Pass Filter) -> BiquadFilterNode (Low-Pass Filter) -> GainNode (Middle and Low Gain) -> BiquadFilterNode (High-Pass Filter)
preHighpass1.connect(preLowpass);
preLowpass.connect(middleAndBassGain);
middleAndBassGain.connect(preHighpass3);
// (AudioSourceNode ->) BiquadFilterNode (High-Pass Filter) -> GainNode (High Treble Gain) -> BiquadFilterNode (High-Pass Filter)
preHighpass2.connect(highTrebleGain);
highTrebleGain.connect(preHighpass3);
// ...
次に, 前段の歪みを生成するクリッピング処理を実装します.
function createCurve(drive, numberOfSamples) {
const curves = new Float32Array(numberOfSamples);
const index = Math.trunc((numberOfSamples - 1) / 2);
const d = (10 ** ((drive / 5.0) - 1.0)) - 0.1;
const c = (d / 5.0) + 1.0;
let peak = 0.4;
if (c === 1) {
peak = 1.0;
} else if ((c > 1) && (c < 1.04)) {
peak = (-15.5 * c) + 16.52;
}
for (let i = 0; i < index; i++) {
curves[index + i] = peak * (((+1) - (c ** (-i))) + ((i * (c ** (-index))) / index));
curves[index - i] = peak * (((-1) + (c ** (-i))) - ((i * (c ** (-index))) / index));
}
curves[index] = 0;
return curves;
}
// If `drive` is about `3`, distortion sound is Crunch.
// If `drive` is about `5`, distortion sound is Overdrive.
// If `drive` is about `8`, distortion sound is Distortion.
const drive = 5;
const samples = 127;
const curve = createCurve(drive, samples);
const oversample = '4x';
const preShaper = new WaveShaperNode(context, { curve, oversample });
// (... ->) BiquadFilterNode (High-Pass Filter) -> WaveShaperNode (Pre Clipping)
preHighpass3.connect(preShaper);
// ...
そして, 前段の歪み後段の歪みの間のフィルタ処理を実装します.
const lowpass = new BiquadFilterNode(context, { type: 'lowpass', frequency: 4000, Q: -3 });
const highpass = new BiquadFilterNode(context, { type: 'highpass', frequency: 40, Q: -3 });
// (... ->) WaveShaperNode (Pre Clipping) -> BiquadFilterNode (Low-Pass Filter) -> BiquadFilterNode (High-Pass Filter)
preShaper.connect(lowpass);
lowpass.connect(highpass);
// ...
後段の歪みを生成するクリッピング処理を実装します (このクリッピングカーブは前段と同じです).
const postShaper = new WaveShaperNode(context, { curve, oversample });
// (... ->) BiquadFilterNode (High-Pass Filter) -> WaveShaperNode (Post Clipping)
highpass.connect(postShaper);
// ...
最後は, ポストイコライザーの実装ですが, これはすでに解説している 3 バンドイコライザーを追加するだけです.
const bass = new BiquadFilterNode(context, { type: 'lowshelf', frequency: 240 });
const middle = new BiquadFilterNode(context, { type: 'peaking', frequency: 500, Q: 1.5 });
const treble = new BiquadFilterNode(context, { type: 'highshelf', frequency: 1600 });
// (... ->) WaveShaperNode (Post Clipping) -> Post Equalizer (Low-Shelving Filter -> Peaking Filter -> High-Shelving Filter) -> AudioDestinationNode (Output)
postShaper.connect(bass);
bass.connect(middle);
middle.connect(treble);
treble.connect(context.destination);
ここまでのコードを網羅して記載すると, 以下のようになります.
function createCurve(drive, numberOfSamples) {
const curves = new Float32Array(numberOfSamples);
const index = Math.trunc((numberOfSamples - 1) / 2);
const d = (10 ** ((drive / 5.0) - 1.0)) - 0.1;
const c = (d / 5.0) + 1.0;
let peak = 0.4;
if (c === 1) {
peak = 1.0;
} else if ((c > 1) && (c < 1.04)) {
peak = (-15.5 * c) + 16.52;
}
for (let i = 0; i < index; i++) {
curves[index + i] = peak * (((+1) - (c ** (-i))) + ((i * (c ** (-index))) / index));
curves[index - i] = peak * (((-1) + (c ** (-i))) - ((i * (c ** (-index))) / index));
}
curves[index] = 0;
return curves;
}
const context = new AudioContext();
const preLowpass = new BiquadFilterNode(context, { type: 'lowpass', frequency: 3200, Q: -3 });
const preHighpass1 = new BiquadFilterNode(context, { type: 'highpass', frequency: 80, Q: -3 });
const preHighpass2 = new BiquadFilterNode(context, { type: 'highpass', frequency: 640, Q: -3 });
const preHighpass3 = new BiquadFilterNode(context, { type: 'highpass', frequency: 160, Q: -3 });
const middleAndBassGain = new GainNode(context, { gain: 0.5 });
const highTrebleGain = new GainNode(context, { gain: 0.5 });
// If `drive` is about `3`, distortion sound is Crunch.
// If `drive` is about `5`, distortion sound is Overdrive.
// If `drive` is about `8`, distortion sound is Distortion.
const drive = 5;
const samples = 127;
const curve = createCurve(drive, samples);
const oversample = '4x';
const preShaper = new WaveShaperNode(context, { curve, oversample });
const postShaper = new WaveShaperNode(context, { curve, oversample });
const lowpass = new BiquadFilterNode(context, { type: 'lowpass', frequency: 4000, Q: -3 });
const highpass = new BiquadFilterNode(context, { type: 'highpass', frequency: 40, Q: -3 });
const bass = new BiquadFilterNode(context, { type: 'lowshelf', frequency: 240 });
const middle = new BiquadFilterNode(context, { type: 'peaking', frequency: 500, Q: 1.5 });
const treble = new BiquadFilterNode(context, { type: 'highshelf', frequency: 1600 });
// (AudioSourceNode ->) GainNode (Input) -> BiquadFilterNode (High-Pass Filter) -> BiquadFilterNode (Low-Pass Filter) -> GainNode (Middle and Low Gain) -> BiquadFilterNode (High-Pass Filter)
preHighpass1.connect(preLowpass);
preLowpass.connect(middleAndBassGain);
middleAndBassGain.connect(preHighpass3);
// (AudioSourceNode ->) BiquadFilterNode (High-Pass Filter) -> GainNode (High Treble Gain) -> BiquadFilterNode (High-Pass Filter)
preHighpass2.connect(highTrebleGain);
highTrebleGain.connect(preHighpass3);
// BiquadFilterNode (High-Pass Filter) -> WaveShaperNode (Pre Clipping)
preHighpass3.connect(preShaper);
// WaveShaperNode (Pre Clipping) -> BiquadFilterNode (Low-Pass Filter) -> BiquadFilterNode (High-Pass Filter)
preShaper.connect(lowpass);
lowpass.connect(highpass);
// BiquadFilterNode (High-Pass Filter) -> WaveShaperNode (Post Clipping)
highpass.connect(postShaper);
// WaveShaperNode (Post Clipping) -> Post Equalizer (Low-Shelving Filter -> Peaking Filter -> High-Shelving Filter) -> AudioDestinationNode (Output)
postShaper.connect(bass);
bass.connect(middle);
middle.connect(treble);
treble.connect(context.destination);
ここまでで, Marshall ライクなプリアンプが実装できたので, エレキギターのクリーントーンのワンショットオーディオを利用して,
プリアンプで歪ませたディストーションサウンドを聴いてみてください (プリアンプによるディストーションサウンドは,
多段のフィルタやクリッピングによって, どうしても音が大きくなってしまうので, 最終的な音量を調整できるように, Master Volume となる
GainNode
を出力前に接続しています).
function createCurve(drive, numberOfSamples) {
const curves = new Float32Array(numberOfSamples);
const index = Math.trunc((numberOfSamples - 1) / 2);
const d = (10 ** ((drive / 5.0) - 1.0)) - 0.1;
const c = (d / 5.0) + 1.0;
let peak = 0.4;
if (c === 1) {
peak = 1.0;
} else if ((c > 1) && (c < 1.04)) {
peak = (-15.5 * c) + 16.52;
}
for (let i = 0; i < index; i++) {
curves[index + i] = peak * (((+1) - (c ** (-i))) + ((i * (c ** (-index))) / index));
curves[index - i] = peak * (((-1) + (c ** (-i))) - ((i * (c ** (-index))) / index));
}
curves[index] = 0;
return curves;
}
const context = new AudioContext();
const mastervolume = new GainNode(context, { gain: 0.3 });
const preLowpass = new BiquadFilterNode(context, { type: 'lowpass', frequency: 3200, Q: -3 });
const preHighpass1 = new BiquadFilterNode(context, { type: 'highpass', frequency: 80, Q: -3 });
const preHighpass2 = new BiquadFilterNode(context, { type: 'highpass', frequency: 640, Q: -3 });
const preHighpass3 = new BiquadFilterNode(context, { type: 'highpass', frequency: 160, Q: -3 });
const middleAndBassGain = new GainNode(context, { gain: 0.5 });
const highTrebleGain = new GainNode(context, { gain: 0.5 });
// If `drive` is about `3`, distortion sound is Crunch.
// If `drive` is about `5`, distortion sound is Overdrive.
// If `drive` is about `8`, distortion sound is Distortion.
const drive = 5;
const samples = 127;
const curve = createCurve(drive, samples);
const oversample = '4x';
const preShaper = new WaveShaperNode(context, { curve, oversample });
const postShaper = new WaveShaperNode(context, { curve, oversample });
const lowpass = new BiquadFilterNode(context, { type: 'lowpass', frequency: 4000, Q: -3 });
const highpass = new BiquadFilterNode(context, { type: 'highpass', frequency: 40, Q: -3 });
const bass = new BiquadFilterNode(context, { type: 'lowshelf', frequency: 240 });
const middle = new BiquadFilterNode(context, { type: 'peaking', frequency: 500, Q: 1.5 });
const treble = new BiquadFilterNode(context, { type: 'highshelf', frequency: 1600 });
fetch('./assets/one-shots/electric-guitar-clean-chord.mp3')
.then((response) => {
return response.arrayBuffer();
})
.then((arrayBuffer) => {
const successCallback = (audioBuffer) => {
const source = new AudioBufferSourceNode(context, { buffer: audioBuffer });
// AudioBufferSourceNode (Input) -> BiquadFilterNode (High-Pass Filter) -> BiquadFilterNode (Low-Pass Filter) -> GainNode (Middle and Low Gain) -> BiquadFilterNode (High-Pass Filter)
source.connect(preHighpass1);
preHighpass1.connect(preLowpass);
preLowpass.connect(middleAndBassGain);
middleAndBassGain.connect(preHighpass3);
// AudioBufferSourceNode (Input) -> BiquadFilterNode (High-Pass Filter) -> GainNode (High Treble Gain) -> BiquadFilterNode (High-Pass Filter)
source.connect(preHighpass2);
preHighpass2.connect(highTrebleGain);
highTrebleGain.connect(preHighpass3);
// BiquadFilterNode (High-Pass Filter) -> WaveShaperNode (Pre Clipping)
preHighpass3.connect(preShaper);
// WaveShaperNode (Pre Clipping) -> BiquadFilterNode (Low-Pass Filter) -> BiquadFilterNode (High-Pass Filter)
preShaper.connect(lowpass);
lowpass.connect(highpass);
// BiquadFilterNode (High-Pass Filter) -> WaveShaperNode (Post Clipping)
highpass.connect(postShaper);
// WaveShaperNode (Post Clipping) -> Post Equalizer (Low-Shelving Filter -> Peaking Filter -> High-Shelving Filter) -> GainNode (Master Volume)
postShaper.connect(bass);
bass.connect(middle);
middle.connect(treble);
treble.connect(mastervolume);
// GainNode (Master Volume) -> AudioDestinationNode (Output)
mastervolume.connect(context.destination);
source.start(0);
};
const errorCallback = (error) => {
// error handling
};
context.decodeAudioData(arrayBuffer, successCallback, errorCallback);
})
.catch((error) => {
// error handling
});
(Marshall ライクな) プリアンプのノード接続図
キャビネットとスピーカーシミュレーター
プリアンプで生成された音は, 実際のアンプ, 特に, スタックアンプのような,
キャビネットとそれに内蔵されているスピーカーから発生するような音にはなりません. それを再現するために,
アンプシミュレーターではプリアンプだけでなく, キャビネットのタイプやスピーカーのサイズや数 (例えば, 12 インチを 4 つなど) を選択できる,
音楽的な表現だと, いわゆる, 箱鳴り感 を再現できる, スピーカーシミュレーター も含まれています.
実際に存在するキャビネットとスピーカーをシミュレートするには, キャビネット内のインパルス応答を測定したオーディオデータと
ConvolverNode
を利用して実装するのがベストです. しかしながら, RIR と異なって, フリーで使える音源が少ないことも考慮して,
簡易的なスピーカーシミュレーターの実装を解説します (簡易的なスピーカーシミュレーターでも, 箱鳴り感は十分再現できます).
簡易的なスピーカーシミュレーターの実装であれば, Low-Pass Filter と Notch Filter を接続することで実装できます.
const context = new AudioContext();
const speakerSimulatorNotch = new BiquadFilterNode(context, { type: 'notch', frequency: 8000, Q: 1 });
const speakerSimulatorLowpass = new BiquadFilterNode(context, { type: 'lowpass', frequency: 3200, Q: 6 });
// BiquadFilterNode (Notch Filter) -> BiquadFilterNode (Low-Pass Filter)
speakerSimulatorNotch.connect(speakerSimulatorLowpass);
スピーカーシミュレーターのノード接続を, プリアンプの次に接続します. (プリアンプとスピーカーシミュレーターの処理の後の音量を調整できるように,
Master Volume となる GainNode
の接続は, スピーカーシミュレーターの後に変更します. つまり,
スピーカーシミュレーターの接続先となります).
function createCurve(drive, numberOfSamples) {
const curves = new Float32Array(numberOfSamples);
const index = Math.trunc((numberOfSamples - 1) / 2);
const d = (10 ** ((drive / 5.0) - 1.0)) - 0.1;
const c = (d / 5.0) + 1.0;
let peak = 0.4;
if (c === 1) {
peak = 1.0;
} else if ((c > 1) && (c < 1.04)) {
peak = (-15.5 * c) + 16.52;
}
for (let i = 0; i < index; i++) {
curves[index + i] = peak * (((+1) - (c ** (-i))) + ((i * (c ** (-index))) / index));
curves[index - i] = peak * (((-1) + (c ** (-i))) - ((i * (c ** (-index))) / index));
}
curves[index] = 0;
return curves;
}
const context = new AudioContext();
const mastervolume = new GainNode(context, { gain: 0.3 });
const preLowpass = new BiquadFilterNode(context, { type: 'lowpass', frequency: 3200, Q: -3 });
const preHighpass1 = new BiquadFilterNode(context, { type: 'highpass', frequency: 80, Q: -3 });
const preHighpass2 = new BiquadFilterNode(context, { type: 'highpass', frequency: 640, Q: -3 });
const preHighpass3 = new BiquadFilterNode(context, { type: 'highpass', frequency: 160, Q: -3 });
const middleAndBassGain = new GainNode(context, { gain: 0.5 });
const highTrebleGain = new GainNode(context, { gain: 0.5 });
// If `drive` is about `3`, distortion sound is Crunch.
// If `drive` is about `5`, distortion sound is Overdrive.
// If `drive` is about `8`, distortion sound is Distortion.
const drive = 5;
const samples = 127;
const curve = createCurve(drive, samples);
const oversample = '4x';
const preShaper = new WaveShaperNode(context, { curve, oversample });
const postShaper = new WaveShaperNode(context, { curve, oversample });
const lowpass = new BiquadFilterNode(context, { type: 'lowpass', frequency: 4000, Q: -3 });
const highpass = new BiquadFilterNode(context, { type: 'highpass', frequency: 40, Q: -3 });
const bass = new BiquadFilterNode(context, { type: 'lowshelf', frequency: 240 });
const middle = new BiquadFilterNode(context, { type: 'peaking', frequency: 500, Q: 1.5 });
const treble = new BiquadFilterNode(context, { type: 'highshelf', frequency: 1600 });
const speakerSimulatorNotch = new BiquadFilterNode(context, { type: 'notch', frequency: 8000, Q: 1 });
const speakerSimulatorLowpass = new BiquadFilterNode(context, { type: 'lowpass', frequency: 3200, Q: 6 });
fetch('./assets/one-shots/electric-guitar-clean-chord.mp3')
.then((response) => {
return response.arrayBuffer();
})
.then((arrayBuffer) => {
const successCallback = (audioBuffer) => {
const source = new AudioBufferSourceNode(context, { buffer: audioBuffer });
// Preamp
// AudioBufferSourceNode (Input) -> BiquadFilterNode (High-Pass Filter) -> BiquadFilterNode (Low-Pass Filter) -> GainNode (Middle and Low Gain) -> BiquadFilterNode (High-Pass Filter)
source.connect(preHighpass1);
preHighpass1.connect(preLowpass);
preLowpass.connect(middleAndBassGain);
middleAndBassGain.connect(preHighpass3);
// AudioBufferSourceNode (Input) -> BiquadFilterNode (High-Pass Filter) -> GainNode (High Treble Gain) -> BiquadFilterNode (High-Pass Filter)
source.connect(preHighpass2);
preHighpass2.connect(highTrebleGain);
highTrebleGain.connect(preHighpass3);
// BiquadFilterNode (High-Pass Filter) -> WaveShaperNode (Pre Clipping)
preHighpass3.connect(preShaper);
// WaveShaperNode (Pre Clipping) -> BiquadFilterNode (Low-Pass Filter) -> BiquadFilterNode (High-Pass Filter)
preShaper.connect(lowpass);
lowpass.connect(highpass);
// BiquadFilterNode (High-Pass Filter) -> WaveShaperNode (Post Clipping)
highpass.connect(postShaper);
// WaveShaperNode (Post Clipping) -> Post Equalizer (Low-Shelving Filter -> Peaking Filter -> High-Shelving Filter) -> BiquadFilterNode (Speaker Simulator)
postShaper.connect(bass);
bass.connect(middle);
middle.connect(treble);
treble.connect(speakerSimulatorNotch);
// Speaker Simulator
// BiquadFilterNode (Notch Filter) -> BiquadFilterNode (Low-Pass Filter) -> GainNode (Master Volume);
speakerSimulatorNotch.connect(speakerSimulatorLowpass);
speakerSimulatorLowpass.connect(mastervolume);
// GainNode (Master Volume) -> AudioDestinationNode (Output)
mastervolume.connect(context.destination);
source.start(0);
};
const errorCallback = (error) => {
// error handling
};
context.decodeAudioData(arrayBuffer, successCallback, errorCallback);
})
.catch((error) => {
// error handling
});
プリアンプとスピーカーシミュレーターのノード接続図
ConvolverNode によるスピーカーシミュレーターの実装
シミューレートしたいキャビネットとスピーカーのインパルス応答を測定したオーディオデータがあれば, リバーブを実装するのと同様に,
ConvolverNode
の buffer
プロパティに, そのオーディオデータの AudioBuffer
インスタンスを設定することで,
より忠実にキャビネットとスピーカーをシミュレートすることが可能です.
キャビネット内を小さな部屋とみなせば, RIR をコンボリューション積分しているのと同じであり, また, キャビネット内をシステム (系) とみなせば,
キャビネット内をモデリングする伝達関数を乗算しているのと同じことです.
スピーカーシミュレーターとしての ConvolverNode
インスタンスも, プリアンプの次に接続します (audioBuffer
変数は,
Fetch API
などから取得した ArrayBuffer
インスタンスを AudioContext
インスタンスの
decodeAudioData
メソッドの引数に指定して生成した (キャビネットとスピーカーのインパルス応答のオーディオデータの実体となる)
AudioBuffer
インスタンスと考えてください).
function createCurve(drive, numberOfSamples) {
const curves = new Float32Array(numberOfSamples);
const index = Math.trunc((numberOfSamples - 1) / 2);
const d = (10 ** ((drive / 5.0) - 1.0)) - 0.1;
const c = (d / 5.0) + 1.0;
let peak = 0.4;
if (c === 1) {
peak = 1.0;
} else if ((c > 1) && (c < 1.04)) {
peak = (-15.5 * c) + 16.52;
}
for (let i = 0; i < index; i++) {
curves[index + i] = peak * (((+1) - (c ** (-i))) + ((i * (c ** (-index))) / index));
curves[index - i] = peak * (((-1) + (c ** (-i))) - ((i * (c ** (-index))) / index));
}
curves[index] = 0;
return curves;
}
const context = new AudioContext();
const mastervolume = new GainNode(context, { gain: 0.3 });
const preLowpass = new BiquadFilterNode(context, { type: 'lowpass', frequency: 3200, Q: -3 });
const preHighpass1 = new BiquadFilterNode(context, { type: 'highpass', frequency: 80, Q: -3 });
const preHighpass2 = new BiquadFilterNode(context, { type: 'highpass', frequency: 640, Q: -3 });
const preHighpass3 = new BiquadFilterNode(context, { type: 'highpass', frequency: 160, Q: -3 });
const middleAndBassGain = new GainNode(context, { gain: 0.5 });
const highTrebleGain = new GainNode(context, { gain: 0.5 });
// If `drive` is about `3`, distortion sound is Crunch.
// If `drive` is about `5`, distortion sound is Overdrive.
// If `drive` is about `8`, distortion sound is Distortion.
const drive = 5;
const samples = 127;
const curve = createCurve(drive, samples);
const oversample = '4x';
const preShaper = new WaveShaperNode(context, { curve, oversample });
const postShaper = new WaveShaperNode(context, { curve, oversample });
const lowpass = new BiquadFilterNode(context, { type: 'lowpass', frequency: 4000, Q: -3 });
const highpass = new BiquadFilterNode(context, { type: 'highpass', frequency: 40, Q: -3 });
const bass = new BiquadFilterNode(context, { type: 'lowshelf', frequency: 240 });
const middle = new BiquadFilterNode(context, { type: 'peaking', frequency: 500, Q: 1.5 });
const treble = new BiquadFilterNode(context, { type: 'highshelf', frequency: 1600 });
const speakerSimulator = new ConvolverNode(context);
// Set instance of `AudioBuffer` that is audio data in cabinet to `buffer` property
// speakerSimulator.buffer = audioBuffer;
// (AudioSourceNode ->) BiquadFilterNode (High-Pass Filter) -> BiquadFilterNode (Low-Pass Filter) -> GainNode (Middle and Low Gain) -> BiquadFilterNode (High-Pass Filter)
preHighpass1.connect(preLowpass);
preLowpass.connect(middleAndBassGain);
middleAndBassGain.connect(preHighpass3);
// (AudioSourceNode ->) BiquadFilterNode (High-Pass Filter) -> GainNode (High Treble Gain) -> BiquadFilterNode (High-Pass Filter)
preHighpass2.connect(highTrebleGain);
highTrebleGain.connect(preHighpass3);
// BiquadFilterNode (High-Pass Filter) -> WaveShaperNode (Pre Clipping)
preHighpass3.connect(preShaper);
// WaveShaperNode (Pre Clipping) -> BiquadFilterNode (Low-Pass Filter) -> BiquadFilterNode (High-Pass Filter)
preShaper.connect(lowpass);
lowpass.connect(highpass);
// BiquadFilterNode (High-Pass Filter) -> WaveShaperNode (Post Clipping)
highpass.connect(postShaper);
// WaveShaperNode (Post Clipping) -> Post Equalizer (Low-Shelving Filter -> Peaking Filter -> High-Shelving Filter) -> ConvolverNode (Speaker Simulator)
postShaper.connect(bass);
bass.connect(middle);
middle.connect(treble);
treble.connect(speakerSimulator);
// ConvolverNode (Speaker Simulator) -> GainNode (Master Volume)
speakerSimulator.connect(mastervolume);
// GainNode (Master Volume) -> AudioDestinationNode (Output)
mastervolume.connect(context.destination);
アンプシミュレーターと歪み系エフェクターによるディストーションサウンド
ここまでで, アンプ (アンプシミュレーター) と歪み系エフェクターによるディストーションサウンドを解説したので, それらを組み合わせて, さらに,
ハードコーディングしていたパラメータをインタラクティブに制御できるようにしたコード例です.
<div>
<button type="button" id="button-picking-down" data-index="0">Picking Down</button>
<button type="button" id="button-picking-up" data-index="1">Picking Up</button>
<button type="button" id="button-chord" data-index="2">Chord</button>
</div>
<dl>
<dt><label for="select-distortion-type">Overdrive / Fuzz</label></dt>
<dd>
<select id="select-distortion-type">
<option value="" selected>none</option>
<option value="overdrive">Overdrive</option>
<option value="fuzz">Fuzz</option>
</select>
</dd>
<dt><label for="range-distortion-drive">Drive</label></dt>
<dd>
<input type="range" id="range-distortion-drive" value="0.5" min="0" max="0.95" step="0.05" disabled />
<span id="print-distortion-drive-value">0.5</span>
</dd>
</dl>
<dl>
<dt>
<label for="checkbox-preamp"><span>Preamp</span></label>
</dt>
<dd><input type="checkbox" id="checkbox-preamp" checked /></dd>
</dl>
<dl>
<dt><label for="range-preamp-normal-gain">Normal Gain (Middle and Low Gain)</label></dt>
<dd>
<input type="range" id="range-preamp-normal-gain" value="0.5" min="0" max="1" step="0.05" />
<span id="print-preamp-normal-gain-value">0.5</span>
</dd>
<dt><label for="range-preamp-high-treble-gain">High Treble Gain</label></dt>
<dd>
<input type="range" id="range-preamp-high-treble-gain" value="0.5" min="0" max="1" step="0.05" />
<span id="print-preamp-high-treble-value">0.5</span>
</dd>
<dt><label for="range-preamp-drive">Drive</label></dt>
<dd>
<input type="range" id="range-preamp-drive" value="5" min="0" max="10" step="0.5" />
<span id="print-preamp-drive-value">5.0</span>
</dd>
<dt><label for="range-preamp-post-equalizer-bass">Bass</label></dt>
<dd>
<input type="range" id="range-preamp-post-equalizer-bass" value="0" min="-24" max="24" step="1" />
<span id="print-preamp-post-equalizer-bass-value">0 dB</span>
</dd>
<dt><label for="range-preamp-post-equalizer-middle">Middle</label></dt>
<dd>
<input type="range" id="range-preamp-post-equalizer-middle" value="0" min="-24" max="24" step="1" />
<span id="print-preamp-post-equalizer-middle-value">0 dB</span>
</dd>
<dt><label for="range-preamp-post-equalizer-treble">Treble</label></dt>
<dd>
<input type="range" id="range-preamp-post-equalizer-treble" value="0" min="-24" max="24" step="1" />
<span id="print-preamp-post-equalizer-treble-value">0 dB</span>
</dd>
</dl>
<dl>
<dt>
<label for="checkbox-speaker-simulator"><span>Speaker Simulator</span></label>
</dt>
<dd><input type="checkbox" id="checkbox-speaker-simulator" checked /></dd>
</dl>
<dl>
<dt><label for="range-distortion-mastervolume">Master Volume</label></dt>
<dd>
<input type="range" id="range-distortion-mastervolume" value="0.5" min="0" max="1" step="0.05" />
<span id="print-distortion-mastervolume-value">0.5</span>
</dd>
</dl>
function createPreampCurve(drive, numberOfSamples) {
const curves = new Float32Array(numberOfSamples);
const index = Math.trunc((numberOfSamples - 1) / 2);
const d = (10 ** ((drive / 5.0) - 1.0)) - 0.1;
const c = (d / 5.0) + 1.0;
let peak = 0.4;
if (c === 1) {
peak = 1.0;
} else if ((c > 1) && (c < 1.04)) {
peak = (-15.5 * c) + 16.52;
}
for (let i = 0; i < index; i++) {
curves[index + i] = peak * (((+1) - (c ** (-i))) + ((i * (c ** (-index))) / index));
curves[index - i] = peak * (((-1) + (c ** (-i))) - ((i * (c ** (-index))) / index));
}
curves[index] = 0;
return curves;
}
function createAsymmetricalOverdriveCurve(drive, numberOfSamples) {
if (drive < 0 || drive >= 1) {
return null;
}
const curves = new Float32Array(numberOfSamples);
const k = (2 * drive) / (1 - drive);
for (let n = 0; n < numberOfSamples; n++) {
const x = (((n - 0) * (1 - (-1))) / (numberOfSamples - 0)) + (-1);
const y = ((1 + k) * x) / (1 + (k * Math.abs(x)));
// Asymmetrical clipping curve
curves[n] = (y > 0) ? y : ((1 - ((drive > 0.5) ? 0.5 : drive)) * y);
}
return curves;
}
const context = new AudioContext();
const samples = 127;
const oversample = '4x';
const shaper = new WaveShaperNode(context);
const preLowpass = new BiquadFilterNode(context, { type: 'lowpass', frequency: 3200, Q: -3 });
const preHighpass1 = new BiquadFilterNode(context, { type: 'highpass', frequency: 80, Q: -3 });
const preHighpass2 = new BiquadFilterNode(context, { type: 'highpass', frequency: 640, Q: -3 });
const preHighpass3 = new BiquadFilterNode(context, { type: 'highpass', frequency: 160, Q: -3 });
const highTrebleGain = new GainNode(context, { gain: 0.5 });
const middleAndBassGain = new GainNode(context, { gain: 0.5 });
const preShaper = new WaveShaperNode(context, { curve: createPreampCurve(5.0, samples), oversample });
const postShaper = new WaveShaperNode(context, { curve: createPreampCurve(5.0, samples), oversample });
const lowpass = new BiquadFilterNode(context, { type: 'lowpass', frequency: 4000, Q: -3 });
const highpass = new BiquadFilterNode(context, { type: 'highpass', frequency: 40, Q: -3 });
const bass = new BiquadFilterNode(context, { type: 'lowshelf', frequency: 240 });
const middle = new BiquadFilterNode(context, { type: 'peaking', frequency: 500, Q: 1.5 });
const treble = new BiquadFilterNode(context, { type: 'highshelf', frequency: 1600 });
const speakerSimulatorNotch = new BiquadFilterNode(context, { type: 'notch', frequency: 8000, Q: 1 });
const speakerSimulatorLowpass = new BiquadFilterNode(context, { type: 'lowpass', frequency: 3200, Q: 6 });
const mastervolume = new GainNode(context, { gain: 0.5 });
const buttonElementPickingDown = document.getElementById('button-picking-down');
const buttonElementPickingUp = document.getElementById('button-picking-up');
const buttonElementChord = document.getElementById('button-chord');
const checkboxElementPreamp = document.getElementById('checkbox-preamp');
const checkboxElementSpeakerSimulator = document.getElementById('checkbox-speaker-simulator');
const rangePreampNormalGainElement = document.getElementById('range-preamp-normal-gain');
const rangePreampHighTrebleGainElement = document.getElementById('range-preamp-high-treble-gain');
const rangePreampDriveElement = document.getElementById('range-preamp-drive');
const rangePreampBassElement = document.getElementById('range-preamp-post-equalizer-bass');
const rangePreampMiddleElement = document.getElementById('range-preamp-post-equalizer-middle');
const rangePreampTrebleElement = document.getElementById('range-preamp-post-equalizer-treble');
const selectEffectorDistortionTypeElement = document.getElementById('select-distortion-type');
const rangeEffectorDistortionDriveElement = document.getElementById('range-distortion-drive');
const rangeDistortionMasterVolumeElement = document.getElementById('range-distortion-mastervolume');
const spanPrintPreampNormalGainElement = document.getElementById('print-preamp-normal-gain-value');
const spanPrintPreampHighTrebleGainElement = document.getElementById('print-preamp-high-treble-value');
const spanPrintPreampDriveElement = document.getElementById('print-preamp-drive-value');
const spanPrintPreampBassElement = document.getElementById('print-preamp-post-equalizer-bass-value');
const spanPrintPreampMiddleElement = document.getElementById('print-preamp-post-equalizer-middle-value');
const spanPrintPreampTrebleElement = document.getElementById('print-preamp-post-equalizer-treble-value');
const spanPrintEffectorDistortionDriveElement = document.getElementById('print-distortion-drive-value');
const spanPrintDistortionMasterVolumeElement = document.getElementById('print-distortion-mastervolume-value');
Promise
.all([
fetch('./assets/one-shots/electric-guitar-clean-picking-down.mp3'),
fetch('./assets/one-shots/electric-guitar-clean-picking-up.mp3'),
fetch('./assets/one-shots/electric-guitar-clean-chord.mp3')
])
.then(async (responses) => {
const audioBuffers = await Promise.all(responses.map(async (response) => {
const arrayBuffer = await response.arrayBuffer();
const audioBuffer = await context.decodeAudioData(arrayBuffer);
return audioBuffer;
}));
const onDown = async (event) => {
const index = Number(event.target.getAttribute('data-index'));
const buffer = audioBuffers[index];
const source = new AudioBufferSourceNode(context, { buffer });
shaper.disconnect(0);
treble.disconnect(0);
speakerSimulatorLowpass.disconnect(0);
source.connect(shaper);
if (checkboxElementPreamp.checked && checkboxElementSpeakerSimulator.checked) {
shaper.connect(preHighpass1);
preHighpass1.connect(preLowpass);
preLowpass.connect(middleAndBassGain);
middleAndBassGain.connect(preHighpass3);
shaper.connect(preHighpass2);
preHighpass2.connect(highTrebleGain);
highTrebleGain.connect(preHighpass3);
preHighpass3.connect(preShaper);
preShaper.connect(lowpass);
lowpass.connect(highpass);
highpass.connect(postShaper);
postShaper.connect(bass);
bass.connect(middle);
middle.connect(treble);
treble.connect(speakerSimulatorNotch);
speakerSimulatorNotch.connect(speakerSimulatorLowpass);
speakerSimulatorLowpass.connect(mastervolume);
} else {
if (checkboxElementPreamp.checked) {
shaper.connect(preHighpass1);
preHighpass1.connect(preLowpass);
preLowpass.connect(middleAndBassGain);
middleAndBassGain.connect(preHighpass3);
shaper.connect(preHighpass2);
preHighpass2.connect(highTrebleGain);
highTrebleGain.connect(preHighpass3);
preHighpass3.connect(preShaper);
preShaper.connect(lowpass);
lowpass.connect(highpass);
highpass.connect(postShaper);
postShaper.connect(bass);
bass.connect(middle);
middle.connect(treble);
treble.connect(mastervolume);
} else if (checkboxElementSpeakerSimulator.checked) {
shaper.connect(speakerSimulatorNotch);
speakerSimulatorNotch.connect(speakerSimulatorLowpass);
speakerSimulatorLowpass.connect(mastervolume);
} else {
shaper.connect(mastervolume);
}
}
mastervolume.connect(context.destination);
source.start(0);
};
buttonElementPickingDown.addEventListener('mousedown', onDown);
buttonElementPickingDown.addEventListener('touchstart', onDown);
buttonElementPickingUp.addEventListener('mousedown', onDown);
buttonElementPickingUp.addEventListener('touchstart', onDown);
buttonElementChord.addEventListener('mousedown', onDown);
buttonElementChord.addEventListener('touchstart', onDown);
checkboxElementPreamp.addEventListener('change', (event) => {
if (event.currentTarget.checked) {
rangePreampNormalGainElement.removeAttribute('disabled');
rangePreampHighTrebleGainElement.removeAttribute('disabled');
rangePreampDriveElement.removeAttribute('disabled');
rangePreampBassElement.removeAttribute('disabled');
rangePreampMiddleElement.removeAttribute('disabled');
rangePreampTrebleElement.removeAttribute('disabled');
} else {
rangePreampNormalGainElement.setAttribute('disabled', 'disabled');
rangePreampHighTrebleGainElement.setAttribute('disabled', 'disabled');
rangePreampDriveElement.setAttribute('disabled', 'disabled');
rangePreampBassElement.setAttribute('disabled', 'disabled');
rangePreampMiddleElement.setAttribute('disabled', 'disabled');
rangePreampTrebleElement.setAttribute('disabled', 'disabled');
}
});
selectEffectorDistortionTypeElement.addEventListener('change', (event) => {
const drive = rangeEffectorDistortionDriveElement.valueAsNumber;
rangeEffectorDistortionDriveElement.removeAttribute('disabled');
switch (event.currentTarget.value) {
case 'overdrive': {
shaper.curve = createAsymmetricalOverdriveCurve(drive, samples);
shaper.oversample = '2x';
break;
}
case 'fuzz': {
shaper.curve = new Float32Array([drive, 0, drive]);
shaper.oversample = '4x';
break;
}
default: {
shaper.curve = null;
shaper.oversample = 'none';
rangeEffectorDistortionDriveElement.setAttribute('disabled', 'disabled');
break;
}
}
});
rangeEffectorDistortionDriveElement.addEventListener('input', (event) => {
const drive = event.currentTarget.valueAsNumber;
switch (selectEffectorDistortionTypeElement.value) {
case 'overdrive': {
shaper.curve = createAsymmetricalOverdriveCurve(drive, samples);
shaper.oversample = '2x';
break;
}
case 'fuzz': {
shaper.curve = new Float32Array([drive, 0, drive]);
shaper.oversample = '4x';
break;
}
default: {
shaper.curve = null;
shaper.oversample = 'none';
break;
}
}
spanPrintEffectorDistortionDriveElement.textContent = drive.toFixed(1);
});
rangePreampNormalGainElement.addEventListener('input', (event) => {
const gain = event.currentTarget.valueAsNumber;
middleAndBassGain.gain.value = gain;
spanPrintPreampNormalGainElement.textContent = gain.toString(10);
});
rangePreampHighTrebleGainElement.addEventListener('input', (event) => {
const gain = event.currentTarget.valueAsNumber;
highTrebleGain.gain.value = gain;
spanPrintPreampHighTrebleGainElement.textContent = gain.toString(10);
});
rangePreampDriveElement.addEventListener('input', (event) => {
const drive = event.currentTarget.valueAsNumber;
preShaper.curve = createPreampCurve(drive, samples);
postShaper.curve = createPreampCurve(drive, samples);
spanPrintPreampDriveElement.textContent = drive.toFixed(1);
});
rangePreampBassElement.addEventListener('input', (event) => {
const gain = event.currentTarget.valueAsNumber;
bass.gain.value = gain;
spanPrintPreampBassElement.textContent = `${gain} dB`;
});
rangePreampMiddleElement.addEventListener('input', (event) => {
const gain = event.currentTarget.valueAsNumber;
middle.gain.value = gain;
spanPrintPreampMiddleElement.textContent = `${gain} dB`;
});
rangePreampTrebleElement.addEventListener('input', (event) => {
const gain = event.currentTarget.valueAsNumber;
treble.gain.value = gain;
spanPrintPreampTrebleElement.textContent = `${gain} dB`;
});
rangeDistortionMasterVolumeElement.addEventListener('input', (event) => {
const gain = event.currentTarget.valueAsNumber;
mastervolume.gain.value = gain;
spanPrintDistortionMasterVolumeElement.textContent = gain.toString(10);
});
})
.catch((error) => {
// error handling
});
音源はエレキギターのクリーンサウンドのワンショットオーディオを 3 つ用意しました (単音ピッキング 2 つ (音高ダウンとアップ) とコード弾き).
制御可能なパラメータが多いので, インタラクティブに制御できる部分は最小限にしました. クリッピングカーブの Float32Array
のサイズ,
および, 歪み系エフェクターのオーバードライブカーブは非対称クリッピング, 整流は全波整流に固定していますが, アプリケーションの要件次第では,
これらもインタラクティブに制御可能にすることもあるでしょう. インタラクティブな部分が多く, コードが長くなっていますが, 分解すれば,
これまで解説してきた, 歪み系エフェクターやアンプシミュレーターの実装です.
追加で解説すべきなのは, アンプシミュレーターと歪み系エフェクターの接続順 です. 一般的には
(マルチエフェクターなどエフェクターの接続順が固定されている場合でも),
アンプシミュレーターの前に歪み系エフェクターを接続します (あとのセクションで解説しますが,
エフェクターの接続順は出力されるサウンドに大きく影響します). UI も, 接続順がイメージしやすいように左から順に並べています.
サンプルコードにおける注意点ですが, 歪み系エフェクター, プリアンプ, スピーカーシミュレーターの有効・無効に応じて, 再生時のノード接続が変わるので,
ワンショットオーディオを再生するイベントリスナーで, 歪み系エフェクターのためのノード (コードでの変数名は shaper
),
プリアンプの最後の接続ノード (コードでの変数名は treble
), スピーカーシミュレーターの最後の接続ノード (コードでの変数名は
speakerSimulatorLowpass
) それぞれで disconnect
メソッドを呼び出して, 前回再生時のノード接続をクリアしていることです
(この処理がないと, 例えば, スピーカーシミュレーターつきで再生後, チェックボックスを外して, スピーカーシミュレーターなしで再生すると,
スピーカシミュレーターつきのノード接続が残るので,
スピーカーシミュレーターつきの音とスピーカーシミュレーターなしの音が合成されて再生されてしまいます). サンプルコードではありますが,
disconnect
メソッドの明示的な呼び出しが必要な, 比較的よくあるユースケースです.
Picking Down
Picking Up
Chord
真空管ギターアンプの歴史
モデリングしたいアンプの数だけ解説することは難しいので, Marshall ライクなアンプシミュレーターの実装のみを解説しましたが,
真空管ギターアンプの歴史を知ることで, モデリングしたいアンプの実装のヒントになるかもしれません.
真空管ギタープリアンプの回路設計 で詳細は記載されていますが, 真空管ギターアンプのルーツは大きく分類すると, Fender (フェンダー )
をルーツとする真空管ギターアンプとそれ以外に分離できます. Marshall (マーシャル), Mesa/Boogie (メサ・ブギー), Soldano (ソルダーノ), Bogner
(ボグナー) など, ハイゲインな真空管ギターアンプのルーツは, Fender にあるので, Fender のギターアンプ設計を理解することで,
真空管ギターアンプの定石を知って, モデリングしたいアンプに近い実装が可能になるかもしれません (参考として, Marshall
ライクな実装のみを解説しましたが, 同じ Web ページに, Fender (ライク) な実装も解説されています).
Fender 系以外の (Fender をルーツとしない) ギターアンプとしては, 有名なのは VOX (ヴォックス), HIWATT (ハイワット), Orange (オレンジ)
などがあります.
ちなみに, ハイゲイン真空管アンプの, ENGL (エングル) のルーツをご存知であれば教えていただけるとうれしいです.
コンプレッサー
コンプレッサーは, 振幅の大きな音を (相対的に) 小さく, 振幅の小さな音を (相対的に) 大きくすることによって, 振幅値のオーバーフロー (例として,
量子化ビット 8 bit
で, 2 の補数表現の場合, 127
(0b0111111
) が正の値の上限で, 1
増えると,
-128
(0b1000000
) で負数となって波形が大きくゆがんでしまいます) や (振幅値のオーバーフローを防ぐための)
クリッピング処理による意図しない歪みを防止しつつ, 迫力のあるサウンドに変化させるエフェクターです. 音楽的な表現だと,
音の粒をそろえる エフェクターと言えます.
音楽ジャンルにもよりますが, ロックやポップスのレコーディングでは一般的に利用されるエフェクターです. ロックやポップスでは, ギター数パート, ベース,
ドラム, そして, ボーカルと定石の構成でも楽音数が多く, ミキシングをある程度オートマティックにおこなう必要があるからです (逆に, クラシックなどでは
(例えば, ピアノ演奏のみの場合), 演奏時の強弱 (フォルテやピアノなど) も忠実に再現することが理想とされます).
DynamicsCompressorNode
Web Audio API でコンプレッサーを実装するのはおそらくもっとも簡単です.
DynamicsCompressorNode
インスタンスを生成して接続するだけです. これだけで, それなりのコンプレッサーとして機能します (DynamicsCompressorNode
インスタンスの各 AudioParam
のデフォルト値が機能するような値になっていますが, コンストラクタの第 2 引数の
DynamicsCompressorOptions
でインスタンス生成時に値を設定したり, AudioParam
のセッターで値を設定したりはできます).
DynamicsCompressorNode
のノード接続図
const context = new AudioContext();
const oscillator = new OscillatorNode(context);
const compressor = new DynamicsCompressorNode(context);
// If use `createDynamicsCompressor`
// const compressor = context.createDynamicsCompressor();
// OscillatorNode (Input) -> DynamicsCompressorNode (Compressor) -> AudioDestinationNode (Output)
oscillator.connect(compressor);
compressor.connect(context.destination);
// Start oscillator immediately
oscillator.start(0);
// Stop oscillator after 10 sec
oscillator.stop(context.currentTime + 10);
以下は, 基本波形の合成セクション でコード例として記載した, 和音となる
OscillatorNode
の合成で, ゲインの調整ではなく, コンプレッサーを利用することで (DynamicsCompressorNode
インスタンスを接続することで), 意図しない音割れを防止するコードです.
const context = new AudioContext();
// C major chord
const oscillatorC = new OscillatorNode(context, { frequency: 261.6255653005991 });
const oscillatorE = new OscillatorNode(context, { frequency: 329.6275569128705 });
const oscillatorG = new OscillatorNode(context, { frequency: 391.9954359817500 });
const compressor = new DynamicsCompressorNode(context);
// If use `createDynamicsCompressor`
// const compressor = context.createDynamicsCompressor();
// OscillatorNode (Input) -> DynamicsCompressorNode (Compressor) -> AudioDestinationNode (Output)
oscillatorC.connect(compressor);
oscillatorE.connect(compressor);
oscillatorG.connect(compressor);
compressor.connect(context.destination);
// Start oscillators immediately
oscillatorC.start(0);
oscillatorE.start(0);
oscillatorG.start(0);
// Stop oscillators after 10 sec
oscillatorC.stop(context.currentTime + 10);
oscillatorE.stop(context.currentTime + 10);
oscillatorG.stop(context.currentTime + 10);
「おそらくもっとも簡単」と表現したのは, AudioNode
単体の接続とデフォルト値でエフェクターとして機能することが理由です. すでに,
解説したように DelayNode
や BiquadFilterNode
単体としては現実世界の (音楽表現として意味をもつ)
エフェクターとして機能することはないからです.
コンプレッサーの仕組み
DynamicsCompressorNode
で制御可能なパラメータ (AudioParam
インスタンス) としては, threshold
, knee
, ratio
, attack
, release
がありますが, コンプレッサーの効果に大きく影響するのは, threshold
(閾値 ) と
ratio
(レシオ値 ) です.
threshold
は, コンプレッサーによる振幅の圧縮がかかる閾値を指定します. 逆に言うと,
threshold
以下の振幅には影響を与えません. 単位はデシベルで, -100 dB
から
0 dB
デシベルまでの範囲で設定可能です. threshold
が 0
(dB
) の場合は,
コンプレッサーが効いていないのと同じで, -100
(dB
) に近づくほど,
より小さな振幅に対してもコンプレッサーが効くことになります (デフォルト値は, -24
(dB
) です).
ratio
は, threshold
を超えた振幅を圧縮する割合を指定します (デフォルト値は 12
なので,
threshold
を超えた振幅値を $\frac{1}{12}$ 倍に圧縮します). したがって, ratio
が
1
(最小値) の場合は, まったく圧縮をしないので, コンプレッサーが効いてないのと同じで, ここから
$\infty$ (ただし, 実際の最大値は 20
です) に近づくほど,
threshold
に近い値に圧縮されていきます. 圧縮後の振幅値は, threshold
の振幅値と圧縮された振幅値を加算した値となります.
リミッター は, ratio
が $\infty$ のコンプレッサーであり,
threshold
を超える振幅はすべて threshold
にまで圧縮します.
knee
は threshold
を超えた振幅の圧縮を緩やかに処理する (Soft knee ) か, 急峻に処理する (Hard knee )
かを決定します. デフォルト値は 30
(dB
) ですが, 最大値の 40
(dB
) に近づくほど,
より緩やかな振幅の圧縮となり, 最小値の 0
(dB
) に近づくほど, 急峻な圧縮となります (音楽的には,
コンプレッサーが急激に効いてしまうと, アナログ感のあるスムーズな圧縮にならず, いわゆる, 「デジタルくさい音 」と言われることもあるので,
それを緩和するパラメータと考えるとよいかもしれません (また, knee
の値とコンプレッションカーブの関係 (関数) は,
単調増加する関数であることが唯一の仕様となっているので, その詳細はレンダリングエンジンの実装に依存することになります).
threshold
(閾値), ratio
(レシオ値), knee
また, 振幅を圧縮した結果, 振幅を大きくする余地が発生します. これによって, 相対的に振幅の小さい音もある程度大きくすることが可能になります (ただし,
DynamicsCompressorNode
の仕様上, どのようなアルゴリズムよって増幅するかは記載されていないので,
レンダリングエンジンの実装に依存することになります).
コンプレッサーによる圧縮と増幅
attack
と release
はイメージとしては, エンベロープジェネレーターの attack
と release
と同じで,
コンプレッサーが効き始めと終わりの時間的変化を指定することになります. 仕様としては, attack
は, コンプレッサーが効き始めて,
10 dB
圧縮されるまでの時間 (秒単位. デフォルト値は, 0.003 sec
), release
は,
コンプレッサーが効き終わってから, 10 dB
増幅されるまでの時間 (秒単位. デフォルト値は, 0.25 sec
) となっています. また,
attack
と release
による, コンプレッサーの時間的な圧縮レベルの変化は, 読み取り専用の
reduction
プロパティ (単位は dB
)で参照することが可能です.
オートパン
オートパン とは, 音像 を周期的に左右のチャンネルに振るエフェクターです. 楽器演奏でのエフェクターとして使われることはほとんどなく, DAW
(Digital Audio Workstation) を利用した音楽制作・音源編集で使われることが多いエフェクターです. Web Audio API において,
オートパンを実装する方法はいくつかありますが, もっとも簡単な実装は StereoPannerNode
クラスを利用する実装です.
オーディオ信号処理としては, 左右のチャンネルが互いに逆位相となるトレモロ をかけることで実装する方法が一般的でもあるので,
こちらの実装も解説したいと思います (さらに, Web Audio API において, チャンネルごとに独立したオーディオ信号処理を適用するための,
ChannelSplitterNode
クラス, および,
ChannelMergerNode
クラスの基本を理解するためにも適切なエフェクターだからです).
音像
人間の聴覚は, 音を聴いただけで, 音源の位置や音源の (物理的な) 大きさや形などを知覚することができます. この感覚的に (実際の音源はどうであれ)
知覚した音を, 音像 と呼びます. すなわち, 聴覚上の音源 とも言えます. Web Audio API の音像に関しては,
StereoPannerNode
クラスもそうですが, あとのセクションで解説する PannerNode
クラスも音像を変化させることが可能ですが,
音源の位置 のみの設定です (AudioListener
クラスで, リスナーの位置も設定可能で, これによって音像を変化させることもできます).
したがって, Web Audio API のスコープの中では, 音像, イコール, 音源の位置と理解しても問題ありません. また, 音像を知覚することを,
音像定位 と呼びます.
音像を適切に設定したり, 変化させることで, 音に立体感や臨場感を与えることができます. 特に, CG (WebGL) と音楽を利用するような Web
アプリケーションでは (理論上は) 重要なエフェクターと言えます (しかしながら, 現状の Web Audio API における PannerNode
クラス, および,
AudioListener
クラスは, それらのユースケースに十分に応えられるほど精度のよいものではありません).
StereoPannerNode
StereoPannerNode
クラスは, 音像を左右に移動させることだけに特化した AudioNode
です (PannerNode
クラスの positionX
プロパティ (AudioParam
) のみを設定可能なクラスで,
PannerNode
クラスのサブセット的なクラスと言えます). 音楽制作・音源編集などのユースケースでは,
左右のチャンネルに音像を振ることができれば十分要件を満たすことができるので, すなわち, オートパンにおいても
StereoPannerNode
で実装可能です (PannerNode
クラスは左右のチャンネルに音像を設定するというよりは,
あくまで立体音響でのユースケースのために定義されています).
StereoPannerNode
には, AudioParam
の pan
プロパティのみが定義されています. デフォルト値は
0
で, 音像は変化しません. pan
プロパティの値は, 最大値 (maxValue
) が 1
, 最小値
(minValue
) が -1
ですが,
最大値に近いほど, 右チャンネルに音像が振られ, 最小値に近いほど, 左チャンネルに音像が振られます .
以下のコードは, pan
プロパティの初期値を -1
(つまり, 左チャンネルに完全に音像を振った状態) に設定して
(コンストラクタの第 2 引数 StereoPannerOptions
で指定しています. ファクトリメソッドを利用する場合は,
AudioParam
の value
プロパティで設定してください). タイマーによって, 右チャンネル,
左チャンネルに交互に音像を移動させます (ヘッドフォンやイヤフォンを着用して確認することを推奨します ).
const context = new AudioContext();
const oscillator = new OscillatorNode(context);
const panner = new StereoPannerNode(context, { pan: -1 });
// If use `createStereoPanner`
// const panner = context.createStereoPanner();
//
// panner.pan.value = -1;
// OscillatorNode (Input) -> StereoPannerNode (pan) -> AudioDestinationNode (Output)
oscillator.connect(panner);
panner.connect(context.destination);
// Start oscillator immediately
oscillator.start(0);
// Stop oscillator after 10 sec
oscillator.stop(context.currentTime + 10);
const intervalid = window.setInterval(() => {
panner.pan.value *= -1;
}, 2000);
oscillator.onended = () => {
window.clearInterval(intervalid);
};
オートパンを実装するには, AudioParam
である pan
に, LFO を接続して周期的に変化させることで可能になります.
オートパンのノード接続図
以下は, 実際のアプリケーションを想定して, ユーザーインタラクティブに, オートパンに関わるパラメータを制御できるようにしたコード例です. Depth
が大きいほど, より大きく左右に音像が振れて, Rate を高くするほど, より速く左右に音像が振られるようになります.
<button type="button">start</button>
<label>
<input type="checkbox" id="checkbox-auto-panner" checked />
<span id="print-checked-auto-panner">ON</span>
</label>
<label for="range-auto-panner-depth">Depth</label>
<input type="range" id="range-auto-panner-depth" value="0" min="0" max="1" step="0.05" />
<span id="print-auto-panner-depth-value">0</span>
<label for="range-auto-panner-rate">Rate</label>
<input type="range" id="range-auto-panner-rate" value="0" min="0" max="5" step="0.05" />
<span id="print-auto-panner-rate-value">0</span>
const context = new AudioContext();
let rateValue = 0;
let oscillator = new OscillatorNode(context);
let lfo = new OscillatorNode(context, { frequency: rateValue });
let isStop = true;
const depth = new GainNode(context);
const panner = new StereoPannerNode(context);
const buttonElement = document.querySelector('button[type="button"]');
const checkboxElement = document.querySelector('input[type="checkbox"]');
const rangeDepthElement = document.getElementById('range-auto-panner-depth');
const rangeRateElement = document.getElementById('range-auto-panner-rate');
const spanPrintCheckedElement = document.getElementById('print-checked-auto-panner');
const spanPrintDepthElement = document.getElementById('print-auto-panner-depth-value');
const spanPrintRateElement = document.getElementById('print-auto-panner-rate-value');
checkboxElement.addEventListener('click', () => {
oscillator.disconnect(0);
lfo.disconnect(0);
if (checkboxElement.checked) {
// Connect nodes
// OscillatorNode (Input) -> StereoPannerNode (Auto Panner) -> AudioDestinationNode (Output)
oscillator.connect(panner);
panner.connect(context.destination);
// Connect nodes for LFO that changes pan periodically
// OscillatorNode (LFO) -> GainNode (Depth) -> pan (AudioParam)
lfo.connect(depth);
depth.connect(panner.pan);
spanPrintCheckedElement.textContent = 'ON';
} else {
// OscillatorNode (Input) -> AudioDestinationNode (Output)
oscillator.connect(context.destination);
spanPrintCheckedElement.textContent = 'OFF';
}
});
buttonElement.addEventListener('mousedown', () => {
if (!isStop) {
return;
}
if (checkboxElement.checked) {
// Connect nodes
// OscillatorNode (Input) -> StereoPannerNode (Auto Panner) -> AudioDestinationNode (Output)
oscillator.connect(panner);
panner.connect(context.destination);
// Start oscillator
oscillator.start(0);
} else {
panner.disconnect(0);
// Connect nodes (Auto Panner OFF)
// OscillatorNode (Input) -> AudioDestinationNode (Output)
oscillator.connect(context.destination);
// Start oscillator
oscillator.start(0);
}
// Connect nodes for LFO that changes pan periodically
// OscillatorNode (LFO) -> GainNode (Depth) -> pan (AudioParam)
lfo.connect(depth);
depth.connect(panner.pan);
lfo.start(0);
isStop = false;
buttonElement.textContent = 'stop';
});
buttonElement.addEventListener('mouseup', () => {
if (isStop) {
return;
}
// Stop immediately
oscillator.stop(0);
lfo.stop(0);
oscillator = new OscillatorNode(context);
lfo = new OscillatorNode(context, { frequency: rateValue });
isStop = true;
buttonElement.textContent = 'start';
});
rangeDepthElement.addEventListener('input', (event) => {
const depthValue = event.currentTarget.valueAsNumber;
depth.gain.value = depthValue;
spanPrintDepthElement.textContent = depthValue.toString(10);
});
rangeRateElement.addEventListener('input', (event) => {
rateValue = event.currentTarget.valueAsNumber;
if (lfo) {
lfo.frequency.value = rateValue;
}
spanPrintRateElement.textContent = rateValue.toString(10);
});
トレモロによる実装
人間の聴覚は, (実際に音源があるかは無関係に) 音が大く聴こえてくる方向に音源があると判断する仕組みがあります. これを,
インテンシティ効果 と呼びます. つまり, 実際に音源がなくても, 左側から聴こえてくる音が右側から聴こえてくる音より, より大きいほど,
音源が左側にあると知覚します. パンの変更やオートパンはこのインテンシティ効果を利用したエフェクターと言えます.
トレモロによるオートパンの実装は, インテンシティ効果を直接コードに落とし込んだ実装と言えます. 左右の位相が互いに逆位相になることにより,
徐々に音像が左右の間で移動することになります. 左右の位相が互いに逆位相になることにより, 徐々に音像が左右の間で移動することになります.
位相差が大きいほど, 左右のどちらかに, 小さいほど中央に音像が移動します.
ChannelSplitterNode
ChannelSplitterNode
は, 指定のチャンネル数に分割するためのクラスです. デフォルト値としては,
6
チャンネルに分割します (おそらく, このデフォルト値は, 5.1 チャンネルサラウンド方式を考慮しての値と思われます). つまり, ステレオ
(2 チャンネル) に分割するのであれば, コンストラクタの第 2 引数 ChannelSplitterOptions
型の
numberOfOutputs
プロパティに 2
を指定します (ファクトリメソッドを利用する場合は, 第 1 引数に指定します).
const context = new AudioContext();
const splitter = new ChannelSplitterNode(context, { numberOfOutputs: 2 });
// If use `createChannelSplitter`
// const splitter = context.createChannelSplitter(2);
ChannelMergerNode
ChannelMergerNode
は, ChannelSplitterNode
によって分割された複数のチャンネルを 1 つのチャンネル (ストリーム)
にまとめるためのクラスです (したがって, ChannelSplitterNode
と併用するケースがほとんどです).
ChannelSplitterNode
のデフォルト値にしたがって, デフォルトの入力チャンネル数は 6
チャンネルです. つまり, ステレオ (2
チャンネル) を 1 つのチャンネルにまとめるのであれば, コントラクタの第 2 引数 ChannelMergerOptions
型の
numberOfInputs
プロパティに 2
を指定します (ファクトリメソッドを利用する場合は, 第 1 引数に指定します).
const context = new AudioContext();
const merger = new ChannelMergerNode(context, { numberOfInputs: 2 });
// If use `createChannelMerge`
// const merger = context.createChannelMerger(2);
connect メソッドの接続先チャンネル指定
connect
メソッドには, いくつかのオーバーロードされたシグネチャがありますが, ChannelSplitterNode
/
ChannelMergerNode
を利用する場合に, 第 2 引数と第 3 引数にチャンネルを指定する形式が実質的に有用になります (ちなみに, 第 2 引数, 第
3 引数はオプショナルです).
第 2 引数には, ChannelSplitterNode
で分割した出力先のチャンネルナンバーを指定します. 例えば, ステレオなら, 左チャンネルであれば
0
, 右チャンネルであれば 1
を指定します.
実装
トレモロによるオートパンの実装で, ChannelSplitterNode
/ ChannelMergerNode
, および, connect
メソッドの第 2
引数, 第 3 引数の指定を解説します.
<audio src="https://korilakkuma.github.io/Web-Music-Documentation/assets/medias/Schubert-Symphony-No8-Unfinished-1st-2020-VR.mp3" crossorigin="anonymous" controls />
const context = new AudioContext();
const audioElement = document.querySelector('audio');
const source = new MediaElementAudioSourceNode(context, { mediaElement: audioElement });
const amplitudeL = new GainNode(context, { gain: +1.0 }); // +1.0 +- ${depthValue}
const amplitudeR = new GainNode(context, { gain: -1.0 }); // -1.0 +- ${depthValue} Inverse Phase
const lfo = new OscillatorNode(context, { frequency: 0 });
const depth = new GainNode(context, { gain: 0 });
const splitter = new ChannelSplitterNode(context, { numberOfOutputs: 2 });
const merger = new ChannelMergerNode(context, { numberOfInputs: 2 });
const lfoSplitter = new ChannelSplitterNode(context, { numberOfOutputs: 2 });
// Connect nodes
// -> GainNode (Amplitude) Output Channel Number is `0` (Left Channel) -> Input Channel Number is `0` (Left Channel) -
// MediaElementAudioSourceNode (Input) -> ChannelSplitterNode -| | -> ChannelMergerNode -> AudioDestinationNode (Output)
// -> GainNode (Amplitude) Output Channel Number is `1` (Right Channel) -> Input Channel Number is `1` (Right Channel) -
source.connect(splitter);
splitter.connect(amplitudeL, 0, 0);
splitter.connect(amplitudeR, 1, 0);
amplitudeL.connect(merger, 0, 0);
amplitudeR.connect(merger, 0, 1);
merger.connect(context.destination);
// Connect nodes for LFO that changes gain periodically
// OscillatorNode (LFO) -> GainNode (Depth) -> ChannelSplitterNode
lfo.connect(depth);
depth.connect(lfoSplitter);
// ChannelSplitterNode (Left Channel) -> gain (AudioParam) (Left Channel)
lfoSplitter.connect(amplitudeL.gain, 0);
// ChannelSplitterNode (Right Channel) -> gain (AudioParam) (Right Channel)
lfoSplitter.connect(amplitudeR.gain, 1);
lfo.start(0);
const rangeDepthElement = document.getElementById('range-auto-panner-by-tremolo-depth');
const rangeRateElement = document.getElementById('range-auto-panner-by-tremolo-rate');
const spanPrintDepthElement = document.getElementById('print-auto-panner-by-tremolo-depth-value');
const spanPrintRateElement = document.getElementById('print-auto-panner-by-tremolo-rate-value');
rangeDepthElement.addEventListener('input', (event) => {
const depthValue = event.currentTarget.valueAsNumber;
depth.gain.value = depthValue;
spanPrintDepthElement.textContent = depthValue.toString(10);
});
rangeRateElement.addEventListener('input', (event) => {
const rateValue = event.currentTarget.valueAsNumber;
lfo.frequency.value = rateValue;
spanPrintRateElement.textContent = rateValue.toString(10);
});
左右のチャンネルに, 互いに逆位相となるトレモロをかけるので, ChannelSplitterNode
の numberOfOutputs
プロパティ,
ChannelMergerNode
の numberOfInputs
プロパティのチャンネル数はそれぞれ, 2
(以上) を指定します. また,
振幅変調させるための GainNode
インスタンスも, それぞれのチャンネルに必要なので 2 つ生成します. また,
左右の gain
プロパティの値を互いに逆, つまり, 絶対値を同じにして, 一方を負数にすることで,
左右のチャンネルが逆位相となるトレモロが実装可能です .
GainNode
インスタンスの gain
プロパティの値を負数 (-1
) にして, 逆位相にする実装は Web Audio API
におけるテクニックの 1 つです
(もっとも, このような Web Audio API 特有のテクニックが, 他のオーディオ API 経験者からすると, 奇怪な API に思われる一因かもしれません).
$
\begin{flalign}
&y_{L}\left(n\right) = \left(1 + \mathrm{depth} \cdot \sin\left(\frac{2\pi \cdot \mathrm{rate} \cdot n}{f_{s}}\right)\right) \cdot x_{L}\left(n\right) \\
&y_{R}\left(n\right) = \left(1 - \mathrm{depth} \cdot \sin\left(\frac{2\pi \cdot \mathrm{rate} \cdot n}{f_{s}}\right)\right) \cdot x_{R}\left(n\right) \\
\end{flalign}
$
ChannelSplitterNode
によって分割された出力は, numberOfOutputs
で指定したチャンネル数と同じです.
それぞれのチャンネルを接続先の AudioNode
, または, AudioParam
の入力チャンネル として (ChannelSplitterNode
の出力チャンネル として), connect
メソッドの第 2 引数に, チャンネルナンバーを指定します (第 3 引数はデフォルトで 0
なので,
不要であれば省略可能です)
ChannelSplitterNode
でチャンネルを分割して, それぞれにオーディオ処理を適用したあと, 1 つのチャンネル (ストリーム) にまとめるために,
ChannelMergerNode
へ接続します. ChannelMergerNode
へまとめられるチャンネルの入力は,
numberOfInputs
で指定したチャンネル数と同じです. それぞれのチャンネルを接続元の AudioNode
の出力チャンネル として (ChannelMergerNode
の入力チャンネル として), connect
メソッドの第 3 引数に,
チャンネルナンバーを指定します (また, AudioParam
の場合, 分割したチャンネル (ストリーム) をまとめる必要はないので,
ChannelMergerNode
へ接続するのは, 分割された AudioNode
のみです).
トレモロによるオートパンのノード接続図
以下は, トレモロによるオートパンのデモです.
OscillatorNode
は 1 チャンネルしかもたないモノラルな入力なので, 入力ノードはステレオ入力となるオーディオデータをもつ
MediaElementAudioSourceNode
を利用しています. したがって, オートパンを実装するには,
StereoPannerNode
を利用したほうが実装も簡潔で汎用性も高いですが, ChannelSplitterNode
と
ChannelMergerNode
の解説として好例なのであえて解説しました.
グライド (ポルタメント)
グライド (ポルタメント ) とは, シンセサイザー特有のエフェクターで, 周波数の変化をなめらかにすることによって,
シンセサイザーらしい音色にすることができます. また, Web Audio API においては, すでに解説した, パラメータのオートメーション (AudioParam
のスケジューリング) のみで実装可能です. シンセサイザー特有のエフェクターでありますが, 同様の実装で, 演奏記号のスラー ,
ギターやベースのスライド奏法 を実現することも可能です.
グライドのパラメータのオートメーション
グライドに必要となるパラメータのオートメーションは, OscillatorNode
インスタンスの frequency
プロパティ (または
detune
プロパティ) で, グライドタイムの経過後に, 次の frequency
プロパティの値になるように変化させます. 以下の実装では,
linearRampToValueAtTime
メソッドを利用して線形的に変化させていますが,
exponentialRampToValueAtTime
メソッドを利用して指数関数的に変化させてもよいでしょう. グライドのアルゴリズム上,
次に発音する音の周波数が同じ場合, 効果はありません.
OscillatorNode
インスタンスの frequency
プロパティのオートメーションと同様に,
AudioBufferSourceNode
インスタンスの detune
プロパティ (または playbackRate
プロパティ) を変化させれば,
スラーやスライド奏法の実現となります.
const context = new AudioContext();
const defaultFrequency = 440;
const nextFrequency = 880;
const glide = 1.5;
const oscillator = new OscillatorNode(context);
// OscillatorNode (Input) -> AudioDestinationNode (Output)
oscillator.connect(context.destination);
const t0 = context.currentTime;
const t1 = t0 + glide;
// Schedule `frequency` (`AudioParam`) for Glide
oscillator.frequency.setValueAtTime(defaultFrequency, t0);
oscillator.frequency.linearRampToValueAtTime(nextFrequency, t1);
// Start oscillator immediately
oscillator.start(t0);
// Stop oscillator after 2.5 sec
oscillator.stop(t1 + 2.5);
以下は, シンセサイザーで利用することを想定して, 鍵盤の UI でインタラクティブに操作できるようにしたコード例です (グライドタイムとメソッド (linearRampToValueAtTime
メソッド, または, exponentialRampToValueAtTime
メソッド) もインタラクティブに制御できるようにしています.
また, 鍵盤の UI の実装はいくつかアプローチ (SVG, Canvas ... etc) がありますが, ここではシンプルに HTML と CSS で実装しています).
HTML や CSS のコードまで記載すると, グライドの本質的な解説からそれてしまうので, ディベロッパーツールなどを使いながら, HTML や CSS
をリーディングしていただくとして, その UI がある前提で, グライドのコードを記載します.
const context = new AudioContext();
const keyboards = document.querySelectorAll('.piano button[type="button"]');
const frequencyRatio = 2 ** (1 / 12);
let glideTime = 0;
let glideType = 'linear';
// Invalid frequency
let prevFrequency = -1;
keyboards.forEach((keyboard) => {
let oscillator = null;
const onDown = async (event) => {
if (context.state !== 'running') {
await context.resume();
}
const t0 = context.currentTime;
if (oscillator !== null) {
// If starts next oscillator during glide, should cancel scheduling
oscillator.frequency.cancelScheduledValues(t0);
oscillator.stop(t0);
}
const pianoIndex = Number(keyboard.getAttribute('data-index'));
const nextFrequency = 27.5 * (frequencyRatio ** pianoIndex);
oscillator = new OscillatorNode(context, { type: 'sawtooth', frequency: (prevFrequency === -1 ? nextFrequency : prevFrequency) });
oscillator.connect(context.destination);
if (prevFrequency !== -1) {
const t1 = t0 + glideTime;
oscillator.frequency.setValueAtTime(prevFrequency, t0);
switch (glideType) {
case 'linear': {
oscillator.frequency.linearRampToValueAtTime(nextFrequency, t1);
break;
}
case 'exponential': {
oscillator.frequency.exponentialRampToValueAtTime(nextFrequency, t1);
break;
}
}
}
// Update frequency
prevFrequency = nextFrequency;
oscillator.start(t0);
keyboard.classList.add('pressed');
};
const onUp = () => {
if (oscillator === null) {
return;
}
oscillator.frequency.cancelScheduledValues(context.currentTime);
oscillator.stop(0);
oscillator = null;
keyboard.classList.remove('pressed');
};
keyboard.addEventListener('mousedown', onDown);
keyboard.addEventListener('touchstart', onDown);
keyboard.addEventListener('mouseup', onUp);
keyboard.addEventListener('touchend', onUp);
});
const rangeGlideTimeElement = document.getElementById('range-glide-time');
const formGlideTypeElement = document.getElementById('form-glide-type');
const spanPrintGlideTimeElement = document.getElementById('print-glide-time-value');
rangeGlideTimeElement.addEventListener('input', (event) => {
glideTime = event.currentTarget.valueAsNumber;
spanPrintGlideTimeElement.textContent = glideTime.toFixed(2);
});
formGlideTypeElement.addEventListener('change', () => {
const radios = formGlideTypeElement.elements['radio-glide-type'];
for (const radio of radios) {
if (radio.checked) {
glideType = radio.value;
break;
}
}
});
UI と関連するイベントリスナーのコードも混在していますが, 以下の 3 つが実用的なグライドを実装する場合に重要となります
(コードのコメントとしても記載しています).
グライドの原理上, 初回の発音では無効なので, 初回の発音が判定できるフラグなどを定義します. 上記のコードでは,
prevFrequency
変数を周波数としては無効な -1
に設定することで初回の発音であることを判定して分岐させています (また,
このほうが, 初回の発音時は, 前回の発音はないので, より原理を実装に落とし込んだコードと言えそうです. もちろん,
boolean
型の変数などで判定しても実装としては問題ありません)
発音のたびに, 現在発音している周波数 (コードでは nextFrequency
変数) を次回の発音まで参照できるようにします. 実装としては,
prevFrequency
変数を更新してしまうのがよいでしょう (1 つ前より以前の周波数を参照する必要はないので)
上記の 2 つが実装できていれば, グライドとしてはほぼ問題なく機能しますが, 鍵盤を連打した場合など, 前回の
frequency
プロパティのスケジューリングが残っている可能性があるので, 発音のたびに,
OscillatorNode
インスタンスが存在していれば, cancelScheduledValues
メソッドでクリアして, 即時停止します (ちなみに,
Firefox の対応が不要であれば, cancelAndHoldAtTime
メソッドを利用してもよいでしょう. その場合,
setValueAtTime
メソッドが不要になります)
また, グライドの実装とは関係ありませんが, このような鍵盤の UI を利用して発音する場合, 周波数の算出式として,
$27.5 \cdot \mathrm{pow}\left(\mathrm{pow}\left(2, \left(\frac{1}{12}\right)\right), \mathrm{index}\right)$
を利用すると便利です. $\mathrm{index}$ は, ピアノ 88 鍵を配列に見立てて, 最も左側にある鍵盤 (27.5 Hz
) のインデックスを 0
とした場合の値です. また, そのインデックスは, それぞれの鍵盤を構成する HTML
のカスタムーデータ属性として定義しておくことで, イベントリスナーで簡単に取得することが可能です (もちろん, 他のよりよい実装があるかもしれませんが).
AudioWorklet が必要なエフェクター
ここまでのセクションで解説したエフェクターは, AudioNode
(のサブクラス) と AudioNode
のサブクラス特有に定義されている
AudioParam
のパラメータの設定, また, それらの接続を駆使する (LFO の接続やフィードバック接続など) ことで実装可能でした. 言い換えると,
直接サウンドデータにアクセスすることなく, すなわち, AudioWorklet を必要とすることなく実装可能なエフェクターです .
しかしながら, 現実世界に存在するエフェクターのなかには, そのアルゴリズムの複雑度に関わらず, 直接サウンドデータにアクセスしないと実装できない,
すなわち, AudioWorklet を必要とするエフェクターもあります. 例えば, すでに解説したボーカルキャンセラは, 言ってしまえば,
オーディオデータを減算するだけの単純な処理ですが, 現状の Web Audio API の仕様では, AudioWorklet を利用する以外に実装する手段はありません. 他にも,
ピッチシフターやノイズサプレッサーといったエフェクターも, 現状の Web Audio API の仕様では, AudioWorklet を利用しないと実装できません
(エフェクターの可能性を考えると, AudioWorklet を必要とするエフェクターのほうが圧倒的に多いと考えることもできます).
もっとも,
Web Audio API の仕様設計の思想に従うと, AudioNode
や AudioParam
の組み合わせで実装できないかを検討することが大事です . すでに解説した, オートワウやアンプシミュレーターなどは比較的, 複雑な実装をしていますが, AudioWorklet を必要とすることなく実装できました.
アルゴリズムの複雑性と AudioWorklet の必要性はあまり関係ありません (研究目的であれば高い相関があるかもしれませんが, 研究用途の場合, そもそも
Web Audio API を使うことが適さないと思われます (MATLAB や Python を使うのが一般的です). リアルタイム性自体が研究目的であっても,
その場合はプラットフォーム (ハードウェアや OS) に依存するので, 研究対象のプラットフォームで研究することになるでしょう).
それでも実装不可能な場合, 最終手段として AudioWorklet で実装します (ただし, 他のオーディオ API 経験者からすると, AudioWorklet
ですべてのオーディオ信号処理を実装してしまう方が学習コストが低いかもしれません. 特に, ビジネスの現場では,
教条的に思想に従うのはあまり意味がないので, そのあたりは柔軟な判断をしてください. このドキュメントでは, あくまで技術的な理想として, まずは, Web
Audio API の仕様設計の思想に従うことを最優先しました).
時間分解能と周波数分解能
ところで, 直接サウンドデータにアクセスしなければ実装できないオーディオ信号処理でも, 時間領域の演算のみで実装できる場合は ,
AudioWorkletProcessCallback
メソッドで, renderQuantumSize
(デフォルトは 128
サンプル)
ごとに処理を適用すれば問題ありません. 例えば, 時間領域でのボーカルキャンセラやノイズゲート, また, エフェクターではありませんが,
ノイズ生成もこれに該当します.
問題となるのは, 周波数領域での演算を必要とする , つまり, 高速フーリエ変換を必要とする場合は ,
周波数分解能の低さ が問題となります. 結論から解説すると, デフォルトの renderQuantumSize
, つまり,
128
サンプルごとの処理では, 周波数分解が非常に低く, 周波数領域での演算精度が悪くなり, 結果として,
エフェクターが想定より効いていなかったり, 場合によってはノイズとなってしまっりといった諸問題が発生します. さらに,
直接サウンドデータにアクセスしなければ実装できないオーディオ信号処理のほとんどは, 周波数領域での演算を必要とします . 例えば,
ピッチシフターやノイズサプレッサー, 周波数領域でのボーカルキャンセラなどです (Web Audio API 1.1 では, 必ずしも
128
サンプル固定ではないものの, 適用される renderQuantumSize
はブラウザやプラットフォーム依存なので,
周波数分解能の低さを根本的に解決はできません).
AudioWorklet における (Web Audio API における) 周波数分解能の低さを解決する手法として, オーバーラップアド (Overlap-Add method :
重畳加算法 ) があります. イメージとしては, 過去の数フレーム (renderQuantumSize
のオーディオデータ) をバッファに格納しておき,
過去のデータとつなぎ合わせて, (入出力以外の) オーディオの処理単位を (一般的には 2 の冪乗で) 任意のサイズで制御することができる手法です.
これによって, 高速フーリエ変換のサイズも renderQuantumSize
に依存しない, つまり,
128
サンプルよりも大きなサンプル数を指定できるので周波数分解能の問題を解決することができます.
では, 周波数分解能の説明をします. 周波数分解能 ($f_{\mathrm{resolution}}$ ) は以下の数式で定義されます. 分子の
$f_{s}$ はサンプリング周波数, 分母の $N$ は, 高速フーリエ変換のサイズです.
$f_{\mathrm{resolution}} = \frac{f_{s}}{N}$
サンプリング周波数を一定, つまり, 定数とすると, 周波数分解能は, 高速フーリエ変換のサイズによって決まることになります. そして,
高速フーリエ変換のサイズである $N$ の値を大きくしていくと, 周波数分解能 ($f_{\mathrm{resolution}}$ ) はより小さい値になっていきます. 周波数分解能の値が小さいほど, より精度の高い周波数演算ができるので周波数分解能が高くなります .
具体的な値で算出すると, サンプリング周波数 $f_{s}$ を 48000
で定数とします.
高速フーリエ変換のサイズを, デフォルトの renderQuantumSize
の 128
サンプルとすると,
$f_{\mathrm{resolution}} = \frac{48000}{128} = 375$ となり, 周波数領域での演算を
375 Hz
単位の粗い単位でしか実行できなくなり, 先に解説した諸問題が発生します. 一方で,
高速フーリエ変換のサイズをオーバーラップアドによって, 2048
サンプル (過去 15 フレーム (renderQuantumSize
) 分と現在の 1
フレーム (renderQuantumSize
) 分のサンプル (ただし, 厳密には, オーバーラップ, つまり, 重なるサンプルがあるので, 過去の必要なフレームは
15 フレームより多いです. あくまで, 理解のための概算と考えてください. 2048
サンプルは,
時間分解能とのバランスを考慮したよく利用されるサンプル数です) とすると,
$f_{\mathrm{resolution}} = \frac{48000}{2048} = 23.4375$ となり, 周波数領域での演算を約
24 Hz
単位の細かい精度で実行できるので, 先に解説した諸問題は実用上発生しません (理論上は, デジタル信号である以上,
少なからず丸め誤差によるノイズなどはありますが).
ピアノ 88 鍵の音域で考えると, 高速フーリエ変換のサイズがデフォルトの renderQuantumSize
の 128
サンプルでは, 低音域の 44
鍵がまったく演算できないことになりますが, 2048
サンプルにすると, 最も低い 27.5 Hz
から 88
鍵の音域をすべて演算可能できることになります. また, すでに解説したグラフィックイコライザーで考えると, 低音の帯域が 32 Hz
から
250 Hz
となるので, 高速フーリエ変換のサイズがデフォルトの renderQuantumSize
の 128
サンプルでは,
低音域をまったく演算できないことになりますが, 2048
サンプルにすると, 十分に低音域も演算できることになります.
ところで, 周波数分解能の低さが問題になるのであれば, なぜ, デフォルトの
renderQuantumSize
をもっと大きな値に仕様策定していないのでしょうか ? 実は,
周波数分解を高くすると時間分解能が低くなるというトレードオフの関係があるからです . 時間分解能は, 1 フレームあたりのサンプル数です. Web Audio
API では, (デフォルトの実装で) renderQuantumSize
が時間分解能です. また, 上記の周波数分解能の数式では, 高速フーリエ変換サイズである
$N$ が時間分解能です. すなわち,
単位時間あたりに処理するサンプル数が小さいほど時間分解能は高くなります (高速フーリエ変換のサイズ (時間分解能である)
$N$ を小さくするほど, 周波数分解能である
$f_{\mathrm{resolution}}$ はより大きな値となります).
時間分解能が高いほど (単位時間あたりに処理するサンプル数が小さいほど), CPU の負荷を減らせるので, グリッチ (glitch) や遅延 (latency)
を軽減することができます. すなわち, Web Audio API の設計では, 時間分解能を優先 しているということです (これは, 非推奨となった仕様である
ScriptProcessorNode
のフィードバックから影響を受けています. また, このことからも, Web Audio API では, AudioWorklet
による実装を最終手段的に利用するべきという暗黙的な設計思想がうかがえます).
先ほどの周波数分解能の算出の例として, 時間分解能とのバランスを考慮した のも, トレードオフの関係があるからです.
実用的によく利用される高速フーリエ変換のサイズは 512
, 1024
, 2048
, 4096
あたりです.
これより小さい, もしくは大きいと, 周波数分解能が低すぎる, または, 時間分解能が低すぎてそれぞれのケースにおける諸問題を発生させてしまいます.
参考までに, AnalyserNode
の fftSize
プロパティのデフォルト値も 2048
となっています.
視覚的にも, 時間分解能と周波数分解能を理解してみましょう. 以下の波形 (OscillatorNode
) は, いずれも, ノコギリ波
(偶奇の倍音成分をもつので視覚的に理解しやすくなります) で, ピアノ 88 鍵盤の最も低い周波数である 27.5 Hz
です.
デフォルトの renderQuantumSize
である 128
サンプルの場合, 時間分解能が高く 1 フレームあたり, 約
2.5 msec
の高い精度で処理されています (Gibbs の現象の詳細を確認できるほどの時間分解能です). 一方で, 周波数分解能が
375 Hz
で, 27.5 Hz
には程遠い帯域なので, まったく処理できていません (128
サンプルのスペクトルの描画はバグではなく, 周波数分解能が低すぎて描画されていません).
Time Domain
Frequency Domain (Spectrum)
時間分解能と周波数分解能
($N = 128$ , $f_{s} = 48000 \space \mathrm{Hz}$ ,
$f_{\mathrm{resolution}} = \frac{f_{s}}{N} = 375 \space \mathrm{Hz}$ )
start
2048
サンプルの場合, 周波数分解能が高く, 27.5 Hz
の帯域と倍音成分が高い精度で処理されています. 一方で,
時間分解能が低くなるので, 1 フレームあたり, 約 40 msec
と低い精度になっています (それにしたがって, レイテンシーも大きくなります).
Time Domain
Frequency Domain (Spectrum)
時間分解能と周波数分解能
($N = 2048$ , $f_{s} = 48000 \space \mathrm{Hz}$ ,
$f_{\mathrm{resolution}} = \frac{f_{s}}{N} = 23.4375 \space \mathrm{Hz}$ )
start
オーバーラップアド
オーバーラップアド (Overlap-Add method : 重畳加算法 ) とは, その命名のとおり,
フレームごとに区切ったオーディオデータのサンプルを 「重ねて 」「加算 」することです. 特に, リアルタイム処理においては,
過去のフレームをバッファに格納しておき, それぞれのフレームに対して, 1 つあとのフレームをスライディングして重ねて, 加算します.
オーバーラップアド
ところで, 周波数分解能の問題だけであれば, わざわざ「重ねて」「加算」する必要はない, すなわち,
オーバーラップアドをわざわざ実装する必要はないように思われます. しかしながら, 周波数領域で演算する場合,
離散フーリエ変換後の信号は周期が $N$ (離散フーリエ変換のサイズ. 実用上は, 高速フーリエ変換のサイズ)
であることを仮定しています
($-\frac{f_{s}}{2}$ (負のナイキスト周波数) から
$\frac{f_{s}}{2}$ (ナイキスト周波数) までが 1 周期で, その間のサンプル数は
$N$ ($0 \lt k \leq \frac{f_{s}}{2}$ の範囲で
$\frac{N}{2}$ ) となります). したがって,
$N$ が対象の信号の周期の整数倍となっていない場合, 時間領域の波形に不連続点が発生してしまい ,
その波形を離散フーリエ変換することで, 本来は存在しない周波数成分が発生してしまいます . そして, 現実のオーディオデータにおいて,
離散フーリエ変換のサイズが, 周期の整数倍になることは稀です.
$N$ が周期の整数倍となる場合 (上) と周期の整数倍とならない場合 (下: 時間領域の波形で不連続点が発生しています)
この問題を緩和するために, (次のセクションで解説する) 窓関数 を利用しますが, 窓関数は, ほとんどが両端で 0
となるので (あるいは,
ハミング窓でもかなり減衰するので), フレーム両端のオーディオデータが失われてしまいます. そこで, オーバーラップアドによって,
フレーム両端のオーディオデータを平滑化して保ちながら , 本来は存在しない周波数成分が発生してしまう問題も緩和することができます. 特に,
巡回畳み込み で不可欠な処理となります.
また, オーバーラップアドの計算量を改良した, オーバーラップセーブ (Overlap-Save method : 重畳保留法 )
が利用されることもありますが, その処理の目的は同じです.
窓関数
理論上, フレームのサイズを十分に大きくしていけば, 離散フーリエ変換のサイズが周期の整数倍になります. しかしながら,
フレームサイズを大きくしすぎると, 時間分解能が低下します. さらに, 高速フーリエ変換を適用するとなると 2 の冪乗単位でフレームを大きくする必要があり,
離散フーリエ変換のサイズが周期の整数倍 (かつ, 2 の冪乗) になるようにフレームサイズを大きくするというのは, 現実的には難しい解決手段です.
そこで, 周波数分解能の低下をある程度を許容して, 本来存在しない周波数成分 (不連続点が原因なので, 段差成分 と呼ばれることもあります)
を除去する (緩和する) ために, 窓関数 を時間領域で乗算 (周波数領域ではコンボリューション積分 ) します. 窓関数によって,
フレーム両端の不連続点を除去 (緩和) して, 離散フーリエ変換のサイズが周期の整数倍になるように処理を適用します.
窓関数の振幅スペクトルの形状は, メインローブ とサイドローブ に分類できます. メインローブの幅が狭いほど周波数分解能が高くなり ,
サイドローブのピーク値が低いほど段差成分の周波数成分が発生するのを防ぐことができます . 例えば, 矩形窓の場合,
その振幅スペクトルの形状はシンク関数 ($\left|H_{rect}\left({\omega}\right)\right| = \left|\frac{\sin\left(\omega\right)}{\omega}\right|$ ) になります.
窓関数の振幅スペクトルの形状 (周期を $T$ とすると, 周波数分解能は
$\omega = \frac{2 \pi}{T}$ となります)
矩形窓の振幅スペクトル (青色のパス) はメインローブの幅 (半透明の青) は狭くて, 周波数分解能は高くなりますが, サイドローブのピーク値
(青色の破線のパス) が高くなってしまうので, 段差成分の周波数成分を除去することができません. そのために, ハニング窓やハミング窓など (狭義での)
窓関数を利用します (ハニング窓の振幅スペクトルはマゼンタのパス, メインローブの幅を半透明のマゼンタ,
サイドローブのピーク値をマゼンタの破線のパスで描画しています).
主な窓関数
Window Function
Formula $\left(0 \leq n \leq N \right)$
Description
矩形窓
$w\left(n\right) = 1$
広義の窓関数で, 周波数分解能が最も高い. 一方で, サイドローブのピーク値が最も高く段差成分をまったく除去できません. コンテキストによっては
(狭義には), 窓関数に含めていない場合もあるので注意.
ハニング窓
$w\left(n\right) = 0.5 - 0.5\cos\left(\frac{2 \pi n}{N}\right)$
オーディオ信号処理において最もよく利用される窓関数で, 周波数分解能は矩形窓より低くなりますが, サイドローブのピーク値が他の窓関数より低く,
両端においては段差成分を完全に除去できます. トレードオフである, 周波数分解能と段差成分の除去のバランスがとれていることが,
オーディオ信号処理において最もよく利用される理由です.
ハミング窓
$w\left(n\right) = 0.54 - 0.46\cos\left(\frac{2 \pi n}{N}\right)$
両端に不連続点が存在するのが特徴 です. ハニング窓より周波数分解能は高くなりますが, サイドローブのピーク値は高くなります.
イメージ的には, 矩形窓とハニング窓の間のような性能の窓関数です.
ブラックマン窓
$
\begin{flalign}
&\alpha = 0.16 \\
&w\left(n\right) = \frac{1 - \alpha}{2} - \frac{1}{2}\cos\left(\frac{2 \pi n}{N}\right) + \frac{\alpha}{2}\cos\left(\frac{4 \pi n}{N}\right) \\
& = 0.42 - 0.5\cos\left(\frac{2 \pi n}{N}\right) + 0.08\cos\left(\frac{4 \pi n}{N}\right) \\
\end{flalign}
$
Web Audio API の
AnalyserNode
インスタンスの getByteFrequencyData
メソッドや
getFloatFrequencyData
メソッドで利用されている窓関数 . ハニング窓より周波数分解能は低くなりますが, そのぶん, サイドローブのピーク値も低くなります. したがって,
より正確なスペクトルを描画するために, 最適な窓関数と言えます.
sine 窓, Vorbis 窓
$w\left(n\right) = \sin\left(\pi n\right), \space w\left(n\right) = \sin\left(\frac{\pi}{2}\sin^{2}\left(\pi n\right)\right)$
MP3 や AAC など, オーディオコーデック
(オーディオ圧縮アルゴリズム. 修正離散コサイン変換 (MDCT : Modified Discrete Cosine Transform )) で利用される窓関数です.
上記の表で記載した以外にも, 窓関数がいくつも定義されているのは,
メインローブの幅 (周波数分解能) とサイドローブのピーク値 (段差成分の有無) がトレードオフ の関係にあるからです. したがって,
ユースケースに応じて最適な窓関数を選択する必要があります .
Time Domain
Frequency Domain (Spectrum)
Window Function
Rectangular Window
Hanning Window
Hamming Window
Blackman Window
窓関数とスペクトル
start
矩形窓とハミング窓は両端の不連続点が残るので, 描画フレームごとに, スペクトルの端が異なった値になっています
システム (系) としての窓関数
現実世界のオーディオ信号処理システムは, 十分に長い時間, 入力と出力を観測すると, 多くのシステム (系) は, 非線形時変システム (Non-Linear Time-Variant system ) となります. 非線形, かつ, 時変システムをモデリングする (数式として表現する) のは一般的に複雑になります. しかしながら,
非線形時変システムであっても, ごく短時間だけを観測すれば, ほとんどは, 線形時不変システム (Linear Time-Invariant system )
となりモデリングが容易となります. 窓関数は, 本来, 非線形時変システムであるオーディオの入出力を, ごく短時間で区切って,
線形時不変システムとしてモデリングすることを可能にしています (信号の定常性やエルゴード過程なども担保します).
周波数分解能と段差成分の除去のトレードオフの判断の必要はありますが, システムを単純にする役割も果たしています.
定常性
確率的な信号において, その統計的性質 (期待値 (平均) や分散など) が時刻によらず一定であるような性質 (つまり, 時不変)
エルゴード過程
観測信号の期待値と時間平均が一致すると仮定して, 期待値を時間平均で代替可能な性質をもつ信号系列
一方で, 人工知能の発展にともなって, 機械学習, 特に, ニューラルネットワークでは, データセットを元に, 非線形時変システムのまま,
モデリングする手法もすでに存在しています.
オーバーラップアド (AudioWorkletProcessor クラスの拡張) と窓関数の実装
一般的な (Web Audio API に限らない), オーディオ信号処理におけるオーバーラップアドと窓関数は, 以下のようなイメージで, フレームごとに,
オーバーラップする部分を最適な窓関数で平滑化します.
オーバーラップアドと窓関数 (矩形窓 (上) とハニング窓 (下))
このセクションでは,
Phaze: a real-time web audio pitch-shifter で利用されている,
OLAProcessor
クラスの実装を参考に, 理解のために, 少し簡素化して, Web Audio API におけるオーバーラップアドの実装と, そのサブクラスの利用例
(窓関数と高速フーリエ変換の適用) を解説します.
OverlapAddProcessor クラスの実装 (AudioWorkletProcessor クラスの拡張)
OLAProcessor
クラスの実装を参考にして, 簡素化したオーバーラップアドのための AudioWorkletProcessor
のサブクラスの実装は以下のようになります.
簡素化した実装は, AudioWorkletNodeOptions
型の numberOfInputs
プロパティと numberOfOutputs
プロパティは,
指定しない という制約 (コーディングルールレベルの制約ですが, TypeScript であれば型レベルで制約を実装することも可能です) の代わりに,
AudioWorkletProcessCallback
の第 1 引数と第 2 引数に渡される, FrozenArray
の
0
番目を固定で取得することで, ループ処理を減らして, 理解しやすい実装にしています (この理由は,
AudioWorkletNodeOptions
型の numberOfInputs
プロパティと
numberOfOutputs
プロパティを明示的に指定してしまうと, AudioWorkletProcessCallback
の第 1 引数と第 2
引数の配列の構造が変わってしまうからです).
この制約と簡素化したループ処理を除けば, 本質的な実装は
OLAProcessor
と同じです.
// Filename is './audio-worklets/overlap-add.js'
/**
* This class extends `AudioWorkletProcessor`.
*/
class OverlapAddProcessor extends AudioWorkletProcessor {
static RENDER_QUANTUM_SIZE = 128;
constructor(options) {
super(options);
this.frameSize = 2048;
this.hopSize = 128;
this.numberOfOverlaps = this.frameSize / this.hopSize;
this.inputBuffers = [[]]; /** @type {[Float32Array[]]} */
this.inputBuffersHead = [[]]; /** @type {[Float32Array[]]} */
this.inputBuffersToSend = [[]]; /** @type {[Float32Array[]]} */
this.outputBuffers = [[]]; /** @type {[Float32Array[]]} */
this.outputBuffersToRetrieve = [[]]; /** @type {[Float32Array[]]} */
if (options.processorOptions) {
this.frameSize = options.processorOptions.frameSize ?? 2048;
}
this.allocateInputChannels(1);
this.allocateOutputChannels(1);
}
/**
* @param {[Float32Array[]]} inputs
* @param {[Float32Array[]]} outputs
* @param {{ [parameterName: string]: Float32Array }} parameters
* @abstract
*/
processOverlapAdd(inputs, outputs, parameters) {}
/**
* @param {[Float32Array[]]} inputs
* @param {[Float32Array[]]} outputs
* @param {{ [parameterName: string]: Float32Array }} parameters
* @override
*/
process(inputs, outputs, parameters) {
this.reallocateChannelsIfNeeded(inputs, outputs);
this.readInputs(inputs);
this.shiftInputBuffers();
this.prepareInputBuffersToSend();
this.processOverlapAdd(this.inputBuffersToSend, this.outputBuffersToRetrieve, parameters);
this.handleOutputBuffersToRetrieve();
this.writeOutputs(outputs);
this.shiftOutputBuffers();
return true;
}
reallocateChannelsIfNeeded(inputs, outputs) {
const inputNumberOfChannels = inputs[0].length;
if (inputNumberOfChannels !== this.inputBuffers[0].length) {
this.allocateInputChannels(inputNumberOfChannels);
}
const outputNumberOfChannels = outputs[0].length;
if (outputNumberOfChannels !== this.outputBuffers[0].length) {
this.allocateOutputChannels(outputNumberOfChannels);
}
}
allocateInputChannels(numberOfChannels) {
this.inputBuffers = [[]];
for (let channelNumber = 0; channelNumber < numberOfChannels; channelNumber++) {
this.inputBuffers[0][channelNumber] = new Float32Array(this.frameSize + OverlapAddProcessor.RENDER_QUANTUM_SIZE);
}
this.inputBuffersHead = [[]];
this.inputBuffersToSend = [[]];
for (let channelNumber = 0; channelNumber < numberOfChannels; channelNumber++) {
this.inputBuffersHead[0][channelNumber] = this.inputBuffers[0][channelNumber].subarray(0, this.frameSize);
this.inputBuffersToSend[0][channelNumber] = new Float32Array(this.frameSize);
}
}
allocateOutputChannels(numberOfChannels) {
this.outputBuffers = [[]];
for (let channelNumber = 0; channelNumber < numberOfChannels; channelNumber++) {
this.outputBuffers[0][channelNumber] = new Float32Array(this.frameSize);
}
this.outputBuffersToRetrieve = [[]];
for (let channelNumber = 0; channelNumber < numberOfChannels; channelNumber++) {
this.outputBuffersToRetrieve[0][channelNumber] = new Float32Array(this.frameSize);
}
}
readInputs(inputs) {
if (inputs[0].length && (inputs[0][0].length === 0)) {
for (let channelNumber = 0; channelNumber < this.inputBuffers[0].length; channelNumber++) {
this.inputBuffers[0][channelNumber].fill(0, this.frameSize);
}
return;
}
for (let channelNumber = 0; channelNumber < this.inputBuffers[0].length; channelNumber++) {
this.inputBuffers[0][channelNumber].set(inputs[0][channelNumber], this.frameSize);
}
}
writeOutputs(outputs) {
for (let channelNumber = 0; channelNumber < this.inputBuffers[0].length; channelNumber++) {
outputs[0][channelNumber].set(this.outputBuffers[0][channelNumber].subarray(0, OverlapAddProcessor.RENDER_QUANTUM_SIZE));
}
}
shiftInputBuffers() {
for (let channelNumber = 0; channelNumber < this.inputBuffers[0].length; channelNumber++) {
this.inputBuffers[0][channelNumber].copyWithin(0, OverlapAddProcessor.RENDER_QUANTUM_SIZE);
}
}
shiftOutputBuffers() {
for (let channelNumber = 0; channelNumber < this.outputBuffers[0].length; channelNumber++) {
this.outputBuffers[0][channelNumber].copyWithin(0, OverlapAddProcessor.RENDER_QUANTUM_SIZE);
this.outputBuffers[0][channelNumber].subarray(this.frameSize - OverlapAddProcessor.RENDER_QUANTUM_SIZE).fill(0);
}
}
prepareInputBuffersToSend() {
for (let channelNumber = 0; channelNumber < this.inputBuffers[0].length; channelNumber++) {
this.inputBuffersToSend[0][channelNumber].set(this.inputBuffersHead[0][channelNumber]);
}
}
handleOutputBuffersToRetrieve() {
for (let channelNumber = 0; channelNumber < this.outputBuffers[0].length; channelNumber++) {
for (let n = 0; n < this.frameSize; n++) {
this.outputBuffers[0][channelNumber][n] += this.outputBuffersToRetrieve[0][channelNumber][n] / this.numberOfOverlaps;
}
}
}
}
実装を理解するには, process
メソッド (AudioWorkletProcessCallback
)
が呼び出しているメソッドをそれぞれ理解するのがよいでしょう. process
メソッド (AudioWorkletProcessCallback
)
が必要なメソッドを順に呼び出すようになっています (GoF の 23 のデザインパターンにあてはめると, Adapter (オーバーラップアドの抽象化) と Facade
(必要なメソッドの呼び出しのみの責務) のような役割を果たします).
OverlapAddProcessor
クラスにおいて, 入力のための 3 つの多次元配列と, 出力のための 2 つの多次元配列は独立しています.
入力と出力をつなげるのは processOverlapAdd
メソッドで, これはサブクラスの責務です.
入力処理は, 128
サンプルを frameSize
サンプルのオーディオデータを格納する
Float32Array
のバッファに格納するだけです. そのとき, サンプル数が不足していれば 0
埋めして,
サンプル数が超過していれば, 最も古い 128
サンプルのデータを破棄します (つまり, オーバーラップアド処理のためのプリプロセス処理です).
readInputs
メソッド
実際の入力オーディオデータ (process
メソッド (AudioWorkletProcessCallback
) の第 1 引数) を読み込んで,
inputBuffers
の末尾に追加するメソッドです.
shiftInputBuffers
メソッド
inputBuffers
に格納されているオーディオデータすべてを 128
サンプル先頭にシフトします. この時点で, 最も古い
128
サンプルのオーディオデータは破棄され, 直近の readInputs
メソッドで読み込まれた
128
サンプルのオーディオデータが, inputBuffersHead
の参照する末尾の 128
サンプルにシフトします.
prepareInputBuffersToSend
メソッド
inputBuffersHead
が参照する, inputBuffers
の先頭から frameSize
サンプル数のオーディオデータを
inputBuffersToSend
に格納して, processOverlapAdd
メソッドで処理できるようにします.
出力処理に関するメソッドは以下の 3 つで, handleOutputBuffersToRetrieve
メソッドがオーバーラップアド処理を適用しています.
handleOutputBuffersToRetrieve
メソッド
processOverlapAdd
メソッドで, outputBuffersToRetrieve
に格納された
frameSize
サンプル数のオーディオデータを, 前の実行時までのオーバーラップアドを適用した, 出力オーディオデータを格納している
outputBuffers
に加算します. この加算は, オーバーラップ数 (numberOfOverlaps
) だけ実行されるので,
その平均を取るように, オーバーラップ数で除算します.
writeOutputs
メソッド
実際の出力オーディオデータ (process
メソッド (AudioWorkletProcessCallback
) の第 2 引数) を読み込んで,
outputBuffers
の先頭から 128
サンプルを参照して出力します.
shiftOutputBuffers
メソッド
outputBuffers
に格納されているオーディオデータすべてを 128
サンプル先頭にシフトします. この時点で, 直近の
writeOutputs
メソッドで出力した 128
サンプルのオーディオデータは破棄されます. また, シフトした結果,
outputBuffers
の末尾の 128
サンプルは 0
埋めしておきます.
説明の順序が逆転的ですが, コンストラクタでは, 各プロパティの初期化と, チャンネルごとの
Float32Array
を格納する多次元配列の初期化メソッドを呼び出します.
allocateInputChannels
メソッド
コンストラクタ呼び出し時と, reallocateChannelsIfNeeded
メソッドから呼び出されます.
allocateOutputChannels
メソッド
コンストラクタ呼び出し時と, reallocateChannelsIfNeeded
メソッドから呼び出されます.
また, process
メソッド (AudioWorkletProcessCallback
) 実行ごとに, チャンネル数がリセットされたり,
変更されたりしていれば, 同様に配列の初期化を実行します (この処理は, AudioWorkletProcessor
の仕様上必要な処理で,
オーバーラップアドの本質的な処理ではないので, 説明を最後にしました).
reallocateChannelsIfNeeded
メソッド
AudioWorkletProcessor
の仕様上, オーディオデータがない場合などは, チャンネル数が 0
になって,
FrozenArray
の構造が変わるので, process
メソッドの最初で, 必要があれば (チャンネル数が異なっていれば),
各チャンネルを Float32Array
で初期化します (allocateInputChannels
メソッドとallocateOutputChannels
メソッドを呼び出します)
上記の OverlapAddProcessor
クラスによるオーバーラップアドは以下のようなイメージになります.
OverlapAddProcessor
クラスによるオーバーラップアド
frameSize
を 1024
サンプル (8 フレーム) とした場合のイメージです. もし詳細が理解しにくければ,
128
サンプル末尾に enqueue (入力) しつつ, 128
サンプルを dequeue (出力) するリングバッファのようなデータ構造をもち,
オーバーラップするオーディオデータは加算してその平均をとるという理解で十分です. また, オーディオデータが不足している場合,
0
埋めされたオーディオデータを加算するので, 加算回数はどのフレームも同じになります (上記のイラストだと,
前半のフレームは薄くなっていますが, 実際には 0
埋めされたフレームが加算されてその平均が出力されています).
OverlapAddProcessor サブクラスの実装 (OverlapAddProcessor クラスの拡張)
OverlapAddProcessor
クラスは直接 registerProcessor
メソッドに指定するのではなく,
OverlapAddProcessor
クラスを拡張 (継承) したサブクラスを指定します. そして, このサブクラスで
processOverlapAdd
メソッドをオーバーライドします (したがって, TypeScript を使う場合は,
processOverlapAdd
メソッドを抽象メソッドにして, OverlapAddProcessor
クラスを抽象クラスにしておくと言語処理系レベルで,
この仕様を矯正できます).
processOverlapAdd
メソッドで必要であれば, 窓関数を適用します. 窓関数は, 実装するオーディオ処理によって適切に選択できるように,
サブクラス側で指定できるように実装しておくのがよいでしょう (窓関数の生成メソッドは,
OverlapAddProcessor
クラスで定義しておいてもよいかもしれません).
具体的な解説として, 実用性はありませんが, processOverlapAdd
メソッドで窓関数 (ハニング窓) を適用して, FFT したあと,
周波数領域では何もせず (Bypass), IFFT して, 再度, 時間領域で窓関数を適用して出力するだけの OverlapAddProcessor
クラスの拡張である
BypassOverlapAddProcessor
クラスの実装を記載します.
processOverlapAdd
メソッドで呼び出している, FFT
/ IFFT
関数は,
高速フーリエ変換の実装 セクションで記載している実装を
AudioWorkletGlobalScope
内で定義していると仮定してください.
// Filename is './audio-worklets/bypass-overlap-add.js'
/**
* This class extends `OverlapAddProcessor`.
*/
class BypassOverlapAddProcessor extends OverlapAddProcessor {
constructor(options) {
super(options);
this.hanningWindow = this.createHanningWindow(this.frameSize);
}
/** @overdrive */
processOverlapAdd(inputs, outputs, parameters) {
const input = inputs[0];
const output = outputs[0];
const numberOfChannels = input.length;
for (let channelNumber = 0; channelNumber < numberOfChannels; channelNumber++) {
const reals = new Float32Array(this.frameSize);
const imags = new Float32Array(this.frameSize);
for (let n = 0; n < this.frameSize; n++) {
reals[n] = this.hanningWindow[n] * input[channelNumber][n];
}
FFT(reals, imags, this.frameSize);
// Bypass
IFFT(reals, imags, this.frameSize);
for (let n = 0; n < this.frameSize; n++) {
output[channelNumber][n] = this.hanningWindow[n] * reals[n];
}
}
}
createHanningWindow(size) {
const w = new Float32Array(size);
for (let n = 0; n < size; n++) {
w[n] = 0.5 - 0.5 * Math.cos((2 * Math.PI * n) / size);
}
return w;
}
}
registerProcessor('BypassOverlapAddProcessor', BypassOverlapAddProcessor);
実装としては, OverlapAddProcessor
クラスを extends
するのと,
processOverlapAdd
メソッドをオーバーライドするだけで, 本質的なオーディオ処理の実装は, AudioWorkletProcessor
クラスの
process
メソッド (AudioWorkletProcessCallback
) と同様に実装できます. 引数に渡されるチャンネルごとの
Float32Array
のサイズが, frameSize
となっていますが, 実装上, その違い意識することはあまりないと思います.
窓関数 (ハニング窓) を適用した, オーバーラップアドは, 以下ようなイメージになります. 1 フレームごとではなく, 指定したフレーム単位で適用されます
(以下のイラストは 8 フレーム (1024
サンプル) 単位).
OverlapAddProcessor
サブクラスによるオーバーラップアドと窓関数
あとは, メインスレッドからフレームサイズ (frameSize
) を指定します. AudioWorkletNode
インスタンスの第 3 引数は,
AudioWorkletNodeOptions
型のオブジェクトを指定できるようになっているので, その
processorOptions
プロパティに, frameSize
プロパティとしてフレームサイズを指定します (仕様では,
processorOptions
プロパティは, プレインオブジェクトなので, 他にもメインスレッドから渡したい値があれば,
AudioWorkletProcessor
クラスに渡すことができます).
また, すでに解説しましたが, OverlapAddProcessor
クラスを使う場合には, AudioWorkletNodeOptions
型の
numberOfInputs
プロパティと numberOfOutputs
プロパティは指定しないように制約をつけてください.
デフォルトのフレームサイズを利用するのであれば, AudioWorkletNode
インスタンスの第 3 引数での指定は不要です.
/**
* This class extends `AudioWorkletProcessor`.
*/
class OverlapAddProcessor extends AudioWorkletProcessor {
static RENDER_QUANTUM_SIZE = 128;
constructor(options) {
super(options);
// ...
if (options.processorOptions) {
this.frameSize = options.processorOptions.frameSize ?? 2048;
}
// ...
}
// ...
}
そして, OverlapAddProcessor
コンストラクタで, processorOptions
プロパティが truthy な値であれば,
frameSize
プロパティが設定されているかを判定して, 設定されていれば, そのフレームサイズで
frameSize
プロパティをオーバーライドします.
<button type="button" id="button-bypass-overlap-add-processor">start</button>
const context = new AudioContext();
// './audio-worklets/bypass-overlap-add.js' is URL that has subclass that extends `OverlapAddProcessor`
context.audioWorklet.addModule('./audio-worklets/bypass-overlap-add.js')
.then(() => {
let oscillator = null;
let processor = null;
const onDown = () => {
if ((oscillator !== null) || (processor !== null)) {
return;
}
oscillator = new OscillatorNode(context);
processor = new AudioWorkletNode(context, 'BypassOverlapAddProcessor', {
processorOptions: {
frameSize: 1024
}
});
// OscillatorNode (Input) -> AudioWorkletNode (Bypass) -> AudioDestinationNode (Output)
oscillator.connect(processor);
processor.connect(context.destination);
oscillator.start(0);
buttonElement.textContent = 'stop'
};
const onUp = () => {
if ((oscillator === null) || (processor === null)) {
return;
}
oscillator.stop(0);
processor.disconnect(context.destination);
oscillator = null;
processor = null;
buttonElement.textContent = 'start'
};
const buttonElement = document.getElementById('button-bypass-overlap-add-processor');
buttonElement.addEventListener('mousedown', onDown);
buttonElement.addEventListener('touchstart', onDown);
buttonElement.addEventListener('mouseup', onUp);
buttonElement.addEventListener('touchend', onUp);
})
.catch((error) => {
// error handling
});
ノイズサプレッサー
ノイズゲート
ノイズを除去するためのエフェクターとしては, 単純なエフェクターだとノイズゲート があります.
ノイズゲートは時間領域のみの演算で実装可能ですが, 一定レベルの振幅以下の信号を無音 (振幅が 0
) にするので, ノイズでなくても,
信号の振幅が指定したレベル以下であれば, 除去されてしまいます. また, 目的の信号と重なった (目的の信号に加算された)
ノイズを除去することができません. (ノイズゲートは, 単純なアルゴリズムでありますが, 現状の Web Audio API の仕様では, AudioWorklet
を利用しないと実装できません. 以下に, ノイズゲートの AudioWorkletProcessor
のサブクラスの実装例を記載します).
// Filename is './audio-worklets/noise-gate.js'
class NoiseGateProcessor extends AudioWorkletProcessor {
constructor(options) {
super(options);
this.level = 0;
this.port.onmessage = (event) => {
if ((event.data.level >= 0) && (event.data.level <= 1)) {
this.level = event.data.level;
}
};
}
process(inputs, outputs) {
const input = inputs[0];
const output = outputs[0];
const numberOfChannels = input.length;
for (let channelNumber = 0; channelNumber < input.length; channelNumber++) {
const bufferSize = input[channelNumber].length;
for (let n = 0; n < bufferSize; n++) {
output[channelNumber][n] = (Math.abs(input[channelNumber][n]) > this.level) ? input[channelNumber][n] : 0;
}
}
return true;
}
}
registerProcessor('NoiseGateProcessor', NoiseGateProcessor);
スペクトルサブトラクション法によるノイズサプレッサー
ノイズサプレッサー では, ノイズのスペクトルの特性に着目して, 振幅スペクトルで演算を実行して, ノイズを目的に除去します . そして,
よく利用されるノイズサプレッサーのアルゴリズムとして, スペクトルサブトラクション法 があります. スペクトルサブトラクション法では,
ノイズをホワイトノイズ (白色雑音) としてモデリングして, その振幅スペクトル (あるいは, その 2 乗のパワースペクトル) が,
すべての周波数成分を一様に含んでいる (すべての周波数成分が同じパワーとなる ) ことを利用して, 振幅スペクトルからノイズの振幅の差分
(サブトラクション: subtraction) を, ノイズ除去後の信号の振幅スペクトルとするアルゴリズムです. また, 周波数領域での演算において,
人間の聴覚は位相スペクトルの違いに鈍感 という特性も利用して, 位相スペクトルに対しては原音そのままに,
時間領域の信号に戻すときに利用するだけです.
これを形式的に, 数式で表現すると以下のようになります.
$w\left(n\right), x\left(n\right), y\left(n\right)$ をそれぞれ, ノイズ信号, 入力信号, そして,
ノイズを含んだ出力信号とします.
$y\left(n\right) = x\left(n\right) + w\left(n\right)$
これらの信号を, FFT ($F$ ) した複素関数をそれぞれ,
$W\left(k\right), X\left(k\right), Y\left(k\right)$ とします.
$F\left[w\left(n\right)\right] = W\left(k\right), \quad F\left[x\left(n\right)\right] = X\left(k\right), \quad F\left[y\left(n\right)\right] =
Y\left(k\right)$
よって, 振幅スペクトル (絶対値) と位相スペクトル (偏角) の信号は以下のように表現できます.
$
\begin{align}
\begin{cases}
&\left|Y\left(k\right)\right| = \left|X\left(k\right)\right| + \left|W\left(k\right)\right| \\
&\tan^{-1}\left(\frac{\mathrm{imag}\left(Y\left(k\right)\right)}{\mathrm{real}\left(Y\left(k\right)\right)}\right) = \tan^{-1}\left(\frac{\mathrm{imag}\left(X\left(k\right)\right)}{\mathrm{real}\left(X\left(k\right)\right)}\right) + \tan^{-1}\left(\frac{\mathrm{imag}\left(W\left(k\right)\right)}{\mathrm{real}\left(W\left(k\right)\right)}\right) \\
\end{cases}
\end{align}
$
ここで, 極系式でこの振幅スペクトル (絶対値) の信号 (関数) と位相スペクトル (偏角) の信号 (関数) を 1 つにまとめて表現すると,
以下のように表現できます ($j$ は $j^{2} = -1$ となる虚数単位,
$A_{W|X|Y}\left(k\right)$ は, 各信号の振幅スペクトの関数,
$\theta_{W|X|Y}\left(k\right)$ は, 各信号のスペクトの関数を表しています.
$A_{Y}\left(k\right)e^{j\theta_{Y}\left(k\right)} = A_{X}\left(k\right)e^{j\theta_{X}\left(k\right)} +
A_{W}\left(k\right)e^{j\theta_{W}\left(k\right)}$
したがって, ノイズを除去した, 元の入力信号 ($X\left(k\right)$ ) のスペクトルは以下のように表現できます.
$X\left(k\right) = A_{Y}\left(k\right)e^{j\theta_{Y}\left(k\right)} - A_{W}\left(k\right)e^{j\theta_{W}\left(k\right)}$
厳密な数式の定義に基づくと, 位相スペクトルも演算対象に含める必要がありますが, ここで, 人間の聴覚特性を利用して位相スペクトルの演算は無視すると,
以下のような, 振幅スペクトルのみの差分演算となります (差分結果が, 負数となる場合は, 無音 (振幅スペクトルが 0
) とします.
$
\begin{align}
A_{X}\left(k\right) =
\begin{cases}
&A_{Y}\left(k\right) - A_{W}\left(k\right) &\left(A_{Y}\left(k\right) - A_{W}\left(k\right) \ge 0 \right) \\
&0 &\left(A_{Y}\left(k\right) - A_{W}\left(k\right) \lt 0 \right) \\
\end{cases}
\end{align}
$
あとは, オイラーの公式に従って, 絶対値と偏角をもとに, 実部の関数と虚部の関数に分解して, IFFT すれば, ノイズを除去した信号を得ることができます.
$x_{\mathrm{subtraction}}\left(n\right) = F^{-1}\left[X\left(k\right)\right] =
F^{-1}\left[A_{X}\left(k\right)e^{j\theta_{X}\left(k\right)}\right] = F^{-1}\left[A_{X}\left(k\right)\cos\left(\theta_{X}\left(k\right)\right) +
jA_{X}\left(k\right)\sin\left(\theta_{X}\left(k\right)\right)\right]$
これを実装した, OverlapAddProcessor
クラスを拡張した NoiseSuppressorProcessor
サブクラスの実装です
(周波数分解能をデフォルトより高くするために, OverlapAddProcessor
クラスを拡張します). また,
processOverlapAdd
メソッドで呼び出している, FFT
/ IFFT
関数は,
高速フーリエ変換の実装 セクションで記載している実装を
AudioWorkletGlobalScope
内で定義していると仮定してください.
// Filename is './audio-worklets/noise-suppressor.js'
class NoiseSuppressorProcessor extends OverlapAddProcessor {
static createHanningWindow(size) {
const w = new Float32Array(size);
for (let n = 0; n < size; n++) {
w[n] = 0.5 - 0.5 * Math.cos((2 * Math.PI * n) / size);
}
return w;
}
constructor(options) {
super(options);
this.threshold = 0;
this.hanningWindow = NoiseSuppressorProcessor.createHanningWindow(this.frameSize);
this.port.onmessage = (event) => {
if ((event.data.threshold >= 0) && (event.data.threshold <= 1)) {
this.threshold = event.data.threshold;
}
};
}
/** @overdrive */
processOverlapAdd(inputs, outputs) {
const input = inputs[0];
const output = outputs[0];
const numberOfChannels = input.length;
const fftSize = this.frameSize;
for (let channelNumber = 0; channelNumber < numberOfChannels; channelNumber++) {
const reals = new Float32Array(fftSize);
const imags = new Float32Array(fftSize);
for (let n = 0; n < fftSize; n++) {
reals[n] = this.hanningWindow[n] * input[channelNumber][n];
}
FFT(reals, imags, fftSize);
const amplitudes = new Float32Array(fftSize);
const phases = new Float32Array(fftSize);
for (let k = 0; k < fftSize; k++) {
amplitudes[k] = Math.sqrt((reals[k] ** 2) + (imags[k] ** 2));
if ((reals[k] !== 0) && (imags[k] !== 0)) {
phases[k] = Math.atan2(imags[k], reals[k]);
}
}
for (let k = 0; k < fftSize; k++) {
amplitudes[k] -= this.threshold;
if (amplitudes[k] < 0) {
amplitudes[k] = 0;
}
}
// Euler's formula
for (let k = 0; k < fftSize; k++) {
reals[k] = amplitudes[k] * Math.cos(phases[k]);
imags[k] = amplitudes[k] * Math.sin(phases[k]);
}
IFFT(reals, imags, fftSize);
for (let n = 0; n < fftSize; n++) {
output[channelNumber][n] = this.hanningWindow[n] * reals[n];
}
}
}
}
registerProcessor('NoiseSuppressorProcessor', NoiseSuppressorProcessor);
ちなみに, BypassOverlapAddProcessor
クラスでも, 同様の処理をしていますが, 窓関数の適用は, FFT の前だけでなく, IFFT
した後の時間領域の信号にも乗算します.
最後に, メインスレッドの実装例です.
<button type="button" id="button-noise-suppressor">start</button>
<label for="range-noise-suppressor-threshold">threshold</label>
<input type="range" id="range-noise-suppressor-threshold" value="0" min="0" max="1" step="0.05" />
<span id="print-noise-suppressor-threshold-value">0.00</span>
const context = new AudioContext();
const buttonElement = document.getElementById('button-noise-suppressor');
const rangeElement = document.getElementById('range-noise-suppressor-threshold');
const spanElement = document.getElementById('print-noise-suppressor-threshold-value');
let processor = null;
let mediaStream = null;
buttonElement.addEventListener('click', async () => {
buttonElement.setAttribute('disabled', 'disabled');
if (processor === null) {
// './audio-worklets/noise-suppressor.js' is URL that has subclass that extends `OverlapAddProcessor`
await context.audioWorklet.addModule('./audio-worklets/noise-suppressor.js')
}
if (mediaStream) {
const audioTracks = mediaStream.getAudioTracks();
for (const audioTrack of audioTracks) {
audioTrack.stop();
}
mediaStream = null;
buttonElement.textContent = 'start';
buttonElement.removeAttribute('disabled');
return;
}
if (mediaStream === null) {
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
}
const source = new MediaStreamAudioSourceNode(context, { mediaStream });
processor = new AudioWorkletNode(context, 'NoiseSuppressorProcessor', {
processorOptions: {
frameSize: 512
}
});
// MediaStreamAudioSourceNode (Input) -> AudioWorkletNode (Noise Suppressor) -> AudioDestinationNode (Output)
source.connect(processor);
processor.connect(context.destination);
buttonElement.textContent = 'stop';
buttonElement.removeAttribute('disabled');
});
rangeElement.addEventListener('input', (event) => {
const threshold = event.currentTarget.valueAsNumber;
if (processor) {
processor.port.postMessage({ threshold });
}
spanElement.textContent = threshold.toFixed(2);
});
ホワイトノイズとインパルス音のスペクトル
ホワイトノイズの振幅スペクトルはすべての周波数成分を一様に含んでいる説明しましたが, 実は,
インパルス音の振幅スペクトルもすべての周波数成分を一様に含んでいます. これは, インパルス音のモデリング関数である, デルタ関数のフーリエ変換が
1
と定数になることから導出できます. したがって, インパルス音の位相スペクトルも 1
と定数であり,
すべての周波数成分を一様に含んでいます (巡回畳み込み セクションを参照).
一方, ホワイトノイズの位相スペクトルは, すべての周波数成分を含んでいますが, 一様ではなくランダムな値 (位相) となります.
人間の聴覚は位相スペクトルの違いに鈍感ではありますが, ホワイトノイズとインパルス音の違いは, 時間領域の波形と位相スペクトルにあらわれており,
人間の聴覚の不思議さとまだまだ明かされていない知覚能力があるのではないかと思います.
Time Domain
Amplitude Spectrum
Phase Spectrum
ホワイトノイズの振幅スペクトルと位相スペクトル
start
ピッチシフター
ピッチシフター とは, 再生速度 (音長) を変化させずに, 周波数を変化させる エフェクターです (結果的に,
ピッチを変化させるエフェクターとなります). オーディオ処理を適用せずに, 再生速度を変化させると周波数も変化しますが, これは物理的な関係があり,
再生速度と周波数は比例関係にあるからです . 実際, AudioBufferSourceNode
の playbackRate
プロパティで再生速度を
2
倍にするとピッチも 2
倍に, 逆に, 再生速度を 0.5
倍にするとピッチも 0.5
倍になって聴こえます.
一般的に, ピッチシフターは, 再生速度を変化させずに , 周波数のみを変化させるエフェクターを意味します.
ピッチシフターは, 原音とオクターブ違いの音を合成するオクターバー , ペダルでピッチを変化させるワーミー (Whammy) (参考:
DigiTech の Whammy ),
原音と 3 度や 5 度の音 (ハーモニー) を合成するハーモナイザー など, 派生するエフェクターが多くあります (楽器演奏において,
ピッチシフターだけを利用することは少ないかもしれません).
ピッチシフターのアルゴリズムもいくつかあります. そのすべてを解説することはできないので, このセクションでは, 以下の 2 つを解説します.
時間領域の演算だけで実装できるリサンプリング とタイムストレッチ を利用した実装 (これは, Adobe Audition で利用されている
SOLA (Synchronized Overlap-Add ) や, PSOLA (Pitch Synchronous Overlap-Add ) の原始的なアルゴリズムです)
周波数領域での演算が必要となりますが, より精度の高いピッチシフターが実現できる, フェーズボコーダ を利用した実装
周波数領域での演算による, ピッチシフターのアルゴリズムとしては, 正弦波モデル (スペクトルモデル ) による,
McAulay & Quatieri モデル (Sinusoidal Spectral Modeling ) や SMS (Spectral Modeling Synthesis ) などがあります
(あるいは, これらのアルゴリズムが, フェーズボコーダに利用されている場合もあります).
リサンプリングとタイムストレッチ
リサンプリング を利用することによって, ピッチを変化させるることができます. リサンプリングとは,
サンプリング周波数を変える処理のことで , サンプル数を増やす場合, アップサンプリング ,
サンプル数を減らす場合, ダウンサンプリング と呼びます. もちろん, 先に解説したように, リサンプリングによって,
サンプリング周波数を変えるだけでは, 再生速度も変化してしまうので, タイムストレッチ を合わせて利用することで, 再生速度を変化させずに,
ピッチを変化させることが可能になります.
左はダウンサンプリングで, ダウンサンプリングしたあと, 元のサンプリング周波数で再生すると, 周波数 (ピッチ) が高く, かつ,
再生速度が速くなる
右はアップサンプリングで, アップサンプリングしたあと, 元のサンプリング周波数で再生すると, 周波数 (ピッチ) が低く, かつ, 再生速度が遅くなる
タイムストレッチ とは, ピッチシフターとは対となるエフェクターで,
周波数 (ピッチ) を変化させずに, 再生速度 (音長) を変化させる エフェクターです.
しかしながら, Web Audio API の仕様上, リサンプリングとタイムストレッチの組み合わせでピッチシフターを実装する場合は,
リアルタイム処理はできないことに注意してください. 再生前のピッチシフト, つまり, オフラインピッチシフター となります. したがって, Web Audio
API でリアルタイムでピッチシフターが必要な場合は, やはり, 次のセクションで解説する, フェーズボコーダなど周波数領域での演算をする必要があります
(ワンショットオーディオなど, ごくサイズの小さいオーディオデータであれば, 体感上はリアルタイム処理の速さがあるかもしれませんが,
厳密なリアルタイム処理ではありません).
あえて解説をするのは, オーディオ信号処理では一般的に利用されるアルゴリズムでもあり, リサンプリングやタイムストレッチ (波形の伸縮処理) は,
ピッチシフター以外でも頻繁に利用するオーディオ信号処理だからです.
さらに追加すると, タイムストレッチでは, 事前にオーディオデータすべてを取得する必要があるので (これがリアルタイム処理できない理由です),
楽曲データの再生でも AudioBufferSourceNode
を利用する必要があります (厳密には, オーディオデータの実体となる,
AudioBuffer
インスタンスから, チャンネルごとのオーディオデータをすべて取得して演算します).
AudioBufferSourceNode
を利用する場合, リサンプリングは playbackRate
プロパティ (AudioParam
)
の値を変更するだけです. playbackRate
プロパティを 1
より大きくすると, ダウンサンプリングとなって,
再生速度が速くなるとともに, ピッチも高くなります. playbackRate
プロパティを 1
より小さくすると,
アップサンプリングとなって, 再生速度が遅くなるとともに, ピッチも低くなります (もちろん, 同様のことは
detune
プロパティでも可能ですが, リサンプリングが目的であれば, playbackRate
プロパティのほうが直感的です).
あとは, タイムストレッチが実装できれば, オフラインピッチシフターが実装できます. タイムストレッチのアルゴリズムの本質は,
再生速度に関わらず同じですが, 再生速度が 1
より大きい場合と, 小さい場合では, 実装は少し異なるので, まずは,
それぞれのケースに対してシンプルに解説します.
再生速度を速くする場合
まずは, 再生速度を速くする場合から解説します. 再生速度を速くする場合とは, 再生速度を
$\mathrm{rate}$ で表した場合, $\mathrm{rate} \gt 1$ となる場合です.
具体例として, $\mathrm{rate} = 1.5 \left(= \frac{3}{2}\right)$ (1.5
倍速)
でタイムストレッチする場合を考えます. この場合, 以下のイラストで表すように, 3 周期分の波形を 2
周期に縮小する処理をオーディオデータ全体に適用します (すなわち, タイムストレッチ適用後のオーディオデータのサイズは,
$\frac{2}{3} = 0.66 \cdots$ 倍となります). ただし, 単純に波形を重ねて縮小するだけでは,
$n$ 番目の周期と $n + 1$ 番目の周期が同時に再生されるだけなので,
$n$ 番目の周期には, 単調減少関数を乗算した波形 と,
$n + 1$ 番目の周期には, 単調増加関数を乗算した波形 をオーバーラップアド することによって,
縮小部分における不連続点を除去, あるいは, 緩和しつつ, 再生速度を速くすることができます.
この周期の縮小処理を, イラスト中の $\mathrm{offset}$ を更新して, 逐次的に実行することで,
オーディオデータ全体の再生速度を速くすることができます.
タイムストレッチ (再生速度が 1
より大きい場合)
ところで, 説明を簡単にするために, あえてわかりやすい再生速度に設定しましたが, 1
より大きい
$\mathrm{rate}$ において, $m$ 周期分の波形を,
$n$ 周期分の波形に縮小するというのは, 以下の関係式が定義できます.
$n = \mathrm{rate} \cdot m \quad \left(\mathrm{rate} \gt 1\right)$
$\mathrm{rate} = 1.5 \left(= \frac{3}{2}\right)$ の場合,
$2n = 3m$ が成立します.
この関係式と, (自己) 相関関数を利用すると, 逐次処理のために必要な $\mathrm{offset}$ の値は,
以下のように定義できます ($\mathrm{period}$ は, 波形の周期です).
$\mathrm{offset} = \mathrm{round}\left(\frac{\mathrm{period}}{\mathrm{rate} - 1}\right) \quad \left(\mathrm{rate} \gt 1\right)$
playbackRate
プロパティによるリサンプリングと, 上記のタイムストレッチのアルゴリズムを実装すると以下のようになります.
const context = new AudioContext();
// const rate = 3 / 2;
// const pitch = 1 / rate;
const rate = 1.5;
const pitch = 0.66;
fetch('./assets/medias/Schubert-Symphony-No8-Unfinished-1st-2020-VR.mp3')
.then((response) => {
return response.arrayBuffer();
})
.then(async (arrayBuffer) => {
const audioBuffer = await context.decodeAudioData(arrayBuffer);
const numberOfChannels = audioBuffer.numberOfChannels;
const resampleRate = audioBuffer.sampleRate / pitch;
const length = Math.ceil(audioBuffer.length / rate);
const pitchShiftAudioBuffer = context.createBuffer(numberOfChannels, length, audioBuffer.sampleRate);
for (let channelNumber = 0; channelNumber < numberOfChannels; channelNumber++) {
const inputBuffer = audioBuffer.getChannelData(channelNumber);
const outputBuffer = new Float32Array(length);
// Time Stretch
const templateSize = Math.trunc(resampleRate * 0.01);
// Set the range that correlation function detects peaks is between 5 msec (0.005 sec) and 20 msec (0.02 sec)
const minPeriod = Math.trunc(resampleRate * 0.005);
const maxPeriod = Math.trunc(resampleRate * 0.02);
const x = new Float32Array(templateSize);
const y = new Float32Array(templateSize);
const r = new Float32Array(maxPeriod + 1);
let offset0 = 0;
let offset1 = 0;
while ((offset0 + (2 * maxPeriod)) < inputBuffer.length) {
for (let n = 0; n < templateSize; n++) {
x[n] = inputBuffer[offset0 + n];
}
let maxCorrelation = 0.0;
let period = minPeriod;
for (let m = minPeriod; m <= maxPeriod; m++) {
for (let n = 0; n < templateSize; n++) {
y[n] = inputBuffer[offset0 + m + n];
}
r[m] = 0.0;
// Correlation function
for (let n = 0; n < templateSize; n++) {
r[m] += x[n] * y[n];
}
if (r[m] > maxCorrelation) {
maxCorrelation = r[m]; // The peak of correlation function
period = m;
}
}
// Overlap-Add
for (let n = 0; n < period; n++) {
outputBuffer[offset1 + n] = inputBuffer[offset0 + n] * ((period - n) / period); // Monotonically decreasing
outputBuffer[offset1 + n] += inputBuffer[offset0 + period + n] * (n / period); // Monotonically increasing
}
const offset = Math.ceil(period / (rate - 1.0));
for (let n = period; n < offset; n++) {
if ((offset0 + period + n) >= inputBuffer.length) {
break;
}
outputBuffer[offset1 + n] = inputBuffer[offset0 + period + n];
}
offset0 += period + offset;
offset1 += offset;
}
pitchShiftAudioBuffer.copyToChannel(outputBuffer, channelNumber);
}
const source = new AudioBufferSourceNode(context, { buffer: pitchShiftAudioBuffer });
// Resampling
source.playbackRate.value = pitch;
source.connect(context.destination);
source.start(0);
})
.catch((error) => {
// error handling
});
再生速度を遅くする場合
同様に, 再生速度を遅くする場合を解説します. 再生速度を遅くする場合とは, 再生速度を
$\mathrm{rate}$ で表した場合, $\mathrm{rate} \lt 1$ となる場合です. そして,
再生速度を速くする場合はオーディオデータを縮小 するのに対して, 再生速度を遅くする場合はオーディオデータを伸長 します (つまり,
オーディオデータを伸縮するというアルゴリズムの本質は同じです). 具体例として,
$\mathrm{rate} = \frac{2}{3}$ ($0.66 \cdots$ 倍速)
でタイムストレッチする場合を考えます. この場合, 以下のイラストで表すように, 2 周期分の波形を 3
周期に伸長する処理をオーディオデータ全体に適用します (すなわち, タイムストレッチ適用後のオーディオデータのサイズは,
$\frac{3}{2} = 1.5$ 倍となります). ただし, 単純に波形を重ねて伸長するだけでは,
$n$ 番目の周期と $n + 1$ 番目の周期が同時に再生されるだけなので,
$n$ 番目の周期には, 単調増加関数を乗算した波形 と,
$n + 1$ 番目の周期には, 単調減少関数を乗算した波形 をオーバーラップアド することによって,
伸長部分における不連続点を除去, あるいは, 緩和しつつ, 再生速度を遅くすることができます.
この周期の伸長処理を, イラスト中の $\mathrm{offset}$ を更新して, 逐次的に実行することで,
オーディオデータ全体の再生速度を遅くすることができます.
タイムストレッチ (再生速度が 1
より小さい場合)
ところで, 説明を簡単にするために, あえてわかりやすい再生速度に設定しましたが, 1
より小さい
$\mathrm{rate}$ において, $m$ 周期分の波形を,
$n$ 周期分の波形に伸長するというのは, 再生速度を速くする場合と同様の, 以下の関係式が定義できます.
$n = \mathrm{rate} \cdot m \quad \left(0 \lt \mathrm{rate} \lt 1\right)$
$\mathrm{rate} = 0.66 \cdots \left(= \frac{2}{3}\right)$ の場合,
$3n = 2m$ が成立します.
この関係式と, (自己) 相関関数を利用すると, 逐次処理のために必要な $\mathrm{offset}$ の値は,
以下のように定義できます ($\mathrm{period}$ は, 波形の周期です).
$\mathrm{offset} = \mathrm{round}\left(\frac{\mathrm{period} \cdot \mathrm{rate}}{1.0 - \mathrm{rate}}\right) \quad \left(0 \lt \mathrm{rate}
\lt 1\right)$
playbackRate
プロパティによるリサンプリングと, 上記のタイムストレッチのアルゴリズムを実装すると以下のようになります.
const context = new AudioContext();
// const rate = 2 / 3;
// const pitch = 1 / rate;
const rate = 0.66;
const pitch = 1.5;
fetch('./assets/medias/Schubert-Symphony-No8-Unfinished-1st-2020-VR.mp3')
.then((response) => {
return response.arrayBuffer();
})
.then(async (arrayBuffer) => {
const audioBuffer = await context.decodeAudioData(arrayBuffer);
const numberOfChannels = audioBuffer.numberOfChannels;
const resampleRate = audioBuffer.sampleRate / pitch;
const length = Math.ceil(audioBuffer.length / rate);
const pitchShiftAudioBuffer = context.createBuffer(numberOfChannels, length, audioBuffer.sampleRate);
for (let channelNumber = 0; channelNumber < numberOfChannels; channelNumber++) {
const inputBuffer = audioBuffer.getChannelData(channelNumber);
const outputBuffer = new Float32Array(length);
// Time Stretch
const templateSize = Math.trunc(resampleRate * 0.01);
// Set the range that correlation function detects peaks is between 5 msec (0.005 sec) and 20 msec (0.02 sec)
const minPeriod = Math.trunc(resampleRate * 0.005);
const maxPeriod = Math.trunc(resampleRate * 0.02);
const x = new Float32Array(templateSize);
const y = new Float32Array(templateSize);
const r = new Float32Array(maxPeriod + 1);
let offset0 = 0;
let offset1 = 0;
while ((offset0 + (2 * maxPeriod)) < inputBuffer.length) {
for (let n = 0; n < templateSize; n++) {
x[n] = inputBuffer[offset0 + n];
}
let maxCorrelation = 0.0;
let period = minPeriod;
for (let m = minPeriod; m <= maxPeriod; m++) {
for (let n = 0; n < templateSize; n++) {
y[n] = inputBuffer[offset0 + m + n];
}
r[m] = 0.0;
// Correlation function
for (let n = 0; n < templateSize; n++) {
r[m] += x[n] * y[n];
}
if (r[m] > maxCorrelation) {
maxCorrelation = r[m]; // The peak of correlation function
period = m;
}
}
for (let n = 0; n < period; n++) {
outputBuffer[offset1 + n] = inputBuffer[offset0 + n];
}
for (let n = 0; n < period; n++) {
outputBuffer[offset1 + period + n] = inputBuffer[offset0 + period + n] * ((period - n) / period); // Monotonically decreasing
outputBuffer[offset1 + period + n] += inputBuffer[offset0 + n] * (n / period); // Monotonically increasing
}
const offset = Math.ceil((period * rate) / (1.0 - rate));
for (let n = period; n < offset; n++) {
if ((offset0 + n) >= inputBuffer.length) {
break;
}
outputBuffer[offset1 + period + n] = inputBuffer[offset0 + n];
}
offset0 += offset;
offset1 += period + offset;
}
pitchShiftAudioBuffer.copyToChannel(outputBuffer, channelNumber);
}
const source = new AudioBufferSourceNode(context, { buffer: pitchShiftAudioBuffer });
// Resampling
source.playbackRate.value = pitch;
source.connect(context.destination);
source.start(0);
})
.catch((error) => {
// error handling
});
相関関数
相関関数は以下のように定義されます (ちなみに, 同じ信号系列 の過去の信号を積和しているので, より厳密には, 自己相関関数 の定義です).
$y\left(m\right)$ は (自己) 相関関数,
$x\left(n\right)$ は相関対象の信号系列, $N$ は (自己)
相関関数のサイズを表しています.
$y\left(m\right) = \sum_{n=0}^{N-1}x\left(n\right)x\left(n + m\right) \quad \left(0 \le m \le N - 1 \right)$
(自己) 相関関数は, 元の信号系列と, その $m$ サンプル過去の信号を
$N$ 個の区間に限定して, 積和演算で算出します (演算の意味は異なりますが, 形式的に数式で考えると,
コンボリューション積分 (畳み込み積分) と同じです).
オーディオ信号処理においては, 相関関数は, 基本周期とその整数倍で正のピークを表すという特徴があります . つまり,
波形データを走査してピークを検出することで, 近似的な周期を求めることができます .
一般的なオーディオ信号処理におけるリサンプリング
一般的なオーディオ信号処理におけるリサンプリングのアルゴリズムには, 線形補間 によるアルゴリズムと,
シンク関数 を利用したアルゴリズムがあります.
以下は, 線形補間によるリサンプリングの実装例です
const context = new AudioContext();
// const pitch = 2 / 3;
const pitch = 0.66;
fetch('./assets/medias/Schubert-Symphony-No8-Unfinished-1st-2020-VR.mp3')
.then((response) => {
return response.arrayBuffer();
})
.then(async (arrayBuffer) => {
const audioBuffer = await audiocontext.decodeAudioData(arrayBuffer);
const numberOfChannels = audioBuffer.numberOfChannels;
const length = Math.ceil(audioBuffer.length / pitch);
const resampledAudioBuffer = audiocontext.createBuffer(numberOfChannels, length, audioBuffer.sampleRate);
for (let channelNumber = 0; channelNumber < numberOfChannels; channelNumber++) {
const inputBuffer = audioBuffer.getChannelData(channelNumber);
const outputBuffer = new Float32Array(length);
// Resampling
for (let n = 0; n < length; n++) {
const t = pitch * n;
const m = Math.trunc(t);
const delta = t - m;
if ((m >= 0) && ((m + 1) < length)) {
outputBuffer[n] = (delta * inputBuffer[m + 1]) + ((1 - delta) * inputBuffer[m]);
}
}
resampledAudioBuffer.copyToChannel(outputBuffer, channelNumber);
}
const source = new AudioBufferSourceNode(audiocontext, { buffer: resampledAudioBuffer });
source.connect(audiocontext.destination);
source.start(0);
})
.catch((error) => {
// error handling
});
もっとも, 線形補間は, リサンプル点を直線で補間するので, 実際の波形とは異なったオーディオデータとなってしまいます (ユースケースによっては,
線形補間で十分な場合もあると思いますが). サンプリング定理にしたがったアルゴリズムは, シンク関数によるリサンプリングの一択です. 以下は,
シンク関数のサイズを 24
とした場合の, リサンプリングの実装例です.
const SIZE_OF_SINC = 24;
function sinc(n) {
if (n === 0) {
return 1;
}
return Math.sin(n) / n;
}
const context = new AudioContext();
// const pitch = 2 / 3;
const pitch = 0.66;
fetch('./assets/medias/Schubert-Symphony-No8-Unfinished-1st-2020-VR.mp3')
.then((response) => {
return response.arrayBuffer();
})
.then(async (arrayBuffer) => {
const audioBuffer = await audiocontext.decodeAudioData(arrayBuffer);
const numberOfChannels = audioBuffer.numberOfChannels;
const length = Math.ceil(audioBuffer.length / pitch);
const resampledAudioBuffer = audiocontext.createBuffer(numberOfChannels, length, audioBuffer.sampleRate);
for (let channelNumber = 0; channelNumber < numberOfChannels; channelNumber++) {
const inputBuffer = audioBuffer.getChannelData(channelNumber);
const outputBuffer = new Float32Array(length);
// Resampling
for (let n = 0; n < length; n++) {
const t = pitch * n;
const offset = Math.trunc(t);
const halfOfSincSize = SIZE_OF_SINC / 2;
for (let m = (offset - halfOfSincSize); m <= (offset + halfOfSincSize); m++) {
if ((m >= 0) && (m < inputBuffer.length)) {
outputBuffer[n] += inputBuffer[m] * sinc(Math.PI * (t - m));
}
}
}
resampledAudioBuffer.copyToChannel(outputBuffer, channelNumber);
}
const source = new AudioBufferSourceNode(audiocontext, { buffer: resampledAudioBuffer });
source.connect(audiocontext.destination);
source.start(0);
})
.catch((error) => {
// error handling
});
Web Audio API におけるタイムストレッチ
タイムストレッチのアルゴリズムは複雑になりますが, MediaElementAudioSourceNode
を利用する場合, その音源となる,
HTMLAudioElement
や HTMLVideoElement
の playbackRate
プロパティは,
タイムストレッチを効かせながら再生速度を変更することができます (AudioBufferSourceNode
の playbackRate
プロパティ
(AudioParam
) とは異なるので注意してください). したがって, タイムストレッチのみが目的であれば,
MediaElementAudioSourceNode
を利用して, HTMLAudioElement
や HTMLVideoElement
の
playbackRate
プロパティを制御すれば簡単に実装できます.
ちなみに, HTMLAudioElement
や HTMLVideoElement
には preservesPitch
プロパティが定義されており,
デフォルト値が true
なので, タイムストレッチが効きます. したがって, このプロパティを false
にすると,
タイムストレッチが無効になり, playbackRate
プロパティ変更とともに, ピッチも変化します.
フェーズボコーダ
このセクションでは,
Phaze: a real-time web audio pitch-shifter
で利用されている, フェーズボコーダを利用したピッチシフター (それと同時に可能な, タイムストレッチ) の実装を解説します.
フェーズボコーダのアルゴリズムは 1 つではなく, 例えば, 最初期のフェーズボコーダは, パンドパスフィルタ (フィルタバンク) を並列接続して,
それぞれの周波数帯域で STFT (Short Time Fourier Transform : 短時間フーリエ変換 . 実装上は単純に, サンプル数が少ない FFT です)
を適用して, ピッチ変換やスペクトルの伸縮を実行します. 変換対象のピッチの検出には, 瞬時周波数 (位相の時間微分) を利用します.
現代的なフェーズボコーダでは, バンドパスフィルタを利用するのではなく, 振幅スペクトルを走査して, ピークを探索して,
それぞれのピークをシフトするのが一般的です.
Phaze: a real-time web audio pitch-shifter
のフェーズボコーダも, おおよそこのような実装となっています (さらにモダンで高度なフェーズボコーダだと,
振幅スペクトルのピークの帯域ごとに窓関数のサイズを可変にするといったより複雑な実装が適用されています.
実装のステップは大きく 3 つです.
STFT (FFT) を適用して, 振幅スペクトル (厳密には, その 2 乗なので, パワースペクトル) の配列を取得
パワースペクトルを走査して, スペクトルのピークのインデックスと, ピークの数を検出
ピークをピッチ (タイムストレッチもする場合は, 再生速度の逆数を乗算) に応じて, 実部と虚部でそれぞれシフトして, ISTFT (IFFT) で,
ピッチシフトした時間領域の波形を取得
OverlapAddProcessor
クラスを拡張した PitchShifterProcessor
サブクラスの実装です.
(周波数分解能をデフォルトより高くするために, OverlapAddProcessor
クラスを拡張します). また,
processOverlapAdd
メソッドで呼び出している, FFT
/ IFFT
関数は,
高速フーリエ変換の実装 セクションで記載している実装を
AudioWorkletGlobalScope
内で定義していると仮定してください.
// Filename is './audio-worklets/pitch-shifter.js'
class PitchShifterProcessor extends OverlapAddProcessor {
static createHanningWindow(size) {
const w = new Float32Array(size);
for (let n = 0; n < size; n++) {
w[n] = 0.5 - 0.5 * Math.cos((2 * Math.PI * n) / size);
}
return w;
}
constructor(options) {
super(options);
this.pitch = 1;
this.speed = 1;
this.timeCursor = 0;
this.hanningWindow = PitchShifterProcessor.createHanningWindow(this.frameSize);
this.port.onmessage = (event) => {
if (event.data.pitch > 0) {
this.pitch = event.data.pitch;
}
if (event.data.speed > 0) {
this.speed = event.data.speed;
}
};
}
/** @overdrive */
processOverlapAdd(inputs, outputs) {
const input = inputs[0];
const output = outputs[0];
const numberOfChannels = input.length;
const fftSize = this.frameSize;
for (let channelNumber = 0; channelNumber < numberOfChannels; channelNumber++) {
const reals = new Float32Array(fftSize);
const imags = new Float32Array(fftSize);
for (let n = 0; n < fftSize; n++) {
reals[n] = this.hanningWindow[n] * input[channelNumber][n];
}
FFT(reals, imags, fftSize);
const halfOfFFTSizze = fftSize / 2;
const bufferSize = halfOfFFTSizze + 1;
const magnitudes = new Float32Array(bufferSize);
const peakIndexes = new Uint16Array(bufferSize);
for (let k = 0; k < bufferSize; k++) {
magnitudes[k] = (reals[k] ** 2) + (imags[k] ** 2);
}
let numberOfPeaks = 0;
// Find peaks
let index = 2;
const end = halfOfFFTSizze + 1 - 2;
while (index < end) {
const magnitude = magnitudes[index];
if ((magnitudes[index - 1] >= magnitude) || (magnitudes[index - 2] >= magnitude)) {
++index;
continue;
}
if ((magnitudes[index + 1] >= magnitude) || (magnitudes[index + 2] >= magnitude)) {
++index;
continue;
}
peakIndexes[numberOfPeaks++] = index;
index += 2;
}
// Shift peaks
const shiftedReals = new Float32Array(fftSize);
const shiftedImags = new Float32Array(fftSize);
for (let k = 0; k < numberOfPeaks; k++) {
const peakIndex = peakIndexes[k];
const shiftedPeakIndex = Math.round(peakIndex * this.pitch * (1 / this.speed));
if (shiftedPeakIndex > bufferSize) {
break;
}
let startIndex = 0;
let endIndex = fftSize;;
if (k > 0) {
const peakIndexBefore = peakIndexes[k - 1];
startIndex = peakIndex - Math.floor((peakIndex - peakIndexBefore) / 2);
}
if (k < (numberOfPeaks - 1)) {
const peakIndexAfter = peakIndexes[k + 1];
endIndex = peakIndex + Math.ceil((peakIndexAfter - peakIndex) / 2);
}
const startOffset = startIndex - peakIndex;
const endOffset = endIndex - peakIndex;
for (let m = startOffset; m < endOffset; m++) {
const binCountIndex = peakIndex + m;
const shiftedBinCountIndex = shiftedPeakIndex + m;
if (shiftedBinCountIndex >= bufferSize) {
break;
}
const omega = (2 * Math.PI * (shiftedBinCountIndex - binCountIndex)) / fftSize;
const shiftedReal = Math.cos(omega * this.timeCursor);
const shiftedImag = Math.sin(omega * this.timeCursor);
shiftedReals[shiftedBinCountIndex] += (reals[binCountIndex] * shiftedReal) - (imags[binCountIndex] * shiftedImag);
shiftedImags[shiftedBinCountIndex] += (reals[binCountIndex] * shiftedImag) + (imags[binCountIndex] * shiftedReal);
}
}
for (let k = 1; k < halfOfFFTSizze; k++) {
shiftedReals[fftSize - k] = 0.0 + shiftedReals[k];
shiftedImags[fftSize - k] = 0.0 - shiftedImags[k];
}
IFFT(shiftedReals, shiftedImags, fftSize);
for (let n = 0; n < fftSize; n++) {
output[channelNumber][n] = this.hanningWindow[n] * shiftedReals[n];
}
}
this.timeCursor += this.hopSize;
}
}
registerProcessor('PitchShifterProcessor', PitchShifterProcessor);
メインスレッドの実装例です.
<button type="button" id="button-pitch-shifter" disabled>start</button>
<label for="range-pitch-shifter">pitch</label>
<input type="range" id="range-pitch-shifter" disabled value="1" min="0.5" max="4" step="0.05" />
<span id="print-pitch-shifter-value">1.00</span>
<label for="range-time-stretch">speed</label>
<input type="range" id="range-time-stretch" disabled value="1" min="0.5" max="4" step="0.05" />
<span id="print-time-stretch-value">1.00</span>
const context = new AudioContext();
const buttonElement = document.getElementById('button-pitch-shifter');
const rangePitchElement = document.getElementById('range-pitch-shifter');
const rangeSpeedElement = document.getElementById('range-time-stretch');
const spanPitchElement = document.getElementById('print-pitch-shifter-value');
const spanSpeedElement = document.getElementById('print-time-stretch-value');
let source = null;
let processor = null;
let pitch = 1;
let speed = 1;
fetch('./assets/medias/Schubert-Symphony-No8-Unfinished-1st-2020-VR.mp3')
.then((response) => {
return response.arrayBuffer();
})
.then(async (arrayBuffer) => {
// './audio-worklets/pitch-shifter.js' is URL that has subclass that extends `OverlapAddProcessor`
await context.audioWorklet.addModule('./audio-worklets/pitch-shifter.js')
const buffer = await context.decodeAudioData(arrayBuffer);
buttonElement.removeAttribute('disabled');
rangePitchElement.removeAttribute('disabled');
rangeSpeedElement.removeAttribute('disabled');
buttonElement.addEventListener('click', () => {
if ((source === null) && (processor === null)) {
source = new AudioBufferSourceNode(context, { buffer, playbackRate: speed });
processor = new AudioWorkletNode(context, 'PitchShifterProcessor');
processor.port.postMessage({ pitch, speed });
// AudioBufferSourceNode (Input) -> AudioWorkletNode (Pitch Shifter) -> AudioDestinationNode (Output)
source.connect(processor);
processor.connect(context.destination);
source.start(0);
source.onended = () => {
source = null;
if (processor) {
processor.disconnect();
processor = null;
}
buttonElement.textContent = 'start'
};
buttonElement.textContent = 'stop'
} else {
source.stop(0);
source = null;
if (processor) {
processor.disconnect();
processor = null;
}
buttonElement.textContent = 'start'
}
});
rangePitchElement.addEventListener('input', (event) => {
pitch = event.currentTarget.valueAsNumber;
if (processor) {
processor.port.postMessage({ pitch });
}
spanPitchElement.textContent = pitch.toFixed(2);
});
rangeSpeedElement.addEventListener('input', (event) => {
speed = event.currentTarget.valueAsNumber;
if (source && processor) {
source.playbackRate.value = speed;
processor.port.postMessage({ speed });
}
spanSpeedElement.textContent = speed.toFixed(2);
});
})
.catch((error) => {
// error handling
});
このセクションでは, 原音をピッチシフトしましたが, 原音とピッチシフとしたエフェクト音をミックスすることで,
簡易的なハーモナイザーも実装できるでしょう (さらにチャレンジしてみたい場合は, 3 度や 5 度のエフェクト音もミックスして,
本格的なハーモナイザーを実装してみてください). あるいは, ピークの帯域ごとに窓関数のサイズを可変にするなど,
さらにモダンなフェーズボコーダに実装してみるのもよいでしょう.
スペクトル領域でのボーカルキャンセラ
AudioWorklet によるオーディオ信号処理の実装例として,
時間領域でのボーカルキャンセラ の実装を解説しました. しかしながら,
時間領域でのボーカルキャンセラは実装はシンプルですが, ドラムなど中央に位置する楽器音も取り除かれてしまう, また,
ボーカルキャンセラ後の左右のチャンネルのデータは同じになるので, モノラル再生になってしまうというデメリットがありました.
スペクトル領域でのボーカルキャンセラは, 実装は複雑になりますが, ボーカル以外の中央に位置する楽器音は残しつつ, かつ,
左右のチャンネルのデータの特徴は保ちながらステレオ再生が可能になります. 具体的に説明すれば, スペクトル領域での演算にすることで,
ボーカルの周波数帯域に範囲を限定して, 左右のチャンネルを減算することで,
時間領域のボーカルキャンセラよりも精度のよいボーカルキャンセラを実装することが可能となります.
時間領域ではボーカル音を対象に除去しているのではなく, 音源の位置関係から結果として除去しているのに対して,
スペクトル領域でのボーカルキャンセラは, ボーカル音を対象にして除去するアルゴリズムです .
ただし, スペクトル領域でのボーカルキャンセラでも, 左右のチャンネルデータが必要なので,
対象のオーディオデータはステレオである必要があることには変わりません.
// Filename is './audio-worklets/vocal-canceler-on-spectrum.js
class SpectrumVocalCancelerProcessor extends OverlapAddProcessor {
// Safe positive minimum on `float` (6 digits)
static MINIMUM_AMPLITUDE = 0.000001;
static createHanningWindow(size) {
const w = new Float32Array(size);
for (let n = 0; n < size; n++) {
w[n] = 0.5 - 0.5 * Math.cos((2 * Math.PI * n) / size);
}
return w;
}
constructor(options) {
super(options);
this.depth = 0;
this.minFrequency = 200;
this.maxFrequency = 8000;
this.threshold = 0.05;
this.hanningWindow = SpectrumVocalCancelerProcessor.createHanningWindow(this.frameSize);
this.port.onmessage = (event) => {
if ((event.data.depth >= 0) && (event.data.depth <= 1)) {
this.depth = event.data.depth;
}
if ((event.data.minFrequency >= 0) && (event.data.minFrequency < this.maxFrequency)) {
this.minFrequency = event.data.minFrequency;
}
if (event.data.maxFrequency >= this.minFrequency) {
this.maxFrequency = event.data.maxFrequency;
}
if ((event.data.threshold >= 0) && (event.data.threshold <= 1)) {
this.threshold = event.data.threshold;
}
};
}
/** @override */
processOverlapAdd(inputs, outputs) {
const input = inputs[0];
const output = outputs[0];
if ((input.length === 0) || (output.length === 0)) {
return true;
}
if ((input.length !== 2) || (output.length !== 2)) {
for (let channelNumber = 0, numberOfChannels = input.length; channelNumber < numberOfChannels; channelNumber++) {
output[channelNumber].set(input[channelNumber]);
}
return true;
}
if (this.depth === 0) {
for (let channelNumber = 0, numberOfChannels = input.length; channelNumber < numberOfChannels; channelNumber++) {
output[channelNumber].set(input[channelNumber]);
}
return true;
}
const inputLs = input[0];
const inputRs = input[1];
const outputLs = output[0];
const outputRs = output[1];
const fftSize = this.frameSize;
const realLs = new Float32Array(fftSize);
const realRs = new Float32Array(fftSize);
const imagLs = new Float32Array(fftSize);
const imagRs = new Float32Array(fftSize);
for (let n = 0; n < fftSize; n++) {
realLs[n] = this.hanningWindow[n] * inputLs[n];
realRs[n] = this.hanningWindow[n] * inputRs[n];
}
FFT(realLs, imagLs, fftSize);
FFT(realRs, imagRs, fftSize);
const absLs = new Float32Array(fftSize);
const absRs = new Float32Array(fftSize);
const argLs = new Float32Array(fftSize);
const argRs = new Float32Array(fftSize);
for (let k = 0; k < fftSize; k++) {
absLs[k] = Math.sqrt((realLs[k] ** 2) + (imagLs[k] ** 2));
absRs[k] = Math.sqrt((realRs[k] ** 2) + (imagRs[k] ** 2));
argLs[k] = Math.atan2(imagLs[k], realLs[k]);
argRs[k] = Math.atan2(imagRs[k], realRs[k]);
}
const minIndex = Math.trunc(this.minFrequency * (fftSize / sampleRate));
const maxIndex = Math.trunc(this.maxFrequency * (fftSize / sampleRate));
for (let k = minIndex; k < maxIndex; k++) {
const numerator = Math.pow((absLs[k] - absRs[k]), 2);
const denominator = Math.pow((absLs[k] + absRs[k]), 2);
if (denominator != 0.0) {
const diff = numerator / denominator;
if (diff < this.threshold) {
absLs[k] = SpectrumVocalCancelerProcessor.MINIMUM_AMPLITUDE;
absRs[k] = SpectrumVocalCancelerProcessor.MINIMUM_AMPLITUDE;
absLs[fftSize - k] = absLs[k];
absRs[fftSize - k] = absRs[k];
}
}
}
for (let k = 0; k < fftSize; k++) {
realLs[k] = absLs[k] * Math.cos(argLs[k]);
realRs[k] = absRs[k] * Math.cos(argRs[k]);
imagLs[k] = absLs[k] * Math.sin(argLs[k]);
imagRs[k] = absRs[k] * Math.sin(argRs[k]);
}
IFFT(realLs, imagLs, fftSize);
IFFT(realRs, imagRs, fftSize);
for (let n = 0; n < fftSize; n++) {
outputLs[n] = ((1 - this.depth) * inputLs[n]) + (this.depth * (this.hanningWindow[n] * realLs[n]));
outputRs[n] = ((1 - this.depth) * inputRs[n]) + (this.depth * (this.hanningWindow[n] * realRs[n]));
}
return true;
}
}
registerProcessor('SpectrumVocalCancelerProcessor', SpectrumVocalCancelerProcessor);
上記の, スペクトル領域でのボーカルキャンセラでは, ボーカルの周波数帯域を 200 Hz
~ 8000 Hz
と仮定して,
その間の振幅スペクトルから, 以下の式で定義される閾値を算出します ($X_{L}\left(k\right)$
は左チャンネルの振幅スペクトル (の配列), $X_{R}\left(k\right)$ は右チャンネルの振幅スペクトル (の配列)
を表しています).
$D\left(k\right) = \frac{\left|X_{L}\left(k\right) - X_{R}\left(k\right)\right|^{2}}{\left|X_{L}\left(k\right) + X_{R}\left(k\right)\right|^{2}}$
この算出した閾値 ($D\left(k\right)$ ) が, あらかじめ指定した閾値 (変数 threshold
) を下回っていれば,
ボーカル音とみなして, 32 bit 浮動小数点の保証される正の最小値 (およそ, 小数点以下 6 桁) の振幅に設定することで除去しています (また,
振幅スペクトルは, ナイキスト周波数を軸に線対称となるので, 線対称となる後半のインデックスにも同様の値を設定します).
あとは, メインスレッドの実装例です. threshold
はハードコーディングでもよいですが, アプリケーション側から設定可能にすると,
除去レベルを調整しやすいので, 以下のメインスレッドの実装例では, ユーザーインタラクティブに制御可能にしています.
<input type="file" id="file-vocal-canceler" />
<audio id="audio-vocal-canceler" controls></audio>
<label for="range-vocal-canceler-depth">depth</label>
<input type="range" id="range-vocal-canceler-depth" disabled value="0" min="0.5" max="1" step="0.05" />
<span id="print-vocal-canceler-depth-value">0.00</span>
<label for="range-vocal-canceler-min-frequency">minFrequency</label>
<input type="range" id="range-vocal-canceler-min-frequency" disabled value="200" min="100" max="16000" step="100" />
<span id="print-vocal-canceler-min-frequency-value">2000 Hz</span>
<label for="range-vocal-canceler-range">range (maxFrequency)</label>
<input type="range" id="range-vocal-canceler-range" disabled value="7800" min="100" max="16000" step="100" />
<span id="print-vocal-canceler-range-value">7800 Hz (8000 Hz)</span>
<label for="range-vocal-canceler-threshold">threshold</label>
<input type="range" id="range-vocal-canceler-threshold" disabled value="0.5" min="0" max="1" step="0.05" />
<span id="print-vocal-canceler-threshold-value">0.50</span>
const context = new AudioContext();
const inputElement = document.getElementById('file-vocal-canceler');
const audioElement = document.getElementById('audio-vocal-canceler');
const rangeDepthElement = document.getElementById('range-vocal-canceler-depth');
const rangeMinFrequencyElement = document.getElementById('range-vocal-canceler-min-frequency');
const rangeRangeElement = document.getElementById('range-vocal-canceler-range');
const rangeThresholdElement = document.getElementById('range-vocal-canceler-threshold');
const spanDepthElement = document.getElementById('print-vocal-canceler-depth-value');
const spanMinFrequencyElement = document.getElementById('print-vocal-canceler-min-frequency-value');
const spanRangeElement = document.getElementById('print-vocal-canceler-range-value');
const spanThresholdElement = document.getElementById('print-vocal-canceler-threshold-value');
let source = null;
let processor = null;
let depth = 0;
let minFrequency = 200;
let maxFrequency = 8000;
let threshold = 0.5;
inputElement.addEventListener('click', async () => {
if (context.state !== 'running') {
await context.resume();
}
rangeDepthElement.removeAttribute('disabled');
rangeMinFrequencyElement.removeAttribute('disabled');
rangeRangeElement.removeAttribute('disabled');
rangeThresholdElement.removeAttribute('disabled');
context.audioWorklet.addModule('./audio-worklets/vocal-canceler-on-spectrum.js')
.then(() => {
processor = new AudioWorkletNode(context, 'SpectrumVocalCancelerProcessor');
processor.port.postMessage({ depth });
processor.port.postMessage({ minFrequency });
processor.port.postMessage({ maxFrequency });
processor.port.postMessage({ threshold });
})
.catch((error) => {
// error handling
})
}, { once: true });
inputElement.addEventListener('change', (event) => {
const file = event.currentTarget.files[0];
audioElement.src = window.URL.createObjectURL(file);
});
audioElement.addEventListener('loadstart', () => {
if (processor === null) {
return;
}
if (source === null) {
source = new MediaElementAudioSourceNode(context, { mediaElement: audioElement });
// MediaElementAudioSourceNode (Input) -> AudioWorkletNode (Vocal Canceler) -> AudioDestinationNode (Output)
source.connect(processor);
processor.connect(context.destination);
}
});
rangeDepthElement.addEventListener('input', (event) => {
depth = event.currentTarget.valueAsNumber;
if (processor) {
processor.port.postMessage({ depth });
}
spanDepthElement.textContent = depth.toFixed(2);
});
rangeMinFrequencyElement.addEventListener('input', (event) => {
minFrequency = event.currentTarget.valueAsNumber;
if (processor) {
processor.port.postMessage({ minFrequency });
}
spanMinFrequencyElement.textContent = `${minFrequency} Hz`;
});
rangeRangeElement.addEventListener('input', (event) => {
const range = event.currentTarget.valueAsNumber;
maxFrequency = minFrequency + range;
if (processor) {
processor.port.postMessage({ maxFrequency });
}
spanRangeElement.textContent = `${range} Hz (${maxFrequency} Hz)`;
});
rangeThresholdElement.addEventListener('input', (event) => {
threshold = event.currentTarget.valueAsNumber;
if (processor) {
processor.port.postMessage({ threshold });
}
spanThresholdElement.textContent = threshold.toFixed(2);
});
スペクトル領域のボーカルキャンセラでも, 音源によってはあまり除去できない場合もあるかと思います. また,
ボーカルの周波数帯域と重なるような楽器音などがある場合は, その楽器音も除去されてしまいます. 実は, ボーカルキャンセラを含めた, 音源分離 は,
まだ完璧なアプローチがない, 研究対象のテーマであり, 今後は, 人工知能の発展とともに, 信号処理のアプローチだけでなく,
機械学習や深層学習のアプローチも取り入れられて発展していくと思われます.
エフェクターの接続順
ここまでのセクションで様々なエフェクター (空間系, モジュレーション系, フィルタ系, 歪み系など) を解説してきました. 最後に, 解説しておくこととして, 1
つのエフェクーだけを利用する場合は考慮する必要のない問題ですが, 多くの場合, 複数のエフェクターを同時に利用することになるでしょう.
アンプシミュレーターと歪み系エフェクターによるディストーションサウンド のセクションでも記載しましたが, エフェクターの接続順は出力されるサウンドに大きく影響を与えます .
もちろん, 仕様的に決まりがあるわけではないので, 利用するエフェクターの個数の階乗 ($n!$ )
も接続順が存在することになります. しかしながら, エフェクターの接続順にはおおよその定石があります .
このセクションでは, ローランド社のマルチエフェクターの ME シリーズである,
ME-70 と
ME-80 にもとづいて,
エフェクターの接続順の定石を解説します (いずれの機種も, 生産・販売終了していますが, コンパクトエフェクター並べたような UI で直感的で使えるのと,
デジタルすぎないサウンドメイクができることから, まだまだ現役で利用されているギタリストは多くいらっしゃいます).
入力 (ギター, ベースなど)
ワウ, Whammy など飛び道具系
コンプレッサー
歪み系エフェクター (ブースター, オーバードライブ, ファズなど
アンプシミュレーター (もしくは, 実機のアンプにリターン接続) とイコライザー
ノイズリダクション (ノイズサプレッサー)
モジュレーション系 (コーラス・フランジャー, トレモロ, フェイザーなど)
ディレイ
リバーブ
出力 (スピーカー, レコーダー端子など)
実機のアンプを利用する場合などは, センド・リターン端子を利用するので接続自体は複雑になりますが, エフェクターの接続順の定石は変わりません.
また, ME-70 と ME-80 にもとづいたエフェクターの接続順の定石を記載しましたが, ローランド社が公開している,
ギター用エフェクトの接続順ガイド も非常に参考になると思います.
もっとも, あくまで定石であって, 必ずしも上記の接続順である必要はありません. エフェクターの接続順を学び,
創造していくのは「守破離」の概念と似ています. 「守」はまず基本や定石を学ぶこと, 知ること. 「破」は「守」を基に, 自分の好みに合う接続にしたり,
エフェクター個々の特性に合わせて, エフェクターの接続順をカスタマイズしたりしていくこと. 「離」は, 「守」「破」を経験したうえで,
まだこの世の中にはないようなサウンドエフェクトを創造したり, アンチパターンと思われていたことをあえて利用できるジャンルを創造したりしていくことです.
Web Audio API におけるエフェクターの実装のまとめ
エフェクターのセクションはかなり長く, 理解が必要なオーディオ信号処理も多かったと思いますが (このサイトのオーナーも執筆に約 1 年間を要しました ...),
これまでのセクションで記載したエフェクターは, エフェクターのタイプ (空間系, モジュレーション系, フィルタ系, 歪み系, 飛び道具系など)
のなかで基本的なエフェクターです.
アプリケーションのユースケースによっては, これだけでも十分な場合もあるかもしれませんが, 現実世界のエフェクターは, 例えば,
ディレイだけでもいくつかの種類があります (アナログディレイやテープディレイのシミュレート, ピンポンディレイやリバースディレイ,
コーラス風のディレイなど).
現実世界に存在してるエフェクターや, まだ存在していない未知のエフェクターを創造していく場合でも,
まずはこのドキュメントで記載した基本を理解しておくことは重要です. 特に, 実装が複雑に思えるエフェクターでも, AudioNode
の接続と,
AudioParam
のオートメーションなどで実装できないかを検討してみることが Web Audio API で実装するうえでは重要です. また, AudioWorklet
が必要なエフェクターもいくつか記載しましたが, 直接オーディオデータにアクセスできる限り, 実装できないエフェクーはかなり少ないでしょう.
このセクションで記載した基本を理解して, ぜひ, 未知のエフェクターを創造してください. あるいは, アナログ回路に深い理解がある場合,
好きな真空管アンプの回路を研究して, アンプシミュレーターを開発してみるのも楽しいと思います. 「楽しみ」と「創造」は無限です.
少しでもそのお役に立てれば幸いです.