発話の改訂モデル

文字おこしくんが送る発話(utterance)は、1 回で届き切るものではありません。文字おこしくんは、同じ発話を認識途中 → 確定 → 整形と何度も送ります。受け手は同じ utteranceId の発話が届くたびに、持っている前の改訂を置き換えます。受け手が追記すると、画面に同じ文が何度も並びます。

3 つの段階

stage 意味 いつ届くか 表示のしかたの例
partial 認識途中。後から文字が変わる 話している間、数百 ms ごと 薄く表示する・字幕なら最終行に出す
final 音声認識の確定 話し終わり(発話区間の終わり)の直後 普通に表示する
refined LLM による整形(句読点・表記の修正) final の数百 ms〜数秒後に、非同期で届く。整形しない設定の Discord サーバーでは届かない final をそのまま置き換える

受け手の決まり

  1. 発話は utteranceId をキーにした表(Map) で持つ。
  2. 届いた utterance の revision が、持っているものより大きいときだけ置き換える。同じか小さいものは捨てる(受け手は、到着順に頼らずに revision を比べるので、再接続の welcome.recent と重なっても、受け手の処理は冪等になる)。
  3. welcome を受け取ったら、状態を丸ごと置き換える(再接続したときも)。受け手は切断中の差分を取り戻せない。代わりに、welcome.recent(直近の発話の最新の改訂)で状態を作り直す。
  4. 文字おこしくんは、話者をまたいだ順序を揃えずに送る。時刻順に並べるときは startedAt(文字おこしくんが最初の音声を受け取った時刻、エポックミリ秒)で並べ、同じなら utteranceId で並べる。
const utterances = new Map(); // utteranceId → 最新の改訂

function onUtterance(u) {
  const known = utterances.get(u.utteranceId);
  if (known !== undefined && known.revision >= u.revision) return false; // 古い・重複した改訂
  utterances.set(u.utteranceId, u); // 追記ではなく置き換え
  return true;
}

function onWelcome(welcome) {
  utterances.clear(); // スナップショットで丸ごと置き換える
  for (const u of welcome.recent) onUtterance(u);
}

const ordered = () =>
  [...utterances.values()].sort((a, b) => a.startedAt - b.startedAt || (a.utteranceId < b.utteranceId ? -1 : 1));

届き方の例

1 人の話者の 1 つの発話は、たとえば次のように届きます(表では trackId などを省いています)。

順 stage revision text 受け手の状態(utt_02)
1 partial 0 それでは それでは
2 partial 1 それでは始め それでは始め
3 partial 2 それでは始めましょ それでは始めましょ
4 final 3 それでは始めましょう それでは始めましょう
5 refined 4 それでは始めましょう。 それでは始めましょう。
6 partial 2(遅れて届いた) それでは始めましょ 変えない(捨てる)

同時に別の話者の発話(別の utteranceId)も並行して届きます。2 人が同時に話せば、2 つの発話が同時に partial のまま伸びていきます。

話者

utterance.speaker は次のどれかです。

kind 意味 表示
declared 入力元が宣言した話者(Discord のユーザーなど) speakerId の話者の表示名
inferred 1 本の音声に複数人の声が入ったトラックで、話者分離が推定した話者 speakerId の話者(「話者 1」など)。inferred の発話は、後の改訂で別の speakerId の話者に付け替わることがある
unattributed 話者が分からない トラック(trackId)単位で表示する

よくある間違い

起きていること 原因 正しくは
同じ文が partial の数だけ並ぶ utterance を受け取るたびに行を追加している utteranceId で置き換える
文字が前の状態に戻る 最後に届いたものを最新とみなしている(遅れて届いた古い改訂で上書きしている) revision を比べる
再接続の後に、重複や消えたはずの状態が残る 再接続の後に welcome.recent を前の状態に足している 状態を welcome で丸ごと置き換える(revision の比較で重複は消える)
句読点の無い文が記録に残る final だけを保存して refined を無視している refined でも置き換える(保存するときも upsert する)
話者をまたいだ前後が入れ替わる 到着順に並べている startedAt で並べる
発話の text や表示名が HTML として動く innerHTML に text や表示名を入れている textContent を使う

保持する数の上限

長いセッションでは発話が増え続けるので、受け手は、ふつう画面に出す分だけの発話を持ちます(公式の表示ページは 500 件を持ちます)。次の順で発話を捨てると、長く話している途中の発話が消えません。

  1. 確定した発話(final / refined)のうち startedAt が最も古いものから捨てる。
  2. 持っている発話がすべて認識途中のときを除き、認識途中(partial)は捨てない。
  3. 上限に達しているときに届いた、知らない utteranceId の refined は無視する(捨てた発話の整形結果なので、入れると古い発話が復活する)。

コーディングエージェント向けのプロンプト

文字おこしくんの Subscribe WebSocket API の utterance メッセージを扱う状態管理を実装してください。
仕様: https://moji.kaitoranma.com/docs/revisions.md

- 発話は utteranceId をキーにした Map で持ち、revision が大きいときだけ置き換える(同じか小さい revision は無視)。追記しない。
- welcome を受け取ったら状態を丸ごと置き換え、welcome.recent を同じ規則で入れる。
- 表示用の並びは startedAt → utteranceId の昇順。
- speaker.kind が unattributed の発話はトラック(trackId)単位、declared / inferred は speakerId 単位でまとめる。inferred は改訂で speakerId が変わりうる。
- 上限を超えたら確定済み(final / refined)の古いものから捨て、partial は残す。
- テスト: partial r0 → final r3 → 遅れた partial r2 → refined r4 の順に入れて、最後が refined のテキスト 1 件になること。welcome で置き換わること。