LLMを活用して話者・スタイルを自動選定するマルチスピーカーAI朗読

はじめに

こんにちは。NewITソリューション部です。

昨今大規模言語モデルに始まりさまざまなAIモデルやサービスが公開されていますが、その中でもGoogle NotebookLMの音声概要の品質の高さには驚いた方も多いかと思います。

かくいう筆者も影響を受け、既存の音声生成モデルで同等品質のものが出力出来ないか調査を行なっておりました。

Gemini APIの音声生成というNotebookLMの音声生成部分をサービス化したようなものもありますが、本記事ではHuggingFaceで見つけたParler-TTSを軽く試した結果をお見せしようと思います。

今回、大規模言語モデルとしてGPT-4.1を利用し、日本語読み上げ用に再学習されたParler-TTSモデル2121-8/japanese-parler-tts-mini、および朗読対象として青空文庫より小酒井 不木著、塵埃は語るのテキストデータをお借りしております。

青空文庫のテキストデータから本文を抽出する

はじめに、青空文庫のテキストデータにはルビや注釈が含まれるため、それらを除去し本文を抽出します。

コード
import re

def aozora_strip(text: str) -> tuple[str, dict[str, set[str]]]:
    """
    青空文庫のマークアップを除去する。
    
    Args:
        text (str): 青空文庫のマークアップを含む入力テキスト。
    
    Returns:
        tuple[str, dict[str, set[str]]]: 本文以外を除去したテキストと、ルビ情報辞書のタプル。
            ルビ情報辞書は、キーが漢字、値がその漢字に対応する読みの集合。
    """

    rubies: dict[str, set[str]] = {}

    # ヘッダーを削除
    text = text.split("-------------------------------------------------------")[-1]

    # フッターを削除
    text = text.split("底本:")[0]
    text = text.split("[#本文終わり]")[0]

    # アノテーションを削除する
    text = re.sub(r"[#.*?]", "", text, flags=re.DOTALL)

    # ルビを抽出・削除する
    def _sub_single(m: re.Match) -> str:
        base = m.group("base")
        if base.startswith("|"):
            base = base[1:]
        reading = m.group("reading")
        rubies.setdefault(base, set()).add(reading)
        return base
    
    text = re.sub(
        r"(?P<base>|[^《]+?|[\u4E00-\u9FFF]+)《(?P<reading>[^》]+?)》",
        _sub_single,
        text,
    )
    rubies = {k: list(v) for k, v in rubies.items()}

    text = text.strip()
    return text, rubies

こちらのコードに青空文庫のテキスト版データを投入することで、本文とルビ一覧が取得できます。

本文(一部抜粋):

 今年の夏は近年にない暑さが続きましたが、九月半ばになると、さすがに秋風が立ちはじめて、朝夕はうすら寒いくらいの気候となりました。わが少年科学探偵塚原俊夫君は、八月に胃腸を壊してからとかく健康がすぐれませんでしたが、秋になってからはすっかり回復して元気すこぶる旺盛、時々、私に向かって、
「兄さん、何かこうハラハラするような冒険はないかなあ。僕は近頃腕が鳴って仕様がない」
 と、皮肉 ....


ルビ(一部抜粋):

{'罹': ['かか'], '扉': ['ドア'], '昨日': ['きのう'], '豊': ['ゆたか'], '常子': ['つねこ'], ...}

以降、ここで抽出した本文を利用します。

登場人物一覧作成

次に以下システムプロンプトと小説本文をGPT-4.1に渡し、XML形式の登場人物の一覧を作成します。

ユーザーから共有される小説の内容を理解し、制約事項に従い登場人物一覧の整理をしてください。

### 制約
- Markdownのコードブロックは出力しないでください
- キャラクターIDは0から開始して下さい
- 語り部も含めて整理してください

### 出力形式
```xml
<characters>
<character id="{{キャラクターID}}">
<name>{{名前}}</name>
<gender>{{性別}}</gender>
<age>{{年齢}}</age>
<personality>{{性格}}</personality>
</character>
</characters>
```

出力例は以下。

<characters>
<character id="0">
<name>語り部(兄さん)</name>
<gender>男性</gender>
<age>不明(成人)</age>
<personality>冷静で思慮深く、俊夫君の良き理解者。事件に対して慎重に対応する。</personality>
</character>
<character id="1">
<name>塚原俊夫</name>
<gender>男性</gender>
<age>少年(具体的な年齢不明)</age>
<personality>科学探偵。聡明で好奇心旺盛、勇敢で機転が利く。冒険好き。</personality>
</character>
<character id="2">
<name>富田重雄</name>
<gender>男性</gender>
<age>40歳前後</age>
<personality>銀行の重役。誠実で家族思い、冷静だが事件には動揺する。</personality>
</character>
 :
</characters>

想定通りの抽出が出来ていますね。

Parler-TTS

ここからはParler-TTSでの音声合成に欲しい情報をLLMに準備して貰います。

登場人物の発話スタイル生成

前述の各登場人物の性格から、発話のスタイルを検討して貰いましょう。

以下はシステムプロンプトです。

character_list_xmlの部分は前述登場人物一覧XMLを反映します)

登場人物一覧を元に、各登場人物の性格や話し方の特徴を分析し、発話スタイルを定義してください。

### 制約
- Markdownブロックは出力しないでください
- 発話スタイルは以下の例を参考に必ず英語で記述してください
- 例1) `A man voice is monotone yet slightly fast in delivery, with a very close recording that almost has no background noise.`
- 例2) `A female speaker delivers a slightly expressive and animated speech with a moderate speed and pitch. The recording is of very high quality, with the speaker's voice sounding clear and very close up.`
- 発話スタイルは、イメージをしやすいよう可能な限り詳細に記述してください

### 登場人物一覧
```xml
{character_list_xml}
```

### 出力形式
```xml
<speech_styles>
<speech_style character_id="{{キャラクターID}}">{{発話スタイル}}</speech_style>
</speech_styles>
```

ただ出力しただけだと少し扱いづらいため、前述のシステムプロンプトで取得した発話スタイルを登場人物一覧のXMLの中に追加してしまいましょう。

コード
import xml.etree.ElementTree as ET

def update_character_list_with_speech_style(character_list_xml: str,
                                            speech_style_xml: str) -> str:
    """
    登場人物一覧XMLに発話スタイルを追加する。

    Args:
        character_list_xml (str): 登場人物一覧のXML文字列。
        speech_style_xml (str): 発話スタイルのXML文字列。
    
    Returns:
        str: 発話スタイルが追加された登場人物一覧XML。
    """

    characters_root = ET.fromstring(character_list_xml)
    speech_styles_root = ET.fromstring(speech_style_xml)
    speech_style_dict = {
        style.get("character_id"): style.text
        for style in speech_styles_root.findall("speech_style")
    }

    for character in characters_root.findall("character"):
        char_id = character.get("id")
        if char_id in speech_style_dict:
            speech_style_elem = ET.Element("speech_style")
            speech_style_elem.text = speech_style_dict[char_id]
            character.append(speech_style_elem)
    
    character_list_with_speech_style_xml = ET.tostring(
        characters_root, encoding="utf-8"
    ).decode("utf-8")
    return character_list_with_speech_style_xml

Pythonの標準パッケージを用いてXMLファイルを解析し、登場人物の子要素に発話スタイルを組み込んでいます。

以下は最終出力の一部抜粋です。

<characters>
<character id="0">
<name>語り部(兄さん)</name>
<gender>男性</gender>
<age>不明(俊夫君より年上の青年~大人)</age>
<personality>冷静で思慮深い。俊夫君の保護者的存在で、事件に対して慎重に対応する。</personality>
<speech_style>A calm and thoughtful male voice, moderately deep and steady, with a measured pace and clear articulation. The delivery is gentle and reassuring, with a subtle undertone of authority and maturity. The recording is clean, with a slight ambient room presence, evoking a sense of quiet contemplation.</speech_style></character>
<character id="1">
<name>塚原俊夫</name>
<gender>男性</gender>
<age>少年(推定10~15歳)</age>
<personality>科学探偵。好奇心旺盛で冒険好き、機転が利き、冷静かつ大胆な行動力を持つ。</personality>
<speech_style>A youthful male voice, energetic and curious, with a lively and slightly quick tempo. The speech is articulate, occasionally punctuated by bursts of excitement or wonder. The recording is crisp, with a close-up feel and a hint of outdoor ambiance, reflecting his adventurous spirit.</speech_style></character>
  :
</characters>

登場人物の年齢や性格に合わせた発話スタイルが生成されています。

文章に発話した登場人物を割り当てる

これで登場人物とその発話のスタイルが定義できました。

次は実際の文章と照らし合わせて、各文章を誰が発話したのか推定してもらいましょう。

コード
from openai import AzureOpenAI

def extract_sentence_list(aoai_client: AzureOpenAI,
                          character_list_with_speech_style_xml: str,
                          text: str,
                          model: str = "gpt-4.1") -> str:
    """
    小説本文から登場人物の発話・内心・叙述を分割し、整理する。
    
    Args:
        aoai_client (AzureOpenAI): Azure OpenAI クライアント。
        character_list_with_speech_style_xml (str): 発話スタイルを含む登場人物一覧のXML文字列。
        text (str): 小説本文。
        model (str, optional): 使用するモデル。デフォルトは "gpt-4.1"。

    Returns:
        str: 整理された文章一覧のXML文字列。
    """

    system_prompt = """
    ユーザーから共有される小説の内容を理解し、以下制約に従って各文章の発話者を登場人物一覧から割り当てて下さい。

    ### 制約
    - Markdownブロックは出力しないでください
    - 一度に出力可能な文字数は2万文字以下として下さい
      - 文字数上限が近づいた際は直近のXMLタグを閉じ、`<!-- CONTINUE -->`を出力して下さい
      - 続きを要求された際は、前回の出力に文字列結合を行なった際に問題無いよう出力して下さい
      - 分割する場合の出力例は以下を参照して下さい
        - 出力1(途中で途切れる場合)
          ```xml
          <parent>
            <child>...</child>
            <!-- CONTINUE -->
          ```
        - 出力2(続き、parent開始タグは出力しない)
          ```xml
            <child>...</child>
          </parent>
          ```
    - ユーザーから共有される文章全体に対して作業をしてください
    - 作業する文に漏れが無いよう注意してください
    - 文頭から句点、疑問符、感嘆符までを一文として区切って下さい

    ### ナレーター(語り手)の扱い
    - 叙述文(誰かの行動・外見・状況を説明する文)は必ずナレーターの文としてください
    - 登場人物の名前が含まれていても、それが会話(「〜」)や内心描写でなければ必ずナレーターの叙述文としてください
    - 「〜の顔は〜した」「〜は〜のように見えた」などの外見や行動の描写は内心ではなく叙述文として扱い、ナレーターに割り当ててください
    - ナレーターは明確に視点が変わる場合を除いて一人で行ってください
    - 語り手(ナレーター)は一人称でも三人称でも構いません。小説本文に従ってください

    ### 発話と内心
    - 発話文(「」で囲まれているもの)はその発話者本人の文としてください
    - 「〜と心の中で思った」「〜と感じた」など内心の描写は、その人物のものとして扱ってください
    - 登場人物のキャラクターIDを割り当てて良いのは「セリフ」または「内心」のみです

    ### 発話+叙述の分割
    - 「〜」と発話の後に「〜と言いました/答えました/尋ねました」などの叙述が続く場合は、必ず分割してください
    - 発話部分(「〜」)は発話者のキャラクターID
    - 叙述部分はナレーターのキャラクターID
    - 一文に発話と叙述が混在している場合は、必ず2つ以上の`<sentence>`要素に分けて出力してください

    ### 出力前チェック
    - 出力直前に以下を確認してください:
      - セリフのみ → 発話者のキャラクターID
      - 内心描写 → 当人のキャラクターID
      - それ以外 → ナレーターのキャラクターID

    ### 出力形式
    ```xml
    <sentences>
      <sentence character_id="{{キャラクターID}}">{{一文}}</sentence>
    </sentences>
    ```

    ### 登場人物一覧
    ```xml
    {character_list_with_speech_style_xml}
    ```
    """.format(character_list_with_speech_style_xml=character_list_with_speech_style_xml.strip())

    messages: list[dict[str, str]] = [
        { "role": "system", "content": system_prompt },
        { "role": "user", "content": text }
    ]
    sentence_list_xml: str = ""
    while True:
        response = aoai_client.chat.completions.create(
            model = model,
            messages = messages,
            max_tokens = 32768,
            temperature = 0.0
        )

        finish_reason = response.choices[0].finish_reason
        print(".Finish reason:", finish_reason)
        if finish_reason == "content_filter":
            print("..Filter status:", response.choices[0].content_filter_results)

        partial_content = response.choices[0].message.content.strip()
        sentence_list_xml += partial_content
        
        if "<!-- CONTINUE -->" not in partial_content:
            # 出力が完了している場合はループを終了
            break

        print("Output truncated, requesting continuation...")
        messages.append({ "role": "assistant", "content": partial_content })
        messages.append({ "role": "user", "content": "続きを出力してください。" })

    return sentence_list_xml

以下プロンプトおよびコードのポイントです。

  • 登場人物名が語り手の文中に出てくる場合、語り手を選ぶように指示
  • 語り手が文中で登場人物のセリフを発話する場合、文全体をその登場人物のセリフとして認識しないように指示
  • 一定文字数を超えて出力した場合、最後のタグを閉じて続きがあることを出力する様に指示
余談①

これらポイントについては、発生していた問題に対しGPT-5を用いてアドバイスや改善案を出して貰ったものです。
GPTを用いてプロンプトを改善する場合はシステムプロンプトと問題の箇所を渡し、どうしてそうなるかを聞き、どうして欲しいかを伝えています。

余談②

この処理においては推理小説の内容をそのまま出力している為、高確率でコンテンツフィルターにブロックされました。その為、ブロックする基準を低下させたフィルターをGPT-4.1モデルに適用し、実行しています。

こちらのコードを実行した結果の抜粋は以下。

<sentences>
<sentence character_id="0">今年の夏は近年にない暑さが続きましたが、九月半ばになると、さすがに秋風が立ちはじめて、朝夕はうすら寒いくらいの気候となりました。</sentence>
<sentence character_id="0">わが少年科学探偵塚原俊夫君は、八月に胃腸を壊してからとかく健康がすぐれませんでしたが、秋になってからはすっかり回復して元気すこぶる旺盛、時々、私に向かって、</sentence>
<sentence character_id="1">「兄さん、何かこうハラハラするような冒険はないかなあ。僕は近頃腕が鳴って仕様がない」</sentence>
 :
<sentence character_id="0">すると、意外にも自動車は、俊夫君が乗るなり、すぐ駆けだそうとしましたので、私は大声をあげて、「まだまだ」と叫んで追いかけようとしました。</sentence>
<sentence character_id="0">と、そのとき遅く私は後頭部にはげしい一撃を受けて、そのまま気絶してしまいました。</sentence>
<!-- CONTINUE --><sentence character_id="0">それからいく分間、あるいはいく時間人事不省に陥っていたのか、もとより私は存じません。</sentence>
<sentence character_id="0">私たちの街は人通りが少ないのと、街灯の数が少ないために暗いのとで、たとい私が街上に横たわっていても、躓きでもしないかぎりは、通行人が発見するに困難だったろうと思います。</sentence>
 :
</sentences>

上手く話者の割り当てが出来ている様です。

また、長文だったため指示通り<!-- CONTINUE -->を出力して一度中断しています。継続後の改行やインデントが少し怪しいですが、正常に読めるXMLファイルが出力されました。

音声合成

ここまでで文章に発話した登場人物の情報を割り当てることが出来ました。

最後に文章毎に音声合成を実行して個別WAVファイルとして出力した後、1つのOggファイルに結合します。

(事前に2121-8/japanese-parler-tts-miniを参考にParler-TTSの準備を行なって下さい)

コード
import os
import xml.etree.ElementTree as ET
import numpy as np
import soundfile as sf
import torch
from numpy.typing import NDArray
from parler_tts import ParlerTTSForConditionalGeneration
from rubyinserter import add_ruby
from transformers import AutoTokenizer

def synthesize_speech(character_list_with_speech_style_xml: str,
                      sentence_list_xml: str,
                      parler_tts_model: str = "2121-8/japanese-parler-tts-mini") -> list[str]:
    """
    ParlerTTSを使用して、文章一覧から音声を合成する。
    
    Args:
        character_list_with_speech_style_xml (str): 発話スタイルを含む登場人物一覧のXML文字列。
        sentence_list_xml (str): 文章一覧のXML
        parler_tts_model (str, optional): 使用するParlerTTSモデル。
            デフォルトは "2121-8/japanese-parler-tts-mini"。
    
    Returns:
        list[str]: 合成された音声ファイルのパスのリスト。
    """
    os.makedirs("tmp", exist_ok=True)

    # ParlerTTSモデルを読み込む
    device: str = "cuda:0" if torch.cuda.is_available() else "cpu"
    model = ParlerTTSForConditionalGeneration.from_pretrained(parler_tts_model).to(device)
    prompt_tokenizer = AutoTokenizer.from_pretrained(parler_tts_model, subfolder="prompt_tokenizer")
    description_tokenizer = AutoTokenizer.from_pretrained(
        parler_tts_model, subfolder="description_tokenizer")

    character_list_root = ET.fromstring(character_list_with_speech_style_xml)
    sentence_list_root = ET.fromstring(sentence_list_xml)

    wav_files = []
    for i, sentence in enumerate(sentence_list_root.findall("sentence")):
        # 一文とその話者情報をXMLから取得する
        print(f"Generating audio for index={i}")
        character_id: str = sentence.attrib["character_id"]
        print(f". CharacterId: {character_id}")

        speech_style = character_list_root.findall(f"character[@id='{character_id}']/speech_style")[0]

        prompt: str = sentence.text
        print(f". Prompt: {prompt}")
        description: str = speech_style.text
        print(f". Description: {description}")

        # 音声合成を実行
        prompt = add_ruby(prompt)
        input_ids = description_tokenizer(description, return_tensors="pt").input_ids.to(device)
        prompt_input_ids = prompt_tokenizer(prompt, return_tensors="pt").input_ids.to(device)

        generation = model.generate(input_ids=input_ids, prompt_input_ids=prompt_input_ids)
        audio_arr = generation.cpu().numpy().squeeze()

        # 生成されたPCMデータをwavファイルとして保存する
        wav_file: str = f"tmp/{i:05d}.wav"
        sf.write(wav_file, audio_arr, model.config.sampling_rate)

        wav_files.append(wav_file)
  
    return wav_files

def merge_speech_wav(wav_files: list[str], output_file: str = "output.ogg") -> None:
    """
    複数のWAVファイルを結合し、OGGファイルとして保存する。
    Args:
        wav_files (list[str]): 結合するWAVファイルのパスのリスト。
        output_file (str, optional): 出力するOGGファイルのパス。デフォルトは "output.ogg"。
    
    Returns:
        None
    """
    assert len(wav_files) > 0, "No wav files found"

    # 適当なファイルからサンプリングレートおよびチャンネル数を取得する
    with sf.SoundFile(wav_files[0], "r") as f0:
        samplerate = f0.samplerate
        channels = f0.channels
    
    # 無音データを作成する
    silence_frames: int = int(round(0.5 * samplerate))
    silence: NDArray = np.zeros((silence_frames, channels), dtype=np.float32)

    with sf.SoundFile(output_file,
                      mode="w",
                      samplerate=samplerate,
                      channels=channels,
                      format="OGG",
                      subtype="VORBIS") as out_f:
        for i, path in enumerate(wav_files):
            with sf.SoundFile(path, "r") as in_f:
                assert in_f.samplerate == samplerate
                assert in_f.channels == channels

                while True:
                    block = in_f.read(frames=8192, dtype="float32", always_2d=True)
                    if len(block) == 0:
                        break
                    out_f.write(block)

            # 各発話間には無音時間を挿入する
            if i != len(wav_files) - 1 and silence_frames > 0:
                out_f.write(silence)

    return

結果

以下はここまでの処理を実行し、出力されたファイルです。

かなり自然な日本語で読み上げてくれていますね。

ただし以下の様にいくつか課題もあります。

  • ところどころ日本語が怪しくなる
    • 3:08付近等、複数箇所で見られる
  • 登場人物毎に声の違いがほぼ無い
    • 全く無いわけではなく、後述2つのようにスタイルを指示し比較すると差は感じられる
    • 英語モデルではどのくらい読み分け可能か少し試したが、最後まで発話されない等問題があった為、割愛
  • 生成に時間が掛かる
    • 31分程度の音声データの生成に、Google Colab (T4)で56分、Apple M3で97分必要
スタイル①
female speaker with a slightly high-pitched voice delivers her words at a moderate speed with a quite monotone tone in a confined environment, resulting in a quite clear audio recording.
スタイル②
A male speaker with a slightly low-pitched voice delivers his words at a moderate speed with a quite monotone tone in a confined environment, resulting in a quite clear audio recording.

現時点では業務利用が出来るとは言えないものの、今後の音声合成モデルの登場が楽しみになるようなモデルでした。

VOICEVOX

ここまでParler-TTSを用いて読み分けを試みましたが、せっかくなのでVOICEVOXでも試してみましょう。

今回はVOICEVOX / voicevox_coreを利用します。環境に合わせて、voicevox_coreのダウンロードおよびpythonパッケージの準備をして下さい。

まずはキャラクターの情報を整理します。

<voices>

<voice id="1">
<name>ずんだもん</name>
<gender>女</gender>
<age>不明</age>
<personality>ずんだ餅の精。やや不幸属性が備わっており、ないがしろにされることもしばしば。ずんだ餅にかかわることはだいたい好き。将来の夢はずんだ餅のさらなる普及。</personality>
<voice_quality>子供っぽい高めの声</voice_quality>
<styles>
<style id="3" path="0.vvm" name="ノーマル" />
<style id="1" path="0.vvm" name="あまあま" />
<style id="7" path="0.vvm" name="ツンツン" />
<style id="5" path="0.vvm" name="セクシー" />
<style id="22" path="5.vvm" name="ささやき" />
<style id="38" path="5.vvm" name="ヒソヒソ" />
<style id="75" path="15.vvm" name="ヘロヘロ" />
<style id="76" path="15.vvm" name="なみだめ" />
</styles>
</voice>

</voices>

名前、性別、年齢、性格、声の特徴は公式サイトから、声のスタイルはvoicevox_coreの各vvmファイルから以下コードで取得しました。

コード
import glob
import os
from voicevox_core.blocking import VoiceModelFile

for path in glob.glob("voicevox_core/models/vvms/*.vvm"):
    vvm_name = os.path.split(path)[-1]
    with VoiceModelFile.open(path) as model:
        for char in model.metas:
            for style in char.styles:
                print(f"{char.name},{style.name},{vvm_name},{style.id}")

以降、大まかな流れは前述のParler-TTS版と同じであるため、異なる箇所のみ簡単に解説します。

登場人物の発話スタイルを決める

Parler-TTSは自由に記述しましたが、VOICEVOXではキャラクターの特徴が近しいものを選択する様に指示しています。


### 制約
- Markdownブロックは出力しないでください
- 登場人物の性格のイメージに近しい発話スタイルを選択してください
- ただし、複数の登場人物でボイスが重複することは可能な限り避けて下さい

### 出力形式
```xml
<speech_styles>
<speech_style character_id="{{キャラクターID}}" voice_id="{{ボイスID}}" />
</speech_styles>
```

文章にVOICEVOXのスタイルを割り当てる

Parler-TTSでは文章に発話した登場人物のみを割り当てていましたが、VOICEVOXでは感情に合わせて適切なVOICEVOXのスタイルも選択するよう指示しています。


### 発話スタイルの割り当て
- 発話内容から人物の心境を検討し、発話キャラクターが所持する適切なスタイルを割り当てて下さい

### 出力形式
```xml
<sentences>
<sentence character_id="{{キャラクターID}}" style_id="{{発話スタイルID}}">{{一文}}</sentence>
</sentences>
```

音声合成

個別にWAVファイルを生成し最後に結合する点はParler-TTSと同じですが、VVMモデルの読み込み回数を最小限にするため、最初にVVMファイル単位で整理を行っています。

コード
import os
import xml.etree.ElementTree as ET
from voicevox_core.blocking import Onnxruntime, OpenJtalk, Synthesizer, VoiceModelFile

def synthesize_speech(sentence_list_xml: str,
                      voice_list_xml: str) -> list[str]:
    """
    VOICEVOXを使用して、文章一覧XMLから音声を合成する。
    
    Args:
        sentence_list_xml (str): 文章一覧のXML文字列。
        voice_list_xml (str): ボイス一覧のXML文字列。
    
    Returns:
        list[str]: 合成されたWAVファイルのパスのリスト。
    """

    os.makedirs("tmp", exist_ok=True)

    sentence_list_root = ET.fromstring(sentence_list_xml)
    voice_list_root = ET.fromstring(voice_list_xml)

    # VVMファイル単位で整理する
    vvm_dict = {}
    for i, sentence in enumerate(sentence_list_root.findall("sentence")):
        style_id = sentence.get("style_id")
        style = voice_list_root.find(f".//style[@id='{style_id}']")
        vvm = style.get("path")

        vvm_dict.setdefault(vvm, []) \
            .append((i, int(style_id), sentence.text))

    # VOICEVOX音声合成機能を初期化
    voicevox_onnxruntime_path = "voicevox_core/onnxruntime/lib/" + Onnxruntime.LIB_VERSIONED_FILENAME
    open_jtalk_dict_dir = "voicevox_core/dict/open_jtalk_dic_utf_8-1.11"
    synthesizer = Synthesizer(Onnxruntime.load_once(filename=voicevox_onnxruntime_path),
                              OpenJtalk(open_jtalk_dict_dir))

    wav_files = []
    for vvm, sentences in vvm_dict.items():
        # VVMファイルを読み込み
        print(f"Generating audio for vvm={vvm}")
        with VoiceModelFile.open(f"voicevox_core/models/vvms/{vvm}") as model:
            synthesizer.load_voice_model(model)
        
        # 各文章の音声を生成して保存
        for (index, style_id, text) in sentences:
            print(f"  Generating audio for index={index}, style_id={style_id}, text={text}")

            wav_file = f"tmp/{index:05d}.wav"
            with open(wav_file, "wb") as f:
                f.write(synthesizer.tts(text, style_id))
            
            wav_files.append(wav_file)
    
    wav_files.sort()
    return wav_files

結果

これら処理を実行し、出力されたファイルです。

こちらの音声ファイルは、以下音声ライブラリを用いて生成されています。

  • VOICEVOX:剣崎雌雄(語り部)
  • VOICEVOX:白上虎太郎(塚原俊夫)
  • VOICEVOX:雀松朱司(富田重雄)
  • VOICEVOX:櫻歌ミコ(富田豊)
  • VOICEVOX:玄野武宏(小田刑事)
  • VOICEVOX:麒ヶ島宗麟(拳骨団の男)
  • VOICEVOX:離途(小田刑事の部下)

Parler-TTSほどではないものの自然な日本語で読み上げてくれており、また完璧では無いものの、登場人物毎に正しく声が読み分けられています。

生成もかなり速く、逐次処理のままApple M3で31分の音声生成が7分程度で完了しています。

さいごに

AIを組み合わせることによって、朗読の自動生成のみならず様々な事が実現可能な時代となりました。

VibeVoiceVoxCPMのような新しいモデルも次々と公開されており、目まぐるしくも楽しい時期がまだまだ続きそうです。

我々NewIT部ではAIを活用した業務システムの開発をお受けしておりますので、ご興味があればお気軽にお問合せください。

いいね (←参考になった場合はハートマークを押して評価お願いします)
読み込み中...

注意事項・免責事項

※技術情報につきましては投稿日時点の情報となります。投稿日以降に仕様等が変更されていることがありますのでご了承ください。

※公式な技術情報の紹介の他、当社による検証結果および経験に基づく独自の見解が含まれている場合がございます。

※これらの技術情報によって被ったいかなる損害についても、当社は一切責任を負わないものといたします。十分な確認・検証の上、ご活用お願いたします。

※当サイトはマイクロソフト社によるサポートページではございません。パーソルクロステクノロジー株式会社が運営しているサイトのため、マイクロソフト社によるサポートを希望される方は適切な問い合わせ先にご確認ください。
 【重要】マイクロソフト社のサポートをお求めの方は、問い合わせ窓口をご確認ください