0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

「抑制した発話終了検出の水位を進めるな:次のターンが永遠に来ない」

0
Posted at

沈黙ゲートは「無音が続いたら相手が話し終わった」とみなします。双方向の通話をモノラルで拾うと、無音は消えます。重なって話すからです。

その経路では、発話終了検出を話者タイムラインの 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 にしています。沈黙ゲートが無い経路で、未完了のまま上限まで抑えたあとに撃つのは半文を取る、です。上限を発話長や言語で変えていますか。

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?