1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

VoiceVoxEngineを用いてストーリーを朗読音声化する【合成音声読み上げ】

Last updated at Posted at 2025-07-13

VoiceVoxEngineを用いて、話者とセリフからなるストーリーデータを朗読音声化しました。手法と使用したコードを備忘録としてまとめます。

この手法を用いて実際に音声化した際の手順については、以下の記事でまとめています。

PC環境

OS:MacOS(arm64)
CPU:Apple M3
メモリ:16GB

実行環境

VSCode上でJupyterNotebookを操作
Pythonバージョン:3.9.6
VoiceVoxEngineバージョン:0.24.0

手順(目次)

  1. VoiceVoxEngineのダウンロード
    -解凍方法
  2. HTTPサーバの起動
  3. 単体のセリフで音声合成
    -カスタマイズ方法
    -キャラクターID一覧
  4. セリフデータの整形
  5. 複数のセリフを結合して音声合成

1. VoiceVoxEngineのダウンロード

VoiceVoxはずんだもんや四国めたんで有名な音声合成ソフトです。無料にも関わらず十分な性能で音声合成を行えます。

一般的に広く使われるのはGUIアプリ版ですが、VoiceVoxEngineとして合成エンジン単体で利用できるアプリケーションがGitHubで公開されています。
こちらはコマンドラインで起動することで、音声合成を実施するための各種APIを取り揃えたサーバをローカルで構築することができます。

以下の公式サイトからダウンロードします。

圧縮ファイルの解凍

ダウンロードしたファイルは7-zipで圧縮されているため、以下のコマンドで解凍します。

7-zipの解凍.zsh
7z x voicevox_engine-macos-arm64-0.24.0.7z.001

私の環境では7-zipがインストールされていなかったため、以下のコマンドでインストールしたのち再度解凍しました。

7-zipのインストール.zsh
brew install p7zip

2. VoiceVoxEngineの起動

解凍すると、VoiceVoxEngineを利用するためのバイナリ・ライブラリ・モデルファイルが一式揃ったアプリケーションファイルが得られます。アプリケーションを起動するには、アプリケーションのディレクトリに移動して以下のコマンドを打ちます。

VoiceVoxEngineの起動.zsh
./run

アプリケーションの起動中は、HTTPリクエストを用いて音声合成が行えるようになります。アプリケーションを停止したいときは、Ctrl+Cで停止してください。

3. 単体のセリフで音声合成

Pythonコードで、実際に音声を合成していきます。
以下でセリフ単体を合成するための関数を定義しています。引数のtextの部分を変えることで色々な言葉を音声化できます。

セリフ出力関数の定義.py
# VOICEVOXを用いたセリフ出力用関数
import os
import requests

ENGINE_URL = "http://localhost:50021" # VOICEVOXエンジンのURL
SPEAKER_ID = 14  # キャラクター選択(VOICEVOX:冥鳴ひまり https://voicevox.hiroshiba.jp)

def make_voicefile(filename, text):
    try:
        # 1. 音声合成用のクエリを作成
        query_response = requests.post(
            f"{ENGINE_URL}/audio_query",
            params={"text": text, "speaker": SPEAKER_ID}
        )
        query_response.raise_for_status()
        query = query_response.json()

        # 話し方調整
        query["speedScale"] = 1.0       # 話す速度
        query["pitchScale"] = 0.0       # 声の高さ
        query["volumeScale"] = 1.0      # 声の大きさ
        query["intonationScale"] = 1.0  # イントネーションの豊かさ
        query["pauseLength"] = None     # 間の広さ

        # 2. 音声データを生成
        synthesis_response = requests.post(
            f"{ENGINE_URL}/synthesis",
            params={"speaker": SPEAKER_ID},
            json=query
        )
        synthesis_response.raise_for_status()
        audio_data = synthesis_response.content

        # 3. WAVファイルとして保存
        os.makedirs("temp", exist_ok=True)
        with open(f"temp/{filename}.wav", "wb") as f:
            f.write(audio_data)

        # print(f"生成完了: temp/{filename}.wav")
        return filename

    except Exception as e:
        print(f"[ERROR] {filename}: {e}")
        return None
セリフ出力の実行.py
make_voicefile("filename", "こんにちは、これはテストです。")

VoiceVoxEngineの話し方の調整

以下の項目を設定することで、話し方を調整することができます。

項目 調整内容 値の範囲
SPEAKER_ID 話者となるキャラクター選択 後述
volumeScale 音量 0~1
speedScale 話す速さ 0~1
pitchScale 声の高さ -1~1
intonationScale イントネーションの豊かさ 0~1
pauseLength 句読点などの間の広さ(秒) None(デフォルト) or 0~
pauseLengthScale 句読点などの間の広さ(倍率) 0~
prePhonemeLength 音声の前の間の広さ(秒) 0~
postPhonemeLength 音声の後ろの間の広さ(秒) 0~

SPEAKER_IDについては、以下から選択することができます。キャラクターの他に、喋り方の雰囲気も合わせて指定できます。
VoiceVoxはキャラクターによって利用規約が異なるため、変更時には注意してください。

ID キャラクター ID キャラクター
1 ずんだもん (あまあま) 51 †聖騎士 紅桜† (ノーマル)
2 四国めたん (ノーマル) 52 雀松朱司 (ノーマル)
3 ずんだもん (ノーマル) 53 麒ヶ島宗麟 (ノーマル)
4 四国めたん (セクシー) 54 春歌ナナ (ノーマル)
5 ずんだもん (セクシー) 55 猫使アル (ノーマル)
6 四国めたん (ツンツン) 56 猫使アル (おちつき)
7 ずんだもん (ツンツン) 57 猫使アル (うきうき)
8 春日部つむぎ (ノーマル) 58 猫使ビィ (ノーマル)
9 波音リツ (ノーマル) 59 猫使ビィ (おちつき)
10 雨晴はう (ノーマル) 60 猫使ビィ (人見知り)
11 玄野武宏 (ノーマル) 61 中国うさぎ (ノーマル)
12 白上虎太郎 (ふつう) 62 中国うさぎ (おどろき)
13 青山龍星 (ノーマル) 63 中国うさぎ (こわがり)
14 冥鳴ひまり (ノーマル) 64 中国うさぎ (へろへろ)
15 九州そら (あまあま) 65 波音リツ (クイーン)
16 九州そら (ノーマル) 66 もち子さん (セクシー/あん子)
17 九州そら (セクシー) 67 栗田まろん (ノーマル)
18 九州そら (ツンツン) 68 あいえるたん (ノーマル)
19 九州そら (ささやき) 69 満別花丸 (ノーマル)
20 もち子さん (ノーマル) 70 満別花丸 (元気)
21 剣崎雌雄 (ノーマル) 71 満別花丸 (ささやき)
22 ずんだもん (ささやき) 72 満別花丸 (ぶりっ子)
23 WhiteCUL (ノーマル) 73 満別花丸 (ボーイ)
24 WhiteCUL (たのしい) 74 琴詠ニア (ノーマル)
25 WhiteCUL (かなしい) 75 ずんだもん (ヘロヘロ)
26 WhiteCUL (びえーん) 76 ずんだもん (なみだめ)
27 後鬼 (人間ver.) 77 もち子さん (泣き)
28 後鬼 (ぬいぐるみver.) 78 もち子さん (怒り)
29 No.7 (ノーマル) 79 もち子さん (喜び)
30 No.7 (アナウンス) 80 もち子さん (のんびり)
31 No.7 (読み聞かせ) 81 青山龍星 (熱血)
32 白上虎太郎 (わーい) 82 青山龍星 (不機嫌)
33 白上虎太郎 (びくびく) 83 青山龍星 (喜び)
34 白上虎太郎 (おこ) 84 青山龍星 (しっとり)
35 白上虎太郎 (びえーん) 85 青山龍星 (かなしみ)
36 四国めたん (ささやき) 86 青山龍星 (囁き)
37 四国めたん (ヒソヒソ) 87 後鬼 (人間(怒り)ver.)
38 ずんだもん (ヒソヒソ) 88 後鬼 (鬼ver.)
39 玄野武宏 (喜び) 89 Voidoll (ノーマル)
40 玄野武宏 (ツンギレ) 90 ぞん子 (ノーマル)
41 玄野武宏 (悲しみ) 91 ぞん子 (低血圧)
42 ちび式じい (ノーマル) 92 ぞん子 (覚醒)
43 櫻歌ミコ (ノーマル) 93 ぞん子 (実況風)
44 櫻歌ミコ (第二形態) 94 中部つるぎ (ノーマル)
45 櫻歌ミコ (ロリ) 95 中部つるぎ (怒り)
46 小夜/SAYO (ノーマル) 96 中部つるぎ (ヒソヒソ)
47 ナースロボ_タイプT (ノーマル) 97 中部つるぎ (おどおど)
48 ナースロボ_タイプT (楽々) 98 中部つるぎ (絶望と敗北)
49 ナースロボ_タイプT (恐怖) 99 離途 (ノーマル)
50 ナースロボ_タイプT (内緒話) 100 黒沢冴白 (ノーマル)

上記はVoiceVoxEngine ver.0.24.0の内容ですが、アップデートによってキャラクター構成が変わる可能性があります。以下のコードでキャラクター一覧が取得できるので、最新版を利用する際はこちらも併せて参照してください。

SPEAKER_IDの一覧取得.py
import requests
ENGINE_URL = "http://localhost:50021"

speakersList = []
try:
    response = requests.get(f"{ENGINE_URL}/speakers")
    response.raise_for_status()
    speakers = response.json()

    for speaker in speakers:
        character_name = speaker.get("name", "名前不明")
        styles = speaker.get("styles", [])
        for style in styles:
            style_name = style.get("name", "スタイル不明")
            style_id = style.get("id", "ID不明")
            print(f"speaker={style_id}: {character_name} ({style_name})")
            speakersList.append({"id":style_id, "name":f"{character_name} ({style_name})"})

except Exception as e:
    print(f"エラーが発生しました: {e}")

4. セリフデータの整形

話者とセリフからなるデータをもとに、読み上げ用データを作成していきます。
今回は、以下のような話者とセリフがまとまったstoryplots配列が与えられた所から整形を開始します。

storyplotsの例
[{'subtitle': 'RS-ST-1 待合室',
  'background': 'sean1',
  'plots': [{'speaker': "", 'text': '1100年冬 イェラグ ペイルロッシュ家領内 カランド麓'},
   {'speaker': '精悍な兵士', 'text': '……'},
   {'speaker': 'リーダーの兵士', 'text': 'ペースを上げろ!'},
   {'speaker': 'リーダーの兵士', 'text': '我々がここへ来た理由を忘れるな!\u3000後ろの連中もしっかりついてこい!'},
   {'speaker': '精悍な兵士', 'text': 'はっ!'},
   {'speaker': 'リーダーの兵士', 'text': '止まれ!\u3000集合!'},
   {'speaker': 'リーダーの兵士', 'text': 'その場で整列!'},
   {'speaker': '精悍な兵士', 'text': '報告!\u3000第二小隊揃いました!'},
   {'speaker': 'リーダーの兵士', 'text': 'よろしい。'},
   {'speaker': 'リーダーの兵士', 'text': '第三小隊はまだか?'},
   {'speaker': '精悍な兵士', 'text': 'はっ、まだ到着しておりません!'},
   {'speaker': 'リーダーの兵士', 'text': '……'},
   {'speaker': '精悍な兵士', 'text': '恐れながら、第三小隊のほうで何かがあったのではないかと……'},
   {'speaker': '精悍な兵士', 'text': '到着を待ちますか?'},
   {'speaker': 'リーダーの兵士', 'text': 'もういい。こうなることも想定の内だ。先に到着した者から進攻を開始せよ!'},
   {'speaker': '精悍な兵士', 'text': 'はっ!'},
   {'speaker': 'リーダーの兵士', 'text': 'この先の作戦計画については知っての通りだ。私が詳細を語る必要はあるまい。'},
   {'speaker': 'リーダーの兵士', 'text': 'これは遠征であり、我らヴィクトリア人による栄誉ある戦いに他ならない!'},
   {'speaker': 'リーダーの兵士',
    'text': '常に警戒を怠るな!\u3000いかなる敵も侮るな!\u3000この一戦に……失敗は許されん!'},
   {'speaker': '精悍な兵士', 'text': 'はっ!'},
   {'speaker': 'リーダーの兵士', 'text': 'よし、そのまま士気を保て!'},
   {'speaker': 'リーダーの兵士', 'text': '第二小隊に告ぐ。引き続き進軍せよ!'},
   {'speaker': 'リーダーの兵士', 'text': '目標――麓の牧獣飼いの酒場!'},
   {'speaker': 'リーダーの兵士',
    'text': '酒量において、あの牧獣飼いどもに負けるわけにはいかん!\u3000今度こそ奴らを完膚なきまでに叩きのめすのだ!'},
   {'speaker': '精悍な兵士', 'text': 'はっ!!'},
   {'speaker': 'イェラグ人男性', 'text': '……'},
   {'speaker': 'イェラグ人男性', 'text': 'あのヴィクトリア人たち、今度は何をしてるんだ?'},
   {'speaker': 'イェラグ人女性',
    'text': '飲み比べでもしに行くんじゃない?\u3000聞いた話じゃ、ライリーおじさんたちと何度も勝負しては、毎回吐くまで飲んでるらしいわよ。'},
   {'speaker': 'イェラグ人女性', 'text': '我らのイェラガンドに……どうか彼らをお救いください。'},
   {'speaker': 'イェラグ人女性', 'text': 'ヴィクトリアの人って、普段からあんなに暇してるのかしら……?'}
   ]},
   {
   ・・・

いくつかの要素を持つ辞書配列を時系列順に並べたリストになっています。要素の詳細は以下のとおりです。

要素名 概要 備考
subtitlet 章番号や章タイトル 音声ファイルの区切りに使用
background シーン番号や背景画像 シーンの区切りに使用
plots セリフ一覧(時系列順)
-speaker セリフの話者 モノローグ/地の文は空文字列
-text セリフの内容

話者の名前については、一つのシーン内では一度のみ読み上げる仕様にします。
以下のコードで、VoiceVoxEngineで出力がしやすいように整形を行います。VoiceVoxは「…」などの表現に関してあまり間を空けてくれない傾向があり、間を開けるための処理を挟んでいます。

セリフデータの整形.py
# セリフデータの整形
storyplotsResult = []
for i, sp in enumerate(storyplots):
    plots = []
    voiceScript = []
    characters = []
    preSpeaker = ""
    for item in sp["plots"]:
        text = item["text"]
        speaker = item["speaker"]

        # voiceScriptの間を取るタイミングを「\n」記号に置換
        replaceSpaceSymbol = lambda text :re.sub("[……,|――]", "\n", text)

        # スピーカー情報の管理
        if speaker == "":
            plots.append({"speaker":None, "text":text.replace("\n", " ")})
        elif speaker != preSpeaker:
            plots.append({"speaker":speaker, "text":text.replace("\n", " ")})
        else:
            plots.append({"speaker":"", "text":text.replace("\n", " ")})
        if not speaker in characters:
            characters.append(speaker)
            voiceScript.append("\n" + speaker + "\n" + replaceSpaceSymbol(text))
        else:
            if speaker != preSpeaker:
                voiceScript.append("\n" + replaceSpaceSymbol(text))
            else:
                voiceScript.append(replaceSpaceSymbol(text))
        preSpeaker = speaker
    storyplotsResult.append({
        "subtitle": sp["subtitle"],
        "background": sp["background"],
        "plots": plots,
        "voiceScript": [x for row in voiceScript for x in row.split("\n")]
    })

整形後のデータは以下のようになります。セリフの間で長い間を空ける際は、リストに空白の文字列を追加することでセリフ間の間を定数倍に伸ばすことができます。

storyplotsResultの例
{'subtitle': 'RS-ST-1 待合室',
  'background': 'sean1',
  'plots': [〜〜省略〜〜],
  'voiceScript': ['',
   '',
   '1100年冬 イェラグ ペイルロッシュ家領内 カランド麓',
   '',
   '精悍な兵士',
   '',
   '',
   '',
   '',
   'リーダーの兵士',
   'ペースを上げろ!',
   '我々がここへ来た理由を忘れるな!\u3000後ろの連中もしっかりついてこい!',
   '',
   'はっ!',
   '',
   '止まれ!\u3000集合!',
   'その場で整列!',
   '',
   '報告!\u3000第二小隊揃いました!',
   '',
   'よろしい。',
   '第三小隊はまだか?',
   '',
   'はっ、まだ到着しておりません!',
   '',
   '',
   '',
   '',
   '',
   '恐れながら、第三小隊のほうで何かがあったのではないかと',
   '',
   '',
   '到着を待ちますか?',
   '',
   'もういい。こうなることも想定の内だ。先に到着した者から進攻を開始せよ!',
   '',
   'はっ!',
   '',
   'この先の作戦計画については知っての通りだ。私が詳細を語る必要はあるまい。',
   'これは遠征であり、我らヴィクトリア人による栄誉ある戦いに他ならない!',
   '常に警戒を怠るな!\u3000いかなる敵も侮るな!\u3000この一戦に',
   '',
   '失敗は許されん!',
   '',
   'はっ!',
   '',
   'よし、そのまま士気を保て!',
   '第二小隊に告ぐ。引き続き進軍せよ!',
   '目標',
   '',
   '麓の牧獣飼いの酒場!',
   '酒量において、あの牧獣飼いどもに負けるわけにはいかん!\u3000今度こそ奴らを完膚なきまでに叩きのめすのだ!',
   '',
   'はっ!!',
   '',
   'イェラグ人男性',
   '',
   '',
   '',
   'あのヴィクトリア人たち、今度は何をしてるんだ?',
   '',
   'イェラグ人女性',
   '飲み比べでもしに行くんじゃない?\u3000聞いた話じゃ、ライリーおじさんたちと何度も勝負しては、毎回吐くまで飲んでるらしいわよ。',
   '我らのイェラガンドに',
   '',
   'どうか彼らをお救いください。',
   'ヴィクトリアの人って、普段からあんなに暇してるのかしら',
   '',
   '?']},
   {
   ・・・

5. 複数のセリフを繋げて合成

複数のセリフを、章ごとに繋げて音声化します。
今回はセリフごとにVoiceVoxで合成を行ったのち、生成されたセリフ.wavを章などの区切りごとに繋げて音声化する手法を取りました。一章丸ごとVoiceVoxに突っ込むのも手段ですが、私の経験上VoiceVoxは一つのセリフが長くなりすぎるとイントネーションが崩れる感覚があり、一括合成は避けました。

storyplotsResultに対して、以下のコードを実行することで合成を行えます。

セリフデータの結合関数を定義.py
# セリフを結合する関数
from pydub import AudioSegment
voiceMargin = 450 # 各インデックスの後ろに指定msの間を追加する

def connect_wav(start_index, end_index, filename):
    combined_audio = AudioSegment.empty()
    previous_index = None

    for idx in range(start_index, end_index):
        filepath = f"temp/{idx}.wav"
        if os.path.exists(filepath):
            audio = AudioSegment.from_wav(filepath)

            # 間にあった空のセリフデータの分だけ間の時間を延長する
            if previous_index is not None:
                missing = idx - previous_index - 1
                gap_duration_ms = (missing + 1) * voiceMargin
                combined_audio += AudioSegment.silent(duration=gap_duration_ms)

            combined_audio += audio
            os.remove(filepath) # 結合済みのtempファイルを削除する
            # print(f"結合&削除: {filepath}")
            previous_index = idx

    # 合成した音声ファイルを出力
    if len(combined_audio) > 0:
        os.makedirs("output", exist_ok=True)
        output_path = f'output/{filename}.wav'
        combined_audio.export(output_path, format="wav")
        print(f"{output_path} を出力")
    else:
        print("有効な音声ファイルがありませんでした。")
音声合成の実行.py
# 音声出力メイン処理
wav_index = 0
pre_export_index = 0
pre_subtitle = storyplotsResult[0]["subtitle"]
subtitle_idx = 0

for i, item in enumerate(storyplotsResult):
    # サブタイトルの切り替わりで音声ファイルを区切る
    if pre_subtitle != item["subtitle"]:
        connect_wav(pre_export_index, wav_index, f'{subtitle_idx}_{pre_subtitle}')
        pre_export_index = wav_index
        pre_subtitle = item["subtitle"]
        subtitle_idx += 1
    
    for text in item["voiceScript"]:
        # 空でないセリフデータに対してのみ出力を実行する(空のセリフについては間を延長)
        make_voicefile(str(wav_index), text)
        wav_index += 1

if pre_export_index != wav_index:
    connect_wav(pre_export_index, wav_index, f'{subtitle_idx}_{pre_subtitle}')
    

参考文献

1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?