Critical frontend bugs: - Add TrackSubscribed/attach() for agent audio playback - Fix decodeToString TypeError with TextDecoder - XSS fix: innerHTML -> textContent in addMessage - Fresh token on reconnect retry Agent fixes: - GemmaLLM subclass with reasoning_content fallback wrapper - Disable Gemma 4 thinking mode via chat_template_kwargs (6.8s -> 0.5s) - Remove duplicate session-level LLM - Replace global _active_session with closure-based handler - asyncio.create_task instead of deprecated get_event_loop - Explicit silero VAD, topic filter on voice-control Infra: - supervisord: all programs log to /dev/stdout - Dockerfile: uv sync --frozen with committed uv.lock - nginx config moved to real file, token_server.py no longer served - entrypoint.sh: cert persisted, only regenerated on IP change - compose: healthcheck + cert volume - token_server: CORS removed, room pinned to voice-room UI upgrade: - Orb UI with state machine (idle/connecting/listening/thinking/speaking) - Streaming transcripts via lk.transcription text streams - Barge-in hint, thinking chip, audio visualizer - Glassmorphism, chat bubbles, settings sheet, light mode - PWA manifest, favicon, wake-lock, safe-area insets - localStorage conversation history Docs: AGENTS.md drift fixed
18 KiB
UPDATE — Deep Review: Why the Voice Assistant Doesn't Talk Back
Date: 2026-08-22 Scope: full code review of the LiveKit + Azure Speech + Gemma voice stack, root-cause analysis of the "mic moves but no transcript / no speech" failure, plus hardening and product/UX recommendations.
This is a spec only. No code changes have been made.
1. Executive Summary
The backend is essentially fine. I verified inside the built image (voice-voice:latest) that agent.py imports cleanly against livekit-agents 1.7.0, the full AgentSession (Azure STT + Azure TTS + local silero VAD + local turn detector) constructs without error, the Gemma endpoint at 192.168.86.2:8023 answers chat and tool calls, and the local EOT/VAD models are bundled in the wheel (no runtime downloads needed).
The product is broken by two front-end bugs in web/app.js. Both are confirmed, both are fatal, and together they produce exactly the reported symptoms (connects, mic meter moves, but no transcript and no audio reply):
-
The agent's audio is never played.
app.jsnever handlesRoomEvent.TrackSubscribedand never callstrack.attach(). WithautoSubscribe: truethe browser receives the agent's TTS track, but the livekit-client SDK does not auto-play audio — you must attach it to a media element. The agent may be speaking perfectly; the user will never hear a single sample. -
Transcripts are silently discarded.
app.js:83callspayload.decodeToString(). In the vendored livekit-client 2.13.0 UMD bundle, theDataReceivedpayload is a plainUint8Array— there is nodecodeToStringmethod anywhere in the bundle (verified: zero grep hits). The call throwsTypeErroron every data packet, and the surroundingtry/catchswallows it, so nothing ever renders.
Fix those two lines-of-code-level issues and the product should work end to end.
2. Root Causes — Confirmed Bugs (fix these first)
2.1 No audio playback path (web/app.js) — CRITICAL
Symptom: agent never "speaks back."
Evidence: no TrackSubscribed handler and no attach() call anywhere in app.js; no <audio> element in index.html.
Fix (in createRoom):
newRoom.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
if (track.kind === LivekitClient.Track.Kind.Audio) {
const el = track.attach(); // creates an <audio> element, autoplays
el.id = `audio-${participant.identity}`;
document.body.appendChild(el);
}
});
newRoom.on(RoomEvent.TrackUnsubscribed, (track) => {
track.detach().forEach((el) => el.remove());
});
Also required — browser autoplay policy: even attached audio can be blocked if the context isn't "unlocked." The Start button click is a user gesture, so call await room.startAudio() right after connect() resolves, and handle the recovery case:
newRoom.on(RoomEvent.AudioPlaybackStatusChanged, () => {
if (!newRoom.canPlaybackAudio) {
// show a "tap to enable audio" button that calls room.startAudio()
}
});
This matters most on iOS Safari, which the mic-meter comment in the code says is a target device.
2.2 payload.decodeToString() does not exist (web/app.js:83) — CRITICAL
Symptom: no transcripts appear for either side of the conversation.
Evidence: livekit-client 2.13.0 delivers DataReceived payloads as Uint8Array; decodeToString has zero occurrences in the vendored bundle. The TypeError is swallowed by the catch (e) { /* ignore */ } block.
Fix:
newRoom.on(RoomEvent.DataReceived, (payload, participant, kind, topic) => {
try {
const msg = JSON.parse(new TextDecoder().decode(payload));
if (msg.type === "transcript") addMessage(msg.role || "agent", msg.text);
} catch (e) {
console.warn("bad data packet", e); // don't swallow silently
}
});
Better long-term fix: drop the custom transcript data channel entirely. livekit-agents 1.x already publishes live transcriptions (including streaming partials) over text streams on the lk.transcription topic. The UI can consume them with:
room.registerTextStreamHandler("lk.transcription", async (reader, participantInfo) => {
const text = await reader.readAll();
// reader.info.attributes["lk.transcribed_track_id"] distinguishes user vs agent
});
That removes ~40 lines of custom code on each side and gives you word-by-word streaming transcripts for free (much better UX than the current end-of-utterance-only rendering).
3. Secondary Issues (likely to bite next, in priority order)
3.1 reasoning_content handling is documented but not implemented (agent/agent.py)
AGENTS.md claims: "The agent handles this by using max_tokens=1000 and falling back to reasoning_content if content is empty." Neither exists in the current code. This is a doc/code regression.
I verified against the live Gemma endpoint: with tools attached, the model returns "content": "" plus a populated "reasoning_content" field. If Gemma ever finishes a turn with all its tokens in reasoning_content and an empty content (which reasoning models do, especially with small max_tokens), the agent will speak nothing — a second, intermittent "silent agent" failure mode that will look identical to bug 2.1 from the user's chair.
Fix: restore the documented behavior — either pass max_tokens / a reasoning_effort-suppressing option through openai.LLM(...), or wrap the LLM node to fall back to reasoning_content when content is empty. Then update AGENTS.md to match whichever is implemented.
3.2 Duplicate, conflicting LLM instances (agent/agent.py)
VoiceAssistant.__init__ builds one openai.LLM(...) and handle_job builds a second identical one for the session. Agent-level components override session-level ones, so the session's llm= is dead weight. Keep exactly one (recommend: agent-level, delete the session one) so future config edits can't silently apply to the ignored copy.
3.3 Global _active_session (agent/agent.py)
- It's never cleared when a session ends, so a voice-change message after a job finishes pokes a dead session.
- If two rooms are ever dispatched, the second clobbers the first and voice changes route to the wrong room.
Fix: make the data handler a closure inside handle_job capturing its own session, and register it per-room. Delete the global.
3.4 asyncio.get_event_loop() is deprecated (agent/agent.py:210-212)
Inside the sync conversation_item_added callback, use asyncio.create_task(...) (a running loop is guaranteed there). get_event_loop() in Python 3.12 emits deprecation warnings and will eventually raise.
3.5 Agent logs are invisible to docker compose logs (supervisord.conf)
All four programs write to files under /var/log/supervisor/ inside the container. docker compose logs -f (which AGENTS.md tells you to use — and even names a nonexistent agent service) shows nothing. This makes every future debugging session harder than it needs to be.
Fix: in each [program:x] block:
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
redirect_stderr=true
And fix AGENTS.md: it's docker compose logs -f voice (single service), or docker exec voice tail -f /var/log/supervisor/agent.log today.
3.6 Dockerfile ignores agent/pyproject.toml
The build stage copies pyproject.toml and then hand-installs a separate list with uv pip install "livekit-agents[mcp]~=1.7" .... Two sources of truth, no lockfile — dependency drift is guaranteed and rebuilds aren't reproducible. Fix: uv sync against the pyproject with a committed uv.lock, or delete the pyproject dependency list. ~=1.7 also permits silent minor-version jumps on rebuild; pin exact versions in the lock.
3.7 Self-signed cert regenerated on every container start (entrypoint.sh)
Every restart mints a new key/cert, so every device re-prompts the scary TLS warning after each deploy. Fix: persist /etc/voice/certs in a volume and only generate when missing (or when the LAN IP changed).
3.8 Retry path reuses a stale token assumption (web/app.js)
Minor: the reconnect path reuses the same JWT (fine — 1 h expiry) but if a user leaves the tab open past expiry, Start fails opaquely. Fetch a fresh token on every attempt; they're free.
4. General Code Review
agent/agent.py
- Good: clean separation of config, prompt quality is genuinely good for spoken output, MCP toolset wiring is correct for 1.7 (
MCPToolsetintools=constructs fine — verified in-container),close_on_disconnect=Falserationale is sound and well-commented. session.start(...)beforectx.connect()matches the 1.x documented pattern — correct, leave it.- The
user_state_changed/user_input_transcribeddiagnostic logging is great; keep it, but see 3.5 or you'll never see it. - No explicit
vad=on the session: 1.7 silently defaults to the bundled local silero VAD (verified:livekit.local_inferencenative lib present, constructs offline). Works, but make it explicit —vad=inference.VAD(model="silero")— so a future SDK default change can't surprise you. interruption={"mode": "vad"}is correct for self-hosted (the SDK source confirmsadaptiverequires the cloud gateway and is disabled outside dev/hosted mode anyway).- Voice-change handler doesn't filter on the
voice-controltopic; it parses every packet. Harmless today, sloppy tomorrow — checkpacket.topic.
web/app.js
- Besides the two critical bugs: the agent-detection logic (
isAgent, poll loop inwaitForAgent) is reasonable, thoughRoomEvent.ParticipantConnected+ initial scan makes the 250 ms poll redundant — a promise resolved from the event handler would be tidier. - The mic meter builds its own
AudioContext+ analyser; livekit-client already exposesLocalAudioTrackevents andcreateAudioAnalyser()helper. Not wrong, just duplicative. stopBtnhandler doesn't clear attached audio elements (moot until 2.1 is fixed — then it leaks one<audio>element per session).addMessageinterpolates transcript text withinnerHTML— XSS via speech: whatever the STT/LLM emits lands in the DOM unescaped. UsetextContentfor the text node.
web/token_server.py
- JWT construction is correct (HS256,
roomConfig.agents[].agentNamein protojson camelCase — the server accepts this). Access-Control-Allow-Origin: *on an endpoint that mints publish-capable tokens: any page a LAN user visits can silently get a token and join the room. Same-origin only — drop the CORS headers entirely (the UI is same-origin through nginx).- No room-name validation: the client can request any room. Pin it server-side to
voice-roomor a validated allowlist. - Single-threaded
HTTPServeris fine at this scale; note it blocks during slow clients.
Infra (Dockerfile / nginx / compose / livekit.yaml)
- nginx: WS proxy for
/livekit/is correct (prefix strip + Upgrade headers). The embedded one-lineprintfconfig in the Dockerfile is unreadable and un-diffable — move it to a realnginx.conffile that gets COPYed. - nginx
location /tokensetsproxy_set_header Content-Type ...— that's a no-op/incorrect use (Content-Type is a client request header); remove. COPY web/ /var/www/voice/publishestoken_server.pysource athttps://host:8090/token_server.py. No secrets in it, but don't serve server code; exclude it (.dockerignore-style or copy files explicitly).devkey: devsecretin livekit.yaml and compose defaults: anyone on the LAN who reads this repo can mint admin tokens. For a LAN toy it's acceptable; generate a random secret at first boot if this ever leaves the bench.- compose has no
healthcheck; a supervisord child crash-looping (the exact failure class you just debugged) leaves the container "Up" and green. Add a healthcheck that curlshttps://localhost:8090/and hits LiveKit's/on 7880. supervisord.confhasnodaemon=falsewhile CMD runssupervisord -n— the flag wins, but make the file say what it does.- AGENTS.md file-layout section is accurate; the Testing section's
docker compose logs -f agentis wrong (see 3.5).
Doc drift (AGENTS.md)
- "max_tokens=1000 / reasoning_content fallback" — documented, not implemented (3.1).
- "Interruption mode must be vad" — implemented, consistent. Good.
- Ports table says 8090 is "UI + signaling" — correct via the nginx proxy; 7880 needn't be exposed at all with host networking (it is, via EXPOSE — cosmetic).
5. Verification Performed (evidence)
All checks run against the built image voice-voice:latest and the live LAN services:
| Check | Result |
|---|---|
import agent in-container |
OK — no API mismatches against livekit-agents 1.7.0 |
VoiceAssistant() + full AgentSession(...) construction |
OK — MCP toolsets, TurnHandlingOptions accepted |
| Default VAD | Local silero via livekit.local_inference native lib (34 MB, bundled) — constructs with --network none |
| Turn detector | Falls back to local v1-mini when not on LiveKit Cloud; degrades to endpointing delay if unavailable — no cloud dependency |
| Azure STT capabilities | streaming: True — no VAD strictly required for transcription |
azure.TTS.update_options |
Exists — live voice switching is valid |
Gemma /v1/chat/completions |
Responds; tool calls work; reasoning_content confirmed present with empty content |
decodeToString in vendored livekit-client 2.13.0 |
0 occurrences — confirmed frontend bug |
TrackSubscribed/attach() in app.js |
Absent — confirmed frontend bug |
| Container state | Not currently running (no runtime logs available) |
6. Recommended Fix Order
app.js: addTrackSubscribed→track.attach()+startAudio()+AudioPlaybackStatusChangedfallback (2.1).app.js:TextDecoderfix forDataReceived(2.2) — or migrate both sides tolk.transcriptiontext streams.agent.py: reasoning_content/max_tokens handling (3.1).- supervisord → stdout logging + AGENTS.md log command fix (3.5).
app.js:innerHTML→textContent(XSS).- Remaining review items as time allows (3.2-3.8, section 4).
Test plan after 1+2:
docker compose up --build -d, wait ~15 s for worker registration.- Browser → Start → say "hello". Expect: your words render as a user message (streamed if using text streams), agent audio plays within ~1-2 s, agent message renders.
- Watch
docker exec voice tail -f /var/log/supervisor/agent.log: confirmuser state -> speaking,STT (final): ..., then TTS synthesis. - Change voice mid-session; next reply uses the new voice.
- Kill/reload the tab, rejoin: agent should still respond (
close_on_disconnect=Falsepath). - iPhone Safari: verify the "tap to enable audio" fallback fires and works.
7. Product / Theming & Visual Upgrade Proposals
The current UI is a functional dev console. To make it feel like a product:
Core interaction model
- Replace the transcript-first layout with a voice-first "orb" UI. A large centered circle that breathes in idle, pulses with your mic level while listening (you already compute the level — drive the orb instead of a "mic: 42%" debug string), and ripples with the agent's output level while speaking. This one element communicates listening/thinking/speaking state instantly and is the signature visual of every good voice product.
- Explicit state machine surfaced in the UI:
idle → connecting → listening → thinking → speaking, driven by the agent'slk.agent.stateparticipant attribute (the SDK publishes it — free to consume). Replace the freeform status strings. - Streaming transcripts with partials (via
lk.transcription): words appear as you say them, greyed until final. Massive perceived-latency win. - Tool-use indicator: when Gemma calls
web_search, show a subtle "searching the web..." chip. Right now web lookups are silent 5-10 s gaps that feel like crashes. - Barge-in affordance: show a small "tap or speak to interrupt" hint while the agent is speaking.
Visual design
- Keep the dark theme, but commit to it: one accent hue (the blue→violet gradient is good), true-black
#09090bbase, glassmorphism panels (backdrop-filter: blur) for the transcript drawer,Inter/Geistvariable font instead of system stack. - Chat bubbles: user right-aligned, agent left-aligned with a small avatar dot in the accent gradient; timestamps on hover. Drop the border-left style.
- Motion: orb idle animation (4 s ease breathing), message enter transitions (~120 ms fade/slide), respect
prefers-reduced-motion. - The voice picker becomes a proper settings sheet: voice cards with a "preview" play button (one cached Azure TTS sample per voice), plus speaking-rate and style (
mstts:express-as) controls — the SSML plumbing already exists. - Light-mode variant via
prefers-color-scheme.
Mobile / platform
- PWA manifest + icons so it installs to the home screen (the HTTPS requirement is already met);
theme-colormeta; safe-area insets. - Wake-lock (
navigator.wakeLock) during a session so the phone doesn't sleep mid-conversation. - Favicon (currently a 404 on every load).
Nice-to-haves that differentiate
- Conversation history persisted to
localStoragewith a "clear" button. - Latency HUD (dev toggle): STT-final→first-audio time per turn — you'll want it while tuning Gemma.
- Push-to-talk mode toggle for noisy rooms (disable VAD interruption, hold spacebar/tap-and-hold the orb).
- Audio visualizer bars on the agent bubble while it speaks (a
createAudioAnalyser()on the remote track — the same trick the mic meter already does locally).
End of review.