発話の改訂モデル
文字おこしくんが送る発話(utterance)は、1 回で届き切るものではありません。文字おこしくんは、同じ発話を認識途中 → 確定 → 整形と何度も送ります。受け手は同じ utteranceId の発話が届くたびに、持っている前の改訂を置き換えます。受け手が追記すると、画面に同じ文が何度も並びます。
3 つの段階
stage |
意味 | いつ届くか | 表示のしかたの例 |
|---|---|---|---|
partial |
認識途中。後から文字が変わる | 話している間、数百 ms ごと | 薄く表示する・字幕なら最終行に出す |
final |
音声認識の確定 | 話し終わり(発話区間の終わり)の直後 | 普通に表示する |
refined |
LLM による整形(句読点・表記の修正) | final の数百 ms〜数秒後に、非同期で届く。整形しない設定の Discord サーバーでは届かない |
final をそのまま置き換える |
- どの段階でも、同じ発話は同じ
utteranceIdで届きます。改訂のたびにrevisionが増えます。 - クライアントの受信が、文字おこしくんからの送信に追いつかないとき、文字おこしくんは
partialを間引くことがあります。revisionは連番とは限りません。 refinedは来ないこともあります。整形しない設定の Discord サーバーではrefinedは来ず、整形に失敗したときも来ません。発話がfinalのまま終わっても、エラーではありません。textは空文字のこともあります。たとえば、認識の途中で音が無かったときに、textが空文字になります。
受け手の決まり
- 発話は
utteranceIdをキーにした表(Map) で持つ。 - 届いた
utteranceのrevisionが、持っているものより大きいときだけ置き換える。同じか小さいものは捨てる(受け手は、到着順に頼らずにrevisionを比べるので、再接続のwelcome.recentと重なっても、受け手の処理は冪等になる)。 welcomeを受け取ったら、状態を丸ごと置き換える(再接続したときも)。受け手は切断中の差分を取り戻せない。代わりに、welcome.recent(直近の発話の最新の改訂)で状態を作り直す。- 文字おこしくんは、話者をまたいだ順序を揃えずに送る。時刻順に並べるときは
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)単位で表示する |
- 表示名は
welcome.speakersとspeaker_upsertedで届きます。受け手はspeaker_removedを受け取った後も、その話者の過去の発話を消さずに残します(表示名は覚えておいたものを使います)。 - 表示名は空文字のことがあります。表示名が空文字のときは、「不明な話者」などの代替名を表示してください。
よくある間違い
| 起きていること | 原因 | 正しくは |
|---|---|---|
同じ文が partial の数だけ並ぶ |
utterance を受け取るたびに行を追加している |
utteranceId で置き換える |
| 文字が前の状態に戻る | 最後に届いたものを最新とみなしている(遅れて届いた古い改訂で上書きしている) | revision を比べる |
| 再接続の後に、重複や消えたはずの状態が残る | 再接続の後に welcome.recent を前の状態に足している |
状態を welcome で丸ごと置き換える(revision の比較で重複は消える) |
| 句読点の無い文が記録に残る | final だけを保存して refined を無視している |
refined でも置き換える(保存するときも upsert する) |
| 話者をまたいだ前後が入れ替わる | 到着順に並べている | startedAt で並べる |
発話の text や表示名が HTML として動く |
innerHTML に text や表示名を入れている |
textContent を使う |
保持する数の上限
長いセッションでは発話が増え続けるので、受け手は、ふつう画面に出す分だけの発話を持ちます(公式の表示ページは 500 件を持ちます)。次の順で発話を捨てると、長く話している途中の発話が消えません。
- 確定した発話(
final/refined)のうちstartedAtが最も古いものから捨てる。 - 持っている発話がすべて認識途中のときを除き、認識途中(
partial)は捨てない。 - 上限に達しているときに届いた、知らない
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 で置き換わること。