Web Music ドキュメント

Web Music

Web Music とは, Web (ブラウザ) をプラットフォームにした音楽アプリケーション, そして, そのような Web アプリケーションを実装するために必要となる, クライアントサイドの JavaScript API の総称です. これは, 一般的な技術用語ではなく, 技術マーケティング的な造語です.

具体的には, 以下のような, クライアントサイド JavaScript API の総称です.

Web Audio API, HTMLMediaElement, WebRTC に関しては, 本サイト制作開始時点の 2023 年時点で W3C recommendation となっており, モダンブラウザであれば利用することが可能です (ただし, クライアントサイド JavaScript の宿命ではありますが, OS やブラウザによって, 挙動が微妙に異なる, さらに, 移植性まで考慮すると, そのためのクロスブラウザ対応の問題は少なからず必要となります). これらのクライアントサイド JavaScript API は 2010 年代前半ごろは, HTML5 というバズワード化したカテゴリに分類される API でした. 現在は, HTML5 という仕様, あるいは, 用語が定着したからか, HTML5 というワードが使われることはほぼなくなりました. したがって, Web Music に関係する API も, 膨大なクライアントサイド JavaScript API のうちのいくつかです (という認識が一般的と言えます).

クライアントサイド JavaScript とは ?

クライアントサイド JavaScript とは, JavaScript の仕様の標準である ECMAScript と (JavaScript の実行コンテキストに依存しない言語仕様. これに準拠している JavaScript のコードであれば, Web ブラウザでも, Node.js でも, ブラウザ拡張でも使うことができます), 実行コンテキスト (広義な意味でのプラットフォーム) である Web ブラウザで実行する場合にのみアクセス可能な API です (例えば, Web Music の API は Node.js で使うことはできません. また, Web ブラウザでも Web Workers が生成したスレッドでは, メインスレッド (UI スレッド) と実行コンテキストが異なるので使うことができません).

Web Audio API

Web Music のなかで, もっともコアな API が Web Audio API です. 言い換えると, Web をプラットフォームとした音楽アプリケーションを制作するほとんどの場合で必要になる API ということです. なぜなら, HTMLAudioElement はオーディオファイルを再生するための API で, 高度なオーディオ処理をすることはできず (厳密には, jsfx のようにハッキーな実装をすることでエフェクトをかけるぐらいは可能ですが, 仕様のユースケースとして想定されている使い方ではありません), リアルタイム性やインタラクティブ性も考慮された API ではないからです (厳密には, 考慮された経緯もあって, Audio コンストラクタが定義されています). また, Web Music として, Web MIDI API や WebRTC を使う場合, 実際のオーディオ処理は Web Audio API が実行することになります.

Web Music の歴史

古くは, IE (Internet Explorer) が独自に, bgsound というタグを実装しており, ブラウザでオーディオをファイルを再生することが可能でした (現在の HTMLAudioElement に相当するタグと言えます). その後, Java アプレットや ActionScript (Flash) によって, 現在の Web Audio API で実現できているような高度なオーディオ処理が可能となりました.

しかし, これらは特定のベンダーに依存し, また, ブラウザの拡張機能 (プラグイン) という位置づけでした. Web 2.0 (もっと言えば, Ajax) を機にブラウザでも, ネイティブアプリケーションに近いアプリケーションが実装されてくるようになると, これまで拡張機能 (オーディオ処理だけでなく, ストレージやローカルファイルへのアクセス, ソケットなど) に頼っていたような機能をブラウザ標準で (クライアントサイド JavaScript API で) 実現できる流れが 2010 年ごろから活発になりました (このころ, HTML5 という位置づけで仕様策定され, モダンブラウザで実装されるようになりました). そういった流れのなかで, Web Audio API も仕様策定されて現在に至っています (草案 (Working Draft) が 2011 年 12 月 15 日 に公開. 2021 年 6 月 17 日に勧告 (W3C recommendation で現在の最新バージョン)).

Audio Data API

厳密な歴史を記載すると, Web Audio API より先に Firefox で, Audio Data API というブラウザ Audio API が実装されていました. HTMLAudioElement の拡張という位置づけであり, 出力するオーディオデータを直接演算する API がメインでした (Web Audio API の ScriptProcessorNode に相当する API). 間もなくして, Web Audio API に統一される方針となり, Firefox も Web Audio API のサポートを開始したので現在は削除されています.

このサイトに関して

このサイト (ドキュメント) の目的は, 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 インスタンスを生成する場合, コンストラクタ形式が推奨されているので, そちらを使います). ただし, 現時点であまりにも実装の乖離が大きい場合は, フォールバック的な解説として, 実装として動作するコードを記載します.

推奨ブラウザ

閲覧自体は, モダンブラウザであれば特に問題ありませんが, 実際のサンプルコードを動作させることを考慮すると, デスクトップブラウザ, 特に, Web Audio API の仕様に準拠している Google Chrome もしくは Mozilla Firefox (いずれも最新バージョン) を推奨します (Google Chrome の場合, より高度な Web Audio API 専用のプロファイラがあるのでおすすめです).

前提知識と経験

前提知識としては, 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 インスタンスに様々なプロパティやメソッドが実装されていることがわかるかと思います. このドキュメントではこれらを (すべてではありませんが) メインに解説していくことになります. また, このように実装を把握することで, 仕様と実装の乖離を調査することにも役立ちます.

AudioContext

Web Audio API でオーディオ処理を実装するうえで意識することはほとんどありませんが, AudioContextBaseAudioContext を拡張 (継承) したクラスであることもわかります.

BaseAudioContext

Autoplay Policy 対策

Web Audio API に限ったことではないですが, ページが開いたときに, ユーザーが意図しない音を聞かせるのはよくないという観点から (つまり, UX 上好ましくないという観点から), ブラウザでオーディオを再生する場合, Autoplay Policy という制限がかかります. これを解除するためには, ユーザーインタラクティブなイベント 発火後に AudioContext インスタンスを生成するか, もしくは, AudioContext インスタンスの resume メソッドを実行して AudioContextState'running' に変更する必要があります. これをしないと, オーディオを鳴らすことができません. また, decodeAudioData など一部のメソッドが Autoplay Policy 解除まで実行されなくなります. ユーザーインタラクティブなイベントとは, click, mousedowntouchstart などユーザーが明示的に操作することによって発火するイベントのことです. したがって, 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 インスタンスに実装されているファクトリメソッド 呼び出す必要があります (ただし, サウンドの出力点となる AudioDestinationNodeAudioContext インスタンスの 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 におけるオーディオ処理で頻繁に使うことになります. このセクションでは, 単純に, GainNodegain プロパティ (AudioParam インスタンス) を参照して, そのパラメータを取得・設定してみます (このセクションでは, 音量の制御と考えても問題ありません).

GainNodeAudioNode のサブクラスなので, コンストラクタ呼び出し, または, ファクトリメソッドで 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 インスタンスを生成したら, OscillatorNodeAudioDestinationNode の間に接続します.

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);

これで実際にサウンドを発生させると, 音の大きさが小さく聴こえるはずです.

このコードだと, 初期値を変更しているだけなので, 例えば, ユーザー操作によって変更するといったことができないので, インスタンス生成時以外でパラメータを設定したり, 取得したりする場合は, GainNodegain プロパティを参照します. これは, 先ほども記載したように, 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&QUOT;] のイベントリスナーで, input[type="range&QUOT;] で入力された値 (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 つのクラスの詳細についても解説を追加していきます.

「音」とは ?

このセクションでは, そもそも「音」とはなにか ? からスタートして, 音の特性について簡単に解説します. とは言っても, 専門すぎることは解説しないので, Web Audio API を理解するうえで, 最低限の解説をできるだけ簡単に解説します. また, そのため, 厳密さは犠牲にしている解説もあると思います. 音のスペシャリストの方からすると, ちょっと違う ... という部分はたくさんあるかと思いますがご了承ください (ただし, あきらかに間違った解説や誤解を招く可能性のある解説については遠慮なく Issue を作成したり, Pull Requests を送ったりしていただければと思います).

Web Audio API について解説するセクションではないので, 音の特性に関して学んだことあれば, このセクションはスキップしていただくのがよいでしょう.

音の実体

そもそも, 「音」って何なのでしょうか? 結論としては, 音とは媒体の振動が聴覚に伝わったものと定義することができます. 「媒体」というものが抽象的でよくわからないかもしれませんが, 具体的には, 空気や水です. 日常の多くの音は空気を媒体として, 空気の振動が聴覚に伝わることで音として知覚するわけですが, 同じことは水中でも起きますし, 普段聴いている自分の声は骨を媒体にして伝わっている音です.

音のモデリング

音をコンピュータで表現するためには, 媒体の振動を数式で表現して, その数式によって導出される数値を 2 進数で表現できる必要があります. 音の実体は媒体の振動というのを説明しましたが, この振動を表現するのに適した数学的な関数が, sin 関数です (cos 関数は sin 関数の位相の違いでしかないので本質的に同じと考えてもよいでしょう. また, tan 関数は含まれません. その理由は, $\frac{\pi}{2}$$-\frac{\pi}{2}$$\infty$$-\infty$ になるので振動を表現するには都合が悪いからと考えてよいでしょう).

Web Audio APIでも, OscillatorNodetype プロパティがとりうる値 (OscillatorType) の 1 つとして 'sine' が定義されています.

音を扱う学問や工学では, この sin 関数が, 音の波 (音波) をモデリングしていることから, 正弦波 (sin 波) と呼ぶことが多いです. とちらであっても, 実体は同じなのですが, このドキュメントではこれ以降, 慣習にしたがって, 正弦波 (sin 波) と記述することにします.

正弦波 (sin 波)

ここからは少し数学・物理的な話になってきます. 正弦波 (sin 関数) ってどんな形か覚えてらっしゃいますか?

正弦波 (sin 関数)

具体的に解説するためにパラメータを設定します.

パラメータつき正弦波 (sin 関数)

振幅と周波数 (周期)

まず, 縦軸に着目してみます. 縦軸のパラメータは, 振幅と呼ばれ, 単位はありません. ちなみに, 振幅 1 の正弦波と表現した場合, 上記のように振幅の最大値が 1, 最小値が -1の 正弦波のことを意味しています. 次に, 横軸に着目してみます. 横軸のパラメータは, 時間を表しています. 縦軸との関係で表現すると, ある時刻における正弦波の振幅値を表した図 (グラフ) と言えます. ここで, パラメータつきの正弦波を見てみます. すると, 山 1 つと谷 1 つを最小の構成として, それが繰り返されている, すなわち, 周期性をもつことがわかります. 数学的には, すべての時間 $t \left(0 \leqq {t} < \infty \right)$ に対して, $f\left(t + L\right) = f\left(t\right)$ となる定数が存在するとき, $f\left(t\right)$ は周期 $L$周期関数と定義されます. そして, sin 関数は, 周期 $L$ としたとき $\sin\left(t + L\right) = \sin\left(t\right)$ が成立するので, 正弦波 (sin 関数) は周期関数です.

この波の最小の構成が発生するために要する時間を周期と呼びます. 例として, 上記の正弦波で考えると, 最小の構成の発生までに 1 sec の時間を要しているので, 周期は 1 sec となります. この真逆の概念を表す用語が周波数です. すなわち, 1 sec の間に, 波の最小の構成が何回発生するか ? ということを表し, 単位は Hz (ヘルツ) です. Hz (ヘルツ) という名前ですが, 日本語に翻訳すれば, 何回の「回」に相当するでしょう. 上記の正弦波で考えると. この正弦波は, 1 sec の間に最小の構成が 1 回発生しているので, 周波数は, 1 Hz ということになります.

周期と周波数は互いに真逆の概念ですが, これは数学的には, 互いに逆数の関係にあります. すなわち, 周期の逆数は周波数を表し, 周波数の逆数は周期を表します. 互いに関係のある値なので, 周期の話をすれば周波数の話も同時にしていることであり, 周波数の話をすれば周期の話も同時にしていることになります. ただ, 周波数という用語のほうがよく使われる傾向にあると思うので, このドキュメントでは, 周波数の用語を優先的に利用することにします.

少し慣れるために, パラメータ (振幅や周波数) を変えた正弦波 (sin 波) を見てましょう.

振幅 0.5, 周波数 1 Hz (周期 1 sec) の正弦波 ('sine')
振幅 1, 周波数 2 Hz (周期 0.5 sec) の正弦波 ('sine')
振幅 1, 周波数 0.5 Hz (周期 2 sec) の正弦波 ('sine')

いかがでしたか ? 振幅と周波数は Web Audio API の解説においても頻出する用語なので, ある程度理解しておくと, Web Audio API の理解も進むでしょう.

基本波形

OscillatorNodetype プロパティ (OscillatorType) の値は, 正弦波を生成する文字列 'sine' 以外にも, 矩形波を生成する 'square' やノコギリ波を生成する 'sawtooth', 三角波を生成する 'triangle' があります. 正弦波の形はわかりましたが, それ以外はどのような形をしているのか見てみましょう.

振幅 0.5, 周波数 4 Hz (周期 0.25 sec) の矩形波 ('square')
振幅 0.5, 周波数 4 Hz (周期 0.25 sec) のノコギリ波 ('sawtooth')
振幅 0.5, 周波数 4 Hz (周期 0.25 sec) の三角波 ('triangle')

矩形波・ノコギリ波・三角波のいずれも正弦波と同じように, 周期性をもつ波 (関数) であるということです. 周期性をもつので, 周波数の概念を適用することができます. そして, 最も重要な点ですが, 周期性をもつ波は周波数の異なる正弦波を合成してできるということです. 矩形波・ノコギリ波・三角波はいずれも周期性をもちます. 周期性をもつので, 矩形波・ノコギリ波・三角波はいずれも周波数の異なる正弦波を合成して生成することができます. シンセサイザーでも, 正弦波・矩形波・ノコギリ波・三角波は基本波形として, サウンド生成のベースとなる波形です. そして, Web Audio API においても, 基本波形はサウンド生成 (OscillatorNode) のベースになる波形です.

音の 3 要素

ここまで, 数学・物理的な話が続いたので, 少し気分を変えて, 感覚視点 (知覚) から音を考えてみましょう.

日常でも, 「音が大きい・小さい」, 音楽を聴いていて「音が高い・低い」, 楽器を演奏していて「この楽器の音色が好き」などと表現することがあるかと思います. これらは, 音を感覚視点, すなわち, 音を知覚するときの視点で, どんな音か ? を表現しています. これらの表現にある, 音の大きさ音の高さ音色音の 3 要素と呼びます.

音の 3 要素と, 先に解説した振幅・周波数・波形と大きな関わりがあります.

音の大きさ (Loudness)
振幅が大きく影響する
音の高さ (Pitch)
周波数が大きく影響する
音色 (Timbre)
波形 (エンベロープ) が大きく影響する

大きく影響するという表現に注意してください. 例えば, 音の大きさは振幅のみで決定されるわけではないということです. 知覚は主観的な指標であり, 振幅・周波数・波形は物理量だからです. 物理現象である音と知覚を関連づける指標として, 音響特徴量 (等ラウドネス曲線や基本周波数, セントロイドなど) が知られていますが, Web Audio API を理解するうえでそこまで知っている必要はないので, 詳細を知りたい場合は, これらのキーワードをもとに, より最適なドキュメントや書籍がたくさんあるのでそちらを参考にしてください.

Web Audio API と音の関係

GainNode の gain プロパティと音の大きさ

GainNodegain プロパティ (AudioParam) を利用することで, 音の大きさを変えることができます. 物理的な視点で見ると, 振幅を操作することによって, 音の大きさを変えています.

GainNode gain

OscillatorNode の frequency プロパティと音の高さ

OscillatorNodefrequency プロパティ (AudioParam) を利用することで, 音の高さを変えることができます. 物理的な視点で見ると, 周波数を操作することによって, 音の高さを変更しています.

OscillatorNode frequency

仕様では, frequency プロパティのとりうる値の範囲は, 負のナイキスト周波数からナイキスト周波数までですが (ナイキスト周波数は, サンプリングのセクションで解説しています. ナイキスト周波数について理解がなければ, おおよそ, -20 kHz ~ 20 kHz と大雑把に把握していただいて問題ないです), 音楽アプリケーションなどで出力する音としてはそこまで設定できてもあまり意味はないでしょう. その理由は, 人間が聴きとることが可能な音の周波数の範囲は 20 Hz ~ 20000 Hz (20 kHz) 程度だからです.

さらに, 音程 (音の高さの差) として知覚可能な周波数の上限, 言い換えると, 音楽として有効な音の周波数はもっと低くなります (ピアノ 88 鍵の音域を参照してください).

ピアノ 88 鍵と周波数

OscillatorNode の detune プロパティと音の高さ

OscillatorNodedetune プロパティ (AudioParam) を利用することでも, 音の高さを変えることができます. 物理的な視点も frequency プロパティと同じです. ただし, detune プロパティは, 音楽的な視点で音の高さを変更します. detune プロパティの用途は, (音楽で言う) 半音よりも小さい範囲で音の高さを調整したり, オクターブ違いの音を生成・合成したりするために利用します. この機能によって, きめ細かいサウンド生成が可能になったり, サウンドを合成する場合において厚みをもたせることが可能になったりします. シンセサイザーのファインチューン機能や, エフェクターの 1 種であるオクターバーを実現するためにあると言えるでしょう.

OscillatorNode detune

frequency プロパティの単位は Hz (ヘルツ) で, 波が 1 sec の間に何回発生するのかを意味していました. 一方で, detune プロパティの単位は cent (セント) です. これは, 音楽の視点から音の高さをとらえた単位で, 1 オクターブの音程を 1200 で等分した値です.

1 つ高いラとか, 1 つ低いラのことを, 1 オクターブ高いラ, 1 オクターブ低いラと表現することがあります. 音楽的な視点でのオクターブはまさにそういう意味です.

オクターブを物理的な視点でみると, 周波数比が 1 : 2 の関係にある音程を意味しています. 具体的に説明すると, いわゆる普通のラ (A) (ギターの第 5 弦の開放弦) の周波数は 440 Hz です (キャリブレーションチューニングなどしている場合は別ですが ...). この音を基準に考えると, 1 オクターブ高いラの周波数は 880 Hz です. 周波数比が, 440 : 880 = 1 : 2 になります.

話を cent に戻すと, この 1 : 2 の音程を 1200 で割った値が 1 cent というわけです. なぜ, 1200 ? と疑問に思う方もいらっしゃると思いますが, ピアノをされる方は直感で理解できると思います. ピアノをされない方のために, 1 オクターブの音程間にピアノの鍵盤がいくつあるか数えてみましょう. 1 オクターブ間であればいいので, 好きな音から始めてください.

1 オクターブの鍵盤数

数えてみると, 12 個の鍵盤があります. 1 オクターブ間の音程を 1200 で割った (1200 分割した) 値が 1 cent でしたので, 1 オクターブ間の音程を 12 分割すると, 100 cent ということになります. つまり, 100 cent 値が高くなると, 右隣の鍵盤の音の高さに変わるということです.

例として, 440 Hz のラ (A) の音を 100 cent 高くすると, 右隣の鍵盤の ラ# (A#) に, さらに 100 cent 高くすると, シ (B) になります. このように, -100 cent ~ 100 cent の間の値を設定することによって, 半音以下の音の高さの調整が可能になるわけです. また, 1200 cent, あるいは, -1200 cent1200 cent ごとに値を設定することにより, オクターブ単位で調整することも可能です.

音楽では, 1 オクターブの音程を 12 等分した周波数比の関係を 12 平均音律と呼びます. 12 平均音律においては, 隣り合う音, つまり, 半音の周波数比は, およそ, 1 : 1.059463 (正確には, 1 : $2^{\left(1 / 12\right)}$) で, これが 100 cent となるわけです.

OscillatorNode の type プロパティと音色

OscillatorNodetype プロパティ (OscillatorType) の値を利用することで, 正弦波だけでなく, 矩形波やノコギリ波, 三角波を生成することができます. それによって, 音色を変化させることが可能です. ちなみに, 波形の概形はエンベロープと呼ばれます. OscillatorNode のみで制御可能な範囲では, この type プロパティに応じたエンベロープが音色に大きく影響しています.

このセクションのまとめとして, 基本波形, 振幅, 周波数を変化させたときの波形を視覚化するデモとなります. 波形の変化とともに, 知覚する音 (音の 3 要素) の変化を体感してみてください.

OscillatorNode

Web Audio API のアーキテクチャを解説するうえで, OscillatorNode は少し説明しましたが, このセクションでは, Web Audio API におけるサウンド生成・合成のベースとなる, OscillatorNode についてその詳細を解説します.

シンセサイザーの基本波形の生成・合成, モジュレーション系エフェクターで必須となる LFO (Low-Frequency Oscillator) など, Web Audio API において用途の広い, コアとなる AudioNode です. LFO に関しては, エフェクターのセクションで解説するので, このセクションでは基本波形の生成・合成に関して解説します.

type プロパティ (OscillatorOptions)

ただし, 'custom' のみは特殊で, 直接値を設定するとエラーが発生します. これは, OscillatorNodesetPeriodicWave メソッドによって, 自動的に 'custom' に設定されます. また, その引数として, AudioContextcreatePeriodicWave メソッドで波形テーブルを生成する必要があります. 波形テーブルの生成は, スペクトルや倍音などオーディオ信号処理の知識が必要になるので, 別のセクションで解説します.

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';
});

期待する動作, つまり, 発音・停止を繰り返すするには, 一度 startstopした 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 コンストラクタ) によるワンショットオーディオの再生は以下のような問題があります.

これらの問題を, ある程度容易に解決してくれるのが AudioBufferSourceNode です (もっとも, AudioBufferSourceNode を利用しても, コンピュータのリソースは有限なので, 計算量が多い場合や他のプロセスがリソースを多く消費している場合などは, 少なからずスケジューリングも正確でなくなります).

AudioBufferSourceNode

buffer プロパティ

AudioBufferSourceNode において, 最も重要と言えるのが, buffer プロパティであり, これは, AudioBuffer インスタンスを参照します. 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 までです. 例えば, ステレオ (numberOfChannels2)であれば, 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 APIFileReader API を使うことになります.

ワンショットオーディオ再生の場合, アプリケーション側であらかじめオーディオデータを Web にアップロードしているケースがほとんどなので, このセクションでは, Fetch APIArrayBuffer を取得する実装を解説します.

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 プロパティです. これらは, 音の物理的な性質, つまり, 再生速度を変化させるとピッチも変化するという性質を利用して, ピッチ (と再生時間) を変更します. 例えば, playbackRate2 に設定すれば, ピッチも 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
  });

期待する動作, つまり, 再生・停止を繰り返すには, 一度 startstop した (あるいは, 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 (HTMLAudioElementHTMLVideoElement) のオーディオデータに対するオーディオ信号処理を適用する場合に利用します.

MediaElementAudioSourceNode

HTMLMediaElement を音源にするので, MediaElementAudioSourceNode のコンストラクタ (もしくは, ファクトリメソッドの createMediaElementSource) には, 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" 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 属性にメディアファイルが指定されている場合は, 特に問題ありませんが, インタラクティブに, 例えば, ユーザーのファイルシステムからメディアファイルを選択するような場合, HTMLMediaElementloadstart イベント発火以降にインスタンスを生成する必要があります (逆に, 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 インスタンスが対応しているという点です. 例えば, HTMLMediaElementsrc 属性のみを変更する場合, MediaElementAudioSourceNode インスタンスを再度生成するとエラーが発生します (逆に, 別のオブジェクトとなる HTMLMediaElement を指定する場合, MediaElementAudioSourceNode インスタンスを生成する必要があります).

したがって, 先ほどのサンプルコードだと, 2 回以上, ファイルを選択してしまうと, 同じ HTMLAudioElement に対して, 複数回 MediaElementAudioSourceNode インスタンスが生成されてエラーが発生してしまうので, 以下のように変更します.

また, File API から選択した楽曲データを, HTMLMediaElementsrc 属性に指定する場合, 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);
});

再生と停止

MediaElementAudioSourceNode に楽曲データを再生・停止するためのメソッドはありません. 再生や一時停止は, コンストラクタの引数に指定した HTMLMediaElementplay / pause メソッドを実行します. したがって, OscillatorNodeAudioBufferSourceNode のように使い捨てのノードではない, つまり, インスタンスを再度生成して AudioDestinationNode に再度接続する必要もないので, この点は直感的な仕様と言えます.

あとは, AudioDestinationNode に接続すれば, 再生・停止することは簡単ですが, これでは HTMLMediaElement をそのまま利用するほうが合理的なので, 簡易例として, オーディオ信号処理を適用していることがわかるように, BiquadFilterNode を利用して Low-Pass Filter (低域通過フィルタ) を使ったサンプルコードです. カットオフ周波数を変更すると, 音の輪郭が変わることを確認してみてください (BiquadFilterNode に関しては, 別のセクションで詳細を解説します).

<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>
<input type="file" />
<audio controls />
const context = new AudioContext();

const inputElement = document.querySelector('input[type="file"]');
const audioElement = document.querySelector('audio');

const inputCutoffElement = document.getElementById('range-cutoff');
const spanElement        = 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;

  spanElement.textContent = `${lowpass.frequency.value} Hz`;
});

// UI (by `controls` attribute) plays and pauses media
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);
});

HTMLMediaElement と MediaElementAudioSourceNode

すでにサンプルコードを実行して, お気づきになったかもしれませんが, HTMLMediaElement のプロパティやイベントハンドラはすべて利用することが可能です. volumemuted, playbackRate は再生する楽曲データそのものに影響します. autoplayloop は再生における UX に影響します. また, 実際のプロダクトでは, loadedmetadata イベント, canplaythroughイベント, timeupdate イベント, ended イベントなどで, UI を更新するイベントハンドラを実行することも多いでしょう. このドキュメントですべてを解説することはできないので, HTMLMediaElement の仕様などを参考にしてください.

よくある実装として, loadedmetadata イベントで duration プロパティ (トータルの再生時間秒数) を取得, timeupdate イベントで currentTime プロパティ (現在の再生位置) を更新, ended イベントで初期表示に戻すというのは Web Audio API に直接関係はありませんが, メディアデータをあつかう Web アプリケーションでは必須になるような実装なので理解しておいて損はないでしょう. また, MediaElementAudioSourceNode の解説に着目するために HTMLMediaElementcontrols 属性での 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 (MediaDevicesgetUserMedia メソッドで取得できる MediaStream インスタンス) で取得したサウンドデータに対するオーディオ信号処理を適用する場合に利用します.

MediaStreamAudioSourceNode

WebRTC の仕様は Web Audio API と同等かそれ以上に膨大ですが, Web Audio API との関係で言えば, MediaDevicesgetUserMedia メソッドを理解すれば問題ないでしょう.

getUserMedia メソッドの引数には MediaStreamConstraints を指定します (少なくとも, Web Audio API で利用することを想定するので, audiotrue にしておきます). 初回実行時は, マイクロフォン (もしくは, 選択したオーディオインターフェース) に対するアクセス許可を求めるダイアログが表示されます. アクセスを許可して問題なければ, 戻り値の Promisefulfilled 状態になります. 成功時の 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
  });

デバイスの破棄

MediaStreamAudioSourceNode には, オーディオデバイスがらの入力を停止するためのメソッドはありません. デバイスを破棄するには, MediaStream インスタンスの getAudioTracks メソッドで, オーディオデバイスの実体である MediaStreamTrack インスタンスの配列を取得します. MediaStreamTrack には, デバイスを破棄するための stop メソッドが実装されているので, 対象の MediaStreamTrack インスタンスで stop メソッドを実行します (同様に, ビデオデバイスを停止するには getVideoTracks メソッドで MediaStreamTrack インスタンスの配列を取得します).

MediaStream
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);

    window.setTimeout(() => {
      const audioTracks = stream.getAudioTracks();

      for (const audioTrack of audioTracks) {
        audioTrack.stop();
      }
    }, 10000);
  })
  .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 クラスを継承しています. AudioWorkletNodeAudioNode に接続することで, AudioWorkletProcessor (の process メソッド) で実装したオーディオ信号処理が適用されて, 次に接続している AudioNode への入力として出力されます.また, AudioNodeAudioWorkletNode に接続することで, 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 メソッド

process メソッドの 第 1 引数には入力サウンドデータとなる Float32Array, 第 2 引数には出力サウンドデータとなる Float32Array, 第 3 引数には, 独自に AudioParam を定義する場合にパラメータとなる Float32Array がそれぞれ渡されます. これらの Float32Array のサイズは, すべて 128 (サンプル) です (詳細は, a-ratek-rate (AutomationRate) を参照してください). また, 第 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++) {
      for (let n = 0; n < 128; 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) ユースケースがほとんどないからです.

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++) {
          for (let n = 0; n < 128; n++) {
            output[channelNumber][n] = (2 * Math.random()) - 1;
          }
        }

        break;
      }

      case 'pinknoise': {
        for (let channelNumber = 0, numberOfChannels = output.length; channelNumber < numberOfChannels; channelNumber++) {
          for (let n = 0; n < 128; 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++) {
          for (let n = 0; n < 128; 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) からメインスレッドにデータを送信することも可能です. その場合, AudioWorkletNodeport プロパティが 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 128 samples' });

    return true;
  }
}

registerProcessor('MessageProcessor', MessageProcessor);

parameterDescriptors メソッド

parameterDescriptors メソッドは, AudioWorkletProcessor で独自の AudioParam を実装したい場合に使います. process メソッドや port プロパティのように (事実上) 必須の実装というわけではないので, ユースケースとして不要であればスキップしてください.

parameterDescriptors メソッドは Getter です. AudioParamDescriptor の配列を返すように実装します. AudioParamDescriptor で定義できるプロパティは, name, defaultValue, minValue, maxValue, automationRate の 5 つで, このなかで, name プロパティのみは必須で定義する必要があります. これは, メインスレッドで AudioParamMap インスタンスである AudioWorkletNodeparameters プロパティから, キーとして対象の AudioParam (AudioParamDescriptor) を取得するのに必要となるからです. それ以外のプロパティについてはデフォルト値が設定されています (defaultValue0, minValuemaxValue がそれぞれ, -3.4028235e383.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++) {
      for (let n = 0; n < 128; n++) {
        output[channelNumber][n] = (2 * Math.random()) - 1;
      }
    }

    return true;
  }
}

registerProcessor('NoiseGeneratorProcessor', NoiseGeneratorProcessor);

parameterDescriptors メソッドを実装すると, process メソッドの第 3 引数が渡されるようになります. name で指定した文字列をプロパティにして, パラメータの Float32Array を取得します. automation'a-rate' であれば, 128 サンプルごとに異なる値が格納されているので, インデックスを走査していきます. 'k-rate' の場合, 128 ごとに固定値なので, 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++) {
      for (let n = 0; n < 128; 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++) {
      for (let n = 0; n < 128; n++) {
        output[channelNumber][n] = gain[0] * ((2 * Math.random()) - 1);
      }
    }

    return true;
  }
}

registerProcessor('NoiseGeneratorProcessor', NoiseGeneratorProcessor);

メインスレッドで, 定義した AudioParamDescriptorAudioParam インスタンスとして取得するには, 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 の Requestcredentials 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++) {
          for (let n = 0; n < 128; n++) {
            output[channelNumber][n] = (2 * Math.random()) - 1;
          }
        }

        break;
      }

      case 'pinknoise': {
        for (let channelNumber = 0, numberOfChannels = output.length; channelNumber < numberOfChannels; channelNumber++) {
          for (let n = 0; n < 128; 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++) {
          for (let n = 0; n < 128; 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++) {
      for (let n = 0; n < 128; 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);

リバースチャンネル

左チャンネルからのサウンド出力と右チャンネルからのサウンド出力を反転する単純なエフェクターです (ただし, その原理から, 左右チャンネルのサウンドデータが異なる場合にしか効果がありません). 一般的には, オーディオデータに対して適用するので, オーディオデータの準備として AudioBufferSourceNodeMediaElementAudioSourceNode を入力ノードとして, 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.date.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-dept" 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 = 128;

    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 におけるスケジューリング (OscillatorNodeAudioBufferSourceNodestart / stop メソッドのスケジューリング, AudioParam のスケジューリング) にはおいて, 基本となる時間が AudioContext インスタンスの currentTime プロパティです. このプロパティは, AudioContext インスタンスが生成されてからの経過時間を秒単位で表現します (したがって, 参照するだけの readonly プロパティです). OscillatorNodeAudioBufferSourceNode を即時に発音・停止するために, start / stop メソッドの第 1 引数に 0 を指定していましたが, 実は, 即時に停止するであれば AudioContext インスタンスの currentTime を指定することでも可能です (即時に発音・停止するのは頻繁にあることなので, デフォルト値 0 で即時に発音・停止するように仕様定義されています). つまり, AudioContext インスタンスの currentTime プロパティを基準に, 未来の時間を指定すれば (加算すれば), スケジューリングが可能になるということです.

仕様上の詳細を解説をすると, OscillatorNodeAudioBufferSourceNodeAudioNode クラスを継承した 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 点異なるのは, AudioBufferSourceNodestart メソッドはオーバライドされています. もし, オーディオデータの再生開始位置を指定したい場合は, 第 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 になったら, Float32Arrayvalues の値にしたがって, duration の時間をかけて変化させる

cancelScheduledValues(cancelTime)

cancelTime 以降のスケジューリングを解除する

cancelAndHoldAtTime(cancelTime)

cancelTime 以降のスケジューリングを解除する (cancelTime 時点の値を保持する)

エンベロープジェネレーター

エンベロープとは ?

そもそも, エンベロープとは, 波形の概形のことです. テキストによる解説よりは, イラストによる解説が一目瞭然なので, 例として以下のような波形で説明します.

時間領域の波形
振幅エンベロープ

上記のエンベロープは, 振幅に対する波形の概形なので, 振幅エンベロープと呼びます. 振幅エンベロープを, 時間的に制御するオーディオ処理をエンベロープジェネレーターと呼びます.

エンベロープジェネレーターの実装において, パラメータのスケジュールの対象になるの GainNode インスタンスの gain プロパティです. gain プロパティは, AudioParam インスタンスであり, AudioParam で仕様定義されているパラメータのオートメーションメソッドを利用することで, パラメータをスケジュールすることが可能です (同様に, OscillatorNode インスタンスの frequency プロパティや, DelayNode インスタンスの delayTime プロパティ, BiquadFilterNodefrequency プロパティ ... など, 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 が実装されていません. しかしながら, cancelScheduledValuessetValueAtTime を使うことでポリフィルを実装することは可能です.

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-ratek-rate (AutomationRate)

AudioParam には, automationRate プロパティがあり, これは 'a-rate''k-rate'AutomationRate 型で列挙されるどちらかの値が設定されています. 'a-rate' は, 1 サンプルごとに値を適用することができる AudioParam です. 'k-rate' は, 128 サンプル単位で値を適用することができる 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 という用語が使われています). 例えば, AudioWorkletProcessor では, 128 サンプルごとの入力に対して (必要があれば, オーディオ処理を適用して), 128 サンプル出力します. リアルタイム性が要求されるようなオーディオ API では, 多くは, このような, 仕様で定義されているバッファサイズごとにオーディオ処理を適用する (そして, それを繰り返す) という API になっています.

Web Audio API におけるガベージコレクション

Web Audio API においてはこのセクションで解説したようなスケジューリングや, DelayNode などを利用した時に発生する遅延オーディオデータなどがあるので, JavaScript の仕様上のガベージコレクションの対象となるオブジェクトに追加して, いくつかの条件があります.

  • 参照が残っていない
  • 処理すべきサウンドデータが残っていない
  • ノードが接続されていない
  • サウンドが停止している
  • スケジューリングが設定されていない

上記 5 つの条件すべてを満たすオブジェクトが, ガベージコレクションの対象となります. ざっくり説明すれば, なにかしらで利用されているオブジェクトはガベージコレクションの対象にならないということです.

参照が残っていない

これに関しては, Web Audio API に限らず, JavaScript, あるいは, ガベージコレクションが実装されているあらゆるプログラミング言語一般的なことです.

処理すべきサウンドデータが残っていない

処理すべきサウンドデータが意図せずに残るケースとして, DelayNodeConvolverNode を利用して, エフェクターであるディレイやリバーブを実装した場合が考えられますが, 実装的には対処する必要はありません. 処理すべきサウンドデータがある場合に, サウンドデータを完了状態にするのは, DelayNodeConvolverNode の役割であるのと, そもそも, このような場合に処理が残っているサウンドデータを破棄するなどの手段が現状の仕様では存在しないからです.

ノードが接続されていない

不要になった 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);

デジタルオーディオ信号処理

A/D 変換

アナログ信号である音 (媒体の振動) をコンピュータで処理するためには, 01 のみの情報, つまり, デジタル信号に変換する必要があります. この変換処理のことを, A/D変換 (Analog to Digital Conversion) と呼びます. A/D 変換は, 大きく 3 つの処理があります.

  1. サンプリング (標本化)
  2. 量子化
  3. 符号化

サンプリング (標本化) と量子化の処理に共通することは, 連続した信号を離散した信号に変換することです. コンピュータでは, 連続した値や無限大となる値を扱うことが不可能だからです.

「音」のセクションでは, いくつか音の波形のイラストを記載しましたが, それらは常に 2 つの連続した物理量 (次元) をもっていました. 時間振幅です. サンプリング (標本化) と量子化は, これら 2 つの連続した物理量を離散信号に変換する処理となります.

サンプリング (標本化)

サンプリング (標本化)は, 時間を離散した値に変換する処理です. 離散信号, すなわち, とびとびの値をとっていくためには, その間隔を決定するパラメータが必要になります. それが, サンプリング周期 (標本化周期) です. サンプリング周期の逆数となるパラメータは, 標本化周波数 (サンプリング周波数) です. 簡単に解説すれば, サンプリング周波数は, 1 sec の間に, いくつのサンプル (離散点) をとるか ? ということを意味しています. 例えば, サンプリング周波数が 48000 Hz の場合, 1 sec の間に 48000 サンプル (離散点) をとることになります.

サンプリングとサンプリング周波数 (サンプリング周期) 1 sec に 8 サンプルあるので, 8 Hz (0.125 sec)

サンプリング (標本化) では重要な定理があります. それは, サンプリング周波数の $\frac{1}{2}$ 以上の周波数は元のアナログ信号に復元できないという定理です. この定理は, サンプリング定理 (標本化定理, シャノンの定理) と呼ばれます. 逆の視点で表現すれば, サンプリング周波数の $\frac{1}{2}$ より低い周波数は元のアナログ信号に復元可能ということです. また, サンプリング周波数の $\frac{1}{2}$ は, ナイキスト周波数と呼ばれます. サンプリング定理から, 原信号に含まれる最大の周波数成分の 2 倍より大きいサンプリング周波数に設定すれば, 元のアナログ信号に復元可能ということになります (実際には, 低域通過フィルタ (Low-Pass Filter) を利用して, 高い周波数成分を除去するプリプロセス処理を施します).

サンプリング定理を満たさないサンプリング周波数, すなわち, 原信号に含まれる最大の周波数成分の 2 倍以下のサンプリング周波数でサンプリングすると, 折り返し歪み (エイリアス歪み) が発生して, ノイズとして復元されてしまいます.

例えば, 1 Hz の信号に対して, 2 サンプル (サンプリング周波数 2 Hz, ナイキスト周波数 1 Hz) では原信号に復元できません.

サンプリング定理 (折り返し歪みが発生する)

1 Hz の信号に対して, 3 サンプル (サンプリング周波数 3 hz, ナイキスト周波数 1.5 Hz) だと, 精度は低いですが, 原信号に復元できます

サンプリング定理 (定理を満たす場合)

サンプリングの精度を高くするほど, すなわち, サンプリング周波数を高くするほど元のアナログ信号に対してより精度の高いデジタル信号に変換可能となります. 一方で, データサイズはサンプリング周波数に比例して大きくなってしまいます.

以下の図は, 充分なサンプル数 (サンプリング周波数) だと原信号により精度高く復元できること表しています. そのトレードオフとして, サンプル数が多くなるので, データサイズはより大きくなることも表しています.

サンプリング定理

サンプリング周波数の具体例として, 音楽 CD は 44.1 kHz に設定されています. 人間の聴覚が知覚可能な周波数はおよそ 20 kHzであることを考慮してサンプリング定理を適用しているからです. さらに音質の高いものだと 96 kHz 以上に設定されている音楽データもあります (ハイレゾオーディオのサンプリング周波数). 電話では 8 kHz に設定されています. 音声の場合は, 多少音質が損なわれても相手の音声を聴きとることが可能なこと, 楽器音ほど高い周波数成分が含まれないこと, リアルタイムに通信するので可能な限りデータサイズを減らす必要があることなどが理由としてあげられます.

Web Audio API では, AudioContext インスタンス生成時の引数として, AudioContextOptionssampleRate プロパティで明示的に指定することが可能です. 明示的に指定しない場合, デバイスのサンプリング周波数 (44100, 48000 など) に設定されています.

サウンドの視覚化の実装では, サンプリング周波数 (AudioContext インスタンス, または, AudioBuffer インスタンスの sampleRate プロパティ) にアクセスすることはよくあります. したがって, サンプリング周波数が何を意味しているのか ? ということと, サンプリング定理に関して理解しておくと役に立つでしょう.

量子化

量子化は, 振幅を離散した値に変換する処理です. サンプリングと同じく, とびとびの値をとっていくためには, その間隔を決定づけるパラメータが必要になります. それが, 量子化ビット (量子化精度) です.

サンプリングされたアナログ信号は時間軸方向は, 離散化されていますが, 振幅軸の方向は連続したままです. 量子化では, 量子化ビットで指定された精度にしたがって, 振幅を整数値に丸める処理を実行します. 例えば, 量子化ビットが 2 bit の場合, 4 つのステップの値 ($2^{2} = 4$) のいずれかに, 3 bit の場合, 8 つのステップの値 ($2^{3} = 8$) のいずれかに振幅が丸められます.

量子化 (量子化ビット 3 bit)

量子化の丸め処理によって生じる誤差を, 量子化雑音と呼びます. 量子化ビットが小さいほど, 丸め処理による誤差が大きくなり, 原信号への復元も精度が低くなってしまいます. 逆に, 量子化の精度を高くするほど, すなわち, 量子化ビットを大きくするほど, 量子化雑音は少なくなり (誤差が小さくなり), 原信号への復元の精度も高くなりますが, データサイズは量子化ビットに比例して大きくなります.

量子化ビット

音楽 CD での量子化ビットは 16 bit に設定されています. ハイレゾオーディオの量子化ビットは 24 bit 以上が必要条件となっています.

符号化

サンプリングによって, 時間軸方向に離散化し, それぞれのサンプル点を, 量子化によって丸めた整数値に 2 進数を割り当てていきます. 量子化した (整数値に丸めた) 振幅を 2 進数に符号化すると, コンピュータの内部で処理することが可能なデジタル信号となります.

サンプリング周波数 16 Hz, 量子化ビット 4 bit, 2 の補数方式で符号化した例です.

符号化

PCM (Pulse Code Modulation)

PCM (Pulse Code Modulation) とは, このセクションで解説したように, アナログ信号をデジタル信号に変換する変調方式のことです. 厳密に表現すると, このセクションで解説した PCM は, 量子化の幅を均等 (線形的) に取得しているので, Linear PCM です. 量子化の方式によって, log-PCM, DPCM (Differential PCM), ADPCM (Adaptive Differential PCM) などがあります. どれが優れた方式というのはなく, ケースによって使い分けますが, 多くの場合, Linear PCM が使われているので, 単純に PCM と言った場合, Linear PCM を意味することが多いです. Web Audio API でもオーディオデータの実体である AudioBuffer では, 32 bit (浮動小数点数) の Linear PCM による値を格納しています.

フーリエ解析

このセクションでは, デジタルオーディオ信号処理において, 中核となる数学的処理である, フーリエ解析 (フーリエ級数とフーリエ級数を一般化した (非周期関数に拡張した) フーリエ変換, コンピュータでフーリエ変換を実行するための離散フーリエ変換, そして, 回転因子の性質を利用して, 離散フーリエ変換の (時間) 計算量を $O\left(N^{2}\right)$ から $O\left(N\mathrm{log_{2}}N\right)$ に減らして実行する高速フーリエ変換) について解説します. もっとも, 数式による厳密な解説や証明は, 最適なドキュメントや書籍がすでにたくさんあるので, できるだけ, Web Audio API での仕様を把握したり, AudioWorklet でオーディオ信号処理を実装したりする場合を想定して, 数式による (厳密な) 解説は最小限にとどめて, イラストやコードをベースに, 概念を理解するために役に立つ内容になればと思います.

フーリエ級数

周期関数は, 周波数の異なる余弦波と正弦波の級数で近似することができます. この級数が, フーリエ級数であり, 周期関数をフーリエ級数で表現する場合, フーリエ級数展開 と呼ばれます. $x\left(t\right)$が, 周期 $T$ の場合, フーリエ級数は以下の数式で定義されます.

$ \begin{flalign} &f\left(t\right) = \frac{a_{0}}{2} + \sum_{n=1}^{\infty}\left(a_{n}\cos\left(n\frac{2 \pi}{T}t\right) + b_{n}\sin\left(n\frac{2 \pi}{T}t\right)\right) \\ &a_{n} = \frac{2}{T}\int_{-\frac{T}{2}}^{\frac{T}{2}}f\left(t\right)\cos\left(n\frac{2 \pi}{T}t\right)dt \\ &b_{n} = \frac{2}{T}\int_{-\frac{T}{2}}^{\frac{T}{2}}f\left(t\right)\sin\left(n\frac{2 \pi}{T}t\right)dt \\ \end{flalign} $

$a_{n}$, $b_{n}$フーリエ係数で, 物理的には各周波数成分の振幅を表しています. また, $\frac{2 \pi}{T} = 2 \pi f$ は, 角速度 $\omega$ で定義される場合もあります.

厳密には, フーリエ級数が成立する条件は, 周期関数であるだけでは不十分で, ディリクレの条件と合わせて十分条件となります. これを理解するためには, 三角関数の基本的な性質 (高校レベルの数学) や三角関数の直交性などをもとに, リーマン・ルベーグの補助定理パーセバルの等式などを理解する必要があるので, 数学的な厳密性を理解したい場合は, それぞれ最適なドキュメントを参考にしてください.

ここでは, 視覚的に理解するために, 周波数の異なる正弦波の級数で, 矩形波やノコギリ波, 三角波を生成してみます. また, 級数を大きくする (項数を大きくする) ほど, 実際の波形により近似することもわかります.

余弦波と正弦波で表現されるフーリエ級数に, オイラーの公式 ( $e^{j\theta} = \cos\left(\theta\right) + j\sin\left(\theta\right)$ ) を適用すると, 複素フーリエ級数を導出可能です (より一般化したフーリエ級数).

$ \begin{align} &f\left(t\right) = \sum_{n=-\infty}^{\infty}c_{n}e^{jn\frac{2 \pi}{T}t} \\ &c_{n} = \frac{1}{T}\int_{-\frac{T}{2}}^{\frac{T}{2}}f\left(t\right)e^{-jn\frac{2 \pi}{T}t}dt \\ \end{align} $

物理的な観点で理解すると, 複素フーリエ級数は, 余弦波と正弦波の 2 次元の振動現象であるフーリエ級数を, 3 次元の回転へと拡張します. また, 複素フーリエ級数によって, フーリエ級数の問題点, すなわち, 位相をシフトするとフーリエ係数の値が変化する問題 (余弦波と正弦波は位相の違いでしかないので, 原信号が同じでもフーリエ係数が異なることが起きうる) を発展的に解決します.

フーリエ変換

フーリエ級数が周期関数のみ適用可能だったのを, 非周期関数にも適用できるように, さらに拡張したフーリエ級数がフーリエ変換です. フーリエ級数から, フーリエ変換を導出するには, 非周期, つまり, 周期を $\infty$ に拡張して導出します. 具体的には, 複素フーリエ級数の係数 $c_{n}$ の周期 $T$$\infty$ に拡張すると, 角速度 $\omega (= \frac{2 \pi}{T} = 2 \pi f)$ が連続的な角速度になることで導出できます (以下は, フーリエ変換と逆フーリエ変換の定義式です).

フーリエ変換の厳密な導出を理解するには, デルタ関数単位階段関数の理解が必要になります (さらに, フーリエ変換では, 絶対可積分 ($\int_{-\infty}^{\infty}\left|f\left(t\right)\right|dt \lt \infty$ ) が必要条件となります. フーリエ級数からフーリエ変換の数学的な導出の詳細に関しては, それぞれ最適なドキュメントを参考にしてください).

$ \begin{flalign} &F\left(f\right) = \int_{-\infty}^{\infty}f\left(t\right)e^{-j2 \pi ft}dt \\ &f\left(t\right) = \frac{1}{2 \pi}\int_{-\infty}^{\infty}F\left(f\right)e^{j2 \pi tf}df \\ \end{flalign} $
スペクトル

フーリエ変換後の関数は, 物理的にはスペクトルとなります. スペクトルとは, 各周波数成分の振幅や位相を表す波形です (したがって, 周波数領域の波形, 周波数ドメインの波形などと表現することもあります). つまり, 2 次元のグラフで考えると, 横軸の次元が周波数となり, 縦軸が振幅であれば, 振幅スペクトル, 位相であれば, 位相スペクトルとなります. もう少し厳密に説明すると, フーリエ変換後の関数は, 複素数の関数となるので, 絶対値を取得すれば振幅スペクトル, 偏角を取得すれば位相スペクトルとなります (以下に, 複素数 $z = x + jy$ を定義した場合の絶対値 $\left|z\right|$ と偏角 $\theta$ を記載します). フーリエ変換後の関数を逆フーリエ変換すると, 元の横軸を時間とした波形となります.

$\left|z\right| = \sqrt{x^{2} + y^{2}} \quad \cos\theta = \frac{x}{\sqrt{x^{2} + y^{2}}} \quad \sin\theta = \frac{y}{\sqrt{x^{2} + y^{2}}} \quad \tan\theta = \left(\frac{\sin\theta}{\cos\theta}\right) \quad \theta = \tan^{-1}\left(\frac{\sin\theta}{\cos\theta}\right) = \tan^{-1}\left(\frac{y}{x}\right)$

ちなみに, 人間の聴覚は位相スペクトルの違いに鈍感という特性があるので, 一般的に, スペクトルと表現した場合, 振幅スペクトルを意味することがほとんどです.

音響特徴量は振幅スペクトルにあらわれることが多く, したがって, オーディオ信号処理を適用する場合, 周波数領域にて演算を実行することが頻繁にあります. このことが, デジタルオーディオ信号処理において, フーリエ解析 (コンピュータでは, 高速フーリエ変換) が中核となる理由です.

離散フーリエ変換 (DFT)

コンピュータで実現する場合, 無限区間の積分は原理上できないので, ある区間で和分を算出する必要があります. これが, 離散フーリエ変換 (DFT: Discrete Fourier Transform)です (余談ですが, コンピュータでの積分は和分, 微分は差分で実装します).

フーリエ変換から, 離散フーリエ変換を導出するには, 周波数 (周期) と, サンプリング周波数 (サンプリング周期) を数列 (離散値) で対応づけます. $f_{s}$ は, サンプリング周波数 ($T_{s}$ は, サンプリング周期). また, 離散フーリエ変換は, 一定のサイズで変換する必要があるので $N$ は, 離散フーリエ変換のサンプル数です (Web Audio API では, AnalyserNodefftSize プロパティの値に相当します).

$ \begin{flalign} &t = nT_{s} = \frac{n}{f_{s}} \quad (n = 0. 1, 2, \cdots N - 1) \\ &f = k\frac{f_{s}}{N} \quad (k = 0, 1, 2, \cdots N - 1) \\ \end{flalign} $

そして, 積分は和分になるので, これらをフーリエ変換の式に適用して, 変形すると, 離散フーリエ変換と逆離散フーリエ変換の定義式が導出できます. $x\left(n\right)$, および, $X\left(k\right)$ は, サンプリングした信号です. 数学的には数列, プログラミング的には配列のような順序性をもつ数値のコレクションと考えると理解しやすいかもしれません.

$ \begin{flalign} &X\left(k\right) = \sum_{n = 0}^{N - 1}x\left(n\right)e^{-j\frac{2 \pi kn}{N}} \\ &x\left(n\right) = \frac{1}{N}\sum_{k = 0}^{N - 1}X\left(k\right)e^{j\frac{2 \pi nk}{N}} \\ \end{flalign} $

多くのプログラミング言語において, 配列のようなコレクションのインデックスは 0 から開始するので, 離散フーリエ変換の積和演算の範囲も, 0 から開始している点と有界となっている点に着目してください.

また, $e^{-j\frac{2 \pi n}{N}}$ は, 回転因子 (回転子) と呼ばれ, 以下のように定義されます.

$W^{n} = e^{-j\frac{2 \pi n}{N}} = \cos\left(\frac{2 \pi n}{N}\right) - j\sin\left(\frac{2 \pi n}{N}\right)$

回転因子は, 例えば, $N$8 とした場合, 複素平面上の単位円を 8 分割するような回転を表現します.

$N = 8$ の場合の回転因子

このことから, 回転因子は以下のような性質をもっています (また, 離散フーリエ変換のサイズ $\frac{N}{2}$ は, ナイキスト周波数成分のインデックスに相当します).

  • $W^{n + N} = W^{n}$
  • $W^{n + \frac{N}{2}} = -W^{n}$

以下は, 回転因子で定義した離散フーリエ変換と逆離散フーリエ変換です. 高速フーリエ変換では, 回転因子の性質 (周期性による対称性や半周期性の負の対称性) を利用して, 各要素の計算量を減らして演算の高速化を実現しています.

$ \begin{flalign} &X\left(k\right) = \sum_{n = 0}^{N - 1}x\left(n\right)W^{n} \\ &x\left(n\right) = \frac{1}{N}\sum_{k = 0}^{N - 1}X\left(k\right)W^{-k} \\ \end{flalign} $

高速フーリエ変換 (FFT)

高速フーリエ変換 (FFT: Fast Fourier Transform) は, 回転因子の性質を利用して, 離散フーリエ変換では (時間) 計算量を $O\left(N^{2}\right)$ 要するのを, $O\left(N\mathrm{log_{2}}N\right)$ にまで減らして, コンピュータでのフーリエ変換を高速化するアルゴリズムです. ただし, 回転因子の性質を利用する関係上, フーリエ変換のサイズが 2 の冪乗の場合のみ高速化できるという制約がつきます (この制約に関しては, 0 埋め処理などによって, 強制的にフーリエ変換のサイズを 2 の冪乗にするなどして解決できます). Web Audio API においても, AnalyserNodefftSize プロパティがとりうる値は, すべて 2 の冪乗です.

実際に, どのように計算量を削減しているのかを解説していきます.

高速フーリエ変換の導出

離散フーリエ変換の式を行列演算に書き換えます.

$ \begin{flalign} &X\left(k\right) = \sum_{n = 0}^{N - 1}x\left(n\right)W^{n} \\ \end{flalign} $

$N = 4$ として, 行列演算に変換します.

$ \begin{bmatrix} X_{0} \\ X_{1} \\ X_{2} \\ X_{3} \\ \end{bmatrix} = \begin{bmatrix} W^{0} & W^{0} & W^{0} & W^{0} \\ W^{0} & W^{1} & W^{2} & W^{3} \\ W^{0} & W^{2} & W^{4} & W^{6} \\ W^{0} & W^{3} & W^{6} & W^{9} \\ \end{bmatrix} \begin{bmatrix} x_{0} \\ x_{1} \\ x_{2} \\ x_{3} \\ \end{bmatrix} $

ここで, 回転因子の性質を利用すると, $W^{4} = W^{0}, W^{6} = W^{2}, W^{9} = W^{1}$ となるので,

$ \begin{bmatrix} X_{0} \\ X_{1} \\ X_{2} \\ X_{3} \\ \end{bmatrix} = \begin{bmatrix} W^{0} & W^{0} & W^{0} & W^{0} \\ W^{0} & W^{1} & W^{2} & W^{3} \\ W^{0} & W^{2} & W^{0} & W^{2} \\ W^{0} & W^{3} & W^{2} & W^{1} \\ \end{bmatrix} \begin{bmatrix} x_{0} \\ x_{1} \\ x_{2} \\ x_{3} \\ \end{bmatrix} $

ここまでで, 離散フーリエ変換の演算を行列演算に変換することができました.

行列演算に変換できたら, 行を偶奇で分割します. 偶数行を行列の上部に入れ替えて, 奇数教を行列の下部に入れ替えます. 変換行列の行を入れ替えるので, 出力となる $X_{k}$ も行が入れ替わることに注意してください.

$ \begin{bmatrix} X_{0} \\ X_{2} \\ X_{1} \\ X_{3} \\ \end{bmatrix} = \begin{bmatrix} W^{0} & W^{0} & W^{0} & W^{0} \\ W^{0} & W^{2} & W^{0} & W^{2} \\ W^{0} & W^{1} & W^{2} & W^{3} \\ W^{0} & W^{3} & W^{2} & W^{1} \\ \end{bmatrix} \begin{bmatrix} x_{0} \\ x_{1} \\ x_{2} \\ x_{3} \\ \end{bmatrix} $

ここで, 回転因子の回転方向を考慮すると, 左上 2 行 2 列の行列と, 右上 2 行 2 列の行列は対称になっている, すなわち, 同じ回転方向の回転因子の行列になっています. 同様に, 左下 2 行 2 列の行列と, 右下 2 行 2 列の行列は負の対称になっている, すなわち, 回転方向が互いに逆方向の回転因子の行列になっています.

これを考慮すると, 行列演算はさらに以下のように変形できます.

$ \begin{bmatrix} X_{0} \\ X_{2} \\ \end{bmatrix} = \begin{bmatrix} W^{0} & W^{0} \\ W^{0} & W^{2} \\ \end{bmatrix} \begin{bmatrix} (x_{0} + x_{2}) \\ (x_{1} + x_{3}) \\ \end{bmatrix} $
$ \begin{bmatrix} X_{1} \\ X_{3} \\ \end{bmatrix} = \begin{bmatrix} W^{0} & W^{1} \\ W^{0} & W^{3} \\ \end{bmatrix} \begin{bmatrix} (x_{0} - x_{2}) \\ (x_{1} - x_{3}) \\ \end{bmatrix} $

この変形によって, 乗算と加算の回数がおよそ半分まで減らすことができました. ここでさらに回転因子の性質を利用すると, 以下のように変形できます.

$ \begin{bmatrix} X_{0} \\ X_{2} \\ \end{bmatrix} = \begin{bmatrix} W^{0} & W^{0} \\ W^{0} & -W^{0} \\ \end{bmatrix} \begin{bmatrix} (x_{0} + x_{2}) \\ (x_{1} + x_{3}) \\ \end{bmatrix} $
$ \begin{bmatrix} X_{1} \\ X_{3} \\ \end{bmatrix} = \begin{bmatrix} W^{0} & W^{1} \\ W^{0} & -W^{1} \\ \end{bmatrix} \begin{bmatrix} (x_{0} - x_{2}) \\ (x_{1} - x_{3}) \\ \end{bmatrix} $

ところで, 行の偶奇を入れ替えたので, 出力となる $X_{k}$ の順序が, 入力の順序と一致しなくなります (つまり, ある時間領域の値のスペクトルが一致しなくなります). 実は, 一見するとランダムに並びますが, 規則性があり, 各インデックスを 2 進数で表現した場合の, ビットを上下反転させた関係になっています. この関係を利用して, インデックスの並びを整列するアルゴリズムをビットリバースと呼びます.

ビットリバースの対応表 2 ビット ($N = 4$)
Index $x_{n}$ $X_{k}$
0 00 00
1 01 10
2 10 01
3 11 11

同じように, $N = 8$ として, 高速フーリエ変換になるように行列演算します.

$ \begin{bmatrix} X_{0} \\ X_{1} \\ X_{2} \\ X_{3} \\ X_{4} \\ X_{5} \\ X_{6} \\ X_{7} \\ \end{bmatrix} = \begin{bmatrix} W^{0} & W^{0} & W^{0} & W^{0} & W^{0} & W^{0} & W^{0} & W^{0} \\ W^{0} & W^{1} & W^{2} & W^{3} & W^{4} & W^{5} & W^{6} & W^{7} \\ W^{0} & W^{2} & W^{4} & W^{6} & W^{8} & W^{10} & W^{12} & W^{14} \\ W^{0} & W^{3} & W^{6} & W^{9} & W^{12} & W^{15} & W^{18} & W^{21} \\ W^{0} & W^{4} & W^{8} & W^{12} & W^{16} & W^{20} & W^{24} & W^{29} \\ W^{0} & W^{5} & W^{10} & W^{15} & W^{20} & W^{25} & W^{30} & W^{35} \\ W^{0} & W^{6} & W^{12} & W^{18} & W^{24} & W^{30} & W^{36} & W^{42} \\ W^{0} & W^{7} & W^{14} & W^{21} & W^{28} & W^{35} & W^{42} & W^{49} \\ \end{bmatrix} \begin{bmatrix} x_{0} \\ x_{1} \\ x_{2} \\ x_{3} \\ x_{4} \\ x_{5} \\ x_{6} \\ x_{7} \\ \end{bmatrix} $

回転因子の性質を利用すると,

$ \begin{bmatrix} X_{0} \\ X_{1} \\ X_{2} \\ X_{3} \\ X_{4} \\ X_{5} \\ X_{6} \\ X_{7} \\ \end{bmatrix} = \begin{bmatrix} W^{0} & W^{0} & W^{0} & W^{0} & W^{0} & W^{0} & W^{0} & W^{0} \\ W^{0} & W^{1} & W^{2} & W^{3} & W^{4} & W^{5} & W^{6} & W^{7} \\ W^{0} & W^{2} & W^{4} & W^{6} & W^{0} & W^{2} & W^{4} & W^{6} \\ W^{0} & W^{3} & W^{6} & W^{1} & W^{4} & W^{7} & W^{2} & W^{5} \\ W^{0} & W^{4} & W^{0} & W^{4} & W^{8} & W^{5} & W^{0} & W^{5} \\ W^{0} & W^{5} & W^{2} & W^{7} & W^{4} & W^{1} & W^{6} & W^{3} \\ W^{0} & W^{6} & W^{4} & W^{2} & W^{0} & W^{6} & W^{4} & W^{2} \\ W^{0} & W^{7} & W^{6} & W^{5} & W^{4} & W^{3} & W^{2} & W^{1} \\ \end{bmatrix} \begin{bmatrix} x_{0} \\ x_{1} \\ x_{2} \\ x_{3} \\ x_{4} \\ x_{5} \\ x_{6} \\ x_{7} \\ \end{bmatrix} $

行列演算に変換できたら, 行を偶奇で分割します. 偶数行を行列の上部に入れ替えて, 奇数教を行列の下部に入れ替えます. 変換行列の行を入れ替えるので, 出力となる $X_{k}$ も行が入れ替わることに注意してください.

$ \begin{bmatrix} X_{0} \\ X_{2} \\ X_{4} \\ X_{6} \\ X_{1} \\ X_{3} \\ X_{5} \\ X_{7} \\ \end{bmatrix} = \begin{bmatrix} W^{0} & W^{0} & W^{0} & W^{0} & W^{0} & W^{0} & W^{0} & W^{0} \\ W^{0} & W^{2} & W^{4} & W^{6} & W^{0} & W^{2} & W^{4} & W^{6} \\ W^{0} & W^{4} & W^{0} & W^{4} & W^{0} & W^{4} & W^{0} & W^{4} \\ W^{0} & W^{6} & W^{4} & W^{2} & W^{0} & W^{6} & W^{4} & W^{2} \\ W^{0} & W^{1} & W^{2} & W^{3} & W^{4} & W^{5} & W^{6} & W^{7} \\ W^{0} & W^{3} & W^{6} & W^{1} & W^{4} & W^{7} & W^{2} & W^{5} \\ W^{0} & W^{5} & W^{2} & W^{7} & W^{4} & W^{1} & W^{6} & W^{3} \\ W^{0} & W^{7} & W^{6} & W^{5} & W^{4} & W^{3} & W^{2} & W^{1} \\ \end{bmatrix} \begin{bmatrix} x_{0} \\ x_{1} \\ x_{2} \\ x_{3} \\ x_{4} \\ x_{5} \\ x_{6} \\ x_{7} \\ \end{bmatrix} $

ここで, 回転因子の回転方向を考慮すると, 左上 4 行 4 列の行列と, 右上 4 行 4 列の行列は対称になっている, すなわち, 同じ回転方向の回転因子の行列になっています. 同様に, 左下 4 行 4 列の行列と, 右下 4 行 4 列の行列は負の対称になっている, すなわち, 回転方向が互いに逆方向の回転因子の行列になっています.

これを考慮すると, 行列演算はさらに以下のように変形できます.

$ \begin{bmatrix} X_{0} \\ X_{2} \\ X_{4} \\ X_{6} \\ \end{bmatrix} = \begin{bmatrix} W^{0} & W^{0} & W^{0} & W^{0} \\ W^{0} & W^{2} & W^{4} & W^{6} \\ W^{0} & W^{4} & W^{0} & W^{4} \\ W^{0} & W^{6} & W^{4} & W^{2} \\ \end{bmatrix} \begin{bmatrix} x_{0} + x_{4} \\ x_{1} + x_{5} \\ x_{2} + x_{6} \\ x_{3} + x_{7} \\ \end{bmatrix} $
$ \begin{bmatrix} X_{1} \\ X_{3} \\ X_{5} \\ X_{7} \\ \end{bmatrix} = \begin{bmatrix} W^{0} & W^{1} & W^{2} & W^{3} \\ W^{0} & W^{3} & W^{6} & W^{1} \\ W^{0} & W^{5} & W^{2} & W^{7} \\ W^{0} & W^{7} & W^{6} & W^{5} \\ \end{bmatrix} \begin{bmatrix} x_{0} - x_{4} \\ x_{1} - x_{5} \\ x_{2} - x_{6} \\ x_{3} - x_{7} \\ \end{bmatrix} $

ここまでの処理によって, $N = 8$ の高速フーリエ変換を $N = 4$ に帰着することができたので, 再帰的に $N = 4$ の場合も, 行の偶奇を入れ替えて回転因子の性質を利用して, $N = 2$ の場合の高速フーリエ変換に帰着させます.

$ \begin{bmatrix} X_{0} \\ X_{4} \\ X_{2} \\ X_{6} \\ \end{bmatrix} = \begin{bmatrix} W^{0} & W^{0} & W^{0} & W^{0} \\ W^{0} & W^{4} & W^{0} & W^{4} \\ W^{0} & W^{2} & W^{4} & W^{6} \\ W^{0} & W^{6} & W^{4} & W^{2} \\ \end{bmatrix} \begin{bmatrix} x_{0} + x_{4} \\ x_{2} + x_{6} \\ x_{1} + x_{5} \\ x_{3} + x_{7} \\ \end{bmatrix} $

ここで, 回転因子の回転方向を考慮すると, 左上 2 行 2 列の行列と, 右上 2 行 2 列の行列は対称になっている, すなわち, 同じ回転方向の回転因子の行列になっています. 同様に, 左下 2 行 2 列の行列と, 右下 2 行 2 列の行列は負の対称になっている, すなわち, 回転方向が互いに逆方向の回転因子の行列になっています.

$ \begin{bmatrix} X_{1} \\ X_{5} \\ X_{3} \\ X_{7} \\ \end{bmatrix} = \begin{bmatrix} W^{0} & W^{1} & W^{2} & W^{3} \\ W^{0} & W^{5} & W^{2} & W^{7} \\ W^{0} & W^{3} & W^{6} & W^{1} \\ W^{0} & W^{7} & W^{6} & W^{5} \\ \end{bmatrix} \begin{bmatrix} x_{0} - x_{4} \\ x_{2} - x_{6} \\ x_{1} - x_{5} \\ x_{3} - x_{7} \\ \end{bmatrix} $

こちらも, 回転因子の回転方向を考慮すると, 左上 2 行 2 列の行列と, 右上 2 行 2 列の行列は時計回りに $\frac{2}{8}$ 回転, また, 左下 2 行 2 列の行列と, 右下 2 行 2 列の行列は反時計回りに $\frac{2}{8}$ 回転しているという対称性があります.

これら考慮すると, 行列演算はさらに以下のように変形できます.

$ \begin{bmatrix} X_{0} \\ X_{4} \\ \end{bmatrix} = \begin{bmatrix} W^{0} & W^{0} \\ W^{0} & W^{4} \\ \end{bmatrix} \begin{bmatrix} (x_{0} + x_{4}) + (x_{2} + x_{6}) \\ (x_{1} + x_{5}) + (x_{3} + x_{7}) \\ \end{bmatrix} $
$ \begin{bmatrix} X_{2} \\ X_{6} \\ \end{bmatrix} = \begin{bmatrix} W^{0} & W^{2} \\ W^{0} & W^{6} \\ \end{bmatrix} \begin{bmatrix} (x_{0} + x_{4}) - (x_{2} + x_{6}) \\ (x_{1} + x_{5}) - (x_{3} + x_{7}) \\ \end{bmatrix} $
$ \begin{bmatrix} X_{1} \\ X_{5} \\ \end{bmatrix} = \begin{bmatrix} W^{0} & W^{1} \\ W^{0} & W^{5} \\ \end{bmatrix} \begin{bmatrix} (x_{0} - x_{4}) + W^{2}(x_{2} - x_{6}) \\ (x_{1} - x_{5}) + W^{2}(x_{3} - x_{7}) \\ \end{bmatrix} $
$ \begin{bmatrix} X_{3} \\ X_{7} \\ \end{bmatrix} = \begin{bmatrix} W^{0} & W^{3} \\ W^{0} & W^{7} \\ \end{bmatrix} \begin{bmatrix} (x_{0} - x_{4}) - W^{2}(x_{2} - x_{6}) \\ (x_{1} - x_{5}) - W^{2}(x_{3} - x_{7}) \\ \end{bmatrix} $

ここでさらに回転因子の性質を利用すると, 以下のように変形できます.

$ \begin{bmatrix} X_{0} \\ X_{4} \\ \end{bmatrix} = \begin{bmatrix} W^{0} & W^{0} \\ W^{0} & -W^{0} \\ \end{bmatrix} \begin{bmatrix} (x_{0} + x_{4}) + (x_{2} + x_{6}) \\ (x_{1} + x_{5}) + (x_{3} + x_{7}) \\ \end{bmatrix} $
$ \begin{bmatrix} X_{2} \\ X_{6} \\ \end{bmatrix} = \begin{bmatrix} W^{0} & W^{2} \\ W^{0} & -W^{2} \\ \end{bmatrix} \begin{bmatrix} (x_{0} + x_{4}) - (x_{2} + x_{6}) \\ (x_{1} + x_{5}) - (x_{3} + x_{7}) \\ \end{bmatrix} $
$ \begin{bmatrix} X_{1} \\ X_{5} \\ \end{bmatrix} = \begin{bmatrix} W^{0} & W^{1} \\ W^{0} & -W^{1} \\ \end{bmatrix} \begin{bmatrix} (x_{0} - x_{4}) + W^{2}(x_{2} - x_{6}) \\ (x_{1} - x_{5}) + W^{2}(x_{3} - x_{7}) \\ \end{bmatrix} $
$ \begin{bmatrix} X_{3} \\ X_{7} \\ \end{bmatrix} = \begin{bmatrix} W^{0} & W^{3} \\ W^{0} & -W^{3} \\ \end{bmatrix} \begin{bmatrix} (x_{0} - x_{4}) - W^{2}(x_{2} - x_{6}) \\ (x_{1} - x_{5}) - W^{2}(x_{3} - x_{7}) \\ \end{bmatrix} $

$N = 2$ の場合の高速フーリエ変換に帰着できたので, 最後にビットリバースを適用してインデックスを並び替えます.

ビットリバースの対応表 3 ビット ($N = 8$)
Index $x_{n}$ $X_{k}$
0 000 000
1 001 100
2 010 010
3 011 110
4 100 001
5 101 101
6 110 011
7 111 111

高速フーリエ変換のサイズを一般化して, $N = 2^{m}$ の場合も, $2^{m}, 2^{m - 1}, 2^{m - 2}\cdots 32, 16, 8, 4$ と再帰的に高速フーリエ変換を導出することが可能です. また, このセクションで解説した高速フーリエ変換は, 時間間引き型高速フーリエ変換のアルゴリズムとなります. 周波数間引き型高速フーリエ変換のアルゴリズムは, 細部は異なりますが本質的には同じです.

同様に, 逆離散フーリエ変換の (時間) 計算量も $O\left(N^{2}\right)$ から $O\left(N\mathrm{log_{2}}N\right)$ に減らすことができます (逆高速フーリエ変換 (IFFT: Inverse Fast Fourier Transform)).

高速フーリエ変換の実装

離散フーリエ変換の定義式から, 高速フーリエ変換が導出できることはわかりましたが, 回転因子の性質や行列の変形をコードに記述するのは少し難しいと思います. そこで, 一般的には, 高速フーリエ変換と等価なバタフライ演算のフロー図を考えることで, より実装に近い形式で考えることができます.

バタフライ演算のフロー図の記号

$N = 4$ の場合の, 高速フーリエ変換の導出をバタフライ演算のフロー図を記載します.

FFT のサイズ $N = 4$ の場合のバタフライ演算のフロー図

同様に, $N = 8$ の場合の, 高速フーリエ変換の導出をバタフライ演算のフロー図を記載します.

FFT のサイズ $N = 8$ の場合のバタフライ演算のフロー図
function pow2(n) {
  return 2 ** n;
}

/**
 * FFT
 *
 * @param {Float32Array} reals This argument is instance of `Float32Array` for real number.
 * @param {Float32Array} imags This argument is instance of `Float32Array` for imaginary number.
 * @param {number} size This argument is FFT size (power of two).
 */
function FFT(reals, imags, size) {
  const indexes = new Uint16Array(size);

  const numberOfStages = Math.log2(size);

  for (let stage = 1; stage <= numberOfStages; stage++) {
    for (let i = 0; i < pow2(stage - 1); i++) {
      const rest = numberOfStages - stage;

      for (let j = 0; j < pow2(rest); j++) {
        const n = i * pow2(rest + 1) + j;
        const m = pow2(rest) + n;
        const w = 2.0 * Math.PI * j * pow2(stage - 1);

        const areal = reals[n];
        const aimag = imags[n];
        const breal = reals[m];
        const bimag = imags[m];
        const wreal = Math.cos(w / size);
        const wimag = -1 * Math.sin(w / size);  // Clockwise

        if (stage < numberOfStages) {
          reals[n] = areal + breal;
          imags[n] = aimag + bimag;
          reals[m] = (wreal * (areal - breal)) - (wimag * (aimag - bimag));
          imags[m] = (wreal * (aimag - bimag)) + (wimag * (areal - breal));
        } else {
          reals[n] = areal + breal;
          imags[n] = aimag + bimag;
          reals[m] = areal - breal;
          imags[m] = aimag - bimag;
        }
      }
    }
  }

  for (let stage = 1; stage <= numberOfStages; stage++) {
    const rest = numberOfStages - stage;

    for (let i = 0; i < pow2(stage - 1); i++) {
      indexes[pow2(stage - 1) + i] = indexes[i] + pow2(rest);
    }
  }

  for (let k = 0; k < size; k++) {
    if (indexes[k] <= k) {
      continue;
    }

    const real = reals[indexes[k]];
    const imag = imags[indexes[k]];

    reals[indexes[k]] = reals[k];
    imags[indexes[k]] = imags[k];

    reals[k] = real;
    imags[k] = imag;
  }
}

/**
 * IFFT
 *
 * @param {Float32Array} reals This argument is instance of `Float32Array` for real number.
 * @param {Float32Array} imags This argument is instance of `Float32Array` for imaginary number.
 * @param {number} size This argument is IFFT size (power of two).
 */
function IFFT(reals, imags, size) {
  const indexes = new Uint16Array(size);

  const numberOfStages = Math.log2(size);

  for (let stage = 1; stage <= numberOfStages; stage++) {
    for (let i = 0; i < pow2(stage - 1); i++) {
      const rest = numberOfStages - stage;

      for (let j = 0; j < pow2(rest); j++) {
        const n = i * pow2(rest + 1) + j;
        const m = pow2(rest) + n;
        const w = 2.0 * Math.PI * j * pow2(stage - 1);

        const areal = reals[n];
        const aimag = imags[n];
        const breal = reals[m];
        const bimag = imags[m];
        const wreal = Math.cos(w / size);
        const wimag = Math.sin(w / size);  // Counterclockwise

        if (stage < numberOfStages) {
          reals[n] = areal + breal;
          imags[n] = aimag + bimag;
          reals[m] = (wreal * (areal - breal)) - (wimag * (aimag - bimag));
          imags[m] = (wreal * (aimag - bimag)) + (wimag * (areal - breal));
        } else {
          reals[n] = areal + breal;
          imags[n] = aimag + bimag;
          reals[m] = areal - breal;
          imags[m] = aimag - bimag;
        }
      }
    }
  }

  for (let stage = 1; stage <= numberOfStages; stage++) {
    const rest = numberOfStages - stage;

    for (let i = 0; i < pow2(stage - 1); i++) {
      indexes[pow2(stage - 1) + i] = indexes[i] + pow2(rest);
    }
  }

  for (let k = 0; k < size; k++) {
    if (indexes[k] <= k) {
      continue;
    }

    const real = reals[indexes[k]];
    const imag = imags[indexes[k]];

    reals[indexes[k]] = reals[k];
    imags[indexes[k]] = imags[k];

    reals[k] = real;
    imags[k] = imag;
  }

  for (let k = 0; k < size; k++) {
    reals[k] /= size;
    imags[k] /= size;
  }
}

FFT と IFFT の実装上の違いは, 回転因子の回転方向が互いに逆なのと, IFFT の場合 $N$ 倍された値になるので, 最後に正規化の処理がある点です.

フーリエ解析 (フーリエ級数・フーリエ変換) のイメージ

基本周波数と倍音

音響特徴量は振幅スペクトルにあらわれることが多いことから, 音の分析, イコール, スペクトル分析と表現しても過言ではないぐらいです. したがって, このセクションでは スペクトルの基本構造に関して解説したいと思います.

周波数成分は, 基本周波数 (略して $f_{0}$ と呼ぶことも多いです) と倍音に分類することができます. 最も低い周波数成分を基本周波数と呼び, 基本周波数の整数倍となる周波数成分を倍音と呼びます.

OscillatorNodefrequency / detune プロパティは周波数を設定するプロパティ (AudioParam) と解説しましたが, 厳密には, 基本周波数を設定するプロパティです.

基本波形を例にとって, 基本周波数と倍音をより具体的に解説します.

基本波形の最小単位は正弦波です. 正弦波は倍音をもちません. 基本周波数の成分しかもたないので純音とも呼ばれます. そして, 基本周波数と倍音を合成した波形が, 矩形波やノコギリ波, 三角波です.

基本波形における, 基本周波数と倍音
Wave Type Spectrum
正弦波 基本周波数成分のみをもつ
矩形波 基本周波数と奇数次の倍音成分をもつ
ノコギリ波 基本周波数と奇数次・偶数次の倍音成分をもつ
三角波 基本周波数と奇数次の倍音成分をもつ (高音域の倍音成分が小さい)
Time Domain
Frequency Domain (Spectrum)

基本波形と同じように, 楽器音や音声も基本周波数と倍音の周波数成分によって構成されています. 厳密には, 自然の音は必ずしもこのような整数倍になっていません. しかし, 実はこのことが人工的な音と感じさせない要因ともなっています. また, エフェクターの 1 つである, オーバードライブやディストーションは, 本来発生しない倍音を発生させることによって歪みを与えます (オーディオ信号処理における非線形処理によって発生させることができるエフェクターです).

また, ホワイトノイズのような雑音はすべての周波数成分を含んでいるので, そのスペクトルは一様になります.

エフェクター

このサイトのオーナーはエレキギターを弾くので, オーナー個人的には, オーディオプログラミングの最大の楽しみはエフェクターを実装することだと思っています.

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 への接続

結論から記載しますと, AudioNodeconnect メソッドはオーバーライドされており, 第 1 引数には AudioNode インスタンスだけでなく, AudioParam インスタンスを指定することも可能です. すなわち, OscillatorNode の接続先を AudioParam にすることで, 対象のパラメータを時間経過とともに周期的に変化させることが可能になります.

また, LFO のソースとなる OscillatorNodeGainNode を接続することで, パラメータの変化量を調整することが可能になります. 一般的なエフェクターのパラメータの, DepthGainNodegain プロパティの値に, RateOscillatorNodefrequency プロパティの値と detune プロパティの値に相当しますプロパティの値に相当します.

LFO の実装例 (ビブラート)

LFO と AudioParam への接続, Depth / Rate の制御を具体的に理解するために, 簡易的なビブラートを実装を記載します. (OscillatorNodefrequency プロパティのデフォルト値である) 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 のパラメータの値の範囲 (AudioParamminValue / maxValue プロパティにそれぞれ, 最小値と最大値が設定されています) を意図せずに超えてしまうバグも防ぐことができます.

440 Hz 0.1 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 としてはこれで十分機能しますが, 波形 (OscillatorNodetype プロパティ (OscillatorOptions)) をノコギリ波や三角波にしても LFO として機能します. もし必要であれば, LFO の波形も選択できるようにすると, エフェクトにバリエーションが出せるかもしれません.

ディレイ・リバーブ

ディレイ・リバーブがどんなエフェクターかを簡単に表現すると, ディレイはやまびこ現象を再現するエフェクター, リバーブはコンサートホールなどの (主に, 室内の) 音の響きを再現するエフェクターとなるでしょう.

表現上はまったく別のエフェクターのように思えますが, その原理は同じです (また, ディレイのパラメータの設定しだいでは, 簡易的なリバーブを再現することも可能です). ディレイ・リバーブともに, FIR フィルタ, つまり, 加算・乗算・遅延というデジタル回路における基本処理で実装可能な点です.

ディレイ

ディレイを実装するために必要な処理は, DelayNodeの接続とフィードバックです.

DelayNode

遅延処理を (抽象化して) 実装するために定義されているのが, DelayNode です. コンストラクタの第 2 引数 (DelayOptionsmaxDelayTime プロパティ) には (ファクトリメソッドの場合, 第 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 and delayTime property

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) ... といった繰り返しで, 遅延音が少しずつ減衰しながら生成されます. つまり, フィードバックの GainNodegain プロパティを適切に設定すれば, エフェクターとしてのディレイにバリエーションが出せるというこでもあります.

ただし, フィードバックの値は 1 未満にする必要があります. これは, 直感的な説明をすれば, 1 以上にすると減衰しない状態 (無限ループのような状態) になってしまうからです. 数学的・工学的な厳密性で説明すると, 絶対可積分を満たさなくなり, (のちのセクションで解説する) FIR フィルタが安定しない回路となるからです.

フィードバックのイメージ
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

ConvolverNode には, buffer プロパティが定義されており, この buffer プロパティに, インパルス応答 (RIR) のオーディオデータの AudioBuffer インスタンスを設定します (コンストラクタ生成であれば, ConvolverOptionsbuffer プロパティで設定することも可能です).

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 に接続して, ConvolverNodeAudioDestinationNode に接続します.

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} $

インパルス音を室内で発生させると, 音が天井や壁などに反射して, 音の響き, すなわち, 残響が発生します. カラオケが好きなかたであれば, エコーのような効果と考えてもよいでしょう.

直接音以外にも反射音 (初期反射音) や何度も反射して聴こえる残響音 (後期残響音) が室内では発生します
インパルス応答のイメージ

これが, インパルス応答です. つまり, インパルス音を音響空間に対する入力としたときの出力 (応答) ということです. そして, その出力が音響空間における, 残響特性を表すことになるので, インパルス応答を利用することによって, コンサートホールなどの音響空間の音の響きをシミュレートするエフェクターであるリバーブが実現できるというわけです. ちなみに, 室内でのインパルス応答をシミュレートする事が多いので, RIR (Room Impulse Response) と表現されることもあります.

実際の RIR の波形

また, この残響が, ディレイにおけるフィードバックと類似した音響効果を発生させることになります (フィードバックのアニメーションとインパルス応答のアニメーションが類似していることに気付いたかもしれません).

イメージでインパルス応答は理解できるかと思いますが, それをコンピュータで実現する場合, 数式でモデリングできる必要があります. ConvolverNode の命名 (Convolve: 畳み込む) が表すように, その演算処理がコンボリューション積分 (畳み込み積分, 合成積) です. 言い換えると, ConvolverNode は, コンボリューション積分を抽象化した AudioNode です.

コンボリューション積分

コンボリューション積分とは, 以下の数式で定義されるように, 信号の遅延と乗算, それらの無限区間の積分によって構成されています. $x\left(t\right)$ は入力信号, $y\left(t\right)$ は出力信号, $h\left(t_{r}\right)$ は, インパルス応答の信号 (ConvolverNodebuffer プロパティに設定する, 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$ の場合の, コンボリューション積分のイメージ

入力信号やインパルス応答が, 現実世界のようにより複雑になると, 計算自体も複雑になりますが, コンボリューション積分がどうのような演算か, そして, ConvolverNode が抽象化している演算のイメージを理解するのに役立てばと思います.

巡回畳み込み

コンボリューション積分は, 周波数領域においては乗算となります. したがって, 時間領域の入力信号 $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 パラメータ) を生成して, DelayNodedelayTime プロパティ に接続します.

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 を DelayNodedelayTime プロパティ (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);
});
0 msec
0
0
0

フランジャー

フランジャーは, コーラスの実装にエフェクト音のフィードバックの接続を追加するだけです (ディレイの実装に 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);
});
0 msec
0
0
0
0

ディレイタイムの周期的な変化とFM 変調

FM 変調 (Frequency Modulation) とは, 時間の経過とともに信号の周波数を変化させることです.

Time Domain
Frequency Domain (Spectrum)
FM 変調のイメージ (スペクトルのピークが 880 Hz ± 440 Hz の間で変調します)

コーラス・フランジャーは, 結果的に 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: ラジアン) としたときに, 以下のような変化になります.

位相変化のイメージ

正弦波の場合, $\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 を通過させた音, つまり, 位相を変化させただけの音というのは, 特に変化を知覚することはできません. スペクトルのセクションでも記載しましたが, 人間の聴覚というのは位相の違いには鈍感だからです. では, なぜ, フェイザーはエフェクトとして知覚することができるのでしょうか ?

フェイザーは, 位相の変化を知覚しているのではなく, 位相を変化させたエフェクト音と原音を合成することで音波を干渉させて, 結果として発生する, 振幅の増減やうねりを知覚しているからです.

位相変化と干渉のイメージ

上記の正弦波で説明すると, 開始時刻において, 原音とエフェクト音 (ともに, 半透明の青色の波) は, 同じ位相にあります. ここで, $\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 段フェイザー)

さらに, アグレッシブなエフェクトを発生させたい場合は, フィードバック接続を追加することも考えられます (ただし, フェイザーにおいて, フィードバックは一般的に必須というわけではありません). また, レゾナンス (BiquadFilterNodeQ プロパティ (AudioParam)) を変更可能にすると, フェイザーによりバリエーションを付加することができます (BiquadFilterNodeQ プロパティは, 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);
});
880 Hz
0
0
1
0

トレモロ・リングモジュレーター

トレモロは, 音の大きさに揺らぎを与えるエフェクターです. ギターでは素早くピッキングを繰り返して, 音が震えているように奏でるトレモロ奏法があります. トレモロは, トレモロ奏法をオーディオ信号処理によって実現するエフェクターとも言えます (ギターでは, もう 1 つトレモロという名称がついているものがあります. ストラトキャスター (タイプ) のギターに搭載されているトレモロアームです. しかし, トレモロアームは, ビブラートの効果を与えるものなので, 同じトレモロという名称ですが, 効果としては別なものです).

リングモジュレーターは, 金属的な音色に変化させるエフェクトです. 例えば, ピアノにリングモジュレーターをかけると, 鐘のような音色に変化します.

エフェクターとして, トレモロとリングモジュレーターはかなり感じが違いますが, 原理は共通しています. その原理とは, AM 変調を利用したエフェクターであることです.

トレモロ

トレモロは振幅 (音の大きさ) を周期的に変化させることによって, 実装することができます. つまり, LFO を, GainNodegain プロパティ (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 + depth \cdot \sin\left(\frac{2\pi \cdot 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);
});
0
0

リンクモジュレーター

リングモジュレーターは, AudioNode の接続としてはトレモロと同じです. LFO の Rate (変調の周波数)を, およそ 100 Hz 以上にしていくと, 原音の周波数成分とは異なる周波数成分が発生するようになります. この周波数成分が金属的な音を生み出す要因となって, 原理は同じながらも, トレモロとは異なるエフェクトを得ることができます.

リングモジュレーターのノード接続図

リングモジュレーターは, 原音の振幅を正弦波で変調するように定義されているので, トレモロと異なり, 基準となる gain プロパティの値は 0 を設定しています.

$y\left(n\right) = \left(depth \cdot \sin\left(\frac{2\pi \cdot 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);
});
1
1000

AM 変調

AM 変調 (Amplitude Modulation) とは, 時間の経過とともに信号の振幅を変化させることです.

Time Domain
Frequency Domain (Spectrum)
AM 変調のイメージ 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) という単位が使われるので (BiquadFilterNodegain プロパティやフィルタの特性グラフなど), フィルタに限ったことではないですが, ここで解説をします.

デシベルとは, 端的には, 音圧レベルを表す単位です. 音圧とは, 音の実体である媒体の振動によって伝わる圧力 (力) のことです (ちなみに, 音圧を体感する 1 つの方法として, スタジオには大抵設置されている Marshall のスタックアンプのキャビネット (スピーカー) の前に立って, 大音量でギターを鳴らすと後ろから押されているような空気の圧力がかかってくるのを体感できます).

ここで, 基準の音圧 ($P_{0} = 2 \cdot 10^{-5} \mathrm{[Pa]}$) を基準 (ちなみに, この基準の音圧は, 1 KHz における可聴な最小の音圧とされています) に, 対象の音の音圧 $P$ の比率の対数をとった値 (以下の定義式. 上は時間領域, 下は周波数領域での定義式) が音圧レベルとなります.

$ \begin{flalign} &20\log_{10}\left(\frac{P}{P_{0}}\right) \quad \left(P_{0} = 2 \cdot 10^{-5} \mathrm{[Pa]}\right) \\ & \\ &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$). これを理解しておくと, BiquadFilterNodegain プロパティが (特定のフィルタの種類において) ある周波数成分を増幅させたり, 減衰させたりすることも理解できると思います.

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);
BiquadFilterNode
frequency / detune プロパティ

フィルタの種類に関わらず, frequency / detune プロパティはすべてのフィルタにおいて有効になります (ただし, フィルタの特性への影響はフィルタの種類ごとに異なります). OscillatorNode などと同じように, frequency プロパティ と detune プロパティを合わせて算出される周波数 ($f_{\mathrm{computed}}\left(t\right)$) は以下のように定義されています.

$f_{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 プロパティは無効で, フィルタ特性に影響を与えることはありません.

350 Hz 0 cent 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 プロパティは無効で, フィルタ特性に影響を与えることはありません.

350 Hz 0 cent 1 dB
High-Pass Filter のフィルタ特性
Band-Pass Filter

Band-Pass Filter (帯域通過フィルタ) とは, 中心周波数 ($f_{\mathrm{computed}}$) 付近の周波数成分を通過させ, それ以外の周波数成分を遮断するフィルタです. 実装的には, Low-Pass Filter と High-Pass Filter を組み合わせることでも実装は可能です.

Band-Pass Filter における, Q プロパティは, 中心周波数を基準にした帯域幅に影響を与えます. Q プロパティの値を大きくするほど, 中心周波数付近の帯域幅が狭くなります (急峻になります). 0 以下の値を設定すると, 中心周波数として機能しなくなるので, 正の値を指定するようにします.

Band-Pass Filter においても, gain プロパティは無効で, フィルタ特性に影響を与えることはありません.

350 Hz 0 cent 1
Band-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 つです).

350 Hz 0 cent 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 つです).

350 Hz 0 cent 0 dB
High-Shelving Filter のフィルタ特性
Peaking Filter

Peaking Filter とは, 中心周波数 ($f_{\mathrm{computed}}$) 付近の周波数成分を増幅, または, 減衰させ, それ以外の周波数成分をそのまま通過させるフィルタです. Peaking Filter における, gain プロパティが, 増幅, または, 減衰の値を決定します. 単位は, デシベル (dB) です.

Peaking Filter における, Q プロパティは, Band-Pass Filter と同様に, 中心周波数を基準にした帯域幅に影響を与えます. Q プロパティの値を大きくするほど, 中心周波数付近の帯域幅が狭くなります (急峻になります). 0 以下の値を設定すると, 中心周波数として機能しなくなるので, 正の値を指定するようにします.

350 Hz 0 cent 1 0 dB
Peaking Filter のフィルタ特性
Notch Filter

Notch Filter (帯域除去フィルタ) とは, 中心周波数 ($f_{\mathrm{computed}}$) 付近の周波数成分を遮断して, それ以外の周波数成分を通過させるフィルタです. (厳密には, その定義が異なる点はありますが) Band-Elimination Filter (帯域阻止フィルタ) と呼ばれることもあります.

Notch Filter における, Q プロパティは, Band-Pass Filter と同様に, 中心周波数を基準にした帯域幅に影響を与えます. Q プロパティの値を大きくするほど, 中心周波数付近の帯域幅が狭くなります (急峻になります). 0 以下の値を設定すると, 中心周波数として機能しなくなるので, 正の値を指定するようにします.

対となる Band-Pass Filter と比較すると, 同じ Q プロパティの値でも, 中心周波数付近の帯域幅が狭くなっています.

Notch Filter においても, gain プロパティは無効で, フィルタ特性に影響を与えることはありません.

350 Hz 0 cent 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 プロパティは無効で, フィルタ特性に影響を与えることはありません.

350 Hz 0 cent 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 以外の値である必要があったり, 係数がすべて 0feedforward はエラーとなったりします). また, 実装上, 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);
IIRFilterNode

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$ 変換での伝達関数を, 物理的な音のスペクトル比に特化すると, 離散フーリエ変換での入出力比が伝達関数になると言えます.

イコライザー

フィルタの組み合わせのみでできるエフェクターとして, イコライザーがあります. 元々は, アナログで録音されていた時代に, 振幅が小さくなってしまう周波数帯域を等しくする (equalize) 用途で使われていましたが, 現在では, 積極的に音を加工するエフェクターとして使われています. 楽器演奏や音楽制作ではもちろんですが, 音楽プレイヤーでもイコライザーは標準的に実装されており, 音楽を聴く場合にもバリエーションを与えています.

音楽プレイヤーのイコライザー (macOS Music アプリ イコライザー)

イコライザーにはいくつか種類がありますが, 頻繁に使われるイコライザーとして, 低音域・中音域・高音域の 3 つの帯域を強調・減衰可能な 3 バンドイコライザー (ギターアンプなどでは, 超高音域が追加されているのも多くあります) と, 10 帯域ぐらいをきめ細かく強調・減衰可能なグラフィックイコライザーがあります.

このセクションでは, BiquadFilterNode を組み合わせて, 3 バンドイコライザーとグラフィックイコライザーの実装を解説します. また, イコライザーでは, 特定の周波数帯域を強調することをブースト, 減衰させることをカットと呼ぶことが多いので, これ以降はこれらの用語を使うことにします.

3 バンドイコライザー

3 バンドイコライザーは, 低音域・中音域・高音域の 3 つの帯域をブースト・カット可能なイコライザーですが, 実装としては, Low-Shelving Filter, Peaking Filter, High-Shelving Filter を使うだけで実装できます. つまり, 3 つの帯域を制御する BiquadFilterNode インスタンスを生成して, 接続することで実装可能です. このとき, それぞれのフィルターの $f_{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 つのフィルタの gain プロパティの値を変更して, ブースト・カットします. また, それらのパラメータ (gain プロパティの値) は, 低音域は Bass, 中音域は Middle, 高音域は Treble として制御可能になっているイコライザーがほとんどです (ちなみに, 超高音域がある場合, Presence となっています).

0 dB 0 dB 0 dB
3 バンドイコライザーのフィルタ特性

以下は, 上記のフィルタ特性となる 3 バンドイコライザーを実際のアプリケーションを想定して, ユーザーインタラクティブに, 3 つの周波数帯域をブースト・カットできるようにしたコード例です. 現実世界の 3 バンドイコライザーでは, ユーザーが操作できるパラメーターではありませんが, $f_{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`;
});
0 dB
0 dB
0 dB