【自分の声TTS×Claude Code×FFmpeg】画面録画を放り込むだけで”自分の声”で解説するショート動画を作ってみた
はじめに
このブログはClaude Code が自分のレポジトリを参照して書いています。私自身、全てを理解できているわけでもなく、精査できているわけでもありません。あくまでも、自分の備忘録として記しています。
何を作ったか
作ったアプリの紹介動画を、画面録画を1本放り込むだけで、自分の声で解説するショート動画として吐き出す仕組み。今回は研修運営アプリの紹介動画を、X 用の横長(16:9)と、リール/TikTok/YouTube ショート用の縦長(9:16)の2種類、同じ素材・同じ音声から出し分けた。

中身は大きく3つのパートに分かれている。(らしい)
- 自分の声を学習させた TTS(音声合成) — テキストを渡すと本人っぽい声で読み上げる
- Claude Code による台本づくり — 録画の中身を実際に見てナレーション原稿を組む
- FFmpeg による動画合成 — 背景・音声・字幕・口パクアバターを1本の mp4 に焼き込む
順番に書いていく。
1. 自分の声を学習させる(TTS)
音声合成エンジンは GPT-SoVITS を使っている。MIT ライセンスのオープンソースで、数分の音声からでも
声をクローンできる few-shot 型が売り。これを自分の録音 12.8分ぶんでファインチューニングした。
学習も推論も完全ローカルで回している。Mac の GPU(MPS バックエンド)で動かしていて、合成時はネットワークに一切出ない。
クローンの精度は SECS(話者埋め込みのコサイン類似度) で客観評価した。
ゼロショット(学習なし)だと 0.58 くらいだったのが、ファインチューニングで 0.70、さらに参照音声を学習スライスそのものに差し替えたら 0.83 まで上がった(本人帯 0.78〜0.87 に入る)。
ここで一番効いたのが、地味だけど 参照音声の選び方だった。学習済みモデルに加えて、合成時に「この声色で喋って」と渡す参照クリップが声の質を大きく左右する。クリーンな別録りより、学習に使ったスライスをそのまま参照にするのが一番似た。0.65 → 0.83 はここで動いた。
パラメータの確定値はこのあたり:
- text split =
cut1(句点で分割) - temperature =
1.1 - top_k =
20 - 参照 = 学習スライス(5.9秒)+その文字起こし
ハマったのは temperature を下げると即 EOS で音声が崩壊すること。
普通は temperature を下げると安定しそうなものだが、この声は 1.0〜1.1 が安全帯で、下げると最初の一音で勝手に終了してしまう。ここは何度か焼き直してようやく腹落ちした。
2. 録画を見て台本を組む(Claude Code)
ここが今回いちばん「らしい」ところ。
ナレーション原稿は、Claude Code に画面録画を渡して、フレームを切り出させて中身を判断させてから書いている。
具体的には、録画から数秒おきにフレームを抜き出し(FFmpeg で -ss <時刻> -i in.mp4 -frames:v 1 out.png)、その画像を実際に見て「今どの場面で何をしているのか」を把握する。会場マップを作っている/席に着いている/ランダムで席替えしている/休憩タイマーを出している……といった場面の切り替わりを、画像ベースで地図にしてから台本に落とす。
これをサボると痛い目に遭う(らしい)。以前、ファイル名(例「交差点」)と実際の中身(実はサーキュレーターの配置)を取り違えて、ナレーションと映像が完全に食い違う動画を作ったことがある。
なので 「映像に映っていないものは喋らない」「必ず自分(or Claude)の目でフレームを確認する」を鉄則にした。(私は確認していない)
原稿は6〜7セグメントくらいに割って、各セグメントを {セリフ, 背景動画+開始秒(seek), 字幕} の組にする。細かいテクニックとして、英略語・数字はカナ表記にする(Power Apps → パワーアップス、3D → スリーディー)。TTS が読み崩すのを防ぐため。冒頭3秒はフック、最後は CTA、という定石も台本側で効かせている。
3. 動画に焼き込む(FFmpeg + PIL)
合成は build_short.py という1本のスクリプトに集約していて、プロジェクトごとの設定 short.json を1枚書くだけで動く。中でやっていることはこんな流れ。
3-1. 音声の連結と尺の確定
各セグメントのナレーション wav を、間に無音(既定 0.22秒、最後だけ 0.5秒)を挟んで連結する。これで動画全体の尺とフレーム数(30fps)が決まる。音声の尺が映像の尺を決める設計にしてあるので、ナレーションがはみ出ることがない。
3-2. 口パク(ML なし)
アバターの口の開閉は、機械学習を使わず音声の音量から作っている。連結した音声を1フレーム幅ごとに区切って RMS(実効値)を計算し、エンベロープを取って 0〜1 に正規化。これを口の開き具合に直結させる。音が大きいフレームほど口が開く。あわせてアバター本体も軽く bob(上下)と squash(縦横の伸縮)をかけて、喋っている感を足している。アイコンは円形にクロップして、金色のリングを付けて表示する。

3-3. 背景映像の整形(縦横の出し分け)
ここが縦横2種類を出し分けている肝。各セグメントの背景を FFmpeg で作る。
vert(fill): 録画をscale=...:force_original_aspect_ratio=increase,cropで画面いっぱいに切り詰める。モーダルや全画面(休憩タイマーの 09:58 とか)が主役のシーンに使う。horz(fit): 録画を1回splitして、片方をガウシアンぼかし(gblur=sigma=28)+暗めにして背景に敷き、もう片方を等倍フィットして中央に重ねる。16:9 のソースを 9:16 に収めるとき、上下の余白をぼかし背景で埋める定番の見せ方。アプリ全景を見せたいシーン(フック・会場づくり・CTA)に使う。
横長版(16:9)は short.json に width/height を渡せば同じスクリプトで出る。
字幕の Y 位置(sub_cy_frac)やアバター位置(右下 br など)も設定で寄せられるようにした。
3-4. 字幕
字幕は libass などを使わず、PIL で直接 PNG に描いている(フォントはヒラギノ角ゴ W6)。
縁取り付きで、2行レイアウトを中央寄せ。フレームごとに「今どのセグメントか」を尺から逆算して、そのセグメントの字幕を焼く。
3-5. 最終 mux
背景セグメントを concat で1本に繋ぎ、その上にアバター+字幕の PNG 連番を overlay で重ね、最後にナレーション音声を -map で乗せて mux。出力は libx264 / yuv420p / +faststart 相当の素直な mp4。
ハマったところ(次にまた踏むやつ)
技術的に効いた教訓を2つだけ残しておく。
(1) FFmpeg の -ss は -i の「後ろ」に置く(正確シーク)
最初、開始秒を -ss で -i の前に置いていた。これは高速シークで普段は問題ないが、-stream_loop と fps フィルタと長尺エンコードが絡むと、指定した秒から数秒ずれた場所から再生される。-i の後ろに置く正確シークに直して解消した。
(2) オフセットより「尺ドリフト」が本質だった
背景と字幕が合わない症状を最初は「開始位置がずれている」と思ったが、本当の原因は別だった。各セグメントは指定した開始秒からそのセグメントの尺ぶん再生され続ける。今回の録画はモーダルが多く、例えば「着席済みマップ」がきれいに映っているのはソースの 4.9〜7.2秒のわずか 2.3秒だけ。セグメントの尺がそれより長いと、再生中にモーダルが閉じて別画面に流れてしまう。「字幕に合う絵がセグメントの尺ぶん持続するか」を場面ごとに検証して、足りなければ原稿を詰めるか seek 窓を選び直す、というのが結論だった。
検証は完成動画から各セグメントの開始・中盤・終盤でフレームを抜いて、字幕と背景が一致しているか目視している。
できあがってみて
きっかけはTTS(Text To Speech )を「ながらAIラジオ」で知ったことでした。Claude Code にこんなのあるらしいけどどうやればいいの?と相談から始めました。なんでもそうですが、自分で実際にやってみると、わかることがたくさんあると感じました。まだ、仕組みは完全に理解できていませんが、とりあえず動いたから良しということで!

簡単に試せるのは、生成AIのおかげです。ありがとうございます😊
