2025年08月28日
こんにちは! 今日もコーディングを楽しんでますか?
こんかいは、いつもとはちょっと趣向をかえて、「JavaScript で音楽を鳴らす」ことに挑戦してみましょう。とはいっても、単純に .mp3
や .ogg
ファイルを再生するのでは面白くありません。
実はモダン JavaScript は、 Web Audio API という機能群にアクセスすることができます。これは音楽を含むオーディオファイルから、その詳細な内容にアクセスできるという代物です。こんかいはこの Web Audio API の入口を、一緒に学習してゆきましょう。
なお、想定技術レベルは「中級」です。
※本記事のコード例の音源は、フリー音源配布サイト DOVA-SYNDROME 提供のフリー素材を使用しています。
※上記の音源の加工には、楽曲パート分離サービス VocalRemover を使用しています。
実例
「ただ音楽を鳴らすだけなら <audio>
タグを使えばいいんじゃないの?」とお考えの皆様も多いと思いますので、まずは実例からご覧いただきましょう。
音楽ファイルの準備ができると、「ベース音再生」ボタンが押せるようになります。押すと、ドラムとベースの音が流れ始めます。
ここで、「メインパート」にチェックを入れると、メインパートがフェードインしてきます。またチェックを外すと、メインパートはフェードアウトしてゆきます。ここで重要なのは、「リズムに寸分の狂いもなく、フェードする」というところです。
Web で音楽を扱ったことのある方の中には、この挙動に驚いた方もいるかも知れません。このリズムに寸分の狂いもなく、複数の音楽ファイルがつながって再生されるというのは、実は過去からずっと望まれていた機能でした。この需要を満たすため、 Web Audio API がブラウザに搭載されるようになったのです。
それでは、最初に概要をご紹介しましょう。
概要
この API の仕組みは、じつは少々複雑かつ抽象的です。HTML の表示などと比べ、より「低レイヤー(人間より機械に近いこと)」なオブジェクトを、細かく駆使する必要があります。
以下は、その中でも重要なオブジェクトの紹介ですが、わかりにくい部分が多いかもしれません。そういう場合は、ここは後で読み返すことにして、最小構成の例(音を鳴らすだけ)から、実際のコード例を読んでみてください!
大まかな仕組み
AudioContext
インスタンスから生成した AudioBufferSourceNode
と GainNode
、そして「スピーカー」を、 .connect()
メソッドで順番に配線するように、音声を流します。以下のようなイメージです。
AudioContext(音楽のミキシングアプリの「プロジェクト」)
│
├── AudioBufferSourceNode (音声トラック)
│ ↓ .connect()
├── GainNode(音量)
│ ↓ .connect()
└── destination(出力:スピーカー)
AudioContext
音楽のミキシングアプリで例えると、「プロジェクト」に相当します。複数の音声トラックを持ち、それらを制御することが主な働きで、音声再生用の正確な時刻(タイムスタンプ)の取得、そしてそれを元にした「一時停止」や「再開」といった機能を持ちます。 グローバルに定義されている AudioContext
クラスによって、インスタンスを生成します。
また、このインスタンスプロパティである .destination
は、音を鳴らす「スピーカー」にあたります。
/** 「プロジェクト」のようなものです */
const audioContext = new AudioContext();
// 主な機能
audioContext.currentTime; // 音声再生タイミングを測るのに十分な精度の「現在のタイムスタンプ」
audioContext.suspend(); // 一時停止
audioContext.resume(); // 一時停止から再開
// このプロジェクトの「スピーカー」
audioContext.destination;
AudioBufferSourceNode
音楽のミキシングアプリで例えると、「音声トラック」に相当します。音声再生の開始、終了を行う機能のほか、波形データ (AudioBuffer
形式)、音声の長さ、ループの開始地点、終了地点などのデータを保持します。ただし、 AudioContext
インスタンスの .destination
プロパティに対し .connect()
メソッドを行わなければ、トラックとして登録されず、音声は再生されません。
このオブジェクトの生成は、AudioContext
インスタンスメソッド .createBufferSource()
によって行います。
/** 「音声トラック」のようなものです */
const audioBufferSourceNode = audioContext.createBufferSource();
// 主な機能
audioBufferSourceNode.start(); // 音声データの再生を開始する
audioBufferSourceNode.stop(); // 音声データの再生を終了する
audioBufferSourceNode.buffer; // 波形データ(AudioBuffer 形式)
audioBufferSourceNode.buffer.duration; // 音声の長さ(秒数)
audioBufferSourceNode.loop; // ループの有無
audioBufferSourceNode.loopStart; // ループ開始位置(秒数)
audioBufferSourceNode.loopEnd; // ループ終了位置(秒数)
// あらかじめ「スピーカー」に `.connect()` しないと、 `.start()` しても音声は鳴りません!
audioBufferSourceNode.connect(audioContext.destination);
GainNode
GainNode
は、音楽のミキシングアプリで例えると「トラックの音量つまみ」に相当します。音量の変化量、変化時間、変化の仕方などの操作を司ります。音量は .gain.value
プロパティに対し、 1 を最大、 0 を最小(無音)とする小数値で指定します。
なお、これも定義しただけでは機能せず、AudioBufferSourceNode
オブジェクトの .connect()
の対象とすることで、はじめて「どの音声トラックに対する操作なのか」が決まります。
このオブジェクトの生成は、AudioContext
インスタンスメソッド .createGain()
によって行います。
/** 「音量つまみ」のようなものです */
const gainNode = audioContext.createGain();
// 主な機能
gainNode.gain.value; // 音量を取得、設定(最大:1 ~ 最小:0)
gainNode.gain.setValueAtTime(音量, 開始時間); // 音量の変化時間
gainNode.gain.linearRampToValueAtTime(目標音量, 終了時間); // 音量の変化の仕方
// 「音声トラック」から `.connect()` されることで、「どのトラックの音量つまみか」が決まります。
audioBufferSourceNode.connect(gainNode);
// ↑の設定をしたあとで、「スピーカー」への `.connect()` を行います。順番が重要です!
gainNode.connect(audioContext.destination);
// チェーン記述が可能なので、この方法で覚えてしまいましょう!
audioBufferSourceNode.connect(gainNode).connect(audioContext.destination);
最小構成の例(音を鳴らすだけ)
まずは、Web Audio API の「最小構成」で音声を再生してみましょう。
ポイント
AudioContext
:音声処理の土台を生成ctx.createBufferSource()
:音声トラック(source)を生成し、音源データを指定// AudioContext を作成 const ctx = new AudioContext(); // 音声トラックを作成 const source = ctx.createBufferSource(); // 音声トラックの、音源データを指定 source.buffer = buffer;
source.connect()
:作成した音声トラックを、出力先(destination)へ配線するsource.start()
:音声トラックの再生開始// 音声トラックを、出力先へ配線 source.connect(ctx.destination); // 音声トラックの再生開始 source.start();
ctx.suspend()
:一時停止ctx.resume()
:一時停止からの再開// AudioContext を、一時停止 ctx.suspend(); // AudioContext の一時停止状態を終え、再開する ctx.resume();
これだけで「音声ファイルを鳴らす」ことができてしまいます!
フェード処理を追加
次はこのコードに、音量を徐々に変化させる「フェードイン/フェードアウト」を加えてみます。各ボタンを押してから「正確に」3秒間のフェードを行うことに注目してください。
ポイント
- GainNode が「音量つまみ」役
- AudioBufferSourceNode → GainNode(音量)→ destination(スピーカー) の順番に
.connect()
で配線する// 音量ノードを作成 const gainNode = ctx.createGain(); // 音声トラックを作成 const source = ctx.createBufferSource(); // トラック → ゲインノード → 出力先 source.connect(gainNode).connect(ctx.destination);
- setValueAtTime() で「フェード開始音量」と「現在のタイムスタンプ」を指定し、linearRampToValueAtTime() で「フェード終了音量」と「その音量になるタイムスタンプ」を指定して、フェードが実現される。
タイムスタンプは ctx.currentTime を基準に秒単位で指定。// フェードイン(押した時点で0 → 3秒かけて1) gainNode.gain.setValueAtTime(0, ctx.currentTime); gainNode.gain.linearRampToValueAtTime(1, ctx.currentTime + 3); // フェードアウト(押した時点で1 → 3秒かけて0) gainNode.gain.setValueAtTime(1, ctx.currentTime); gainNode.gain.linearRampToValueAtTime(0, ctx.currentTime + 3);
この AudioContext
オブジェクト(ctx
)の .currentTime
プロパティこそ、上で述べた「各トラックに提供する、正確な時計」です。いちど .connect(ctx.destination)
で配線してしまえば、あらゆるふるまいは、必ずその時計に従って行われるようになります!
複数のトラックを管理する
では、最初の例に戻って、複数のトラックを管理する方法を説明しましょう。
前提として、音源は「ベースのみの音源」と「通常の音源」の2種類(長さは同じ)あります。チェックボックスの状態によって、「いま流れていないほうの音源」を元にした音声トラックをそのつど生成し、クロスフェードで切り替えてゆくという仕組みです。
正確な時計を得る
演奏が開始されるさい、関数 startPlay()
が実行されます。
/**
* 最初の音楽の演奏を開始
*/
function startPlay() {
audioContext.resume();
musicStartTimeStamp = audioContext.currentTime;
musicStartTimeStamp
には、演奏を開始した時点での、正確な時刻が代入されます。これによって、演奏中に「演奏開始からどのくらいの時間が経過したのか」は、
現在の経過時間 = audioContext.currentTime - musicStartTimeStamp
という式で求められるようになります。
新しいトラックを生成する
続いて、メインパートのチェックボックスを操作したときに実行される関数 switchTrack()
内を注目してください。
// 現在のループ内オフセットを計算
const elapsed =
(audioContext.currentTime - musicStartTimeStamp) % current.buffer.duration;
ここでは、新しいトラックの再生開始位置が、「楽曲の先頭から、何秒後の時点か」を求めています。この楽曲はずっとループするので、先の引き算だけだと、どんどん経過時間が大きくなってしまいます。そのため、求めた経過時間を、さらに「楽曲全体の秒数」で割った余り(剰余)を、再生開始位置として定義しています。
次に、新しいトラックを生成して再生する関数 playTrack()
を見てみましょう。
/**
* 音声データの再生
* @param {AudioBuffer} buffer
* @param {number} [offset=0]
* @param {number} [volume=1]
* @returns {{sourceNode: AudioBufferSourceNode, gainNode: GainNode, buffer: AudioBuffer}} 現在再生している音声
*/
function playTrack(buffer, offset = 0, volume = 1) {
const sourceNode = audioContext.createBufferSource();
// 中略
sourceNode.start(audioContext.currentTime, offset);
先ほど求めた elapsed
は、この関数の offset
として与えられています。そして、新しく生成された AudioBufferSourceNode
(変数名は sourceNode
)の .start()
メソッドに、第 2 引数として渡されています。この意味は、
新しいトラックを、今すぐ(
audioContext.currentTime
)再生せよ。ただしトラックの再生位置は、先頭からoffset
秒からとする。
です。これで、ひとつの audioContext
上で、2 つのトラックが同時に、同じタイミングで再生され始めました。
今までのトラックをフェードアウトさせ、終了させる
もう一度、関数 switchTrack()
に戻ります。
// フェードアウト(現在の)
current.gainNode.gain.setValueAtTime(
current.gainNode.gain.value,
audioContext.currentTime
);
current.gainNode.gain.linearRampToValueAtTime(
0,
audioContext.currentTime + FADE_DURATION
);
current.sourceNode.stop(now + FADE_DURATION);
gainNode.gain.linearRampToValueAtTime()
は、「目標の音量」と「いつまでに」を引数として受け入れます。ここでは、
音量を徐々に 0 にせよ。その時間は、現在(
audioContext.currentTime
)よりFADE_DURATION
(2)秒後である。
を意味します。要するに「2 秒かけてフェードアウトせよ」です。
そして sourceNode.stop(now + FADE_DURATION)
は、「指定した時間に、この音声トラックを停止し、オブジェクトをガベージコレクション対象にせよ」です。ここでは、
現在(
audioContext.currentTime
)よりFADE_DURATION
(2)秒後に、この音声トラックを停止せよ。
です。つまり、フェードアウトがちょうど終わる時間に、この音声トラックは停止し、削除が予定されることになります。
あとは、新しい音声トラックの音量を、逆に 0 から 1 にする「フェードイン」を指定すれば、クロスフェードの出来上がりです!
お疲れ様でした!
まとめ
こんかいは、あらかじめ用意した音源ファイルから音声トラックを生成して、楽曲を再生したり、複数の音声トラック同士の制御について、学んでゆきました。
Web Audio API は今までの「プログラミング」の記事の内容と比べて「低レイヤー」であるため、直感的ではなく、抽象的な部分も多いことが、おわかりいただけたことと思います。こんかいの記事の内容はまだ基礎的な部分のみのご紹介でしたが、応用としては「音声にエフェクトをかける」、「音声そのものを生成し、再生する」といったことも可能です。それらはいずれ、別の機会にご紹介したいと思います。
こんかいのお話は、以上です。それでは、よきコーディングライフを!