アキゾラソフトのロゴアキゾラソフト技術 × キャリア × WEBツール

Blazor × Web Audio APIで音楽を切れ目なく再生する|scheduledTime活用術

Contents

はじめに

前の記事でBlazor側でバッファリング方式のPCM生成をしている話を書きました。その波形を実際にブラウザで切れ目なく再生するには、単に順番にsource.start()を呼ぶだけではダメで、Web Audio APIの時刻スケジューリングをうまく使う必要があります。

この記事では、PICOMで採用した再生スケジューリングの仕組みと、そこで押さえておきたい実装上のポイントを書きます。

📌この記事の要約

Blazor WebAssembly側で生成したWAVバイナリを、JS interop経由でWeb Audio APIに渡して再生します。次のバッファをsource.start(scheduledTime)で予約することで、複数のバッファを継ぎ目なく繋いで再生できます。AudioContext.currentTimeと自前で管理するscheduledTimeを比較して、ブラウザ再生時刻が追いついたら最新時刻にリセットする制御が肝です。

前提:なぜsource.start()を順番に呼ぶだけではダメなのか

素朴に考えると、次のように書きたくなります。

async function playChunks(chunks) {
    for (const chunk of chunks) {
        const buffer = await audioContext.decodeAudioData(chunk);
        const source = audioContext.createBufferSource();
        source.buffer = buffer;
        source.connect(audioContext.destination);
        source.start();
        await new Promise(r => source.onended = r);
    }
}

これだと各チャンクの終わり(onended)を待ってから次を始めるので、チャンクの境界で必ず微妙な無音が入ります。JSのイベントループのスケジューリング精度は数ms〜数十msあり、人間の耳はそれを「プチッ」という切れ目として認識します。

Web Audio APIはこれを解決するために、「何秒後に再生を始めるか」を事前に予約する機能を持っています。それがsource.start(scheduledTime)です。

実装:App.razor.jsの中身

PICOMの再生ロジックはJS側のApp.razor.jsにあります。まず、モジュールスコープでaudioContextscheduledTimeを持ちます。

let audioContext = new AudioContext();
let scheduledTime = 0;

const playBuffer = async (audioBuffer, scheduledTime) => {
    const source = audioContext.createBufferSource();
    source.buffer = audioBuffer;
    source.connect(audioContext.destination);

    return new Promise((resolve) => {
        source.onended = resolve;
        source.start(scheduledTime);
    });
};

scheduledTimeは「次にバッファを流し始める時刻」を保持する変数です。これをsource.start(scheduledTime)に渡すことで、Web Audio APIに「この時刻になったら再生を始めてね」と予約できます。

playSound:Blazor側から呼ばれる再生エントリポイント

Blazor側からはplaySound(wavBytes)として呼び出されます。

export const playSound = async (wavBytes) => {
    const audioBuffer = await audioContext.decodeAudioData(wavBytes.buffer);
    const currentTime = audioContext.currentTime;

    if (currentTime < scheduledTime) {
        playBuffer(audioBuffer, scheduledTime);
        scheduledTime += audioBuffer.duration;
    } else {
        playBuffer(audioBuffer, currentTime);
        scheduledTime = currentTime + audioBuffer.duration;
    }
};

ロジックのポイントはcurrentTime < scheduledTimeの比較です。現在時刻が予約時刻より前なら、まだ前のバッファが終わっていないので、そのまま予約行列の後ろに追加します。追いついている(もしくは初回)なら、現在時刻から再生を始めて、scheduledTimeを今からaudioBuffer.duration秒後に設定します。

この仕組みにより、Blazor側がバッファを作りながら随時playSoundを呼べば、自動で継ぎ目なしの再生になります。Blazor側のチャンク生成が遅れてもスケジュールに隙間ができるだけで、逆にチャンクを先行して生成しても予約行列が詰まるだけです。

AudioContext.currentTimeとscheduledTimeの関係

ここが一番わかりづらいポイントなので、時系列で図を引いてみます。

時刻(秒): 0.0 ---- 1.0 ---- 2.0 ---- 3.0 ---- 4.0
            ^ 最初の playSound 呼び出し (currentTime=0.5)
            |
            +-- buffer1 (duration=1.0) を start(0.5)
            |   scheduledTime = 0.5 + 1.0 = 1.5
                      ^ 2回目の playSound (currentTime=0.8)
                      |   currentTime < scheduledTime なので予約キューへ
                      +-- buffer2 を start(1.5)
                      |   scheduledTime = 1.5 + 1.0 = 2.5

JSのイベントループやdecodeAudioDataの非同期待ちで呼び出し間隔がバラついても、scheduledTimeは常に「次のバッファを入れるべき時刻」を指しているので、再生自体は正確にスケジュールされ続けます。

scheduledTime方式の得
  • バッファ間に無音が入らない
  • Blazor側の生成速度に関係なく、再生が滑らか
  • 残り再生時間もscheduledTime - currentTimeで計算できる
払ったコスト
  • 初回のcurrentTime < scheduledTime判定を忘れると二重再生になる
  • AudioContextをcloseした後はscheduledTimeのリセットも必要
  • 再生停止と再初期化

    停止時はAudioContext.close()で全体を閉じます。

    export const stopSound = () => {
        audioContext.close();
        scheduledTime = 0;
    }
    
    export const init = () => {
        scheduledTime = 0;
        audioContext = new AudioContext();
    }
    

    close()したAudioContextは再利用できないので、次回再生時は新しいインスタンスを作り直します。init()関数で再初期化し、scheduledTimeも0にリセットします。

    ここで地味にハマるのが、AudioContextの作成数に制限があることです。ブラウザによっては、1ページに作成できるAudioContextの最大数が決まっていて(Chromiumは6前後)、再生・停止を繰り返すとこの制限に当たって新しいContextが作れなくなります。

    現状のPICOMは、再生のたびにinit()で新しいContextを作り、停止時にstopSound()close()しています。つまり再生・停止のたびにContextを作り直しているので、繰り返すうちにこの上限へ近づく可能性があります。Contextを毎回作り直さず、1セッション中は使い回す形にすれば上限を避けられるため、ここは今後の改善ポイントとして残しています。

    残り再生時間の取得

    ユーザーに進捗バーを出すために、「今どれくらいの時間の音が予約済みか」を取れるようにしています。

    export const getRemainingPlaybackSeconds = () => {
        return Math.max(0, scheduledTime - audioContext.currentTime);
    }
    

    Math.max(0, ...)で0未満を弾いているのは、スケジュール済みの時刻を過ぎた後に呼ばれる可能性があるためです。再生が終わった瞬間はscheduledTime - currentTimeが負になりえます。

    autoplay制約への対応

    Web Audio APIで気をつけたいのが、ブラウザのautoplay制約です。ユーザーのタッチやクリックより前に作られたAudioContextはsuspended状態になり、そのままでは音が鳴りません。鳴らすには、ユーザー操作を起点にContextをrunning状態にする必要があります。

    PICOMのApp.razor.jsはアプリ起動時に読み込まれるので、最初のAudioContextはこの時点で作られます(まだユーザー操作がないためsuspendedになりえます)。実際の再生は、再生ボタンを押したときにinit()が呼ばれて新しいContextを作り直すところから始まります。一般的にはユーザー操作のハンドラ内でaudioContext.resume()を呼ぶ方法もありますが、PICOMの再生パイプラインでは明示的なresume()は使わず、再生というユーザー操作を起点にinit()でContextを作り直す形にしています。

    まとめ

    • 複数バッファを継ぎ目なく再生するにはsource.start(scheduledTime)で予約する
    • scheduledTimeは自前で管理し、currentTimeと比較して追いついたらリセットする
    • AudioContextのcloseは不可逆。再初期化には新しいインスタンスが必要
    • autoplay制約があるため、再生ボタン押下を起点にinit()でContextを作り直してから鳴らす
    • Web Audio APIの時刻は全部秒単位。ミリ秒を期待すると爆死する
    プロフィール画像
    WRITTEN BY
    あきぞら

    東京都市大学 情報工学部在籍(2027年卒業予定)。
    中学時代よりC#および.NETを用いた個人開発をスタート。現在はWebアプリエンジニアとして活動し、国内大手SaaS企業より内定。 培った技術の活用法から、新卒エンジニアとしてのキャリア形成、生産性を高めるITツールの導入まで、ITに興味のある方や現役エンジニアに役立つバラエティ豊かな情報を発信しています。