バイリンガル字幕は一見 UI の機能に見えます。上に翻訳、下に原文。難しいのは「正確」という一語です。翻訳の一行一行が、いま話されているその一行と一致していなければならない。動画全体を通して、どこへシークしても。
バイリンガル字幕のリリース直後、奇妙な報告が届きました。翻訳行は自然な文章なのに、内容が 3 分先のシーンのものだったのです。ひどいときは同じフレーズが 8 回繰り返され、文字の壁になりました。このノートでは、原因として突き止めたことと、それを終わらせた 3 つの設計判断を書きます。
タダ飯には毒がある
動画プラットフォームは機械翻訳の字幕トラックを提供しています。無料で即時、リクエスト一つ。字幕プロダクトを作る人が最初に手を伸ばすのはこれで、私たちも例外ではありませんでした。しかし原文トラックと突き合わせて計測した結論は——テキストはまあ使える、タイミングは信用できない。翻訳と音声のずれは一定ではなく、再生とともに蓄積していきます。蓄積する誤差に「補正」は存在しません。ある量だけずれているのではなく、そもそも一度も整列していなかったのです。
プロダクトの信頼性で支払うタダ飯は、タダではありません。
断片から文へ
プラットフォームに原文トラックを要求して返ってくるのは、文ではありません。発話の切れ目で刻まれた数百の断片で、それぞれにミリ秒単位の時刻が付いています:
RAW FRAGMENTS (split by speech pauses)
382.1s "I guess heterosexual women"
383.3s "aren't allowed to have hair anymore."
385.7s "They have to shave their heads instead..."
|
| deterministic sentence merge
v
SPINE CUES (split by sentences)
#153 382.1s - 385.0s "I guess heterosexual women
aren't allowed to have hair anymore."
#154 385.7s - ... "They have to shave their heads..."
timing = exact union of the fragments, never invented
最初の二つの断片は実は一つの文です。自動字幕はどこでもこうで、一つの思考が二、三個のキューに割れているのが常態です。
これをそのまま翻訳に渡すことはできません。半分の思考はどうとでも訳せてしまう——"I guess heterosexual women" 単体ではどこへでも行けます。そのまま表示することもできません。断片は速すぎて読み切れない。そこで最初の処理として、断片を文の形のキューに縫い合わせる決定的なマージを通します。文が終わるまでマージを続け、同時にいくつかの安全弁を置く——長くなりすぎるキュー、画面に居座りすぎるキュー、明確な沈黙をまたぐキュー(間は天然の区切りです)は強制的に切ります。マージは文字体系も認識します。CJK はスペースなしで結合し、長さの予算もラテン文字より厳しくなります。
その中で一つ、他より重要なルールがあります。マージは断片の境界でしか起こらない。マージ後の各キューの開始・終了は、構成断片の時刻の正確な合併です——でっち上げのタイムスタンプを持つキューは一つもありません。
そしてこのマージが決定的である——同じ入力からは常に同じ文、同じ番号が出る——からこそ、後段の二つが成立します。ID が結合キーとして使えるほど安定すること。完成した翻訳を「そのトラックの正確な形」に対してキャッシュできること。どちらもすぐ後で効いてきます。
判断 1:タイムラインは一本だけ
翻訳トラックを丸ごと捨て、一つのルールで作り直しました。システムに存在するタイムラインは正確に一本。原文字幕トラック——一度だけ取得し、文単位にマージしたもの——が、内部で spine(背骨)と呼ぶ構造になります。各行に ID、開始時刻、長さ、原文:
YouTube native caption track <-- the only source of time
|
| parse -> merge into sentences
v
SPINE
#0 0.0s - 4.5s "Apple is releasing eight new..."
#1 4.5s - 8.2s "Here to comment are the most..."
#2 8.2s - 11.9s "Red Heart and Aerial Tramway."
...
|
v pushed to screen immediately (native text)
translations attach later, keyed by id -- never by time
このルールの牙はここです。翻訳は時間情報を一切持たない。翻訳は ID に付く注釈であり、第二のトラックではありません。ある瞬間に画面へ出すものは spine だけが決める。翻訳が答えるのは「#21 は日本語で何か」だけで、「いま何を表示すべきか」には決して答えません。
判断 2:ID を付けて送り、ID で受け取る
spine ができたら、文を ID 付きのバッチで LLM に送ります。慣用句や続き物のジョークが切れないよう、前後数行を文脈として添えます(翻訳対象外と明記):
SEND RECEIVE
context #18, #19 (do not translate)
translate #20 "They have to..." #20 -> translated
#21 "Under what..." #21 -> translated
#22 "Let me think..." #23 -> translated
#23 "[Laughs] I..."
set check: #22 missing
|
next round: resend #22 ONLY
LLM の出力は構造的に信用できません。行を飛ばす、二行を結合する、存在しない ID を作る。だから各バッチは受信時に集合検証します——返ってきた ID の集合は送った集合と一致しなければならない。余分は破棄。欠けた ID はより小さいバッチで再送し、それでも失敗し続けるならその一行だけ諦めて原文を表示します。失敗は構造として隔離されます。
スケジューリングは再生位置に従います。いま見ているバッチが最優先、次のバッチは先読み、残りはバックグラウンドで埋める。シークは「次にどのバッチか」を変えるだけ——完了した翻訳は二度とやり直さず、翻訳し終えた動画はあなたの Mac に永続キャッシュされます。
判断 3:なぜドリフトが起こり得ないのか
JOIN BY POSITION (fragile) JOIN BY ID (cannot slip)
native translated native translations
[0] ------- [0] #0 --lookup-- {#0: "..."}
[1] ------- [1] #1 --lookup-- {#1: "..."}
[2] --+ (blank -- dropped) #2 --lookup-- missing -> native
[3] +---- [2] <- off by one #3 --lookup-- {#3: "..."}
[4] --+
[5] +---- [3] <- drift grows
one hole shifts every line a hole stays a hole:
after it, forever one native line, nothing else
左は順序や時刻の整列に依存するすべての結合の持病です。穴が一つ空くと以降のすべての行がずれ、誤差は複利で育つ——まさにプラットフォームの翻訳トラックで計測した挙動です。右では結合キーが ID そのもの。リクエストに明記し、レスポンスにもそのまま返すことを要求します。間違った ID は何もずらしません。検証に落ちて再送されるだけ。「ずれているのに受理される」状態が存在しないのです。
私たちはドリフトを直したのではありません。ドリフトが成立するための前提を消したのです。ドリフトには「翻訳が自前の時間を持つ」という仮定が必要ですが、私たちの翻訳は時間を一切持ちません。
手に入るもの
動画が始まった瞬間に原文字幕が出ます。翻訳は数秒で追いつき、1 分以内に動画全体が完了、以後は永続キャッシュ。LLM は前後の会話が見えるので、口語もジョークも逐語訳ではなく言葉として出てきます。そしていつも通りサーバーはありません。翻訳はあなた自身の Gemini キーで Mac から直接実行され、あなたが何を観ているかを私たちが見る場所は存在しません。
字幕プロダクトは同じ教訓を二度学びます。プラットフォームのデータは、言葉を信じる前に時計を検証せよ。この授業料は支払い済みです。このノートがその領収書です。