1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

STT の定稿を発話終了検出にすると、読点で LLM が走る

1
Posted at

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 を起こしていますか。半句に答えが飛ぶ症状があれば、どのフラグだったか聞きたいです。

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?