Debug Voice
Make a voice session readable after the fact, then fix from evidence. The agent cannot hear the app; the log is its ears, the user is its judge. No audio, no tokens, never in prod.
Works for any stack. The pipeline is a small contract (below); implement it in whatever the app already uses.
Workflow
- Map the app (read only).
- Plan: write the change list, show it, stop. Nothing is edited until the user aligns.
- Install the agreed pieces in the app's language, framework, and conventions.
- Verify the sink, hand the app to the user.
- Fix loop from the log.
1. Map
Find, and note the paths:
- Voice client: where realtime events are received and sent, mic capture, audio playback, token fetch.
- Server: framework, how routes are declared, where shared server code lives, how env is read, what "production" means here.
- Conventions: language(s), module system, formatter, where scripts or tasks live (
package.json, Makefile, pyproject, justfile), .gitignore.
- Client kind: browser, mobile, desktop, CLI. A non-browser client still POSTs the same JSON; a single-process app can skip HTTP and append to the file directly.
- Where audio deltas are handled. They must be counted, never logged.
2. Plan, then stop
Fill this in with real paths and the app's language, post it, and wait for a yes or a trimmed list. Do not edit files before that.
## Debug voice: plan
Add
- <path>: client logger (batch, redact, flush) in <language>
- <path>: dev-only sink `POST /api/voice/log` → `.voice-logs/<sessionId>.ndjson`
- <path> (optional): summary command `<cmd>`; otherwise read the NDJSON with jq
Modify
- <voice client file>: hook points start, token, mic, env, ws.*, client/server events,
audio.in (2 s windows), audio.out.first, audio.out, play.stop, stop
- <token route>: append `server.token { ok, status, ms, upstream }` (never the token)
- <UI file>: session id in the voice status line and in voice error messages
- .gitignore: `/.voice-logs`
- <scripts file>: a `voice:logs` task (only if the summary command is wanted)
Logged: event names and non-audio fields, timings, byte counts, mic RMS.
Never: tokens, API keys, raw audio, strings over 400 chars.
Off in production unless `VOICE_LOG=1`.
Reply "go", or strike lines you do not want.
3. Install: the contract
Match the app. Same language as the surrounding code, same route style, same formatter. Write the pieces from the contract below; do not introduce a second language or toolchain for logging.
Session id: 8 lowercase hex chars from a UUID. The sink accepts ^[a-z0-9]{4,64}$; it becomes a file name.
Entry (one JSON object per line):
| Field |
Client |
Server |
t |
ms since the logger started |
absent; the reader aligns by ts |
ts |
epoch ms |
epoch ms |
kind |
start, server, client, error, audio.in, … |
server.token, … |
src |
absent |
"server" |
| rest |
the hook's fields, redacted |
the hook's fields |
Redaction, applied client side before buffering: on audio event types (response.output_audio.delta, response.audio.delta, input_audio_buffer.append) replace delta / audio with bytes = decoded base64 length; strings over 400 chars cut to 400 + …[N chars]; objects deeper than 4 → "[depth]"; arrays over 50 items truncated.
Client logger: buffer entries; flush as POST <sink> {"sessionId","entries":[…]} every 1 s or at 200 entries; on stop flush with keepalive (or the platform's "survive navigation" equivalent); swallow every transport error, logging must never throw into the voice path. Also mirror entries to the console in dev.
Sink: POST /api/voice/log, JSON body. 404 unless dev or VOICE_LOG=1. 400 if sessionId fails the regex or entries is not an array. Append at most 500 entries per request, drop any line over 16,000 chars, to .voice-logs/<sessionId>.ndjson, creating the directory. Reply 204.
Pseudocode for any server:
handle POST /api/voice/log:
if production and VOICE_LOG != "1": return 404
body = parse json or return 400
if not regex(body.sessionId) or not list(body.entries): return 400
mkdir .voice-logs; append join(json(e) for e in body.entries[:500] if len < 16000) to .voice-logs/{sessionId}.ndjson
return 204
Pseudocode for the client logger:
logger(sessionId, sink):
buffer = []; started = now()
log(kind, data): buffer.push({ ...redact(data), t: now() - started, ts: epoch_ms(), kind }); schedule flush (1 s timer, or immediately at 200 entries)
server(event, extra): log("server", { ...redact_event(event), ...extra }) # never per audio delta
client(event): log("client", redact_event(event)) # never per audio chunk
error(where, err, extra): log("error", { where, name, message, ...extra })
flush(final=false): POST sink {"sessionId","entries": buffer}; buffer = []; ignore all errors; keepalive when final
close(): flush(final=true)
Hook points
kind and fields; the shape is the same in every language.
| When |
kind and fields |
| Session start |
start { url, target_rate } |
| Token fetched / failed |
token.ok { ms } / error { where: "token", name, message, ms } |
| Mic granted / denied |
mic.ok { ms, label, settings } / error { where: "mic", … } |
| Audio graph ready |
env { ua, mic_rate, mic_state, play_rate, play_state, capture_frames, target_rate } |
| Socket |
ws.connecting, ws.open { ms }, ws.error, ws.close { code, reason, wasClean, by } |
| Every event sent, except audio chunks |
client { …redacted event } |
| Every event received, except audio deltas |
server { …redacted event, phase } |
| Phase change (dedupe) |
phase { phase } |
| Mic chunks, aggregated per 2 s |
audio.in { chunks, bytes, rms_max, rms_avg, pending, mic_state, phase } |
| Pre-open buffer sent on open |
audio.flush { chunks } |
| First audio delta of a response |
audio.out.first { response_id, bytes, since_response_created_ms, since_speech_stopped_ms, play_state } |
response.done |
audio.out { response_id, status, deltas, bytes, audio_ms, wall_ms, max_gap_ms, queued_ms, underruns, drain_ms_max } |
| Barge-in stop |
play.stop { reason, dropped_ms } |
| User stop |
stop { by: "client", phase }, then flush |
| Token route (server) |
server.token { ok, status, ms, upstream } |
In the message handler: if the event is an audio delta, count bytes and gaps and play it; otherwise log it as server with the current phase, then run the existing handling. Keep speechStoppedT from input_audio_buffer.speech_stopped and createdT from response.created; report both distances on the first delta. In the player, count an underrun when the next scheduled time is already in the past mid-response, track the largest drain, reset on response.created.
UI: show the id in the voice status line (Listening · session ab12cd34) and append (voice session <id>) to voice errors, so the user can name the run.
4. Verify
curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost:<port>/api/voice/log \
-H 'Content-Type: application/json' \
-d '{"sessionId":"smoke001","entries":[{"t":0,"ts":0,"kind":"start"}]}' # 204
curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost:<port>/api/voice/log \
-H 'Content-Type: application/json' -d '{"sessionId":"../x","entries":[]}' # 400
cat .voice-logs/smoke001.ndjson && rm .voice-logs/smoke001.ndjson
Then hand off: the user tests in the real app and reports what they said, what they heard, when it went wrong, and the session id.
Reading a log
Any of these; none needs the app's toolchain:
f=.voice-logs/<id>.ndjson
jq -r 'select(.kind|IN("start","token.ok","mic.ok","env","ws.open","ws.close","server.token","stop","error")) | "\(.t // .ts)ms \(.kind) \(.type // "") \(.message // "")"' $f # milestones and errors
jq -r 'select(.kind=="server") | .type' $f | sort | uniq -c | sort -rn # server event counts
jq -c 'select(.kind|IN("audio.out.first","audio.out"))' $f # per-turn latency, gaps, underruns
jq -c 'select(.kind=="audio.in")' $f # mic windows, rms
Or read the file; it is one object per line in time order. Server entries have only ts; align them to the client clock with the start entry's ts. If the user wants a summary command, write one in the app's language that prints: milestones, server event counts, one line per audio.out turn with its audio.out.first latencies, mic window totals, errors and closes, and the last 25 entries.
5. Fix loop
- Instrument (steps 1–4) if the app has no
.voice-logs pipeline yet.
- User tests in the real app and describes the run.
- Read the log around the failing
t.
- Match symptom → signature → fix (table below). No matching signature: add logging first, re-test, then fix.
- Fix one thing, re-test, confirm the signature is gone in a fresh log.
- Write it down: append a confirmed row under "Confirmed from sessions". If the fix changes how voice should be built, update
/add-voice too.
Symptom → log signature → fix (starter rows)
| Symptom (user) |
Signature (log) |
Fix |
| Silence, but transcript appears |
env.play_state or audio.out.first.play_state = suspended |
Create and resume the playback audio context inside the user gesture; one context per session, not per turn |
| Assistant interrupts itself |
speech_started with phase=speaking; audio.in.rms_max rises only during playback |
Echo. Confirm with headphones (if it stops, it is echo). Keep echo cancellation on, lower speaker volume, or gate mic sends while speaking |
| Choppy, stuttering |
audio.out.underruns > 0, drain_ms_max high, max_gap_ms far above chunk length |
Schedule a small lead (150–250 ms) before the first chunk plays; do not rebuild the audio context per turn |
| Crackle, wrong pitch or speed |
session.update rate ≠ buffer rate; odd bytes |
One rate everywhere (audio.input/output.format.rate, player buffer); even-byte alignment |
| Never connects, or closes at once |
ws.close before session.updated; server.token.ok=false |
Mint a token per click (300 s), protocol xai-client-secret.<token>, model in URL; read server.token.status and upstream |
| Mic does nothing |
audio.in.rms_max ≈ 0 in every window; mic.ok.label unexpected |
Wrong device or OS permission; check mic.ok.settings, label, mic_state |
| No user transcript |
no conversation.item.input_audio_transcription.updated |
Set audio.input.transcription.model: "grok-transcribe" in session.update |
| Slow first word |
audio.out.first.since_speech_stopped_ms high |
Try reasoning.effort: "none"; shorter instructions; check token.ok.ms and ws.open.ms for connect cost |
| User text appears after the reply |
...transcription.updated t > response.created t |
Create the user row on input_audio_buffer.committed (item_id), fill it on updated |
| First words cut off |
ws.open.ms large and audio.flush.chunks at the buffer cap |
Start mic before the socket, buffer early audio, raise the pre-open cap |
Confirmed from sessions
Append after a fix is verified in a fresh log. Format: YYYY-MM-DD · symptom · signature · fix · file(s).
Rules
- Plan first; no edits before the user aligns on the change list.
- Never tokens, keys, or raw audio. Audio becomes byte counts.
- Dev only. The sink returns 404 in production unless
VOICE_LOG=1. .voice-logs/ is gitignored.
- Logging never throws into the voice path.
- Aggregate audio; never log per chunk.
- One change per re-test so the log tells you which fix worked.
- Do not invent xAI event names; confirm in https://docs.x.ai/developers/model-capabilities/audio/speech-to-speech before adding a signature.
1---2name: debug-voice3description: Use when the user runs /debug-voice, says voice mode has flaws, asks to see or capture what happened in a Grok realtime voice session, or a voice integration has no debug logging yet. Proposes a plan, then installs a dev-only log pipeline (client logger → local NDJSON) in the app's own language and conventions, then runs the fix loop: match the user's report to log signatures, fix one thing, re-test.4---5
6# Debug Voice
7
8Make a voice session readable after the fact, then fix from evidence. The agent cannot hear the app; the log is its ears, the user is its judge. No audio, no tokens, never in prod.
9
10Works for any stack. The pipeline is a small contract (below); implement it in whatever the app already uses.
11
12## Workflow
13
141. **Map** the app (read only).
152. **Plan**: write the change list, show it, **stop**. Nothing is edited until the user aligns.
163. **Install** the agreed pieces in the app's language, framework, and conventions.
174. **Verify** the sink, hand the app to the user.
185. **Fix loop** from the log.
19
20## 1. Map
21
22Find, and note the paths:
23
24- Voice client: where realtime events are received and sent, mic capture, audio playback, token fetch.
25- Server: framework, how routes are declared, where shared server code lives, how env is read, what "production" means here.
26- Conventions: language(s), module system, formatter, where scripts or tasks live (`package.json`, `Makefile`, `pyproject`, `justfile`), `.gitignore`.
27- Client kind: browser, mobile, desktop, CLI. A non-browser client still POSTs the same JSON; a single-process app can skip HTTP and append to the file directly.
28- Where audio deltas are handled. They must be counted, never logged.
29
30## 2. Plan, then stop
31
32Fill this in with real paths and the app's language, post it, and wait for a yes or a trimmed list. Do not edit files before that.
33
34```markdown
35## Debug voice: plan
36
37Add
38- <path>: client logger (batch, redact, flush) in <language>
39- <path>: dev-only sink `POST /api/voice/log` → `.voice-logs/<sessionId>.ndjson`
40- <path> (optional): summary command `<cmd>`; otherwise read the NDJSON with jq
41
42Modify
43- <voice client file>: hook points start, token, mic, env, ws.*, client/server events,
44 audio.in (2 s windows), audio.out.first, audio.out, play.stop, stop
45- <token route>: append `server.token { ok, status, ms, upstream }` (never the token)
46- <UI file>: session id in the voice status line and in voice error messages
47- .gitignore: `/.voice-logs`
48- <scripts file>: a `voice:logs` task (only if the summary command is wanted)
49
50Logged: event names and non-audio fields, timings, byte counts, mic RMS.
51Never: tokens, API keys, raw audio, strings over 400 chars.
52Off in production unless `VOICE_LOG=1`.
53
54Reply "go", or strike lines you do not want.
55```
56
57## 3. Install: the contract
58
59Match the app. Same language as the surrounding code, same route style, same formatter. Write the pieces from the contract below; do not introduce a second language or toolchain for logging.
60
61**Session id**: 8 lowercase hex chars from a UUID. The sink accepts `^[a-z0-9]{4,64}$`; it becomes a file name.
62
63**Entry** (one JSON object per line):
64
65| Field | Client | Server |
66| --- | --- | --- |
67| `t` | ms since the logger started | absent; the reader aligns by `ts` |
68| `ts` | epoch ms | epoch ms |
69| `kind` | `start`, `server`, `client`, `error`, `audio.in`, … | `server.token`, … |
70| `src` | absent | `"server"` |
71| rest | the hook's fields, redacted | the hook's fields |
72
73**Redaction, applied client side before buffering**: on audio event types (`response.output_audio.delta`, `response.audio.delta`, `input_audio_buffer.append`) replace `delta` / `audio` with `bytes` = decoded base64 length; strings over 400 chars cut to 400 + `…[N chars]`; objects deeper than 4 → `"[depth]"`; arrays over 50 items truncated.
74
75**Client logger**: buffer entries; flush as `POST <sink> {"sessionId","entries":[…]}` every 1 s or at 200 entries; on stop flush with keepalive (or the platform's "survive navigation" equivalent); swallow every transport error, logging must never throw into the voice path. Also mirror entries to the console in dev.
76
77**Sink**: `POST /api/voice/log`, JSON body. `404` unless dev or `VOICE_LOG=1`. `400` if `sessionId` fails the regex or `entries` is not an array. Append at most 500 entries per request, drop any line over 16,000 chars, to `.voice-logs/<sessionId>.ndjson`, creating the directory. Reply `204`.
78
79Pseudocode for any server:
80
81```
82handle POST /api/voice/log:
83 if production and VOICE_LOG != "1": return 404
84 body = parse json or return 400
85 if not regex(body.sessionId) or not list(body.entries): return 400
86 mkdir .voice-logs; append join(json(e) for e in body.entries[:500] if len < 16000) to .voice-logs/{sessionId}.ndjson
87 return 204
88```
89
90Pseudocode for the client logger:
91
92```
93logger(sessionId, sink):
94 buffer = []; started = now()
95 log(kind, data): buffer.push({ ...redact(data), t: now() - started, ts: epoch_ms(), kind }); schedule flush (1 s timer, or immediately at 200 entries)
96 server(event, extra): log("server", { ...redact_event(event), ...extra }) # never per audio delta
97 client(event): log("client", redact_event(event)) # never per audio chunk
98 error(where, err, extra): log("error", { where, name, message, ...extra })
99 flush(final=false): POST sink {"sessionId","entries": buffer}; buffer = []; ignore all errors; keepalive when final
100 close(): flush(final=true)
101```
102
103## Hook points
104
105`kind` and fields; the shape is the same in every language.
106
107| When | `kind` and fields |
108| --- | --- |
109| Session start | `start { url, target_rate }` |
110| Token fetched / failed | `token.ok { ms }` / `error { where: "token", name, message, ms }` |
111| Mic granted / denied | `mic.ok { ms, label, settings }` / `error { where: "mic", … }` |
112| Audio graph ready | `env { ua, mic_rate, mic_state, play_rate, play_state, capture_frames, target_rate }` |
113| Socket | `ws.connecting`, `ws.open { ms }`, `ws.error`, `ws.close { code, reason, wasClean, by }` |
114| Every event sent, except audio chunks | `client { …redacted event }` |
115| Every event received, except audio deltas | `server { …redacted event, phase }` |
116| Phase change (dedupe) | `phase { phase }` |
117| Mic chunks, aggregated per 2 s | `audio.in { chunks, bytes, rms_max, rms_avg, pending, mic_state, phase }` |
118| Pre-open buffer sent on open | `audio.flush { chunks }` |
119| First audio delta of a response | `audio.out.first { response_id, bytes, since_response_created_ms, since_speech_stopped_ms, play_state }` |
120| `response.done` | `audio.out { response_id, status, deltas, bytes, audio_ms, wall_ms, max_gap_ms, queued_ms, underruns, drain_ms_max }` |
121| Barge-in stop | `play.stop { reason, dropped_ms }` |
122| User stop | `stop { by: "client", phase }`, then flush |
123| Token route (server) | `server.token { ok, status, ms, upstream }` |
124
125In the message handler: if the event is an audio delta, count bytes and gaps and play it; otherwise log it as `server` with the current phase, then run the existing handling. Keep `speechStoppedT` from `input_audio_buffer.speech_stopped` and `createdT` from `response.created`; report both distances on the first delta. In the player, count an underrun when the next scheduled time is already in the past mid-response, track the largest drain, reset on `response.created`.
126
127UI: show the id in the voice status line (`Listening · session ab12cd34`) and append `(voice session <id>)` to voice errors, so the user can name the run.
128
129## 4. Verify
130
131```bash
132curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost:<port>/api/voice/log \
133 -H 'Content-Type: application/json' \
134 -d '{"sessionId":"smoke001","entries":[{"t":0,"ts":0,"kind":"start"}]}' # 204
135curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost:<port>/api/voice/log \
136 -H 'Content-Type: application/json' -d '{"sessionId":"../x","entries":[]}' # 400
137cat .voice-logs/smoke001.ndjson && rm .voice-logs/smoke001.ndjson
138```
139
140Then hand off: the user tests in the real app and reports what they said, what they heard, when it went wrong, and the session id.
141
142## Reading a log
143
144Any of these; none needs the app's toolchain:
145
146```bash
147f=.voice-logs/<id>.ndjson
148jq -r 'select(.kind|IN("start","token.ok","mic.ok","env","ws.open","ws.close","server.token","stop","error")) | "\(.t // .ts)ms \(.kind) \(.type // "") \(.message // "")"' $f # milestones and errors
149jq -r 'select(.kind=="server") | .type' $f | sort | uniq -c | sort -rn # server event counts
150jq -c 'select(.kind|IN("audio.out.first","audio.out"))' $f # per-turn latency, gaps, underruns
151jq -c 'select(.kind=="audio.in")' $f # mic windows, rms
152```
153
154Or read the file; it is one object per line in time order. Server entries have only `ts`; align them to the client clock with the `start` entry's `ts`. If the user wants a summary command, write one in the app's language that prints: milestones, server event counts, one line per `audio.out` turn with its `audio.out.first` latencies, mic window totals, errors and closes, and the last 25 entries.
155
156## 5. Fix loop
157
1581. **Instrument** (steps 1–4) if the app has no `.voice-logs` pipeline yet.
1592. **User tests** in the real app and describes the run.
1603. **Read the log** around the failing `t`.
1614. **Match** symptom → signature → fix (table below). No matching signature: add logging first, re-test, then fix.
1625. **Fix one thing**, re-test, confirm the signature is gone in a fresh log.
1636. **Write it down**: append a confirmed row under "Confirmed from sessions". If the fix changes how voice should be built, update `/add-voice` too.
164
165## Symptom → log signature → fix (starter rows)
166
167| Symptom (user) | Signature (log) | Fix |
168| --- | --- | --- |
169| Silence, but transcript appears | `env.play_state` or `audio.out.first.play_state` = `suspended` | Create and resume the playback audio context inside the user gesture; one context per session, not per turn |
170| Assistant interrupts itself | `speech_started` with `phase=speaking`; `audio.in.rms_max` rises only during playback | Echo. Confirm with headphones (if it stops, it is echo). Keep echo cancellation on, lower speaker volume, or gate mic sends while `speaking` |
171| Choppy, stuttering | `audio.out.underruns` > 0, `drain_ms_max` high, `max_gap_ms` far above chunk length | Schedule a small lead (150–250 ms) before the first chunk plays; do not rebuild the audio context per turn |
172| Crackle, wrong pitch or speed | `session.update` rate ≠ buffer rate; odd `bytes` | One rate everywhere (`audio.input/output.format.rate`, player buffer); even-byte alignment |
173| Never connects, or closes at once | `ws.close` before `session.updated`; `server.token.ok=false` | Mint a token per click (300 s), protocol `xai-client-secret.<token>`, `model` in URL; read `server.token.status` and `upstream` |
174| Mic does nothing | `audio.in.rms_max` ≈ 0 in every window; `mic.ok.label` unexpected | Wrong device or OS permission; check `mic.ok.settings`, `label`, `mic_state` |
175| No user transcript | no `conversation.item.input_audio_transcription.updated` | Set `audio.input.transcription.model: "grok-transcribe"` in `session.update` |
176| Slow first word | `audio.out.first.since_speech_stopped_ms` high | Try `reasoning.effort: "none"`; shorter `instructions`; check `token.ok.ms` and `ws.open.ms` for connect cost |
177| User text appears after the reply | `...transcription.updated` `t` > `response.created` `t` | Create the user row on `input_audio_buffer.committed` (`item_id`), fill it on `updated` |
178| First words cut off | `ws.open.ms` large and `audio.flush.chunks` at the buffer cap | Start mic before the socket, buffer early audio, raise the pre-open cap |
179
180## Confirmed from sessions
181
182Append after a fix is verified in a fresh log. Format: `YYYY-MM-DD · symptom · signature · fix · file(s)`.
183
184## Rules
185
186- Plan first; no edits before the user aligns on the change list.
187- Never tokens, keys, or raw audio. Audio becomes byte counts.
188- Dev only. The sink returns 404 in production unless `VOICE_LOG=1`. `.voice-logs/` is gitignored.
189- Logging never throws into the voice path.
190- Aggregate audio; never log per chunk.
191- One change per re-test so the log tells you which fix worked.
192- Do not invent xAI event names; confirm in https://docs.x.ai/developers/model-capabilities/audio/speech-to-speech before adding a signature.