見出し画像

ローカルLLM OllamaとVOICEVOXを使った、ブラウザ上で動作する ずんだもんと四国めたんの寸劇

前回の続きです

前回、ブラウザで「ずんだもん動画」風の寸劇を再現するJSエンジンをChatGPT o3-mini-highで作成しました。
今回はこれを使って、ローカルLLMでずんだもん劇場のシナリオを作成し、それをVOICEVOXで音声化しブラウザで再現する一連の流れをプログラムで組みましたので紹介します。

サンプルサイト(スマフォブラウザでも動作)※音声はWAVなのでサイズには注意
テキストボックスをクリックしてシナリオ進行

ブラウザでの寸劇をPCでキャプチャしたもの


簡単な流れ

  1. Ollamaでずんだもんと四国めたんの寸劇をテーマにそって書いてもらう(Pythonプログラム)

  2. 寸劇シナリオテキストをシナリオJSONに変換(Pythonプログラム)

  3. シナリオJSONを下にVOICEVOXで音声化(Goプログラム)

  4. (任意)シナリオにそって背景画像をChatGPT DALLなどで生成

  5. できあがったアセットをブラウザで読み込み

動作イメージ


1. Ollamaでずんだもんと四国めたんの寸劇をテーマにそって書いてもらう

まずあるテーマにそってローカルLLM(Ollama)にシナリオを書いてもらいます。今回は「北海道に旅行にいくには?」にしました

python3 kaiwa4scenario.py
シナリオ生成用プロンプトを入力してください: """
北海道に旅行にいく計画をたててそのあと実際に観光をする2名の会話をかいてみてください
北海道の名所を紹介する動画風のシナリオ
"""

するとこんな返答になります(LLMモデルはphi4)


===== 生成されたシナリオ(テキスト) =====
ずんだもん:「そうなのだ、北海道に旅行に行く計画を立てるところから始めましょうか。どんな名所が見たいか考えてみましょうなのだ」
四国めたん:「あら、私は断然富良野や美瑛の絶景が観たいわよ。そこに行けばきっと心が洗われるかもね」

ずんだもん:「富良野と美瑛は素晴らしい場所なのだね。では、旅のスケジュールを考えてみましょうか。どのくらい滞在するつもり?」
四国めたん:「3日間くらいがちょうど良いかしら?そこでじっくり観光して、地元のグルメを楽しみたいわよ」

ずんだもん:「では初日に札幌入りして、次の日は富良野と美瑛をまわるプランがいいかもしれないなのだ。どう思います?」
四国めたん:「それは素敵ね。最終日に札幌で北海道グルメを堪能して、新千歳空港から帰りましょうか」

ずんだもん:「その計画ならば、どれも楽しみがたっぷり詰まっているわけだなのだ。札幌にはラーメンやジンギスカンが有名だよ」

四国めたん:「そうね、札幌でおいしいものを食べて帰りたいわ。特に美味しいホタテの食べ放題なんかがあれば最高よ」

ずんだもん:「では動画でも撮って、北海道旅行の思い出を残してみませんか。きっと素敵な映像になるでしょうなのだ」
四国めたん:「それは面白そうね。風景やグルメを楽しみつつ、色々な思い出をカメラに収めてみましょうか」

ずんだもん:「それで旅の計画がすっかり決まったわけだ。北海道は冬でも楽しめるところなので、服装や準備に気をつけてくださいねなのだ」
四国めたん:「大丈夫よ、しっかりと準備をしておくわ。北海道が待ってるからさ」

ずんだもん:「素敵な旅になること間違いなしですね。心配事は一つもありませんように、頑張りましょうなのだ」
四国めたん:「あら、ありがとう。私たちなら大丈夫わよ。楽しみましょうね!」
============================================

ずんだもんっぽい語尾が完全に再現されてないものの嘘情報はまじってなさそうです。

2. 寸劇シナリオテキストをシナリオJSONに変換(Pythonプログラム)

さきほどのLLMが出力したシナリオテキストをエンジンがよめるように、ノベルゲームのシナリオJSONのように、背景やキャラクター画像の指定がはいったJSONに変換します。
先ほどのテキストは以下のようになります

{
  "backgrounds": {
    "1": "bg1.png",
    "2": "bg2.png",
    "3": "bg3.png"
  },
  "defaultLeftCharacter": "四国めたん-ノーマル.png",
  "defaultRightCharacter": "ずんだもん-ノーマル.png",
  "scenes": [
    {
      "setBackground": "1",
      "lines": [
        {
          "character": "ずんだもん",
          "serif": "そうなのだ、北海道に旅行に行く計画を立てるところから始めましょうか。どんな名所が見たいか考えてみましょうなのだ",
          "sound": "1.wav",
          "images": [
            "ずんだもん-喋り1.png",
            "ずんだもん-喋り2.png"
          ],
          "color": "#00ff00"
        },
        {
          "character": "四国めたん",
          "serif": "あら、私は断然富良野や美瑛の絶景が観たいわよ。そこに行けばきっと心が洗われるかもね",
          "sound": "2.wav",
          "images": [
            "四国めたん-喋り1.png",
            "四国めたん-喋り2.png"
          ],
          "color": "#ff00ff"
        }
      ]
    },
    {
      "setBackground": "2",
      "lines": [
        {
          "character": "ずんだもん",
          "serif": "富良野と美瑛は素晴らしい場所なのだね。では、旅のスケジュールを考えてみましょうか。どのくらい滞在するつもり?",
          "sound": "3.wav",

ここまでのLLMでシナリオ生成ー>シナリオJSON出力までは1本のpythonプログラムで実現できます

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

# VOICEVOX の設定(今回は使用しないが、参考として残す)
VOICEVOX_HOST = os.environ.get("VOICEVOX_HOST", "localhost")
VOICEVOX_PORT = 50021  # 固定ポート

# ---------------------------------------------
# プロンプト入力(複数行対応)を取得する関数
# ---------------------------------------------
def get_prompt_input():
    r"""
    ユーザーにシナリオ生成用プロンプトを入力させる関数です。
    入力が \"\"\" で始まる場合、複数行入力モードとし、末尾の \"\"\" で終了します。
    それ以外の場合は、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行で出力してください。発言内容には必ず「」をつけること。

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

# ---------------------------------------------
# 2. シナリオテキストを解析して発話ごとに分割する関数
# ---------------------------------------------
def parse_scenario(scenario_text):
    """
    シナリオテキストを行ごとに分割し、各行から発話者と発言内容を抽出する。
    例)
      入力行: ずんだもん:「オーバーツーリズムって、〜」
      出力: {"character": "ずんだもん", "serif": "オーバーツーリズムって、〜", ...}
    
    さらに、キャラクターに応じて以下のプロパティを自動付与する:
      ・ずんだもんの場合:
          images: ["ずんだもん-喋り1.png", "ずんだもん-喋り2.png"],
          color: "#00ff00"
      ・四国めたんの場合:
          images: ["四国めたん-喋り1.png", "四国めたん-喋り2.png"],
          color: "#ff00ff"
      ・sound はシーケンシャルに "1.wav", "2.wav", ... と設定
    """
    conversation = []
    pattern = re.compile(r'^(ずんだもん|四国めたん)\s*[::]\s*「([^」]+)」')
    lines = scenario_text.strip().splitlines()
    sound_index = 1
    for line in lines:
        line = line.strip()
        match = pattern.match(line)
        if match:
            speaker = match.group(1).strip()
            text = match.group(2).strip()
            if speaker == "ずんだもん":
                images = ["ずんだもん-喋り1.png", "ずんだもん-喋り2.png"]
                color = "#00ff00"
            elif speaker == "四国めたん":
                images = ["四国めたん-喋り1.png", "四国めたん-喋り2.png"]
                color = "#ff00ff"
            else:
                images = []
                color = "#ffffff"
            sound = f"{sound_index}.wav"
            sound_index += 1
            conversation.append({
                "character": speaker,
                "serif": text,
                "sound": sound,
                "images": images,
                "color": color
            })
    return conversation

# ---------------------------------------------
# 3. シナリオ設定ファイル(scenario.json)を生成する関数
# ---------------------------------------------
def build_scenario_json(conversation):
    """
    解析した会話(発話リスト)をもとに、シナリオ設定ファイルのJSON構造を生成する。
    ※ 背景は出力時に各シーンごとに setBackground を出力するが、基本は固定背景とする。
    """
    scenario_json = {
        "backgrounds": {
            "1": "bg1.png",
            "2": "bg2.png"
        },
        "defaultLeftCharacter": "四国めたん-ノーマル.png",
        "defaultRightCharacter": "ずんだもん-ノーマル.png",
        "scenes": []
    }
    
    # ここでは、2名の会話が終わるごとに1シーンとする
    # 今回はconversationは発話リスト(各発話の辞書)のリストとする
    # 例えば、2つの発話(ずんだもんと四国めたん)のペアを1シーンにまとめる
    for i in range(0, len(conversation), 2):
        scene_lines = conversation[i:i+2]
        # シーンの背景は固定(後から手動で変更しやすいように setBackground "1" を入れる)
        scene = {
            "setBackground": "1",
            "lines": scene_lines
        }
        scenario_json["scenes"].append(scene)
    return scenario_json

# ---------------------------------------------
# 4. メイン処理
# ---------------------------------------------
def main():
    # 複数行入力に対応したプロンプトを取得
    prompt = get_prompt_input()
    
    # LLMでシナリオ生成(テキスト形式)
    scenario_text = generate_scenario(prompt)
    print("\n===== 生成されたシナリオ(テキスト) =====")
    print(scenario_text)
    print("============================================\n")
    
    # シナリオテキストを解析
    conversation = parse_scenario(scenario_text)
    if not conversation:
        print("シナリオの解析に失敗しました。パースできる発話が見つかりませんでした。")
        return
    
    # 解析結果をもとに scenario.json の構造を生成
    scenario_json = build_scenario_json(conversation)
    
    # JSONファイルとして出力
    output_filename = "scenario.json"
    with open(output_filename, "w", encoding="utf-8") as f:
        json.dump(scenario_json, f, ensure_ascii=False, indent=2)
    
    print(f"シナリオ設定ファイル '{output_filename}' を出力しました。")
    
if __name__ == '__main__':
    main()

3.シナリオJSONを下にVOICEVOXで音声化(Goプログラム)

こちらに関しては前回の記事で紹介しています。

さきほどのシナリオJSONを読み込んで、ローカルPCに立てたVOICEVOXサーバーに音声ファイルの出力をリクエストします
VOICEVOXサーバーはそこまでCPU負荷は高くなくすぐ音声ファイルができあがります

/zundamon_browser_player/voice_generator scenario.json
2025/02/22 14:49:00 saved audio file: 1.wav
2025/02/22 14:49:02 saved audio file: 2.wav
2025/02/22 14:49:03 saved audio file: 3.wav
2025/02/22 14:49:04 saved audio file: 4.wav
2025/02/22 14:49:05 saved audio file: 5.wav
2025/02/22 14:49:06 saved audio file: 6.wav
2025/02/22 14:49:08 saved audio file: 7.wav
2025/02/22 14:49:09 saved audio file: 8.wav
2025/02/22 14:49:10 saved audio file: 9.wav
2025/02/22 14:49:11 saved audio file: 10.wav
2025/02/22 14:49:13 saved audio file: 11.wav
2025/02/22 14:49:14 saved audio file: 12.wav
2025/02/22 14:49:15 saved audio file: 13.wav
2025/02/22 14:49:16 saved audio file: 14.wav

4.(任意)シナリオにそって背景画像をChatGPT DALLなどで生成

会話の途中で背景を変えたい場合は、setBackgroundの値を変更し
適宜どこかで画像をつくって指定します。
楽なのはChatGPT DALLでしょう。

{
  "backgrounds": {
    "1": "bg1.png",
    "2": "bg2.png",
    "3": "bg3.png"
  },
  "defaultLeftCharacter": "四国めたん-ノーマル.png",
  "defaultRightCharacter": "ずんだもん-ノーマル.png",
  "scenes": [
    {
      "setBackground": "1",
      "lines": [
        {
          "character": "ずんだもん",
          "serif": "そうなのだ、北海道に旅行に行く計画を立てるところから始めましょうか。どんな名所が見たいか考えてみましょうなのだ",
          "sound": "1.wav",
          "images": [
            "ずんだもん-喋り1.png",
            "ずんだもん-喋り2.png"
          ],
          "color": "#00ff00"
        },
~~~略

    {
      "setBackground": "3",
      "lines": [
        {
          "character": "ずんだもん",
          "serif": "その計画ならば、どれも楽しみがたっぷり詰まっているわけだなのだ。札幌にはラーメンやジンギスカンが有名だよ",
          "sound": "7.wav",
          "images": [
            "ずんだもん-喋り1.png",
            "ずんだもん-喋り2.png"
          ],
          "color": "#00ff00"
        },


5. できあがったアセットをブラウザで読み込み

あとはindex.htmlをブラウザ上でよみこむだけです。
ローカルではVS Codeのlive Preview拡張などを使ってサーバー経由で読み込みしましょう。


ソースはこちら


今後の展望、改良点

今回はあらかじめシナリオを出力し、音声化したあとブラウザ上で再生する流れでしたがnode.jsやstreamlitを使えばリアルタイムにシナリオ出力ー>音声化がスムーズにいくのでリアルタイム寸劇も可能となります。シナリオ出力にはある程度のスペックのマシンが必要ですが、そのパターンも試してみたいと思います。


いいなと思ったら応援しよう!