ストリーミング ASR を扱っていると、時計が 2 つあることに気づきます。
- 壁時計 — 実際に経過した時間
- ストリーム内時計 — 上流が数えている「送られてきた音声の長さ」
ふだんは同じ速さで進みます。ところが、無音のあいだも接続を生かしておくために短い無音フレームを間隔をあけて送る(キープアライブ)ようにすると、ここがずれ始めます。10ms の無音を 400ms ごとに送る場合、ストリーム内時計は壁時計の 1/40 でしか進みません。
その結果、上流が「末尾の無音が 800ms 続いたら確定」と判定するまでに、壁時計では 32 秒かかります。5 秒黙いただけで「もう確定しているはず」と判断して接続を閉じると、最後の発話とその確定結果をまとめて失います。
この記事は、実際に動かしている音声経路でこのずれを踏んだときの記録です。あわせて、ずれを扱うために書いたコードを載せます。
単位の違う 2 つのゲート
ストリーミング ASR の上流には、少なくとも 2 つの独立したゲートがあります。
| ゲート | 数えている対象 | 実測 |
|---|---|---|
| 一定時間バイトが来なければ切る | 壁時計(最後にバイトを書いてからの経過) | 何も送らないと 15.002 秒で切断。400ms ごとに 10ms 送れば 45 秒生き続ける |
| 末尾の無音が一定長になったら確定する | ストリーム内時計(届いた音声のうち、末尾の無音の長さ) | 800ms |
(筆者らは 2 社のストリーミング ASR を使い分けていて、どちらにも同じ形のゲートがあります。上の 2 つの実測値は、それぞれ該当するゲートを持つ側で測ったものです。)
1 つ目のゲートは「どれだけ黙ったか」ではなく「バイトが届いたか」で判定しています。だから中身は何でもよく、10ms の無音を 1 枚送るだけでタイマーは戻ります。
問題は、1 つ目を避けるために送っているこの 10ms が、2 つ目のゲートの時計を動かす側でもあることです。片方を守るためのキープアライブが、もう片方の時計を遅らせます。
上流が見ているのはバイト数だけ
上流はこちらの壁時計を見ていません。見ているのは届いたバイト数です。
16kHz / 16bit / mono の PCM なら、32 バイトが 1ms、10ms が 320 バイトです。つまり「送ったバイト数 ÷ 32」が、そのまま上流にとっての音声の長さになります。
無音のあいだに送る量を減らすほど、この時計は遅くなります。キープアライブ 1 枚の長さを変えて、実際に計算してみます(間隔は 400ms で固定)。
| キープアライブ 1 枚 | ストリーム内の無音 800ms に必要な枚数 | それが壁時計で何秒か | ストリーム内時計の速さ |
|---|---|---|---|
| 10ms | 80 枚 | 32.0 秒 | 1/40 |
| 20ms | 40 枚 | 16.0 秒 | 1/20 |
| 50ms | 16 枚 | 6.4 秒 | 1/8 |
| 100ms | 8 枚 | 3.2 秒 | 1/4 |
| 400ms | 2 枚 | 0.8 秒 | 1/1 |
この表は、この記事の後半に載せているコードでそのまま出力したものです。
ここにトレードオフがあります。キープアライブを実時間どおりに送れば(400ms の無音を 400ms ごとに)時計は一致しますが、送った音声はそのまま課金対象になります。音声の長さで課金されるサービスでは、黙っているあいだ実時間ぶんの無音を流すのはそのままコストです。だから最小限の 10ms だけを送る。その結果、上流の無音判定は 40 倍遅くなる。
どちらが正しいという話ではありません。この 2 つが同じ「無音」という言葉で呼ばれていることが、事故の入口です。
ずれを扱うコード
上流が返してくる start_time / end_time はストリーム内座標です。これを壁時計と混ぜないために、「実際に上流へ書き出したフレーム」を 1 枚ずつ記録して、ストリーム内座標 → 壁時計 の写像を持ちます。
依存ゼロなので、そのまま node(22.18 以降)でも bun でも動きます。
/**
* ストリーミング ASR の「ストリーム内時計」と「壁時計」を分けて扱う最小実装。
* 依存ゼロ。16kHz / 16bit / mono の PCM は 32 バイト = 1ms。
*
* node stream-clock.ts
*/
/** 16kHz / 16bit / mono:32 バイトが 1ms */
const PCM_BYTES_PER_MS = 32;
/** PCM の実体。時計は長さしか見ないので中身は 0 で構いません */
const pcm = (ms: number) => new Uint8Array(ms * PCM_BYTES_PER_MS);
/** 実際に上流へ書き出した 1 フレームと、そのときの壁時計時刻 */
interface Mark {
/** このフレームを送り終えた時点のストリーム内座標(ms) */
streamMs: number;
/** 実際に書き出した壁時計時刻(ms) */
at: number;
}
/**
* ストリーム内座標 → 壁時計 の写像。
*
* 記録するのは「実際に上流へ書き出したフレーム」だけです。無音のキープアライブも
* 上流から見れば音声なので、必ず同じ入口を通します。
*/
class StreamClock {
private streamMs = 0;
private marks: Mark[] = [];
/** 古い Mark を捨てる上限。直近の会話ぶんだけ残せば足ります */
private readonly limit: number;
constructor(limit = 4096) {
this.limit = limit;
}
/** 上流へ書き出したフレームを 1 枚ぶん記録します */
record(frame: Uint8Array, at: number): void {
this.streamMs += frame.length / PCM_BYTES_PER_MS;
this.marks.push({ streamMs: this.streamMs, at });
if (this.marks.length > this.limit) {
this.marks.splice(0, this.marks.length - this.limit);
}
}
/** 上流が認識している「音声の総量」。壁時計とは別物です */
get totalStreamMs(): number {
return this.streamMs;
}
/**
* ストリーム内座標 `offsetMs` の音声が、実際に上流へ出ていった壁時計時刻。
* その位置がすでに窓から落ちていれば null(推測で埋めない)。
*/
wallClockAt(offsetMs: number): number | null {
return this.marks.find((m) => m.streamMs >= offsetMs)?.at ?? null;
}
}
interface Options {
/** キープアライブを送る間隔(壁時計 ms) */
keepAliveIntervalMs: number;
/** キープアライブ 1 枚の音声長(ms) */
keepAliveFrameMs: number;
/** 上流が「ストリーム内の無音が N ms 続いたら確定」と判定する閾値 */
endWindowMs: number;
/** 収音を始めてから誰かが話し始めるまでの壁時計(ms) */
idleWallMs: number;
/** 発話の長さ(ms)。実音声は 100ms ずつ実時間で送る前提 */
speechMs: number;
/** 発話開始から最初の中間結果が届くまでの壁時計(ms) */
firstPartialAfterMs: number;
}
const REAL_FRAME_MS = 100;
function run(o: Options) {
const clock = new StreamClock();
let wall = 0;
// ① 収音を始めてしばらく誰も話さない。届くのはキープアライブだけ
while (wall < o.idleWallMs) {
wall = Math.min(wall + o.keepAliveIntervalMs, o.idleWallMs);
clock.record(pcm(o.keepAliveFrameMs), wall);
}
// ② 発話開始。実音声は実時間で送るので、ここからはストリーム内時計 = 壁時計
const speechStartWall = wall;
const speechStartStream = clock.totalStreamMs;
for (let sent = 0; sent < o.speechMs; sent += REAL_FRAME_MS) {
wall += REAL_FRAME_MS;
clock.record(pcm(REAL_FRAME_MS), wall);
}
const speechEndWall = wall;
// ③ 最初の中間結果が届いた。遅延をどう数えるか
const firstPartialWall = speechStartWall + o.firstPartialAfterMs;
const speechStartAt = clock.wallClockAt(speechStartStream);
const rawMs = firstPartialWall;
const correctedMs =
speechStartAt == null ? rawMs : firstPartialWall - speechStartAt;
// ④ 発話のあともキープアライブだけ。ストリーム内の無音が閾値に届くのは壁時計でいつか
let silenceStream = 0;
let frames = 0;
while (silenceStream < o.endWindowMs) {
wall += o.keepAliveIntervalMs;
clock.record(pcm(o.keepAliveFrameMs), wall);
silenceStream += o.keepAliveFrameMs;
frames++;
}
// ⑤ 壁時計で 5 秒黙いた時点の、ストリーム内の無音
const silenceAt5sWall = (5000 / o.keepAliveIntervalMs) * o.keepAliveFrameMs;
return {
speechStartStream,
rawMs,
correctedMs,
finalizeWallSilenceMs: wall - speechEndWall,
finalizeFrames: frames,
silenceAt5sWall,
};
}
const base: Options = {
keepAliveIntervalMs: 400,
keepAliveFrameMs: 10,
endWindowMs: 800,
idleWallMs: 20_000,
speechMs: 3_000,
firstPartialAfterMs: 300,
};
console.log('--- 収音開始後 20 秒は誰も話さず、そこから 3 秒の質問が来た場合 ---');
const main = run(base);
console.log(`発話開始のストリーム内座標 : ${main.speechStartStream} ms`);
console.log(`生の経過時間(収音開始基準) : ${main.rawMs} ms`);
console.log(`補正後の遅延(発話開始基準) : ${main.correctedMs} ms`);
console.log(
`ストリーム内の無音 ${base.endWindowMs}ms に届くまでの壁時計: ${main.finalizeWallSilenceMs} ms` +
`(キープアライブ ${main.finalizeFrames} 枚)`,
);
console.log(
`壁時計で 5 秒黙った時点のストリーム内無音: ${main.silenceAt5sWall} ms`,
);
console.log(
'\n--- キープアライブ 1 枚の長さを変えると、ストリーム内無音 800ms は壁時計で何秒か ---',
);
for (const keepAliveFrameMs of [10, 20, 50, 100, 400]) {
const r = run({ ...base, keepAliveFrameMs });
const slow = (r.finalizeWallSilenceMs / base.endWindowMs).toFixed(0);
console.log(
` 1 枚 ${String(keepAliveFrameMs).padStart(3)}ms` +
`(${String(base.endWindowMs / keepAliveFrameMs).padStart(3)} 枚で 800ms)` +
` → 壁時計 ${(r.finalizeWallSilenceMs / 1000).toFixed(1)} 秒` +
` / ストリーム内時計は壁時計の 1/${slow}`,
);
}
実行結果です。
--- 収音開始後 20 秒は誰も話さず、そこから 3 秒の質問が来た場合 ---
発話開始のストリーム内座標 : 500 ms
生の経過時間(収音開始基準) : 20300 ms
補正後の遅延(発話開始基準) : 300 ms
ストリーム内の無音 800ms に届くまでの壁時計: 32000 ms(キープアライブ 80 枚)
壁時計で 5 秒黙った時点のストリーム内無音: 125 ms
--- キープアライブ 1 枚の長さを変えると、ストリーム内無音 800ms は壁時計で何秒か ---
1 枚 10ms( 80 枚で 800ms) → 壁時計 32.0 秒 / ストリーム内時計は壁時計の 1/40
1 枚 20ms( 40 枚で 800ms) → 壁時計 16.0 秒 / ストリーム内時計は壁時計の 1/20
1 枚 50ms( 16 枚で 800ms) → 壁時計 6.4 秒 / ストリーム内時計は壁時計の 1/8
1 枚 100ms( 8 枚で 800ms) → 壁時計 3.2 秒 / ストリーム内時計は壁時計の 1/4
1 枚 400ms( 2 枚で 800ms) → 壁時計 0.8 秒 / ストリーム内時計は壁時計の 1/1
発話開始のストリーム内座標が 500ms なのに、壁時計ではそこまで 20 秒かかっています。この 20 秒のあいだに送った音声は、キープアライブ 50 枚ぶんの 500ms だけです。
このずれで壊れるもの 3 つ
① 収音開始からの経過時間は、遅延ではない
一番よく踏むのがこれです。
認識エンジンは、実際に音声が来るまで何も返しません。つまり「収音開始 → 最初の結果」のあいだには、誰も話していない時間が丸ごと入ります。上の実行例では、生の経過時間が 20300ms、発話開始からの実際の遅延は 300ms です。
この 2 つを混ぜると、無音の長いセッションだけが分単位の外れ値として尾を引き、パーセンタイルが意味を失います。直しは簡単で、発話開始のストリーム内座標を写像で壁時計に戻してから引くだけです。上のコードの correctedMs がそれにあたります。
ストリーム内座標は、上流が返してくる start_time などから取れます。「取れなかったときは生の値のままにする」のが大事で、推測で埋めると数字だけが良くなります。
② 「N 秒黙ったから確定済み」は成り立たない
無音が続いたら接続を閉じる、という最適化を入れたときに踏みました。
壁時計で 5 秒黙いた時点の、ストリーム内の無音は 125ms です(実行結果の最終行)。800ms にはまだ届いていません。確定結果はまだ来ていないのに、こちらから接続を閉じてしまう。
実際にプレビュー環境の E2E テスト(「収音を始めて 30 秒黙り、そのあと話しても転写できるか」)が赤になりました。最後の発話と、その確定結果がまとめて消えていました。
あとから計算すると、30 秒の無音でストリーム内時計は 750ms しか進んでいません(30 秒 ÷ 400ms × 10ms = 750ms)。800ms に届いていないので、確定結果がまだ来ていないのは当然でした。観測と計算が一致したので、この説明で裏が取れました。
直しは「無音を理由に閉じるのではなく、終端フレームを明示的に送って尾段の結果を待つ」です。閉じる経路と終わらせる経路を分けます。待ち時間は、最大でストリーム内の無音窓ぶんだけかかります。
③ 上流が返す時刻は、そのまま引き算できない
上流が返す end_time はストリーム内座標です。end_time - start_time は「ストリーム内で何 ms 話したか」であって、壁時計の長さではありません。
「話し終わってから確定結果が届くまでの遅れ」を出したいときも同じです。この遅れは実在していて、実測では 3 段の実質問で約 370ms / 約 830ms でした(2 社で差があります)。下流に「確定結果が来た時刻」を基準にしたタイマーがあると、この分を毎回むだに待ちます。
出す手順は同じです。確定結果に含まれる末尾のストリーム内座標 → 写像 → 壁時計 → 現在時刻から引く。写像が窓から落ちていたら、その値は出しません。
おまけ:アイドル解放の閾値も、先に分布を測る
同じ「無音をどう数えるか」の話で、もう 1 つ踏んだのがこれです。
接続を握り続けると上流の同時接続枠を食うので、「無音が一定時間続いたら枠を返す」という最適化を入れました。閾値を 5 秒にしたところ、返した枠がすぐ取り戻されます。省エネではなく、張り直しのコストを自分で買っていたわけです。
ある日の「解放 → 再取得」4,658 ペアを測ると、沈黙の長さは p25 9.9 秒 / p50 18.2 秒 / p75 42 秒でした。面接や会議でいう「少し黙る」は、思いのほか長いものです。
| 閾値 | 解放される回数 | 解放後も 30 秒以上静かだった割合 |
|---|---|---|
| 5 秒 | 4,658 回(100%) | 30% |
| 30 秒 | 1,587 回(34%) | 49% |
| 60 秒 | 782 回(17%) | 59% |
| 90 秒 | 462 回(10%) | 60% |
60 秒が転換点でした(回数はまだ減るのに、当たる割合が伸びない)。ここで打ち切って 60 秒にしています。
張り直しにはコストもかかります。この経路ではハンドシェイクが p50 104ms / p95 662ms、そのあいだの音声はバッファに積んで送り直します。「何秒空いたら返すか」は、先に空き時間の分布を測ってから、その p75 より十分大きい値で決める。汎用の数字を先に置くと、たいてい空振りします。
まとめ
- ストリーミング ASR の「無音」には 2 種類あり、単位が違います。バイトが来ないことによる切断は壁時計、末尾の無音による確定はストリーム内時計です。
- 上流が見ているのはバイト数だけです。16kHz / 16bit / mono なら 32 バイト = 1ms。
- キープアライブを小さく送るほど、ストリーム内時計は遅れます。10ms を 400ms ごとで 1/40、ストリーム内の無音 800ms が壁時計で 32 秒。
- 上流が返す時刻はストリーム内座標です。実際に書き出したフレームを記録して写像を持つと、遅延指標も確定結果の遅れも正しく出せます。
- 「N 秒黙ったから確定済み」で接続を閉じないでください。終端を明示して尾段を待ちます。
「どれだけ時間が経ったか」と「どれだけ音声を送ったか」は別のことです。 上流が数えているのは後者で、こちらが数えているのは往々にして前者です。
参考
この音声経路は、筆者が開発に関わっているリアルタイム面接 AI(AceRound)で運用しているものです。面接の最中に接続が切れると、認識中の一文ごと消えるので、時計のずれが一番出やすい場所のひとつです。
