0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

TypeScriptでJitter Bufferを実装する:順不同の音声フレームを期限付きで再生する

0
Posted at

リアルタイム音声を扱う背景として、面接向け音声処理の記事を先に公開しています:https://aceround.app/ja/blog/voice-ai-interview-preparation

TypeScriptでJitter Bufferを実装する:順不同の音声フレームを期限付きで再生する

UDP、WebSocket、あるいは複数の非同期処理をまたぐ音声パイプラインでは、フレームが送信順に届くとは限りません。ここでは、小さな順序バッファを実装して、次の3つを同時に満たします。

  • seq の順序でフレームを出力する
  • 欠番が来たら、無限には待たない
  • 後ろのフレームが届いている場合だけ、期限後に無音を補完して再生を前へ進める

Map に入れてから「次に欲しい番号」だけを見る構成です。遅延を増やしすぎないことが重要なので、これは録音データを完全復元する仕組みではなく、ライブ再生向けの小さな Jitter Buffer です。

順不同の受信フレームを順序どおりに出力するJitter Bufferの状態遷移

先に決めるべき境界

フレームを次の型で扱います。実際の音声バイト列でも、STT の途中結果でも同じ考え方を使えます。

type Frame = {
  seq: number;
  payload: Uint8Array;
};

type PlayedFrame = {
  seq: number;
  payload: Uint8Array;
  concealed: boolean;
};

この実装が置く不変条件は2つです。

  1. nextSeq より小さい番号は、すでに再生済みなのでバッファに残さない
  2. 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 を差し替えても再生順序の境界が崩れません。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?