← ノート

DeepSRT が正確なバイリンガル字幕を実現する方法

砕けた字幕を完全な文に縫い合わせ、番号を付けて LLM に渡し、訳文を一つずつ番号の席に戻す——タイムラインは最初から最後まで一本だけ。

2026年7月 · DeepSRT 開発ノート

DeepSRT で SNL の動画を再生:翻訳字幕と原文字幕が同じ一秒に揃い、右側に AI 要約サイドバー
実際の結果:訳文の行と原文の行が同じ一秒に揃う——動画全体のすべての秒で。

バイリンガル字幕は一見 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 に永続キャッシュされます。

DeepSRT で Jimmy O. Yang のスタンドアップを再生、タイムラインは 0:04——再生 4 秒で翻訳と原文の両方が既に画面上にある
再生位置優先スケジューリングの実際:再生開始から 4 秒で、バイリンガル字幕はもう画面にある。

判断 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 は何もずらしません。検証に落ちて再送されるだけ。「ずれているのに受理される」状態が存在しないのです。

私たちはドリフトを直したのではありません。ドリフトが成立するための前提を消したのです。ドリフトには「翻訳が自前の時間を持つ」という仮定が必要ですが、私たちの翻訳は時間を一切持ちません。

DeepSRT で KARINA の WATERBOMB ソウルのライブを再生:高速な歌詞と文中の [音楽] マーカーごと、訳文と原文が同じ一秒に揃う
最も過酷なケース:フェスのライブステージ——高速な歌詞、歓声、文中のサウンドマーカー——それでも同じ一秒に揃う。

手に入るもの

動画が始まった瞬間に原文字幕が出ます。翻訳は数秒で追いつき、1 分以内に動画全体が完了、以後は永続キャッシュ。LLM は前後の会話が見えるので、口語もジョークも逐語訳ではなく言葉として出てきます。そしていつも通りサーバーはありません。翻訳はあなた自身の Gemini キーで Mac から直接実行され、あなたが何を観ているかを私たちが見る場所は存在しません。

字幕プロダクトは同じ教訓を二度学びます。プラットフォームのデータは、言葉を信じる前に時計を検証せよ。この授業料は支払い済みです。このノートがその領収書です。