1
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でPCMの固定長Ring Bufferを実装する:AudioWorkletをネットワーク遅延から守る

1
Posted at

この記事の背景となる音声対話のユースケースは、先に公開した 音声AI面接対策 で説明しています。本稿はそのプロダクト解説ではなく、ブラウザで音声をリアルタイム送信するときのキュー設計を扱います。

TypeScriptでPCMの固定長Ring Bufferを実装する:AudioWorkletをネットワーク遅延から守る

リアルタイム音声アプリで最初に守るべきなのは、AudioWorklet の処理をネットワーク送信で待たせないことです。

マイクは一定間隔で PCM フレームを出します。一方、WebSocket や STT API の送信完了はネットワーク状況で揺れます。Worklet のコールバック内で送信を待つ構造にすると、短い遅延が積み重なって音声フレームを落とし、さらに処理のタイミングまで不安定になります。

解決策は、音声生成側と送信側の間に 容量固定の Ring Buffer を置くことです。この記事では次を実装します。

  • AudioWorklet は PCM フレームを main thread に渡すだけにする
  • main thread は固定長キューに入れ、送信は常に 1 本だけ動かす
  • キューが満杯ならメモリを増やさず、欠落を観測可能にする
  • FIFO、回り込み、満杯時の境界をテストする

AudioWorkletからRing Bufferを経由してSTTへ送る構成図

図の要点は、送信待ちを AudioWorklet に持ち込まないことです。遅いのはネットワークであり、音声スレッドではありません。

無限配列が壊すもの

frames.push(frame) だけでは、送信先が遅くなったときにキューが際限なく伸びます。これは単なるメモリ問題ではありません。数秒前の音声を遅れて送っても、ライブ字幕や会話システムでは価値が低く、遅延だけが増えます。

固定容量にすると、設計上の問いが明確になります。

  1. キューが満杯になったとき、古いフレームと新しいフレームのどちらを捨てるか
  2. 何フレーム捨てたかをどこへ記録するか
  3. 欠落が許されない用途では、いつセッションを明示的に失敗させるか

ここでは音声ストリームを「最新性が重要」とみなし、新しいフレームを受け付けず 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 は添字を回すだけなので、pushshift はどちらも 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.lengthdropped を観測して調整します。

送信は 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 にしません。

Ring Bufferの送信状態と満杯時の扱い

境界テスト

少なくとも次の 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 を使った」だけで終わらせず、以下の順で話すと判断が伝わります。

  1. ボトルネックを分離する: 音声コールバックは一定周期、ネットワークは可変遅延です。
  2. 容量を固定する: 無限キューでメモリと遅延を同時に悪化させません。
  3. 不変条件を置く: headtailsize で空と満杯を明確にします。
  4. 過負荷を観測する: drop をメトリクスにし、無音で壊れたように見せません。
  5. 整合性の境界を決める: 再接続時に再送するのか、欠落を通知するのかをプロトコルで選びます。

固定長 Ring Buffer は小さなデータ構造ですが、リアルタイム系では「処理できない量を受けたときにどう壊れるか」を明示するための部品です。音声、センサー、ログ、ストリーミング UI でも同じ考え方を使えます。

1
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
1
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?