この記事の背景となる音声対話のユースケースは、先に公開した 音声AI面接対策 で説明しています。本稿はそのプロダクト解説ではなく、ブラウザで音声をリアルタイム送信するときのキュー設計を扱います。
TypeScriptでPCMの固定長Ring Bufferを実装する:AudioWorkletをネットワーク遅延から守る
リアルタイム音声アプリで最初に守るべきなのは、AudioWorklet の処理をネットワーク送信で待たせないことです。
マイクは一定間隔で PCM フレームを出します。一方、WebSocket や STT API の送信完了はネットワーク状況で揺れます。Worklet のコールバック内で送信を待つ構造にすると、短い遅延が積み重なって音声フレームを落とし、さらに処理のタイミングまで不安定になります。
解決策は、音声生成側と送信側の間に 容量固定の Ring Buffer を置くことです。この記事では次を実装します。
- AudioWorklet は PCM フレームを main thread に渡すだけにする
- main thread は固定長キューに入れ、送信は常に 1 本だけ動かす
- キューが満杯ならメモリを増やさず、欠落を観測可能にする
- FIFO、回り込み、満杯時の境界をテストする
図の要点は、送信待ちを AudioWorklet に持ち込まないことです。遅いのはネットワークであり、音声スレッドではありません。
無限配列が壊すもの
frames.push(frame) だけでは、送信先が遅くなったときにキューが際限なく伸びます。これは単なるメモリ問題ではありません。数秒前の音声を遅れて送っても、ライブ字幕や会話システムでは価値が低く、遅延だけが増えます。
固定容量にすると、設計上の問いが明確になります。
- キューが満杯になったとき、古いフレームと新しいフレームのどちらを捨てるか
- 何フレーム捨てたかをどこへ記録するか
- 欠落が許されない用途では、いつセッションを明示的に失敗させるか
ここでは音声ストリームを「最新性が重要」とみなし、新しいフレームを受け付けず dropped を増やします。古いフレームを捨てる方針でも構いませんが、いずれにせよ隠さずメトリクスとして残すことが重要です。
Ring Buffer の不変条件
容量を capacity、次に読む位置を head、次に書く位置を tail、格納数を size とします。
0 <= size <= capacity- 空なら
size === 0であり、shift()はundefined - 満杯なら
size === capacityであり、push()は失敗する - 読み書き位置は
(index + 1) % capacityで進む -
head === tailだけでは空と満杯を区別できないため、sizeを別に持つ
Array.shift() は先頭要素を詰め直すため、フレーム数に比例する処理になり得ます。Ring Buffer は添字を回すだけなので、push と shift はどちらも O(1) です。
最小実装
class PcmRingBuffer {
private readonly slots: (Int16Array | undefined)[];
private head = 0;
private tail = 0;
private size = 0;
public dropped = 0;
constructor(private readonly capacity: number) {
if (!Number.isInteger(capacity) || capacity < 1) {
throw new RangeError("capacity must be a positive integer");
}
this.slots = Array.from({ length: capacity });
}
push(frame: Int16Array): boolean {
if (this.size === this.capacity) {
this.dropped++;
return false;
}
this.slots[this.tail] = frame;
this.tail = (this.tail + 1) % this.capacity;
this.size++;
return true;
}
shift(): Int16Array | undefined {
if (this.size === 0) return undefined;
const frame = this.slots[this.head];
this.slots[this.head] = undefined;
this.head = (this.head + 1) % this.capacity;
this.size--;
return frame;
}
get length(): number {
return this.size;
}
}
フレームの型を Int16Array にしたのは、Float32 PCM を 16-bit PCM に変換した後の送信単位を表すためです。キューはフレームの意味を知る必要がなく、順序と容量だけを管理します。
AudioWorklet はコピーして即座に返す
AudioWorklet の入力バッファはオーディオエンジンが管理しています。main thread に渡す値はコピーし、転送可能な ArrayBuffer として送ります。ネットワーク I/O はこの場所に書きません。
class PcmCaptureProcessor extends AudioWorkletProcessor {
process(inputs) {
const channel = inputs[0]?.[0];
if (!channel) return true;
// Audio engine が所有する buffer をそのまま転送しない。
const copy = channel.slice();
this.port.postMessage(copy, [copy.buffer]);
return true;
}
}
registerProcessor("pcm-capture", PcmCaptureProcessor);
process() が true を返す限りノードは動作を続けます。ここで await socket.send(...) のような非同期処理を試みる必要はありません。そもそも送信完了を待つ責務を持たせない設計にします。
main thread で Float32 PCM を詰める
Web Audio API の Float32 PCM は通常 [-1, 1] の範囲です。送信する 16-bit PCM へ変換するときは、必ず範囲を clamp します。
function toPcm16(samples: Float32Array): Int16Array {
const pcm = new Int16Array(samples.length);
for (let i = 0; i < samples.length; i++) {
const value = Math.max(-1, Math.min(1, samples[i]));
pcm[i] = value < 0 ? value * 0x8000 : value * 0x7fff;
}
return pcm;
}
const queue = new PcmRingBuffer(64);
workletNode.port.onmessage = ({ data }: MessageEvent<Float32Array>) => {
const accepted = queue.push(toPcm16(data));
if (!accepted) {
metrics.increment("audio.frames_dropped", 1);
// 欠落が許されない要件なら、ここで明示的にセッションを停止する。
}
void pump();
};
たとえば 48 kHz の 128 samples は約 2.67 ms です。容量 64 は約 171 ms 相当ですが、実際の容量はフレームサイズ、許容遅延、送信先の復旧時間から決めます。数字を固定の正解にせず、queue.length と dropped を観測して調整します。
送信は 1 本の pump に直列化する
onmessage のたびに送信を始めると、ネットワークへ無制限に並行リクエストが発生します。送信中フラグを 1 つ持ち、キューを 1 本ずつ排出します。
let sending = false;
async function pump(): Promise<void> {
if (sending) return;
sending = true;
try {
while (true) {
const frame = queue.shift();
if (!frame) return;
// sendPcm は WebSocket または STT 接続に1フレーム送る関数。
await sendPcm(frame);
}
} catch (err) {
console.error("[audio-stream] PCM send failed", { err });
// 再接続・終了・再キューイングの方針はプロトコル単位で明示する。
throw err;
} finally {
sending = false;
// finally の直前に到着したフレームを取りこぼさない。
if (queue.length > 0) void pump();
}
}
ここで重要なのは、再接続を「古いフレームを黙って送る」ための仕組みにしないことです。音声の連続性が必要なら sequence number や欠落通知をプロトコルに含めます。鮮度を優先するなら、再接続時にキューを明示的に破棄して欠落を記録します。どちらにするかは利用者に見える性質なので、暗黙の fallback にしません。
境界テスト
少なくとも次の 3 ケースは実行しておくと、添字の回り込みと容量制御を確認できます。
import { strict as assert } from "node:assert";
{
const q = new PcmRingBuffer(3);
q.push(Int16Array.of(1));
q.push(Int16Array.of(2));
assert.equal(q.shift()?.[0], 1);
q.push(Int16Array.of(3));
q.push(Int16Array.of(4));
assert.deepEqual(
[q.shift()?.[0], q.shift()?.[0], q.shift()?.[0]],
[2, 3, 4],
);
assert.equal(q.length, 0);
}
{
const q = new PcmRingBuffer(2);
q.push(Int16Array.of(1));
q.push(Int16Array.of(2));
assert.equal(q.push(Int16Array.of(3)), false);
assert.equal(q.dropped, 1);
assert.deepEqual([q.shift()?.[0], q.shift()?.[0]], [1, 2]);
}
assert.throws(() => new PcmRingBuffer(0), RangeError);
実行結果は 3 tests passed でした。満杯時に既存データの順序が崩れないこと、drop が数えられること、容量 0 を早く拒否することを確認しています。
技術面接で説明するなら
この設計を説明するときは「Ring Buffer を使った」だけで終わらせず、以下の順で話すと判断が伝わります。
- ボトルネックを分離する: 音声コールバックは一定周期、ネットワークは可変遅延です。
- 容量を固定する: 無限キューでメモリと遅延を同時に悪化させません。
-
不変条件を置く:
head、tail、sizeで空と満杯を明確にします。 - 過負荷を観測する: drop をメトリクスにし、無音で壊れたように見せません。
- 整合性の境界を決める: 再接続時に再送するのか、欠落を通知するのかをプロトコルで選びます。
固定長 Ring Buffer は小さなデータ構造ですが、リアルタイム系では「処理できない量を受けたときにどう壊れるか」を明示するための部品です。音声、センサー、ログ、ストリーミング UI でも同じ考え方を使えます。