見出し画像

ローカルLLM OllamaとVOICEVOXを使って、ずんだもんと四国めたんの会話劇の音源を作成する

前回からの続きです

前回、OllamaとVOICEVOXを使って、ローカルLLMで出力した文章をずんだもんに喋ってもらうコードを作成しました。今日はその応用で、ずんだもんと四国めたんに会話劇を繰り広げてもらいその音源を作成するプログラムをつくってみましょう。
できたサンプルがこちら


環境準備

  • Ollama

  • LLMモデル phi4

  • VOICEVOX

  • PCメモリ16GB程度のマシン

VOICEVOXの話者一覧の取得

今回は四国めたんも使うので該当のspeakerIDを取得するためにコードを一回つくっておきます(VOICEVOXのサーバーを起動させておく)

import requests
import json
import os

VOICEVOX_HOST = os.environ.get("VOICEVOX_HOST", "localhost")
VOICEVOX_PORT = 50021  # ポートは固定

def get_voicevox_speakers(base_url=f"http://{VOICEVOX_HOST}:50021"):
    """VoiceVoxの話者一覧を取得する"""
    try:
        response = requests.get(f"{base_url}/speakers")
        response.raise_for_status()
        speakers = response.json()
        return speakers
    except requests.exceptions.RequestException as e:
        print(f"Error: {e}")
        return None

def main():
    speakers = get_voicevox_speakers()
    if speakers:
        print(json.dumps(speakers, indent=2, ensure_ascii=False))
    else:
        print("話者一覧の取得に失敗しました。")

if __name__ == "__main__":
    main()

出力結果がこちら

[
{
"name": "四国めたん",
"speaker_uuid": "7ffcb7ce-00ec-4bdc-82cd-45a8889e43ff",
"styles": [
{
"name": "ノーマル",
"id": 2,
"type": "talk"
},
{
"name": "あまあま",
"id": 0,
"type": "talk"
},
{
"name": "ツンツン",
"id": 6,
"type": "talk"
},
{
"name": "セクシー",
"id": 4,
"type": "talk"
},
{
"name": "ささやき",
"id": 36,
"type": "talk"
},
{
"name": "ヒソヒソ",
"id": 37,
"type": "talk"
}
],
"version": "0.15.7",
"supported_features": {
"permitted_synthesis_morphing": "SELF_ONLY"
}
},
{
"name": "ずんだもん",
"speaker_uuid": "388f246b-8c41-4ac1-8e2d-5d79f3ff56d9",
"styles": [
{
"name": "ノーマル",
"id": 3,
"type": "talk"
},

ずんだもんはID3、四国めたんのIDは2と判明しました。
では2名の会話劇を作成してみましょう

実装コード1

import os
import ollama         # ollama の公式ライブラリ
import requests
import json
import tempfile
import simpleaudio as sa
import re

# VOICEVOX サーバーのホストを環境変数から取得(なければ "localhost")
VOICEVOX_HOST = os.environ.get("VOICEVOX_HOST", "localhost")
VOICEVOX_PORT = 50021  # ポートは固定

# ---------------------------------------------
# 1. LLMで会話シナリオを生成する関数
# ---------------------------------------------
def generate_scenario(prompt):
    """
    指定されたテーマに沿って、ずんだもんと四国めたんが議論するシナリオを生成する。
    
    以下のキャラクター口調の特徴を必ず反映すること:
    
    【ずんだもんの口調の特徴】
    ・ずんだもんの口調は、語尾に「なのだー」をつけるのが特徴です。
    ・また、柔らかく優しい口調で話します。
    
    【四国めたんの口調の特徴】
    ・四国めたんは、誰にでもタメ口で話します。
    ・「〜かしら」や「〜わよ」のような高飛車な口調で話すのが特徴です。
    
    ※ 出力形式は必ず以下の形式に従うこと:
         ずんだもん: 「発言内容」
         四国めたん: 「発言内容」
    各発話は1行で出力してください。
    """
    messages = [
        {
            "role": "system",
            "content": (
                "あなたは会話シナリオ生成エンジンです。以下のキャラクター口調の特徴に基づいて、"
                "指定されたテーマに沿ったずんだもんと四国めたんの議論シナリオを生成してください。\n\n"
                "【ずんだもんの口調の特徴】\n"
                "・ずんだもんの口調は、語尾に「なのだ」や「のだ」をつけるのが特徴です。\n"
                "・また、柔らかく優しい口調で話します。\n\n"
                "【四国めたんの口調の特徴】\n"
                "・四国めたんは、誰にでもタメ口で話します。\n"
                "・「〜かしら」や「〜わよ」のような高飛車な口調で話すのが特徴です。\n\n"
                "※ 出力形式は必ず以下の形式に従うこと:\n"
                "ずんだもん:「発言内容」\n"
                "四国めたん:「発言内容」\n"
                "各発話は1行で出力してください。発言内容には「」をつけること\n"
            )
        },
        {"role": "user", "content": prompt}
    ]
    
    # モデルとして phi4 を指定して問い合わせ
    result = ollama.chat(model="phi4", messages=messages)
    
    try:
        # 生成結果は result['message']['content'] から取得する
        scenario = result["message"]["content"]
    except KeyError:
        scenario = "シナリオの生成に失敗しました。"
    
    return scenario

# ---------------------------------------------
# 2. シナリオテキストを解析して発話ごとに分割する関数
# ---------------------------------------------
def parse_scenario(scenario):
    """
    シナリオテキストを行ごとに分割し、
    各行から発話者と発話内容を抽出する。

    例)
      入力行: ずんだもん: 「オーバーツーリズムって、〜」
      出力: ("ずんだもん", "オーバーツーリズムって、〜")
    """
    conversation = []
    # 正規表現パターン(行の先頭が「ずんだもん」または「四国めたん」)
    pattern = re.compile(r'^(ずんだもん|四国めたん)\s*[::]\s*「([^」]+)」')
    
    lines = scenario.strip().splitlines()
    for line in lines:
        line = line.strip()
        match = pattern.match(line)
        if match:
            speaker = match.group(1).strip()
            text = match.group(2).strip()
            conversation.append((speaker, text))
    return conversation

# ---------------------------------------------
# 3. VOICEVOX で音声合成を行う関数
# ---------------------------------------------
def get_voicevox_audio(text, speaker):
    """
    text: 読み上げるテキスト
    speaker: VOICEVOX の speaker 番号(ずんだもん→3, 四国めたん→2)
    """
    # (1) audio_query エンドポイントで合成用パラメータを取得
    audio_query_url = f"http://{VOICEVOX_HOST}:{VOICEVOX_PORT}/audio_query"
    params = {"text": text, "speaker": speaker}
    r = requests.post(audio_query_url, params=params)
    if r.status_code != 200:
        print("audio_query に失敗しました:", r.text)
        return None
    query = r.json()
    
    # (2) synthesis エンドポイントで音声合成(WAVデータ取得)
    synthesis_url = f"http://{VOICEVOX_HOST}:{VOICEVOX_PORT}/synthesis"
    headers = {"Content-Type": "application/json"}
    r2 = requests.post(synthesis_url, params={"speaker": speaker}, data=json.dumps(query), headers=headers)
    if r2.status_code != 200:
        print("synthesis に失敗しました:", r2.text)
        return None
    return r2.content  # WAV バイナリデータ

# ---------------------------------------------
# 4. 合成した音声(WAV)を再生する関数
# ---------------------------------------------
def play_audio(audio_data):
    """
    音声データ(WAVバイナリ)を一時ファイルに書き出し、再生する。
    """
    with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as f:
        f.write(audio_data)
        filename = f.name
    try:
        wave_obj = sa.WaveObject.from_wave_file(filename)
        play_obj = wave_obj.play()
        play_obj.wait_done()
    except Exception as e:
        print("音声再生に失敗しました:", e)
    finally:
        os.remove(filename)

# ---------------------------------------------
# 5. メイン処理
# ---------------------------------------------
def main():
    prompt = input("シナリオ生成用プロンプトを入力してください: ")
    
    # (1) LLM でシナリオ生成
    scenario = generate_scenario(prompt)
    print("\n===== 生成されたシナリオ =====")
    print(scenario)
    print("=============================\n")
    
    # (2) シナリオを解析して発話ごとに分割
    conversation = parse_scenario(scenario)
    if not conversation:
        print("シナリオの解析に失敗しました。パースできる発話が見つかりませんでした。")
        return
    
    # (3) 各発話を VOICEVOX で音声合成し、順次再生する
    for speaker, text in conversation:
        print(f"{speaker}: {text}")
        # speaker 名により VOICEVOX の speaker ID を選択
        if speaker == "ずんだもん":
            voicevox_speaker = 3
        elif speaker == "四国めたん":
            voicevox_speaker = 2
        else:
            voicevox_speaker = 3  # 万が一のデフォルト
        
        audio = get_voicevox_audio(text, speaker=voicevox_speaker)
        if audio:
            play_audio(audio)
        else:
            print(f"{speaker} の音声合成に失敗しました。")
            
if __name__ == '__main__':
    main()

ずんだもん、四国めたんのキャラクター設定や語尾再現度は完璧ではありませんが、一応ないよりはマシ程度にプロンプトに付け加えています。

コードのロジックとしてはLLMの出力が

===== 生成されたシナリオ =====
ずんだもん:「マスクさんがOpenAIを買収するって話なのだけど、ちょっと意外じゃない?昔から対立してたのにね」

四国めたん:「そうよね。でも考えてみれば、マスクさんがOpenAIを改革しようって言ってるわけだし、彼ならやり遂げられそう かもしれないわよ」

こういった形式を期待しているので、たまにこの形式に出力が従わない場合はエラーで終了します。

こんな形で実行します

python3 kaiwa2.py
シナリオ生成用プロンプトを入力してください: 日本だけが公営競技が多い理由について考察する2名の会話を生成して

===== 生成されたシナリオ =====
ずんだもん: 「なぜ日本だけが公営競技が多いのか、考えてみたら面白いと思うのだ。」

四国めたん: 「確かにね。私は日本人のギャンブル好きが理由じゃないかしら?」

ずんだもん: 「それもあるのかもしれませんね。歴史的背景を考えてみたら、競馬や競輪は昔から日本に根付いている文化なのだよ。」

四国めたん: 「そうかしら?でも最近ではオンラインギャンブルも増えてきているわよね。それを含めたら、どうなるのかしら?」

ずんだもん: 「その通りですね。国が管理する公営競技には信頼性があるから、オンラインギャンブルより安心して参加できるという点も大事なのかもしれません。」

四国めたん: 「それもあるわね。でもやっぱり税収を考えたら、公営競技が多い方が良いってことなのかしら?」

ずんだもん: 「確かに税収は重要ですよね。そして地域活性化にもつながるから、各地で様々な公営競技を支援しているのだと思うのだ。」

四国めたん: 「そういうことかしら。それに比べて海外ではどうなっているんだろう?日本と同じくらい公営競技が盛んじゃない気がするわよ。」

ずんだもん: 「その通りですね。日本独自の文化や法律、そして規制の違いがあるからかもしれません。それに地域ごとの特色を活かした競技も多いのだと思うのです。」

四国めたん: 「そうなのね。興味深いわね。日本だけが公営競技が多い理由はやっぱり複雑なのかしら?」

ずんだもん: 「それがこの話題ですよね。文化、法律、経済など様々な要因がからみ合っていると思うのだ。興味深くて楽しい議論になったわけだなのだよ。」
=============================

このセリフを音源を作成ー>再生を繰り返しながら実行してくれます

実装コード2 改行入力に対応(WEB記事読み込み用)

いままのままだと入力が1行で打ち込まないといけないので面倒です、WEBニュースについて2名に会話してもらいたい場合、ニュース記事がこのままだとコピペできないので複数行の入力に対応します

import os
import ollama         # ollama の公式ライブラリ
import requests
import json
import tempfile
import simpleaudio as sa
import re

# VOICEVOX サーバーのホストを環境変数から取得(なければ "localhost")
VOICEVOX_HOST = os.environ.get("VOICEVOX_HOST", "localhost")
VOICEVOX_PORT = 50021  # ポートは固定

# ---------------------------------------------
# プロンプト入力(複数行対応)を取得する関数
# ---------------------------------------------
def get_prompt_input():
    """
    ユーザーにシナリオ生成用プロンプトを入力させる関数です。
    入力が ””” で始まる場合、複数行入力モードとし、末尾の ””” で終了します。
    それ以外の場合は、1行の入力として扱います。
    """
    first_line = input("シナリオ生成用プロンプトを入力してください: ")
    # 入力が """ で始まる場合、複数行入力モード
    if first_line.startswith('"""'):
        # 先頭の """ を取り除く
        content = first_line[3:]
        # もし同じ行で終了していれば、末尾の """ を取り除いて終了
        if content.endswith('"""'):
            return content[:-3].strip()
        lines = [content]
        while True:
            line = input()
            # 入力行が """ で終わる場合、末尾の """ を取り除いて終了
            if line.endswith('"""'):
                lines.append(line[:-3])
                break
            else:
                lines.append(line)
        return "\n".join(lines).strip()
    else:
        return first_line

# ---------------------------------------------
# 1. LLMで会話シナリオを生成する関数
# ---------------------------------------------
def generate_scenario(prompt):
    """
    指定されたテーマに沿って、ずんだもんと四国めたんが議論するシナリオを生成する。
    
    以下のキャラクター口調の特徴を必ず反映すること:
    
    【ずんだもんの口調の特徴】
    ・ずんだもんの口調は、語尾に「なのだ」や「のだ」をつけるのが特徴です。
    ・また、柔らかく優しい口調で話します。
    
    【四国めたんの口調の特徴】
    ・四国めたんは、誰にでもタメ口で話します。
    ・「〜かしら」や「〜わよ」のような高飛車な口調で話すのが特徴です。
    
    ※ 出力形式は必ず以下の形式に従うこと:
         ずんだもん:「発言内容」
         四国めたん:「発言内容」
    各発話は1行で出力してください。発言内容には「」をつけること
    """
    messages = [
        {
            "role": "system",
            "content": (
                "あなたは会話シナリオ生成エンジンです。以下のキャラクター口調の特徴に基づいて、"
                "指定されたテーマに沿ったずんだもんと四国めたんの議論シナリオを生成してください。\n\n"
                "【ずんだもんの口調の特徴】\n"
                "・ずんだもんの口調は、語尾に「なのだ」や「のだ」をつけるのが特徴です。\n"
                "・また、柔らかく優しい口調で話します。\n\n"
                "【四国めたんの口調の特徴】\n"
                "・四国めたんは、誰にでもタメ口で話します。\n"
                "・「〜かしら」や「〜わよ」のような高飛車な口調で話すのが特徴です。\n\n"
                "※ 出力形式は必ず以下の形式に従うこと:\n"
                "ずんだもん:「発言内容」\n"
                "四国めたん:「発言内容」\n"
                "各発話は1行で出力してください。発言内容には「」をつけること\n"
            )
        },
        {"role": "user", "content": prompt}
    ]
    
    # モデルとして phi4 を指定して問い合わせ
    result = ollama.chat(model="phi4", messages=messages)
    
    try:
        # 生成結果は result['message']['content'] から取得する
        scenario = result["message"]["content"]
    except KeyError:
        scenario = "シナリオの生成に失敗しました。"
    
    return scenario

# ---------------------------------------------
# 2. シナリオテキストを解析して発話ごとに分割する関数
# ---------------------------------------------
def parse_scenario(scenario):
    """
    シナリオテキストを行ごとに分割し、
    各行から発話者と発話内容を抽出する。

    例)
      入力行: ずんだもん:「オーバーツーリズムって、〜」
      出力: ("ずんだもん", "オーバーツーリズムって、〜")
    """
    conversation = []
    # 正規表現パターン(行の先頭が「ずんだもん」または「四国めたん」)
    pattern = re.compile(r'^(ずんだもん|四国めたん)\s*[::]\s*「([^」]+)」')
    
    lines = scenario.strip().splitlines()
    for line in lines:
        line = line.strip()
        match = pattern.match(line)
        if match:
            speaker = match.group(1).strip()
            text = match.group(2).strip()
            conversation.append((speaker, text))
    return conversation

# ---------------------------------------------
# 3. VOICEVOX で音声合成を行う関数
# ---------------------------------------------
def get_voicevox_audio(text, speaker):
    """
    text: 読み上げるテキスト
    speaker: VOICEVOX の speaker 番号(ずんだもん→3, 四国めたん→2)
    """
    # (1) audio_query エンドポイントで合成用パラメータを取得
    audio_query_url = f"http://{VOICEVOX_HOST}:{VOICEVOX_PORT}/audio_query"
    params = {"text": text, "speaker": speaker}
    r = requests.post(audio_query_url, params=params)
    if r.status_code != 200:
        print("audio_query に失敗しました:", r.text)
        return None
    query = r.json()
    
    # (2) synthesis エンドポイントで音声合成(WAVデータ取得)
    synthesis_url = f"http://{VOICEVOX_HOST}:{VOICEVOX_PORT}/synthesis"
    headers = {"Content-Type": "application/json"}
    r2 = requests.post(synthesis_url, params={"speaker": speaker}, data=json.dumps(query), headers=headers)
    if r2.status_code != 200:
        print("synthesis に失敗しました:", r2.text)
        return None
    return r2.content  # WAV バイナリデータ

# ---------------------------------------------
# 4. 合成した音声(WAV)を再生する関数
# ---------------------------------------------
def play_audio(audio_data):
    """
    音声データ(WAVバイナリ)を一時ファイルに書き出し、再生する。
    """
    with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as f:
        f.write(audio_data)
        filename = f.name
    try:
        wave_obj = sa.WaveObject.from_wave_file(filename)
        play_obj = wave_obj.play()
        play_obj.wait_done()
    except Exception as e:
        print("音声再生に失敗しました:", e)
    finally:
        os.remove(filename)

# ---------------------------------------------
# 5. メイン処理
# ---------------------------------------------
def main():
    # get_prompt_input() を利用して、複数行入力に対応する
    prompt = get_prompt_input()
    
    # (1) LLM でシナリオ生成
    scenario = generate_scenario(prompt)
    print("\n===== 生成されたシナリオ =====")
    print(scenario)
    print("=============================\n")
    
    # (2) シナリオを解析して発話ごとに分割
    conversation = parse_scenario(scenario)
    if not conversation:
        print("シナリオの解析に失敗しました。パースできる発話が見つかりませんでした。")
        return
    
    # (3) 各発話を VOICEVOX で音声合成し、順次再生する
    for speaker, text in conversation:
        print(f"{speaker}: {text}")
        # speaker 名により VOICEVOX の speaker ID を選択
        if speaker == "ずんだもん":
            voicevox_speaker = 3
        elif speaker == "四国めたん":
            voicevox_speaker = 2
        else:
            voicevox_speaker = 3  # 万が一のデフォルト
        
        audio = get_voicevox_audio(text, speaker=voicevox_speaker)
        if audio:
            play_audio(audio)
        else:
            print(f"{speaker} の音声合成に失敗しました。")
            
if __name__ == '__main__':
    main()

これで複数行の入力に対応しました

上記の記事についてずんだもんと四国めたんに会話してもらいます(記事はテキストコピペ)

penguin@:~/code_wsl/voicevox-1$ python3 kaiwa3-combine.py
シナリオ生成用プロンプトを入力してください: """

下記のニュースについて議論する2名の会話を生成して


マスク氏、974億ドルのOpenAI買収を主導 「Twitterを97.4億ドルで買収しましょうか」とアルトマンCEO
2025年02月11日 07時19分 公開
[ITmedia]
印刷
見る
Share
36
0
 イーロン・マスク氏率いる投資家グループが、米OpenAIを管理する非営利団体の買収に974億ドルの入札を行ったと、Wall Street Journal(リンク先は要購読)が2月10日(現地時間)、マスク氏の弁護士マーク・トベロフ氏からのコメントを添えて報じた 。

 wsj
 OpenAIの共同創業者であるマスク氏は、複数回OpenAIを提訴している。12月の提訴では、OpenAIが反競争的な慣行を行い、マスク氏の慈善寄付の条件に違反していると主張した。

 トベロフ氏が提供した声明文でマスク氏は「OpenAIがかつてそうであったような、オープンソースで安全性を重視し、公益のための組織に戻る時が来た。われわれはそれを必ず実現させる」と語った。

 この報道を受け、OpenAIのサム・アルトマンCEOは(マスク氏がオーナーであるSNSの)X上に「いいえ、結構です。でも、よろ しければTwitterを97.4億ドルで買収しましょうか」とポストした。

 これに対し、マスク氏は10分後、「詐欺師」とリプライした。

"""

===== 生成されたシナリオ =====
ずんだもん:「マスクさんがOpenAIを買収するって話なのだけど、ちょっと意外じゃない?昔から対立してたのにね」

四国めたん:「そうよね。でも考えてみれば、マスクさんがOpenAIを改革しようって言ってるわけだし、彼ならやり遂げられそう かもしれないわよ」

ずんだもん:「それにしても、サム・アルトマンの返答が面白かったなのだ。『Twitterを買収しましょう』って。ユーモアのセンスはいつも抜群ね」

四国めたん:「そうね、ちょっとした挑発かしら?でもそれでマスクさんが『詐欺師』ってリプライするなんて。どこまで言い合 いになるのかしら」

ずんだもん:「そうなのだね。二人とも有名人だから、議論が盛り上がるのは必然的なのだよね」

四国めたん:「まあ、どっちにしろAI業界全体に注目が集まっているわ。今後も面白い展開が続きそうよ」

ずんだもん:「それは本当なのだ。私たちはこのニュースを見守り続けることにしようかのだ」
=============================

音声出力結果が下記です


実装コード3 すべての音源を結合して保存


これまでの実装コードでは各キャラクターの音声を生成ー>再生ー>削除
を繰り返していたので最終的な音源は保存さないのと、すべての音源を結合できていません。すべての音源を結合したシナリオ音源を作成するコードを最後に作成します

ここから先は

6,526字 / 2ファイル

¥ 100

PayPay
PayPayで支払うと抽選でお得

この記事が気に入ったらチップで応援してみませんか?