はじめに
GMKtec NucBox EVO X2 などに搭載されている AMD Ryzen AI MAX+ 395 (Strix Halo / gfx1151, 統合 GPU 48GB VRAM) 上で、音声 → STT → LLM → TTS → VRM リップシンク を完全ローカル・クラウドAPI不使用で動かすパイプラインを作りました。ブラウザの 🎤 ボタンを長押しすると、VRoid で作ったオリジナルキャラ「コテコ」が VOICEVOX のずんだもん声 で返事してくれます。
リポジトリはこちら → kotetsuy/AIassistant
本記事は前後半の二部構成です。
-
前半:
git cloneから./start_all.shでコテコが喋るまでの手順 - 後半: アーキテクチャ・MoE と MTP のモデル選定・WhisperX turbo 切替の効果・LLM→TTS パイプライン化など、レイテンシ最適化の技術解説
主な最適化と実測値:
| 最適化 | 効果 |
|---|---|
| Qwen3 thinking モード OFF | LLM 段を 4〜8 秒短縮 |
| LLM → TTS streaming pipelining | 初音まで 3.32 s → 1.06 s |
WhisperX large-v3 → large-v3-turbo
|
転写 474 ms → 247 ms (-48%) |
| dense 27B+MTP → MoE 35B-A3B | TTFT・生成速度ともに約 4x |
2026-06 更新: 当初は dense な Qwen3.6-27B に MTP 投機デコード (
--spec-type draft-mtp) を併用していましたが、帯域が細い iGPU では MoE モデル (Qwen3.6-35B-A3B, アクティブ約 3B) の方が TTFT・生成速度ともに速いため、現構成では MoE に切り替えて MTP を外しました。MTP 構成の実測と切替の経緯は後半 §3 に残してあります。
第一部 — セットアップ手順
前提条件
| 項目 | バージョン / 状態 |
|---|---|
| マシン | GMKtec NucBox EVO X2 等 (Ryzen AI MAX+ 395, gfx1151, 48GB unified VRAM) |
| OS | Ubuntu 24.04.4 LTS |
| ROCm | 7.2.1 (/opt/rocm) |
| Python | 3.12.3 (Ubuntu 24.04 標準) |
| Docker | 29.x (VOICEVOX 用) |
| ブラウザ | Google Chrome (Firefox も可) |
| 周辺 | tmux / curl / uv / huggingface_hub (hf CLI) |
ROCm 7.2.1 を /opt/rocm にインストールしておき、HSA_OVERRIDE_GFX_VERSION=11.5.1 で gfx1151 を有効化する前提です。
1. リポジトリと依存物の取得
本体リポジトリには whisperX-rocm / llama.cpp / qwen3.6 をシンボリックリンクで参照する構造になっているので、まず本体と依存物を ホームディレクトリ直下 に並べて配置します。
cd ~
git clone https://github.com/kotetsuy/AIassistant.git
git clone https://github.com/ggml-org/llama.cpp.git
WhisperX の ROCm フォークと CTranslate2 の ROCm フォークも別途必要です:
mkdir -p ~/whisperx && cd ~/whisperx
git clone https://github.com/<your_whisperx_rocm_fork>/whisperX-rocm.git
git clone https://github.com/<your_ctranslate2_rocm_fork>/ctranslate2-rocm.git
実機では
whisperX-rocmを~/AIzunda/whisperX-rocmに置いていますが、新規構築する場合は~/whisperx/whisperX-rocmでも構いません。AIassistant 側のwhisperX-rocmは シンボリックリンク なので、リンク先は環境に合わせて貼り直してください。
2. CTranslate2-ROCm をソースビルド
faster-whisper が呼ぶ CTranslate2 を ROCm/HIP 対応でビルドします。
cd ~/whisperx/ctranslate2-rocm
mkdir -p build && cd build
export HSA_OVERRIDE_GFX_VERSION=11.5.1
export AMDGPU_TARGETS=gfx1151
cmake .. -DWITH_HIP=ON -DWITH_MKL=OFF -DWITH_OPENBLAS=ON \
-DCMAKE_HIP_ARCHITECTURES=gfx1151 -DCMAKE_BUILD_TYPE=Release \
-DOPENMP_RUNTIME=COMP \
-DCMAKE_HIP_COMPILER=/opt/rocm/lib/llvm/bin/clang++ \
-DCMAKE_CXX_COMPILER=/opt/rocm/lib/llvm/bin/clang++ \
-DCMAKE_C_COMPILER=/opt/rocm/lib/llvm/bin/clang \
-DCMAKE_PREFIX_PATH=/opt/rocm -DBUILD_CLI=OFF
make -j$(nproc) && sudo make install
/usr/local/lib/libctranslate2.so が入れば成功です。
3. WhisperX-ROCm 用の venv を作る
cd ~/whisperx/whisperX-rocm
uv venv && uv pip install -e .
# ROCm 版 ctranslate2 の Python バインディングを再インストール
rm -rf .venv/lib/python3.12/site-packages/ctranslate2*
export CTRANSLATE2_ROOT=/usr/local
uv pip install --reinstall pybind11 ~/whisperx/ctranslate2-rocm/python
確認:
.venv/bin/python -c "import torch; print('CUDA:', torch.cuda.is_available())"
# → CUDA: True (ROCm の HIP レイヤーが CUDA API を翻訳している)
.venv/bin/python -c "import ctranslate2; print(ctranslate2.__version__)"
4. llama.cpp を ROCm 対応でビルド
現構成の既定 LLM Qwen3.6-35B-A3B は MoE + Mamba ハイブリッド なので、対応済みの新しめの llama.cpp が必要です。master 最新を pull してビルドしてください。
cd ~/llama.cpp
git pull --ff-only origin master
mkdir -p build && cd build
export HSA_OVERRIDE_GFX_VERSION=11.5.1
export AMDGPU_TARGETS=gfx1151
cmake .. -DGGML_HIP=ON -DAMDGPU_TARGETS=gfx1151 \
-DCMAKE_HIP_COMPILER=/opt/rocm/lib/llvm/bin/clang++ \
-DCMAKE_BUILD_TYPE=Release
make -j$(nproc)
ビルド後の確認:
./bin/llama-server --version
# version: 9294 (...) など
いまの既定構成 (MoE) は MTP 投機デコードを使いません。dense な Qwen3.6-27B + MTP を試したい場合のみ、
--spec-type draft-mtp対応の master が必要です (./bin/llama-server --help | grep spec-typeにdraft-mtpが出れば対応版)。詳細は後半 §3 を参照。
5. Qwen3.6-35B-A3B (MoE) モデルをダウンロード
hf (huggingface CLI、旧 huggingface-cli) でモデルを取得します。Unsloth Dynamic の Q4_K_XL 量子化を使います:
mkdir -p ~/qwen3.6
hf download unsloth/Qwen3.6-35B-A3B-GGUF \
Qwen3.6-35B-A3B-UD-Q4_K_XL.gguf mmproj-F16.gguf \
--local-dir ~/qwen3.6
メインモデルは約 21GB、視覚エンコーダ (mmproj-F16.gguf、約 858MB) はマルチモーダルを使う場合のみ必要です。確認:
ls -lh ~/qwen3.6/Qwen3.6-35B-A3B-UD-Q4_K_XL.gguf
# 約 20.8 GiB
アクティブパラメータは 1 トークンあたり約 3B 相当なので、総 34.66B の割に高速です (Ryzen AI MAX+ 395 で tg128 ≈ 50 t/s)。後半 §3 で dense 27B + MTP 構成との比較を載せています。
6. AIassistant 内のシンボリックリンクを張る
~/AIassistant 配下から llama.cpp / whisperX-rocm / qwen3.6 を相対パスで参照できるようにします。
cd ~/AIassistant
ln -sf ../llama.cpp llama.cpp
ln -sf ../whisperx/whisperX-rocm whisperX-rocm
ln -sf ../qwen3.6 qwen3.6
ls -la
# llama.cpp -> ../llama.cpp
# qwen3.6 -> ../qwen3.6
# whisperX-rocm -> ../whisperx/whisperX-rocm
7. ttllm ブリッジの依存を venv に追加
cd ~/AIassistant/ttllm
./install.sh
fastapi / uvicorn / httpx / python-multipart / pydantic が WhisperX-ROCm の venv に追加 されます (専用 venv は作らず共有)。
8. VRM モデル (コテコ) を配置
VRoid Studio などで作った VRM 1.0 モデルを置きます:
mkdir -p ~/AIassistant/vroid
cp /path/to/your_avatar.vrm ~/AIassistant/vroid/koteko.vrm
ファイル名を変える場合は以下 2 箇所を合わせて書き換えてください:
# three-vrm/server.py
VRM_DIR = os.path.expanduser("~/AIassistant/vroid")
<!-- three-vrm/TalkingHead/zundamon.html -->
const VRM_URL = "http://localhost:8000/vrm/koteko.vrm";
9. VOICEVOX Docker を取得
docker pull voicevox/voicevox_engine:cpu-ubuntu20.04-latest
# 起動は start_all.sh が面倒を見るのでここでは pull だけで OK
CPU 推論版を使います。GPU は LLM + STT で埋めるので、TTS は CPU が無難。
10. 一括起動
cd ~/AIassistant
./start_all.sh
以下が直列で立ち上がり、HTTP health check で待ち合わせます:
- VOICEVOX (Docker, port 50021)
- llama-server (Qwen3.6-35B-A3B MoE, port 8080,
-fit off起動・--spec-typeなし) - ttllm ブリッジ (port 8001)
- WhisperX warmup (
POST /warmupを叩いて初回のモデルロードを済ませる) - three-vrm サーバ (port 8000)
- Chrome で
http://localhost:8000/zundamon.htmlを自動オープン - vtt (CLI PTT、任意)
全ウィンドウは tmux セッション aiassistant に入っているので、
tmux attach -t aiassistant # ログを見る
~/AIassistant/stop_all.sh # 全部止める
で操作できます。
11. 動作確認
ブラウザが開いたら 画面を一度クリック して AudioContext を有効化 (Chrome の user-gesture 要件)。右下の 🎤 ボタンの動線:
- 長押し (≥ 250 ms): 押している間だけ録音、離すと自動で送信
- 短クリック: 録音開始 → もう一度クリックで送信
ユーザー発話は薄青、コテコの返答は白の字幕として出ます。初音まで体感 1 秒前後で返ってくれば成功です。
第二部 — 技術解説
1. 全体アーキテクチャ
ブラウザ (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-turbo)
└─ llama-server (Qwen3.6-35B-A3B MoE, port 8080)
↓ SSE で token ストリーム
three-vrm: 文境界で分割 → VOICEVOX (port 50021) → WS 配信
↓ WS (audio + visemes)
ブラウザ: AudioContext 連続再生 + VRM リップシンク + 背景 + idle motion
主要コンポーネント:
| 層 | 採用技術 | 役割 |
|---|---|---|
| マイク I/O | MediaRecorder (Chrome, webm/opus) | ブラウザ側で録音 |
| STT | WhisperX-ROCm + CTranslate2 (faster-whisper) | 音声 → テキスト |
| LLM | llama.cpp (llama-server) + Qwen3.6-35B-A3B MoE |
応答生成 (アクティブ約 3B) |
| TTS | VOICEVOX Engine (CPU, Docker) | テキスト → WAV + accent_phrases |
| 表示 | three-vrm 1.0 + @pixiv/three-vrm | VRM のロード・リップシンク・idle motion |
| 中継 | three-vrm Python サーバ (aiohttp) | WS で audio + viseme を broadcast |
| ブリッジ | ttllm (FastAPI) | WhisperX と llama-server を一本化 |
全プロセスはローカル完結。外部 API は一切叩きません。
2. Qwen3 thinking モードを切る
Qwen3 系は既定で 返答前に数百トークンの reasoning_content (内部独白) を吐きます。これがそのまま体感遅延に直結 (4〜8 秒) するので、ttllm から llama-server に渡す chat completion リクエストで 無効化 しています。
# ttllm/server.py
payload = {
"messages": messages,
...
"chat_template_kwargs": {"enable_thinking": False},
}
llama-server 側がこれを chat template に流し込み、Qwen3 の jinja template が <think> ブロックをスキップする経路に入ります。1 行追加で大きな差が出るのでまず最初に入れるべき最適化です。
3. MTP と MoE の選択
現構成の既定 LLM は Qwen3.6-35B-A3B (MoE, Q4_K_XL, 約 21GB) で、start_all.sh はこれを -fit off で起動します (--spec-type は付けません)。これは当初使っていた dense な Qwen3.6-27B + MTP 投機デコード から切り替えたものです。なぜ切り替えたのか、経緯と実測を残しておきます。
旧構成: Qwen3.6-27B + MTP 投機デコード
MTP (Multi-Token Prediction) は DeepSeek-V3 が導入したアーキ機能で、本体モデルに加えて MTP ヘッドが追加トークンを予測 する仕組み。投機的デコード (speculative decoding) における draft モデルを「同じモデルの末端に組み込んだ追加層」で実現するため、別ファイルの draft モデルが要らないのが強みです。
Qwen3.6-27B には MTP 層が 1 つ付属しており (gguf_dump.py の出力に nextn_predict_layers = 1 が出る)、llama.cpp の --spec-type draft-mtp で投機的デコードが効きます。サポートは PR #22673 でマージされました。
llama-server \
-m ~/qwen3.6/Qwen3.6-27B-MTP-Q8_0.gguf \
--host 127.0.0.1 --port 8080 \
-ngl 99 -c 8192 \
--spec-type draft-mtp
実測 (同じ gguf、同一プロンプト、142 トークン生成、温度 0.7、seed 42):
| 指標 | MTP なし | MTP 有効 | 改善 |
|---|---|---|---|
| 生成 tokens/sec | 7.71 | 10.15 | +31.7% (1.32x) |
| 142 トークン応答時間 | 18.42 s | 13.99 s | -24% |
| TTFT (初トークン) | 0.46 s | 0.48 s | ≒ 同等 |
| Draft acceptance | — | 24.7% (60/243) | — |
重要な注意: MTP は 生成中の速度 を上げる仕組みで、TTFT (初トークン到達時間) は変わりません。よって「初音までの時間」(streaming pipelining で 1.06 s 達成) は MTP では短縮されず、効果が出るのは「長文応答の完走時間」だけです。短い応答ほど効果が薄れます。
現構成に切り替えた理由: 帯域が細い iGPU では MoE が断然速い
MTP は draft が当たった分だけステップを進める仕組みなので、そもそも 本体の生成が遅いと頭打ち になります。dense な 27B は帯域が細い iGPU では重く、MTP を足しても 10 tok/s 程度が限界でした。
そこで、1 トークンあたりアクティブ約 3B パラメータのみの Qwen3.6-35B-A3B (MoE, Q4_K_XL, 約 21GB) に切り替えると、MTP を使わなくても劇的に速くなります:
| 指標 | dense 27B | MoE 35B-A3B | 改善 |
|---|---|---|---|
| TTFT | 約 360 ms | 約 88 ms | 約 4x |
| 生成 tokens/sec | 約 5.0 | 約 19.8 | 約 4x |
MoE は TTFT・生成速度の両方で dense + MTP を上回るため、現構成では MTP を外しました。start_all.sh の llama-server 起動は次のとおりです (draft モデルも --spec-type も不要):
llama-server \
-m ~/qwen3.6/Qwen3.6-35B-A3B-UD-Q4_K_XL.gguf \
--host 127.0.0.1 --port 8080 \
-ngl 99 -c 8192 -fit off
dense 27B + MTP を再評価したい場合は、
--spec-type draft-mtp対応の llama.cpp master をビルドし、Qwen3.6-27B-MTP-Q8_0.gguf(MTP 層入り、約 29GB) を使ってください。MTP 層はgguf_dump.py --no-tensors ... | grep nextnでqwen35.nextn_predict_layers = 1が出るかで確認できます。
4. WhisperX を large-v3 → large-v3-turbo
STT 段は WHISPER_MODEL 環境変数で切り替えるだけです。
# ttllm/server.py
WHISPER_MODEL = os.getenv("WHISPER_MODEL", "large-v3-turbo")
実測ベンチ
/warmup 済みの steady state で 2.56 秒の音声サンプル (VOICEVOX で合成した「こんにちは、ずんだもんなのだ」) を 5 回転写:
| 指標 | large-v3 | large-v3-turbo | 改善 |
|---|---|---|---|
| 転写時間 (steady median) | 474 ms | 247 ms | -48% (1.92x) |
| 転写時間 (cold first) | 664 ms | 440 ms | -34% |
| モデルロード | 6.51 s | 4.83 s | -26% |
「最初の発話」への効果
start_all.sh は起動時に POST /warmup を叩いて WhisperX をロード済みにするので、ユーザーが最初に喋ったときは すでに warmup 済みの steady state から始まります。よって短縮量は 227 ms (= 474 - 247)。これは MTP と違って TTFT に直接効きます。
認識精度は短文・日常会話では差なし。長尺・複雑なオーディオは未検証なのでケースによっては large-v3 に戻す価値があるかもしれません。
5. LLM → VOICEVOX の streaming pipelining
非ストリーミング (/voice_chat) では LLM 全体の応答テキストを完全に受信してから VOICEVOX に送るので、長文だと数秒の "無音" が発生します。これを解決するのが /voice_chat_speak_stream パイプライン です。
サーバ側 (ttllm)
llama-server を stream: true で呼び、SSE で {transcript} → {token}×N → {done} を流します。
# ttllm/server.py 抜粋
async def _stream_llama(...):
async with httpx.AsyncClient(timeout=LLAMA_TIMEOUT) as client:
async with client.stream("POST",
f"{LLAMA_SERVER_URL}/v1/chat/completions",
json={**payload, "stream": True}) as r:
async for line in r.aiter_lines():
if line.startswith("data:"):
yield line[5:].lstrip()
中継側 (three-vrm)
SSE を受けながら [。!?\n] で文分割 し、文ごとに VOICEVOX を非同期で呼びます。VOICEVOX 合成は asyncio.Queue + consumer task で 直列化 して WS の送信順序を保証しつつ、LLM デコードはバックグラウンドで並列継続:
# three-vrm/server.py 抜粋
sentence_buf = ""
tts_queue: asyncio.Queue = asyncio.Queue()
async def tts_worker():
while True:
text, seq = await tts_queue.get()
wav, accents = await synthesize(text, speaker_id)
visemes, vtimes, vdurations = mora_to_visemes(accents)
await ws.send_json({
"type": "speak",
"audio": base64_wav,
"visemes": visemes,
"vtimes": vtimes,
"vdurations": vdurations,
"seq": seq,
})
async for tok in stream_llama(...):
sentence_buf += tok
# 60 文字超は読点 [、] でも切る (長文保険)
while m := re.search(r"[。!?\n]|.{60,}?[、]", sentence_buf):
await tts_queue.put((sentence_buf[:m.end()], next(seq)))
sentence_buf = sentence_buf[m.end():]
クライアント側 (ブラウザ)
各 speak チャンクを startAt = max(playheadTime, audioCtx.currentTime + 0.02) で キュー末尾に連続再生。viseme は絶対時刻でスケジュールされるので複数チャンクが連続しても干渉しません。
const audioBuf = await audioCtx.decodeAudioData(bytes.buffer.slice(0));
const startAt = Math.max(playheadTime, audioCtx.currentTime + 0.02);
const src = audioCtx.createBufferSource();
src.buffer = audioBuf;
src.connect(audioCtx.destination);
src.start(startAt);
playheadTime = startAt + audioBuf.duration;
const offsetMs = (startAt - audioCtx.currentTime) * 1000;
for (let i = 0; i < visemes.length; i++) {
visemeSchedule.push({
time: startAt + vtimes[i] / 1000,
name: VISEME_MAP[visemes[i]] ?? null,
});
}
実測ベンチ
長文 8 文の応答で:
| 指標 | 改善前 (非ストリーミング) | 改善後 (pipeline) |
|---|---|---|
| 初音までの時間 | 3.32 s | 1.06 s |
| 全体完了時間 | 3.32 s | 2.98 s |
初音まで 3 倍速 になっており、これが最もユーザー体感に効きます。
6. VOICEVOX moras → ARKit viseme への変換
VOICEVOX は /audio_query で モーラ単位の発音情報 (子音/母音、長さ) を返してくれます:
{
"moras": [
{"text": "コ", "consonant": "k", "consonant_length": 0.07,
"vowel": "o", "vowel_length": 0.08},
{"text": "テ", "consonant": "t", "consonant_length": 0.05,
"vowel": "e", "vowel_length": 0.08},
...
]
}
これを ARKit/Apple の音素 (aa/ih/ou/ee/oh/nn/PP/FF/...) に変換し、@pixiv/three-vrm の expressionManager に渡します。
# three-vrm/server.py
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",
"f": "FF",
"s": "SS", "z": "SS", "sh": "SS",
"t": "DD", "d": "DD", "ts": "DD",
"k": "kk", "g": "kk",
"ch": "CH", "j": "CH",
"n": "nn", "ny": "nn",
"r": "RR", "ry": "RR",
"h": "sil", "hy": "sil", "w": "sil", "y": "sil",
}
子音には consonant_length を、母音には vowel_length をそれぞれ独立した viseme として送るので、「ka」は kk (短) → aa (長) の 2 段階で口が動きます。
ブラウザ側は VRM 1.0 の expression name (aa / ih / ou / ee / oh / nn 等) にマッピングし直し、expressionManager.setValue("aa", 1.0) で適用します。
サーバが送る
vtimes/vdurationsは ミリ秒 ですが、ブラウザ側はaudioCtx.currentTime(秒) と比較するため/1000変換が必須。ここを忘れると口がぴくりとも動きません。
7. Idle モーション (プロシージャル)
VRM ロード直後は T-pose 棒立ちで違和感があるので、毎フレーム微小回転を加えています。三角関数で複数周波数を重ねるだけのプロシージャルアニメ:
const IDLE = {
breathHz: 0.25, // 胸の上下動
swayHz: 0.13, // 体の左右揺れ
headHz: 0.08, // 頭の緩やかな振り
};
function applyIdlePose(t) {
const spine = vrm.humanoid.getNormalizedBoneNode("spine");
const chest = vrm.humanoid.getNormalizedBoneNode("chest");
const head = vrm.humanoid.getNormalizedBoneNode("head");
const breath = Math.sin(t * 2 * Math.PI * IDLE.breathHz);
const sway = Math.sin(t * 2 * Math.PI * IDLE.swayHz);
if (spine) {
spine.rotation.x = breath * 0.012; // 約 ±0.7°
spine.rotation.z = sway * 0.020; // 約 ±1.1°
}
if (head) {
head.rotation.y = Math.sin(t * 2 * Math.PI * IDLE.headHz) * 0.030;
}
}
ポイントは vrm.update(delta) の 前 にポーズを設定すること。これにより VRM の spring bone (髪・スカート等) が自然な二次追従モーションを生成します。VRMA 形式のアニメーションを読まずとも十分自然な待機モーションが得られます。
8. 新ターン開始時に前の発話を即停止
UX 上、ユーザーが新しいマイク入力を始めた瞬間に前の応答音声がまだ流れているとストレスです。マイクボタンが押された 時点で (サーバの turn_start を待たずに) クライアントが全 AudioBufferSourceNode を stop(0) し、viseme キューも消します:
function stopAllPlayback() {
for (const s of activeSources) {
try { s.stop(0); } catch {}
try { s.disconnect(); } catch {}
}
activeSources = [];
visemeSchedule = [];
playheadTime = audioCtx ? audioCtx.currentTime : 0;
}
micBtn.addEventListener("pointerdown", () => {
stopAllPlayback(); // 先に止める
startRecording();
});
サーバ往復を待たないので体感が即応します。
9. 背景ランダムローテーション
~/AIassistant/images/*.{jpg,png,webp} を自動検出し、5 分ごとにランダム切替:
# three-vrm/server.py
async def images_list_handler(request):
files = sorted(f for f in os.listdir(IMAGES_DIR)
if f.lower().endswith((".jpg",".jpeg",".png",".webp",".gif")))
return web.json_response({"images": [f"/images/{f}" for f in files]})
// three-vrm/TalkingHead/zundamon.html
const BG_INTERVAL_MS = 5 * 60 * 1000;
fetch("/images_list").then(r => r.json()).then(d => {
bgList = d.images;
swapBackground();
setInterval(swapBackground, BG_INTERVAL_MS);
});
<body> の background-image を更新するだけ。新しい画像はディレクトリに放り込めばリロードで反映 (サーバ再起動不要)。
10. 既知の制約
| 現象 | 状況 / 回避策 |
|---|---|
| WhisperX が 60 秒超の音声で GPU memory fault | ROCm 7.x + PyTorch nightly の既知問題。vtt 側は VAD で 55 秒に強制カットして回避。ブラウザ録音も長尺は避ける |
| Silero VAD が "No active speech" を返すと WhisperX が IndexError |
_transcribe_path で捕捉して空文字に落とす |
| VOICEVOX が CPU 推論 | GPU を LLM + STT で使い切るための選択。短文は十分リアルタイム、長文は合成律速になる可能性 |
Chrome AudioContext の初回 user-gesture 要件 |
画面を一度クリックする必要あり (UI で案内表示) |
| llama-server を 直叩き すると thinking が ON のまま | ttllm 経由なら強制 OFF。直叩き時は chat_template_kwargs を自分で渡す必要 |
トラブルシューティング
| 症状 | 対処 |
|---|---|
| 🎤 を押しても無音 | 画面をクリックして AudioContext を有効化。ブラウザの mic 権限も確認 |
| コテコが喋らない / 500 エラー |
tmux attach -t aiassistant で ttllm のログ確認、curl :8001/health で llama 到達性チェック |
| 初回発話が遅い |
curl -X POST :8001/warmup で WhisperX 先読み |
| 腕の向きがおかしい (VRM 差し替え時) |
zundamon.html:applyRestPose の rotation.z 符号を反転 |
| 背景が切り替わらない | DevTools console で /images_list のレスポンス確認。画像を置いたらブラウザリロード |
| VRM が読めない |
server.py の VRM_DIR と zundamon.html の VRM_URL を確認 |
| 全部止めたい | ~/AIassistant/stop_all.sh |
まとめ
AMD Ryzen AI MAX+ 395 (gfx1151) の 48GB unified VRAM の上で、
- Qwen3.6-35B-A3B (MoE, アクティブ約 3B) を llama.cpp で推論 (dense 27B+MTP 比で TTFT・生成速度ともに約 4x)
- WhisperX-ROCm で日本語 STT (large-v3-turbo で -48% 短縮)
- VOICEVOX で TTS (CPU だが短文には十分)
- three-vrm でブラウザに口パク VRM 表示
これらをすべて ローカル完結・streaming pipelining 化 することで、ユーザー視点では「マイクを押して話す → 1 秒前後で VRM アバターがずんだもん声で返事を始める」という体験を実現しています。
クラウド API 課金もネットワーク往復もなく、データも一切外に出ません。手元で完結する「声で話せるアシスタント」のリファレンス実装としてどうぞ。
ライセンスと音声クレジット:
- 本体コード: MIT
- 音声 (VOICEVOX): VOICEVOX:ずんだもん (利用規約)
- VRM: VRoid Studio で作成したオリジナルキャラ「コテコ」