アゲアゲな年末を過ごすために、JavaScriptのちょっとマニアックなAPI「Web Audio API」を用いてシーケンサー機能付きのシンセサイザーを作成しました。
実物はこちらから動かせるので、まずはぜひ遊んでみてください!全体のコードや、操作方法はリポジトリにございます。
はじめに
Web Audio APIはブラウザ上で音声制御を行うことができるJavaScriptのAPIです。やや独特な仕組みを持ち少々とっつきにくい部分もありますが、工夫次第で本格的な音声制御も可能な数多くの機能を持っています。
本記事では、今回の実装でも利用している
- 基本的な概念「
Node
」「connect
」について - 音声のテンポを合わせる方法
- 音声合成
- wavファイルの再生
- 音量変更
- フィルター
- エフェクト(ディレイ、リバーブ)の作り方
をテーマに、Web Audio APIの機能や使い方を解説します。本記事の内容を大まかに理解することで、アイデア次第で様々な応用ができると思います。
注意点
本記事に記載しているコードは説明のために省略、簡略化しているため、リポジトリの内容とは異なります。必要に応じて、実際のコードを併せて確認しながら読んでいただければと思います。
開発環境
- TypeScript 4.5.4
- webpack 5.65.0
- HTML
- CSS
- Netlify
特別な内容はないため、環境構築手順は割愛します。
基本的な概念「Node」「connect」について
Web Audio APIを触るとすぐにお目にかかる、以下のようなコードがあります。
const audioCtx = new AudioContext();
const oscNode = new OscillatorNode(audioCtx, { type: 'sawtooth', frequency: 440 });
const gainNode = new GainNode(audioCtx, { gain: 0.5 });
oscNode.connect(gainNode);
gainNode.connect(audioCtx.destination);
oscNode.start();
あまり馴染みのない書き方でちょっと面食らいますが、これは文字通り**「音響機材を作成、接続」しているイメージ**でとらえると分かりやすいです。
まず1行目では「AudioContext
」というWeb Audio APIを動かすための土台を作成していまます。この「AudioContext
」は利用時に一度立ち上げるだけでよいです。
その後、上記のコードでいうgainNode
やoscNode
などの「機材」を「connect
(接続)」 していきます。Node
にはいろいろな種類がありますが、大きく分けると楽器のように「音を出すもの」とエフェクターのように「音を加工するもの」の2種類に分けられます。
注意点として、Node
は最終的にdestination
に接続されていないと音が出ません。どれだけオーディオ機器をたくさん繋いでも、スピーカーが接続されていないと音は出ないのと同じイメージですね。
アプリを作成するときは、作成したい機能に応じて、どのようにルーティングを行うべきかをある程度イメージしておくと実装がしやすいと思います。
音声のテンポを合わせる方法
Web Audio APIは「音のテンポを合わせる」実装がちょっと難しく、工夫が必要です。
JavaScript標準のタイマー系機能は音楽用途で利用できるほど正確ではなく、容易にズレが生じます。そのため、今回のアプリのように「指定したBPMで16分音符ごとに音が鳴る」ような実装は、単純なsetTimeout
やsetInterval
だけだと実現することができません。
そこで今回は、テンポ同期の仕組みとしてこちらの記事で解説されているアプローチを採用しました。
setInterval
と、Web Audio APIで取得できるcurrentTime
というパラメータを併用することで、正確なタイミングでの発音を「先読み予約」し、長時間ループ再生を行ってもズレが発生しないようにしています。
上記記事の作例として用意されているメトロノームの実装は非常に参考になるので、ぜひチェックしてみてください。
音声合成(OscillatorNode)
ここからは、Web Audio APIで利用できる代表的なNode
をご紹介します。
Web Audio APIはあらかじめ収録された音をロードして鳴らすだけでなく、内部で音声合成を行う機能を持っています。
OscillatorNode
というNodeを利用することで、4種の波形を任意の周波数で鳴らすことができます。
// 440Hz (A4)の高さで鋸波を発音する
const oscNode = new OscillatorNode(audioCtx, { type: 'sawtooth', frequency: 440 });
oscNode.connect(audioCtx.destination);
oscNode.start();
音の高さは数値で指定する必要があるため、周波数と音名の対応表を調べてリスト化しておくとスムーズに実装が行えると思います。参考までに、今回は以下のような関数を作成して対応しました。
/**
* 音名を周波数に変換して返す
* @param pitchName 音名 (c2 - b5)
* @returns 周波数
*/
const pitchNameToFrequency = (pitchName: PitchName): number => {
const table = {
c2: 65.406,
'c#2': 69.296,
d2: 73.416,
'd#2': 77.782,
e2: 82.407,
f2: 87.307,
'f#2': 92.499,
g2: 97.999,
'g#2': 103.826,
a2: 110,
'a#2': 116.541,
b2: 123.471,
c3: 130.813,
'c#3': 138.591,
d3: 146.832,
'd#3': 155.563,
e3: 164.814,
f3: 174.614,
'f#3': 184.997,
g3: 195.998,
'g#3': 207.652,
a3: 220,
'a#3': 233.082,
b3: 246.942,
// (以下略)
};
return table[pitchName];
};
※追記: 上記のように表を作る方法は入力ミスもあるので、コメントいただいたような処理を実装して計算で出すのもよいと思います!
wavファイルの再生(SourceNode)
今回のアプリにはドラムマシン機能があり、キック、スネア、ハイハットの3種の音色を16分のパターンで打ち込むことができます。このドラムの音色は、先述した音声合成ではなくあらかじめ作曲ツールを用いて収録したオーディオファイル(wav)を用いています。
オーディオファイルの再生自体は通常のaudioタグ等でも可能ですが、Web Audio APIではファイルをAudioBuffer
という形式に変換して読み込むことで、リアルタイムでテンポに同期したり、エフェクトをかけることができるようになっています。
// 指定したオーディオファイルをAudioBufferに変換する関数
const setupSample = async (samplePath: string) => {
const response = await fetch(samplePath);
const arrayBuffer = await response.arrayBuffer();
const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer);
return audioBuffer;
};
// バスドラムのwavをAudioBufferに変換
const kickBuffer = await setupSample('assets/audio/kick.wav');
変換したオーディオはSourceNode
というNode
で読み込むことができます。このSourceNode
は、先ほど登場したOscillatorNode
などのNode
と同じように扱うことができます。
// SourceNodeを用いてwavファイルを再生
let kickNode = audioCtx.createBufferSource();
kickNode.buffer = kickBuffer;
kickNode.connect(audioCtx.destination);
kickNode.start();
音量変更(GainNode)
先述したOscillatorNode
やSourceNode
は「音を出すためのNode
」ですが、続いて「音を加工するNode
」を見ていきましょう。
中でも最もシンプルなのは「原音の音量を変えて出力する」GainNode
です。
const gainNode = new GainNode(audioCtx, { gain: 0.5 });
const oscNode = new OscillatorNode(audioCtx, { type: 'sawtooth', frequency: 440 });
oscNode.connect(gainNode);
gainNode.connect(audioCtx.destination);
// oscNode は gainNode を通るため、gain(音量)が0.5で出力される
oscNode.start();
単純ではありますが利用機会も多く、「全体の音量を調節する」「エフェクトの音量だけ変える」など、ルーティング次第で様々な用途で利用することができます。
なお、Node
のパラメーターは再代入によって変更が可能なので、input
タグから受け取った値を反映させることで、ユーザーがグリグリと動かすことができるようになります。
// ここでは、あらかじめ「masterGainNode」という全体のボリュームを制御するGainNodeを作成しています
const handleChangeMasterVolume = (e: Event) => {
if (!(e.currentTarget instanceof HTMLInputElement)) return;
const value = Number(e.currentTarget.value);
if (value < 0 || value > 50) {
alert('無効な値です。');
return;
}
masterGainNode.gain.value = value;
};
フィルター(BiquadFilterNode)
ある周波数の音をカットする「フィルター」はサウンドメイクに欠かせませんが、これはBiquadFilterNode
というNode
を利用することで実装できます。
// 1000Hz以上の帯域をカットするローパスフィルター
const filterNode = new BiquadFilterNode(audioCtx, {
type: 'lowpass',
frequency: 1000,
Q: 10,
});
実際にconnect
して使うと以下のようになります。
const oscNode = new OscillatorNode(audioCtx, { type: 'sawtooth', frequency: 440 });
oscNode.connect(filterNode);
filterNode.connect(audioCtx.destination);
// 1000Hz以上がカットされた、A4の鋸波が鳴る
oscNode.start();
エフェクト(ディレイ、リバーブ)の作り方
ここからは少し複雑になります。
ディレイやリバーブといったエフェクトは一般的に「エフェクトで得られたサウンド(WET)」と「原音(DRY)」を混ぜて鳴らします。そのため、さきほどのフィルターとは異なり、connect
が1本道ではなくなります。
ディレイ(DelayNode)
Web Audio APIのディレイはDelayNode
によって実装できるのですが、このDelayNode
は非常にシンプルな仕組みで、
- 原音と同じ音を、指定した時間だけ遅らせてもう一度鳴らす
という機能だけを持っています。
シンセやエレキギターなどで利用されるディレイといえば、反響音が何度も繰り返されながら減衰していくサウンドを思い浮かべる方が多いと思います。このようなサウンドを得るためには、少しだけ実装に工夫が必要です。
const oscNode = new OscillatorNode(audioCtx, { type: 'sawtooth', frequency: 440 });
const delayNode = new DelayNode(audioCtx, { delayTime: 1 });
const delayFeedBackNode = new GainNode(audioCtx, { gain: 0.75 });
// 原音
oscNode.connect(audioCtx.destination);
// ディレイ
oscNode.connect(delayNode);
delayNode.connect(delayFeedBackNode);
delayFeedBackNode.connect(delayNode);
delayNode.connect(audioCtx.destination);
delayNode
によって発音されたサウンドが、GainNode
であるdelayFeedBackNode
を通って再びdelayNode
に戻ってくるようなループ状のルーティングになっています。この方法で、少しずつ音が小さくなって減衰するような反響音を表現することができます。
リバーブ(ConvolverNode)
Web Audio APIのリバーブConvolverNode
は「コンボリューションリバーブ」で、残響データ(インパルスレスポンス)を用いて原音を加工する仕組みになっています。
今回は、あらかじめ作曲ツールで作成したインパルスレスポンスを読み込んで利用しています。読み込む際にAudioBuffer
に変換するのは、先に紹介したドラムのwavファイルと同様です。
const oscNode = new OscillatorNode(audioCtx, { type: 'sawtooth', frequency: 440 });
// setupSample はドラムの解説でも使用した自作の関数です
const impulse = await setupSample('assets/audio/impulse.wav');
const convolverNode = new ConvolverNode(audioCtx, {
buffer: impulse,
});
// 原音
oscNode.connect(audioCtx.destination);
// リバーブ
oscNode.connect(convolverNode);
convoloverNode.connect(audioCtx.destination);
convolverNode
が発音するのは残響音のみなので、原音と混ぜてdestination
に送ることでリバーブエフェクトらしいサウンドを得ることができます。
その他のポイント
その他、今回の実装で工夫したいくつかのポイントを紹介します。
クリック後にAPIの初期化を行う
Web Audio APIは音声を扱うAPIなので、ページを開いたら勝手に再生されるような実装は避け、ユーザーの許可を待つように実装することが望ましいです(多くの環境で、ユーザー操作を待たずにAudioContextを初期化するような処理を書くと警告が出ます)。今回はアクセス時の「START」ボタンを押してから初期化が始まるようにしています。
シンセのフィルターと全体のフィルターを2つ用意する
OSCのセクションにあるフィルターとEFFECTのセクションにある「DJ-FILTER」はどちらもBiquadFilterNode
で作成したフィルターですが、ルーティングが異なります。シンセのフィルターはドラムパートのサウンドには影響しない一方で、「DJ-FILTER」は演奏全体に効果を及ぼすようにしています。
前者は主にオシレーターの音作り用として、後者は演奏中の飛び道具エフェクトとして利用するようなイメージで作成しました。
input type="range"のノブ実装
特定範囲の値をコントロールできるinput type="range"
はもともとスライダー型のUIなのですが、シンセといえば「丸いツマミ(ノブ)」をグリグリ回す操作がおなじみです。自前実装はけっこう面倒なので、今回はこちらのライブラリを利用しました。
おわりに
今回のアプリはWebアプリケーションの強みである「ブラウザさえあればいつでも音が鳴らせる手軽さ」を活かして、「専門知識がなくてもすぐ遊べる」ように作ってみました。
フレーズを再生しながらツマミをグリグリして、Web Audio APIの音声制御機能を体感していただければ幸いです!