STT の definite(定稿)を「話が終わった」と読むと、読点の位置で LLM が走ります。
双方向ストリーミング ASR は、発話の途中でも区間を凍らせます。definite: true は「この区間の文字はもう変わらない」です。ターンの終了ではありません。リアルタイムで相手の発話に答えを出すエージェントを運用していると、ここを取り違えた瞬間に半句へ回答が飛びます。
結論
発話終了検出は、定稿イベントの上に載せません。壁時計の無音ゲートです。
-
interim(同一utteranceId)→ 上書き。連結しない -
definite→ committed に固定する。LLM は起こさない - 有声音の最終時刻から、完了文は 1600ms、未完了テールは 2400ms 無音が続いたら fire
ASR 側の end_window も使いません。キープアライブが流時間を潰すと、800ms の流内無音が壁時計 32 秒になります。
誤りの再現
途中結果が読点で定稿される列です。ASR は呼びません。イベントの形だけ再現します。
type SttEvent =
| { kind: "interim"; utteranceId: string; text: string; atMs: number }
| { kind: "definite"; utteranceId: string; text: string; atMs: number }
| { kind: "audio"; energy: number; atMs: number };
const incompleteTurn: SttEvent[] = [
{ kind: "interim", utteranceId: "u1", text: "それで", atMs: 1000 },
{ kind: "interim", utteranceId: "u1", text: "それで、", atMs: 1400 },
{ kind: "definite", utteranceId: "u1", text: "それで、", atMs: 1800 },
];
function naiveFireOnDefinite(events: SttEvent[]): string | null {
for (const event of events) {
if (event.kind === "definite") return event.text;
}
return null;
}
Bun で回すと、答えはこうなります。
naiveOnDefinite = それで、
話者はまだ続きを言うつもりです。LLM は「それで、」だけを質問として受け取ります。字幕は正しく見えます。壊れるのは起動タイミングです。
interim を連結するのも別の壊れ方です。同一 ID の "それで" と "それで、" を足すと "それでそれで、" になります。部分結果は置換です。
正しさ:定稿では起こさない
未完了テールの判定は、漏らし側に倒します。誤判定するたびに、完了した質問まで毎回 +800ms 待ちます。
const COMPLETE_PUNCT = new Set(["?", "?", "!", "!"]);
const INCOMPLETE_PUNCT = new Set([",", ",", "、", ";", ";", ":", ":"]);
const NEUTRAL_TRAILING = /[。..…~~\s]+$/;
const INCOMPLETE_TAILS = [
"それで",
"それから",
"つまり",
"あと",
"について",
"としては",
"けど",
"から",
"ので",
"という",
"みたいな",
"例えば",
] as const;
const GATE = { baseMs: 1600, extraMs: 800, silentEnergy: 0.02 } as const;
function isIncompleteUtterance(text: string): boolean {
const value = text.trim();
if (!value) return false;
const last = value[value.length - 1] ?? "";
if (COMPLETE_PUNCT.has(last)) return false;
if (INCOMPLETE_PUNCT.has(last)) return true;
const tail = value.replace(NEUTRAL_TRAILING, "");
if (!tail) return false;
return INCOMPLETE_TAILS.some((word) => tail.endsWith(word));
}
function adaptiveGateMs(text: string): number {
return isIncompleteUtterance(text) ? GATE.baseMs + GATE.extraMs : GATE.baseMs;
}
方向は一方向です。ユーザーが決めた 1600ms より短くはしません。短くしたいなら、語彙表ではなく意味の EOU モデルが要ります。語彙表で攻めに行くと、間の多い話し手を切り刻みます。
ターン検出器は、有声音の最終時刻だけを壁時計で持ちます。
type Decision =
| { type: "hold"; reason: string }
| { type: "fire"; text: string; waitedMs: number; reason: string };
class TurnDetector {
private committed: string[] = [];
private liveId: string | null = null;
private liveText = "";
private lastVoiceMs: number | null = null;
private fired = false;
ingest(event: SttEvent): Decision {
if (event.kind === "audio") {
if (event.energy > GATE.silentEnergy) this.lastVoiceMs = event.atMs;
return this.maybeFire(event.atMs);
}
if (event.kind === "interim") {
this.liveId = event.utteranceId;
this.liveText = event.text;
this.lastVoiceMs = event.atMs;
this.fired = false;
return { type: "hold", reason: "interim-overwrite" };
}
if (this.liveId === event.utteranceId || this.liveId === null) {
this.committed.push(event.text);
this.liveId = null;
this.liveText = "";
}
this.lastVoiceMs = event.atMs;
this.fired = false;
return { type: "hold", reason: "definite-is-not-eou" };
}
private maybeFire(nowMs: number): Decision {
const text = this.visibleText();
if (!text || this.fired || this.lastVoiceMs === null) {
return { type: "hold", reason: "empty-or-already-fired" };
}
const waited = nowMs - this.lastVoiceMs;
const need = adaptiveGateMs(text);
if (waited < need) {
return { type: "hold", reason: `silence ${waited}ms < gate ${need}ms` };
}
this.fired = true;
return {
type: "fire",
text,
waitedMs: waited,
reason: isIncompleteUtterance(text)
? "extended-gate-after-incomplete-tail"
: "wall-clock-silence",
};
}
visibleText(): string {
return [...this.committed, this.liveText].filter(Boolean).join("");
}
}
同じ incompleteTurn のあとに、無音フレームを二段載せます。
const withSilence: SttEvent[] = [
...incompleteTurn,
{ kind: "audio", energy: 0, atMs: 1800 + GATE.baseMs },
{ kind: "audio", energy: 0, atMs: 1800 + GATE.baseMs + GATE.extraMs },
];
1600ms 無音 = hold (silence 1600ms < gate 2400ms)
2400ms 無音 = fire 「それで、」 waitedMs=2400
完了した質問は延長しません。
const completeTurn: SttEvent[] = [
{ kind: "interim", utteranceId: "u2", text: "自己紹介をお願いします", atMs: 500 },
{ kind: "definite", utteranceId: "u2", text: "自己紹介をお願いします。", atMs: 900 },
{ kind: "audio", energy: 0, atMs: 900 + GATE.baseMs },
];
complete question = fire 「自己紹介をお願いします。」 waitedMs=1600
句点は中立です。剥がしたあと、テール語に当たるかだけ見ます。疑問符と感嘆符は完了とみなして延長しません。
第二の罠:キープアライブが流時間を潰す
長時間の双方向 ASR は、無音でもフレームを送り続けないと切れます。こちらは 400ms ごとに 10ms の無音フレームを足しています。
ASR の end_window は流に乗った無音の合計です。壁時計ではありません。
function wallClockForStreamSilence(
endWindowMs: number,
keepAliveEveryMs: number,
keepAliveFrameMs: number,
): number {
return Math.ceil(endWindowMs / keepAliveFrameMs) * keepAliveEveryMs;
}
wallClockForStreamSilence(800, 400, 10);
// => 32000
800ms の流内無音を貯めるには、10ms フレームが 80 枚要ります。400ms 間隔なら 32 秒です。キープアライブ中に「ASR が定稿してくれる」のを待つと、最後の一句が数十秒残ります。
本番では、無音が続いたあとに接続を閉じるなら、空の末包を先に送り、尾の定稿が戻るまで待ちます。即 close() すると、最後の一句ごと消えます。発話終了検出そのものは、この流内時計ではなく、上の壁時計ゲートでやります。
確認したケース
上のコードを Bun 1.4 で実行しています。
| ケース | 結果 |
|---|---|
definite で即 fire |
それで、 が質問になる |
| 同一 ID の interim を連結 |
それでそれで、 になる |
| 未完了テール + 1600ms 無音 | hold。ゲートは 2400ms |
| 未完了テール + 2400ms 無音 | fire。waitedMs=2400
|
自己紹介をお願いします。 + 1600ms |
fire。延長しない |
end_window=800 × 10ms/400ms キープアライブ |
壁時計 32000ms |
リポジトリに ASR SDK は不要です。イベント配列を自分で渡せば、同じ分岐を試せます。
皆さんの現場では、is_final / definite / speech_final のどれで LLM を起こしていますか。半句に答えが飛ぶ症状があれば、どのフラグだったか聞きたいです。