BeAct Co., Ltd.

BLOG
社員ブログ

ブラウザで音声を自在に再生!Web Audio API 入門

こんにちは! 今日もコーディングを楽しんでますか?

こんかいは、いつもとはちょっと趣向をかえて、「JavaScript で音楽を鳴らす」ことに挑戦してみましょう。とはいっても、単純に .mp3.ogg ファイルを再生するのでは面白くありません。

実はモダン JavaScript は、 Web Audio API という機能群にアクセスすることができます。これは音楽を含むオーディオファイルから、その詳細な内容にアクセスできるという代物です。こんかいはこの Web Audio API の入口を、一緒に学習してゆきましょう。

なお、想定技術レベルは「中級」です。

※本記事のコード例の音源は、フリー音源配布サイト DOVA-SYNDROME 提供のフリー素材を使用しています。

※上記の音源の加工には、楽曲パート分離サービス VocalRemover を使用しています。


実例

「ただ音楽を鳴らすだけなら <audio> タグを使えばいいんじゃないの?」とお考えの皆様も多いと思いますので、まずは実例からご覧いただきましょう。

音楽ファイルの準備ができると、「ベース音再生」ボタンが押せるようになります。押すと、ドラムとベースの音が流れ始めます。

ここで、「メインパート」にチェックを入れると、メインパートがフェードインしてきます。またチェックを外すと、メインパートはフェードアウトしてゆきます。ここで重要なのは、「リズムに寸分の狂いもなく、フェードする」というところです。

Web で音楽を扱ったことのある方の中には、この挙動に驚いた方もいるかも知れません。このリズムに寸分の狂いもなく、複数の音楽ファイルがつながって再生されるというのは、実は過去からずっと望まれていた機能でした。この需要を満たすため、 Web Audio API がブラウザに搭載されるようになったのです。

それでは、最初に概要をご紹介しましょう。

概要

この API の仕組みは、じつは少々複雑かつ抽象的です。HTML の表示などと比べ、より「低レイヤー(人間より機械に近いこと)」なオブジェクトを、細かく駆使する必要があります。

以下は、その中でも重要なオブジェクトの紹介ですが、わかりにくい部分が多いかもしれません。そういう場合は、ここは後で読み返すことにして、最小構成の例(音を鳴らすだけ)から、実際のコード例を読んでみてください!

大まかな仕組み

AudioContext インスタンスから生成した AudioBufferSourceNodeGainNode 、そして「スピーカー」を、 .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 が「音量つまみ」役
  • AudioBufferSourceNodeGainNode(音量)→ 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 は今までの「プログラミング」の記事の内容と比べて「低レイヤー」であるため、直感的ではなく、抽象的な部分も多いことが、おわかりいただけたことと思います。こんかいの記事の内容はまだ基礎的な部分のみのご紹介でしたが、応用としては「音声にエフェクトをかける」、「音声そのものを生成し、再生する」といったことも可能です。それらはいずれ、別の機会にご紹介したいと思います。

こんかいのお話は、以上です。それでは、よきコーディングライフを!