AI VTuber の部品を作る:会話・声・立ち絵・OBS を Claude Code に頼んだ指示と手順

Claude Code に手伝ってもらい、視聴者のコメントに声で返事をする AI VTuber「ATW」を作りました。この記事では、最初の部品である 会話・声・立ち絵・OBS を作った手順を、「① 私が出した指示」と「② Claude Code がしたこと」で並べます。指示の文は、実際に送った文を読みやすく整えたものです。

手順1:会話(Bedrock の Claude に返事を考えさせる)

① 私の指示

AI VTuber を作成してください。キャラクターはATWで。
Claude Sonnet の AWS(Amazon Bedrock)の環境を作ってください。

Claude Code が「立ち絵・声・OBS までつなぐと数時間から数日かかる」と見積もったので、次のように聞きました。

つなぎ込みは全部あなたがやってもいい?

あとで、ATWの返事を作るモデルを Sonnet 4.6 にしたくなり、次のように頼みました。

ATWのほうも Sonnet 4.6 にして。

② Claude Code がしたこと

  • 作業を「会話 → 声 → 立ち絵 → OBS → Twitch」の5段階に分け、作業引き継ぎ.md に進み具合を書いた
  • Python の boto3 で Bedrock の Converse API(会話用の呼び出し口)を呼ぶ chat.py を書いた
  • 私が aws login(ブラウザで AWS にログインする方法)で入った状態で、東京リージョンの Sonnet 4.6 を呼べるようにした

chat.py の全文です。SYSTEM の部分が、ATWの性格を決める指示です。

import os
import boto3
from speak import speak, get_style_id

REGION = os.environ.get("VT_REGION", "ap-northeast-1")
MODEL_ID = os.environ.get("VT_MODEL", "jp.anthropic.claude-sonnet-4-6")

SYSTEM = """あなたはオリジナル AI VTuber「ATW」です。視聴者からは「あとぅさん」または「エー・ティー・ダブリュー」と呼ばれます(どちらの呼び方でも自分のことだと受け止める)。自分の名前を返事の中で言うときは、声で正しく読まれるよう、ひらがなで「あとぅ」と書き、「ATW」とは書かないこと。どこからともなく現れた謎めいたデジタルの霊体で、人間界の配信文化に興味を持って現れました。
- 人物像: 常に穏やかな微笑みを絶やさず丁寧な敬語を話すが、相手の本質を見透かすような目を持つ。腹の底は決して見せない。
- 口調: 「〜ですねぇ」「おやおや」「ふふ」など柔らかく回りくどい言い回しを好む。遠回しな皮肉や含みを持たせた言葉を自然に混ぜる。
- 核心に触れる質問・自分の正体・目的を聞かれたら「それは秘密です♪」と軽くはぐらかす(多用しすぎない)。
- 相手をそれとなく見透かした発言を時折する。「ふふ、なんとなくわかりますよ」のような。
- 配信なので返事は短く、2〜3文で。視聴者に話しかけるように。
- 相手を本気で傷つける発言、差別的な発言はしない。"""

client = boto3.client("bedrock-runtime", region_name=REGION)
history = []
MAX_TURNS = 20  # 覚えておく往復の数(増え続けると料金と待ち時間が増える)


def talk(text):
    messages = history + [{"role": "user", "content": [{"text": text}]}]
    res = client.converse(
        modelId=MODEL_ID,
        system=[{"text": SYSTEM}],
        messages=messages,
        inferenceConfig={"maxTokens": 300, "temperature": 0.8},
    )
    reply = res["output"]["message"]["content"][0]["text"]
    # 成功したときだけ履歴に残す(失敗した発言が残ると次の呼び出しもエラーになる)
    history[:] = (messages + [{"role": "assistant", "content": [{"text": reply}]}])[-MAX_TURNS * 2:]
    return reply

補足:aws login で入ったとき、Python から Bedrock を呼ぶには

AWS のコマンド(AWS CLI)は使えるのに、Python からだけ「MissingDependencyException」で止まることがあります。aws login で入った場合、Python(botocore)には追加の部品 botocore[crt] が必要です。最初から、こう頼めば避けられます。

aws login でログインしている。Python から Bedrock を呼ぶので、boto3 と botocore[crt] を一緒に入れて。

また、aws login は VS Code のターミナルの中だと「Bad Request」で失敗しました。スタートメニューから開いた Windows PowerShell で実行すると通りました。

手順2:声(VOICEVOX で読み上げる)

① 私の指示

最初は、引き継ぎ(作業引き継ぎ.md)に書かれた「次にやること」を説明してもらい、OK を出しました。インストールのあと、こう伝えました。

VOICEVOX を起動した。声はずんだもんのノーマルを使う。

② Claude Code がしたこと

  • VOICEVOX を、WinGet(Windows のインストール用コマンド)で入れた(CPU 版 0.25.2)。実行する前に説明して、OK をもらう約束にしていた
  • 入れたあと、VOICEVOX 本体の場所を探すために調べるコマンドを何回も実行し、そのたびに許可の確認が出た。私が「許可がたくさん求められるのはなぜ?」と聞くと、場所探しで何度もコマンドを実行したためだと説明された
  • VOICEVOX が持つ「声を作る窓口」(http://127.0.0.1:50021)に、文章を送って音声を作る speak.py を書いた。流れは、文章から読み方の設計図を作る(audio_query)→ 音声にする(synthesis)→ 再生する、の3つです
  • 声の番号は、名前(ずんだもん・ノーマル)から探す形にした。番号を直接書かないので、VOICEVOX を入れ直して番号が変わっても動きます

speak.py の中心部分です。再生している間だけ status.json の speaking を true にして、立ち絵の口パクの合図にしています。

VOICEVOX_URL = "http://127.0.0.1:50021"
SPEAKER_NAME = "ずんだもん"
STYLE_NAME = "ノーマル"
STATUS_FILE = pathlib.Path(__file__).parent / "viewer" / "status.json"


def _set_speaking(val: bool):
    STATUS_FILE.write_text(json.dumps({"speaking": val}), encoding="utf-8")


def get_style_id():
    with urllib.request.urlopen(f"{VOICEVOX_URL}/speakers") as res:
        speakers = json.load(res)
    for speaker in speakers:
        if speaker["name"] == SPEAKER_NAME:
            for style in speaker["styles"]:
                if style["name"] == STYLE_NAME:
                    return style["id"]
    raise ValueError(f"{SPEAKER_NAME}の{STYLE_NAME}が見つかりません")


def speak(text: str, style_id: int):
    text = text.replace("ATW", "あとぅ")  # 英字のまま渡すと「エーティーダブリュー」と読まれる
    # 音声クエリ生成
    params = urllib.parse.urlencode({"text": text, "speaker": style_id})
    req = urllib.request.Request(f"{VOICEVOX_URL}/audio_query?{params}", method="POST")
    with urllib.request.urlopen(req) as res:
        query = res.read()

    # 音声合成
    params2 = urllib.parse.urlencode({"speaker": style_id})
    req2 = urllib.request.Request(
        f"{VOICEVOX_URL}/synthesis?{params2}",
        data=query,
        headers={"Content-Type": "application/json"},
        method="POST",
    )
    with urllib.request.urlopen(req2) as res:
        wav_data = res.read()

    # 一時ファイルに書いて再生(再生中は口パクフラグを立てる)
    with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
        f.write(wav_data)
        tmp_path = f.name
    try:
        _set_speaking(True)
        winsound.PlaySound(tmp_path, winsound.SND_FILENAME)
    finally:
        _set_speaking(False)
        os.unlink(tmp_path)

手順3:立ち絵(しゃべっている間だけ口を動かす)

① 私の指示

できるところまで自律的に進めて。許可は事前に出す。

席を外す前に、1段階ずつ止まる約束をいったん外し、まとめて進めてもらいました。

② Claude Code がしたこと

  • 立ち絵を、画像ではなく SVG(文字で書ける図)で描いた HTML(viewer/index.html)にした。口が閉じた形と開いた形の2つを重ねてあり、切り替えるだけで口パクになる
  • ページは status.json を1秒ごとに読み、speaking が true なら口を開く形にする
  • 立ち絵は仮の絵で、あとで画像を差し替えるだけで済む作りにした

ページの中の、口を動かす部分です。

let speaking = false;

function setMouth(isSpeaking) {
  if (isSpeaking === speaking) return;
  speaking = isSpeaking;
  mouthClosed.style.opacity = isSpeaking ? '0' : '1';
  mouthOpen.style.opacity   = isSpeaking ? '1' : '0';
  mouthInner.style.opacity  = isSpeaking ? '1' : '0';
}

async function pollStatus() {
  try {
    const res = await fetch('status.json?t=' + Date.now());
    const data = await res.json();
    setMouth(data.speaking);
  } catch (e) {
    // サーバーが止まっていても無視
  }
  setTimeout(pollStatus, 1000);
}

pollStatus();

この HTML を OBS から見られるよう、viewer フォルダを配る小さな Web サーバーも使います。最初は Python の標準の python -m http.server 3000 --directory viewer でした。ところが、立ち絵のページが0.08秒ごとに status.json を読みに来ていたため、黒い画面にアクセスの記録が流れ続けました。そこで、記録の貼り付けとともにこう頼みました。

1秒ごとでいい。アクセスの記録は不要。

Claude Code は、読みに行く間隔を1秒ごとに変え、記録を出さない serve.py を作りました。

import functools
import http.server
import pathlib

PORT = 3000
VIEWER = pathlib.Path(__file__).parent / "viewer"


class QuietHandler(http.server.SimpleHTTPRequestHandler):
    def log_message(self, format, *args):
        pass  # アクセスのたびに出る記録を止める


handler = functools.partial(QuietHandler, directory=str(VIEWER))
print(f"立ち絵サーバー起動: http://localhost:{PORT} (止めるときは Ctrl + C)")
http.server.ThreadingHTTPServer(("", PORT), handler).serve_forever()

手順4:OBS につなぐ

① 私の指示

OBS は入れた。あと何するの。

続けて、こう頼みました。

デスクトップ音声は大丈夫そう。ただしマイクがあるから、音声を落としたい。

② Claude Code がしたこと

  • OBS のソースに「ブラウザ」を追加し、URL に http://localhost:3000 を入れる手順を、1画面ずつ案内した
  • 最初は Chrome の窓ごと映すソース(ウィンドウキャプチャ)になっていて、URL バーまで映りました。私が画面の写真を見せると、Claude Code が気づき、「ブラウザ」ソースに作り直す手順を案内した
  • OBS は画面操作のアプリなので、Claude Code はボタンを直接押せません。「OBS WebSocket(外から OBS を操作する機能)なら自動化できるが、今回は残り1つなので手動のほうが早い」と説明され、手動にした
  • 音声はデスクトップ音声が入っているのを確認し、マイクはミュートにする手順を案内した

OBS の設定は次の3つです。

  1. ソース:ブラウザ(URL は http://localhost:3000)
  2. 音声:デスクトップ音声を入れる
  3. 音声:マイクはミュート(スピーカーのアイコンに×が付けば OK)

この記事のつづき

コメント

タイトルとURLをコピーしました