このサイトのオーナーはエレキギターを弾くので, オーナー個人的には, オーディオプログラミングの最大の楽しみはエフェクターを実装することだと思っています.
エフェクター実装の基本
一方で, 抽象化されているがゆえに, Web Audio API でエフェクターを実装する場合に理解しておくべきことが 2 つあります.
LFO (Low Frequency Oscillator) の実装
AudioParam
への接続
LFO (Low Frequency Oscillator)
エフェクターにはいくつかの種類があり, モジュレーション系と呼ばれるエフェクター (コーラス, フランジャー, フェイザー, トレモロ, ワウなど)
を実装するためには, 特定のパラメータを時間経過とともに周期的に変化させる必要があります . 具体的には, コーラス / フランジャーは,
ディレイタイム (遅延時間) を時間経過とともに周期的に変化させることによって実装可能です. そして,
特定のパラメータを時間経過とともに周期的に変化させる機能が LFO (Low Frequency Oscillator ) です.
LFO の実装は Web Audio API に限ったことではないのですが, OscillatorNode
を利用して LFO を実装する場合には, Web Audio API
特有のことをもう 1 つ理解している必要があります. それが, AudioParam
への接続です.
AudioParam への接続
結論から記載しますと, AudioNode
の connect
メソッドはオーバーライドされており, 第 1 引数には
AudioNode
インスタンスだけでなく, AudioParam
インスタンスを指定することも可能です. すなわち,
OscillatorNode
の接続先を AudioParam
にすることで,
対象のパラメータを時間経過とともに周期的に変化させることが可能になります.
また, LFO のソースとなる OscillatorNode
に GainNode
を接続することで, パラメータの変化量を調整することが可能になります.
一般的なエフェクターのパラメータの, Depth は GainNode
の gain
プロパティの値に, Rate は
OscillatorNode
の frequency
プロパティの値と detune
プロパティの値に相当しますプロパティの値に相当します.
LFO の実装例 (ビブラート)
LFO と AudioParam
への接続, Depth / Rate の制御を具体的に理解するために, 簡易的なビブラートを実装を記載します. (OscillatorNode
の frequency
プロパティのデフォルト値である) 440 Hz
を基準に, Depth で設定した値が Rate の周期で変化することになります.
初期値で言うと, 440 Hz
± 10 Hz
の範囲で, frequency
プロパティの値が,
1 sec
の間に変化することになります.
<button type="button">start</button>
<label for="range-lfo-depth">Depth</label>
<input type="range" id="range-lfo-depth" value="10" min="0" max="50" step="1" />
<span id="print-lfo-depth-value">10</span>
<label for="range-lfo-rate">Rate</label>
<input type="range" id="range-lfo-rate" value="1" min="1" max="10" step="1" />
<span id="print-lfo-rate-value">1</span>
const context = new AudioContext();
let oscillator = null;
let lfo = null;
let depth = null;
let depthValue = 10;
let rateValue = 1;
const buttonElement = document.querySelector('button[type="button"]');
const rangeDepthElement = document.getElementById('range-lfo-depth');
const rangeRateElement = document.getElementById('range-lfo-rate');
const spanPrintDepthElement = document.getElementById('print-lfo-depth-value');
const spanPrintRateElement = document.getElementById('print-lfo-rate-value');
buttonElement.addEventListener('mousedown', (event) => {
if ((oscillator !== null) || (lfo !== null)) {
return;
}
oscillator = new OscillatorNode(context, { frequency: 440 });
lfo = new OscillatorNode(context, { frequency: rateValue });
depth = new GainNode(context, { gain: depthValue });
// OscillatorNode (Input) -> AudioDestinationNode (Output)
oscillator.connect(context.destination);
// OscillatorNode (LFO) -> GainNode (Depth) -> OscillatorNode.frequency (AudioParam)
// 440 Hz +- ${depthValue} Hz
lfo.connect(depth);
depth.connect(oscillator.frequency);
// Start immediately
oscillator.start(0);
// Start LFO
lfo.start(0);
buttonElement.textContent = 'stop';
});
buttonElement.addEventListener('mouseup', (event) => {
if ((oscillator === null) || (lfo === null)) {
return;
}
// Stop immediately
oscillator.stop(0);
lfo.stop(0);
// GC (Garbage Collection)
oscillator = null;
lfo = null;
depth = null;
buttonElement.textContent = 'start';
});
rangeDepthElement.addEventListener('input', (event) => {
depthValue = event.currentTarget.valueAsNumber;
if (depth) {
depth.gain.value = depthValue;
}
spanPrintDepthElement.textContent = Math.trunc(depthValue).toString(10);
});
rangeRateElement.addEventListener('input', (event) => {
rateValue = event.currentTarget.valueAsNumber;
if (lfo) {
lfo.frequency.value = rateValue;
}
spanPrintRateElement.textContent = Math.trunc(rateValue).toString(10);
});
汎用的な LFO と Depth の制御
基準値と Depth の関係から, パラメータ変化の最小値を考慮しておく必要があるのは, LFO の実装として汎用性に欠けます (先ほどのビブラートの実装だと,
基準値を 27.5 Hz
にした場合, Depth の値によっては, 負数の周波数になってしまいます). より汎用的な LFO にするために,
パラメータの変化量を直接 Depth に設定するのではなく, 基準値に対する変化割合を格納する変数を追加して, その比率と基準値から実際の Depth
を算出します. このような実装にすることで, 基準値に関わらず, Depth の値は 0
から 1
(0 %
から
100 %
) になるのでより汎用的な実装になります, また, 各 AudioParam
のパラメータの値の範囲 (AudioParam
の
minValue
/ maxValue
プロパティにそれぞれ, 最小値と最大値が設定されています)
を意図せずに超えてしまうバグも防ぐことができます.
start
OscillatorNode frequency
440 Hz
Depth
0.1
Rate
1
<button type="button">start</button>
<label for="range-oscillator-frequency">OscillatorNode frequency</label>
<input type="range" id="range-oscillator-frequency" value="440" min="27.5" max="4000" step="0.5" />
<span id="print-oscillator-frequency-value">440</span>
<label for="range-lfo-depth">Depth</label>
<input type="range" id="range-lfo-depth" value="0.1" min="0" max="1" step="0.05" />
<span id="print-lfo-depth-value">10</span>
<label for="range-lfo-rate">Rate</label>
<input type="range" id="range-lfo-rate" value="1" min="1" max="10" step="1" />
<span id="print-lfo-rate-value">1</span>
const context = new AudioContext();
let oscillator = null;
let lfo = null;
let depth = null;
let frequency = 440;
let depthRate = 0.1;
let rateValue = 1;
const buttonElement = document.querySelector('button[type="button"]');
const rangeFrequencyElement = document.getElementById('range-oscillator-frequency');
const rangeDepthElement = document.getElementById('range-lfo-depth');
const rangeRateElement = document.getElementById('range-lfo-rate');
const spanPrintFrequencyElement = document.getElementById('print-oscillator-frequency-value');
const spanPrintDepthElement = document.getElementById('print-lfo-depth-value');
const spanPrintRateElement = document.getElementById('print-lfo-rate-value');
buttonElement.addEventListener('mousedown', (event) => {
if ((oscillator !== null) || (lfo !== null)) {
return;
}
oscillator = new OscillatorNode(context, { frequency });
lfo = new OscillatorNode(context, { frequency: rateValue });
depth = new GainNode(context, { gain: oscillator.frequency.value * depthRate });
// OscillatorNode (Input) -> AudioDestinationNode (Output)
oscillator.connect(context.destination);
// OscillatorNode (LFO) -> GainNode (Depth) -> OscillatorNode.frequency (AudioParam)
lfo.connect(depth);
depth.connect(oscillator.frequency);
// Start immediately
oscillator.start(0);
// Start LFO
lfo.start(0);
buttonElement.textContent = 'stop';
});
buttonElement.addEventListener('mouseup', (event) => {
if ((oscillator === null) || (lfo === null)) {
return;
}
// Stop immediately
oscillator.stop(0);
lfo.stop(0);
// GC (Garbage Collection)
oscillator = null;
lfo = null;
depth = null;
buttonElement.textContent = 'start';
});
rangeFrequencyElement.addEventListener('input', (event) => {
frequency = event.currentTarget.valueAsNumber;
if (oscillator && depth) {
oscillator.frequency.value = frequency;
depth.gain.value = oscillator.frequency.value * depthRate;
}
spanPrintFrequencyElement.textContent = `${(Math.trunc(frequency * 10) / 10)} Hz`;
});
rangeDepthElement.addEventListener('input', (event) => {
depthRate = event.currentTarget.valueAsNumber;
if (oscillator && depth) {
depth.gain.value = oscillator.frequency.value * depthRate;
}
spanPrintDepthElement.textContent = depthRate.toString(10);
});
rangeRateElement.addEventListener('input', (event) => {
rateValue = event.currentTarget.valueAsNumber;
if (lfo) {
lfo.frequency.value = rateValue;
}
spanPrintRateElement.textContent = Math.trunc(rateValue).toString(10);
});
LFO の波形
ここまでの解説やコードでは, LFO の波形は OscillatorNode
のデフォルト値である sin 波 ('sine'
) を使っていました.
LFO としてはこれで十分機能しますが, 波形 (OscillatorNode
の type
プロパティ (OscillatorOptions
))
をノコギリ波や三角波にしても LFO として機能します. また, ランダムなパラメータ変化をさせるためにホワイトノイズを使う場合もあります.
もし必要であれば, LFO の波形も選択できるようにすると, エフェクトにバリエーションが出せるかもしれません.
ディレイ・リバーブ
ディレイ・リバーブがどんなエフェクターかを簡単に表現すると, ディレイはやまびこ現象を再現するエフェクター, リバーブはコンサートホールなどの (主に,
室内の) 音の響きを再現するエフェクターとなるでしょう.
表現上はまったく別のエフェクターのように思えますが, その原理は同じです (また, ディレイのパラメータの設定しだいでは,
簡易的なリバーブを再現することも可能です). ディレイ・リバーブともに, FIR フィルタ , つまり,
加算・乗算・遅延というデジタルフィルタにおける基本処理で実装可能な点です.
ディレイ
ディレイを実装するために必要な処理は, DelayNode
の接続とフィードバックです.
DelayNode
遅延処理を (抽象化して) 実装するために定義されているのが, DelayNode
です. コンストラクタの第 2 引数 (DelayOptions
の maxDelayTime
プロパティ) には (ファクトリメソッドの場合, 第 1 引数), 遅延時間 (ディレイタイム)
の最大値を秒単位で指定します. 省略した場合のデフォルト値は 1 sec
です.
const context = new AudioContext();
const delay = new DelayNode(context, { maxDelayTime: 5 }); // 5 sec
// If use `createDelay`
// const delay = context.createDelay(5); // 5 sec
DelayNode
インスタンスには, AudioParam
である delayTime
プロパティが定義されています. これが,
遅延時間 (ディレイタイム) を決定づけるプロパティです. 最小値は 0 sec
, 最大値はインスタンス生成時に指定した値 (sec) です.
遅延した音を生成するには, サウンドの入力点となるノード (OscillatorNode
など) を DelayNode
に接続します.
以下のコードは, サウンド出力点である AudioDestinationNode
に対して 2 つの入力ノードが接続されています. 1 つは, 原音を出力するため,
そして, もう 1 つは遅延音を出力するためです. このように, 複数のノードを入力ノードとして接続することで,
それぞれ入力された音をミックスすることが可能になります. これは, ディレイだけではなく他のエフェクターを実装する場合においても,
原音とエフェクト音をミックスする という処理は必要になります.
const context = new AudioContext();
const delay = new DelayNode(context);
// If use `createDelay`
// const delay = context.createDelay();
delay.delayTime.value = 0.5;
const oscillator = new OscillatorNode(context);
// Connect nodes for original sound
// OscillatorNode (Input) -> AudioDestinationNode (Output)
oscillator.connect(context.destination);
// Connect nodes for delay sound
// OscillatorNode (Input) -> DelayNode (Delay) -> AudioDestinationNode (Output)
oscillator.connect(delay);
delay.connect(context.destination);
oscillator.start(0);
oscillator.stop(context.currentTime + 2);
これで, ディレイが実装できました ... と言いたいところですが, このコードでは, エフェクターとしてのディレイ は実現できていません.
エフェクターとしての という意味は, 上記のコードでも遅延した音は発生します. しかしながら, やまびこ現象のように,
遅延音が少しずつ減衰しながら何度も繰り返し生成することが実装できていません.
Web Audio API の設計思想からの観点で説明すると,
DelayNode
は, 指定された遅延時間で遅延音を 1 つだけ生成することだからです . すなわち,
エフェクターのディレイを実現するという役割までは担いません.
そこで, エフェクターとしてのディレイを実装するには,
DelayNode
の接続と次のセクションで解説するフィードバック という処理が必要になります.
フィードバック
フィードバック とは, 出力された音を入力音として利用することです. つまり, DelayNode によって出力された遅延音を,
再び入力音とすればディレイを実現することが可能です. これを, Web Audio APIで実装するには,
フィードバックのための GainNode
を接続して, その入出力に同じ DelayNode
を接続します. また,
フィードバックの実装は, ディレイに限らず, 様々なエフェクターで必要となります.
ちなみに, エレキギターではフィードバック奏法と呼ばれる奏法があります. この奏法は, アンプから出力された音で弦を振動させて,
それをピックアップが拾い, 再びアンプから出力させることで, 理論上, 永遠の音の伸びを奏でる奏法です.
const context = new AudioContext();
const delay = new DelayNode(context);
// If use `createDelay`
// const delay = context.createDelay();
delay.delayTime.value = 0.5;
const feedback = new GainNode(context, { gain: 0.5 });
const oscillator = new OscillatorNode(context);
// Connect nodes for original sound
// OscillatorNode (Input) -> AudioDestinationNode (Output)
oscillator.connect(context.destination);
// Connect nodes for delay sound
// OscillatorNode (Input) -> DelayNode (Delay) -> AudioDestinationNode (Output)
oscillator.connect(delay);
delay.connect(context.destination);
// Connect nodes for feedback
// (OscillatorNode (Input) ->) DelayNode (Delay) -> GainNode (Feedback) -> DelayNode (Delay) -> GainNode (Feedback) -> ...
delay.connect(feedback);
feedback.connect(delay);
oscillator.start(0);
oscillator.stop(context.currentTime + 2);
上記のコードでは, 大きく 3 つの接続ができました.
原音を出力する接続
エフェクト音 (遅延音) を出力する接続
フィードバック (エフェクト音を再び入力する) のための接続
フィードバック (エフェクト音を再び入力する) の接続によって, 遅延音が少しずつ減衰しながら何度も繰り返し生成されます. 具体例として, 原音のゲインを
1
, フィードバッグのゲインが 0.5
とすると, 1 つめの遅延音のゲインは, 0.5
(1 x 0.5
), 2
つめの遅延音のゲインは, 0.25
(0.5 x 0.5
), 3 つめの遅延音のゲインは, 0.125
(0.25 x 0.5
) ...
といった繰り返しで, 遅延音が少しずつ減衰しながら生成されます. つまり, フィードバックの GainNode
の
gain
プロパティを適切に設定すれば, エフェクターとしてのディレイにバリエーションが出せるというこでもあります.
ただし, フィードバックの値は 1
未満にする必要があります . これは, 直感的な説明をすれば,
1
以上にすると減衰しない状態 (無限ループのような状態) になってしまうからです. 数学的・工学的な厳密性で説明すると,
絶対可積分 を満たさなくなり, (のちのセクションで解説する) FIR
フィルタが安定しないフィルタとなるからです.
フィードバックのイメージ
start
Dry / Wet
Dry / Wet とは, 原音とエフェクト音のゲインを調整するパラメータ (または, そのような機能) のことです.
現実世界のエフェクターにおいても, Dry (原音) / Wet (エフェクト音) としてコントロール可能になっているものが多いので,
このドキュメントでもそれに従って Dry / Wet (あるいは, それらを合わせる Mix) と呼ぶことにします.
const context = new AudioContext();
const delay = new DelayNode(context, { delayTime: 0.5 });
// If use `createDelay`
// const delay = context.createDelay();
// delay.delayTime.value = 0.5;
const dry = new GainNode(context, { gain: 0.7 }); // for gain of original sound
const wet = new GainNode(context, { gain: 0.3 }); // for gain of delay sound
const feedback = new GainNode(context, { gain: 0.5 }); // for feedback
const oscillator = new OscillatorNode(context);
// Connect nodes for original sound
// OscillatorNode (Input) -> GainNode (Dry) -> AudioDestinationNode (Output)
oscillator.connect(dry);
dry.connect(context.destination);
// Connect nodes for delay sound
// OscillatorNode (Input) -> DelayNode (Delay) -> GainNode (Wet) -> AudioDestinationNode (Output)
oscillator.connect(delay);
delay.connect(wet);
wet.connect(context.destination);
// Connect nodes for feedback
// (OscillatorNode (Input) ->) DelayNode (Delay) -> GainNode (Feedback) -> DelayNode (Delay) -> GainNode (Feedback) -> ...
delay.connect(feedback);
feedback.connect(delay);
oscillator.start(0);
oscillator.stop(context.currentTime + 2);
上記のコードは, Dry / Wet のための GainNode
を接続して, ディレイの実装完成コードです. Dry / Wet の制御を可能にすることで,
原音とエフェクト音のゲインを調整するだけで, ディレイにさらなるバリエーションが生まれます. さらに, ノード接続を変更することなくエフェクターのオン
/ オフを切り替えることが可能になります. 例えば, Dry を 1
, Wet を 0
にすれば, エフェクターオフ (つまり, 原音のみ)
のサウンドになります.
ディレイのノード接続図
フィードバックと同様に, Dry / Web (あるいは, Mix) のパラメータ制御は, ディレイだけではなく, 他のエフェクターでも利用されるので, まずは,
ノード接続が比較的単純なディレイの実装でそれらを理解しておくとよいでしょう.
遅延音の実装とリングバッファ
ディレイはエフェクター実装における基本を理解するためにも最適なエフェクターですが, ディレイのコアとなる,
遅延音はどのようにして実装するのでしょうか ? (Web Audio API では,
DelayNode
がそれを抽象化しているので気にすることはほとんどないと思いますが). 結論的には,
リングバッファ に入力された音を格納する (enqueue) ことで, 過去の入力音をリングバッファのサイズだけ蓄積する事が可能です (DelayNode
コンストラクタで指定する遅延時間の最大値は, このリングバッファのサイズを決定するためにあります). 指定した
delayTime
の値が経過したら, リングバッファに格納した過去の入力音を, 原音 (現在時刻の音) と合成して出力します.
加算・乗算・遅延はデジタルフィルタを構成する基本処理なので, 遅延に関しても,
ローレイヤーでどのように実装されているかを理解しておくと応用が利くはずです. 実装言語は C++ になりますが, より詳細な解説は,
ディレイの実装例 | C++でVST作り を参考にするとよいと思います.
リバーブ
簡易的なリバーブであれば, ディレイのパラメータを適切に設定することで実装することは可能です. しかしながら, 現実世界のエフェクターのリバーブは,
ディレイと実装は異なり, シミュレートしたい音響空間のインパルス応答 (RIR : Room Impulse Response )
と呼ばれるオーディオデータを利用します. インパルス応答の詳細に関しては, 後半のセクションで解説しますので, とりあえず,
リバーブを実装してみましょう.
ConvolverNode
インパルス応答のオーディオデータをエフェクターとして利用するには, ConvolverNode
を利用します. ConvolverNode
は,
コンボリューション積分 (畳み込み積分 , 合成積 ) という数学的な演算を抽象化する AudioNode
です
(デジタルフィルタの視点では, FIR フィルタ を抽象化します. のちほど解説しますが, 見立ての違いであり, 本質的には, コンボリューション積分も
FIR フィルタも同じです. コンボリューション積分も FIR フィルタも次のセクション以降で解説しています).
const context = new AudioContext();
const convolver = new ConvolverNode(context);
// If use `createConvolver`
// const convolver = context.createConvolver();
ConvolverNode
には, buffer
プロパティが定義されており, この buffer
プロパティに, インパルス応答
(RIR) のオーディオデータの AudioBuffer
インスタンスを設定します (コンストラクタ生成であれば,
ConvolverOptions
の buffer
プロパティで設定することも可能です).
AudioBuffer
インスタンスの生成は, ワンショットオーディオ と同じです.
const context = new AudioContext();
fetch('./assets/rirs/rir.mp3')
.then((response) => {
return response.arrayBuffer();
})
.then((arrayBuffer) => {
const successCallback = (audioBuffer) => {
const convolver = new ConvolverNode(context);
// If use `ConvolverOptions`
// const convolver = new ConvolverNode(context, { buffer: audioBuffer });
// If use `createConvolver`
// const convolver = context.createConvolver();
convolver.buffer = audioBuffer;
};
const errorCallback = (error) => {
// error handling
};
context.decodeAudioData(arrayBuffer, successCallback, errorCallback);
})
.catch((error) => {
// error handling
});
サウンドの入力点となるノード (OscillatorNode
など) を ConvolverNode
に接続して, ConvolverNode
を
AudioDestinationNode
に接続します.
const context = new AudioContext();
fetch('./assets/rirs/rir.mp3')
.then((response) => {
return response.arrayBuffer();
})
.then((arrayBuffer) => {
const successCallback = (audioBuffer) => {
const convolver = new ConvolverNode(context);
// If use `ConvolverOptions`
// const convolver = new ConvolverNode(context, { buffer: audioBuffer });
// If use `createConvolver`
// const convolver = context.createConvolver();
convolver.buffer = audioBuffer;
const dry = new GainNode(context, { gain: 0.6 }); // for gain of original sound
const wet = new GainNode(context, { gain: 0.4 }); // for gain of reverb sound
const oscillator = new OscillatorNode(context);
// Connect nodes for original sound
// OscillatorNode (Input) -> GainNode (Dry) -> AudioDestinationNode (Output)
oscillator.connect(dry);
dry.connect(context.destination);
// Connect nodes for reverb sound
// OscillatorNode (Input) -> ConvolverNode (Reverb) -> GainNode (Wet) -> AudioDestinationNode (Output)
oscillator.connect(convolver);
convolver.connect(wet);
wet.connect(context.destination);
oscillator.start(0);
oscillator.stop(context.currentTime + 5);
};
const errorCallback = (error) => {
// error handling
};
context.decodeAudioData(arrayBuffer, successCallback, errorCallback);
})
.catch((error) => {
// error handling
});
ディレイの場合と同じように, 原音とエフェクト音の接続, そして, Dry / Wet の GainNode
も接続しています.
遅延音の生成処理と遅延音に対する演算処理は, ConvolverNode
が抽象化しているので, フィードバックのための接続は不要です.
リバーブのノード接続図
以上で, リバーブが完成しました. ところで, リバーブを利用するためには, インパルス応答 (RIR) のオーディオファイルが必要です.
楽曲やワンショットオーディオファイルはあっても, インパルス応答のオーディオデータをもっている人は少ないと思います.
機材をもっていれば実際に測定するのも可能ですが, そこまでするのはちょっとめんどうです. となると, Web
上で公開されているファイルを利用することになるのですが, 利用条件や著作権の関係から無償で自由に利用できるのは意外とありません. とりあえず, 1
つ紹介するのは,
Concert Hall Impulse Responses - Pori, Finland
です. readme.txt の 5.
Copyright のセクションに, 「The data are provided free for noncommercial purposes, provided the authors are cited when the data are used in any research application. 」と記載されているので, 非商用利用であれば, 自身が開発されている Web アプリケーションに利用しても問題なさそうです.
インパルス応答
インパルス応答を理解するには, まずは, インパルス音 について理解する必要があります. インパルス音とは,
ごく短時間において瞬間的に発生する音です. 具体的には, ピストルの音や風船が破裂するときの音がインパルス音と言えるでしょう.
インパルス音のイメージは以下のグラフのようになります. このような物理現象を数式でモデリングするための最適な関数が, デルタ関数 です.
インパルス音 (デルタ関数) のグラフ表現
以下は, デルタ関数の定義です. デルタ関数は, $t = t_{r}$ において, 横幅が
$\lim_{\mathrm{w}\to 0}$ , 高さが $\lim_{\mathrm{h}\to \infty}$ となる関数
(数学での関数をより一般化した, 超関数 に分類されます) なので, 無限の区間において積分すると, その値 (つまり, 面積) が
1
となります.
$
\begin{flalign}
&\delta\left(t - t_{r}\right)dt =
\begin{cases}
\infty & (\mathrm{if} \quad t = t_{r}) \\
0 & (\mathrm{if} \quad t \neq t_{r})
\end{cases}
\end{flalign}
$
$
\begin{flalign}
&\int_{-\infty}^{\infty}\delta\left(t - t_{r}\right)dt = 1 \\
\end{flalign}
$
インパルス音を室内で発生させると, 音が天井や壁などに反射して, 音の響き, すなわち, 残響 が発生します. カラオケが好きなかたであれば,
エコー のような効果と考えてもよいでしょう.
直接音以外にも反射音 (初期反射音) や何度も反射して聴こえる残響音 (後期残響音) が室内では発生します
インパルス応答のイメージ
start
これが, インパルス応答 です. つまり, インパルス音を音響空間に対する入力としたときの出力 (応答) ということです. そして, その出力が音響空間における, 残響特性を表すことになるので , インパルス応答を利用することによって,
コンサートホールなどの音響空間の音の響きをシミュレートするエフェクターであるリバーブが実現できるというわけです. ちなみに,
室内でのインパルス応答をシミュレートする事が多いので, RIR (Room Impulse Response) と表現されることもあります.
実際の RIR の波形
start
また, この残響が, ディレイにおけるフィードバックと類似した音響効果を発生させることになります
(フィードバックのアニメーションとインパルス応答のアニメーションが類似していることに気付いたかもしれません).
イメージでインパルス応答は理解できるかと思いますが, それをコンピュータで実現する場合, 数式でモデリングできる必要があります.
ConvolverNode
の命名 (Convolve : 畳み込む) が表すように, その演算処理がコンボリューション積分 (畳み込み積分 ,
合成積 ) です. 言い換えると, ConvolverNode
は, コンボリューション積分を抽象化した AudioNode
です.
コンボリューション積分
コンボリューション積分 とは, 以下の数式で定義されるように, 信号の遅延と乗算, それらの無限区間の積分によって構成されています.
$x\left(t\right)$ は入力信号, $y\left(t\right)$ は出力信号,
$h\left(t_{r}\right)$ は, インパルス応答の信号 (ConvolverNode
の
buffer
プロパティに設定する, AudioBuffer
のオーディオデータと考えてもよいでしょう) です.
$
\begin{flalign}
&y\left(t\right) = \int_{0}^{\infty}x\left(t - t_{r}\right)h\left(t_{r}\right)dt
\end{flalign}
$
コンピュータでは, 連続信号をあつかうことはできないので, サンプリングされた離散信号の遅延と乗算, それらの加算となります. また,
無限の加算はできないので, 有界な値 (サンプル数) $N$ となります.
$
\begin{flalign}
&y\left(n\right) = \sum_{m = 0}^{N}x\left(n - m\right)h\left(m\right)
\end{flalign}
$
具体的に理解するために, $N = 5$ として, 展開してみます.
$
\begin{flalign}
&y\left(n\right) = x\left(n\right)h\left(0\right) + x\left(n - 1\right)h\left(1\right) + x\left(n - 2\right)h\left(2\right) + x\left(n - 3\right)h\left(3\right) + x\left(n - 4\right)h\left(4\right) + x\left(n - 5\right)h\left(5\right)
\end{flalign}
$
理解しやすいように, 入力信号 $x\left(n\right)$ は, 振幅が 1
のパルス列とします. また,
出力信号の実際の値を算出するために, インパルス応答の信号は, 以下の値とします.
$
\begin{flalign}
&h\left(0\right) = 1.0 \\
&h\left(1\right) = 0.75 \\
&h\left(2\right) = 0.5 \\
&h\left(3\right) = 0.25 \\
&h\left(4\right) = 0.125 \\
&h\left(5\right) = 0.0625 \\
\end{flalign}
$
これらの信号のコンボリューション積分をイラストにすると, 以下のようなグラフになります.
コンボリューション積分
上部が入力信号のパルス列 ($x\left(n\right)$ ), 中央がインパルス応答 ($h\left(m\right)$ ), 下部がコンボリューション積分結果の出力信号 ($y\left(n\right)$ ) となります.
出力信号の振幅のみスケールが異なることに注意してください. ここで, $n = 3$ に相当する時刻までの値を,
先ほどの展開したコンボリューション積分に適用して実際の値を算出すると,
$
\begin{flalign}
&y\left(0\right) = x\left(0\right)h\left(0\right) + x\left(-1\right)h\left(1\right) + x\left(-2\right)h\left(2\right) + x\left(-3\right)h\left(3\right) + x\left(-4\right)h\left(4\right) + x\left(-5\right)h\left(5\right) \\
&y\left(1\right) = x\left(1\right)h\left(0\right) + x\left(0\right)h\left(1\right) + x\left(-1\right)h\left(2\right) + x\left(-2\right)h\left(3\right) + x\left(-3\right)h\left(4\right) + x\left(-4\right)h\left(5\right) \\
&y\left(2\right) = x\left(2\right)h\left(0\right) + x\left(1\right)h\left(1\right) + x\left(0\right)h\left(2\right) + x\left(-1\right)h\left(3\right) + x\left(-2\right)h\left(4\right) + x\left(-3\right)h\left(5\right) \\
&y\left(3\right) = x\left(3\right)h\left(0\right) + x\left(2\right)h\left(1\right) + x\left(1\right)h\left(2\right) + x\left(0\right)h\left(3\right) + x\left(-1\right)h\left(4\right) + x\left(-2\right)h\left(5\right) \\
\end{flalign}
$
負数に相当する時刻は, 振幅が 0
とみなせるので, $n \geq 0$ の項のみ記述すると,
$
\begin{flalign}
&y\left(0\right) = x\left(0\right)h\left(0\right) \\
&y\left(1\right) = x\left(1\right)h\left(0\right) + x\left(0\right)h\left(1\right) \\
&y\left(2\right) = x\left(2\right)h\left(0\right) + x\left(1\right)h\left(1\right) + x\left(0\right)h\left(2\right) \\
&y\left(3\right) = x\left(3\right)h\left(0\right) + x\left(2\right)h\left(1\right) + x\left(1\right)h\left(2\right) + x\left(0\right)h\left(3\right) \\
\end{flalign}
$
また, 入力信号は, 振幅 1
のパルス列なので, $x\left(n\right) = 1$ となるので,
$
\begin{flalign}
&y\left(0\right) = h\left(0\right) \\
&y\left(1\right) = h\left(0\right) + h\left(1\right) \\
&y\left(2\right) = h\left(0\right) + h\left(1\right) + h\left(2\right) \\
&y\left(3\right) = h\left(0\right) + h\left(1\right) + h\left(2\right) + h\left(3\right) \\
\end{flalign}
$
最後に, $h\left(m\right)$ の実際の値を適用すれば, 出力信号 $y\left(n\right)$ の
$n = 3$ までの値が算出できます.
$
\begin{flalign}
&y\left(0\right) = 1.0 \\
&y\left(1\right) = 1.0 + 0.75 = 1.75 \\
&y\left(2\right) = 1.0 + 0.75 + 0.5 = 2.25 \\
&y\left(3\right) = 1.0 + 0.75 + 0.5 + 0.25 = 2.5 \\
\end{flalign}
$
目視レベルではありますが, グラフで表示されている出力信号と (大きく) 値の相違がないことが確認できます. 計算結果を抽象化すると,
コンボリューション積分で生成された出力信号は, 現在の時刻の信号だけではなく, 過去の信号の影響も受ける ということですということです (逆に,
それを, 厳密に数式で定義したのがコンボリューション積分と言えます).
$N = 5$ , $n = 3$ の場合の, コンボリューション積分のイメージ
start
入力信号やインパルス応答が, 現実世界のようにより複雑になると, 計算自体も複雑になりますが, コンボリューション積分がどうのような演算か, そして,
ConvolverNode
が抽象化している演算 のイメージを理解するのに役立てばと思います.
もっとも, 数学・物理的に理解しようとすると少し難しく感じるかもしれませんが, プログラミング言語で実装すれば, 2
重ループと積和演算で構成される単純な処理です.
// コンボリューション積分のコード片
const x = new Float32Array(2400);
const h = new Float32Array(1200);
const y = new Float32Array(2400);
// 入力信号
for (let n = 0; n < x.length; n++) {
x[n] = Math.sin((2 * Math.PI * n * 2) / 1200);
}
// インパルス応答
for (let m = 0; m < h.length; m++) {
h[m] = 0.5 * Math.exp(-m);
}
// 出力信号
for (let n = 0; n < x.length; n++) {
for (let m = 0; m < h.length; m++) {
if ((n - m) >= 0) {
y[n] += (x[n - m] * h[m]);
}
}
}
巡回畳み込み
コンボリューション積分は, 周波数領域においては乗算 となります. したがって, 時間領域の入力信号
$x\left(n\right)$ を (離散) フーリエ変換した信号を $X\left(k\right)$ ,
インパルス応答を (離散) フーリエ変換した信号を $H\left(k\right)$ , 出力信号
$y\left(n\right)$ を (離散) フーリエ変換した信号を
$Y\left(k\right)$ と定義すると, コンボリューション積分は以下のように定義することもできます,
$F$ はフーリエ変換 (離散フーリエ変換), $F^{-1}$ は逆フーリエ変換
(逆離散フーリエ変換) です.
$
\begin{flalign}
&y\left(n\right) = \sum_{m = 0}^{N}x\left(n - m\right)h\left(m\right) = F^{-1}\left[Y\left(k\right)\right] = F^{-1}\left[X\left(k\right)H\left(k\right)\right]
\end{flalign}
$
この性質を利用して, 時間領域ではコンボリューション積分になるオーディオ信号処理を, 周波数領域に変換して, 乗算のみで信号処理を適用して,
時間領域に変換する巡回畳み込み という処理 (ある種のテクニック的な処理です) があります.
インパルス応答も数学的には, デルタ関数のフーリエ変換と周波数領域で定義される伝達関数 の乗算の逆フーリエ変換で定義することもできます (以下,
簡単にですが, 導出を記載しておきます).
$
\begin{flalign}
&h\left(n\right) = \sum_{m = 0}^{\infty}\delta\left(n - m\right)h\left(m\right) = F^{-1}\left[F\left[\delta\left(n\right)\right]H\left(k\right)\right]
\end{flalign}
$
ここで, デルタ関数のフーリエ変換を導出すると (導出の理解が難しければ, とりあえず
$F\left[\delta\left(t\right)\right] = 1$ と覚えてしまっていいでしょう. 他に覚えやすいフーリエ変換関係だと,
矩形関数とシンク関数 ($\frac{\sin\pi x}{\pi x}$ ) は, 互いにフーリエ変換の関係にあります),
$
\begin{flalign}
&F\left[\delta\left(t\right)\right] = \int_{-\infty}^{\infty}\delta\left(t\right)e^{-j2 \pi ft}dt
\end{flalign}
$
デルタ関数の定義より, 上記のフーリエ変換において, $t = 0$ 以外の積分区間では
$\delta\left(t\right) = 0$ となるので,
$
\begin{flalign}
&F\left[\delta\left(t\right)\right] = \int_{-\infty}^{\infty}\delta\left(0\right)e^{-j2 \pi f \cdot 0}dt = \int_{-\infty}^{\infty}\delta\left(0\right)dt \cdot e^{0} = 1 \cdot 1 = 1
\end{flalign}
$
(数学的な厳密性は欠いてしまいますが, 同じように離散信号にも適用すると) デルタ関数のフーリエ変換は 1
です. つまり,
$F\left[\delta\left(n\right)\right] = 1$ なので,
$
\begin{flalign}
&h\left(n\right) = \sum_{m = 0}^{\infty}\delta\left(n - m\right)h\left(m\right) = F^{-1}\left[F\left[\delta\left(n\right)\right]H\left(k\right)\right] = F^{-1}\left[H\left(k\right)\right]
\end{flalign}
$
Web Audio API は (他のオーディオ API と比較すると) 抽象度が高いので, 巡回畳み込みまで駆使するケースはほとんどないかもしれませんが,
一応知っておくと実装のヒントになることがあるかもしれません.
FIR フィルタ
デジタルフィルタを数学的な厳密性まで含めて解説すると, それだけで 1 冊の書籍になるぐらいの解説になるので, FIR
フィルタをディレイ・リバーブの観点で解説します.
FIR フィルタ (Finite Impulse Response filter ) は, 以下の数式で定義されるデジタルフィルタです.
$
\begin{flalign}
&y\left(n\right) = \sum_{m = 0}^{N}x\left(n - m\right)h\left(m\right)
\end{flalign}
$
数式的には, コンボリューション積分と同じです. すなわち, 見立ての違い でしかありません. 数学的にはコンボリューション積分, 工学的には FIR
フィルタと表現しています. 言い換えると, コンボリューション積分を実装に落とし込んだのが FIR フィルタということです.
具体的に, $N = 3$ (乗算器の数 $N + 1$ )
の加算器・乗算器・遅延器の要素を利用してブロック図として表現します (遅延器の $z^{-1}$ の表記は
$z$ 変換 に由来しますが, ブロック図としては, 1 サンプル分遅延させる要素 (素子) の理解で問題ありません).
FIR フィルタ
FIR フィルタの図から, 逆に, コンボリューション積分の数式を導出することが可能なこともわかります.
乗算器の数 (遅延器の数もそれに比例) と係数は, ディレイは制御可能なパラメータで決定できますが, リバーブは室内の特性によって決定されます. すなわち,
乗算器の数と係数の算出がディレイとリバーブの実装に違いに表れます .
ディレイは遅延時間とフィードバックよって乗算器の数と係数を決定する のに対して,
リバーブはインパルス応答 (RIR) のオーディオデータから乗算器の数と係数が決定されます .
Schroeder Reverberator (シュレーダーリバーブ)
コムフィルタ (遅延音を生成) と All-Pass Filter (位相変化によって, 遅延音の間を補間して残響音の密度を高める) を駆使して, 人工的にリバーブ
(人工インパルス応答) を生成する実装が知られています. 実装言語は Python になりますが,
シュレーダーリバーブ(人工残響エフェクタ)のPython実装と試聴デモ などが参考になります.
コーラス・フランジャー
コーラス は, 音に揺らぎを与えるエフェクターです. 合唱では, どんなに歌唱力の高い人が集まって歌っても多少なりともピッチのずれは生じてしまいます.
しかし, この微妙なずれが合唱らしさを生み出している要因でもあります. コーラスでは, この微妙なピッチのずれをオーディオ信号処理で再現します.
フランジャー は, ジェット機のエンジン音のように, 音に強烈なうねりを与えるエフェクターです.
このように, コーラスとフランジャーは, エフェクターとしてはまったく異なるように感じますし, 現実世界のエフェクターでも,
コーラスとフランジャーはそれぞれ別に存在していますが, その原理は同じです. ディレイタイム (遅延時間) を周期的に変化させる ことによって,
FM 変調 を発生させている点です. 原理が同じであるにも関わらず, 別のエフェクターとして感じるのは,
パラメータの設定値やフィードバックの有無が影響しています (また, 音楽的に目的が異なるので, それぞれ,
コーラス・フランジャーとして別になっていると思います).
コーラス・フランジャーはディレイが基本となっているので, ディレイの実装がよくわからないという場合は, (前のセクション解説しているので)
ディレイの実装を理解してから, このセクションを進めてください.
コーラス
コーラスは, ディレイタイムを周期的に変化させたエフェクト音 を原音とミックスすることにより実装できます.
ディレイタイムを周期的に変化させることが実装のポイントになりますが, ここで LFO を利用することで, ディレイタイムを周期的に変化させることができます.
つまり, Web Audio API においては, LFO の接続先を DelayNode
の AudioParam である
delayTime
プロパティに接続すれば実装完了です.
まず, 原音の出力の接続と, エフェクト音の出力の接続 (DelayNode
の接続) のみを実装します.
const context = new AudioContext();
const delay = new DelayNode(context, { delayTime: 0.02 });
const oscillator = new OscillatorNode(context);
// Connect nodes for original sound
// OscillatorNode (Input) -> AudioDestinationNode (Output)
oscillator.connect(context.destination);
// Connect nodes for delay sound
// OscillatorNode (Input) -> DelayNode (Delay) -> AudioDestinationNode (Output)
oscillator.connect(delay);
delay.connect(context.destination);
oscillator.start(0);
oscillator.stop(context.currentTime + 2);
そして, LFO の実装で解説したように, LFO のための OscillatorNode
インスタンスと
GainNode
インスタンス (Depth パラメータ) を生成して, DelayNode
の delayTime
プロパティ に接続します.
const context = new AudioContext();
const baseDelayTime = 0.020;
const depthValue = 0.005;
const rateValue = 1;
const delay = new DelayNode(context, { delayTime: baseDelayTime });
const oscillator = new OscillatorNode(context);
const lfo = new OscillatorNode(context, { frequency: rateValue });
const depth = new GainNode(context, { gain: depthValue });
// Connect nodes for original sound
// OscillatorNode (Input) -> AudioDestinationNode (Output)
oscillator.connect(context.destination);
// Connect nodes for delay sound
// OscillatorNode (Input) -> DelayNode (Delay) -> AudioDestinationNode (Output)
oscillator.connect(delay);
delay.connect(context.destination);
// Connect nodes for LFO that changes delay time periodically
// OscillatorNode (LFO) -> GainNode (Depth) -> delayTime (AudioParam)
lfo.connect(depth);
depth.connect(delay.delayTime);
// Start oscillator and LFO
oscillator.start(0);
lfo.start(0);
// Stop oscillator and LFO
oscillator.stop(context.currentTime + 5);
lfo.stop(context.currentTime + 5);
ディレイのノード接続からフィードバックを除いて, LFO を DelayNode
の delayTime
プロパティ (AudioParam
)
に接続したノード接続と同じです. パラメータに関しては, コーラスの場合, 基準となるディレイタイムを 20 - 30 msec
にして,
± 5 ~ 10 msec
, Rate はゆっくりと 1 Hz
ぐらいがよいでしょう. (もっとも, 実際のプロダクトでは,
ある程度自由度高く設定できるように, 汎用的な LFO になるように実装することになるでしょう).
コーラスの原理的な実装としてはこれで完了ですが, エフェクターとしてはまだコーラスっぽくありません.
原音とエフェクト音が同じゲインで合成されているので, 原音とエフェクト音が別々に出力されているように聴こえると思います.
原音が少し揺れているぐらいにエフェクト音を合成するとコーラスらしくなるので, Dry / Wet のための GainNode
を接続して,
原音とエフェクト音のゲインを調整します.
const context = new AudioContext();
const baseDelayTime = 0.020;
const depthValue = 0.005;
const rateValue = 1;
const delay = new DelayNode(context, { delayTime: baseDelayTime });
const oscillator = new OscillatorNode(context);
const lfo = new OscillatorNode(context, { frequency: rateValue });
const depth = new GainNode(context, { gain: depthValue });
const dry = new GainNode(context, { gain: 0.7 }); // for gain of original sound
const wet = new GainNode(context, { gain: 0.3 }); // for gain of chorus sound
// Connect nodes for original sound
// OscillatorNode (Input) -> GainNode (Dry) -> AudioDestinationNode (Output)
oscillator.connect(dry);
dry.connect(context.destination);
// Connect nodes for delay sound
// OscillatorNode (Input) -> DelayNode (Delay) -> GainNode (Wet) -> AudioDestinationNode (Output)
oscillator.connect(delay);
delay.connect(wet);
wet.connect(context.destination);
// Connect nodes for LFO that changes delay time periodically
// OscillatorNode (LFO) -> GainNode (Depth) -> delayTime (AudioParam)
lfo.connect(depth);
depth.connect(delay.delayTime);
// Start oscillator and LFO
oscillator.start(0);
lfo.start(0);
// Stop oscillator and LFO
oscillator.stop(context.currentTime + 5);
lfo.stop(context.currentTime + 5);
コーラスのノード接続図
以上で, コーラスの実装は完了です. ハードコーディングしているパラメータが多いので, ある程度実際のアプリケーションを想定して, UI
からパラメータ設定を可能にすると以下のようなコードとなるでしょう (Dry / Wet は同時に設定する Mix としています).
<button type="button">start</button>
<label for="range-chorus-delay-time">Delay time</label>
<input type="range" id="range-chorus-delay-time" value="0" min="0" max="50" step="1" />
<span id="print-chorus-delay-time-value">0 msec</span>
<label for="range-chorus-depth">Depth</label>
<input type="range" id="range-chorus-depth" value="0" min="0" max="1" step="0.05" />
<span id="print-chorus-depth-value">0</span>
<label for="range-chorus-rate">Rate</label>
<input type="range" id="range-chorus-rate" value="0" min="0" max="1" step="0.05" />
<span id="print-chorus-rate-value">0</span>
<label for="range-chorus-mix">Mix</label>
<input type="range" id="range-chorus-mix" value="0" min="0" max="1" step="0.05" />
<span id="print-chorus-mix-value">0</span>
const context = new AudioContext();
let oscillator = null;
let lfo = null;
let depthRate = 0;
let rateValue = 0;
let mixValue = 0;
const delay = new DelayNode(context);
const depth = new GainNode(context, { gain: delay.delayTime.value * depthRate });
const dry = new GainNode(context, { gain: 1 - mixValue });
const wet = new GainNode(context, { gain: mixValue });
const buttonElement = document.querySelector('button[type="button"]');
const rangeDelayTimeElement = document.getElementById('range-chorus-delay-time');
const rangeDepthElement = document.getElementById('range-chorus-depth');
const rangeRateElement = document.getElementById('range-chorus-rate');
const rangeMixElement = document.getElementById('range-chorus-mix');
const spanPrintDelayTimeElement = document.getElementById('print-chorus-delay-time-value');
const spanPrintDepthElement = document.getElementById('print-chorus-depth-value');
const spanPrintRateElement = document.getElementById('print-chorus-rate-value');
const spanPrintMixElement = document.getElementById('print-chorus-mix-value');
buttonElement.addEventListener('mousedown', (event) => {
if ((oscillator !== null) || (lfo !== null)) {
return;
}
oscillator = new OscillatorNode(context);
lfo = new OscillatorNode(context, { frequency: rateValue });
// Connect nodes for original sound
// OscillatorNode (Input) -> GainNode (Dry) -> AudioDestinationNode (Output)
oscillator.connect(dry);
dry.connect(context.destination);
// Connect nodes for delay sound
// OscillatorNode (Input) -> DelayNode (Delay) -> GainNode (Wet) -> AudioDestinationNode (Output)
oscillator.connect(delay);
delay.connect(wet);
wet.connect(context.destination);
// Connect nodes for LFO that changes delay time periodically
// OscillatorNode (LFO) -> GainNode (Depth) -> delayTime (AudioParam)
lfo.connect(depth);
depth.connect(delay.delayTime);
// Start oscillator and LFO immediately
oscillator.start(0);
lfo.start(0);
buttonElement.textContent = 'stop';
});
buttonElement.addEventListener('mouseup', (event) => {
if ((oscillator === null) || (lfo === null)) {
return;
}
// Stop immediately
oscillator.stop(0);
lfo.stop(0);
// GC (Garbage Collection)
oscillator = null;
lfo = null;
buttonElement.textContent = 'start';
});
rangeDelayTimeElement.addEventListener('input', (event) => {
delay.delayTime.value = event.currentTarget.valueAsNumber * 0.001;
depth.gain.value = delay.delayTime.value * depthRate;
spanPrintDelayTimeElement.textContent = `${Math.trunc(delay.delayTime.value * 1000)} msec`;
});
rangeDepthElement.addEventListener('input', (event) => {
depthRate = event.currentTarget.valueAsNumber;
depth.gain.value = delay.delayTime.value * depthRate;
spanPrintDepthElement.textContent = depthRate.toString(10);
});
rangeRateElement.addEventListener('input', (event) => {
rateValue = event.currentTarget.valueAsNumber;
if (lfo) {
lfo.frequency.value = rateValue;
}
spanPrintRateElement.textContent = rateValue.toString(10);
});
rangeMixElement.addEventListener('input', (event) => {
mixValue = event.currentTarget.valueAsNumber;
dry.gain.value = 1 - mixValue;
wet.gain.value = mixValue;
spanPrintMixElement.textContent = mixValue.toString(10);
});
フランジャー
フランジャー は, コーラスの実装にエフェクト音のフィードバックの接続を追加するだけです (ディレイの実装に LFO を追加して,
ディレイタイムを周期的に変化させるとも言えます). つまり, コーラスの実装をより汎用的にした実装となります. パラメータしだいで, フランジャーになり,
コーラスにもなります. 原理的には, 同じなのでこのあたりの区別は, 音楽的な感覚による違いでしかありません.
フランジャーのノード接続図
<button type="button">start</button>
<label for="range-flanger-delay-time">Delay time</label>
<input type="range" id="range-flanger-delay-time" value="0" min="0" max="50" step="1" />
<span id="print-flanger-delay-time-value">0 msec</span>
<label for="range-flanger-depth">Depth</label>
<input type="range" id="range-flanger-depth" value="0" min="0" max="1" step="0.05" />
<span id="print-flanger-depth-value">0</span>
<label for="range-flanger-rate">Rate</label>
<input type="range" id="range-flanger-rate" value="0" min="0" max="10" step="0.5" />
<span id="print-flanger-rate-value">0</span>
<label for="range-flanger-mix">Mix</label>
<input type="range" id="range-flanger-mix" value="0" min="0" max="1" step="0.05" />
<span id="print-flanger-mix-value">0</span>
<label for="range-flanger-feedback">Mix</label>
<input type="range" id="range-flanger-feedback" value="0" min="0" max="0.9" step="0.05" />
<span id="print-flanger-feedback-value">0</span>
const context = new AudioContext();
let oscillator = null;
let lfo = null;
let depthRate = 0;
let rateValue = 0;
let mixValue = 0;
const delay = new DelayNode(context);
const depth = new GainNode(context, { gain: delay.delayTime.value * depthRate });
const dry = new GainNode(context, { gain: 1 - mixValue });
const wet = new GainNode(context, { gain: mixValue });
const feedback = new GainNode(context, { gain: 0 });
const buttonElement = document.querySelector('button[type="button"]');
const rangeDelayTimeElement = document.getElementById('range-flanger-delay-time');
const rangeDepthElement = document.getElementById('range-flanger-depth');
const rangeRateElement = document.getElementById('range-flanger-rate');
const rangeMixElement = document.getElementById('range-flanger-mix');
const rangeFeedbackElement = document.getElementById('range-flanger-feedback');
const spanPrintDelayTimeElement = document.getElementById('print-flanger-delay-time-value');
const spanPrintDepthElement = document.getElementById('print-flanger-depth-value');
const spanPrintRateElement = document.getElementById('print-flanger-rate-value');
const spanPrintMixElement = document.getElementById('print-flanger-mix-value');
const spanPrintFeedbackElement = document.getElementById('print-flanger-feedback-value');
buttonElement.addEventListener('mousedown', (event) => {
if ((oscillator !== null) || (lfo !== null)) {
return;
}
oscillator = new OscillatorNode(context);
lfo = new OscillatorNode(context, { frequency: rateValue });
// Connect nodes for original sound
// OscillatorNode (Input) -> GainNode (Dry) -> AudioDestinationNode (Output)
oscillator.connect(dry);
dry.connect(context.destination);
// Connect nodes for delay sound
// OscillatorNode (Input) -> DelayNode (Delay) -> GainNode (Wet) -> AudioDestinationNode (Output)
oscillator.connect(delay);
delay.connect(wet);
wet.connect(context.destination);
// Connect nodes for feedback
// (OscillatorNode (Input) ->) DelayNode (Delay) -> GainNode (Feedback) -> DelayNode (Delay) -> GainNode (Feedback) -> ...
delay.connect(feedback);
feedback.connect(delay);
// Connect nodes for LFO that changes delay time periodically
// OscillatorNode (LFO) -> GainNode (Depth) -> delayTime (AudioParam)
lfo.connect(depth);
depth.connect(delay.delayTime);
// Start oscillator and LFO immediately
oscillator.start(0);
lfo.start(0);
buttonElement.textContent = 'stop';
});
buttonElement.addEventListener('mouseup', (event) => {
if ((oscillator === null) || (lfo === null)) {
return;
}
// Stop immediately
oscillator.stop(0);
lfo.stop(0);
// GC (Garbage Collection)
oscillator = null;
lfo = null;
buttonElement.textContent = 'start';
});
rangeDelayTimeElement.addEventListener('input', (event) => {
delay.delayTime.value = event.currentTarget.valueAsNumber * 0.001;
depth.gain.value = delay.delayTime.value * depthRate;
spanPrintDelayTimeElement.textContent = `${Math.trunc(delay.delayTime.value * 1000)} msec`;
});
rangeDepthElement.addEventListener('input', (event) => {
depthRate = event.currentTarget.valueAsNumber;
depth.gain.value = delay.delayTime.value * depthRate;
spanPrintDepthElement.textContent = depthRate.toString(10);
});
rangeRateElement.addEventListener('input', (event) => {
rateValue = event.currentTarget.valueAsNumber;
if (lfo) {
lfo.frequency.value = rateValue;
}
spanPrintRateElement.textContent = rateValue.toString(10);
});
rangeMixElement.addEventListener('input', (event) => {
mixValue = event.currentTarget.valueAsNumber;
dry.gain.value = 1 - mixValue;
wet.gain.value = mixValue;
spanPrintMixElement.textContent = mixValue.toString(10);
});
rangeFeedbackElement.addEventListener('input', (event) => {
const feedbackValue = event.currentTarget.valueAsNumber;
feedback.gain.value = feedbackValue;
spanPrintFeedbackElement.textContent = feedbackValue.toString(10);
});
ディレイタイムの周期的な変化とFM 変調
FM 変調 (Frequency Modulation ) とは, 時間の経過とともに信号の周波数を変化させることです.
Time Domain
Frequency Domain (Spectrum)
FM 変調のイメージ (スペクトルのピークが 880 Hz ± 440 Hz
の間で変調します)
start
コーラス・フランジャーは, 結果的に FM 変調を発生させていると解説しましたが, これに対して疑問に思うことがあるかもしれません.
周波数を周期的に変化させるために, なぜ, ディレイタイムを周期的に変化させているのかということです
(ディレイタイムの周期的な変化が原理となっているかということです) LFO の実装例 で解説したように,
直接的に周波数を周期的に変化させればよいはずです. しかしながら, 基本波形のように, 基本周波数が明確な場合はそれで問題ないのですが, アンサンブル
(つまり, 楽曲) や音声において, 一般的に, 基本周波数を (精度高く) 推定するアルゴリズムは複雑になりますし, それにともなって計算量も多くなります
(ちなみに, $f_{0}$ 推定などと呼ばれます). つまり, 汎用的なコーラス・フランジャーを実装するとなると,
直接的に周波数を変化させることは難しくなります.
ここで, コンボリューション積分とフーリエ変換の性質から,
時間領域における遅延は, 周波数領域においては周波数成分の変化となります (数学的な詳細を知る必要はないですが,
巡回畳み込み のセクションが参考になると思います). すなわち,
時間領域においてディレイタイムを周期的に変化させることは, 周波数領域において各周波数成分を周期的に変化させる こととなり, 結果として
(汎用的な) FM 変調となります.
ちなみに, すでにサンプルコードを実行して気づいたかもしれませんが,
コーラス・フランジャーで, エフェクト音のみの出力にした場合, ビブラートとなります . ビブラートはまさに FM 変調であり, 言い換えれば,
コーラス・フランジャーは, ビブラートをベースにしたエフェクターであり, 汎用的な実装とパラメータ設定でビブラートにすることも可能ということです.
フェイザー
フェイザー は, (テキストでは表現しにくいですが) シュワシュワという独特な感じのエフェクトを与えます. パラメータの設定しだいでは,
フランジャーっぽい感じにもなります. 実際, 楽曲を聴くと, フェイザーかフランジャーを使っているかは判断ができないぐらい似ているエフェクターです.
フランジャーに似ているエフェクトでありながら, その原理はまったく異なります. フェイザーは, 特定の周波数帯域の音の位相 を周期的に変化させて,
原音と合成して, 音を干渉 させることによって実装できるエフェクターです. ちなみに, フェイザーの正式な名称は, フェイズ・シフター であり,
まさに, 名称がその原理を表していると言えます.
位相
位相 とは, 時間領域における波の位置のことです (数学的には, ある時刻の複素数平面における偏角と考えることもできます). したがって,
周期性をもつ波の場合, 位相はその周期内の値だけを考慮すれば事足ります (正弦波の場合,
$\sin\theta = \sin\left(\theta + 2\pi\right) = \sin\left(\theta + 4\pi\right) = \cdots = \sin\left(\theta + 2n\pi\right)$
となるので, $2\pi$ の区間の位相を考慮すればよいことになります). また, 正弦波と余弦波は位相の違いでしかありません
($\sin\theta = \cos\left(\theta - \frac{\pi}{2}\right)$ . これは, 位相で考えなくても,
加法定理で数式から導出できることでもあります).
位相 (例: $\theta = \frac{\pi}{4}$ の場合)
位相と周期性から, 位相の変化をイメージすると, 横軸を位相 (単位は radian
: ラジアン) としたときに, 以下のような変化になります.
位相変化のイメージ
start
$+\frac{\pi}{2}$
正弦波の場合, $\frac{\pi}{2}$ シフトすると反転した余弦波,
$\pi$ シフトすると反転した自身の波 (反転した正弦波),
$\frac{3\pi}{2}$ シフトすると余弦波, $2\pi$ シフトすると元の正弦波に戻ります.
フェイザーの原理を理解するうえで, この位相変化のイメージは重要になるのでおさえておいてください.
All-Pass Filter
フェイザーは, 位相を変化させる周波数帯域を周期的に変化させて, 原音と合成して音を干渉させることによって実装できます. つまり,
位相を変化させる ことがフェイザーにとって重要な処理となりますが, All-Pass Filter を使うことで,
対象の周波数成分の位相を変化させることができます (他のフィルタと異なり, すべての周波数成分のゲイン (振幅) を変化させずに通過させるので,
オールパス という名前がついています).
Web Audio API では, BiquadFilterNode
インスタンスの type
プロパティに 'allpass'
を設定することで,
簡単に All-Pass Filter を使うことができます.
const context = new AudioContext();
const oscillator = new OscillatorNode(context, { type: 'sawtooth' });
const allpass = new BiquadFilterNode(context, { type: 'allpass', frequency: 880 });
oscillator.connect(allpass);
allpass.connect(context.destination);
oscillator.start(0);
oscillator.stop(context.currentTime + 2);
位相変化と干渉
ところで, All-Pass Filter を通過させた音, つまり, 位相を変化させただけの音というのは, 特に変化を知覚することはできません.
スペクトル のセクションでも記載しましたが, 人間の聴覚というのは位相の違いには鈍感 だからです. では, なぜ,
フェイザーはエフェクトとして知覚することができるのでしょうか ?
フェイザーは, 位相の変化を知覚しているのではなく, 位相を変化させたエフェクト音と原音を合成することで音波を干渉 させて, 結果として発生する,
振幅の増減やうねり を知覚しているからです.
位相変化と干渉のイメージ
start
$+\frac{\pi}{2}$
上記の正弦波で説明すると, 開始時刻において, 原音とエフェクト音 (ともに, 半透明の青色の波) は, 同じ位相にあります. ここで,
$\theta = \frac{\pi}{2}$ の位相の点に着目すると, 同じ位相なので, 音波が重なり, 合成された結果, 出力音 (マゼンタ色の波) は
$0.5 \cdot \sin\left(\frac{\pi}{2}\right) + 0.5 \cdot \sin\left(\frac{\pi}{2}\right) = 1$
となって振幅が増幅します. エフェクト音の位相を $\frac{\pi}{2}$ ずらすと, 反転した余弦波となり, 位相
$\theta = \frac{\pi}{2}$ でのエフェクト音は
$-0.5 \cdot \cos\left(\frac{\pi}{2}\right) = 0$ となるので, 合成された結果, 出力音の振幅値は,
$0.5 \cdot \sin\left(\frac{\pi}{2}\right) - 0.5\cdot \cos\left(\frac{\pi}{2}\right) = 0.5$ となります. さらに,
エフェクト音の位相を $\frac{\pi}{2}$ (開始時刻を基準にすると, $\pi$ ) ずらすと,
反転した正弦波となり, 位相 $\theta = \frac{\pi}{2}$ でのエフェクト音は
$-0.5 \cdot \sin\left(\frac{\pi}{2}\right) = -0.5$ となるので, 合成された結果の振幅値は,
$0.5 \cdot \sin\left(\frac{\pi}{2}\right) - 0.5 \cdot \sin\left(\frac{\pi}{2}\right) = 0$ となります (位相
$\theta = \frac{\pi}{2}$ に限らず, 反転した正弦波と合成するので, すべての位相においてその振幅は
0
であり, すなわち, 無音となります). そして, エフェクト音の位相を 1 周期分, つまり, $2\pi$ ずらすと,
エフェクト音は再び元の位相に戻るので, 開始時刻と同様の出力音となります.
周期関数なので, 1 周期分以上の位相の変化 ($2\pi$ 以上の位相の変化) においては, 同様の振幅の増減の現象が繰り返し発生します.
このように, 複数の音波を合成した結果, 生じる振幅の増減現象を干渉 と呼びます (実は, これまでも, エフェクターの解説で実装した,
原音とエフェクト音を合成する処理というのも, 物理的な視点では, 音波の干渉です. 音楽的には, このような音を重ねる処理を, 合成, あるいは,
ミキシングと呼ぶので, 合成という用語を優先して使いました).
また, 位相がわずかに異なる時点で音波を重ねることをうねり (うなりとも呼びます) と呼び, 音楽的な効果が高いことが知られています (同様に,
周波数をわずかにずらした音波を重ねた場合でもうねり現象は発生します. 実は, これが原始的なコーラスでもあります).
したがって, 位相を変化させたエフェクト音と原音を合成するような AudioNode
の接続を実装すると以下のようになります.
const context = new AudioContext();
const oscillator = new OscillatorNode(context, { type: 'sawtooth' });
const allpass = new BiquadFilterNode(context, { type: 'allpass', frequency: 880 });
// Connect nodes for original sound
// OscillatorNode (Input) -> AudioDestinationNode (Output)
oscillator.connect(context.destination);
// Connect nodes for shifting phase
// OscillatorNode (Input) -> BiquadFilterNode (All-Pass Filter) -> AudioDestinationNode (Output)
oscillator.connect(allpass);
allpass.connect(context.destination);
oscillator.start(0);
oscillator.stop(context.currentTime + 2);
ところが, この実装では, 位相を変化する周波数成分が固定されたままなので, 干渉による振幅の増減変化が知覚できるほど発生しないので,
フェイザーとして聴こえません. 位相変化させる周波数成分を周期的に変化させるように, LFO を All-Pass Filter の frequency
プロパティ
(AudioParam
) に接続します.
const context = new AudioContext();
const baseFrequency = 880;
const depthValue = 220;
const rateValue = 1;
const oscillator = new OscillatorNode(context, { type: 'sawtooth' });
const allpass = new BiquadFilterNode(context, { type: 'allpass', frequency: baseFrequency });
const lfo = new OscillatorNode(context, { frequency: rateValue });
const depth = new GainNode(context, { gain: depthValue });
// Connect nodes for original sound
// OscillatorNode (Input) -> AudioDestinationNode (Output)
oscillator.connect(context.destination);
// Connect nodes for shifting phase
// OscillatorNode (Input) -> BiquadFilterNode (All-Pass Filter) -> AudioDestinationNode (Output)
oscillator.connect(allpass);
allpass.connect(context.destination);
// Connect nodes for LFO that changes All-Pass Filter frequency periodically
// OscillatorNode (LFO) -> GainNode (Depth) -> frequency (AudioParam)
lfo.connect(depth);
depth.connect(allpass.frequency);
// Start oscillator and LFO
oscillator.start(0);
lfo.start(0);
// Stop oscillator and LFO
oscillator.stop(context.currentTime + 5);
lfo.stop(context.currentTime + 5);
これで, 位相を変化させた音と干渉によって生じる振幅の増減, つまり, フェイザーのエフェクト音を知覚できるようになったと思います. あとは,
コーラスやフランジャーと同様に, 原音とエフェクト音のゲインを制御できるように, Dry / Wet のための GainNode
を接続します.
const context = new AudioContext();
const baseFrequency = 880;
const depthValue = 220;
const rateValue = 1;
const oscillator = new OscillatorNode(context, { type: 'sawtooth' });
const allpass = new BiquadFilterNode(context, { type: 'allpass', frequency: baseFrequency });
const lfo = new OscillatorNode(context, { frequency: rateValue });
const depth = new GainNode(context, { gain: depthValue });
const dry = new GainNode(context, { gain: 0.5 }); // for gain of original sound
const wet = new GainNode(context, { gain: 0.5 }); // for gain of phaser sound
// Connect nodes for original sound
// OscillatorNode (Input) -> GainNode (Dry) -> AudioDestinationNode (Output)
oscillator.connect(dry);
dry.connect(context.destination);
// Connect nodes for shifting phase
// OscillatorNode (Input) -> BiquadFilterNode (All-Pass Filter) -> GainNode (Wet) -> AudioDestinationNode (Output)
oscillator.connect(allpass);
allpass.connect(wet);
wet.connect(context.destination);
// Connect nodes for LFO that changes All-Pass Filter frequency periodically
// OscillatorNode (LFO) -> GainNode (Depth) -> frequency (AudioParam)
lfo.connect(depth);
depth.connect(allpass.frequency);
// Start oscillator and LFO
oscillator.start(0);
lfo.start(0);
// Stop oscillator and LFO
oscillator.stop(context.currentTime + 5);
lfo.stop(context.currentTime + 5);
現実世界のフェイザーは, よりアグレッシブな干渉を発生させるために, All-Pass Filter を複数接続します. 2 個, 4 個, 12 個, 24
個の接続可能なフェイザーが多く, それらは, All-Pass Filter の接続数 $n$ 個によって,
$n$ 段フェイザー と呼ばれることもあります.
以下は, All-Pass Filter を 4 つ接続したフェイザー (4 段フェイザー) の実装です. 1 つだけの接続の場合より, 干渉による変化が大きく聴こえると思います.
const context = new AudioContext();
const baseFrequency = 880;
const depthValue = 220;
const rateValue = 1;
const oscillator = new OscillatorNode(context, { type: 'sawtooth' });
const allpasses = [
new BiquadFilterNode(context, { type: 'allpass', frequency: baseFrequency }),
new BiquadFilterNode(context, { type: 'allpass', frequency: baseFrequency }),
new BiquadFilterNode(context, { type: 'allpass', frequency: baseFrequency }),
new BiquadFilterNode(context, { type: 'allpass', frequency: baseFrequency })
];
const lfo = new OscillatorNode(context, { frequency: rateValue });
const depth = new GainNode(context, { gain: depthValue });
const dry = new GainNode(context, { gain: 0.5 }); // for gain of original sound
const wet = new GainNode(context, { gain: 0.5 }); // for gain of phaser sound
// Connect nodes for original sound
// OscillatorNode (Input) -> GainNode (Dry) -> AudioDestinationNode (Output)
oscillator.connect(dry);
dry.connect(context.destination);
// Connect nodes for shifting phase
// OscillatorNode (Input) -> BiquadFilterNode (All-Pass Filter) x 4 -> GainNode (Wet) -> AudioDestinationNode (Output)
oscillator.connect(allpasses[0]);
for (let i = 0; i < 3; i++) {
allpasses[i].connect(allpasses[i + 1]);
}
allpasses[3].connect(wet);
wet.connect(context.destination);
// Connect nodes for LFO that changes All-Pass Filter frequency periodically
// OscillatorNode (LFO) -> GainNode (Depth) -> frequency (AudioParam)
lfo.connect(depth);
for (let i = 0; i < 4; i++) {
depth.connect(allpasses[i].frequency);
}
// Start oscillator and LFO
oscillator.start(0);
lfo.start(0);
// Stop oscillator and LFO
oscillator.stop(context.currentTime + 5);
lfo.stop(context.currentTime + 5);
フェイザーのノード接続図 (4 段フェイザー)
さらに, アグレッシブなエフェクトを発生させたい場合は, フィードバック接続を追加することも考えられます (ただし, フェイザーにおいて,
フィードバックは一般的に必須というわけではありません). また, レゾナンス (BiquadFilterNode
の Q
プロパティ
(AudioParam
)) を変更可能にすると, フェイザーによりバリエーションを付加することができます (BiquadFilterNode
の
Q
プロパティは, type
プロパティ (フィルタの種類) によって制御しているフィルタの特性が異なるので, 詳細はフィルタのセクション で解説します).
以下は, 実際のアプリケーションを想定して, ユーザーインタラクティブに, All-Pass Filter の接続数や,
位相変化させる周波数成分などフェイザーに関わるパラメータを制御できるようにしたコード例です. フェイザーはその原理から,
エフェクト音のみでは変化を得ることができないので, Mix の値が 1
未満になるように上限を設定していることにも着目してください.
<button type="button">start</button>
<select id="select-phaser-stages">
<option value="2">2 stages</option>
<option value="4" selected >4 stages</option>
<option value="8">8 stages</option>
<option value="12">12 stages</option>
<option value="24">24 stages</option>
</select>
<label for="range-phaser-frequency">Frequency</label>
<input type="range" id="range-phaser-frequency" value="880" min="100" max="4000" step="1" />
<span id="print-phaser-frequency-value">880 Hz</span>
<label for="range-phaser-depth">Depth</label>
<input type="range" id="range-phaser-depth" value="0" min="0" max="1" step="0.05" />
<span id="print-phaser-depth-value">0</span>
<label for="range-phaser-rate">Rate</label>
<input type="range" id="range-phaser-rate" value="0" min="0" max="10" step="0.5" />
<span id="print-phaser-rate-value">0</span>
<label for="range-phaser-resonance">Resonance</label>
<input type="range" id="range-phaser-resonance" value="1" min="1" max="20" step="1" />
<span id="print-phaser-resonance-value">1</span>
<label for="range-phaser-mix">Mix</label>
<input type="range" id="range-phaser-mix" value="0" min="0" max="0.9" step="0.05" />
<span id="print-phaser-mix-value">0</span>
const context = new AudioContext();
let oscillator = null;
let lfo = null;
let numberOfStages = 4;
let baseFrequency = 880;
let depthRate = 0;
let rateValue = 0;
let resonance = 1;
let mixValue = 0;
const allpasses = [
new BiquadFilterNode(context, { type: 'allpass', frequency: baseFrequency }),
new BiquadFilterNode(context, { type: 'allpass', frequency: baseFrequency }),
new BiquadFilterNode(context, { type: 'allpass', frequency: baseFrequency }),
new BiquadFilterNode(context, { type: 'allpass', frequency: baseFrequency })
];
const depth = new GainNode(context, { gain: baseFrequency * depthRate });
const dry = new GainNode(context, { gain: 1 - mixValue });
const wet = new GainNode(context, { gain: mixValue });
const buttonElement = document.querySelector('button[type="button"]');
const selectPhaserStagesElement = document.getElementById('select-phaser-stages');
const rangeFrequencyElement = document.getElementById('range-phaser-frequency');
const rangeDepthElement = document.getElementById('range-phaser-depth');
const rangeRateElement = document.getElementById('range-phaser-rate');
const rangeResonanceElement = document.getElementById('range-phaser-resonance');
const rangeMixElement = document.getElementById('range-phaser-mix');
const spanPrintFrequencyElement = document.getElementById('print-phaser-frequency-value');
const spanPrintDepthElement = document.getElementById('print-phaser-depth-value');
const spanPrintRateElement = document.getElementById('print-phaser-rate-value');
const spanPrintResonanceElement = document.getElementById('print-phaser-resonance-value');
const spanPrintMixElement = document.getElementById('print-phaser-mix-value');
buttonElement.addEventListener('mousedown', (event) => {
if ((oscillator !== null) || (lfo !== null)) {
return;
}
oscillator = new OscillatorNode(context, { type: 'sawtooth' });
lfo = new OscillatorNode(context, { frequency: rateValue });
// Connect nodes for original sound
// OscillatorNode (Input) -> GainNode (Dry) -> AudioDestinationNode (Output)
oscillator.connect(dry);
dry.connect(context.destination);
// Connect nodes for shifting phase
// OscillatorNode (Input) -> BiquadFilterNode (All-Pass Filter) x N -> GainNode (Wet) -> AudioDestinationNode (Output)
oscillator.connect(allpasses[0]);
for (let i = 0; i < (numberOfStages - 1); i++) {
allpasses[i].connect(allpasses[i + 1]);
}
allpasses[numberOfStages - 1].connect(wet);
wet.connect(context.destination);
// Connect nodes for LFO that changes All-Pass Filter frequency periodically
// OscillatorNode (LFO) -> GainNode (Depth) -> frequency (AudioParam)
lfo.connect(depth);
for (let i = 0; i < numberOfStages; i++) {
depth.connect(allpasses[i].frequency);
}
// Start oscillator and LFO immediately
oscillator.start(0);
lfo.start(0);
buttonElement.textContent = 'stop';
});
buttonElement.addEventListener('mouseup', (event) => {
if ((oscillator === null) || (lfo === null)) {
return;
}
// Stop immediately
oscillator.stop(0);
lfo.stop(0);
// GC (Garbage Collection)
oscillator = null;
lfo = null;
buttonElement.textContent = 'start';
});
selectPhaserStagesElement.addEventListener('change', (event) => {
numberOfStages = Number(event.currentTarget.value);
for (let i = 0, len = allpasses.length; i < len; i++) {
allpasses[i].disconnect(0);
}
for (let i = 0; i < numberOfStages; i++) {
allpasses[i] = new BiquadFilterNode(context, { type: 'allpass', frequency: baseFrequency });
}
if (oscillator !== null) {
oscillator.connect(allpasses[0]);
}
for (let i = 0; i < (numberOfStages - 1); i++) {
allpasses[i].connect(allpasses[i + 1]);
}
allpasses[numberOfStages - 1].connect(wet);
wet.connect(context.destination);
for (let i = 0; i < numberOfStages; i++) {
depth.connect(allpasses[i].frequency);
}
});
rangeFrequencyElement.addEventListener('input', (event) => {
baseFrequency = event.currentTarget.valueAsNumber;
for (let i = 0; i < numberOfStages; i++) {
allpasses[i].frequency.value = baseFrequency;
}
spanPrintFrequencyElement.textContent = `${Math.trunc(baseFrequency)} Hz`;
});
rangeDepthElement.addEventListener('input', (event) => {
depthRate = event.currentTarget.valueAsNumber;
depth.gain.value = baseFrequency * depthRate;
spanPrintDepthElement.textContent = depthRate.toString(10);
});
rangeRateElement.addEventListener('input', (event) => {
rateValue = event.currentTarget.valueAsNumber;
if (lfo) {
lfo.frequency.value = rateValue;
}
spanPrintRateElement.textContent = rateValue.toString(10);
});
rangeResonanceElement.addEventListener('input', (event) => {
resonance = event.currentTarget.valueAsNumber;
for (let i = 0; i < numberOfStages; i++) {
allpasses[i].Q.value = resonance;
}
spanPrintResonanceElement.textContent = resonance.toString(10);
});
rangeMixElement.addEventListener('input', (event) => {
mixValue = event.currentTarget.valueAsNumber;
dry.gain.value = 1 - mixValue;
wet.gain.value = mixValue;
spanPrintMixElement.textContent = mixValue.toString(10);
});
トレモロ・リングモジュレーター
トレモロ は, 音の大きさに揺らぎを与えるエフェクターです. ギターでは素早くピッキングを繰り返して,
音が震えているように奏でるトレモロ奏法があります. トレモロは, トレモロ奏法をオーディオ信号処理によって実現するエフェクターとも言えます (ギターでは,
もう 1 つトレモロという名称がついているものがあります. ストラトキャスター (タイプ) のギターに搭載されているトレモロアームです. しかし,
トレモロアームは, ビブラートの効果を与えるものなので, 同じトレモロという名称ですが, 効果としては別なものです).
リングモジュレーター は, 金属的な音色に変化させるエフェクトです. 例えば, ピアノにリングモジュレーターをかけると, 鐘のような音色に変化します.
エフェクターとして, トレモロとリングモジュレーターはかなり感じが違いますが, 原理は共通しています. その原理とは,
AM 変調 を利用したエフェクターであることです.
トレモロ
トレモロは振幅 (音の大きさ) を周期的に変化させることによって, 実装することができます. つまり, LFO を, GainNode
の
gain
プロパティ (AudioParam
) に接続することによって実装できます. トレモロは, これまで解説したディレイ・リバーブ,
コーラス・フランジャー, フェイザーなどと異なり, 原音を変化させるので, AudioNode
の接続も実装も非常にシンプルです (トレモロのようのな,
原音を直接変化させるエフェクターをインサートエフェクト と呼ぶことがあります).
トレモロのノード接続図
const context = new AudioContext();
const depthValue = 0.25;
const rateValue = 2.5;
const amplitude = new GainNode(context, { gain: 0.5 }); // 0.5 +- ${depthValue}
const oscillator = new OscillatorNode(context);
const lfo = new OscillatorNode(context, { frequency: rateValue });
const depth = new GainNode(context, { gain: depthValue });
// Connect nodes
// OscillatorNode (Input) -> GainNode (Amplitude) -> AudioDestinationNode (Output)
oscillator.connect(amplitude);
amplitude.connect(context.destination);
// Connect nodes for LFO that changes gain periodically
// OscillatorNode (LFO) -> GainNode (Depth) -> gain (AudioParam)
lfo.connect(depth);
depth.connect(amplitude.gain);
// Start oscillator and LFO
oscillator.start(0);
lfo.start(0);
// Stop oscillator and LFO
oscillator.stop(context.currentTime + 5);
lfo.stop(context.currentTime + 5);
トレモロは, 振幅 1
を基準に値が変化するように定義されています ($f_{s}$ はサンプリング周波数).
しかしながら, そのまま実装すると, 実際には音割れ (クリッピング) が発生してしまうので, 実装では, 0.5
を基準に, Depth
の値が増減するようにしています.
$y\left(n\right) = \left(1 + 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);
});
リンクモジュレーター
リングモジュレーター は, 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);
});
AM 変調
AM 変調 (Amplitude Modulation ) とは, 時間の経過とともに信号の振幅を変化させることです.
Time Domain
Frequency Domain (Spectrum)
AM 変調のイメージ
start
Rate
1 Hz
変調の周期を短くしていくと (LFO の Rate を高くしていくと), 原音の周波数成分だけではなく, LFO の周波数も周波数成分として発生します. これは,
原音の波形がキャリア (搬送波 ) となって, LFO の正弦波がモジュレーター となってエンベロープを形成して周波数成分となるからです
(出力音のエンベロープが正弦波になっていることに着目してください).
この仕組みを発展させた音合成が, FM シンセサイザー (FM 音源) で, キャリア (搬送波) とモジュレーターの波形を正弦波として,
それらを合成する正弦波によって定義されます ($A$ はキャリアの振幅,
$f_{c}$ はキャリアの周波数, $\beta$ は変調指数 (LFO の Depth に相当),
$f_{m}$ はモジュレーターの周波数 (LFO の Rate に相当)).
$y\left(n\right) = A \cdot \sin\left(\frac{2\pi \cdot f_{c} \cdot n}{f_{s}} + \left(\beta \cdot \sin\left(\frac{2\pi \cdot f_{m} \cdot
n}{f_{s}}\right)\right)\right)$
(命名的に混同しますが) リングモジュレーター自体の原理は AM 変調であり, それが, FM シンセサイザーの原理になっているということです.
フィルタ
フィルタという言葉は日常生活でも使われますし, コンピューターサイエンスにおいても, UNIX 系 OS でパイプとフィルタがあります. フィルタの概念としては,
ある結果を遮断して, ある結果を通過させるということでしょう.
オーディオ信号処理におけるフィルタ も同様に, ある周波数成分の音を通過・遮断, あるいは, 増幅・減衰させて, 周波数特性を変化させます.
フィルタだけをエフェクターとして使うことはあまりなく, すでに解説したフェイザーや, このあとのセクションで解説する,
イコライザーやワウなどフィルタ系のエフェクターで使われることが多いです.
また, 音響特徴量はスペクトル, つまり, 周波数成分として表れることが多いので, 音の加工においてもフィルタを理解することは重要となります.
デシベル
フィルタの解説において, 仕様上, デシベル (dB ) という単位が使われるので (BiquadFilterNode
の
gain
プロパティやフィルタの特性グラフなど), フィルタに限ったことではないですが, ここで解説をします.
デシベルとは, 端的には, 音圧レベル を表す単位です. 音圧 とは, 音の実体である媒体の振動によって伝わる圧力 (力) のことです.
ここで, 基準の音圧 ($P_{0} = 2 \cdot 10^{-5} \mathrm{[Pa]}$ ) を基準 (ちなみに, この基準の音圧は,
1 KHz
における可聴な最小の音圧とされています) に, 対象の音の音圧 $P$ の比率の対数をとった値
(以下の定義式. 上は時間領域, 下は周波数領域での定義式. 多くのケースにおいて, 音圧パワー (音圧の 2 乗) の比を算出することが有用なので,
各音圧の 2 乗の比で定義しています) が音圧レベルとなります.
$
\begin{flalign}
&10\log_{10}\left(\frac{P}{P_{0}}\right)^{2} = 20\log_{10}\left(\frac{P}{P_{0}}\right) \quad \left(P_{0} = 2 \cdot 10^{-5} \mathrm{[Pa]}\right) \\
& \\
&10\log_{10}\left|X\left(k\right)\right|^{2} = 20\log_{10}\left|X\left(k\right)\right| \\
& \\
\end{flalign}
$
対数で表す理由は大きく 2 つあります.
音圧は非常に広範囲な値となるので, 人間の感覚とうまく対応ない
フェヒナーの法則 という心理学の理論で, 人間の感覚量は刺激強度の対数に比例する という法則が適用できる
(以下の表を参考にして) 6 dB
音圧レベルが大きくなれば音圧は約 2 倍 ($20\log_{10}2$ ) になります.
20 dB
が大きくなれば 10 倍 ($20\log_{10}10$ ), 40 dB
大きくなれば 100 倍 ($20\log_{10}100$ ) ... という関係で, 本来であれば広範囲におよぶ値を対数をとることによって解決しています.
デシベル差と倍率
Difference decibel
Magnification
Example
0 dB
1 倍
人間の聴力の限界
6 dB
2 倍
10 dB
3 倍
20 dB
10 倍
木の葉のふれあう音
40 dB
100 倍
図書館
60 dB
1,000 倍
会話
80 dB
10,000 倍
目覚まし時計
100 dB
100,000 倍
電車のガード下
120 dB
1,000,000 倍
飛行機のエンジン付近
フィルタの特性を表すグラフでは, 0 dB
を基準に見ていただくのがよいのですが, これは,
0 dB
が入力音と出力音の振幅比が変わらない (つまり, そのまま通過させる) ことを意味しているからです ($20\log_{10}1 = 0$ ). これを理解しておくと, BiquadFilterNode
の gain
プロパティが (特定のフィルタの種類において)
ある周波数成分を増幅させたり, 減衰させたりすることも理解できると思います.
BiquadFilterNode
Web Audio API において, 様々なフィルタを簡単に利用するには BiquadFilterNode
を使うのが最適です. Biquad とは,
双 2 次 という意味で, BiquadFilterNode
の次数は 2 次となります (以下の伝達関数で定義されています). したがって,
BiquadFilterNode
では実装できないフィルタ, 具体的には, 奇数次
のフィルタを使いたい場合, あとのセクションで解説する
IIRFilterNode
を使う必要があります (BiquadFilterNode
は 2 次の IIR フィルタです).
BiquadFilterNode
では, フィルタの特性に関わるプロパティとして, type
プロパティ (BiquadFilterType
), frequency
/ detune
プロパティ (どちらも AudioParam
), Q
プロパティ
(AudioParam
), gain
プロパティ (AudioParam
) が定義されています. type
プロパティ以外は,
type
プロパティ (すなわち, フィルタの種類) によって, 制御するフィルタの特性が異なったり, あるいは, そもそも無効だったりするので,
BiquadFilterNode
で使える 8 つのフィルタの種類ごとに解説を進めます.
フィルタの種類に関わらず, BiquadFilterNode
は, そのインスタンスを接続するだけで機能します (また, コンストラクタ形式であれば,
インスタンス生成時に, 第 2 引数に BiquadFilterOptions を指定して, 初期値を変更することも可能です).
const context = new AudioContext();
const oscillator = new OscillatorNode(context, { type: 'sawtooth' });
const filter = new BiquadFilterNode(context);
// If use `createBiquadFilter`
// const filter = context.createBiquadFilter();
// OscillatorNode (Input) -> BiquadFilterNode -> AudioDestinationNode (Output)
oscillator.connect(filter);
filter.connect(context.destination);
oscillator.start(0);
frequency / detune プロパティ
フィルタの種類に関わらず, frequency
/ detune
プロパティはすべてのフィルタにおいて有効になります (ただし,
フィルタの特性への影響はフィルタの種類ごとに異なります). OscillatorNode
などと同じように, frequency
プロパティと
detune
プロパティを合わせて算出される周波数 ($f_{\mathrm{computed}}\left(t\right)$ )
は以下のように定義されています.
$f_{\mathrm{computed}}\left(t\right) = \mathrm{frequency}\left(t\right) \cdot \mathrm{pow}\left(2, \left(\mathrm{detune}\left(t\right) / 1200
\right)\right)$
frequency
/ detune
プロパティ, Q
プロパティ, gain
プロパティは, すべて
AudioParam
なので, オートメーションさせたり, LFO を接続したりすることが可能です.
BiquadFilterNode の定義式
時間領域での BiquadFilterNode
の定義式 (2 次の IIR フィルタ) は, 以下のように定義されています.
$a_{0}y\left(n\right) + a_{1}y\left(n - 1\right) + a_{2}y\left(n - 2\right) = b_{0}x\left(n\right) + b_{1}x\left(n - 1\right) + b_{2}x\left(n -
2\right)$
これを $z$ 変換すると, 伝達関数 (周波数領域での BiquadFilterNode
の定義式) は,
以下のように定義されます (時間領域の遅延は, $z$ 変換の次数となります).
$
\begin{flalign}
&H\left(z\right) = \frac{\frac{b_{0}}{a_{0}} + \frac{b_{1}}{a_{0}}z^{-1} + \frac{b_{2}}{a_{0}}z^{-2}}{1 + \frac{a_{1}}{a_{0}}z^{-1} + \frac{a_{2}}{a_{0}}z^{-2}}
\end{flalign}
$
Biquad Filter (双 2 次フィルタ) はオーディオ信号処理で頻繁に利用される, Robert Bristow-Johnson 氏が書いた有名な設計手法の解説, Audio-EQ-Cookbook
というのがあり,
W3C のドキュメント としても公開されています. Web
Audio API の BiquadFilterNode
も Audio EQ Cookbook をベースにした実装になっています (厳密には, 多少の改変がされています).
IIR フィルタに関してはあとのセクションで解説します.
Low-Pass Filter
Low-Pass Filter (低域通過フィルタ ) とは, カットオフ周波数 ($f_{\mathrm{computed}}$ )
付近までの周波数成分を通過させ, それより大きい周波数成分を遮断するフィルタです. すでに解説しましたが, サンプリング定理のために, A/D 変換や D/A
変換で使われたり, エフェクターのワウで使われたり, エフェクト音のトーンを設定したり, おそらく最も使用頻度の高いフィルタになります
(おそらくその理由で, デフォルト値になっていると思われます).
Low-Pass Filter における, Q
プロパティ (クオリティファクタ , または, レゾナンス と呼ばれることが多いです) は,
カットオフ周波数付近の急峻を変化させます. 正の値にすると, 急峻が鋭くなって, カットオフ周波数付近の周波数成分を増幅させます (これは,
ワウの実装において重要になる点です). 負の値を設定すると, カットオフ周波数付近の周波数成分を減衰させるフィルタ特性になります.
Low-Pass Filter においては, gain
プロパティは無効で, フィルタ特性に影響を与えることはありません.
frequency
350 Hz
detune
0 cent
Q
1 dB
Low-Pass Filter のフィルタ特性
High-Pass Filter
High-Pass Filter (高域通過フィルタ ) とは, Low-Pass Filter と逆で, カットオフ周波数 ($f_{\mathrm{computed}}$ ) 付近までの周波数成分を遮断して, それより大きい周波数成分を通過させるフィルタです. Low-Pass Filter と比較すると, 使用頻度は低いですが,
プリアンプ (アンプシミュレーター) や歪み系のエフェクターの実装では重要なフィルタとなります.
High-Pass Filter における, Q
プロパティは, Low-Pass Filter と同様に, カットオフ周波数付近の急峻を変化させます.
正の値にすると, 急峻が鋭くなり, カットオフ周波数付近の周波数成分を増幅させます. 負の値を設定すると,
カットオフ周波数付近の周波数成分を減衰させるフィルタ特性になります.
High-Pass Filter においても, gain
プロパティは無効で, フィルタ特性に影響を与えることはありません.
frequency
350 Hz
detune
0 cent
Q
1 dB
High-Pass Filter のフィルタ特性
Low-Shelving Filter
Low-Shelving Filter とは, カットオフ周波数 ($f_{\mathrm{computed}}$ ) 付近までの周波数成分を増幅,
または, 減衰させ, それより大きい周波数成分をそのまま通過させるフィルタです. Low-Shelving Filter における, gain
プロパティが,
増幅, または, 減衰の値を決定します. 単位は, デシベル (dB
) です.
Low-Shelving Filter においては, Q
プロパティは無効で, フィルタ特性に影響を与えることはありません (一般的な, Biquad Filter
においては, フィルタ特性に影響しますが, Web Audio API の BiquadFilterNode
でやや実装が改変されている点の 1 つです).
frequency
350 Hz
detune
0 cent
gain
0 dB
Low-Shelving Filter のフィルタ特性
High-Shelving Filter
High-Shelving Filter とは, カットオフ周波数 ($f_{\mathrm{computed}}$ )
付近までの周波数成分をそのまま通過させ, それより大きい周波数成分を増幅, または, 減衰させるフィルタです. High-Shelving Filter における,
gain
プロパティが, 増幅, または, 減衰の値を決定します. 単位は, デシベル (dB
) です.
High-Shelving Filter においても, Q
プロパティは無効で, フィルタ特性に影響を与えることはありません (一般的な, Biquad Filter
においては, フィルタ特性に影響しますが, Web Audio API の BiquadFilterNode
でやや実装が改変されている点の 1 つです).
frequency
350 Hz
detune
0 cent
gain
0 dB
High-Shelving Filter のフィルタ特性
Peaking Filter
Peaking Filter とは, 中心周波数 ($f_{\mathrm{computed}}$ ) 付近の周波数成分を増幅, または,
減衰させ, それ以外の周波数成分をそのまま通過させるフィルタです. Peaking Filter における, gain
プロパティが, 増幅, または,
減衰の値を決定します. 単位は, デシベル (dB
) です.
Peaking Filter における, Q
プロパティは, Band-Pass Filter と同様に, 中心周波数を基準にした帯域幅に影響を与えます.
Q
プロパティの値を大きくするほど, 中心周波数付近の帯域幅が狭くなります (急峻になります).
0 以下の値を設定すると, 中心周波数として機能しなくなるので, 正の値を指定するようにします .
frequency
350 Hz
detune
0 cent
Q
1
gain
0 dB
Peaking Filter のフィルタ特性
Notch Filter
Notch Filter (帯域除去フィルタ ) とは, 中心周波数 ($f_{\mathrm{computed}}$ )
付近の周波数成分を遮断して, それ以外の周波数成分を通過させるフィルタです. (厳密には, その定義が異なる点はありますが)
Band-Elimination Filter (帯域阻止フィルタ ) と呼ばれることもあります. また, 実装的には, Low-Pass Filter と High-Pass Filter
を組み合わせることでも実装は可能です.
Notch Filter における, Q
プロパティは, Band-Pass Filter と同様に, 中心周波数を基準にした帯域幅に影響を与えます.
Q
プロパティの値を大きくするほど, 中心周波数付近の帯域幅が狭くなります (急峻になります).
0 以下の値を設定すると, 中心周波数として機能しなくなるので, 正の値を指定するようにします .
対となる Band-Pass Filter と比較すると, 同じ Q
プロパティの値でも, 中心周波数付近の帯域幅が狭くなっています.
Notch Filter においても, gain
プロパティは無効で, フィルタ特性に影響を与えることはありません.
frequency
350 Hz
detune
0 cent
Q
1
Notch Filter のフィルタ特性
All-Pass Filter
All-Pass Filter (全域通過フィルタ ) とは, 振幅特性は変化させずに, 中心周波数 ($f_{\mathrm{computed}}$ ) 付近の周波数成分の位相特性を変化させるフィルタです . したがって, フィルタ特性のグラフも, All-Pass Filter のみは,
位相スペクトル (縦軸が, 位相で単位は radian
) となっています (振幅特性が変わらないので, 振幅スペクトルで表示すると,
パラメータを変化させてもフィルタ特性は変わりません).
中心周波数では, $\pm \pi$ で最も位相が変化し ($\pm \pi$ 位相変化すると,
逆位相となります), 中心周波数から離れる周波数成分ほど, ほとんど位相は変化しなくなります.
All-Pass Filter における, Q
プロパティは, 中心周波数付近の急峻に影響を与えます.
Q
プロパティの値を大きくするほど, 中心周波数付近のフィルタ特性が急峻になって,
それ以外の周波数成分の位相特性に影響を与えなくなります. つまり, 位相特性を変化させる周波数帯域をより狭くします.
0 以下の値を設定すると, 中心周波数として機能しなくなるので, 正の値を指定するようにします .
All-Pass Filter においては, gain
プロパティは無効で, フィルタ特性に影響を与えることはありません.
frequency
350 Hz
detune
0 cent
Q
1
All-Pass Filter のフィルタ特性 (位相スペクトル)
IIRFilterNode
BiquadFilterNode
では実装できない IIR フィルタを実装する場合, 次の手段としては,
IIRFilterNode
クラスを利用することです (最後の手段は, AudioWorklet で実装することです).
IIRFilterNode
では, BiquadFilterNode
でフィルタの特性に影響を与えていた, frequency
プロパティや
Q
プロパティ, gain
プロパティなどは, リアルタイムに変化させることができなくなる点には注意してください.
IIRFilterNode
に与えるパラメータは, AudioParam
ではないからです.
実装としては, IIRFilterNode
コンストラクタの第 2 引数に, IIRFilterOptions
として,
フィルタの係数の配列を設定します. IIRFilterOptions
オブジェクトの feedforward
プロパティは,
IIR フィルタの伝達関数の分子となる係数 (以下の伝達関数の $b_{m}$ ),
feedback
プロパティは, IIR フィルタの伝達関数の分母となる係数 (以下の伝達関数の
$a_{n}$ ) をそれぞれ設定します (ファクトリメソッドの場合, 第 1 引数に feedforward
, 第 2 引数に
feedback
を指定します). IIRFilterNode
の伝達関数は以下の定義式となります.
BiquadFilterNode
の伝達関数と異なり, フィルタの次数を自由に設定できる点に着目してください.
$
\begin{flalign}
&H\left(z\right) = \frac{\sum_{m=0}^{M}b_{m}z^{-m}}{\sum_{n=0}^{N}a_{n}z^{-n}}
\end{flalign}
$
ただし, まったく制約がないわけではなく, 0 次のフィルタはエラーとなります (それ以外にも, $a_{0}$ は,
0
以外の値である必要があったり, 係数がすべて 0
の feedforward
はエラーとなったりします). また, 実装上,
20 次までのフィルタが上限となります .
簡易的ではありますが, 1 次の IIR フィルタによる, Low-Pass Filter と High-Pass Filter の実装例です.
const context = new AudioContext();
const cutoff = 1000; // 1000 Hz
const b = (cutoff / context.sampleRate) * Math.PI;
const b0 = b;
const b1 = b;
const a0 = 1 + b;
const a1 = -1 + b;
const feedforward = new Float64Array([b0, b1]);
const feedback = new Float64Array([a0, a1]);
const oscillator = new OscillatorNode(context, { type: 'sawtooth' });
const filter = new IIRFilterNode(context, { feedforward, feedback });
// If use `createIIRFilter`
// const filter = context.createIIRFilter(feedforward, feedback);
// OscillatorNode (Input) -> IIRFilterNode (Low-Pass Filter) -> AudioDestinationNode (Output)
oscillator.connect(filter);
filter.connect(context.destination);
oscillator.start(0);
const context = new AudioContext();
const cutoff = 4000; // 4000 Hz
const a = (cutoff / context.sampleRate) * Math.PI;
const b0 = 1;
const b1 = -1;
const a0 = 1 + a;
const a1 = -1 + a;
const feedforward = new Float64Array([b0, b1]);
const feedback = new Float64Array([a0, a1]);
const oscillator = new OscillatorNode(context, { type: 'sawtooth' });
const filter = new IIRFilterNode(context, { feedforward, feedback });
// If use `createIIRFilter`
// const filter = context.createIIRFilter(feedforward, feedback);
// OscillatorNode (Input) -> IIRFilterNode (High-Pass Filter) -> AudioDestinationNode (Output)
oscillator.connect(filter);
filter.connect(context.destination);
oscillator.start(0);
IIR フィルタ
IIR フィルタ (Infinite Impulse Response filter ) は, 以下の数式で定義されるデジタルフィルタです.
$
\begin{flalign}
&y\left(n\right) = \sum_{m = 0}^{J}b\left(m\right)x\left(n - m\right) - \sum_{m = 1}^{I}a\left(m\right)y\left(n - m\right)
\end{flalign}
$
フィルタを通過した音が再度フィルタを通ることになる, フィードバックがある ことが, FIR フィルタと大きく異なる点です.
定義式上は無限にインパルス応答が続くことになるので, Infinite (無限の) と命名されています (もちろん,
コンピュータでは無限のフィルタを実装することはできないので, 有限の次数でうちきる必要があります. IIRFilterNode
の場合, その上限が
20
ということです). また, フィードバックの項 ($a\left(m\right)$ ) は,
$m = 1$ から始まっている点に着目してください.
フィードバックがある利点は, 次数の低いフィルタでも性能のよいフィルタ, つまり, 通過する周波数成分と遮断する周波数成分を可能な限りはっきりと分ける
(理想フィルタ に近づける) フィルタが低次数で実装できます (実際, BiquadFilterNode
の次数は 2 次です).
具体的に, 2 次の IIR フィルタ ($J = I = 2$ ) を加算器・乗算器・遅延器の要素を利用してブロック図として表現します.
IIR フィルタ
理想フィルタと遷移帯域幅
アナログフィルタでは, カットオフ周波数や中心周波数を境に, 通過する周波数成分と阻止される周波数成分がはっきりと分離されます. しかし,
BiquadFilterNode
のセクションで表示しているフィルタ特性のように, デジタルフィルタにおいては, はっきりと分離されることなく,
曖昧な帯域が存在することがわかるかと思います. これは, アナログフィルタにおいては, 無限の区間で定義されるフィルタを,
コンピュータでは無限の区間をあつかうことはできないので, 有限の区間でうちきる必要があるからです. その処理によって,
どうしても曖昧な帯域が発生してしまいます. これを遷移帯域幅 と言います. つまり, デジタルフィルタで, アナログフィルタのような,
遷移帯域幅のない理想フィルタ を実装することはコンピュータの原理上, 不可能となります. しかしながら, 可能な限り遷移帯域幅を小さくして,
アナログフィルタ (理想フィルタ) に近づけることは可能であり,
理想フィルタに近いフィルタが, デジタルフィルタにおいて性能のよいフィルタの重要な指標となります . そして, IIR フィルタは低次数で,
性能のよいフィルタ, すなわち, 理想フィルタに近いフィルタを実装することが可能です.
FIR フィルタと IIR フィルタの伝達関数
これまで, 伝達関数 という用語をそれとなく使っていましたが, ここで詳細を解説します. 伝達関数とは, その名のとおり,
伝わりやすさ を数学の関数として定義したものです. オーディオ信号処理に限定して定義すると,
入力音のスペクトルに対する出力音のスペクトルの比の関数 で定義できます.
FIR フィルタの場合, $x\left(n\right)$ , $y\left(n\right)$ ,
$b\left(m\right)$ をそれぞれ離散フーリエ変換した関数を $X\left(k\right)$ ,
$Y\left(k\right)$ , $H\left(k\right)$ とすると,
$
\begin{flalign}
&y\left(n\right) = \sum_{m = 0}^{N}b\left(m\right)x\left(n - m\right)
\end{flalign}
$
時間領域でのコンボリューション積分は周波数領域では乗算となるので,
$
\begin{flalign}
&Y\left(k\right) = H\left(k\right)X\left(k\right)
\end{flalign}
$
$H\left(k\right)$ が伝達関数となるので, 式を変形すると, 入出力スペクトルの比になることがわかります.
$
\begin{flalign}
&H\left(k\right) = \frac{Y\left(k\right)}{X\left(k\right)}
\end{flalign}
$
IIR フィルタも同様に, $x\left(n\right)$ , $y\left(n\right)$ ,
$b\left(m\right)$ , $a\left(m\right)$ をそれぞれ離散フーリエ変換した関数を
$X\left(k\right)$ , $Y\left(k\right)$ ,
$B\left(k\right)$ , $A\left(k\right)$ とすると,
$
\begin{flalign}
&y\left(n\right) = \sum_{m = 0}^{J}b\left(m\right)x\left(n - m\right) - \sum_{m = 1}^{I}a\left(m\right)y\left(n - m\right)
\end{flalign}
$
時間領域でのコンボリューション積分は周波数領域では乗算となるので,
$
\begin{flalign}
&Y\left(k\right) = B\left(k\right)X\left(k\right) - A\left(k\right)Y\left(k\right)
\end{flalign}
$
ここで, IIR フィルタの伝達関数を $H\left(k\right)$ として, 式変形すると,
$
\begin{flalign}
&H\left(k\right) = \frac{Y\left(k\right)}{X\left(k\right)} = \frac{B\left(k\right)}{1 + A\left(k\right)}
\end{flalign}
$
FIR フィルタと異なり, フィードバックがあるので, その伝達関数は分数式として表現されます (IIRFilterNode
の
$a_{0}$ が 0
以外の値でなければならない理由です).
ところで, 伝達関数をオーディオ信号処理に限らずに, 数学的に一般化すると (複素平面へ拡張すると), 離散フーリエ変換ではなく,
$z$ 変換 したあるシステムへの入出力比の関数となります (例えば, RIR は, ある室内をシステムとみなして,
システムへの入力をインパルス音とした場合の出力ということに特化して説明できます).
$z$ 変換での FIR フィルタ, IIR フィルタの伝達関数は以下のようになります.
$
\begin{flalign}
&H\left(z\right) = \frac{Y\left(z\right)}{X\left(z\right)} \quad (FIR) \\
&H\left(z\right) = \frac{B\left(z\right)}{1 + A\left(z\right)} \quad (IIR) \\
\end{flalign}
$
言い換えると, $z$ 変換での伝達関数を, 物理的な音のスペクトル比に特化すると,
離散フーリエ変換での入出力比が伝達関数になると言えます.
また, 音の伝達特性 (周波数特性) は, RIR 以外にも, 声道フィルタや HRTF (頭部伝達関数), スピーカーキャビネットの響きなどがあります.
イコライザー
フィルタの組み合わせのみでできるエフェクターとして, イコライザー があります. 元々は, アナログ方式で録音されていた時代に,
振幅が大きくなってしまう低音域や振幅が小さくなってしまう高音域を等しくする (equalize) 用途で使われていましたが, 現在では,
積極的に音を加工するエフェクターとして使われています. 楽器演奏や音楽制作ではもちろんですが, 音楽プレイヤーでもイコライザーは標準的に実装されており,
音楽を聴く場合にもバリエーションを与えています.
音楽プレイヤーのイコライザー (macOS Music アプリ イコライザー)
イコライザーにはいくつか種類がありますが, 頻繁に使われるイコライザーとして, 低音域・中音域・高音域の 3 つの帯域を強調・減衰可能な
3 バンドイコライザー (ギターアンプなどでは, 超高音域が追加されているイコライザーも多くあります) と, 10
帯域ぐらいをきめ細かく強調・減衰可能なグラフィックイコライザー があります.
このセクションでは, BiquadFilterNode
を組み合わせて, 3 バンドイコライザーとグラフィックイコライザーの実装を解説します. また,
イコライザーでは, 特定の周波数帯域を強調することをブースト , 減衰させることをカット と呼ぶことが多いので,
これ以降はこれらの用語を使うことにします.
3 バンドイコライザー
3 バンドイコライザー は, 低音域・中音域・高音域の 3 つの帯域をブースト・カット可能なイコライザーですが, 実装としては, Low-Shelving Filter,
Peaking Filter, High-Shelving Filter を使うだけで実装できます. つまり, 3 つの帯域を制御する BiquadFilterNode
インスタンスを生成して,
接続することで実装可能です. このとき, それぞれのフィルタの
$f_{\mathrm{computed}}$ は厳密に決まっているわけではありませんが, 以下のような値が設定されることが多いようです.
Low-Shelving Filter (低音域)
250 Hz
~ 500 Hz
Peaking Filter (中音域)
1000 Hz
~ 2000 Hz
High-Shelving Filter (高音域)
4000 Hz
~ 8000 Hz
また, Peaking Filter のみ, Q
プロパティの値を設定可能ですが, これも厳密に決まっているわけではありませんが, コード例としては
$\frac{1}{\sqrt{2}}$ を設定しています.
3 バンドイコライザーのノード接続図
現実世界のイコライザーでは, それぞれ 3 つのフィルタの gain
プロパティの値を変更して, ブースト・カットします. また, それらのパラメータ
(gain
プロパティの値) は, 低音域は Bass , 中音域は Middle , 高音域は
Treble として制御可能になっているイコライザーがほとんどです (ちなみに, 超高音域がある場合, Presence となっています).
Bass
0 dB
Middle
0 dB
Treble
0 dB
3 バンドイコライザーのフィルタ特性
以下は, 上記のフィルタ特性となる 3 バンドイコライザーを実際のアプリケーションを想定して, ユーザーインタラクティブに, 3
つの周波数帯域をブースト・カットできるようにしたコード例です. 原音を直接変化させるインサートエフェクトなので, 本質的な実装は, 3 つの帯域を制御する
BiquadFilterNode
インスタンスの接続処理です. 現実世界の 3 バンドイコライザーでは, ユーザーが操作できるパラメーターではありませんが,
$f_{\mathrm{computed}}$ の値や Peaking Filter の Q
プロパティの値なども変更してみて,
好みの値を探索してみるのもよいと思います.
<button type="button">start</button>
<label>
<input type="checkbox" id="checkbox-3-bands-equalizer" checked />
<span id="print-checked-3-bands-equalizer">ON</span>
</label>
<label>
<span>OscillatorNode frequency</span>
<input type="range" id="range-3-bands-equalizer-oscillator-frequency" value="440" min="27.5" max="4000" step="0.5" />
<span id="print-3-bands-equalizer-oscillator-frequency-value">440 Hz</span>
</label>
<label for="range-3-bands-equalizer-bass">Bass</label>
<input type="range" id="range-3-bands-equalizer-bass" value="0" min="-24" max="24" step="1" />
<span id="print-3-bands-equalizer-bass-value">0 dB</span>
<label for="range-3-bands-equalizer-middle">Middle</label>
<input type="range" id="range-3-bands-equalizer-middle" value="0" min="-24" max="24" step="1" />
<span id="print-3-bands-equalizer-middle-value">0 dB</span>
<label for="range-3-bands-equalizer-treble">Treble</label>
<input type="range" id="range-3-bands-equalizer-treble" value="0" min="-24" max="24" step="1" />
<span id="print-3-bands-equalizer-treble-value">0 dB</span>
const context = new AudioContext();
let frequency = 440;
let oscillator = new OscillatorNode(context, { type: 'sawtooth', frequency });
let isStop = true;
const bass = new BiquadFilterNode(context, { type: 'lowshelf', frequency: 250 });
const middle = new BiquadFilterNode(context, { type: 'peaking', frequency: 1000, Q: Math.SQRT1_2 });
const treble = new BiquadFilterNode(context, { type: 'highshelf', frequency: 4000 });
const buttonElement = document.querySelector('button[type="button"]');
const checkboxElement = document.querySelector('input[type="checkbox"]');
const rangeBassElement = document.getElementById('range-3-bands-equalizer-bass');
const rangeMiddleElement = document.getElementById('range-3-bands-equalizer-middle');
const rangeTrebleElement = document.getElementById('range-3-bands-equalizer-treble');
const spanPrintCheckedElement = document.getElementById('print-checked-3-bands-equalizer');
const spanPrintBassElement = document.getElementById('print-3-bands-equalizer-bass-value');
const spanPrintMiddleElement = document.getElementById('print-3-bands-equalizer-middle-value');
const spanPrintTrebleElement = document.getElementById('print-3-bands-equalizer-treble-value');
const rangeOscillatorFrequencyElement = document.getElementById('range-3-bands-equalizer-oscillator-frequency');
const spanPrintOscillatorFrequencyElement = document.getElementById('print-3-bands-equalizer-oscillator-frequency-value');
checkboxElement.addEventListener('click', () => {
oscillator.disconnect(0);
if (checkboxElement.checked) {
// OscillatorNode (Input) -> Equalizer (Low-Shelving Filter -> Peaking Filter -> High-Shelving Filter) -> AudioDestinationNode (Output)
oscillator.connect(bass);
bass.connect(middle);
middle.connect(treble);
treble.connect(context.destination);
spanPrintCheckedElement.textContent = 'ON'
} else {
// OscillatorNode (Input) -> AudioDestinationNode (Output)
oscillator.connect(context.destination);
spanPrintCheckedElement.textContent = 'OFF'
}
});
buttonElement.addEventListener('mousedown', () => {
if (!isStop) {
return;
}
if (checkboxElement.checked) {
// Connect nodes (Equalizer ON)
// OscillatorNode (Input) -> Equalizer (Low-Shelving Filter -> Peaking Filter -> High-Shelving Filter) -> AudioDestinationNode (Output)
oscillator.connect(bass);
bass.connect(middle);
middle.connect(treble);
treble.connect(context.destination);
} else {
// Connect nodes (Equalizer OFF)
// OscillatorNode (Input) -> AudioDestinationNode (Output)
oscillator.connect(context.destination);
}
// Start oscillator
oscillator.start(0);
isStop = false;
buttonElement.textContent = 'stop';
});
buttonElement.addEventListener('mouseup', () => {
if (isStop) {
return;
}
// Stop immediately
oscillator.stop(0);
oscillator = new OscillatorNode(context, { type: 'sawtooth', frequency });
isStop = true;
buttonElement.textContent = 'start';
});
rangeOscillatorFrequencyElement.addEventListener('input', (event) => {
frequency = event.currentTarget.valueAsNumber;
if (oscillator) {
oscillator.frequency.value = frequency
}
spanPrintOscillatorFrequencyElement.textContent = `${frequency} Hz`;
});
rangeBassElement.addEventListener('input', (event) => {
const gain = event.currentTarget.valueAsNumber;
bass.gain.value = gain;
spanPrintBassElement.textContent = `${gain} dB`;
});
rangeMiddleElement.addEventListener('input', (event) => {
const gain = event.currentTarget.valueAsNumber;
middle.gain.value = gain;
spanPrintMiddleElement.textContent = `${gain} dB`;
});
rangeTrebleElement.addEventListener('input', (event) => {
const gain = event.currentTarget.valueAsNumber;
treble.gain.value = gain;
spanPrintTrebleElement.textContent = `${gain} dB`;
});
グラフィックイコライザー
グラフィックイコライザー は, 10 帯域ほどの周波数成分をブースト・カットできるイコライザーですが, 実装としては, Peaking Filter
を制御したい周波数帯域の数だけ接続するだけです. $f_{\mathrm{computed}}$ を制御したい周波数帯域に設定します.
グラフィックイコライザーのノード接続図
グラフィックイコライザーのフィルタ特性
以下は, 上記のフィルタ特性となるグラフィックイコライザーを実際のアプリケーションを想定して, ユーザーインタラクティブに,
各周波数帯域をブースト・カットできるようにしたコード例です.
<button type="button">start</button>
<label>
<input type="checkbox" id="checkbox-graphic-equalizer" checked />
<span id="print-checked-graphic-equalizer">ON</span>
</label>
<label>
<span>OscillatorNode frequency</span>
<input type="range" id="range-graphic-equalizer-oscillator-frequency" value="440" min="27.5" max="4000" step="0.5" />
<span id="print-graphic-equalizer-oscillator-frequency-value">440 Hz</span>
</label>
<label for="range-graphic-equalizer-32Hz">32 Hz</label>
<input type="range" id="range-graphic-equalizer-32Hz" value="0" min="-24" max="24" step="1" />
<span id="print-graphic-equalizer-32Hz-value">0 dB</span>
<label for="range-graphic-equalizer-62Hz">62.5 Hz</label>
<input type="range" id="range-graphic-equalizer-62Hz" value="0" min="-24" max="24" step="1" />
<span id="print-graphic-equalizer-62Hz-value">0 dB</span>
<label for="range-graphic-equalizer-125Hz">125 Hz</label>
<input type="range" id="range-graphic-equalizer-125Hz" value="0" min="-24" max="24" step="1" />
<span id="print-graphic-equalizer-125Hz-value">0 dB</span>
<label for="range-graphic-equalizer-250Hz">250 Hz</label>
<input type="range" id="range-graphic-equalizer-250Hz" value="0" min="-24" max="24" step="1" />
<span id="print-graphic-equalizer-250Hz-value">0 dB</span>
<label for="range-graphic-equalizer-500Hz">500 Hz</label>
<input type="range" id="range-graphic-equalizer-500Hz" value="0" min="-24" max="24" step="1" />
<span id="print-graphic-equalizer-500Hz-value">0 dB</span>
<label for="range-graphic-equalizer-1000Hz">1000 Hz</label>
<input type="range" id="range-graphic-equalizer-1000Hz" value="0" min="-24" max="24" step="1" />
<span id="print-graphic-equalizer-1000Hz-value">0 dB</span>
<label for="range-graphic-equalizer-2000Hz">2000 Hz</label>
<input type="range" id="range-graphic-equalizer-2000Hz" value="0" min="-24" max="24" step="1" />
<span id="print-graphic-equalizer-2000Hz-value">0 dB</span>
<label for="range-graphic-equalizer-4000Hz">4000 Hz</label>
<input type="range" id="range-graphic-equalizer-4000Hz" value="0" min="-24" max="24" step="1" />
<span id="print-graphic-equalizer-4000Hz-value">0 dB</span>
<label for="range-graphic-equalizer-8000Hz">8000 Hz</label>
<input type="range" id="range-graphic-equalizer-8000Hz" value="0" min="-24" max="24" step="1" />
<span id="print-graphic-equalizer-8000Hz-value">0 dB</span>
<label for="range-graphic-equalizer-16000Hz">1.6 kHz</label>
<input type="range" id="range-graphic-equalizer-16000Hz" value="0" min="-24" max="24" step="1" />
<span id="print-graphic-equalizer-16000Hz-value">0 dB</span>
const context = new AudioContext();
let frequency = 440;
let oscillator = new OscillatorNode(context, { type: 'sawtooth', frequency });
let isStop = true;
const buttonElement = document.querySelector('button[type="button"]');
const checkboxElement = document.querySelector('input[type="checkbox"]');
const spanPrintCheckedElement = document.getElementById('print-checked-graphic-equalizer');
const rangeOscillatorFrequencyElement = document.getElementById('range-graphic-equalizer-oscillator-frequency');
const spanPrintOscillatorFrequencyElement = document.getElementById('print-graphic-equalizer-oscillator-frequency-value');
const centerFrequencies = [32, 62.5, 125, 250, 500, 1000, 2000, 4000, 8000, 16000];
const peakingFilters = centerFrequencies.map((frequency) => {
return new BiquadFilterNode(context, { type: 'peaking', frequency, Q: Math.SQRT1_2 });
});
centerFrequencies.forEach((frequency, index) => {
document.getElementById(`range-graphic-equalizer-${Math.trunc(frequency)}Hz`).addEventListener('input', (event) => {
const peakingFilter = peakingFilters[index];
peakingFilter.gain.value = event.currentTarget.valueAsNumber;
document.getElementById(`print-graphic-equalizer-${Math.trunc(frequency)}Hz-value`).textContent = `${peakingFilter.gain.value} dB`;
});
});
checkboxElement.addEventListener('click', () => {
oscillator.disconnect(0);
if (checkboxElement.checked) {
oscillator.connect(peakingFilters[0]);
for (let i = 0, len = peakingFilters.length - 1; i < len; i++) {
peakingFilters[i].connect(peakingFilters[i + 1]);
}
peakingFilters[peakingFilters.length - 1].connect(context.destination);
spanPrintCheckedElement.textContent = 'ON';
} else {
oscillator.connect(context.destination);
spanPrintCheckedElement.textContent = 'OFF';
}
});
buttonElement.addEventListener('mousedown', () => {
if (!isStop) {
return;
}
if (checkboxElement.checked) {
oscillator.connect(peakingFilters[0]);
for (let i = 0, len = peakingFilters.length - 1; i < len; i++) {
peakingFilters[i].connect(peakingFilters[i + 1]);
}
peakingFilters[peakingFilters.length - 1].connect(context.destination);
} else {
oscillator.connect(context.destination);
}
oscillator.start(0);
isStop = false;
buttonElement.textContent = 'stop'
});
buttonElement.addEventListener('mouseup', () => {
if (isStop) {
return;
}
oscillator.stop(0);
oscillator = new OscillatorNode(context, { type: 'sawtooth', frequency });
isStop = true;
buttonElement.textContent = 'start';
});
rangeOscillatorFrequencyElement.addEventListener('input', (event) => {
frequency = event.currentTarget.valueAsNumber;
if (oscillator) {
oscillator.frequency.value = frequency;
}
spanPrintOscillatorFrequencyElement.textContent = `${frequency} Hz`;
});
ワウ
フィルタを利用したよく使われるエフェクターとして, ワウ があります. 原理は, Low-Pass Filter のカットオフ周波数, もしくは, Band-Pass Filter
の中心周波数 ($f_{\mathrm{computed}}$ ) を時間経過とともに変化させることによって,
まさに「ワ」「ウ」と発声しているようなエフェクト音を生成することができます (エレキギターなどでは, いわゆる飛び道具的なエフェクターとして使われます).
音声分析合成技術である, ボコーダー (Vocoder : Voice Coder ) と似ているエフェクト音ですが, ワウの発明起源は諸説あるようです (また,
ボコーダーから, さらに音楽用途に改良して使われるようになったエフェクターとしては, フェーズ・ボコーダー があります).
ワウを実装するためには, $f_{\mathrm{computed}}$ (Low-Pass Filter のカットオフ周波数, もしくは, Band-Pass Filter
の中心周波数) を時間経過とともに変化させる必要があります. これをワウペダル (参考:
VOX 社のワウペダル )
と呼ばれるコントローラーで変化させるするのがペダルワウ , 楽器の発音時の振幅の変化 (打弦や撥弦の強弱)
に応じて変化させるのがオートワウ となります .
ワウの原理 ($f_{\mathrm{computed}}$ が時間経過とともに変化することに着目してください)
start
ワウは原音を直接変化させるエフェクターなので, インサートエフェクトとなります.
ペダルワウ
Web ブラウザでペダルワウを実装するには, ハードウェア的な制約があるので, このセクションでは,
LFO を擬似的なワウペダルとして ペダルワウの実装を解説します (もし, Web MIDI API の理解があり, MIDI 機器があれば, ベロシティなどに応じて
$f_{\mathrm{computed}}$ を変化させるようにすると, よりペダルワウに近い体験ができると思います).
原理をそのまま実装に落とし込めば, ペダルワウの実装はトレモロなどと同じです. BiquadFilterNode
を接続して,
frequency
プロパティ (AudioParam
) に LFO を接続して時間経過とともに
$f_{\mathrm{computed}}$ を変化させることで実装可能です.
Low-Pass Filter を使う場合, カットオフ周波数付近の急峻を鋭くしておく必要があるので , Q
プロパティの値は 10
~
20
程度に設定しておく必要があります (Band-Pass Filter の場合は, デフォルト値で問題ありません).
IIRFilterNode
を使っても実装は可能ですが, その場合, カットオフ周波数は AudioParam
ではないので, LFO も AudioWorklet
を利用して実装する必要が生じます. 特にフィルタのチューニングが必要なければ (奇数次の IIR フィルタを使わなければならない理由があるなど),
BiquadFilterNode
を使うほうが実装は簡潔になります.
ペダルワウのノード接続図
const context = new AudioContext();
const cutoff = 880;
const depthRate = 0.5;
const rateValue = 0.5;
const resonance = 10;
const lowpass = new BiquadFilterNode(context, { type: 'lowpass', frequency: cutoff, Q: resonance });
const oscillator = new OscillatorNode(context, { type: 'sawtooth', frequency: 440 });
const lfo = new OscillatorNode(context, { frequency: rateValue });
const depth = new GainNode(context, { gain: cutoff * depthRate });
// Connect nodes
// OscillatorNode (Input) -> BiquadFilterNode (Low-Pass Filter) -> AudioDestinationNode (Output)
oscillator.connect(lowpass);
lowpass.connect(context.destination);
// Connect nodes for LFO that changes Low-Pass Filter's frequency periodically
// OscillatorNode (LFO) -> GainNode (Depth) -> delayTime (AudioParam)
lfo.connect(depth);
depth.connect(lowpass.frequency);
// Start oscillator and LFO
oscillator.start(0);
lfo.start(0);
// Stop oscillator and LFO
oscillator.stop(context.currentTime + 10);
lfo.stop(context.currentTime + 10);
以下は, 実際のアプリケーションを想定して, ユーザーインタラクティブに, ペダルワウに関わるパラメータを制御できるようにしたコード例です.
一般的なペダルワウでは, ワウペダルのプレッシャーレベルに応じて, カットオフ周波数 ($f_{\mathrm{computed}}$ )
が変化するようになっていますが, LFO を擬似的なワウペダルとした実装なので, 基準となる $f_{\mathrm{computed}}$ と
Depth でワウペダルのプレッシャーレベルを, Rate でペダルの動きを擬似的に実装しています.
<button type="button">start</button>
<label>
<input type="checkbox" id="checkbox-pedal-wah" checked />
<span id="print-checked-pedal-wah">ON</span>
</label>
<label for="range-pedal-wah-cutoff">Cutoff Frequency</label>
<input type="range" id="range-pedal-wah-cutoff" value="1000" min="1000" max="2000" step="1" />
<span id="print-pedal-wah-cutoff-value">1000 Hz</span>
<label for="range-pedal-wah-depth">Depth</label>
<input type="range" id="range-pedal-wah-depth" value="0" min="0" max="1" step="0.05" />
<span id="print-pedal-wah-depth-value">0</span>
<label for="range-pedal-wah-rate">Rate</label>
<input type="range" id="range-pedal-wah-rate" value="0" min="0" max="10" step="0.5" />
<span id="print-pedal-wah-rate-value">0</span>
<label for="range-pedal-wah-resonance">Resonance</label>
<input type="range" id="range-pedal-wah-resonance" value="1" min="1" max="20" step="1" />
<span id="print-pedal-wah-resonance-value">1</span>
const context = new AudioContext();
let cutoff = 1000;
let depthRate = 0;
let rateValue = 0;
let resonance = 1;
let oscillator = new OscillatorNode(context, { type: 'sawtooth', frequency: 440 });
let lfo = new OscillatorNode(context, { frequency: rateValue });
let isStop = true;
const lowpass = new BiquadFilterNode(context, { type: 'lowpass', frequency: cutoff, Q: resonance });
const depth = new GainNode(context, { gain: lowpass.frequency.value * depthRate });
const buttonElement = document.querySelector('button[type="button"]');
const checkboxElement = document.querySelector('input[type="checkbox"]');
const rangeCutoffElement = document.getElementById('range-pedal-wah-cutoff');
const rangeDepthElement = document.getElementById('range-pedal-wah-depth');
const rangeRateElement = document.getElementById('range-pedal-wah-rate');
const rangeResonanceElement = document.getElementById('range-pedal-wah-resonance');
const spanPrintCheckedElement = document.getElementById('print-checked-pedal-wah');
const spanPrintCutoffElement = document.getElementById('print-pedal-wah-cutoff-value');
const spanPrintDepthElement = document.getElementById('print-pedal-wah-depth-value');
const spanPrintRateElement = document.getElementById('print-pedal-wah-rate-value');
const spanPrintResonanceElement = document.getElementById('print-pedal-wah-resonance-value');
checkboxElement.addEventListener('click', () => {
oscillator.disconnect(0);
lowpass.disconnect(0);
lfo.disconnect(0);
if (checkboxElement.checked) {
// Connect nodes
// OscillatorNode (Input) -> BiquadFilterNode (Low-Pass Filter) -> AudioDestinationNode (Output)
oscillator.connect(lowpass);
lowpass.connect(context.destination);
// Connect nodes for LFO that changes Low-Pass Filter's frequency periodically
// OscillatorNode (LFO) -> GainNode (Depth) -> frequency (AudioParam)
lfo.connect(depth);
depth.connect(lowpass.frequency);
spanPrintCheckedElement.textContent = 'ON';
} else {
// OscillatorNode (Input) -> AudioDestinationNode (Output)
oscillator.connect(context.destination);
spanPrintCheckedElement.textContent = 'OFF';
}
});
buttonElement.addEventListener('mousedown', () => {
if (!isStop) {
return;
}
if (checkboxElement.checked) {
// Connect nodes
// OscillatorNode (Input) -> BiquadFilterNode (Low-Pass Filter) -> AudioDestinationNode (Output)
oscillator.connect(lowpass);
lowpass.connect(context.destination);
// Start oscillator
oscillator.start(0);
} else {
lowpass.disconnect(0);
// Connect nodes (Pedal Wah OFF)
// OscillatorNode (Input) -> AudioDestinationNode (Output)
oscillator.connect(context.destination);
// Start oscillator
oscillator.start(0);
}
// Connect nodes for LFO that changes Low-Pass Filter's frequency periodically
// OscillatorNode (LFO) -> GainNode (Depth) -> frequency (AudioParam)
lfo.connect(depth);
depth.connect(lowpass.frequency);
// Start LFO
lfo.start(0);
isStop = false;
buttonElement.textContent = 'stop';
});
buttonElement.addEventListener('mouseup', () => {
if (isStop) {
return;
}
// Stop immediately
oscillator.stop(0);
lfo.stop(0);
oscillator = new OscillatorNode(context, { type: 'sawtooth', frequency: 440 });
lfo = new OscillatorNode(context, { frequency: rateValue });
isStop = true;
buttonElement.textContent = 'start';
});
rangeCutoffElement.addEventListener('input', (event) => {
cutoff = event.currentTarget.valueAsNumber;
lowpass.frequency.value = cutoff;
spanPrintCutoffElement.textContent = `${cutoff.toString(10)} Hz`;
});
rangeDepthElement.addEventListener('input', (event) => {
depthRate = event.currentTarget.valueAsNumber;
depth.gain.value = lowpass.frequency.value * depthRate;
spanPrintDepthElement.textContent = depthRate.toString(10);
});
rangeRateElement.addEventListener('input', (event) => {
rateValue = event.currentTarget.valueAsNumber;
if (lfo) {
lfo.frequency.value = rateValue;
}
spanPrintRateElement.textContent = rateValue.toString(10);
});
rangeResonanceElement.addEventListener('input', (event) => {
resonance = event.currentTarget.valueAsNumber;
lowpass.Q.value = resonance;
spanPrintResonanceElement.textContent = resonance.toString(10);
});
オートワウ
汎用的なオートワウを実装するためには, WaveShaperNode
クラスの理解が必要であったり, ノード接続が複雑であったりするので,
このセクションでは, すでに解説した BiquadFilterNode
の frequency
プロパティ (AudioParam
)
のオートメーションメソッドを利用した簡易的な実装を解説します (これは, アナログシンセサイザーの VCF (Voltage Controlled Filter :
電圧制御フィルタ ) の実装でもあります).
エンベロープジェネレーターと同様のオートメーションを BiquadFilterNode
の frequency
プロパティに実装することです. そして,
それに対応するように, GainNode
の gain
プロパティにもオートメーションを適用すれば, 同様のスケジューリングで,
振幅に応じて $f_{\mathrm{computed}}$ も変化することになるので, オートワウが実装できます. 現実的には,
オートワウが有用なのは, このような, ユーザーインタラクティブな操作に応じて, 振幅の変化とともに,
$f_{\mathrm{computed}}$ を変化させればよいケースがほとんどなので, 簡易的なオートワウでも十分なことが多いでしょう.
const context = new AudioContext();
const cutoff = 1000;
const targetCutoff = 2000;
const resonance = 10;
const attack = 1.0;
const decay = 0.5;
const sustain = 0.5;
const release = 1.0;
const envelopegenerator = new GainNode(context);
let oscillator = new OscillatorNode(context, { type: 'sawtooth', frequency: 440 });
let isStop = true;
const lowpass = new BiquadFilterNode(context, { type: 'lowpass', frequency: cutoff, Q: resonance });
const buttonElement = document.querySelector('button[type="button"]');
buttonElement.addEventListener('mousedown', () => {
if (!isStop) {
return;
}
// Connect nodes
// OscillatorNode (Input) -> GainNode (Envelope Generator) -> BiquadFilterNode (Low-Pass Filter) -> AudioDestinationNode (Output)
oscillator.connect(envelopegenerator);
envelopegenerator.connect(lowpass);
lowpass.connect(context.destination);
const t0 = context.currentTime;
const t1 = t0 + attack;
const t2 = decay;
const t2Level = lowpass.frequency.value * sustain;
// Attack -> Decay -> Sustain
envelopegenerator.gain.cancelScheduledValues(t0);
envelopegenerator.gain.setValueAtTime(0, t0);
envelopegenerator.gain.linearRampToValueAtTime(1, t1);
envelopegenerator.gain.setTargetAtTime(sustain, t1, t2);
lowpass.frequency.cancelScheduledValues(t0);
lowpass.frequency.setValueAtTime(cutoff, t0);
lowpass.frequency.exponentialRampToValueAtTime(targetCutoff, t1);
lowpass.frequency.setTargetAtTime(t2Level, t1, t2);
// Start oscillator
oscillator.start(0);
isStop = false;
buttonElement.textContent = 'stop';
});
buttonElement.addEventListener('mouseup', () => {
if (isStop) {
return;
}
const t3 = context.currentTime;
const t4 = release;
// Sustain -> Release
envelopegenerator.gain.cancelScheduledValues(t3);
envelopegenerator.gain.setTargetAtTime(0, t3, t4);
lowpass.frequency.cancelScheduledValues(t3);
lowpass.frequency.setTargetAtTime(cutoff, t3, t4);
// Stop after Release
oscillator.stop(t3 + t4);
oscillator = new OscillatorNode(context, { type: 'sawtooth', frequency: 440 });
isStop = true;
buttonElement.textContent = 'start';
});
ワウは飛び道具的なエフェクターなので, linearRampToValueAtTime
メソッドではなく,
exponentialRampToValueAtTime
メソッドで指数関数的に変化させてみました (もちろん, 線形的に変化させる
linearRampToValueAtTime
メソッドでもオートワウとしては機能します).
以下は, 実際のアプリケーションを想定して, ユーザーインタラクティブに, (パラメータのオートメーションによる)
オートワウに関わるパラメータを制御できるようにしたコード例です.
<button type="button">start</button>
<label for="range-vcf-auto-wah-attack">attack</label>
<input type="range" id="range-vcf-auto-wah-attack" value="1" min="0" max="1" step="0.01" />
<span id="print-vcf-auto-wah-attack-value">1</span>
<label for="range-vcf-auto-wah-decay">decay</label>
<input type="range" id="range-vcf-auto-wah-decay" value="0.3" min="0" max="1" step="0.01" />
<span id="print-vcf-auto-wah-decay-value">0.3</span>
<label for="range-vcf-auto-wah-sustain">sustain</label>
<input type="range" id="range-vcf-auto-wah-sustain" value="0.5" min="0" max="1" step="0.01" />
<span id="print-vcf-auto-wah-sustain-value">0.5</span>
<label for="range-vcf-auto-wah-release">release</label>
<input type="range" id="range-vcf-auto-wah-release" value="1" min="0" max="1" step="0.01" />
<span id="print-vcf-auto-wah-release-value">1</span>
const context = new AudioContext();
const cutoff = 1000;
const targetCutoff = 2000;
const resonance = 10;
let attack = 1.0;
let decay = 0.5;
let sustain = 0.5;
let release = 1.0;
const envelopegenerator = new GainNode(context);
let oscillator = new OscillatorNode(context, { type: 'sawtooth', frequency: 440 });
let isStop = true;
const lowpass = new BiquadFilterNode(context, { type: 'lowpass', frequency: cutoff, Q: resonance });
const buttonElement = document.querySelector('button[type="button"]');
const rangeAttackElement = document.getElementById('range-vcf-auto-wah-attack');
const rangeDecayElement = document.getElementById('range-vcf-auto-wah-decay');
const rangeSustainElement = document.getElementById('range-vcf-auto-wah-sustain');
const rangeReleaseElement = document.getElementById('range-vcf-auto-wah-release');
const spanPrintAttackElement = document.getElementById('print-vcf-auto-wah-attack-value');
const spanPrintDecayElement = document.getElementById('print-vcf-auto-wah-decay-value');
const spanPrintSutainElement = document.getElementById('print-vcf-auto-wah-sustain-value');
const spanPrintReleaseElement = document.getElementById('print-vcf-auto-wah-release-value');
buttonElement.addEventListener('mousedown', () => {
if (!isStop) {
return;
}
// Connect nodes
// OscillatorNode (Input) -> GainNode (Envelope Generator) -> BiquadFilterNode (Low-Pass Filter) -> AudioDestinationNode (Output)
oscillator.connect(envelopegenerator);
envelopegenerator.connect(lowpass);
lowpass.connect(context.destination);
const t0 = context.currentTime;
const t1 = t0 + attack;
const t2 = decay;
const t2Level = lowpass.frequency.value * sustain;
// Attack -> Decay -> Sustain
envelopegenerator.gain.cancelScheduledValues(t0);
envelopegenerator.gain.setValueAtTime(0, t0);
envelopegenerator.gain.linearRampToValueAtTime(1, t1);
envelopegenerator.gain.setTargetAtTime(sustain, t1, t2);
lowpass.frequency.cancelScheduledValues(t0);
lowpass.frequency.setValueAtTime(cutoff, t0);
lowpass.frequency.exponentialRampToValueAtTime(targetCutoff, t1);
lowpass.frequency.setTargetAtTime(t2Level, t1, t2);
// Start oscillator
oscillator.start(0);
isStop = false;
buttonElement.textContent = 'stop';
});
buttonElement.addEventListener('mouseup', () => {
if (isStop) {
return;
}
const t3 = context.currentTime;
const t4 = release;
// Sustain -> Release
envelopegenerator.gain.cancelAndHoldAtTime(t3);
envelopegenerator.gain.setTargetAtTime(0, t3, t4);
lowpass.frequency.cancelAndHoldAtTime(t3);
lowpass.frequency.setTargetAtTime(cutoff, t3, t4);
// Stop after Release (+ until nearly zero audio data)
oscillator.stop(t3 + t4 + 5);
oscillator = new OscillatorNode(context, { type: 'sawtooth', frequency: 440 });
isStop = true;
buttonElement.textContent = 'start';
});
rangeAttackElement.addEventListener('input', (event) => {
attack = event.currentTarget.valueAsNumber;
spanPrintAttackElement.textContent = attack.toString(10);
});
rangeDecayElement.addEventListener('input', (event) => {
decay = event.currentTarget.valueAsNumber;
spanPrintDecayElement.textContent = decay.toString(10);
});
rangeSustainElement.addEventListener('input', (event) => {
sustain = event.currentTarget.valueAsNumber;
spanPrintSutainElement.textContent = sustain.toString(10);
});
rangeReleaseElement.addEventListener('input', (event) => {
release = event.currentTarget.valueAsNumber;
spanPrintReleaseElement.textContent = release.toString(10);
});
汎用的なオートワウ
汎用的なオートワウを実装するためには, WaveShaperNode
クラスを理解して, エンベロープフォロワー を実装する必要があるので,
ここでは, とりあえず形式的に (以下のように実装すればオートワウができる程度に) 理解していただければだいじょうぶです (WaveShaperNode
クラスやエンベロープフォロワーに関しては,
ディストーション (歪み系エフェクター) のセクション で詳細を解説します).
const context = new AudioContext();
const depthValue = 0.25;
const rateValue = 1;
const envelopeFollower = new WaveShaperNode(context, { buffer: new Float32Array([1, 0, 1]) });
const sensitivity = new BiquadFilterNode(context, { type: 'lowpass', frequency: 2000, Q: 10 });
const lowpass = new BiquadFilterNode(context, { type: 'lowpass', frequency: 2, Q: 1 });
const oscillator = new OscillatorNode(context, { type: 'sawtooth', frequency: 440 });
const lfo = new OscillatorNode(context, { frequency: rateValue });
const depth = new GainNode(context, { gain: 1000 });
const lfoDepth = new GainNode(context, { gain: depthValue });
const amplitude = new GainNode(context, { gain: 0.5 }); // 0.5 +- ${depthValue}
// Connect nodes
// OscillatorNode (Input) -> GainNode (Tremolo) -> BiquadFilterNode (Sensitivity) -> GainNode (Output)
oscillator.connect(amplitude);
amplitude.connect(sensitivity);
sensitivity.connect(context.destination);
// OscillatorNode (Input) -> WaveShaperNode (Envelope Follower) -> WaveShaperNode (Envelope Follower) -> BiquadFilterNode (Low-Pass Filter) -> GainNode (Depth) -> frequency (AudioParam)
oscillator.connect(envelopeFollower);
envelopeFollower.connect(lowpass);
lowpass.connect(depth);
depth.connect(sensitivity.frequency);
lfo.connect(lfoDepth);
lfoDepth.connect(amplitude.gain);
// Start oscillator
oscillator.start(0);
lfo.start(0);
// Stop oscillator
oscillator.stop(context.currentTime + 5);
lfo.stop(context.currentTime + 5);
音源の振幅の変化をシミュレートするために, トレモロを使っています (したがって, ここでのトレモロは, オートワウの実装には直接的に関係ありません).
ノード接続は, 大きく 2 つで構成されています. 1 つは, 音源と振幅に対する感度のための Low-Pass Filter, そして
AudioDestinationNode
の接続と, もう 1 つは, 音源の振幅をエンベロープフォロワー に接続することによって,
全波整流 (端的には, 振幅の絶対値に波形を整形する処理) を施し, それをもとに, 感度の Low-Pass Filter の
frequency
プロパティを振幅に応じて変化させるための (AudioParam
への) 接続です.
オートワウのノード接続図
全波整流した原音を, カットオフ周波数が非常に低い Low-Pass Filter を通過させることで, 波形 (エンベロープ) ではなく, 振幅の変化を出力とします.
あとは, 振幅に応じて Depth の値が変化するようにすれば, Sensitivity の Low-Pass Filter の
$f_{\mathrm{computed}}$ が原音の振幅の変化に応じて変化することになり, (汎用的な) オートワウが実装できます.
以下は, 実際のアプリケーションを想定して, ユーザーインタラクティブに, オートワウに関わるパラメータを制御できるようにしたコード例です.
原音はハードコーディングしていますが, OscillatorNode
だけでなく, ワンショットオーディオや
MediaStreamAudioSourceNode
をオーディオソースとして音声に適用するのもおもしろいかもしれません.
<button type="button">start</button>
<label>
<input type="checkbox" id="checkbox-auto-wah" checked />
<span id="print-checked-auto-wah">ON</span>
</label>
<label for="range-auto-wah-sensitivity">Sensitivity</label>
<input type="range" id="range-auto-wah-sensitivity" value="1000" min="1000" max="2000" step="1" />
<span id="print-auto-wah-sensitivity-value">1000 Hz</span>
<label for="range-auto-wah-depth">Depth</label>
<input type="range" id="range-auto-wah-depth" value="0.5" min="0" max="1" step="0.05" />
<span id="print-auto-wah-depth-value">0.5</span>
<label for="range-auto-wah-resonance">Resonance</label>
<input type="range" id="range-auto-wah-resonance" value="10" min="1" max="40" step="1" />
<span id="print-auto-wah-resonance-value">10</span>
const context = new AudioContext();
let sensitivityFrequency = 2000;
let sensitivityDepthRate = 0.5;
let sensitivityResonance = 10;
const tremoloDepthValue = 0.25;
const tremoloRateValue = 1;
let oscillator = new OscillatorNode(context, { type: 'sawtooth', frequency: 440 });
let lfo = new OscillatorNode(context, { frequency: tremoloRateValue });
// for Tremolo (simulates amplitude modulation)
const lfoDepth = new GainNode(context, { gain: tremoloDepthValue });
const amplitude = new GainNode(context, { gain: 0.5 }); // 0.5 +- ${tremoloDepthValue}
lfo.connect(lfoDepth);
lfoDepth.connect(amplitude.gain);
// Start LFO (simulates amplitude modulation)
lfo.start(0);
const envelopeFollower = new WaveShaperNode(context, { buffer: new Float32Array([1, 0, 1]) });
const sensitivity = new BiquadFilterNode(context, { type: 'lowpass', frequency: sensitivityFrequency, Q: sensitivityResonance });
const lowpass = new BiquadFilterNode(context, { type: 'lowpass', frequency: 2, Q: 1 });
const depth = new GainNode(context, { gain: sensitivity.frequency.value * sensitivityDepthRate });
let isStop = true;
const buttonElement = document.querySelector('button[type="button"]');
const checkboxElement = document.querySelector('input[type="checkbox"]');
const rangeSensitivityFrequencyElement = document.getElementById('range-auto-wah-sensitivity');
const rangeSensitivityDepthElement = document.getElementById('range-auto-wah-depth');
const rangeResonanceElement = document.getElementById('range-auto-wah-resonance');
const spanPrintCheckedElement = document.getElementById('print-checked-auto-wah');
const spanPrintSensitivityFrequencyElement = document.getElementById('print-auto-wah-sensitivity-value');
const spanPrintDepthElement = document.getElementById('print-auto-wah-depth-value');
const spanPrintResonanceElement = document.getElementById('print-auto-wah-resonance-value');
checkboxElement.addEventListener('click', () => {
oscillator.disconnect(0);
sensitivity.disconnect(0);
envelopeFollower.disconnect(0);
lowpass.disconnect(0);
depth.disconnect(0);
if (checkboxElement.checked) {
// Connect nodes
// OscillatorNode (Input) -> GainNode (Tremolo) -> BiquadFilterNode (Sensitivity) -> GainNode (Output)
oscillator.connect(amplitude);
amplitude.connect(sensitivity);
sensitivity.connect(context.destination);
// OscillatorNode (Input) -> WaveShaperNode (Envelope Follower) -> BiquadFilterNode (Low-Pass Filter) -> GainNode (Depth) -> frequency (AudioParam)
oscillator.connect(envelopeFollower);
envelopeFollower.connect(lowpass);
lowpass.connect(depth);
depth.connect(sensitivity.frequency);
spanPrintCheckedElement.textContent = 'ON';
} else {
// OscillatorNode (Input) -> AudioDestinationNode (Output)
oscillator.connect(context.destination);
spanPrintCheckedElement.textContent = 'OFF';
}
});
buttonElement.addEventListener('mousedown', () => {
if (!isStop) {
return;
}
if (checkboxElement.checked) {
// Connect nodes
// OscillatorNode (Input) -> GainNode (Tremolo) -> BiquadFilterNode (Sensitivity) -> GainNode (Output)
oscillator.connect(amplitude);
amplitude.connect(sensitivity);
sensitivity.connect(context.destination);
// OscillatorNode (Input) -> WaveShaperNode (Envelope Follower) -> BiquadFilterNode (Low-Pass Filter) -> GainNode (Depth) -> frequency (AudioParam)
oscillator.connect(envelopeFollower);
envelopeFollower.connect(lowpass);
lowpass.connect(depth);
depth.connect(sensitivity.frequency);
// Start oscillator
oscillator.start(0);
} else {
oscillator.disconnect(0);
// Connect nodes (Auto Wah OFF)
// OscillatorNode (Input) -> AudioDestinationNode (Output)
oscillator.connect(context.destination);
// Start oscillator
oscillator.start(0);
}
isStop = false;
buttonElement.textContent = 'stop';
});
buttonElement.addEventListener('mouseup', () => {
if (isStop) {
return;
}
// Stop immediately
oscillator.stop(0);
oscillator = new OscillatorNode(context, { type: 'sawtooth', frequency: 440 });
isStop = true;
buttonElement.textContent = 'start';
});
rangeSensitivityFrequencyElement.addEventListener('input', (event) => {
sensitivityFrequency = event.currentTarget.valueAsNumber;
sensitivity.frequency.value = sensitivityFrequency;
spanPrintSensitivityFrequencyElement.textContent = `${sensitivityFrequency.toString(10)} Hz`;
});
rangeSensitivityDepthElement.addEventListener('input', (event) => {
sensitivityDepthRate = event.currentTarget.valueAsNumber;
depth.gain.value = sensitivityFrequency * sensitivityDepthRate;
spanPrintDepthElement.textContent = sensitivityDepthRate.toString(10);
});
rangeResonanceElement.addEventListener('input', (event) => {
sensitivityResonance = event.currentTarget.valueAsNumber;
sensitivity.Q.value = sensitivityResonance;
spanPrintResonanceElement.textContent = sensitivityResonance.toString(10);
});
ディストーション (歪み系エフェクター)
楽器に限らず, オーディオ機器において歪み というのは本来避けるべき現象であり, そのため, 音割れが発生しないように, 振幅の調整 (基本波形の合成 セクションのデモなどを参照してください) をしたり, コンプレッサーなどを使って歪みが発生しないようにします. 現在では,
歪みを積極的に使うエレキギターでも, 元々のアンプはあくまで振幅 (ゲイン) を増幅させる機器であり, クリーンサウンドを使っていたと言われています.
しかし, あるギタリストが真空管アンプのボリュームを最大にして演奏した歪みが偶然にもよかったことから, 歪み系の音である (広義の)
ディストーションサウンド (広義というのは, 歪み系のサウンドの総称というニュアンスです) が使われるようになったと言われています
(このときの歪みは, (狭義の) ディストーションというよりは, ウォームでナチュラルな歪みであるオーバードライブ (Overdrive )
だったそうです).
ここから, よりハイゲインなディストーションサウンドを生成できるような真空管アンプが開発されたり,
ローランド社の伝説的なオーバードライブコンパクトエフェクターとなった BOSS
OD-1 Overdrive
が開発されたりと (現在でも中古市場で, コンパクトエフェクターにしてはかなりの高額で取引されるほどです), 現在では, 技術の進歩にともなって, アンプ
(シミュレーター) でも, エフェクターでも, 様々な種類のディストーションサウンドを得ることができます.
もっとも, アンプによるディストーションサウンドの生成と, エフェクターによるディストーションサウンドの生成は仕組みとしてはやや異なります. また,
ギタリストの多くはエフェクターによるディストーションサウンドより, (真空管) アンプのディストーションサウンドを基本に音づくりをする傾向にあるようです.
歴史的な経緯としても, 先にアンプシミュレーターによるディストーションサウンドの実装を解説したいところですが, Web Audio API においては,
どちらのディストーションサウンドにおいても, WaveShaperNode
クラスを使うことが基本になるのと, 実装としては,
エフェクターによるディストーションサウンドのほうが簡潔で理解しやすいと思うので, まずは, Crunch (クランチ ),
Overdrive (オーバードライブ ), Distortion (ディストーション ), Fuzz (ファズ )
に分類されるエフェクターによるディストーションサウンドの実装に関して解説します.
WaveShaperNode
ディストーションサウンドの原理は, 周波数領域でみると, 原音には存在しない周波数成分を発生させる非線形処理 が原理となっています. これを,
時間領域の波形でみると, 意図的な音割れを発生させるクリッピング が原理となっています.
クリッピング
波形を単純にクリッピングしただけでは, ディストーションサウンドではなく, 音割れっぽくなってしまうことや, 非線形処理によって折り返し歪み
(エイリアス歪み) が発生するのを防ぐためのオーバーサンプル処理 などを抽象化するのが, WaveShaperNode
クラスです.
curve プロパティ
波形のクリッピングを決定するための Float32Array
を指定します. curve
プロパティで設定される
Float32Array
は, 入力の振幅に対する出力の振幅を表しています . つまり, new Float32Array([0, 1])
であれば,
入出力の振幅比は 1 : 1 となり, クリッピングカーブの形状は直線となります. すなわち, これは, 線形処理 となるので,
curve
プロパティを指定しないのと同じです (curve
プロパティのデフォルト値は undefined
です).
curve
プロパティに設定する Float32Array
のサイズの上限は仕様では決められていませんが,
下限は 2
です (これは最低でも直線を表現しなければならないことを考慮すると直感的でもあります).
サンプルとサンプルの間は線形補間が適用される ので, 計算コストも考慮すると現実的な上限は
8192
サンプルぐらいまでで十分と言えます (あまりに少ないと, 線形補間の間が大きくなるので,
カーブではなく直線をつないだような形状となってしまい, クリッピングの精度が悪くなるので, ディストーションカーブとして使う場合は,
512
サンプル程度は下限としておいたほうがよいでしょう).
もっとも, 手動的にクリッピングカーブとなる Float32Array
の値を設定することはあまりなく (エンベロープフォロワーなどを除いて),
ディストーションサウンドとしてのクリッピングカーブを指定する場合, 定石となるようなクリッピングカーブが知られているので,
それを利用してクリッピングカーブを生成するのがよいでしょう (例えば, シグモイド関数 ($\sigma_{a}\left(x\right) = \frac{1}{1 + e^{-ax}}$ ) やオーバードライブカーブ など).
curve
プロパティに指定されたクリッピングカーブによって, 非線形処理 が適用されて, 入出力の振幅比が歪み ,
倍音成分が発生してディストーションサウンドとなります.
クリッピングカーブ
oversample プロパティ
ディストーションサウンドの生成において必須ではありませんが, クリッピング処理によって原音には存在しない周波数成分が発生しますが,
ナイキスト周波数以上の周波数成分が発生する可能性, すなわち, 折り返し歪み (エイリアス歪み) が発生してしまう可能性があります . この歪みは,
ノイズとなって知覚されてしまうので, それを防ぐために,
プリプロセス処理 (クリッピング前の処理) として, アップサンプリングによってクリッピング対象の信号のサンプル数を増やし, ポストプロセス処理
(クリッピング後の処理) として, ダウンサンプリングによって元のサンプル数に戻すという処理
(オーバーサンプル処理 ) が知られています.
オーバーサンプル処理の, アップサンプリングのサンプリング周波数を設定するのが, oversample
プロパティです. もっとも,
直接アップサンプリング周波数を設定するのではなく, '2x'
, または, '4x'
の文字列 (OverSampleType
列挙型. デフォルト値は, オーバーサンプル処理をしない 'none'
です) を指定することによって, サンプリング周波数の 2 倍,
または, 4 倍にアップサンプリングします. ディストーションサウンドの音質だけを考えるなら, 常に
'4x'
を指定しておくのがよいですが, オーバーサンプル処理によって,
サンプル数が増えるほどパフォーマンスは低下してしまいます. したがって, Crunch のような軽い歪みやエンベロープフォロワーのような場合には
'none'
, Distortion や Fuzz のような激しい歪みの場合には '4x'
, Overdrive の場合には
'2x'
というように, 歪みの深さや用途に応じて設定を変えるほうが, 音質とパフォーマンス (CPU リソースやレイテンシー)
の観点からは理想と言えます.
curve
プロパティと oversample
プロパティが理解できれば, WaveShaperNode
インスタンス生成して接続するだけです
(ファクトリメソッドでインスタンスを生成する場合は, AudioContext
インスタンスの
createWaveShaper
メソッドを呼び出します).
WaveShaperNode
のノード接続図
const context = new AudioContext();
const curve = new Float32Array(4096);
const amount = 0.7;
const k = (2 * amount) / (1 - amount);
for (let n = 0, numberOfSamples = curve.length; n < numberOfSamples; n++) {
const x = (((n - 0) * (1 - (-1))) / (numberOfSamples - 0)) + (-1);
const y = ((1 + k) * x) / (1 + (k * Math.abs(x)));
curve[n] = y;
}
const shaper = new WaveShaperNode(context, { curve, oversample: '2x' });
// If use `createWaveShaper`
// const shaper = context.createWaveShaper();
//
// shaper.curve = curve;
// shaper.oversample = '2x';
const oscillator = new OscillatorNode(context);
// Connect nodes
// OscillatorNode (Input) -> WaveShaperNode (Clipping) -> AudioDestinationNode (Output)
oscillator.connect(shaper);
shaper.connect(context.destination);
// Start oscillator immediately
oscillator.start(0);
// Stop oscillator after 10 sec
oscillator.stop(context.currentTime + 10);
非対称クリッピング
クリッピングカーブでも紹介したオーバードライブカーブ でも Overdrive を実装することは可能ですが, Overdrive によっては,
クリッピング後の波形が非対称となる非対称クリッピング で実装されていることも多いのでこのセクションで解説します (ちなみに, BOSS
OD-1 Overdrive
も非対称クリッピング回路が利用されています).
非対称クリッピングすることによって, 奇数次の倍音成分だけではなく, 偶数次の倍音成分も発生するので (端的には, 倍音成分が増えるので),
ハイゲインよりの Overdrive を生成することが可能となります.
非対称クリッピング
したがって,
オーバードライブカーブ をもとに, 非対称のクリッピングになるように, 正負の判定を追加して非対称になるように設定します. 非対称クリッピングカーブの形状は,
クリッピングカーブ で Curve Type
を
Overdrive (Asymmetrical)
に選択すると確認できます.
const context = new AudioContext();
const curve = new Float32Array(4096);
const amount = 0.7;
const k = (2 * amount) / (1 - amount);
for (let n = 0, numberOfSamples = curve.length; n < numberOfSamples; n++) {
const x = (((n - 0) * (1 - (-1))) / (numberOfSamples - 0)) + (-1);
const y = ((1 + k) * x) / (1 + (k * Math.abs(x)));
// Asymmetrical clipping curve
curve[n] = (y > 0) ? y : ((1 - ((amount > 0.5) ? 0.5 : amount)) * y);
}
const shaper = new WaveShaperNode(context, { curve, oversample: '2x' });
// If use `createWaveShaper`
// const shaper = context.createWaveShaper();
//
// shaper.curve = curve;
// shaper.oversample = '2x';
const oscillator = new OscillatorNode(context);
// Connect nodes
// OscillatorNode (Input) -> WaveShaperNode (Clipping) -> AudioDestinationNode (Output)
oscillator.connect(shaper);
shaper.connect(context.destination);
// Start oscillator immediately
oscillator.start(0);
// Stop oscillator after 10 sec
oscillator.stop(context.currentTime + 10);
また, 対称性に関わらず, Overdrive のクリッピングカーブは滑らかな曲線を設定することが多く, ソフトクリッピング された波形となります (逆に,
Distortion や Fuzz などは, 急峻に変化する曲線や非線形に直線を組み合わせた設定にすることが多く, ハードクリッピング された波形となります).
ソフトクリッピング (左) とハードクリッピング (右)
全波整流・半波整流
最も激しい歪みに分類される Fuzz では, クリッピングだけでなく, 絶対値をとる全波整流 , あるいは,
負数となる振幅を 0
にする半波整流 が実装されていることが多いです (ちなみに,
「整流器 」を英語に訳すと「Rectifier 」ですが, ハイゲイン真空管アンプで有名な Mesa/Boogie 社の
Dual Rectifier
などはこれに由来します)
整流 (左が全波整流, 右が半波整流)
整流のクリッピングカーブ (curve
プロパティの値) は直線の組み合わせなので, 手動的に設定できます. 全波整流であれば
new Float32Array([1, 0, 1])
, 半波整流であれば new Float32Array([0, 0, 1])
を設定します (全波整流は,
オートワウのセクションで解説した, エンベロープフォロワー でもあります). クリッピングカーブの形状は,
クリッピングカーブ で Curve Type
を Full Wave Rectifier
, または,
Half Wave Rectifier
に選択すると確認できます.
Fuzz では, 整流とハードクリッピングを組み合わせるので, 整流のクリッピングカーブの Float32Array
の
1
となっている値をハードクリッピングする値に設定します.
Fuzz (整流とハードクリッピング)
例えば, 0.5
でハードクリッピングする場合, 全波整流であれば new Float32Array([0.5, 0, 0.5])
, 半波整流であれば
new Float32Array([0, 0, 0.5])
) に設定すれば, 整流とハードクリッピングを組み合わせたクリッピングカーブとなります.
クリッピングカーブの形状は, クリッピングカーブ で Curve Type
を
Full Wave Rectifier + Hard Clipping
, または, Half Wave Rectifier + Hard Clipping
に選択して,
Clipping Level
の値を変更することで確認できます.
const context = new AudioContext();
const level = 0.5;
// Full Wave Rectifier
const curve = new Float32Array([level, 0, level]);
// If use Half Wave Rectifier
// const curve = new Float32Array([0, 0, level]);
const shaper = new WaveShaperNode(context, { curve, oversample: '4x' });
// If use `createWaveShaper`
// const shaper = context.createWaveShaper();
//
// shaper.curve = curve;
// shaper.oversample = '4x';
const oscillator = new OscillatorNode(context);
// Connect nodes
// OscillatorNode (Input) -> WaveShaperNode (Clipping) -> AudioDestinationNode (Output)
oscillator.connect(shaper);
shaper.connect(context.destination);
// Start oscillator immediately
oscillator.start(0);
// Stop oscillator after 10 sec
oscillator.stop(context.currentTime + 10);
歪み系エフェクターによるディストーションサウンドのインタラクティブなコード例は, アンプミュレーターと合わせたいので,
アンプシミュレーターと歪み系エフェクターによるディストーションサウンド のセクションで記載しています.
アンプシミュレーター
ギターアンプにおけるアンプとは, プリアンプ , パワーアンプ , キャビネットとスピーカー (コンボアンプやスモールアンプでは,
キャビネットはなくて, スピーカーのみであることが多いですが, アンプ本来の音をできるだけ忠実に再現することを目的に解説するので,
キャビネット内にスピーカーが内蔵されているようなスタックアンプをイメージしていただくのがよいかもしれません) で構成されています.
パワーアンプは振幅を増幅させるのが主な役割なので,
アンプ独特のディストーションサウンドは, プリアンプとキャビネットとスピーカーによってつくられます
(アンプシミュレーターやマルチエフェクターによっては, プリアンプの設定として, キャビネットとスピーカー構成の設定も含まれています).
特に, プリアンプの実装が, アンプによるディストーションサウンドにおいて重要となります . アンプシミュレーターを実装するとなると,
モデリングしたいアンプの数だけ実装が存在することになりますが, エフェクターによるディストーションサウンドと比較して,
ほとんどのプリアンプで共通して異なる点は, クリッピングの前後にフィルタ処理 があること, そして,
プリアンプの個性となるクリッピングカーブです .
もっとも, モデリングするアンプの数だけ実装を解説することはできないので, このセクションでは,
WEB AMP like Marshall
を参考に, ほとんどのスタジオで設置されている Marshall (ライクな) アンプシミュレーターの実装を解説します (さらに, Marshall といっても,
年代ごとに特色があり,
古くは, JTM や JMP, JCM, 最近だと DSL や JVM など様々です . あくまでこのセクションでの実装は Marshall ライクな実装と捉えてください).
プリアンプ
Marshall ライクなプリアンプの特徴として, 2 つのクリッピング処理による歪み と, クリッピングのプリプロセスとなる High-Pass Filter と Low-Pass Filter によるフィルタ処理 , また, 2 つのクリッピングの間にもフィルタ処理があること, そして, クリッピングのポストプロセスとしてポストイコライザー があることです.
まずは, プリプロセスとなるフィルタ処理を実装します.
const context = new AudioContext();
const preLowpass = new BiquadFilterNode(context, { type: 'lowpass', frequency: 3200, Q: -3 });
const preHighpass1 = new BiquadFilterNode(context, { type: 'highpass', frequency: 80, Q: -3 });
const preHighpass2 = new BiquadFilterNode(context, { type: 'highpass', frequency: 640, Q: -3 });
const preHighpass3 = new BiquadFilterNode(context, { type: 'highpass', frequency: 160, Q: -3 });
const middleAndBassGain = new GainNode(context, { gain: 0.5 });
const highTrebleGain = new GainNode(context, { gain: 0.5 });
// (AudioSourceNode ->) GainNode (Input) -> BiquadFilterNode (High-Pass Filter) -> BiquadFilterNode (Low-Pass Filter) -> GainNode (Middle and Low Gain) -> BiquadFilterNode (High-Pass Filter)
preHighpass1.connect(preLowpass);
preLowpass.connect(middleAndBassGain);
middleAndBassGain.connect(preHighpass3);
// (AudioSourceNode ->) BiquadFilterNode (High-Pass Filter) -> GainNode (High Treble Gain) -> BiquadFilterNode (High-Pass Filter)
preHighpass2.connect(highTrebleGain);
highTrebleGain.connect(preHighpass3);
// ...
次に, 前段の歪みを生成するクリッピング処理を実装します.
function createCurve(drive, numberOfSamples) {
const curves = new Float32Array(numberOfSamples);
const index = Math.trunc((numberOfSamples - 1) / 2);
const d = (10 ** ((drive / 5.0) - 1.0)) - 0.1;
const c = (d / 5.0) + 1.0;
let peak = 0.4;
if (c === 1) {
peak = 1.0;
} else if ((c > 1) && (c < 1.04)) {
peak = (-15.5 * c) + 16.52;
}
for (let i = 0; i < index; i++) {
curves[index + i] = peak * (((+1) - (c ** (-i))) + ((i * (c ** (-index))) / index));
curves[index - i] = peak * (((-1) + (c ** (-i))) - ((i * (c ** (-index))) / index));
}
curves[index] = 0;
return curves;
}
// If `drive` is about `3`, distortion sound is Crunch.
// If `drive` is about `5`, distortion sound is Overdrive.
// If `drive` is about `8`, distortion sound is Distortion.
const drive = 5;
const samples = 127;
const curve = createCurve(drive, samples);
const oversample = '4x';
const preShaper = new WaveShaperNode(context, { curve, oversample });
// (... ->) BiquadFilterNode (High-Pass Filter) -> WaveShaperNode (Pre Clipping)
preHighpass3.connect(preShaper);
// ...
そして, 前段の歪み後段の歪みの間のフィルタ処理を実装します.
const lowpass = new BiquadFilterNode(context, { type: 'lowpass', frequency: 4000, Q: -3 });
const highpass = new BiquadFilterNode(context, { type: 'highpass', frequency: 40, Q: -3 });
// (... ->) WaveShaperNode (Pre Clipping) -> BiquadFilterNode (Low-Pass Filter) -> BiquadFilterNode (High-Pass Filter)
preShaper.connect(lowpass);
lowpass.connect(highpass);
// ...
後段の歪みを生成するクリッピング処理を実装します (このクリッピングカーブは前段と同じです).
const postShaper = new WaveShaperNode(context, { curve, oversample });
// (... ->) BiquadFilterNode (High-Pass Filter) -> WaveShaperNode (Post Clipping)
highpass.connect(postShaper);
// ...
最後は, ポストイコライザーの実装ですが, これはすでに解説している 3 バンドイコライザーを追加するだけです.
const bass = new BiquadFilterNode(context, { type: 'lowshelf', frequency: 240 });
const middle = new BiquadFilterNode(context, { type: 'peaking', frequency: 500, Q: 1.5 });
const treble = new BiquadFilterNode(context, { type: 'highshelf', frequency: 1600 });
// (... ->) WaveShaperNode (Post Clipping) -> Post Equalizer (Low-Shelving Filter -> Peaking Filter -> High-Shelving Filter) -> AudioDestinationNode (Output)
postShaper.connect(bass);
bass.connect(middle);
middle.connect(treble);
treble.connect(context.destination);
ここまでのコードを網羅して記載すると, 以下のようになります.
function createCurve(drive, numberOfSamples) {
const curves = new Float32Array(numberOfSamples);
const index = Math.trunc((numberOfSamples - 1) / 2);
const d = (10 ** ((drive / 5.0) - 1.0)) - 0.1;
const c = (d / 5.0) + 1.0;
let peak = 0.4;
if (c === 1) {
peak = 1.0;
} else if ((c > 1) && (c < 1.04)) {
peak = (-15.5 * c) + 16.52;
}
for (let i = 0; i < index; i++) {
curves[index + i] = peak * (((+1) - (c ** (-i))) + ((i * (c ** (-index))) / index));
curves[index - i] = peak * (((-1) + (c ** (-i))) - ((i * (c ** (-index))) / index));
}
curves[index] = 0;
return curves;
}
const context = new AudioContext();
const preLowpass = new BiquadFilterNode(context, { type: 'lowpass', frequency: 3200, Q: -3 });
const preHighpass1 = new BiquadFilterNode(context, { type: 'highpass', frequency: 80, Q: -3 });
const preHighpass2 = new BiquadFilterNode(context, { type: 'highpass', frequency: 640, Q: -3 });
const preHighpass3 = new BiquadFilterNode(context, { type: 'highpass', frequency: 160, Q: -3 });
const middleAndBassGain = new GainNode(context, { gain: 0.5 });
const highTrebleGain = new GainNode(context, { gain: 0.5 });
// If `drive` is about `3`, distortion sound is Crunch.
// If `drive` is about `5`, distortion sound is Overdrive.
// If `drive` is about `8`, distortion sound is Distortion.
const drive = 5;
const samples = 127;
const curve = createCurve(drive, samples);
const oversample = '4x';
const preShaper = new WaveShaperNode(context, { curve, oversample });
const postShaper = new WaveShaperNode(context, { curve, oversample });
const lowpass = new BiquadFilterNode(context, { type: 'lowpass', frequency: 4000, Q: -3 });
const highpass = new BiquadFilterNode(context, { type: 'highpass', frequency: 40, Q: -3 });
const bass = new BiquadFilterNode(context, { type: 'lowshelf', frequency: 240 });
const middle = new BiquadFilterNode(context, { type: 'peaking', frequency: 500, Q: 1.5 });
const treble = new BiquadFilterNode(context, { type: 'highshelf', frequency: 1600 });
// (AudioSourceNode ->) GainNode (Input) -> BiquadFilterNode (High-Pass Filter) -> BiquadFilterNode (Low-Pass Filter) -> GainNode (Middle and Low Gain) -> BiquadFilterNode (High-Pass Filter)
preHighpass1.connect(preLowpass);
preLowpass.connect(middleAndBassGain);
middleAndBassGain.connect(preHighpass3);
// (AudioSourceNode ->) BiquadFilterNode (High-Pass Filter) -> GainNode (High Treble Gain) -> BiquadFilterNode (High-Pass Filter)
preHighpass2.connect(highTrebleGain);
highTrebleGain.connect(preHighpass3);
// BiquadFilterNode (High-Pass Filter) -> WaveShaperNode (Pre Clipping)
preHighpass3.connect(preShaper);
// WaveShaperNode (Pre Clipping) -> BiquadFilterNode (Low-Pass Filter) -> BiquadFilterNode (High-Pass Filter)
preShaper.connect(lowpass);
lowpass.connect(highpass);
// BiquadFilterNode (High-Pass Filter) -> WaveShaperNode (Post Clipping)
highpass.connect(postShaper);
// WaveShaperNode (Post Clipping) -> Post Equalizer (Low-Shelving Filter -> Peaking Filter -> High-Shelving Filter) -> AudioDestinationNode (Output)
postShaper.connect(bass);
bass.connect(middle);
middle.connect(treble);
treble.connect(context.destination);
ここまでで, Marshall ライクなプリアンプが実装できたので, エレキギターのクリーントーンのワンショットオーディオを利用して,
プリアンプで歪ませたディストーションサウンドを聴いてみてください (プリアンプによるディストーションサウンドは,
多段のフィルタやクリッピングによって, どうしても音が大きくなってしまうので, 最終的な音量を調整できるように, Master Volume となる
GainNode
を出力前に接続しています).
function createCurve(drive, numberOfSamples) {
const curves = new Float32Array(numberOfSamples);
const index = Math.trunc((numberOfSamples - 1) / 2);
const d = (10 ** ((drive / 5.0) - 1.0)) - 0.1;
const c = (d / 5.0) + 1.0;
let peak = 0.4;
if (c === 1) {
peak = 1.0;
} else if ((c > 1) && (c < 1.04)) {
peak = (-15.5 * c) + 16.52;
}
for (let i = 0; i < index; i++) {
curves[index + i] = peak * (((+1) - (c ** (-i))) + ((i * (c ** (-index))) / index));
curves[index - i] = peak * (((-1) + (c ** (-i))) - ((i * (c ** (-index))) / index));
}
curves[index] = 0;
return curves;
}
const context = new AudioContext();
const mastervolume = new GainNode(context, { gain: 0.3 });
const preLowpass = new BiquadFilterNode(context, { type: 'lowpass', frequency: 3200, Q: -3 });
const preHighpass1 = new BiquadFilterNode(context, { type: 'highpass', frequency: 80, Q: -3 });
const preHighpass2 = new BiquadFilterNode(context, { type: 'highpass', frequency: 640, Q: -3 });
const preHighpass3 = new BiquadFilterNode(context, { type: 'highpass', frequency: 160, Q: -3 });
const middleAndBassGain = new GainNode(context, { gain: 0.5 });
const highTrebleGain = new GainNode(context, { gain: 0.5 });
// If `drive` is about `3`, distortion sound is Crunch.
// If `drive` is about `5`, distortion sound is Overdrive.
// If `drive` is about `8`, distortion sound is Distortion.
const drive = 5;
const samples = 127;
const curve = createCurve(drive, samples);
const oversample = '4x';
const preShaper = new WaveShaperNode(context, { curve, oversample });
const postShaper = new WaveShaperNode(context, { curve, oversample });
const lowpass = new BiquadFilterNode(context, { type: 'lowpass', frequency: 4000, Q: -3 });
const highpass = new BiquadFilterNode(context, { type: 'highpass', frequency: 40, Q: -3 });
const bass = new BiquadFilterNode(context, { type: 'lowshelf', frequency: 240 });
const middle = new BiquadFilterNode(context, { type: 'peaking', frequency: 500, Q: 1.5 });
const treble = new BiquadFilterNode(context, { type: 'highshelf', frequency: 1600 });
fetch('./assets/one-shots/electric-guitar-clean-chord.mp3')
.then((response) => {
return response.arrayBuffer();
})
.then((arrayBuffer) => {
const successCallback = (audioBuffer) => {
const source = new AudioBufferSourceNode(context, { buffer: audioBuffer });
// AudioBufferSourceNode (Input) -> GainNode (Input) -> BiquadFilterNode (High-Pass Filter) -> BiquadFilterNode (Low-Pass Filter) -> GainNode (Middle and Low Gain) -> BiquadFilterNode (High-Pass Filter)
source.connect(preHighpass1);
preHighpass1.connect(preLowpass);
preLowpass.connect(middleAndBassGain);
middleAndBassGain.connect(preHighpass3);
// AudioBufferSourceNode (Input) -> BiquadFilterNode (High-Pass Filter) -> GainNode (High Treble Gain) -> BiquadFilterNode (High-Pass Filter)
source.connect(preHighpass2);
preHighpass2.connect(highTrebleGain);
highTrebleGain.connect(preHighpass3);
// BiquadFilterNode (High-Pass Filter) -> WaveShaperNode (Pre Clipping)
preHighpass3.connect(preShaper);
// WaveShaperNode (Pre Clipping) -> BiquadFilterNode (Low-Pass Filter) -> BiquadFilterNode (High-Pass Filter)
preShaper.connect(lowpass);
lowpass.connect(highpass);
// BiquadFilterNode (High-Pass Filter) -> WaveShaperNode (Post Clipping)
highpass.connect(postShaper);
// WaveShaperNode (Post Clipping) -> Post Equalizer (Low-Shelving Filter -> Peaking Filter -> High-Shelving Filter) -> GainNode (Master Volume)
postShaper.connect(bass);
bass.connect(middle);
middle.connect(treble);
treble.connect(mastervolume);
// GainNode (Master Volume) -> AudioDestinationNode (Output)
mastervolume.connect(context.destination);
source.start(0);
};
const errorCallback = (error) => {
// error handling
};
context.decodeAudioData(arrayBuffer, successCallback, errorCallback);
})
.catch((error) => {
// error handling
});
キャビネットとスピーカーシミュレーター
プリアンプで生成された音は, 実際のアンプ, 特に, スタックアンプのような,
キャビネットとそれに内蔵されているスピーカーから発生するような音にはなりません. それを再現するために,
アンプシミュレーターではプリアンプだけでなく, キャビネットのタイプやスピーカーのサイズや数 (例えば, 12 インチを 4 つなど) を選択できる,
音楽的な表現だと, いわゆる, 箱鳴り感 を再現できる, スピーカーシミュレーター も含まれています.
実際に存在するキャビネットとスピーカーをシミュレートするには, キャビネット内のインパルス応答を測定したオーディオデータと
ConvolverNode
を利用して実装するのがベストです. しかしながら, RIR と異なって, フリーで使える音源が少ないことも考慮して,
簡易的なスピーカーシミュレーターの実装を解説します (簡易的なスピーカーシミュレーターでも, 箱鳴り感は十分再現できます).
簡易的なスピーカーシミュレーターの実装であれば, Low-Pass Filter と Notch Filter を接続することで実装できます.
const context = new AudioContext();
const speakerSimulatorNotch = new BiquadFilterNode(context, { type: 'notch', frequency: 8000, Q: 1 });
const speakerSimulatorLowpass = new BiquadFilterNode(context, { type: 'lowpass', frequency: 3200, Q: 6 });
// BiquadFilterNode (Notch Filter) -> BiquadFilterNode (Low-Pass Filter)
speakerSimulatorNotch.connect(speakerSimulatorLowpass);
スピーカーシミュレーターのノード接続を, プリアンプの次に接続します. (プリアンプとスピーカーシミュレーターの処理の後の音量を調整できるように,
Master Volume となる GainNode
の接続は, スピーカーシミュレーターの後に変更します. つまり,
スピーカーシミュレーターの接続先となります).
function createCurve(drive, numberOfSamples) {
const curves = new Float32Array(numberOfSamples);
const index = Math.trunc((numberOfSamples - 1) / 2);
const d = (10 ** ((drive / 5.0) - 1.0)) - 0.1;
const c = (d / 5.0) + 1.0;
let peak = 0.4;
if (c === 1) {
peak = 1.0;
} else if ((c > 1) && (c < 1.04)) {
peak = (-15.5 * c) + 16.52;
}
for (let i = 0; i < index; i++) {
curves[index + i] = peak * (((+1) - (c ** (-i))) + ((i * (c ** (-index))) / index));
curves[index - i] = peak * (((-1) + (c ** (-i))) - ((i * (c ** (-index))) / index));
}
curves[index] = 0;
return curves;
}
const context = new AudioContext();
const mastervolume = new GainNode(context, { gain: 0.3 });
const preLowpass = new BiquadFilterNode(context, { type: 'lowpass', frequency: 3200, Q: -3 });
const preHighpass1 = new BiquadFilterNode(context, { type: 'highpass', frequency: 80, Q: -3 });
const preHighpass2 = new BiquadFilterNode(context, { type: 'highpass', frequency: 640, Q: -3 });
const preHighpass3 = new BiquadFilterNode(context, { type: 'highpass', frequency: 160, Q: -3 });
const middleAndBassGain = new GainNode(context, { gain: 0.5 });
const highTrebleGain = new GainNode(context, { gain: 0.5 });
// If `drive` is about `3`, distortion sound is Crunch.
// If `drive` is about `5`, distortion sound is Overdrive.
// If `drive` is about `8`, distortion sound is Distortion.
const drive = 5;
const samples = 127;
const curve = createCurve(drive, samples);
const oversample = '4x';
const preShaper = new WaveShaperNode(context, { curve, oversample });
const postShaper = new WaveShaperNode(context, { curve, oversample });
const lowpass = new BiquadFilterNode(context, { type: 'lowpass', frequency: 4000, Q: -3 });
const highpass = new BiquadFilterNode(context, { type: 'highpass', frequency: 40, Q: -3 });
const bass = new BiquadFilterNode(context, { type: 'lowshelf', frequency: 240 });
const middle = new BiquadFilterNode(context, { type: 'peaking', frequency: 500, Q: 1.5 });
const treble = new BiquadFilterNode(context, { type: 'highshelf', frequency: 1600 });
const speakerSimulatorNotch = new BiquadFilterNode(context, { type: 'notch', frequency: 8000, Q: 1 });
const speakerSimulatorLowpass = new BiquadFilterNode(context, { type: 'lowpass', frequency: 3200, Q: 6 });
fetch('./assets/one-shots/electric-guitar-clean-chord.mp3')
.then((response) => {
return response.arrayBuffer();
})
.then((arrayBuffer) => {
const successCallback = (audioBuffer) => {
const source = new AudioBufferSourceNode(context, { buffer: audioBuffer });
// Preamp
// AudioBufferSourceNode (Input) -> GainNode (Input) -> BiquadFilterNode (High-Pass Filter) -> BiquadFilterNode (Low-Pass Filter) -> GainNode (Middle and Low Gain) -> BiquadFilterNode (High-Pass Filter)
source.connect(preHighpass1);
preHighpass1.connect(preLowpass);
preLowpass.connect(middleAndBassGain);
middleAndBassGain.connect(preHighpass3);
// AudioBufferSourceNode (Input) -> BiquadFilterNode (High-Pass Filter) -> GainNode (High Treble Gain) -> BiquadFilterNode (High-Pass Filter)
source.connect(preHighpass2);
preHighpass2.connect(highTrebleGain);
highTrebleGain.connect(preHighpass3);
// BiquadFilterNode (High-Pass Filter) -> WaveShaperNode (Pre Clipping)
preHighpass3.connect(preShaper);
// WaveShaperNode (Pre Clipping) -> BiquadFilterNode (Low-Pass Filter) -> BiquadFilterNode (High-Pass Filter)
preShaper.connect(lowpass);
lowpass.connect(highpass);
// BiquadFilterNode (High-Pass Filter) -> WaveShaperNode (Post Clipping)
highpass.connect(postShaper);
// WaveShaperNode (Post Clipping) -> Post Equalizer (Low-Shelving Filter -> Peaking Filter -> High-Shelving Filter) -> BiquadFilterNode (Speaker Simulator)
postShaper.connect(bass);
bass.connect(middle);
middle.connect(treble);
treble.connect(speakerSimulatorNotch);
// Speaker Simulator
// BiquadFilterNode (Notch Filter) -> BiquadFilterNode (Low-Pass Filter) -> GainNode (Master Volume);
speakerSimulatorNotch.connect(speakerSimulatorLowpass);
speakerSimulatorLowpass.connect(mastervolume);
// GainNode (Master Volume) -> AudioDestinationNode (Output)
mastervolume.connect(context.destination);
source.start(0);
};
const errorCallback = (error) => {
// error handling
};
context.decodeAudioData(arrayBuffer, successCallback, errorCallback);
})
.catch((error) => {
// error handling
});
ConvolverNode によるスピーカーシミュレーターの実装
シミューレートしたいキャビネットとスピーカーのインパルス応答を測定したオーディオデータがあれば, リバーブを実装するのと同様に,
ConvolverNode
の buffer
プロパティに, そのオーディオデータの AudioBuffer
インスタンスを設定することで,
より忠実にキャビネットとスピーカーをシミュレートすることが可能です.
キャビネット内を小さな部屋とみなせば, RIR をコンボリューション積分しているのと同じであり, また, キャビネット内をシステム (系) とみなせば,
キャビネット内をモデリングする伝達関数を乗算しているのと同じことです.
スピーカーシミュレーターとしての ConvolverNode
インスタンスも, プリアンプの次に接続します (audioBuffer
変数は,
Fetch API
などから取得した ArrayBuffer
インスタンスを AudioContext
インスタンスの
decodeAudioData
メソッドの引数に指定して生成した (キャビネットとスピーカーのインパルス応答のオーディオデータの実体となる)
AudioBuffer
インスタンスと考えてください).
function createCurve(drive, numberOfSamples) {
const curves = new Float32Array(numberOfSamples);
const index = Math.trunc((numberOfSamples - 1) / 2);
const d = (10 ** ((drive / 5.0) - 1.0)) - 0.1;
const c = (d / 5.0) + 1.0;
let peak = 0.4;
if (c === 1) {
peak = 1.0;
} else if ((c > 1) && (c < 1.04)) {
peak = (-15.5 * c) + 16.52;
}
for (let i = 0; i < index; i++) {
curves[index + i] = peak * (((+1) - (c ** (-i))) + ((i * (c ** (-index))) / index));
curves[index - i] = peak * (((-1) + (c ** (-i))) - ((i * (c ** (-index))) / index));
}
curves[index] = 0;
return curves;
}
const context = new AudioContext();
const mastervolume = new GainNode(context, { gain: 0.3 });
const preLowpass = new BiquadFilterNode(context, { type: 'lowpass', frequency: 3200, Q: -3 });
const preHighpass1 = new BiquadFilterNode(context, { type: 'highpass', frequency: 80, Q: -3 });
const preHighpass2 = new BiquadFilterNode(context, { type: 'highpass', frequency: 640, Q: -3 });
const preHighpass3 = new BiquadFilterNode(context, { type: 'highpass', frequency: 160, Q: -3 });
const middleAndBassGain = new GainNode(context, { gain: 0.5 });
const highTrebleGain = new GainNode(context, { gain: 0.5 });
// If `drive` is about `3`, distortion sound is Crunch.
// If `drive` is about `5`, distortion sound is Overdrive.
// If `drive` is about `8`, distortion sound is Distortion.
const drive = 5;
const samples = 127;
const curve = createCurve(drive, samples);
const oversample = '4x';
const preShaper = new WaveShaperNode(context, { curve, oversample });
const postShaper = new WaveShaperNode(context, { curve, oversample });
const lowpass = new BiquadFilterNode(context, { type: 'lowpass', frequency: 4000, Q: -3 });
const highpass = new BiquadFilterNode(context, { type: 'highpass', frequency: 40, Q: -3 });
const bass = new BiquadFilterNode(context, { type: 'lowshelf', frequency: 240 });
const middle = new BiquadFilterNode(context, { type: 'peaking', frequency: 500, Q: 1.5 });
const treble = new BiquadFilterNode(context, { type: 'highshelf', frequency: 1600 });
const speakerSimulator = new ConvolverNode(context);
// Set instance of `AudioBuffer` that is audio data in cabinet to `buffer` property
// speakerSimulator.buffer = audioBuffer;
// (AudioSourceNode ->) GainNode (Input) -> BiquadFilterNode (High-Pass Filter) -> BiquadFilterNode (Low-Pass Filter) -> GainNode (Middle and Low Gain) -> BiquadFilterNode (High-Pass Filter)
preHighpass1.connect(preLowpass);
preLowpass.connect(middleAndBassGain);
middleAndBassGain.connect(preHighpass3);
// (AudioSourceNode ->) BiquadFilterNode (High-Pass Filter) -> GainNode (High Treble Gain) -> BiquadFilterNode (High-Pass Filter)
preHighpass2.connect(highTrebleGain);
highTrebleGain.connect(preHighpass3);
// BiquadFilterNode (High-Pass Filter) -> WaveShaperNode (Pre Clipping)
preHighpass3.connect(preShaper);
// WaveShaperNode (Pre Clipping) -> BiquadFilterNode (Low-Pass Filter) -> BiquadFilterNode (High-Pass Filter)
preShaper.connect(lowpass);
lowpass.connect(highpass);
// BiquadFilterNode (High-Pass Filter) -> WaveShaperNode (Post Clipping)
highpass.connect(postShaper);
// WaveShaperNode (Post Clipping) -> Post Equalizer (Low-Shelving Filter -> Peaking Filter -> High-Shelving Filter) -> ConvolverNode (Speaker Simulator)
postShaper.connect(bass);
bass.connect(middle);
middle.connect(treble);
treble.connect(speakerSimulator);
// ConvolverNode (Speaker Simulator) -> GainNode (Master Volume)
speakerSimulator.connect(mastervolume);
// GainNode (Master Volume) -> AudioDestinationNode (Output)
mastervolume.connect(context.destination);
アンプシミュレーターと歪み系エフェクターによるディストーションサウンド
ここまでで, アンプ (アンプシミュレーター) と歪み系エフェクターによるディストーションサウンドを解説したので, それらを組み合わせて, さらに,
ハードコーディングしていたパラメータをインタラクティブに制御できるようにしたコード例です.
<div>
<button type="button" id="button-picking-down" data-index="0">Picking Down</button>
<button type="button" id="button-picking-up" data-index="1">Picking Up</button>
<button type="button" id="button-chord" data-index="2">Chord</button>
</div>
<dl>
<dt><label for="select-distortion-type">Overdrive / Fuzz</label></dt>
<dd>
<select id="select-distortion-type">
<option value="" selected>none</option>
<option value="overdrive">Overdrive</option>
<option value="fuzz">Fuzz</option>
</select>
</dd>
<dt><label for="range-distortion-drive">Drive</label></dt>
<dd>
<input type="range" id="range-distortion-drive" value="0.5" min="0" max="0.95" step="0.05" disabled />
<span id="print-distortion-drive-value">0.5</span>
</dd>
</dl>
<dl>
<dt>
<label for="checkbox-preamp"><span>Preamp</span></label>
</dt>
<dd><input type="checkbox" id="checkbox-preamp" checked /></dd>
</dl>
<dl>
<dt><label for="range-preamp-normal-gain">Normal Gain (Middle and Low Gain)</label></dt>
<dd>
<input type="range" id="range-preamp-normal-gain" value="0.5" min="0" max="1" step="0.05" />
<span id="print-preamp-normal-gain-value">0.5</span>
</dd>
<dt><label for="range-preamp-high-treble-gain">High Treble Gain</label></dt>
<dd>
<input type="range" id="range-preamp-high-treble-gain" value="0.5" min="0" max="1" step="0.05" />
<span id="print-preamp-high-treble-value">0.5</span>
</dd>
<dt><label for="range-preamp-drive">Drive</label></dt>
<dd>
<input type="range" id="range-preamp-drive" value="5" min="0" max="10" step="0.5" />
<span id="print-preamp-drive-value">5.0</span>
</dd>
<dt><label for="range-preamp-post-equalizer-bass">Bass</label></dt>
<dd>
<input type="range" id="range-preamp-post-equalizer-bass" value="0" min="-24" max="24" step="1" />
<span id="print-preamp-post-equalizer-bass-value">0 dB</span>
</dd>
<dt><label for="range-preamp-post-equalizer-middle">Middle</label></dt>
<dd>
<input type="range" id="range-preamp-post-equalizer-middle" value="0" min="-24" max="24" step="1" />
<span id="print-preamp-post-equalizer-middle-value">0 dB</span>
</dd>
<dt><label for="range-preamp-post-equalizer-treble">Treble</label></dt>
<dd>
<input type="range" id="range-preamp-post-equalizer-treble" value="0" min="-24" max="24" step="1" />
<span id="print-preamp-post-equalizer-treble-value">0 dB</span>
</dd>
</dl>
<dl>
<dt>
<label for="checkbox-speaker-simulator"><span>Speaker Simulator</span></label>
</dt>
<dd><input type="checkbox" id="checkbox-speaker-simulator" checked /></dd>
</dl>
<dl>
<dt><label for="range-distortion-mastervolume">Master Volume</label></dt>
<dd>
<input type="range" id="range-distortion-mastervolume" value="0.5" min="0" max="1" step="0.05" />
<span id="print-distortion-mastervolume-value">0.5</span>
</dd>
</dl>
function createPreampCurve(drive, numberOfSamples) {
const curves = new Float32Array(numberOfSamples);
const index = Math.trunc((numberOfSamples - 1) / 2);
const d = (10 ** ((drive / 5.0) - 1.0)) - 0.1;
const c = (d / 5.0) + 1.0;
let peak = 0.4;
if (c === 1) {
peak = 1.0;
} else if ((c > 1) && (c < 1.04)) {
peak = (-15.5 * c) + 16.52;
}
for (let i = 0; i < index; i++) {
curves[index + i] = peak * (((+1) - (c ** (-i))) + ((i * (c ** (-index))) / index));
curves[index - i] = peak * (((-1) + (c ** (-i))) - ((i * (c ** (-index))) / index));
}
curves[index] = 0;
return curves;
}
function createAsymmetricalOverdriveCurve(drive, numberOfSamples) {
if (drive < 0 || drive >= 1) {
return null;
}
const curves = new Float32Array(numberOfSamples);
const k = (2 * drive) / (1 - drive);
for (let n = 0; n < numberOfSamples; n++) {
const x = (((n - 0) * (1 - (-1))) / (numberOfSamples - 0)) + (-1);
const y = ((1 + k) * x) / (1 + (k * Math.abs(x)));
// Asymmetrical clipping curve
curves[n] = (y > 0) ? y : ((1 - ((drive > 0.5) ? 0.5 : drive)) * y);
}
return curves;
}
const context = new AudioContext();
const samples = 127;
const oversample = '4x';
const shaper = new WaveShaperNode(context);
const preLowpass = new BiquadFilterNode(context, { type: 'lowpass', frequency: 3200, Q: -3 });
const preHighpass1 = new BiquadFilterNode(context, { type: 'highpass', frequency: 80, Q: -3 });
const preHighpass2 = new BiquadFilterNode(context, { type: 'highpass', frequency: 640, Q: -3 });
const preHighpass3 = new BiquadFilterNode(context, { type: 'highpass', frequency: 160, Q: -3 });
const highTrebleGain = new GainNode(context, { gain: 0.5 });
const middleAndBassGain = new GainNode(context, { gain: 0.5 });
const preShaper = new WaveShaperNode(context, { curve: createPreampCurve(5.0, samples), oversample });
const postShaper = new WaveShaperNode(context, { curve: createPreampCurve(5.0, samples), oversample });
const lowpass = new BiquadFilterNode(context, { type: 'lowpass', frequency: 4000, Q: -3 });
const highpass = new BiquadFilterNode(context, { type: 'highpass', frequency: 40, Q: -3 });
const bass = new BiquadFilterNode(context, { type: 'lowshelf', frequency: 240 });
const middle = new BiquadFilterNode(context, { type: 'peaking', frequency: 500, Q: 1.5 });
const treble = new BiquadFilterNode(context, { type: 'highshelf', frequency: 1600 });
const speakerSimulatorNotch = new BiquadFilterNode(context, { type: 'notch', frequency: 8000, Q: 1 });
const speakerSimulatorLowpass = new BiquadFilterNode(context, { type: 'lowpass', frequency: 3200, Q: 6 });
const mastervolume = new GainNode(context, { gain: 0.5 });
const buttonElementPickingDown = document.getElementById('button-picking-down');
const buttonElementPickingUp = document.getElementById('button-picking-up');
const buttonElementChord = document.getElementById('button-chord');
const checkboxElementPreamp = document.getElementById('checkbox-preamp');
const checkboxElementSpeakerSimulator = document.getElementById('checkbox-speaker-simulator');
const rangePreampNormalGainElement = document.getElementById('range-preamp-normal-gain');
const rangePreampHighTrebleGainElement = document.getElementById('range-preamp-high-treble-gain');
const rangePreampDriveElement = document.getElementById('range-preamp-drive');
const rangePreampBassElement = document.getElementById('range-preamp-post-equalizer-bass');
const rangePreampMiddleElement = document.getElementById('range-preamp-post-equalizer-middle');
const rangePreampTrebleElement = document.getElementById('range-preamp-post-equalizer-treble');
const selectEffectorDistortionTypeElement = document.getElementById('select-distortion-type');
const rangeEffectorDistortionDriveElement = document.getElementById('range-distortion-drive');
const rangeDistortionMasterVolumeElement = document.getElementById('range-distortion-mastervolume');
const spanPrintPreampNormalGainElement = document.getElementById('print-preamp-normal-gain-value');
const spanPrintPreampHighTrebleGainElement = document.getElementById('print-preamp-high-treble-value');
const spanPrintPreampDriveElement = document.getElementById('print-preamp-drive-value');
const spanPrintPreampBassElement = document.getElementById('print-preamp-post-equalizer-bass-value');
const spanPrintPreampMiddleElement = document.getElementById('print-preamp-post-equalizer-middle-value');
const spanPrintPreampTrebleElement = document.getElementById('print-preamp-post-equalizer-treble-value');
const spanPrintEffectorDistortionDriveElement = document.getElementById('print-distortion-drive-value');
const spanPrintDistortionMasterVolumeElement = document.getElementById('print-distortion-mastervolume-value');
Promise
.all([
fetch('./assets/one-shots/electric-guitar-clean-picking-down.mp3'),
fetch('./assets/one-shots/electric-guitar-clean-picking-up.mp3'),
fetch('./assets/one-shots/electric-guitar-clean-chord.mp3')
])
.then(async (responses) => {
const audioBuffers = await Promise.all(responses.map(async (response) => {
const arrayBuffer = await response.arrayBuffer();
const audioBuffer = await context.decodeAudioData(arrayBuffer);
return audioBuffer;
}));
const onDown = async (event) => {
const index = Number(event.target.getAttribute('data-index'));
const buffer = audioBuffers[index];
const source = new AudioBufferSourceNode(context, { buffer });
shaper.disconnect(0);
treble.disconnect(0);
speakerSimulatorLowpass.disconnect(0);
source.connect(shaper);
if (checkboxElementPreamp.checked && checkboxElementSpeakerSimulator.checked) {
shaper.connect(preHighpass1);
preHighpass1.connect(preLowpass);
preLowpass.connect(middleAndBassGain);
middleAndBassGain.connect(preHighpass3);
shaper.connect(preHighpass2);
preHighpass2.connect(highTrebleGain);
highTrebleGain.connect(preHighpass3);
preHighpass3.connect(preShaper);
preShaper.connect(lowpass);
lowpass.connect(highpass);
highpass.connect(postShaper);
postShaper.connect(bass);
bass.connect(middle);
middle.connect(treble);
treble.connect(speakerSimulatorNotch);
speakerSimulatorNotch.connect(speakerSimulatorLowpass);
speakerSimulatorLowpass.connect(mastervolume);
} else {
if (checkboxElementPreamp.checked) {
shaper.connect(preHighpass1);
preHighpass1.connect(preLowpass);
preLowpass.connect(middleAndBassGain);
middleAndBassGain.connect(preHighpass3);
shaper.connect(preHighpass2);
preHighpass2.connect(highTrebleGain);
highTrebleGain.connect(preHighpass3);
preHighpass3.connect(preShaper);
preShaper.connect(lowpass);
lowpass.connect(highpass);
highpass.connect(postShaper);
postShaper.connect(bass);
bass.connect(middle);
middle.connect(treble);
treble.connect(mastervolume);
} else if (checkboxElementSpeakerSimulator.checked) {
shaper.connect(speakerSimulatorNotch);
speakerSimulatorNotch.connect(speakerSimulatorLowpass);
speakerSimulatorLowpass.connect(mastervolume);
} else {
shaper.connect(mastervolume);
}
}
mastervolume.connect(context.destination);
source.start(0);
};
buttonElementPickingDown.addEventListener('mousedown', onDown);
buttonElementPickingDown.addEventListener('touchstart', onDown);
buttonElementPickingUp.addEventListener('mousedown', onDown);
buttonElementPickingUp.addEventListener('touchstart', onDown);
buttonElementChord.addEventListener('mousedown', onDown);
buttonElementChord.addEventListener('touchstart', onDown);
checkboxElementPreamp.addEventListener('change', (event) => {
if (event.currentTarget.checked) {
rangePreampNormalGainElement.removeAttribute('disabled');
rangePreampHighTrebleGainElement.removeAttribute('disabled');
rangePreampDriveElement.removeAttribute('disabled');
rangePreampBassElement.removeAttribute('disabled');
rangePreampMiddleElement.removeAttribute('disabled');
rangePreampTrebleElement.removeAttribute('disabled');
} else {
rangePreampNormalGainElement.setAttribute('disabled', 'disabled');
rangePreampHighTrebleGainElement.setAttribute('disabled', 'disabled');
rangePreampDriveElement.setAttribute('disabled', 'disabled');
rangePreampBassElement.setAttribute('disabled', 'disabled');
rangePreampMiddleElement.setAttribute('disabled', 'disabled');
rangePreampTrebleElement.setAttribute('disabled', 'disabled');
}
});
selectEffectorDistortionTypeElement.addEventListener('change', (event) => {
const drive = rangeEffectorDistortionDriveElement.valueAsNumber;
rangeEffectorDistortionDriveElement.removeAttribute('disabled');
switch (event.currentTarget.value) {
case 'overdrive': {
shaper.curve = createAsymmetricalOverdriveCurve(drive, samples);
shaper.oversample = '2x';
break;
}
case 'fuzz': {
shaper.curve = new Float32Array([drive, 0, drive]);
shaper.oversample = '4x';
break;
}
default: {
shaper.curve = null;
shaper.oversample = 'none';
rangeEffectorDistortionDriveElement.setAttribute('disabled', 'disabled');
break;
}
}
});
rangeEffectorDistortionDriveElement.addEventListener('input', (event) => {
const drive = event.currentTarget.valueAsNumber;
switch (selectEffectorDistortionTypeElement.value) {
case 'overdrive': {
shaper.curve = createAsymmetricalOverdriveCurve(drive, samples);
shaper.oversample = '2x';
break;
}
case 'fuzz': {
shaper.curve = new Float32Array([drive, 0, drive]);
shaper.oversample = '4x';
break;
}
default: {
shaper.curve = null;
shaper.oversample = 'none';
break;
}
}
spanPrintEffectorDistortionDriveElement.textContent = drive.toFixed(1);
});
rangePreampNormalGainElement.addEventListener('input', (event) => {
const gain = event.currentTarget.valueAsNumber;
middleAndBassGain.gain.value = gain;
spanPrintPreampNormalGainElement.textContent = gain.toString(10);
});
rangePreampHighTrebleGainElement.addEventListener('input', (event) => {
const gain = event.currentTarget.valueAsNumber;
highTrebleGain.gain.value = gain;
spanPrintPreampHighTrebleGainElement.textContent = gain.toString(10);
});
rangePreampDriveElement.addEventListener('input', (event) => {
const drive = event.currentTarget.valueAsNumber;
preShaper.curve = createPreampCurve(drive, samples);
postShaper.curve = createPreampCurve(drive, samples);
spanPrintPreampDriveElement.textContent = drive.toFixed(1);
});
rangePreampBassElement.addEventListener('input', (event) => {
const gain = event.currentTarget.valueAsNumber;
bass.gain.value = gain;
spanPrintPreampBassElement.textContent = `${gain} dB`;
});
rangePreampMiddleElement.addEventListener('input', (event) => {
const gain = event.currentTarget.valueAsNumber;
middle.gain.value = gain;
spanPrintPreampMiddleElement.textContent = `${gain} dB`;
});
rangePreampTrebleElement.addEventListener('input', (event) => {
const gain = event.currentTarget.valueAsNumber;
treble.gain.value = gain;
spanPrintPreampTrebleElement.textContent = `${gain} dB`;
});
rangeDistortionMasterVolumeElement.addEventListener('input', (event) => {
const gain = event.currentTarget.valueAsNumber;
mastervolume.gain.value = gain;
spanPrintDistortionMasterVolumeElement.textContent = gain.toString(10);
});
})
.catch((error) => {
// error handling
});
音源はエレキギターのクリーンサウンドのワンショットオーディオを 3 つ用意しました (単音ピッキング 2 つ (音高ダウンとアップ) とコード弾き).
制御可能なパラメータが多いので, インタラクティブに制御できる部分は最小限にしました. クリッピングカーブの Float32Array
のサイズ,
および, 歪み系エフェクターのオーバードライブカーブは非対称クリッピング, 整流は全波整流に固定していますが, アプリケーションの要件次第では,
これらもインタラクティブに制御可能にすることもあるでしょう. インタラクティブな部分が多く, コードが長くなっていますが, 分解すれば,
これまで解説してきた, 歪み系エフェクターやアンプシミュレーターの実装です.
追加で解説すべきなのは, アンプシミュレーターと歪み系エフェクターの接続順 です. 一般的には
(マルチエフェクターなどエフェクターの接続順が固定されている場合でも),
アンプシミュレーターの前に歪み系エフェクターを接続します (あとのセクションで解説しますが,
エフェクターの接続順は出力されるサウンドに大きく影響します). UI も, 接続順がイメージしやすいように左から順に並べています.
サンプルコードにおける注意点ですが, 歪み系エフェクター, プリアンプ, スピーカーシミュレーターの有効・無効に応じて, 再生時のノード接続が変わるので,
ワンショットオーディオを再生するイベントリスナーで, 歪み系エフェクターのためのノード (コードでの変数名は shaper
),
プリアンプの最後の接続ノード (コードでの変数名は treble
), スピーカーシミュレーターの最後の接続ノード (コードでの変数名は
speakerSimulatorLowpass
) それぞれで disconnect
メソッドを呼び出して, 前回再生時のノード接続をクリアしていることです
(この処理がないと, 例えば, スピーカーシミュレーターつきで再生後, チェックボックスを外して, スピーカーシミュレーターなしで再生すると,
スピーカシミュレーターつきのノード接続が残るので,
スピーカーシミュレーターつきの音とスピーカーシミュレーターなしの音が合成されて再生されてしまいます). サンプルコードではありますが,
disconnect
メソッドの明示的な呼び出しが必要な, 比較的よくあるユースケースです.
Picking Down
Picking Up
Chord
真空管ギターアンプの歴史
モデリングしたいアンプの数だけ解説することは難しいので, Marshall ライクなアンプシミュレーターの実装のみを解説しましたが,
真空管ギターアンプの歴史を知ることで, モデリングしたいアンプの実装のヒントになるかもしれません.
真空管ギタープリアンプの回路設計 で詳細は記載されていますが, 真空管ギターアンプのルーツは大きく分類すると, Fender (フェンダー )
をルーツとする真空管ギターアンプとそれ以外に分離できます. Marshall (マーシャル), Mesa/Boogie (メサ・ブギー), Bogner (ボグナー) など,
ハイゲインな真空管ギターアンプのルーツは, Fender にあるので, Fender のギターアンプ設計を理解することで, 真空管ギターアンプの定石を知って,
モデリングしたいアンプに近い実装が可能になるかもしれません (参考として, Marshall ライクな実装のみを解説しましたが, 同じ Web ページに, Fender
(ライク) な実装も解説されています).
Fender 系以外の (Fender をルーツとしない) ギターアンプとしては, 有名なのは VOX (ヴォックス), HIWATT (ハイワット), Orange (オレンジ)
などがあります.
コンプレッサー
コンプレッサーは, 振幅の大きな音を (相対的に) 小さく, 振幅の小さな音を (相対的に) 大きくすることによって, 振幅値のオーバーフロー (例として,
量子化ビット 8 bit
で, 2 の補数表現の場合, 127
(0b0111111
) が正の値の上限で, 1
増えると,
-128
(0b1000000
) で負数となって波形が大きくゆがんでしまいます) や (振幅値のオーバーフローを防ぐための)
クリッピング処理による意図しない歪みを防止しつつ, 迫力のあるサウンドに変化させるエフェクターです. 音楽的な表現だと,
音の粒をそろえる エフェクターと言えます.
音楽ジャンルにもよりますが, ロックやポップスのレコーディングでは一般的に利用されるエフェクターです. ロックやポップスでは, ギター数パート, ベース,
ドラム, そして, ボーカルと定石の構成でも楽音数が多く, ミキシングをある程度オートマティックにおこなう必要があるからです (逆に, クラシックなどでは
(例えば, ピアノ演奏のみの場合), 演奏時の強弱 (フォルテやピアノなど) も忠実に再現することが理想とされます).
DynamicsCompressorNode
Web Audio API でコンプレッサーを実装するのはおそらくもっとも簡単です.
DynamicsCompressorNode
インスタンスを生成して接続するだけです. これだけで, それなりのコンプレッサーとして機能します (DynamicsCompressorNode
インスタンスの各 AudioParam
のデフォルト値が機能するような値になっていますが, コンストラクタの第 2 引数の
DynamicsCompressorOptions
でインスタンス生成時に値を設定したり, AudioParam
のセッターで値を設定したりはできます).
DynamicsCompressorNode
のノード接続図
const context = new AudioContext();
const oscillator = new OscillatorNode(context);
const compressor = new DynamicsCompressorNode(context);
// If use `createDynamicsCompressor`
// const compressor = context.createDynamicsCompressor();
// OscillatorNode (Input) -> DynamicsCompressorNode (Compressor) -> AudioDestinationNode (Output)
oscillator.connect(compressor);
compressor.connect(context.destination);
// Start oscillator immediately
oscillator.start(0);
// Stop oscillator after 10 sec
oscillator.stop(context.currentTime + 10);
以下は, 基本波形の合成セクション でコード例として記載した, 和音となる
OscillatorNode
の合成で, ゲインの調整ではなく, コンプレッサーを利用することで (DynamicsCompressorNode
インスタンスを接続することで), 意図しない音割れを防止するコードです.
const context = new AudioContext();
// C major chord
const oscillatorC = new OscillatorNode(context, { frequency: 261.6255653005991 });
const oscillatorE = new OscillatorNode(context, { frequency: 329.6275569128705 });
const oscillatorG = new OscillatorNode(context, { frequency: 391.9954359817500 });
const compressor = new DynamicsCompressorNode(context);
// If use `createDynamicsCompressor`
// const compressor = context.createDynamicsCompressor();
// OscillatorNode (Input) -> DynamicsCompressorNode (Compressor) -> AudioDestinationNode (Output)
oscillatorC.connect(compressor);
oscillatorE.connect(compressor);
oscillatorG.connect(compressor);
compressor.connect(context.destination);
// Start oscillators immediately
oscillatorC.start(0);
oscillatorE.start(0);
oscillatorG.start(0);
// Stop oscillators after 10 sec
oscillatorC.stop(context.currentTime + 10);
oscillatorE.stop(context.currentTime + 10);
oscillatorG.stop(context.currentTime + 10);
「おそらくもっとも簡単」と表現したのは, AudioNode
単体の接続とデフォルト値でエフェクターとして機能することが理由です. すでに,
解説したように DelayNode
や BiquadFilterNode
単体としては現実世界の (音楽表現として意味をもつ)
エフェクターとして機能することはないからです.
コンプレッサーの仕組み
DynamicsCompressorNode
で制御可能なパラメータ (AudioParam
インスタンス) としては, threshold
, knee
, ratio
, attack
, release
がありますが, コンプレッサーの効果に大きく影響するのは, threshold
(閾値 ) と
ratio
(レシオ値 ) です.
threshold
は, コンプレッサーによる振幅の圧縮がかかる閾値を指定します. 逆に言うと,
threshold
以下の振幅には影響を与えません. 単位はデシベルで, -100 dB
から
0 dB
デシベルまでの範囲で設定可能です. threshold
が 0
(dB
) の場合は,
コンプレッサーが効いていないのと同じで, -100
(dB
) に近づくほど,
より小さな振幅に対してもコンプレッサーが効くことになります (デフォルト値は, -24
(dB
) です).
ratio
は, threshold
を超えた振幅を圧縮する割合を指定します (デフォルト値は 12
なので,
threshold
を超えた振幅値を $\frac{1}{12}$ 倍に圧縮します). したがって, ratio
が
1
(最小値) の場合は, まったく圧縮をしないので, コンプレッサーが効いてないのと同じで, ここから
$\infty$ (ただし, 実際の最大値は 20
です) に近づくほど,
threshold
に近い値に圧縮されていきます. 圧縮後の振幅値は, threshold
の振幅値と圧縮された振幅値を加算した値となります.
リミッター は, ratio
が $\infty$ のコンプレッサーであり,
threshold
を超える振幅はすべて threshold
にまで圧縮します.
threshold
(閾値) と ratio
(レシオ値)
knee
は threshold
を超えた振幅の圧縮を緩やかに処理する (Soft knee ) か, 急峻に処理する (Hard knee )
かを決定します. デフォルト値は 30
(dB
) ですが, 最大値の 40
(dB
) に近づくほど,
より緩やかな振幅の圧縮となり, 最小値の 0
(dB
) に近づくほど, 急峻な圧縮となります (音楽的には,
コンプレッサーが急激に効いてしまうと, アナログ感のあるスムーズな圧縮にならず, いわゆる, 「デジタルくさい音 」と言われることもあるので,
それを緩和するパラメータと考えるとよいかもしれません (また, knee
の値とコンプレッションカーブの関係 (関数) は,
単調増加する関数であることが唯一の仕様となっているので, その詳細はレンダリングエンジンの実装に依存することになります).
Hard knee (左) と Soft knee (右)
また, 振幅を圧縮した結果, 振幅を大きくする余地が発生します. これによって, 相対的に振幅の小さい音もある程度大きくすることが可能になります (ただし,
DynamicsCompressorNode
の仕様上, どのようなアルゴリズムよって増幅するかは記載されていないので,
レンダリングエンジンの実装に依存することになります).
コンプレッサーによる圧縮と増幅
attack
と release
はイメージとしては, エンベロープジェネレーターの attack
と release
と同じで,
コンプレッサーが効き始めと終わりの時間的変化を指定することになります. 仕様としては, attack
は, コンプレッサーが効き始めて,
10 dB
圧縮されるまでの時間 (秒単位. デフォルト値は, 0.003 sec
), release
は,
コンプレッサーが効き終わってから, 10 dB
増幅されるまでの時間 (秒単位. デフォルト値は, 0.25 sec
) となっています. また,
attack
と release
による, コンプレッサーの時間的な圧縮レベルの変化は, 読み取り専用の
reduction
プロパティ (単位は dB
)で参照することが可能です.