Blazor × Web Audio APIで音楽を切れ目なく再生する|scheduledTime活用術
はじめに
前の記事で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にあります。まず、モジュールスコープでaudioContextとscheduledTimeを持ちます。
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は常に「次のバッファを入れるべき時刻」を指しているので、再生自体は正確にスケジュールされ続けます。
- バッファ間に無音が入らない
- Blazor側の生成速度に関係なく、再生が滑らか
- 残り再生時間も
scheduledTime - currentTimeで計算できる
currentTime < scheduledTime判定を忘れると二重再生になる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の時刻は全部秒単位。ミリ秒を期待すると爆死する





