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?

「無音が消えると、文単位の話者分離は混線する」

1
Posted at

ストリーミング STT は、無音が無いと二人の発話を 1 つの utterance にまとめます。そこに文単位の話者ラベルを貼ると、切り替わった側の声まで同じ人のものになります。下流が LLM なら、他人の発話で推論が走ります。

話者分離は utterance ではなく、詞の中点 で独立した話者タイムラインを引いてください。前後の判定点が一致しない詞は uncertain です。前の詞のラベルを継承してはいけません。

Google の関連検索でも「音声認識 話者分離」「リアルタイム 話者分離」「Whisper 話者分離 リアルタイム」が出ます。バッチの議事録(Whisper + pyannote)の記事は多いです。こちらは 話者ラベルを返さない双方向ストリーミング ASR の上で、リアルタイムに LLM を撃つ側の話です。

何が壊れるか

面接官が「自己紹介をお願いします」と言い終わらないうちに、候補者が「はい、私は」と被せます。電話はステレオではありません。無音ゲートは発話終了を検知できません。

STT が見るのは一つのモノラル PCM です。返ってくる定稿はだいたいこうなります。

自己紹介をはい私は

文単位で話者を付けると、この文字列全体が interviewer か candidate のどちらかになります。どちらに倒しても片方が壊れます。

  • interviewer に倒す → 候補者の「はい私は」が質問バッファに入り、LLM が自分の声に答えます
  • candidate に倒す → 面接官の質問が落ちます

バッチの話者分離でよく見る「セグメント同士の時間重なり最大」も、この形では足りません。STT 側がすでに セグメントを 1 本に潰している からです。潰れたあとに区間同士を突合しても、切る場所がありません。

詞の中点でタイムラインを引く

必要な入力は二つです。STT とは別物です。

  1. 詞列。各詞に start_time / end_time(ストリーム内ミリ秒)
  2. 話者タイムライン。数百ミリ秒おきの 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 が増え、長すぎると混線を見逃します。

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?