ストリーミング STT は、無音が無いと二人の発話を 1 つの utterance にまとめます。そこに文単位の話者ラベルを貼ると、切り替わった側の声まで同じ人のものになります。下流が LLM なら、他人の発話で推論が走ります。
話者分離は utterance ではなく、詞の中点 で独立した話者タイムラインを引いてください。前後の判定点が一致しない詞は uncertain です。前の詞のラベルを継承してはいけません。
Google の関連検索でも「音声認識 話者分離」「リアルタイム 話者分離」「Whisper 話者分離 リアルタイム」が出ます。バッチの議事録(Whisper + pyannote)の記事は多いです。こちらは 話者ラベルを返さない双方向ストリーミング ASR の上で、リアルタイムに LLM を撃つ側の話です。
何が壊れるか
面接官が「自己紹介をお願いします」と言い終わらないうちに、候補者が「はい、私は」と被せます。電話はステレオではありません。無音ゲートは発話終了を検知できません。
STT が見るのは一つのモノラル PCM です。返ってくる定稿はだいたいこうなります。
自己紹介をはい私は
文単位で話者を付けると、この文字列全体が interviewer か candidate のどちらかになります。どちらに倒しても片方が壊れます。
- interviewer に倒す → 候補者の「はい私は」が質問バッファに入り、LLM が自分の声に答えます
- candidate に倒す → 面接官の質問が落ちます
バッチの話者分離でよく見る「セグメント同士の時間重なり最大」も、この形では足りません。STT 側がすでに セグメントを 1 本に潰している からです。潰れたあとに区間同士を突合しても、切る場所がありません。
詞の中点でタイムラインを引く
必要な入力は二つです。STT とは別物です。
- 詞列。各詞に
start_time/end_time(ストリーム内ミリ秒) - 話者タイムライン。数百ミリ秒おきの
other | self判定点(声紋でも、別チャネルの VAD でも、何でもよい)
各詞について中点 (start + end) / 2 をタイムラインに投げます。
切替点では、中点の 前の判定点と後の判定点が食い違います。ここで前の詞のラベルを引き継ぐと、候補者が口を開いた最初の数文字が安定して interviewer 側に流れます。一致したときだけラベルを返し、食い違ったら uncertain にしてください。字幕には出してよいです。質問バッファと LLM には入れません。
端(会話の先頭・末尾)だけは、片側の判定点が近いときに外挿します。そこは切替点ではないからです。
英語は空白を足す
中日韓は詞をそのまま連結すれば文になります。英語は違います。"Tell"+"me"+"about" を連結すると Tellmeabout になり、空白で語数を数えるノイズ判定が 1 語 と見ます。長い質問が「短いノイズ」扱いで落ちます。ラテン文字同士の境界だけスペースを入れてください。
短い other は、本人が長く話しているときだけ捨てる
詞境界でタイムラインが空振りすると、本人の連発の途中に 1〜2 字だけ interviewer へ漏れます。これを質問にすると、通らない破片で LLM が走ります。
捨ててよい条件は狭いです。
- その定稿の other が短い(CJK なら 3 字以下、それ以外は 3 語以下)
- かつ 同じ定稿の self の方が長い
self の反証が無い短い other は残します。「なぜ」「続けて」のような短い追問を落とす方が高いです。
実装
bun でこのまま動きます。
type SpeakerLabel = "self" | "other";
type TimedWord = {
text: string;
start_time: number;
end_time: number;
};
type TimelineEntry = {
timeMs: number;
label: SpeakerLabel;
};
const LATIN_WORD_CHAR = /[A-Za-z0-9]/;
const TRAILING_PUNCT = /[,.!?;:)\]}"']/;
const ATTRIBUTION_NOISE_MAX = 3;
function appendWord(acc: string, word: string): string {
if (!word) return acc;
if (!acc) return word;
const left = acc[acc.length - 1] ?? "";
const right = word[0] ?? "";
if (!LATIN_WORD_CHAR.test(right)) return acc + word;
if (!LATIN_WORD_CHAR.test(left) && !TRAILING_PUNCT.test(left)) {
return acc + word;
}
return `${acc} ${word}`;
}
function speechLength(text: string): number {
const t = text.trim();
if (!t) return 0;
if (/[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af]/.test(t)) return t.length;
return t.split(/\s+/).length;
}
class SpeakerTimeline {
constructor(private readonly entries: readonly TimelineEntry[]) {}
strictLabelAt(timeMs: number, edgeGapMs = 400): SpeakerLabel | null {
let before: TimelineEntry | null = null;
let after: TimelineEntry | null = null;
for (const e of this.entries) {
if (e.timeMs <= timeMs) {
if (!before || e.timeMs > before.timeMs) before = e;
} else if (!after || e.timeMs < after.timeMs) {
after = e;
}
}
if (before && after) {
return before.label === after.label ? before.label : null;
}
const edge = before ?? after;
if (!edge) return null;
return Math.abs(edge.timeMs - timeMs) <= edgeGapMs ? edge.label : null;
}
}
function splitWordsBySpeaker(
words: readonly TimedWord[],
timeline: SpeakerTimeline,
afterMs: number,
) {
let otherText = "";
let selfText = "";
let uncertainText = "";
let lastEndMs = afterMs;
for (const w of words) {
if (w.start_time < afterMs) continue;
const mid = (w.start_time + w.end_time) / 2;
const label = timeline.strictLabelAt(mid);
if (label === "self") selfText = appendWord(selfText, w.text);
else if (label === "other") otherText = appendWord(otherText, w.text);
else uncertainText = appendWord(uncertainText, w.text);
lastEndMs = Math.max(lastEndMs, w.end_time);
}
return { otherText, selfText, uncertainText, lastEndMs };
}
function isAttributionNoise(otherText: string, selfText: string): boolean {
const other = speechLength(otherText);
if (other === 0 || other > ATTRIBUTION_NOISE_MAX) return false;
return speechLength(selfText) > other;
}
const words: TimedWord[] = [
{ text: "自己", start_time: 0, end_time: 280 },
{ text: "紹介", start_time: 280, end_time: 560 },
{ text: "を", start_time: 560, end_time: 720 },
{ text: "はい", start_time: 1100, end_time: 1280 },
{ text: "私", start_time: 1280, end_time: 1480 },
{ text: "は", start_time: 1480, end_time: 1620 },
];
const timeline = new SpeakerTimeline([
{ timeMs: 125, label: "other" },
{ timeMs: 375, label: "other" },
{ timeMs: 625, label: "other" },
{ timeMs: 700, label: "other" },
{ timeMs: 1125, label: "self" },
{ timeMs: 1375, label: "self" },
{ timeMs: 1625, label: "self" },
]);
const mixed = splitWordsBySpeaker(words, timeline, 0);
console.log(mixed);
// { otherText: "自己紹介を", selfText: "はい私は", uncertainText: "", lastEndMs: 1620 }
const overlapWord: TimedWord[] = [
{ text: "自己", start_time: 0, end_time: 400 },
{ text: "紹介をはい", start_time: 800, end_time: 1100 },
{ text: "私は", start_time: 1200, end_time: 1500 },
];
console.log(splitWordsBySpeaker(overlapWord, timeline, 0));
// other: "自己" / self: "私は" / uncertain: "紹介をはい"
const enWords: TimedWord[] = [
{ text: "Tell", start_time: 0, end_time: 200 },
{ text: "me", start_time: 200, end_time: 320 },
{ text: "about", start_time: 320, end_time: 500 },
{ text: "yourself", start_time: 500, end_time: 800 },
];
const enTimeline = new SpeakerTimeline([
{ timeMs: 100, label: "other" },
{ timeMs: 350, label: "other" },
{ timeMs: 600, label: "other" },
]);
console.log(splitWordsBySpeaker(enWords, enTimeline, 0).otherText);
// "Tell me about yourself"
console.log(isAttributionNoise("は", "はい私はエンジニアです")); // true
console.log(isAttributionNoise("なぜ", "")); // false
afterMs は watermark です。発話の切り替わりを先に flush したあと、同じ詞を定稿で二度出さないために使います。
切替点にまたがる詞(上の 紹介をはい)は uncertain になります。ここを interviewer に倒すと、候補者の声で LLM が走ります。倒さない方が安いです。
詞タイムスタンプが無いとき
STT が詞を返さない経路では、この分割は動きません。そのときは utterance 単位に戻すしかありません。ラベルが取れないなら uncertain にして、自動では撃たない方が安全です。「全部 interviewer」に倒すと、故障中に自分の声へ答え続けます。
ストリーミング ASR で話者切替を詞単位でやっている人は、タイムラインの判定点間隔を何ミリ秒にしていますか。短すぎると切替点の uncertain が増え、長すぎると混線を見逃します。