沈黙ゲートは「無音が続いたら相手が話し終わった」とみなします。双方向の通話をモノラルで拾うと、無音は消えます。重なって話すからです。
その経路では、発話終了検出を話者タイムラインの other→self フリップに置き換えます。self が 900ms・3点続いたら撃つ。語尾が「それから」「つまり」なら抑制する。抑制したときは水位 lastTurnEndMs を進めてはいけません。進めると、次のターンが永遠に来ません。
Google のサジェストでは「発話検出」「発話検知」「llm音声対話システム」「vad 音声」「話者交代」が出ます。英語側は stt endpointing / speech endpointing / asr endpointing です。Qiita で「発話終了検出」を検索すると、定稿をゲートにした話や Deepgram の endpointing が出てきます。沈黙待ちの VAD は多いです。沈黙が無い通話で、話者フリップそのものをゲートにする話は少ないです。
詞の中点で話者を分ける話は前回です。定稿を発話終了に使うと読点で LLM が走る話はこちらです。今回はその先、沈黙ゲートが死んだあとに何で撃つか、です。
無音が無いと、沈黙ゲートは永久に発火しない
相手が「自己紹介をお願いします」と言い終わらないうちに、こちらが「はい」と被せます。電話はステレオではありません。VAD が見るのは一つのモノラル PCM です。
沈黙閾値を 300ms にしても、1600ms にしても、重なりがある限り閾値に達しません。LLM は待ち続けます。
代わりの信号は単純です。相手が other、自分が self なら、other→self の切替は「相手の話が落ちて、自分が口を開いた」です。そこで pending の相手側テキストを定稿し、LLM を撃つ。
「うん」では切らない
フリップ判定を「self が 2点、400ms」にすると、短い相槌で切れます。通話の「うん」「はい」はだいたい 400〜700ms です。そこで半文を定稿すると、LLM は破片を質問として受け取ります。
900ms / 3点に上げます。相槌は窓に other が残るか、点が足りないかで落ちます。本当に受け答えを始めたときだけヒットします。代償は、相手の話が落ちてから撃つまでに約半秒足すことです。
窓の左端に最後の other が残っているあいだは、点が全部 self になりません。最後の other が窓の外に出るまで待ちます。900ms 窓なら、other の末尾から 900ms 超えたあたりで初めて通ります。
未完了なら、フリップを握りつぶす
フリップは「自分が口を開いた」であって、「相手が言い終わった」ではありません。相手が「自己紹介をお願いしますそれから」で止まっているときに、短い相槌が入るとフリップします。その半文を LLM に渡すと、接続詞の破片が質問になります。
語尾が接続詞・読点なら抑制します。pending は戻します。連抑制 2 回(判定窓ふたつ、だいたい半秒)までは待ちます。沈黙ゲートが無いので、無限に抑えると相手がそこで止まったとき永遠に撃てません。上限の先は半文でも撃ちます。沈黙で救えない経路の、切れないよりは切る、です。
語尾リストは言語ごとに持ちます。日本語なら「それから」「つまり」「あの」「もし」「について」。疑問符・感嘆符で終わっていれば完了です。読点で止まっていれば未完了です。
抑制パスで水位を進めるな
ここが本題です。
detectTurnFlip(nowMs, sinceMs) は、sinceMs より後に other があること を要求します。sinceMs は「前回ほんとうに turnEnd を撃った時刻」です。
抑制したフリップで sinceMs = nowMs にすると、その瞬間から先は self しか増えません。次の判定は hadOther === false で落ちます。上限も意味を失います。pending は残り、LLM は二度と走りません。
沈黙ゲートがある経路なら、その失敗を無音が拾います。この経路には無音がありません。水位が唯一の再開条件です。
正しい抑制は、回数だけ増やして水位を触らないことです。同じ other が sinceMs の後ろに残り続けるので、次の窓でもう一度フリップを評価できます。2 回目も未完了なら、3 回目で上限に達して撃ちます。
間違えると 1 回目で水位が nowMs に飛び、2 回目以降は none のままです。
実装
bun でこのまま動きます。
type SpeakerLabel = "self" | "other";
type TimelineEntry = {
timeMs: number;
label: SpeakerLabel;
};
class SpeakerTimeline {
constructor(private readonly entries: TimelineEntry[]) {}
add(timeMs: number, label: SpeakerLabel): void {
this.entries.push({ timeMs, label });
}
detectTurnFlip(
nowMs: number,
sinceMs: number,
sustainMs = 900,
minPoints = 3,
): number | null {
const recent = this.entries.filter(
(e) => e.timeMs > nowMs - sustainMs && e.timeMs <= nowMs,
);
if (recent.length < minPoints || recent.some((e) => e.label !== "self")) {
return null;
}
const hadOther = this.entries.some(
(e) => e.label === "other" && e.timeMs > sinceMs && e.timeMs <= nowMs,
);
if (!hadOther) return null;
let flipMs = recent[0]!.timeMs;
for (let i = this.entries.length - 1; i >= 0; i--) {
const e = this.entries[i]!;
if (e.timeMs > nowMs) continue;
if (e.label === "other") {
flipMs = e.timeMs;
break;
}
flipMs = e.timeMs;
}
return flipMs;
}
}
const COMPLETE_PUNCT = new Set(["?", "?", "!", "!"]);
const INCOMPLETE_PUNCT = new Set([",", ",", "、", ";", ";", ":", ":"]);
const NEUTRAL_TRAILING = /[。..…~~\s]+$/;
const INCOMPLETE_TAILS = [
"あの",
"その",
"つまり",
"それから",
"そして",
"たとえば",
"もし",
"について",
"として",
"だから",
"でも",
];
function isIncompleteUtterance(text: string | null | undefined): 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));
}
type TurnEndResult =
| { kind: "none" }
| { kind: "suppressed"; suppressed: number; tail: string }
| { kind: "fired"; flipMs: number; flush: string };
function checkTurnFlip(opts: {
timeline: SpeakerTimeline;
nowMs: number;
lastTurnEndMs: number;
pending: string | null;
suppressedFlips: number;
maxSuppressed: number;
advanceOnSuppress: boolean;
}): {
result: TurnEndResult;
lastTurnEndMs: number;
pending: string | null;
suppressedFlips: number;
} {
const flipMs = opts.timeline.detectTurnFlip(opts.nowMs, opts.lastTurnEndMs);
if (flipMs === null) {
return {
result: { kind: "none" },
lastTurnEndMs: opts.lastTurnEndMs,
pending: opts.pending,
suppressedFlips: opts.suppressedFlips,
};
}
const flush = opts.pending ?? "";
if (
isIncompleteUtterance(flush) &&
opts.suppressedFlips < opts.maxSuppressed
) {
return {
result: {
kind: "suppressed",
suppressed: opts.suppressedFlips + 1,
tail: flush.slice(-12),
},
lastTurnEndMs: opts.advanceOnSuppress ? opts.nowMs : opts.lastTurnEndMs,
pending: opts.pending,
suppressedFlips: opts.suppressedFlips + 1,
};
}
return {
result: { kind: "fired", flipMs, flush },
lastTurnEndMs: opts.nowMs,
pending: null,
suppressedFlips: 0,
};
}
const backchannel = new SpeakerTimeline([
{ timeMs: 100, label: "other" },
{ timeMs: 350, label: "other" },
{ timeMs: 600, label: "other" },
{ timeMs: 850, label: "other" },
{ timeMs: 1100, label: "self" },
{ timeMs: 1350, label: "self" },
]);
console.log("400ms/2点 @1350", backchannel.detectTurnFlip(1350, 0, 400, 2));
// 850
console.log("900ms/3点 @1350", backchannel.detectTurnFlip(1350, 0, 900, 3));
// null
const real = new SpeakerTimeline([
{ timeMs: 100, label: "other" },
{ timeMs: 350, label: "other" },
{ timeMs: 600, label: "other" },
{ timeMs: 850, label: "other" },
{ timeMs: 1100, label: "self" },
{ timeMs: 1350, label: "self" },
{ timeMs: 1600, label: "other" },
{ timeMs: 1850, label: "other" },
{ timeMs: 2200, label: "self" },
{ timeMs: 2450, label: "self" },
{ timeMs: 2700, label: "self" },
]);
console.log("900ms/3点 @2700", real.detectTurnFlip(2700, 0, 900, 3));
// null(窓の左端に other 1850 が残る)
console.log("900ms/3点 @2800", real.detectTurnFlip(2800, 0, 900, 3));
// 1850
console.log(isIncompleteUtterance("自己紹介をお願いしますそれから")); // true
console.log(isIncompleteUtterance("自己紹介をお願いします?")); // false
function runPath(advanceOnSuppress: boolean, label: string) {
const timeline = new SpeakerTimeline([
{ timeMs: 200, label: "other" },
{ timeMs: 450, label: "other" },
{ timeMs: 700, label: "other" },
{ timeMs: 950, label: "other" },
{ timeMs: 1300, label: "self" },
{ timeMs: 1550, label: "self" },
{ timeMs: 1800, label: "self" },
]);
let lastTurnEndMs = 0;
let pending: string | null = "自己紹介をお願いしますそれから";
let suppressedFlips = 0;
for (const nowMs of [1900, 2800, 3700]) {
if (nowMs > 1900) {
for (let t = nowMs - 700; t <= nowMs - 100; t += 250) {
timeline.add(t, "self");
}
}
const step = checkTurnFlip({
timeline,
nowMs,
lastTurnEndMs,
pending,
suppressedFlips,
maxSuppressed: 2,
advanceOnSuppress,
});
lastTurnEndMs = step.lastTurnEndMs;
pending = step.pending;
suppressedFlips = step.suppressedFlips;
console.log(label, nowMs, step.result, "watermark", lastTurnEndMs);
}
}
runPath(false, "correct");
// 1900 suppressed watermark 0
// 2800 suppressed watermark 0
// 3700 fired watermark 3700
runPath(true, "wrong");
// 1900 suppressed watermark 1900
// 2800 none watermark 1900
// 3700 none watermark 1900
correct は 2 回握りつぶしたあと、上限で撃ちます。wrong は 1 回目で水位が 1900 に飛び、その後ろに other が無いので終わりです。
フロントへ turnEnd を飛ばすなら、定稿イベントの直後に乗せることになります。React なら state が追いつく前に読むので、数十ミリ秒ずらした方が安全です。それは別件です。水位のバグの方が先に死にます。
抑制上限を 2 にしています。沈黙ゲートが無い経路で、未完了のまま上限まで抑えたあとに撃つのは半文を取る、です。上限を発話長や言語で変えていますか。