🚀

Python と VOICEVOX で俳句読み上げ動画を自動生成する

に公開

本チュートリアルでは、Python と VOICEVOX Core、MoviePy v2を用いて「俳句を読み上げる動画」を自動生成するシステムを段階的に構築していきます。この記事は ChatGPT の力を借りて書きました。

前回の振り返りと今回の課題

前回の記事では run.py で音声ファイルを生成し、コマンドツールの FFmpeg を使って音声ファイルを結合したり、動画を生成しました。背景画像の生成には ImageMagick を使いました。細々としたコマンドオプションを使うので、作業は意外と煩雑であることがわかりました。

今回は前回確認したワークフローの処理を Python のコードに置き換えることにします。Python のコードに置き換えれば Windows などほかの OS でも利用できるようになるほか、可読性が上がり、長期的なコードのメンテナンスをやりやすくなります。

検証環境

  • Python 3.13 (uv で導入)
  • VOICEVOX Core 0.16.0
  • MoviePy v2
  • FFmpeg(MoviePyで使用)

環境の構築に関して、こちらの記事をご参照ください。

MoviePy のインストールは次のとおりです。

uv pip install moviepy

MoviePy は FFmpeg に依存します。Debian/Ubuntu の場合、次のコマンドでインストールできます。

sudo apt install ffmpeg

仕様

設定ファイルをもとに動画を生成します。設定ファイルは2つあります。1つめの設定ファイルには VOICEVOX を動かすための依存ライブラリのパスが記載されます。もう1つの設定ファイルには俳句と読み上げるキャラ、動画のファイル名が記載されます。

今回の設定ファイルの形式は TOML とします。Python 3.11 では TOML を読み込むための tomllib が標準モジュールになりました。

コードの読み方

今回、処理を複数の関数に分割します。それぞれの関数のシグネチャ(引数と戻り値の数やデータ型)について大まかな把握が必要です。ファイルのパスは str 型と Path 型が混在していることに注意が必要です。

設定ファイルの読み込み

config_loader.py
def load_haiku_config(
    entry_path: str | Path = "haiku_entries.toml",
    env_path: str | Path = "haiku_env.toml"
) -> List[Dict[str, Any]]:

音声ファイルの生成

voicevox_synthesizer.py
def synthesize_voice_from_config(config: Dict[str, Any]) -> Dict[str, Any]:

音声ファイルの統合

audio_joiner.py
def join_and_export_audio_sync(config: dict, silence_duration: float = 0.5, fps: int = 44100) -> Path:

動画の生成

video_generator.py
def generate_video_sync(config: Dict[str, Any], resolution=(1920, 1080), fps=30) -> Path:

設定ファイルのサンプル

設定ファイルを2つ用意します(haiku_env.tomlhaiku_entries.toml)。haiku_env.toml には VOICEVOX を動かすために必要な依存ライブラリのパスなどを記載します。

haiku_env.toml
mode = "CPU"
onnxruntime = "voicevox_core/onnxruntime/lib/libvoicevox_onnxruntime.so.1.17.3"
dict_dir = "voicevox_core/dict/open_jtalk_dic_utf_8-1.11"
ld_library_path = "voicevox_core/c_api/lib"

haiku_entries.toml には俳句や読み上げキャラの設定などを記載します。句と句のあいだの無音の時間を作るため、句をわけて記載します。背景画像 (background) を指定しない場合、代替画像が自動生成されます。

haiku_entries.toml
[[entries]]
first = "古池や"
second = "蛙飛びこむ"
third = "水の音"
style_id = 3
vvm = "models/0.vvm"
filename = "videos/furuike.mp4"
#background = "/path/to/background.png"

[[entries]]
first = "柿食えば"
second = "鐘が鳴るなり"
third = "法隆寺"
style_id = 2
vvm = "models/0.vvm"
filename = "videos/kaki.mp4"
#background = "/path/to/background.png"

設定ファイルの読み込み

まずは設定ファイルを読み込み、正常に解析されているか確認します。config_loader.py を追加します。

config_loader.py
import tomllib  # Python 3.11以降用、JSONも対応可
from pathlib import Path
from typing import Any, Dict, List

# TOML ファイルを読み込む関数
def load_toml_config(path: str | Path) -> Dict[str, Any]:
    path = Path(path)
    with path.open("rb") as f:
        return tomllib.load(f)

# 2つの TOML 設定ファイルを読み込み、エントリごとに env をマージして返す
def load_haiku_config(
    entry_path: str | Path = "haiku_entries.toml",
    env_path: str | Path = "haiku_env.toml"
) -> List[Dict[str, Any]]:
    entries = load_toml_config(entry_path)["entries"]
    env = load_toml_config(env_path)
    return [{**entry, **env} for entry in entries]

main.py を用意し、設定ファイルを読み込んでみましょう。

main.py
from config_loader import load_haiku_config
from pprint import pprint

configs = load_haiku_config()
pprint(configs)

スクリプトの実行結果です。

masakielastic@penguin:~/test$ uv run main.py
[{'dict_dir': 'voicevox_core/dict/open_jtalk_dic_utf_8-1.11',
  'filename': 'videos/furuike.mp4',
  'first': '古池や',
  'ld_library_path': 'voicevox_core/c_api/lib',
  'mode': 'CPU',
  'onnxruntime': 'voicevox_core/onnxruntime/lib/libvoicevox_onnxruntime.so.1.17.3',
  'second': '蛙飛びこむ',
  'style_id': 3,
  'third': '水の音',
  'vvm': 'models/0.vvm'},
 {'dict_dir': 'voicevox_core/dict/open_jtalk_dic_utf_8-1.11',
  'filename': 'videos/kaki.mp4',
  'first': '柿食えば',
  'ld_library_path': 'voicevox_core/c_api/lib',
  'mode': 'CPU',
  'onnxruntime': 'voicevox_core/onnxruntime/lib/libvoicevox_onnxruntime.so.1.17.3',
  'second': '鐘が鳴るなり',
  'style_id': 2,
  'third': '法隆寺',
  'vvm': 'models/0.vvm'}]

音声ファイルの生成

次は VOICEVOX を使って音声ファイルを生成します。voicevox_synthesizer.py を追加します。

voicevox_synthesizer.py
import os
import multiprocessing
from pathlib import Path
from voicevox_core import AccelerationMode
from voicevox_core.blocking import Onnxruntime, OpenJtalk, Synthesizer, VoiceModelFile
from typing import Dict, Any

def synthesize_voice_from_config(config: Dict[str, Any]) -> Dict[str, Any]:
    # 環境変数の設定(必要に応じて)
    if "ld_library_path" in config:
        os.environ["LD_LIBRARY_PATH"] = str(config["ld_library_path"]) + ":" + os.environ.get("LD_LIBRARY_PATH", "")

    # 共通設定の準備
    onnxruntime_path = str(config["onnxruntime"])
    dict_dir = str(config["dict_dir"])
    vvm_path = str(config["vvm"])
    style_id = int(config["style_id"])
    mode = config.get("mode", "AUTO").upper()

    # 音声合成器の初期化
    onnxruntime = Onnxruntime.load_once(filename=onnxruntime_path)
    synthesizer = Synthesizer(
        onnxruntime,
        OpenJtalk(dict_dir),
        acceleration_mode=mode,
        cpu_num_threads=max(multiprocessing.cpu_count(), 2),
    )
    with VoiceModelFile.open(vvm_path) as model:
        synthesizer.load_voice_model(model)

    # 各句を音声合成
    audio_paths = []
    base_name = Path(config["filename"]).stem
    output_dir = Path("tmp")
    output_dir.mkdir(parents=True, exist_ok=True)

    for i, key in enumerate(["first", "second", "third"], 1):
        text = config[key]
        query = synthesizer.create_audio_query(text, style_id)
        wav = synthesizer.synthesis(query, style_id)
        out_path = output_dir / f"{base_name}_{i}.wav"
        out_path.write_bytes(wav)
        audio_paths.append(out_path)

    return {
        "audio_paths": audio_paths,
        "output_path": output_dir / f"{base_name}.wav"
    }

引数と戻り値の型を確認します。引数の config のそれぞれの値を strint 型に変換したり、戻り値の型に Path 型が使われています。

main.py を次のように修正します。

main.py
from config_loader import load_haiku_config
from voicevox_synthesizer import synthesize_voice_from_config
from pprint import pprint

configs = load_haiku_config()

for cfg in configs:
    audio_paths = synthesize_voice_from_config(cfg)
    pprint(audio_paths)

スクリプトの実行結果です。

masakielastic@penguin:~/test$ uv run main.py
{'audio_paths': [PosixPath('tmp/furuike_1.wav'),
                 PosixPath('tmp/furuike_2.wav'),
                 PosixPath('tmp/furuike_3.wav')],
 'output_path': PosixPath('tmp/furuike.wav')}
{'audio_paths': [PosixPath('tmp/kaki_1.wav'),
                 PosixPath('tmp/kaki_2.wav'),
                 PosixPath('tmp/kaki_3.wav')],
 'output_path': PosixPath('tmp/kaki.wav')}

tmp ディレクトリを目視して俳句のそれぞれの句ごとに音声ファイルがあることを確認します。

masakielastic@penguin:~/test$ ls tmp
furuike_1.wav  furuike_2.wav  furuike_3.wav  kaki_1.wav  kaki_2.wav  kaki_3.wav

音声ファイルの統合

それぞれの句ごとの音声ファイルを統合します。次の audio_joiner.py を追加します。

audio_joiner.py
from moviepy import AudioClip, AudioFileClip, concatenate_audioclips
import numpy as np
from pathlib import Path

def join_and_export_audio_sync(config: dict, silence_duration: float = 0.5, fps: int = 44100) -> Path:

    def create_silence(duration: float, fps: int = 44100) -> AudioClip:
        return AudioClip(lambda t: np.zeros((len(np.atleast_1d(t)), 1)), duration=duration, fps=fps)

    clips = []
    silence = create_silence(silence_duration, fps=fps)
    for path in config["audio_paths"]:
        clip = AudioFileClip(str(path))
        clips.extend([clip, silence])
    if clips:
        clips = clips[:-1]
    final_clip = concatenate_audioclips(clips)
    output_path = config["output_path"]
    final_clip.write_audiofile(str(output_path), fps=fps)
    return output_path

音声ファイルのあいだに create_silence 関数で生成した無音ファイルを挿入することで間をつくっています。

main.py を更新します。

main.py
from config_loader import load_haiku_config
from voicevox_synthesizer import synthesize_voice_from_config
from audio_joiner import join_and_export_audio_sync

configs = load_haiku_config()

for cfg in configs:
    audio_paths = synthesize_voice_from_config(cfg)
    result_path = join_and_export_audio_sync(audio_paths)
    print("\n")

main.py を実行した結果です。

masakielastic@penguin:~/test$ uv run main.py
MoviePy - Writing audio in tmp/furuike.wav
MoviePy - Done.

MoviePy - Writing audio in tmp/kaki.wav
MoviePy - Done.                                                                                                               

tmp ディレクトリのなかに furuike.wavkaki.wav があることを確認します。

masakielastic@penguin:~/test$ ls tmp
furuike_1.wav  furuike_2.wav  furuike_3.wav  furuike.wav  kaki_1.wav  kaki_2.wav  kaki_3.wav  kaki.wav

動画の生成

最後に背景画像、音声ファイルから動画を生成します。video_generator.py を追加します。

from moviepy import AudioFileClip, ImageClip
from pathlib import Path
from typing import Dict, Any
from PIL import Image

# フォールバック背景画像を生成(淡いグレー)
def create_fallback_background(size=(1920, 1080), color="#eeeeee") -> Image.Image:
    return Image.new("RGB", size, color)

# 音声と背景画像から動画を生成(同期)
def generate_video_sync(config: Dict[str, Any], resolution=(1920, 1080), fps=30) -> Path:
    output_path = Path(config["filename"])
    output_path.parent.mkdir(parents=True, exist_ok=True)

    # 背景画像の取得または生成
    if "background" in config:
        image_path = Path(config["background"])
        background = ImageClip(str(image_path)).resized(resolution)
    else:
        fallback = create_fallback_background(size=resolution)
        tmp_image_path = Path("tmp") / f"{output_path.stem}_bg.png"
        tmp_image_path.parent.mkdir(parents=True, exist_ok=True)
        fallback.save(tmp_image_path)
        background = ImageClip(str(tmp_image_path)).resized(resolution)

    # 音声の読み込み
    audio_path = Path(config["audio_path"])
    audio_clip = AudioFileClip(str(audio_path))

    # 背景画像と音声を合成
    video = background.with_duration(audio_clip.duration).with_audio(audio_clip)
    video.write_videofile(str(output_path), fps=fps)

    return output_path

設定ファイルで背景画像が指定されていない場合、自動的にフォールバックの画像が生成されます。

main.py を更新します。

main.py
from config_loader import load_haiku_config
from voicevox_synthesizer import synthesize_voice_from_config
from audio_joiner import join_and_export_audio_sync
from video_generator import generate_video_sync

configs = load_haiku_config()

for cfg in configs:
    audio_paths = synthesize_voice_from_config(cfg)
    result_path = join_and_export_audio_sync(audio_paths)
    result = generate_video_sync({**cfg, "audio_path": str(result_path)})
    print("\n")

実行結果です。

masakielastic@penguin:~/test$ uv run main.py
MoviePy - Writing audio in tmp/furuike.wav
MoviePy - Done.                                                                                                               
MoviePy - Building video videos/furuike.mp4.
MoviePy - Writing audio in furuikeTEMP_MPY_wvf_snd.mp3
MoviePy - Done.                                                                                                               
MoviePy - Writing video videos/furuike.mp4

MoviePy - Done !                                                                                                              
MoviePy - video ready videos/furuike.mp4


MoviePy - Writing audio in tmp/kaki.wav
MoviePy - Done.                                                                                                               
MoviePy - Building video videos/kaki.mp4.
MoviePy - Writing audio in kakiTEMP_MPY_wvf_snd.mp3
MoviePy - Done.                                                                                                               
MoviePy - Writing video videos/kaki.mp4

MoviePy - Done !                                                                                                              
MoviePy - video ready videos/kaki.mp4

                                                             

まとめ

本記事では、VOICEVOX Core と MoviePy を用いて、俳句を読み上げる動画を Python のコードだけで自動生成するシステムを段階的に構築しました。以下の観点から得られた知見を振り返ります。

システム全体の構成

  • TOML による設定ファイル:環境依存情報(辞書やライブラリのパス)と作品ごとの俳句や出力先、スタイルIDを明確に分離。
  • 音声合成(VOICEVOX Core):句ごとの音声を生成し、後段の処理との接続性を保つために辞書型で出力を管理。
  • 音声結合(MoviePy):句と句のあいだに無音を挿入し、自然な朗読音声を生成。
  • 動画生成:背景画像と音声を組み合わせ、YouTube 向け解像度で動画を出力。背景画像が未指定の場合は淡いグレーのフォールバック画像を自動生成。

設計上の工夫と判断

  • Path 型と str 型の明確な切り分けにより、混乱を防ぎつつ柔軟なファイル操作を実現。各処理を関数単位で分離し、責務を明確に:再利用性とテストのしやすさを高めた。
  • エラー耐性のある構成:背景画像が存在しない場合にもフォールバックが機能するなど、運用時のトラブルを最小限に抑える設計。

今後の展望

  • 字幕やテロップの挿入、画像のフェードやアニメーションなどの演出も MoviePy や PIL を用いて拡張可能。
  • Webアプリケーション化や CLIツール化により、俳句投稿者自身が手軽に動画を生成できる環境の構築も視野に入る。
  • 音声ファイルや動画の 一時ファイルのキャッシュ管理や 出力フォルダの整理機能などの改善も今後の課題となる。

Discussion