昨日1日かけて、AIニュースアンカーを作った。
静止画からリップシンク動画を生成し、ローカルLLMで文体を書き換え、音声合成して、ニュースサイトにリアルタイムで流す。全部ローカルで、全部自分で繋いだ。
動くものができた瞬間、「あ、これ本当に個人でできるんだ」という感覚があった。3年前には想像もしなかった。
何を作ったか
news.xyz というニュースサイトがある。僕が作ったやつで、世界中のニュースをリアルタイムで集めてRustで配信している。
そこに「AIアナウンサー」機能を追加した。記事の🔊ボタンを押すと、AIアンカーの女性が記事を読み上げる。テキストで読むより、なんか頭に入る。
仕組みはシンプルだ。
ニュース本文
↓
Qwen3.5-122B(ローカルLLM)で「深夜ラジオ女性アナ」に書き換え
↓
Kokoro TTS で音声合成(日本語、24kHz WAV)
↓
LatentSync でリップシンク動画生成(静止画 → 口が動く動画)
↓
Fly.io のニュースサーバーにアップロード
↓
フロントエンドでポーリング → 動画が届いたら表示
アンカーのキャラクターは5人作った。リカ、ナナ、アオイ、マヤ、ハル。それぞれ42枚、35枚、34枚……と大量の写真をGeminiで生成しておいて、記事ごとに違う写真がスライドショーで流れる。
投票機能まで作った。「AIアナウンサー総選挙2026」として、誰のファンか毎日1票投票できる。冗談みたいだが、真剣に作った。
技術スタックの話
M5 Mac(Apple Silicon、192.168.0.47)を処理サーバーとして使っている。128GB統合メモリ、3TB SSD。これが全部の推論を担う。
Qwen3.5-122B-A10B-4bitをMLXで動かしている。MoEモデルなので実効パラメータは10Bだが、品質は抜群にいい。ニュース本文を「深夜ラジオの女性アナウンサー風」に書き換えるプロンプトを渡すと、ちゃんと色気とユーモアが乗った文体になる。英語・ローマ字を全部カタカナに直す後処理も入れた。
PERSONA_PROMPT = """あなたは深夜ラジオの女性アナウンサー「リカ」です。
ルール:
- ニュースの事実は正確に伝える
- 語尾や合いの手で自然な色気とユーモアを足す
例:「〜なんですって、ドキドキしますよね」「もうたまりません」
- 英語・ローマ字は一切使わない(AIは「エーアイ」など)
- 150字以内
"""
Kokoro-82Mで音声合成。Hugging FaceのオープンモデルでApple Siliconで速い。jf_alphaというボイスが日本語で自然な女性声になる。生成後にWhisperで文字起こしして、元テキストとの一致率が80%未満なら再生成する品質ループを入れた。
LatentSyncがリップシンク生成エンジン。静止画 + 音声WAVを食わせると、口が音声に合わせて動く動画が出てくる。オープンソースで、Apple Silicon(MPS)で動く。
117GBのRAMを食い潰した話
最初にfloat32で動かしたら、117GBのRAMを全部使い切った。
3.5秒の動画を生成するのに2時間以上かかる。ファンが全開で回り、Macがオーブンになった。明らかにおかしい。
原因を調べると、MPSでfloat32を使うとfloat16の何十倍もメモリを使うことがわかった。
# ダメ(117GB、2時間)
dtype = torch.float32
# 正解(318MB、3.5分)
dtype = torch.float16 if device_str == "mps" else torch.float32
たった1行の変更で、メモリが117GB→318MBになり、処理時間が2時間→3.5分になった。
それでもまだ問題があった。MacのGPUが熱で絞られていた。float32で2時間回し続けた後、チップが冷えるまでは正常な速度が出なかった。推論が1ステップ40秒のはずが609秒かかる、という状態。チップを冷ましてから再実行したら正常に戻った。
Apple Siliconの熱設計はうまくできているが、2時間フルロードをかけると限界が来る。
非同期パイプラインの設計
TTS(音声合成)とLatentSync(リップシンク)を直列に繋ぐと遅い。音声は数秒で生成できるのに、ユーザーを3.5分待たせることになる。
なので分けた。
ユーザーが🔊ボタンを押す
↓
①音声生成(M5、数秒)→ すぐブラウザで再生開始
②リップシンク生成(M5、3.5分)→ バックグラウンドで実行
↓
フロントエンドが5秒ごとにポーリング
↓
動画が届いたら静止画から差し替えて▶ LIVE表示
動画が届く前は静止画で、届いたら動画に切り替わる。動画は3.5秒ループで生成して、フロントでループ再生させている。これで生成時間が予測可能になった(長い音声でも常に3.5秒)。
バックエンドはRust(Axum)、フロントエンドはVanilla JS。Fly.ioの永続ボリューム(/data/lipsync/)に動画を保存して、記事IDとアンカーIDのペアでファイル名を管理している。
アンカーと記事の対応
記事ごとにどのアンカーが担当するか、決定論的なハッシュ関数で決めた。
fn announcer_for_article(id: &str) -> &'static str {
let mut h: u32 = 0;
for c in id.chars() {
h = h.wrapping_mul(31).wrapping_add(c as u32);
}
match h % 5 {
0 => "rika_01",
1 => "rika_02",
2 => "rika_03",
3 => "rika_04",
_ => "rika_05",
}
}
同じ記事を誰が開いても同じアンカーが表示される。JavaScriptでも同じ計算をして、APIとフロントが同期している。ランダムにしたら「この記事はリカが読む」という一貫性がなくなる。
できたもの
- AIアンカー5人がニュースを読む
- 読み上げはリカ口調に書き換えられた文体
- 約3.5分で口が動く動画が届く
- 総選挙ページで誰が人気か投票できる
全部ローカルで処理しているのでAPIコストゼロ。Qwen3.5-122BもKokoroもLatentSyncもオープンモデルなので、ライセンス料もゼロ。M5 Macの電気代だけ。
個人でここまでできるようになった
3年前、僕がMercariでプロダクト責任者をやっていた頃、「ディープフェイクみたいな動画は大企業しか作れない」と思っていた。スタジオ、声優、専門エンジニア、GPUクラスタが必要だと。
今は違う。M5 MacとオープンモデルとPythonとRustがあれば、週末に一人で作れる。
クオリティはまだ荒い。リップシンクは3.5分かかるし、Whisperの照合スコアが低い文章は再生成する。でも「動くAIアンカーがニュースを読む」という体験は成立している。
LatentSyncのstepsを5に落とし、DeepCacheを有効にし、float32→float16に変えた。この3つの変更で2時間が3.5分になった。チューニングの余地はまだある。
技術は今、個人でも扱えるところまで来た。
問題は「何を作るか」だけだ。
news.xyz は https://news.xyz で動いています。AIアナウンサー総選挙は こちら から。