リアルタイム音声を扱う背景として、面接向け音声処理の記事を先に公開しています:https://aceround.app/ja/blog/voice-ai-interview-preparation
TypeScriptでJitter Bufferを実装する:順不同の音声フレームを期限付きで再生する
UDP、WebSocket、あるいは複数の非同期処理をまたぐ音声パイプラインでは、フレームが送信順に届くとは限りません。ここでは、小さな順序バッファを実装して、次の3つを同時に満たします。
-
seqの順序でフレームを出力する - 欠番が来たら、無限には待たない
- 後ろのフレームが届いている場合だけ、期限後に無音を補完して再生を前へ進める
Map に入れてから「次に欲しい番号」だけを見る構成です。遅延を増やしすぎないことが重要なので、これは録音データを完全復元する仕組みではなく、ライブ再生向けの小さな Jitter Buffer です。
先に決めるべき境界
フレームを次の型で扱います。実際の音声バイト列でも、STT の途中結果でも同じ考え方を使えます。
type Frame = {
seq: number;
payload: Uint8Array;
};
type PlayedFrame = {
seq: number;
payload: Uint8Array;
concealed: boolean;
};
この実装が置く不変条件は2つです。
-
nextSeqより小さい番号は、すでに再生済みなのでバッファに残さない -
drain()が返す番号は必ず連続する。欠番が期限切れなら、その番号の無音フレームを返す
重要なのは「何も来ていない」状態と「後続フレームがあるのに、途中だけ欠けている」状態を分けることです。前者で無音を量産すると、単に送信が止まっただけなのに再生位置が暴走します。
最小実装
nextSeq はセッション開始時のプロトコルから決めます。先頭に届いたフレームを基準にすると、最初のパケット自体が遅延したときに正しい欠番を判断できません。
export class JitterBuffer {
private readonly pending = new Map<number, Frame>();
private gapStartedAt: number | undefined;
constructor(
private nextSeq: number,
private readonly maxWaitMs: number,
private readonly silence: Uint8Array,
) {}
push(frame: Frame): void {
if (frame.seq < this.nextSeq) return;
if (!this.pending.has(frame.seq)) this.pending.set(frame.seq, frame);
}
drain(now: number): PlayedFrame[] {
const played: PlayedFrame[] = [];
while (true) {
const frame = this.pending.get(this.nextSeq);
if (frame) {
this.pending.delete(this.nextSeq);
played.push({ ...frame, concealed: false });
this.nextSeq += 1;
this.gapStartedAt = undefined;
continue;
}
const hasLaterFrame = [...this.pending.keys()].some(
(seq) => seq > this.nextSeq,
);
if (!hasLaterFrame) return played;
if (this.gapStartedAt === undefined) {
this.gapStartedAt = now;
return played;
}
if (now - this.gapStartedAt < this.maxWaitMs) return played;
played.push({
seq: this.nextSeq,
payload: this.silence,
concealed: true,
});
this.nextSeq += 1;
this.gapStartedAt = now;
}
}
}
この drain() は、正常なフレームを取り出せる限り連続して取り出します。欠番にぶつかったときだけ待機時計を開始します。時計を push() 側で始めないのは、再生器がまだその欠番を必要としていない段階で待機時間を消費したくないためです。
順不同と欠損をテストする
時間を引数で渡せば、実時間の setTimeout を使わずに境界を検証できます。以下は Bun でも Node.js のテストランナーでもそのまま読める、最小の断言です。
import { strict as assert } from "node:assert";
import { JitterBuffer } from "./jitter-buffer.ts";
const bytes = (value: number) => new Uint8Array([value]);
const buffer = new JitterBuffer(100, 50, bytes(0));
buffer.push({ seq: 100, payload: bytes(100) });
buffer.push({ seq: 102, payload: bytes(102) });
assert.deepEqual(
buffer.drain(0).map(({ seq, concealed }) => [seq, concealed]),
[[100, false]],
);
assert.deepEqual(buffer.drain(49), []);
assert.deepEqual(
buffer.drain(50).map(({ seq, concealed }) => [seq, concealed]),
[[101, true], [102, false]],
);
buffer.push({ seq: 101, payload: bytes(101) });
assert.deepEqual(buffer.drain(60), []);
このケースだけでは足りません。少なくとも次も追加します。
-
102の後に101が期限内に届き、補完なしで101, 102と出る - 同じ
seqが二度届いても一度しか出ない - 後続フレームが存在しない間は、期限が過ぎても無音を出さない
-
seqが再生済みより小さい再送を受けても状態が変わらない
実運用で追加するもの
この最小版は順序と期限だけに集中しています。音声を実際に流す場合は、次の値をプロトコルと一緒に決めます。
-
maxWaitMs:短いほど低遅延ですが、少しの揺れでも補完が増えます - 無音フレームのサイズ:codec のフレーム長と一致させます
- 上限バッファ量:極端に未来の
seqを受けたときにメモリを使い切らないためです - ラップアラウンド:
seqが有限ビット幅なら、単純な<比較は使えません
音声 codec によっては、単純な無音より PLC(Packet Loss Concealment)を使う方が自然です。ただし、PLC を入れても「いつ待つのをやめるか」という Jitter Buffer の責務は残ります。まずは欠番の待機と進行条件をテストで固定しておくと、codec を差し替えても再生順序の境界が崩れません。