debug-voice
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseDebug 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.
让语音会话可事后回溯,再基于证据修复问题。Agent无法感知应用的音频输出;日志就是它的耳朵,用户则是裁判。不记录音频、不记录token,绝不在生产环境启用。
适用于任意技术栈。该日志管道是一套轻量契约(见下文),使用应用已有的技术栈实现即可。
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.
- 梳理应用结构(只读操作)。
- 制定方案:列出修改清单,展示给用户后暂停。在用户确认前,不修改任何文件。
- 安装部署:按照应用的语言、框架和规范,实现双方确认的功能模块。
- 验证日志接收端(sink),将应用交还给用户测试。
- 基于日志执行修复循环。
1. Map
1. 结构梳理
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.
找到并记录以下路径/位置:
- 语音客户端:实时事件的收发位置、mic采集、音频播放、token获取的相关代码。
- 服务端:使用的框架、路由声明方式、服务端公共代码位置、环境变量读取方式、此处“生产环境”的判定逻辑。
- 项目规范:使用的语言、模块系统、代码格式化工具、脚本/任务配置文件位置(、
package.json、Makefile、pyproject)、justfile文件位置。.gitignore - 客户端类型:浏览器、移动端、桌面端、CLI。非浏览器客户端仍可发送相同的JSON POST请求;单进程应用可跳过HTTP,直接将日志追加到文件中。
- 音频delta的处理位置:这些数据仅做计数,绝对不能写入日志。
2. Plan, then stop
2. 制定方案并等待确认
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.
markdown
undefined用实际路径和应用的语言填充以下模板,发送给用户后等待确认(用户可能同意,也可能删减清单)。在此之前不要修改任何文件。
markdown
undefinedDebug voice: plan
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 ; otherwise read the NDJSON with jq
<cmd>
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 (never the token)
server.token { ok, status, ms, upstream } - <UI file>: session id in the voice status line and in voice error messages
- .gitignore:
/.voice-logs - <scripts file>: a task (only if the summary command is wanted)
voice:logs
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=1Reply "go", or strike lines you do not want.
undefinedAdd
- <path>: client logger (batch, redact, flush) in <language>
- <path>: dev-only sink →
POST /api/voice/log.voice-logs/<sessionId>.ndjson - <path> (optional): summary command ; otherwise read the NDJSON with jq
<cmd>
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 (never the token)
server.token { ok, status, ms, upstream } - <UI file>: session id in the voice status line and in voice error messages
- .gitignore:
/.voice-logs - <scripts file>: a task (only if the summary command is wanted)
voice:logs
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=1Reply "go", or strike lines you do not want.
undefined3. Install: the contract
3. 安装部署:契约规范
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 ; it becomes a file name.
^[a-z0-9]{4,64}$Entry (one JSON object per line):
| Field | Client | Server |
|---|---|---|
| ms since the logger started | absent; the reader aligns by |
| epoch ms | epoch ms |
| | |
| absent | |
| rest | the hook's fields, redacted | the hook's fields |
Redaction, applied client side before buffering: on audio event types (, , ) replace / with = decoded base64 length; strings over 400 chars cut to 400 + ; objects deeper than 4 → ; arrays over 50 items truncated.
response.output_audio.deltaresponse.audio.deltainput_audio_buffer.appenddeltaaudiobytes…[N chars]"[depth]"Client logger: buffer entries; flush as 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.
POST <sink> {"sessionId","entries":[…]}Sink: , JSON body. unless dev or . if fails the regex or is not an array. Append at most 500 entries per request, drop any line over 16,000 chars, to , creating the directory. Reply .
POST /api/voice/log404VOICE_LOG=1400sessionIdentries.voice-logs/<sessionId>.ndjson204Pseudocode 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 204Pseudocode 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)实现需与应用现有风格保持一致:使用与周边代码相同的语言、相同的路由风格、相同的格式化工具。按照下方的契约实现各模块,不要为了日志功能引入额外的语言或工具链。
会话ID(sessionId):从UUID中提取的8位小写十六进制字符。sink仅接受匹配格式的ID,该ID会作为日志文件名。
^[a-z0-9]{4,64}$日志条目(每行一个JSON对象):
| 字段 | 客户端 | 服务端 |
|---|---|---|
| 日志记录器启动后经过的毫秒数 | 无;日志读取器通过 |
| Unix 时间戳(毫秒) | Unix 时间戳(毫秒) |
| | |
| 无 | |
| 其余字段 | 钩子的字段(已脱敏) | 钩子的字段 |
脱敏规则(在客户端缓冲前执行):对于音频类事件(、、),将/字段替换为字段,值为base64解码后的字节数;长度超过400的字符串截断为400字符并追加(N为原长度);嵌套深度超过4层的对象替换为;长度超过50的数组做截断处理。
response.output_audio.deltaresponse.audio.deltainput_audio_buffer.appenddeltaaudiobytes…[N chars]"[depth]"客户端日志记录器(client logger):对日志条目进行缓冲;每1秒或缓冲达到200条时,通过刷新到服务端;会话停止时使用keepalive机制刷新(或平台对应的“页面跳转后仍可执行”的等效机制);吞掉所有传输错误,日志功能绝对不能抛出异常影响语音链路。开发环境下同时将日志输出到控制台。
POST <sink> {"sessionId","entries":[…]}日志接收端(sink):接口为,请求体为JSON。非开发环境且未设置时返回。如果不匹配正则或不是数组,返回。每个请求最多追加500条日志,丢弃长度超过16000字符的行,日志写入文件,目录不存在则自动创建。请求成功返回。
POST /api/voice/logVOICE_LOG=1404sessionIdentries400.voice-logs/<sessionId>.ndjson204任意服务端的伪代码实现:
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客户端日志记录器的伪代码实现:
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| When | |
|---|---|
| Session start | |
| Token fetched / failed | |
| Mic granted / denied | |
| Audio graph ready | |
| Socket | |
| Every event sent, except audio chunks | |
| Every event received, except audio deltas | |
| Phase change (dedupe) | |
| Mic chunks, aggregated per 2 s | |
| Pre-open buffer sent on open | |
| First audio delta of a response | |
| |
| Barge-in stop | |
| User stop | |
| Token route (server) | |
In the message handler: if the event is an audio delta, count bytes and gaps and play it; otherwise log it as with the current phase, then run the existing handling. Keep from and from ; 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 .
serverspeechStoppedTinput_audio_buffer.speech_stoppedcreatedTresponse.createdresponse.createdUI: show the id in the voice status line () and append to voice errors, so the user can name the run.
Listening · session ab12cd34(voice session <id>)包含和对应字段;所有语言的字段结构保持一致。
kind| 触发时机 | |
|---|---|
| 会话启动 | |
| Token获取成功/失败 | |
| Mic权限授予/拒绝 | |
| 音频图就绪 | |
| 套接字连接 | |
| 每发送一个事件(音频块除外) | |
| 每接收一个事件(音频delta除外) | |
| 阶段变化(去重后) | |
| Mic音频块(每2秒聚合一次) | |
| 连接建立时发送预开缓冲区 | |
| 响应的首个音频delta | |
| |
| 打断(barge-in)停止播放 | |
| 用户主动停止 | |
| Token路由(服务端) | |
在消息处理逻辑中:如果是音频delta事件,仅统计字节数和间隔并播放;否则将其作为类型事件连同当前阶段一起记录,再执行原有处理逻辑。保留事件的时间戳和事件的时间戳;在首个音频delta事件中上报这两个时间点的差值。在播放器中,当响应播放过程中下一个调度播放时间已过期时,计为一次欠载(underrun),跟踪最大排空时间(drain),并在事件时重置。
serverinput_audio_buffer.speech_stoppedspeechStoppedTresponse.createdcreatedTresponse.createdUI层面:在语音状态栏中显示会话ID(例如),并在语音错误消息后追加,方便用户标识本次运行。
Listening · session ab12cd34(voice session <id>)4. Verify
4. 验证
bash
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.ndjsonThen 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.
bash
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随后交接给用户:用户在真实应用中测试,并反馈他们说的内容、听到的内容、问题出现的时间以及会话ID。
Reading a log
日志读取
Any of these; none needs the app's toolchain:
bash
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, rmsOr read the file; it is one object per line in time order. Server entries have only ; align them to the client clock with the entry's . If the user wants a summary command, write one in the app's language that prints: milestones, server event counts, one line per turn with its latencies, mic window totals, errors and closes, and the last 25 entries.
tsstarttsaudio.outaudio.out.first可使用以下任意方式读取日志,均无需依赖应用的工具链:
bash
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也可以直接读取文件;日志按时间顺序排列,每行一个JSON对象。服务端条目只有字段;可通过条目的将服务端时间与客户端时钟对齐。如果用户需要摘要命令,用应用的语言实现一个,输出内容包括:关键里程碑事件、服务端事件计数、每个轮次的一行摘要(含对应的延迟)、mic窗口统计、错误与关闭事件,以及最后25条日志。
tsstarttsaudio.outaudio.out.first5. Fix loop
5. 修复循环
- Instrument (steps 1–4) if the app has no pipeline yet.
.voice-logs - 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 too.
/add-voice
- 如果应用还没有日志管道,先埋点(执行步骤1-4)。
.voice-logs - 用户测试:在真实应用中测试并描述本次运行的情况。
- 读取日志:定位到故障发生的时间点附近的日志。
t - 匹配:症状 → 特征 → 修复方案(见下表)。如果没有匹配的特征,先补充日志,重新测试后再修复。
- 修复一个问题,重新测试,确认在新的日志中该特征已消失。
- 记录归档:在“会话确认记录”下追加一条已验证的记录。如果该修复改变了语音功能的构建方式,同时更新技能。
/add-voice
Symptom → log signature → fix (starter rows)
症状 → 日志特征 → 修复方案(初始条目)
| Symptom (user) | Signature (log) | Fix |
|---|---|---|
| Silence, but transcript appears | | Create and resume the playback audio context inside the user gesture; one context per session, not per turn |
| Assistant interrupts itself | | Echo. Confirm with headphones (if it stops, it is echo). Keep echo cancellation on, lower speaker volume, or gate mic sends while |
| Choppy, stuttering | | 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 | | One rate everywhere ( |
| Never connects, or closes at once | | Mint a token per click (300 s), protocol |
| Mic does nothing | | Wrong device or OS permission; check |
| No user transcript | no | Set |
| Slow first word | | Try |
| User text appears after the reply | | Create the user row on |
| First words cut off | | Start mic before the socket, buffer early audio, raise the pre-open cap |
| 用户反馈症状 | 日志特征 | 修复方案 |
|---|---|---|
| 有静音,但转录文本正常显示 | | 在用户手势交互中创建并恢复播放音频上下文;每个会话使用一个上下文,而非每轮对话新建一个 |
| 助手会打断自己说话 | 出现 | 回声问题。用耳机测试确认(如果戴耳机后问题消失,就是回声导致)。保持回声消除开启,降低扬声器音量,或在 |
| 语音卡顿、断断续续 | | 在首个音频块播放前预留一小段缓冲时间(150-250毫秒);不要每轮对话重建音频上下文 |
| 有杂音、音调或速度异常 | | 全链路使用统一采样率( |
| 无法连接,或连接后立即断开 | | 每次点击时生成一个token(有效期300秒),协议格式为 |
| Mic完全没反应 | 每个窗口的 | 设备选择错误或系统权限问题;检查 |
| 没有用户转录文本 | 没有 | 在 |
| 首词响应慢 | | 尝试设置 |
| 用户文本在回复之后才出现 | | 在 |
| 首字被截断 | | 在建立socket连接前启动mic采集,提前缓冲音频,提高预开缓冲区上限 |
Confirmed from sessions
会话确认记录
Append after a fix is verified in a fresh log. Format: .
YYYY-MM-DD · symptom · signature · fix · file(s)在新日志中验证修复生效后,在此追加记录。格式:。
YYYY-MM-DD · 症状 · 特征 · 修复方案 · 修改文件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=1is gitignored..voice-logs/ - 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.
- 先制定方案;在用户确认修改清单前,不修改任何文件。
- 绝不记录token、密钥或原始音频。音频仅记录字节数。
- 仅用于开发环境。生产环境下sink返回404,除非设置了。
VOICE_LOG=1目录已加入.voice-logs/。.gitignore - 日志功能绝不抛出异常影响语音链路。
- 音频数据做聚合处理;绝不记录单个音频块的内容。
- 每次重新测试只改一个问题,这样才能通过日志确认哪个修复生效了。
- 不要臆造xAI事件名称;添加新的特征前,先在https://docs.x.ai/developers/model-capabilities/audio/speech-to-speech 中确认。