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

podcast-tool 開発日記 #3 複数エンジンとキャッシュ — 声の出どころを増やし、二度目を速くする

前回までのあらすじ

第2回では、誕生当日の夜から翌々日にかけての「音を整える」数日間を追った。話者ごとの速度調整、BGMのクロスフェードとループ、セクション間の無音、動的な音量調整——「とりあえず音が出る」から「聴けるものにする」へ、地味だが効く改良が密に積まれた。

その締めで、第2回の予告にこう書いた。「次は増えすぎた main.py にメスを入れ、pytest と GitHub Actions を入れるところまで追う」と。実はその工事は、誕生からわずか3日後——2025年7月14日に、ほぼ一日で済んでしまっていた。一枚岩だった main.py は、設定読み込みが config_utils.py へ(3f68288)、VOICEVOX 通信が voice.py へ(b4062da)、BGM・オーディオ処理が mixer.py へ(f5eb224)と切り出され、その日のうちに pytest 環境(pytest.ini)とテスト実行用の GitHub Actions ワークフロー(test_and_coverage.yml)まで載った。一枚岩は、思っていたよりあっさり崩れた。

なので第3回は、その分割され、テストの土台が入ったコードの上で初めて成立する話に進む。テーマは二つ。声の出どころを増やす(複数エンジン対応)ことと、二度目の生成を速くする(キャッシュ)ことだ。時期は2025年8月の前半。誕生から1ヶ月弱が経っている。

声の出どころを、ひとつから複数へ

8月4日の午後、エンジンまわりに手が入る。

1
6b91408  2025-08-04 14:52  [feat] 複数のVoicevox互換エンジンに対応

それまで config.json は、エンジンの宛先をたった一行で持っていた。

1
"voicevox_engine_url": "http://localhost:50021"

VOICEVOX エンジン1台に話しかける、という前提だ。これがこのコミットで、こう変わる。

1
2
3
4
"engines": [
  { "name": "voicevox", "url": "http://localhost:50021" },
  { "name": "aivis",    "url": "http://localhost:10101" }
]

単数の URL から、エンジンの配列へ。新しく加わった aivisAivisSpeech——VOICEVOX 互換の API を持つ、別系統の音声合成エンジンだ。互換 API という一点を足がかりに、声の選択肢をエンジンをまたいで広げにいった格好になる。

実装側、voice.py+115 / −69 とこの回でいちばん動いている。象徴的なのが新設された get_all_engine_speakers() で、設定に並んだ全エンジンから話者一覧をかき集めて束ねる。話者は「VOICEVOX の中の誰か」ではなく「登録したエンジン群のどこかにいる誰か」になった。台本に書いた話者名から ID を引く resolve_speaker_ids() も、この少し前(7月15日の 27fc83a)にフォールバック機構が入っていて、名前が見つからないときの逃げ道が用意されている。データ駆動の発想は相変わらずで、増えたのは設定ファイルの数行、というのが気持ちいい。

翌コミットではテストカバレッジの底上げ(40413ed)と、ruff を使った整形・型ヒント追加(4d8f7f9)が続く。7月14日に入れたテストとリンタの土台が、さっそく機能追加の安全網として効いている。

作る前に、仕様書を書く

複数エンジンから5日空いて、8月10日の未明。次の大物——キャッシュに取りかかる前に、まず手が動いたのはコードではなく文書だった。

1
7007461  2025-08-10 04:46  [docs] 包括的なアプリケーション仕様書を追加

SPECIFICATION.md302行まとめて新設している。いきなり実装に突っ込むのではなく、ツール全体の仕様をいったん文章で固めてから次に進む。立ち上げ期の勢い一辺倒だった第1回・第2回と比べると、開発の呼吸が少し変わってきたのが分かる箇所だ。この仕様書は、このあとキャッシュを足すたびに追記されていく。

二度目の生成を、速くする

そして同じ未明、キャッシュシステムの構築が一気に進む。ここは枝(ブランチ)を切って組み上げ、最後に本流へ合流させる、という作り方をしている。

1
2
3
4
5
298f8cf  2025-08-10 04:50  [feat] キャッシュシステムの基盤機能を追加
a3fc86f  2025-08-10 04:53  [feat] --init時のキャッシュディレクトリ自動作成機能を追加
3c46dd1  2025-08-10 05:03  [feat] キャッシュシステムの完全統合とドキュメント整備
f1134b2  2025-08-10 05:09  [feat] キャッシュクリーンアップ機能の実装完了
780a899  2025-08-10 12:28  [feat] キャッシュシステムの完全実装(マージ)

そもそも、なぜキャッシュが要るのか。音声合成は遅い。台本を少し直して作り直すたびに、変えていないセリフまで全部もう一度エンジンに投げ直すのは、明らかに無駄だ。同じ入力からは同じ音が出るのだから、一度作った音は取っておけばいい——それがこのキャッシュの動機だ。

基盤になる cache_utils.py(約170行)は、責務ごとに小さな関数を並べている。

関数役割
generate_cache_key(speaker_id, text, speed_scale)生成パラメータからキャッシュキーを作る
get_cache_path(cache_dir, cache_key, cache_type)キーから保存先パスを決める
save_to_cache(...) / load_from_cache(...)キャッシュへの保存・読み出し
cleanup_old_cache(cache_dir, max_age_days=30)古いキャッシュを掃除する
get_cache_stats(cache_dir)キャッシュの統計を取る

肝は generate_cache_key() だ。話者ID・発話テキスト・話速倍率の3つを | で繋ぎ、SHA256 で64文字のハッシュにする。テキストはハッシュ前に Unicode の NFC 正規化をかけていて、見た目が同じで内部表現だけ違う文字列が別物扱いされる事故を防いでいる。つまり「同じ話者が・同じ速さで・同じ文を喋る」なら、必ず同じキーになる。キーが一致すれば、エンジンを叩かずに前回の音を返せる、という寸法だ。

呼び出し側の voice.py には generate_voice_with_cache() が加わった。素の generate_voice() の手前にキャッシュ確認をかぶせる薄いラッパで、ヒットすれば即返し、外れたときだけエンジンに合成を頼んで結果を保存する。設定は config.jsoncache ブロックに集約された。

1
2
3
4
5
6
"cache": {
  "enabled": true,
  "directory": ".cache",
  "max_age_days": 30,
  "auto_cleanup": true
}

既定で有効、保存先は .cache、30日より古いものは自動で掃除する。--init(プロジェクト初期化)時にはキャッシュ用ディレクトリも自動で掘られる(a3fc86f)ので、使う側は存在をほとんど意識しなくていい。掃除まで含めて「置いておいても太りすぎない」よう手当てされているのが、いかにも実運用を見据えた作りだ。

この一連の作業は、最後に 780a899 として本流へマージされ、コミットメッセージのとおり「キャッシュシステムの完全実装」が main に乗った。マージコミットの差分には cache_utils.py と4本のテストファイル(test_cache_utils.py / test_init_cache.py / test_voice_cache.py / test_cache_cleanup.py)がまとめて姿を現す。機能とテストが必ずセットで入ってくるあたり、7月14日に整えた土台がちゃんと習慣として根づいているのが見てとれる。

この期間でできたこと

8月前半に積み上がったのは、このあたりだ。

できたことコミット
複数の VOICEVOX 互換エンジン対応(voicevox + aivis)6b91408
全エンジン横断の話者収集 get_all_engine_speakers()6b91408
包括的な仕様書 SPECIFICATION.md7007461
キャッシュ基盤 cache_utils.py(SHA256キー・NFC正規化)298f8cf
--init でのキャッシュディレクトリ自動作成a3fc86f
voice.py へのキャッシュ統合(generate_voice_with_cache3c46dd1
古いキャッシュの自動クリーンアップf1134b2
キャッシュシステムの完成(main へマージ)780a899

声の出どころを増やし、二度目の生成を速くした——どちらも「作り続けるための土台」を厚くする回だった。エンジンが増えれば表現の幅が広がり、キャッシュが効けば試行錯誤のサイクルが速くなる。派手な見栄えはまだ無いが、このあと動画へ突っ込んでいくための足回りが、ここで静かに固められている。

次回予告

第4回は、いよいよ 動画対応の幕開けを追う。8月後半、podcast-tool に動画生成機能が初めて登場し(43fe3a1)、台本の media タグのトランジション属性で映像を切り替える仕掛けが入る。「音声ツール」が「音と映像のツール」へと舵を切る、シリーズの転回点だ。


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

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