A fully local stack that runs voice → STT → LLM → TTS → VRM lip-sync end-to-end on Ubuntu + AMD Ryzen AI Max+ 395 (ROCm). Click the 🎤 button in the browser and Zundamon answers you by voice.
Browser (three-vrm)
└─ Mic capture (MediaRecorder webm/opus)
↓ POST /voice_chat_speak_stream
three-vrm server (port 8000)
↓ POST /voice_chat_stream
ttllm bridge (port 8001)
├─ WhisperX-ROCm (STT, large-v3)
└─ llama-server (Qwen3.6-35B-A3B, port 8080)
↓ SSE token stream
three-vrm: split at sentence boundaries → VOICEVOX (port 50021) → WS broadcast
↓ WS (audio + visemes)
Browser: AudioContext continuous playback + VRM lip-sync + background + idle motion
| Path | Role | Port |
|---|---|---|
voicevox/ |
VOICEVOX Engine (Docker, CPU inference) | 50021 |
~/AIzunda/llama.cpp/build/bin/llama-server |
Qwen3.6 inference | 8080 |
qwen3.6/Qwen3.6-35B-A3B-UD-Q4_K_XL.gguf |
LLM weights | — |
ttllm/ |
FastAPI bridge (WhisperX + llama.cpp) | 8001 |
three-vrm/ |
aiohttp server + VRM viewer (HTML/three-vrm) | 8000 |
vtt/ |
CLI PTT mic (optional) | — |
images/ |
VRM viewer background (rotates every 5 min) | — |
zundavrm/VRM/Zundamon_2025_VRM10A.vrm |
Zundamon VRM 1.0 model | — |
whisperX-rocm/ |
WhisperX ROCm fork (venv at ~/AIzunda/whisperX-rocm/.venv) |
— |
- 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 (for VOICEVOX)
- Browser: Google Chrome (Firefox also works since
AudioContextis supported) - tmux / curl: used by the launch script
See each subdirectory's README.md for detailed setup:
ttllm/README.md / vtt/README.md / three-vrm/README.md / voicevox/README.md /
whisperX-rocm/README.md.
~/AIzunda/start_all.sh # start everything + health checks + WhisperX warmup + open Chrome
~/AIzunda/stop_all.sh # stop the tmux session + VOICEVOX
~/AIzunda/stop_all.sh --keep-voicevox # leave the VOICEVOX container runningstart_all.sh creates a tmux session named aizunda and runs each service in its own window.
| window | command |
|---|---|
| 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, optional) |
Watch logs: tmux attach -t aizunda
Shut everything down: ~/AIzunda/stop_all.sh
Startup order is serialized by dependency, with an HTTP health-check wait between
stages (the llama-server model load has a 600-second timeout). Right after ttllm
comes up, the script posts to /warmup to preload the WhisperX model so the
first utterance isn't slow.
start_all.shautomatically opens Chrome athttp://localhost:8000/zundamon.html- Click the page once to unlock AudioContext (browser user-gesture requirement)
- The 🎤 button at bottom-right
- Long-press (≥ 250 ms): records only while held, sends on release
- Short click: starts recording → click again to send
- User speech appears as pale-blue subtitles; Zundamon's reply as white subtitles
Short utterances (e.g. "hello") land at ~1 s of perceived latency; even long replies target first audio in ~1 second.
By default, Qwen3 emits several hundred tokens of reasoning_content (internal
monologue) before the reply, costing multiple seconds of felt latency. ttllm
passes chat_template_kwargs: {"enable_thinking": false} to llama-server to
disable it (ttllm/server.py:_call_llama). This one line alone shaves 4–8 s off
the LLM stage.
- ttllm adds
/voice_chat_stream(SSE) and calls llama-server withstream: true, returning{transcript}→{token}×N→{done}. - three-vrm's
/voice_chat_speak_streamconsumes the SSE. It splits on[。!?\n]; as a long-line fallback, anything over 60 chars is also split on[、]. TTS is serialized viaasyncio.Queue+ consumer task (to preserve WS ordering) while LLM decoding continues in parallel. - The client resets its playhead on
turn_startand queues eachspeakchunk atstartAt = max(playheadTime, now)so chunks play back-to-back. Visemes are scheduled at absolute timestamps, so they don't interfere across chunks.
Measured result (long 8-sentence reply):
| Metric | Before (non-streaming) | After (pipelined) |
|---|---|---|
| Time to first audio | 3.32 s | 1.06 s |
| Total completion | 3.32 s | 2.98 s |
When the mic is pressed, the client calls stop(0) on every scheduled
AudioBufferSourceNode and clears the viseme queue (stopAllPlayback). It
doesn't wait for the server's turn_start to arrive, so the response feels
instant.
- Images are auto-discovered from
~/AIzunda/images/*.{jpg,png,webp} GET /images_listreturns the file list;GET /images/<name>serves them- One is picked at page load; every 5 minutes it switches to another random image (
zundamon.html) - No images are bundled. Drop more images into the directory to add them (no server restart required)
To avoid a stiff T-pose, a small rotation is applied each frame
(zundamon.html:applyIdlePose).
| Part | Frequency | Amplitude |
|---|---|---|
| spine / chest (X, breathing) | 0.25 Hz | ±0.7° |
| spine / chest (Z, sway) | 0.13 Hz (phase-shifted) | ±1.1° |
| head (X) | 0.10 Hz | ±0.9° |
| head (Y) | 0.08 Hz | ±1.7° |
Since the pose is set before vrm.update(delta), the VRM's spring bones
(hair, skirt, etc.) follow naturally as secondary motion.
The VRM default is T-pose, so right after load applyRestPose() drops both arms
to a natural standing position and bends the elbows about 14° (zundamon.html).
| Method | Path | Purpose |
|---|---|---|
| GET | /health |
Self + llama-server reachability |
| POST | /warmup |
Preload WhisperX model |
| POST | /transcribe |
Audio → text |
| POST | /chat |
Text → LLM reply (non-streaming) |
| POST | /voice_chat |
Audio → reply (non-streaming) |
| POST | /voice_chat_stream |
Audio → SSE (transcript + token + done) new |
| Method | Path | Purpose |
|---|---|---|
| GET | /zundamon.html |
Viewer |
| GET | /ws |
WebSocket (turn_start / speak / turn_end / transcript / error) |
| POST | /speak |
Speak a given text |
| POST | /voice_chat_speak |
Audio → one-shot reply (non-streaming) |
| POST | /voice_chat_speak_stream |
Audio → pipelined reply new |
| GET | /images_list |
Background image list |
| GET | /images/{name} |
Serve a background image |
| GET | /vrm/{name} |
Serve the VRM file |
| GET | /status |
Number of connected clients |
- WhisperX segfaults on GPU for audio longer than 60 s (known issue with ROCm 7.x + PyTorch nightly). The vtt CLI hard-caps each utterance at 55 s via VAD to work around this; avoid long recordings from the browser too.
- Empty-audio utterances previously 500'd. When Silero VAD returned
"No active speech", WhisperX raised IndexError. Now caught in
_transcribe_pathand mapped to an empty string (ttllm/server.py). - VOICEVOX runs on CPU deliberately, to avoid VRAM contention with ROCm. Fine for short text in real-time; long replies may become synthesis-bound.
- Chrome's AudioContext requires an initial click (user-gesture requirement).
- Qwen3 thinking is always off when you go through ttllm, but if you hit
llama-server directly you need to pass
chat_template_kwargsyourself.
All hardcoded paths in shell scripts and Python have been replaced with
$USER / os.path.expanduser("~/..."). No /home/<someone> remains. To run
as a different user, just keep the directory layout (~/AIzunda/,
~/AIzunda/llama.cpp/, ~/AIzunda/whisperX-rocm/.venv/) and it works.
| Symptom | Fix |
|---|---|
| 🎤 press produces no audio | Click the page to unlock AudioContext. Check mic permissions in the browser |
| Zundamon stays silent / 500 errors | tmux attach -t aizunda to read ttllm logs. curl :8001/health to confirm llama reachability |
| First utterance is slow | curl -X POST :8001/warmup to preload WhisperX |
| Arms point the wrong way (after swapping VRM) | Flip the sign of rotation.z in zundamon.html:applyRestPose |
| Background doesn't rotate | Check /images_list in DevTools. Reload the browser after dropping in new images |
| VRM fails to load | Verify server.py's VRM_DIR matches your filesystem. The filename must match VRM_URL in zundamon.html |
| Stop everything | ~/AIzunda/stop_all.sh |
The goal: a cloud-free "Zundamon you can talk to", running fully locally on a single AMD Ryzen AI Max+ 395 + ROCm machine. With Qwen3.6-35B-A3B thinking-mode disabled and the LLM→TTS pipeline, first audio lands in about a second, and minimal code gives a non-jarring idle motion and background presentation.
Room for extension:
- Conversation history (currently stateless per turn — just pass
history) - VRMA-format idle animation loading (currently procedural)
- Swap VOICEVOX for a GPU build (speeds up synthesis of long replies)
- Switch to a smaller STT model (medium can trim 200–300 ms)
- Gesture syncing during LLM streaming