このサイトでの挿絵は、Gemini Proによる生成が大半です。 また、情報収集・まとめなどのサイトについては、検索結果をベースとしたものを使用しています。可能な限り出典を明示するよう努めますが完全ではありません。各自で調べる事もお忘れなく。

podcast-tool 開発日記 #5 — 同期と字幕の戦い

2025年9月前半。第4回で「音声+動画の基礎が整った」と書いたが、正確には「動いているように見えた」が正しかった。動画の長さが音声と合っていない。BGMのイントロが終わってから声が入るはずなのに、動画はその時間を無視して進む。9月9日から14日にかけての1週間は、その「見えていたズレ」を直す格闘だった。

タイムコードは既にあった

実は 9月14日以前から、動画にはタイムコードが焼き付けられていた。video.py に FFmpeg の drawtext フィルターが組み込まれており、左上に HH:MM:SS,mmm 形式の現在時刻が表示される仕組みだ。デバッグ用として入れていたこれが、後に「本当の同期ズレがどのくらいか」を目で確認する手段になった。

問題は数値として現れていた。動画 test.mp4 の再生時間が 26.0秒、期待値は 31.99秒——約6秒の不足。セクション間のギャップ1秒が3回分(計3秒)動画に反映されておらず、BGMのイントロ・アウトロ時間も計算から抜け落ちていた。

timing-memo.md と格闘の設計図

9月9日、timing-memo.md というファイルが復元された(14d292c)。タイムライン計算の考え方を整理したメモで、こういう内容が書いてある。

BGMには intro_volume_up_duration(声が入る前のBGMのみ時間)と outro_volume_down_duration(声が終わった後のBGMのみ時間)という2つのパラメータがある。台詞の長さに加えて、これら前後の「BGM単体時間」も動画の長さに含まれなければならない。セクション間のギャップも同様だ。

セクション長 = intro時間 + 台詞合計時間 + outro時間
全体長 = セクション1長 + ギャップ1秒 + セクション2長 + ...

原理はシンプルだ。ただし、これを「音声の合成フェーズ」「BGMのミックスフェーズ」「動画の生成フェーズ」という複数の処理段階を経た後でも正確に維持するのが難しかった。9月10日のコミット 70b7acd では「階層的タイミング管理システム」の設計が完了し、実装開始前のスナップショットとして記録が残っている。コミットメッセージに書かれた問題分析がそのまま設計書になっていた。

9月11日の修正ループ

朝7時16分、d7c99ca「動画生成処理の修正前(現状のコミット)」——これは「これから直す前の状態を保存した」という意味のコミットだ。

そこから約1時間、修正が連続で入る。

07:19  eab0e14  セクション長計算を修正
07:37  0c84912  動画生成プロセスにデバッグログを追加
07:42  965c334  ギャップ動画の挿入を無効化、デバッグログを再追加
07:45  085da25  セクション動画の結合時に1秒のクロスフェードを適用
07:50  0da668d  セクション動画生成時の不要な時間加算を削除
07:51  f706431  ログ出力の'crossfade_overlap'未定義エラーを修正

ひとつ直すと別の問題が出る、という典型的なデバッグの連鎖だ。「ギャップ動画の挿入を無効化」というコミットがあることから、ギャップの扱い方自体を一度リセットして別アプローチに切り替えたのが読み取れる。

そして午前11時26分、c80728e「BGM intro/outro機能完成と完全同期達成」。コミットメッセージのテスト結果がそのまま残っていた。

- intro単体 (main4: 3s) → 9.023265s完全同期
- outro単体 (ending: 8s) → 13.491950s完全同期
- intro+outro (self_intro: 2s+2s) → 15.504489s完全同期
- 0.6ms精度の音声・動画同期を達成

0.6ミリ秒。この数字が出たとき、長かった午前中の格闘が終わった。

字幕という新しい軸

同期が取れたその3日後、今度は字幕に手が伸びた。

9月14日昼、54fac99「字幕焼き込み機能の実装(WIP)」。実装メモを見ると、当日の調査結果として「タイムコード表示は video.py に実装済み」「SRTファイル生成と焼き込みは未実装」と書いてある。あるものと無いものの棚卸しから始める、いつものスタイルだ。

新しく subtitle_generator.py が生まれた。中心は SubtitleGenerator クラスと SubtitleEntry データクラス——タイムラインの voice_segment イベントを読み取り、SRT形式(HH:MM:SS,mmm --> HH:MM:SS,mmm)に変換する。FFmpeg の subtitles フィルターで動画に焼き込む部分は video.py 側に追加された。既存の drawtext タイムコードとの共存も考慮した実装になっている。

夕方19時33分、e0f7ba5「YouTube Shorts字幕焼き込み機能実装」でテスト完了が確認された。

BGMオフセットという落とし穴

ただし、そこで終わらなかった。

動作確認してみると、字幕の表示タイミングが全体的に 3秒早い。「皆さん、こんにちは」という最初の台詞が 00:00:03,592 から始まるはずが、00:00:00,592 頃に出てしまう。

原因はBGMのイントロだった。音声ファイルには先頭3秒のBGMイントロが含まれており、実際の台詞はその分遅れて始まる。ところが SubtitleGeneratorTimelinevoice_segment の開始時刻をそのまま使っていた——BGMイントロのぶんがタイムライン上で考慮されていなかった。

修正コミット f608294「BGMイントロ時間を考慮した字幕タイミング修正」では、main.pybgm_config から intro_volume_up_duration を読み取り、TimelineGenerator.add_voice_segmentbgm_intro_offset パラメータとして渡す形に変えた。これで字幕開始が 3.592秒 → 6.592秒 に修正され、音声と画面が一致した。

BGMのintro/outroは、音声ミキシングだけでなく字幕のタイミングにも影響する。ひとつの設定値が複数の処理系にまたがって意味を持つ、という複雑さがここで顔を出した。

この週で積み上がったもの

9月9日から15日の7日間で、3つのことが整った。BGMのイントロ・アウトロを含めた動画の完全同期(0.6ms精度)、SRT字幕ファイルの生成と動画への焼き込み、そして字幕タイミングへのBGMオフセット反映。どれも「ある機能を追加したら別の場所がずれた」という連鎖の中で生まれている。

次回予告

第6回は、同じ9月の中盤へ進む。字幕がSRTの単色テキストから ASS形式 へと進化し、話者ごとに色やフォントサイズを変えるスタイリング機能が登場する。セリフを読まなくても「誰が話しているか」が画面で分かる——そういう表現力が加わっていく。


この記事は podcast-tool のコミット履歴を一次資料として書いています。引用したコミットハッシュ・時刻・コード構成は当時のリポジトリ状態に基づきます。

Hugo で構築されています。
テーマ StackJimmy によって設計されています。