1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ローカル完結で「ずんだもんと声で会話できる」AIスタック AIzunda — Ryzen AI Max+ 395 (ROCm) で初音1秒

1
Last updated at Posted at 2026-05-05

ローカル完結で「ずんだもんと声で会話できる」AI スタック AIzunda

ブラウザのマイクを押すと、ローカルだけで動く LLM がずんだもんの声で返事をしてくれて、VRM がリップシンクで口パクする ―― そういう「音声 → STT → LLM → TTS → VRM」のパイプラインを 1 台のマシンで完結させる試みです。

クラウド API は一切呼ばず、AMD Ryzen AI Max+ 395 + ROCm のワンマシンで動かすことを前提にしています。GitHub リポジトリは kotetsuy/AIzunda にあります。

長文応答でも初音まで約 1 秒になるよう、Qwen3 の thinking 抑制と LLM→TTS のパイプライン化を入れているのが地味なポイントです。

こちらは動画です。


前半:git clone してから動かすまで

動作確認済みの環境

項目
OS Ubuntu 24.04.4 LTS
GPU AMD Ryzen AI Max+ 395 / Radeon 8060S (gfx1151、48GB VRAM)
ROCm 7.2.1 (/opt/rocm)
Python 3.12.3
Docker 29.x(VOICEVOX 用)
ブラウザ Google Chrome(Firefox でも可)
その他 tmux / curl(起動スクリプトで使用)

ROCm 対応 AMD GPU + Ubuntu 24.04 の組み合わせを想定しています。NVIDIA / CUDA で動かす場合は WHISPER_DEVICE=cuda のままで OK ですが、HSA_OVERRIDE_GFX_VERSION 系の環境変数は外してください。

1. リポジトリと外部依存を取得

# 本リポジトリ
git clone https://github.com/kotetsuy/AIzunda.git ~/AIzunda
cd ~/AIzunda

# llama.cpp(ROCm/HIP ビルド)
git clone https://github.com/ggerganov/llama.cpp.git ~/AIzunda/llama.cpp

# WhisperX-ROCm(STT)・CTranslate2-ROCm を ~/AIzunda 直下に配置
# (それぞれ各プロジェクトの README に従ってビルド)

~/AIzunda 以下に揃えるディレクトリ構成は次のとおりです。

~/AIzunda/
├── llmtvoice/        # パイプライン総合ドキュメント
├── three-vrm/        # VRM ビューア + VOICEVOX 中継 (aiohttp)
├── ttllm/            # WhisperX ↔ llama.cpp ブリッジ (FastAPI)
├── voicevox/         # VOICEVOX Docker 起動の手順 / テスト
├── vtt/              # CLI PTT クライアント (任意)
├── start_all.sh      # 一括起動
├── stop_all.sh       # 一括停止
├── llama.cpp/        # ← 自分で git clone & ビルド
├── qwen3.6/          # ← GGUF モデルを置く
├── whisperx-rocm/    # ← WhisperX-ROCm の venv
└── zundavrm/VRM/     # ← VRM ファイルを置く

各サブモジュールの README は GitHub 上で個別に確認できます:

2. llama.cpp を ROCm でビルド

~/AIzunda/llama.cpp で HIP 対応ビルドを行います(詳細は llama.cpp 本体の手順に従ってください)。最終的に ~/AIzunda/llama.cpp/build/bin/llama-server が出来ていれば OK です。

その上で Qwen3.6-35B-A3B の GGUF を ~/AIzunda/qwen3.6/Qwen3.6-35B-A3B-UD-Q4_K_XL.gguf に置きます。

3. WhisperX-ROCm(STT)の venv を作る

WhisperX 本体は別リポジトリの ROCm フォークを使います。~/AIzunda/whisperx-rocm/.venv に WhisperX / torch-ROCm 2.9 / ctranslate2-rocm / faster-whisper / pyannote.audio を入れた venv を作ります(llmtvoice/READMEJ.md の「セットアップ」節参照)。

4. ttllm ブリッジの依存を venv に追加

cd ~/AIzunda/ttllm
./install.sh

ttllm/install.sh は WhisperX の venv に fastapi / uvicorn / httpx / python-multipart / pydantic を追加するだけのスクリプトです。

5. VOICEVOX を Docker で取得

docker pull voicevox/voicevox_engine:cpu-ubuntu20.04-latest
docker run -d --name voicevox_engine --restart unless-stopped \
  -p 50021:50021 voicevox/voicevox_engine:cpu-ubuntu20.04-latest

curl -s http://localhost:50021/version  # 起動確認

ROCm との VRAM 競合を避けたいので CPU イメージを使っています。短文ならこれでも十分リアルタイムです。

6. VRM モデルを配置

~/AIzunda/zundavrm/VRM/Zundamon_2025_VRM10A.vrm にずんだもん VRM 1.0 モデルを置きます。別ファイル名にしたい場合は three-vrm/server.pyVRM_DIR と、ブラウザ側 zundamon.htmlVRM_URL を揃えてください。

7. 一括起動

ここまで来れば、あとは 1 コマンドです。

~/AIzunda/start_all.sh

start_all.sh は tmux セッション aizunda を作って、各サービスを別ウィンドウで走らせます。

window コマンド
0 voicevox docker logs -f voicevox_engine
1 llama llama-server -m Qwen3.6... --port 8080 -ngl 99 -c 8192
2 ttllm ttllm/run.sh (uvicorn)
3 three-vrm python3 three-vrm/server.py
4 vtt vtt/run.sh --device USB (CLI PTT、任意)

起動順序は依存関係に合わせて直列化してあり、各段で HTTP health check を待ちます(llama-server だけタイムアウト最大 600 秒)。ttllm が立ち上がった直後に /warmup を叩いて WhisperX を先読みするので、最初の発話が遅くなりません。

ログを覗くには:

tmux attach -t aizunda

全部止めるには:

~/AIzunda/stop_all.sh
# VOICEVOX のコンテナだけ残したいときは
~/AIzunda/stop_all.sh --keep-voicevox

stop_all.sh は tmux セッションと VOICEVOX コンテナを止めます。

8. ブラウザで使う

start_all.sh が自動で Chrome を開きます(http://localhost:8000/zundamon.html)。

  1. 画面を一度クリック して AudioContext を有効化(ブラウザの user-gesture 要件)
  2. 右下の 🎤 ボタン
    • 長押し(≥ 250ms): 押している間だけ録音、離すと送信
    • 短クリック: 録音開始 → もう一度クリックで送信
  3. ユーザー発話は薄青の字幕、ずんだもんの返答は白の字幕で出ます

CLI から動作確認するなら:

# 各サービス疎通
curl -s http://localhost:50021/version
curl -s http://localhost:8080/health
curl -s http://localhost:8001/health

# テキスト → VOICEVOX → ブラウザで口パク
curl -X POST http://localhost:8000/speak \
  -H 'Content-Type: application/json' \
  -d '{"text":"こんにちはなのだ","speaker_id":3}'

# 音声ファイル → 文字起こし + LLM 応答 + 合成 + 口パク
curl -X POST http://localhost:8000/voice_chat_speak \
  -F "audio=@sample.wav" -F "speaker_id=3"

後半:ブロック図とテクノロジー詳細

全体ブロック図

ブラウザ → three-vrm サーバ → ttllm ブリッジ → (WhisperX + llama-server) → VOICEVOX → ブラウザ という 5 段構成です。点線が WS による音声 + viseme のブロードキャストで、ここがリップシンクの入力になります。

AIzunda パイプライン全体

スクリーンショット 2026-05-05 19.55.36.png

ブラウザ (three-vrm)
  └─ マイク録音 (MediaRecorder webm/opus)
         ↓ POST /voice_chat_speak_stream
    three-vrm サーバ (port 8000)
         ↓ POST /voice_chat_stream
       ttllm ブリッジ (port 8001)
         ├─ WhisperX-ROCm (STT, large-v3)
         └─ llama-server (Qwen3.6-35B-A3B, port 8080)
         ↓ SSE で token ストリーム
    three-vrm: 文境界で分割 → VOICEVOX (port 50021) → WS 配信
         ↓ WS (audio + visemes)
 ブラウザ: AudioContext 連続再生 + VRM リップシンク + 背景 + idle motion

各サービスの役割とポートをまとめると次のとおりです。

パス 役割 ポート
voicevox/ VOICEVOX Engine(Docker, CPU 推論) 50021
~/AIzunda/llama.cpp/build/bin/llama-server Qwen3.6 推論 8080
qwen3.6/Qwen3.6-35B-A3B-UD-Q4_K_XL.gguf LLM モデル
ttllm/ FastAPI ブリッジ(WhisperX + llama.cpp) 8001
three-vrm/ aiohttp サーバ + VRM ビューア(HTML/three-vrm) 8000
vtt/ CLI PTT マイク(任意)
images/ VRM ビューア背景(5 分ごとにローテーション)
zundavrm/VRM/Zundamon_2025_VRM10A.vrm ずんだもん VRM 1.0 モデル
whisperx-rocm/ WhisperX の ROCm フォーク

採用テクノロジー

採用 理由
STT WhisperX-ROCm + Silero VAD 単一プロセスで VAD・転写・(必要なら)強制アライメントまで完結。ROCm 上で float16 が出る
LLM llama.cpp(llama-server) + Qwen3.6-35B-A3B UD-Q4_K_XL OpenAI 互換 API + SSE。MoE で 35B 級でも GPU 1 枚に乗る
TTS VOICEVOX(CPU Docker) accent_phrases から moras が取れ、リップシンク用 viseme に変換しやすい
アバター @pixiv/three-vrm(VRM 1.0) expressionManageraa/ih/ou/ee/oh/nn が標準化されている
バックエンド FastAPI(ttllm) + aiohttp(three-vrm) SSE / WebSocket / multipart の取り回しがそれぞれ素直

主要エンドポイント

ttllm(port 8001) — ttllm/server.py

メソッド パス 用途
GET /health 自身 + llama-server 到達性
POST /warmup WhisperX モデル先読み
POST /transcribe 音声 → テキスト
POST /chat テキスト → LLM 応答(非 streaming)
POST /voice_chat 音声 → 応答(非 streaming)
POST /voice_chat_stream 音声 → SSE(transcript + token + done)

three-vrm(port 8000) — three-vrm/server.py

メソッド パス 用途
GET /zundamon.html ビューア
GET /ws WebSocket(turn_start / speak / turn_end / transcript / error)
POST /speak テキスト指定で発話
POST /voice_chat_speak 音声 → ワンショット応答(非 streaming)
POST /voice_chat_speak_stream 音声 → パイプライン応答
GET /images_list 背景画像一覧
GET /images/{name} 背景画像配信
GET /vrm/{name} VRM ファイル配信
GET /status クライアント数

レイテンシ最適化:初音 3.32 s → 1.06 s

ここが本スタックの実装上の主役です。短い発話(「こんにちは」程度)で体感 1 秒前後、長文応答でも初音 1 秒台を目標にしました。

Before/After 比較

スクリーンショット 2026-05-05 19.57.30.png

実測値は次のとおりです(8 文応答)。

指標 改善前(非 streaming) 改善後(pipeline)
初音までの時間 3.32 s 1.06 s
全体完了時間 3.32 s 2.98 s

1. Qwen3 thinking モードを切る

既定で Qwen3 系は返答前に reasoning_content(内部独白)を数百トークン吐くので、これだけで体感 4〜8 秒くらい遅延します。ttllm から llama-server を叩くときに chat_template_kwargs で無効化しています。

# ttllm/server.py: _call_llama
payload = {
    "messages": messages,
    "temperature": temperature,
    "max_tokens": max_tokens,
    "stream": False,
    # Qwen3 系は既定で thinking を吐くので chat template 側で切る
    "chat_template_kwargs": {"enable_thinking": False},
}

参考: ttllm/server.py

これを渡さないと、reasoning_content 側に数百トークン食われて content が空のまま max_tokens に到達することがあります。

2. LLM → VOICEVOX のパイプライン化

  • ttllm 側に /voice_chat_stream(SSE) を追加し、llama-serverstream: true で叩く。{transcript}{token}×N{done} の順で流す。
  • three-vrm 側の /voice_chat_speak_stream がこの SSE を消費し、[。!?\n] で文分割、長文保険として 60 文字超は [、] でも切る。
  • TTS は asyncio.Queue + consumer task で直列化(WebSocket の順序保証が要るため)。一方で LLM のデコードは並列継続させる。
  • クライアントは turn_start で playhead をリセットし、各 speak チャンクを startAt = max(playheadTime, audioCtx.currentTime) でキュー末尾に連続再生。viseme は絶対時刻でスケジュールするので、複数チャンクでも干渉しない。

3. 新ターン開始時に前の発話を即停止

マイクを押した時点で、クライアントは現在スケジュール済みの全 AudioBufferSourceNodestop(0) し、viseme キューも消します(stopAllPlayback)。サーバの turn_start 到着を待たないので、体感が即応します。

VOICEVOX → viseme → VRM のリップシンク経路

VOICEVOX の accent_phrases から取れる moras を、VRM 1.0 標準表情 aa / ih / ou / ee / oh / nn にマップしているのがリップシンクの心臓部です。

リップシンクのデータフロー

スクリーンショット 2026-05-05 19.58.22.png

実装は three-vrm/server.pymora_to_visemes 関数に集約されています。

VOWEL_TO_VISEME = {
    "a": "aa", "i": "I", "u": "U", "e": "E", "o": "O",
    "N": "nn", "cl": "sil", "pau": "sil",
}

CONSONANT_TO_VISEME = {
    "p": "PP",  "b": "PP",  "m": "PP",
    "py": "PP", "by": "PP", "my": "PP",
    "f": "FF",
    "s": "SS",  "z": "SS",  "sh": "SS",
    "t": "DD",  "d": "DD",  "ts": "DD",
    "k": "kk",  "g": "kk",  "ky": "kk", "gy": "kk",
    "ch": "CH", "j": "CH",
    "n": "nn",  "ny": "nn",
    "r": "RR",  "ry": "RR",
    "h": "sil", "hy": "sil", "w": "sil", "y": "sil",
}

時間軸はミリ秒でサーバから送り、ブラウザ側で audioCtx.currentTime(秒)と比較するため /1000 変換するのが地味なハマりポイントです(README にもわざわざ書いてある)。

VRM ビューアの演出

背景ランダムローテーション

  • 画像は ~/AIzunda/images/*.{jpg,png,webp} を自動検出
  • GET /images_list で一覧、GET /images/<name> で配信
  • ページ読み込み時に 1 枚選び、5 分ごとに別画像へランダム切替
  • 画像はリポジトリに同梱しません。追加するならディレクトリに放り込むだけで OK(サーバ再起動不要)

Idle モーション

T-pose 棒立ちを避けるため、毎フレーム微小な回転を加えています(zundamon.html:applyIdlePose)。

部位 周波数 振幅
spine / chest(X 軸、呼吸) 0.25 Hz ±0.7°
spine / chest(Z 軸、左右揺れ) 0.13 Hz(位相違い) ±1.1°
head(X 軸) 0.10 Hz ±0.9°
head(Y 軸) 0.08 Hz ±1.7°

vrm.update(delta) の前にポーズを当てているので、VRM の spring bones(髪・スカート等)が自然に二次追従します。

両手を下ろす

VRM のデフォルトは T-pose なので、ロード直後に applyRestPose() で両腕を自然立ちの位置に落とし、肘も約 14° 曲げています。

既知の制約

  • WhisperX は 60 秒超で GPU memory fault(ROCm 7.x + PyTorch nightly の既知問題)。vtt は VAD で 55 秒に強制カットして回避しています。ブラウザ側の録音も長尺は避けてください。
  • 無音発話で以前 500 エラー が出ていましたが、Silero VAD が "No active speech" を返したときの WhisperX IndexError_transcribe_path で捕捉して空文字に落とすように修正済み。
  • VOICEVOX は CPU 推論。ROCm との VRAM 競合を避けるための選択。短文なら十分リアルタイム、長文では合成が律速になることがあります。
  • Chrome の AudioContext は初回クリック必須(user-gesture 要件)。
  • Qwen3 の thinkingttllm 経由では常に OFF ですが、llama-server を直叩きする場合は chat_template_kwargs を自分で付ける必要があります。

トラブルシュート

現象 対処
マイク を押しても無音 画面をクリックして AudioContext を有効化。ブラウザの mic 権限も確認
ずんだもんが喋らない / 500 エラー tmux attach -t aizundattllm のログ確認。curl :8001/health で llama 到達性もチェック
初回発話が遅い curl -X POST :8001/warmup で WhisperX 先読み
腕の向きがおかしい(VRM 差し替え時) zundamon.html:applyRestPoserotation.z 符号を反転
背景が切り替わらない DevTools console で /images_list のレスポンスを確認。画像を置いたらブラウザリロード
VRM が読めない server.pyVRM_DIR と実ファイルパスを確認
全部止めたい ~/AIzunda/stop_all.sh

拡張の余地

  • 会話履歴の保持(現在は毎ターンステートレス、history パラメタで渡すだけ)
  • VRMA 形式の idle アニメ読み込み(現在はプロシージャル)
  • VOICEVOX を GPU ビルドに差し替え(長文応答の合成を高速化)
  • smaller STT model への切替(medium で 200〜300 ms 短縮可能)
  • LLM ストリーミング中の手振りジェスチャ連動
  • LLM プロンプトに <emotion>...</emotion> を出力させ、VRM 1.0 の happy / sad / angry 表情にマッピング

参考・ライセンス

各コンポーネントの README は GitHub 上で個別に読めます。

1
1
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
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?