AI 语音与工具 / 语音项目发布 内容更新

AI语音为什么反应慢?给采音、上传、首字和播放分段计时

完整客户端单调时钟探针、串口采集和分位数分析,区分首字幕、LLM首token、发送调用与声学起音;20次实物记录待测。

读完能做什么
得到可构建的本地分段时间线、完整性检查与20次尝试方法,保留未观察到的后端/声学段。
开始之前
完成真实前置;能断电追线、核桥接UART与完整镜像/分区;服务端账号和观察权限由读者自行取得。
所需器材
官方ESP32-S3-DevKitC-1 v1.1 / WROOM-1-N16R8、桥接UART USB线、万用表与可靠短线、PID6049 ICS43434匹配停产存量,排针已可靠焊接;PID3006单声道MAX98357A、PID1313 8Ω/1W、功放独立稳压3.3V≥1A、SD低压SPST/1k/10k电阻、共地;BTL不接地、PID4440 128×32自动复位revB OLED,GPIO47/39/40按钮及外10k上拉、自己的匹配语音服务/账号和获授权日志;无实体/云端结果预填
版本组合
SDK / Python:ESP-IDF5.5.5 / Python3.12.13应用原树:xiaozhi v2.4.0 / 5540258abcbfa62518d09959308200be1c5b1b2b;完整1590来源+两兼容补丁+本站项目锁与音频:实际标准Registry74项锁;esp_audio_effects1.3.0~1;输入/输出16k,独立I2S,无AEC串口 / 字体:可选pyserial3.5;78/xiaozhi-fonts2.0.0 / basic_14_1 + default common_14_1,14px1bpp,组件元数据Apache-2.0
适用范围

官方DevKitC-1 v1.1 / WROOM-1-N16R8;小智v2.4.0/5540258…+两处板局部兼容补丁/本站项目;IDF5.5.5;PID6049/PID3006/PID1313/PID4440,实体与云端待测。

这篇验证到了哪一步

资料核对已通过
docs/esp32-evidence/audio-projects-sources.json及audio-projects.md;实际文件/命令/原日志/输入和产物SHA分阶段留存。验证日期:
构建 / 配置已通过
docs/esp32-evidence/audio-projects-builds.json及audio-projects.md;实际文件/命令/原日志/输入和产物SHA分阶段留存。对象:ESP32-S3-DevKitC-1 v1.1 / WROOM-1-N16R8版本:ESP-IDF5.5.5 / xiaozhi v2.4.0+显式16k/I2C兼容补丁+本站项目验证日期:
仿真已通过
docs/esp32-evidence/audio-projects-host.json及audio-projects.md;实际文件/命令/原日志/输入和产物SHA分阶段留存。验证日期:
本站实板待验证
20次实体会话、指定板/时钟/显示/麦克风/功放/声学/光学/云端未执行;所有物理CSV仅表头。

构建通过只说明指定环境中的代码或配置通过检查;实物功能、功耗和稳定性以对应的实板记录为准。

01 / 语音项目

这篇要完成什么,尚未验证什么

本篇产出可构建的客户端计时固件、完整串口采集/分析工具和单次时间线。记录来自客户端同一个64位esp_timer单调时钟;“首字”在这里明确操作化为首次非空TTS sentence_start字幕JSON被客户端回调观察到,不是LLM首token、屏幕首像素或声学起音。服务端内部STT/LLM/TTS没有共享时钟和trace_id时,不能靠两台机器的日志相减拆解。

硬件、云端与20次真实会话尚未执行;hardware-observations.csv/attempts.csv/server-stages.csv只有表头。主机20份数据全部来自标记synthetic的生产记录器夹具;它们验证工具,不是性能报告。

参考:[13] 固定Application:事件、手动采音、协议回调与主循环[12] 固定AudioService:任务、队列、解码/重采样与输出[18] ESP-IDF5.5.5 esp_timer:64位开机单调微秒

02 / 语音项目

1. 锁定可复建组合和供电

本包是完整小智v2.4.0 / 5540258abcbfa62518d09959308200be1c5b1b2b派生工程,固定ESP-IDF5.5.5/Python3.12.13,采用A07明确的两处板局部兼容补丁:I2C命名初始化顺序,以及MAX98357A输出24k→16k。上游最低>=5.5.2不是本板原样成功保证;v2.5.x要求>=6.0.1,不能替换tag后沿用本锁。原1590跟踪文件、实际dependencies.lock、全部本站生产源、差异与许可均提供;不下载第二SDK,不把补丁版称原样上游。

本组合仅官方ESP32-S3-DevKitC-1 v1.1 / WROOM-1-N16R8,16MB Quad Flash/8MB Octal PSRAM80MHz。A01/A02的N8R8不是本工程目标。保留bread-compact-wifi板标识/工厂与所有既有引脚,原生USB19/20不接,桥接UART0为43/44,35/36/37留给OPI。GPIO48的原SingleLed不是v1.1板载RGB38;这里两者都不外接,不把它们当实验灯。

两套独立I2S主时钟,不短接时钟:输入16kHz、32bit LEFT槽、24有效位;固定Simplex源码右移12再限16bit,不能照抄A01的增益/DC算法。输出16kHz、32bit LEFT槽,64fs标称BCLK1.024MHz;实际时钟未测。MAX98357A Rev7 p6保证LRCLK为7.6–8.4/15.2–16.8/30.4–50.4/83.8–100.8kHz,原版24k不在保证范围。服务端24k Opus经原AudioService/esp_audio_effects1.3.0~1的24→16重采样再送本板codec,不把服务端采样率直接接到功放LRCLK。

软件音量平方缩放,默认70且NVS可恢复,非正值开机会回10;它不是开机静音保证。先保持SD断开、核VIN/共地/短路,再从低音量试验;喇叭1W额定不证明实际输出功率。原重采样open失败只记日志、空handle会旁路,get_max_out/process返回值未完整检查。看到Failed to create output resampler/Opus decoder、异常语速/音调/压降/发热时先断开SD、再停功放外供,查堆/PSRAM/decoder率并RESET;本站没有声称自动静音或DSP质量通过。没有AEC,不做边听边播实验。

具体器材断电接线与核对
PID6049 ICS43434,匹配停产存量且排针可靠焊接3V→主板3V3,GND共地,SEL→GND,WS4/SCK5/DOUT6;非PDM/模拟麦克风
PID3006 单声道MAX98357ADIN7/BCLK15/LRC16;GAIN→功放VIN选择6dB;不要换成不同供电/偏置的“兼容”板
PID1313 C2464-001,8Ω/1W仅接功放OUT+和OUT−;BTL两端均不接GND、示波器地夹或耳机公共地
功放外供独立稳压3.3V、额定≥1A,仅与主板共地,正极不并到主板3V3/5V;不是以GPIO带负载
物理SD静音SD经10kΩ到GND;功放VIN经低压SPST和1kΩ到SD。开关断开=下拉静音,闭合才启用;首次上电/改线/诊断先断开
PID4440,128×32 I2C、STEMMA QT自动复位revBVIN→主板3V3,GND,SDA41/SCL42,0x3C;RST不另接。不是需手接RESET的旧PID931
手动采音按钮GPIO47→按钮→GND,外接10kΩ到主板3V3;39/40分别音量减/加,也各接10k上拉;BOOT0保留板载按钮

参考:[1] S3 DevKitC1 v1.1固定文档:J1/J3、电源与USB[2] ICS43434 DS-000069 v1.2:电气/边沿/24bit/启动[3] PID6049固定PCB:3V、SEL与47k数据下拉[4] MAX98357A/MAX98357B Rev7:供电/采样率/SD/GAIN/BTL[5] PID3006固定单声道PCB:VIN/SD1M/BTL端子[6] PID1313扬声器2017-02-14厂家规格:8Ω额定1W[7] PID6049产品页:具体型号与停产边界[8] ESP32-S3-WROOM-1/1U v1.8:N16R8规格表[9] PID4440固定revB PCB:上拉/电平转换/自动复位/128×32[10] 固定本板config:I2S/按钮/OLED/未接GPIO48[11] 固定Simplex codec:板参数/32bit槽/右移限幅/音量[26] 实际esp_audio_effects1.3.0~1来源:rate converter格式/采样率/API约束

03 / 语音项目

2. 先定义每个标记,不把调用返回叫送达

只用GPIO47手动按住说话/释放结束;BOOT自动会话、唤醒与AFE不在本测量范围。固件仅在Application处理新StartListening、状态Idle且播放队列空时尝试BEGIN。已有报告没取走、正在播放、重入或复位造成的未记录尝试必须写进attempts.csv;不能只挑有完整报告的轮次。

每个阶段只记一次,最多20行RAM,锁内不做IO/分配;音频任务只写标记,主循环在窗口完成后才批量写A09v1 UART行。字段包含boot观察标签、全局seq、turn、event、us/detail。探针仍有时间戳/短锁/额外8字节packet元数据开销,本次未量化;不能拿探针版数据直接当零开销原机数据。

标记精确定义与边界
BEGIN主循环接受本次计时窗口;不等于按键物理边沿
LISTEN_CMD_RETURN上游SendStartListening返回;不是服务器收到命令
MIC_CHUNK_RETURN窗口内发起的首次10ms ReadAudioData成功返回;不等于人开始说话/ADC首样本
FIRST_SEND_RETURN带本地epoch的首个音频packet经上游SendAudio返回true;不是无线ACK、TCP对端或云处理成功
STOP_LISTEN_RETURN释放后主循环调用SendStopListening返回;不等于服务端收尾时间
STT_JSON首次非空stt.text回调观察;可能为中间/最终文本,未区分服务端算法完成
FIRST_TTS_SENTENCE_JSON首次非空tts.sentence_start.text回调,字幕可观察点;不是LLM首token
FIRST_TTS_AUDIO_CALLBACKSpeaking状态收到首个音频packet;不表示解码成功
FIRST_PCM_WRITE_ENTER / RETURN对同epoch的首段PCM调用codec OutputData前/后;返回不证明DMA播完、功放出声或声压
END / failure首次PCM调用返回后结束报告,或网络错误/发送失败/解码队列拒绝/重入/观察到60秒超时结束

参考:[13] 固定Application:事件、手动采音、协议回调与主循环[12] 固定AudioService:任务、队列、解码/重采样与输出[18] ESP-IDF5.5.5 esp_timer:64位开机单调微秒

04 / 语音项目

3. 本地窗口怎样穿过音频队列

lab_trace_epoch只在AudioStreamPacket/AudioTask内存对象中携带,传给发送、解码、PCM队列;完整WebSocket三种版本与MQTT/UDP SendAudio体的桌面字节夹具核对它没有进wire格式。AES/socket只是明确桩,非加密、无线或远端证明。

raw/manual固定路径下,AfeAudioEngine::Feed调用OutputRawAudio并同步回调OnOutput,lab_feed_epoch只包住此调用,防旧编码包标成新窗口;启用AFE/WakeNet会触发编译范围保护,不能未经重审复用。旧epoch的发送失败不能终止新的记录,完整64位计时避免49天窗口复活。64位ID没有加到云协议:服务端晚到旧音频仍可能落入当前本地观察窗口,所以本报告不是端到端因果trace。逐轮隔离、等播放排空,异常记ambiguous/失败,不编造跨端关联。

解码失败、分配失败、队列清空可能使某段缺失或最终观察超时,不会补0。60秒由主循环下一次poll观察;阻塞网络调用、调度停顿会延后记录,不能称硬实时截止。一次END只表示首段调用或失败报告结束,整句回答可能还在队列;下一轮等完整播放结束和Idle,不能把END当声学结束。

参考:[14] 固定Protocol内存packet与wire边界[15] 固定WebSocket SendAudio三协议版本[16] 固定MQTT/UDP SendAudio加密nonce与输出[17] 固定AfeAudioEngine:raw Feed/OutputRawAudio同步路径[12] 固定AudioService:任务、队列、解码/重采样与输出[18] ESP-IDF5.5.5 esp_timer:64位开机单调微秒

05 / 语音项目

4. 构建并确认你刷的是本轮产物

先激活你已安装的ESP-IDF5.5.5环境,核idf.py --version确为ESP-IDF v5.5.5。运行工具时用该环境的Python3.12.13;串口采集额外固定pyserial3.5。python-requirements.lock只列本站工具直接依赖,SDK依赖仍按该SDK的原锁;managed组件按完整firmware/dependencies.lock固定74项(含IDF),保留原LICENSE/NOTICE,没有本地override。

首次build先跑上游lang_header,处理固定CMake缺少main→语言生成头显式依赖的原问题,不手改生成头。工具对与上次成功不同的编译输入刷新mtime并核字节不变,防COW/拷贝保旧mtime导致Ninja重用旧对象;实际新build日志、输入、bin/ELF/map/hash一起记录。仅已有bin不够,没有成功记录或源/产品/原log变化,manifest/flash拒绝。verify_project.py验证本交付固定源;你主动改源后它应拒绝,不能悄悄更新原版清单冒充原样。

改板型、Flash/PSRAM或defaults必须保原产物后用新的空build目录重新configure,并检查实际sdkconfig;defaults不是对已有sdkconfig强制覆盖。不要为了省空间复用其他板型build,勿更换SDK/关闭编译错误。当前固定N16R8与128×32/OLED/中文/raw/manual/禁WakeNet与AEC的实际构建结果另列验证表。

只在拥有相同具体实物并完成断电检查后,使用桥接UART USB口。先查local-products.json与该次build/flasher_args.json,再替换你自己的端口:

不把原生USB口当UART桥;BOOT进ROM时释放BOOT再RESET可恢复。必须携带同次bootloader/partition/app/assets及地址表,不能只写app.bin或沿用8MB布局。默认OTA/服务端更新可能替换本站派生固件;使用自己可控的服务配置/版本策略,升级前保完整镜像,不承诺云平台将保留本站补丁。联网/激活按实际服务条款和自己的账号操作,验证码、token、Wi-Fi密码与现实对话只留本地私有日志;本次没有账号、激活或真实音频上传/播放实验。

代码 / 配置
python tools/verify_project.py
python tools/project.py configure
python tools/project.py build
python tools/project.py manifest

python tools/project.py flash --port YOUR_UART_BRIDGE_PORT
python tools/project.py monitor --port YOUR_UART_BRIDGE_PORT

参考:[28] 固定v2.4.0应用manifest:依赖与IDF最低约束[19] 固定CMake:本板工厂、14px字体与lang_header[27] IDF5.5.5 LCD I2C真实字段顺序[8] ESP32-S3-WROOM-1/1U v1.8:N16R8规格表

06 / 语音项目

5. 采集至少20次的方法与空表

先固定房间/背景噪声、麦克风和喇叭位置、路由器/频段、服务端STT/LLM/TTS版本、同一句短问题、音量/SD位置、串口波特率和固件SHA。使用你获授权观察的后端;若只能看云对话而无服务端阶段日志,只报告客户端区间,不宣称定位到LLM内部。完整server-stages.csv给出可控后端的trace_id、stage及同台服务端start/end单调ns;没有真实ID映射时不得与本地turn硬拼,更不能跨时钟相减。

实际参数以--help为准。工具origin只是调用者标签,不能自动认证这就是物理记录。不要上传包含现实对话的日志;本次提供的CSV无数据行,不给平均延迟或“优化百分比”。

  1. 先保持SD断开完成供电/录音路径检查,全部按钮释放,桥接UART连接;此阶段记录在hardware-observations.csv,未核通过不开始性能试验。
  2. 根据真实串口名采集,SD仅在检查完成后按低音量方法启用。固定时长至少覆盖20次完整尝试和失败;另存私有原始UART日志用于诊断。
  3. 每次只按GPIO47,问题/说话节奏固定,释放后等整句排空、Idle再开下一轮;每次尝试无论成功/失败/重启/无报告都填attempts.csv。
  4. 每改变一项条件就新建一组捕获;同一boot文件分析,复位后另开文件,并保留重启/无报告分母。
  5. 至少20个有效样本才能给每项median/p90/p95。completeReports只表示窗口正常END且有首次PCM调用返回,并不保证六项阶段齐全或整句对话成功。不足、缺段、失败不补零;失败率、未报告尝试与各项有效样本数一起呈现。
代码 / 配置
python -m pip install -r python-requirements.lock
python tools/capture_trace.py --port YOUR_UART_BRIDGE_PORT --baud 115200 --seconds 1200 --output trace-private.txt
python tools/analyze_trace.py trace-private.txt --origin serial-unverified --attempts attempts.csv --min-complete 20 --output local-report.json

参考:[13] 固定Application:事件、手动采音、协议回调与主循环[12] 固定AudioService:任务、队列、解码/重采样与输出[14] 固定Protocol内存packet与wire边界[18] ESP-IDF5.5.5 esp_timer:64位开机单调微秒

07 / 语音项目

6. 读报告,按证据提出下一步

工具给六个本地区间:BEGIN→MIC返回、MIC→发送调用返回、STOP→首字幕JSON、STOP→首音频回调、首音频回调→首PCM调用返回、BEGIN→首PCM调用返回。只在同一64位客户端时钟内相减。网络等待、服务端计算、返回传输可能混在STOP后长段,不能直接全算成LLM时间。

分析器拒绝序列缺口/重复、跨boot混文件、格式错误、溢出和不完整窗口;缺少特定阶段保留null/缺失项。分位数用排序后(n−1)p线性插值;仅统计该项完整报告,显示分母和失败/ambiguous数量,不能从20次尝试偷换成20次有效值。

  • MIC→send长,先查是否采集积累/编码队列/主循环调度;需要更多本地探针才能继续拆,不据一个数字就换模型。
  • STOP→字幕/音频都长,查看获授权的后端阶段区间、服务器负载和网络,保持同问题/配置;没有后端日志则写“未区分”。
  • audio回调→PCM返回长,查解码/重采样/队列/调度与原错误日志;PCM返回仍不是声学起音,仪器记录表保持独立。
  • 首字幕比音频早或晚都可能;字幕JSON不是LLM首token,客户端也没有证明屏幕已刷新。

参考:[13] 固定Application:事件、手动采音、协议回调与主循环[12] 固定AudioService:任务、队列、解码/重采样与输出[18] ESP-IDF5.5.5 esp_timer:64位开机单调微秒

08 / 语音项目

7. 可复现主机工具检查

主机夹具在macOS/POSIX桌面、clang与ASan/UBSan上执行;PTY依赖os.openpty,测试不是在ESP32上跑。生产Recorder头8组ASan/UBSan涵盖重复标记、重入、旧epoch/旧失败、60秒/完整32位毫秒周期、时钟回退和线程互斥。生产UART格式产生20个明确synthetic窗口;真实文件夹具检查20/19样本门限与6种拒绝与失败标记降级,真实OS PTY分片240行核采集器。不初始化I2S、codec、GPIO或服务器,不把synthetic样本叫麦克风实录。相同完整源、生成体、原log和输入SHA在作者证据归档。

代码 / 配置
python tests/run_trace_host.py --output /tmp/a09-recorder-contract
python tests/check_files_pty.py --producer-dir /tmp/a09-recorder-contract --output /tmp/a09-file-pty-contract
python tests/check_transport_metadata.py --output /tmp/a09-wire-contract
python tests/check_project_tool.py --output /tmp/a09-project-tool-contract

参考:[13] 固定Application:事件、手动采音、协议回调与主循环[12] 固定AudioService:任务、队列、解码/重采样与输出[14] 固定Protocol内存packet与wire边界[15] 固定WebSocket SendAudio三协议版本[16] 固定MQTT/UDP SendAudio加密nonce与输出

09 / 语音项目

8. 故障、恢复和许可

原小智MIT和组件原LICENSE/NOTICE保留,本站探针/工具/概念图MIT,PySerial BSD-3-Clause。source manifest列出原1590、本站改动和新头,不把制造商手册转授为MIT。完整实体20轮、时钟/声学和后端运行仍pending。

现象可执行定位与恢复
没有A09v1或报告少核bridge UART0/115200、手动GPIO47、Idle/队列空、实际ELF标记;等先前报告刷完。保持无报告attempt,不从分母删除
多boot/seq缺口/OVERFLOW保原文件和attempt记录,检查串口拥塞/复位/供电;分boot重新采,不修剪坏行伪造完整
TIMEOUT/缺PCM查首音频是否收到、解码/重采样/队列日志;先SD静音,再查服务/堆/PSRAM,恢复同版本完整镜像,不把超时赋0ms
字符串存在却没新编译比source/输入/obj编译日志与ELF专属标记;本工具刷新变化输入mtime。仅Exit0和bin文件不能证明当前源已进入镜像
换回旧版本关闭SD/功放外供,保存本次原log/镜像和配置,使用完整原下载包+verify_project+新空build,按该次地址表恢复;不要改SDK或删managed锁

参考:[10] 固定本板config:I2S/按钮/OLED/未接GPIO48[19] 固定CMake:本板工厂、14px字体与lang_header[28] 固定v2.4.0应用manifest:依赖与IDF最低约束[26] 实际esp_audio_effects1.3.0~1来源:rate converter格式/采样率/API约束

10 / 语音项目

本次实际做过的检查与空白物理记录

作者验证按来源、构建、主机和实物分别记录。正常报告、成功返回和编译通过都不是对话/声音/光学成功;下面的样本标签保留其真实来源。

检查实际结果边界
真实固件IDF5.5.5实际增量build Exit0/输入稳定2631520B bin;28产品/原日志与全部输入按阶段COW留存
同源主机生产Recorder8组、完整双transport体4组;真实文件20/19门限/6拒绝+失败降级、OS PTY240行全部使用明确桩/合成时间,非音频/无线/屏幕实测
实际文件/数据五项防stale工具检查使用真实桌面Ninja/文件;分位数只在各项≥20样本输出字体/ELF/文件计算不能替代实体结果
实体会话/声音/显示/光未执行,物理表只有表头20次实体尝试/后端/声学待测

参考:[13] 固定Application:事件、手动采音、协议回调与主循环[12] 固定AudioService:任务、队列、解码/重采样与输出[19] 固定CMake:本板工厂、14px字体与lang_header[18] ESP-IDF5.5.5 esp_timer:64位开机单调微秒

11 / 语音项目

完整固定RAM记录器

以下与下载包当前文件逐字对应。Application/音频/协议的完整原文件、所有新增头、完整差异、配置/依赖锁、README和许可均随包;CURRENT-SOURCE.json核完整来源树,不靠截断片段重建工程。

代码 / 配置
// ---- firmware/main/lab_latency_policy.h ----
// SPDX-License-Identifier: MIT
#pragma once
#include <array>
#include <cstddef>
#include <cstdint>
#include <limits>
#include <mutex>
namespace a09 {
enum class Event : uint8_t {
    BEGIN,
    LISTEN_CMD_RETURN,
    MIC_CHUNK_RETURN,
    FIRST_SEND_RETURN,
    STOP_LISTEN_RETURN,
    STT_JSON,
    TTS_START_JSON,
    FIRST_TTS_SENTENCE_JSON,
    FIRST_TTS_AUDIO_CALLBACK,
    FIRST_PCM_WRITE_ENTER,
    FIRST_PCM_WRITE_RETURN,
    NETWORK_FAILURE,
    SEND_FAILURE,
    DECODE_QUEUE_REJECT,
    TIMEOUT,
    REENTRY,
    CLOCK_INVALID,
    END,
    COUNT
};
inline const char* name(Event e) {
    constexpr const char* names[] = {"BEGIN",
                                     "LISTEN_CMD_RETURN",
                                     "MIC_CHUNK_RETURN",
                                     "FIRST_SEND_RETURN",
                                     "STOP_LISTEN_RETURN",
                                     "STT_JSON",
                                     "TTS_START_JSON",
                                     "FIRST_TTS_SENTENCE_JSON",
                                     "FIRST_TTS_AUDIO_CALLBACK",
                                     "FIRST_PCM_WRITE_ENTER",
                                     "FIRST_PCM_WRITE_RETURN",
                                     "NETWORK_FAILURE",
                                     "SEND_FAILURE",
                                     "DECODE_QUEUE_REJECT",
                                     "TIMEOUT",
                                     "REENTRY",
                                     "CLOCK_INVALID",
                                     "END"};
    return e < Event::COUNT ? names[static_cast<unsigned>(e)] : "INVALID";
}
struct Row {
    uint64_t seq = 0, turn = 0, us = 0;
    Event event = Event::BEGIN;
    int32_t detail = 0;
};
struct Report {
    std::array<Row, 20> rows{};
    std::size_t count = 0;
    bool overflow = false;
};
// No I/O/allocation while locked. Only a completed report may be taken/flushed.
// Does not change the upstream conversation, audio, or network state.
class Recorder {
public:
    uint64_t begin(uint64_t now) {
        std::lock_guard<std::mutex> lock(mutex_);
        if (active_) {
            terminal(Event::REENTRY, now);
            return 0;
        }
        if (ready_ || turn_ == std::numeric_limits<uint64_t>::max())
            return 0;
        ++turn_;
        start_ = now;
        mask_ = 0;
        count_ = 0;
        overflow_ = false;
        active_ = true;
        put(Event::BEGIN, now, 0);
        return turn_;
    }
    uint64_t epoch() {
        std::lock_guard<std::mutex> lock(mutex_);
        return active_ ? turn_ : 0;
    }
    bool mark(uint64_t epoch, Event event, uint64_t now, int32_t detail = 0) {
        std::lock_guard<std::mutex> lock(mutex_);
        if (!active_ || !epoch || epoch != turn_ || event >= Event::COUNT ||
            event == Event::BEGIN || event == Event::END)
            return false;
        if (now < start_) {
            terminal(Event::CLOCK_INVALID, start_);
            return false;
        }
        const auto bit = uint32_t(1) << static_cast<unsigned>(event);
        if (mask_ & bit)
            return false;
        put(event, now, detail);
        if (event == Event::FIRST_PCM_WRITE_RETURN)
            finish(now, 0);
        return true;
    }
    void fail_epoch(uint64_t epoch, Event reason, uint64_t now) {
        std::lock_guard<std::mutex> lock(mutex_);
        if (active_ && epoch && epoch == turn_)
            terminal(now < start_ ? Event::CLOCK_INVALID : reason, now < start_ ? start_ : now);
    }
    void fail(Event reason, uint64_t now) {
        std::lock_guard<std::mutex> lock(mutex_);
        if (active_)
            terminal(now < start_ ? Event::CLOCK_INVALID : reason, now < start_ ? start_ : now);
    }
    void poll(uint64_t now) {
        std::lock_guard<std::mutex> lock(mutex_);
        if (active_ && now < start_)
            terminal(Event::CLOCK_INVALID, start_);
        else if (active_ && now - start_ >= 60000000ULL)
            terminal(Event::TIMEOUT, now);
    }
    bool take(Report& out) {
        std::lock_guard<std::mutex> lock(mutex_);
        if (!ready_)
            return false;
        out.rows = rows_;
        out.count = count_;
        out.overflow = overflow_;
        ready_ = false;
        count_ = 0;
        return true;
    }

private:
    void put(Event e, uint64_t now, int32_t detail) {
        if (count_ == rows_.size()) {
            overflow_ = true;
            active_ = false;
            ready_ = true;
            return;
        }
        rows_[count_++] = {++sequence_, turn_, now, e, detail};
        mask_ |= uint32_t(1) << static_cast<unsigned>(e);
    }
    void finish(uint64_t now, int32_t reason) {
        put(Event::END, now, reason);
        active_ = false;
        ready_ = true;
    }
    void terminal(Event e, uint64_t now) {
        put(e, now, 0);
        finish(now, static_cast<int32_t>(e));
    }
    std::mutex mutex_;
    std::array<Row, 20> rows_{};
    uint64_t turn_ = 0, sequence_ = 0, start_ = 0;
    uint32_t mask_ = 0;
    std::size_t count_ = 0;
    bool active_ = false, ready_ = false, overflow_ = false;
};
}  // namespace a09

参考:[13] 固定Application:事件、手动采音、协议回调与主循环[12] 固定AudioService:任务、队列、解码/重采样与输出[14] 固定Protocol内存packet与wire边界[18] ESP-IDF5.5.5 esp_timer:64位开机单调微秒

12 / 语音项目

完整生产时钟与完成后UART输出

以下与下载包当前文件逐字对应。Application/音频/协议的完整原文件、所有新增头、完整差异、配置/依赖锁、README和许可均随包;CURRENT-SOURCE.json核完整来源树,不靠截断片段重建工程。

代码 / 配置
// ---- firmware/main/lab_latency.h ----
// SPDX-License-Identifier: MIT
#pragma once
#include <esp_log.h>
#include <esp_random.h>
#include <esp_timer.h>
#include "lab_latency_policy.h"
#if CONFIG_USE_AUDIO_PROCESSOR || !CONFIG_WAKE_WORD_DISABLED
#error \
    "A09 trace scope is the locked raw/manual configuration; re-evaluate epoch propagation for AFE/wake modes"
#endif
namespace a09 {
inline uint64_t now() { return static_cast<uint64_t>(esp_timer_get_time()); }
inline Recorder& recorder() {
    static Recorder r;
    return r;
}
inline uint64_t epoch() { return recorder().epoch(); }
inline void mark(uint64_t turn, Event event) { recorder().mark(turn, event, now()); }
inline void fail(Event event) { recorder().fail(event, now()); }
inline void fail_epoch(uint64_t turn, Event event) { recorder().fail_epoch(turn, event, now()); }
// Called by Application main-loop owner only, after a report has completed.
// UART is never written from an audio task, or while this measurement is active.
inline void flush() {
    recorder().poll(now());
    Report report;
    if (!recorder().take(report))
        return;
    static const uint32_t boot = esp_random();  // observation label, not authentication
    for (std::size_t i = 0; i < report.count; ++i) {
        const auto& r = report.rows[i];
        ESP_LOGI("a09", "A09v1 boot=%08lx seq=%llu turn=%llu event=%s us=%llu detail=%ld",
                 static_cast<unsigned long>(boot), static_cast<unsigned long long>(r.seq),
                 static_cast<unsigned long long>(r.turn), name(r.event),
                 static_cast<unsigned long long>(r.us), static_cast<long>(r.detail));
    }
    if (report.overflow)
        ESP_LOGE("a09", "A09v1_OVERFLOW boot=%08lx", static_cast<unsigned long>(boot));
}
}  // namespace a09

参考:[13] 固定Application:事件、手动采音、协议回调与主循环[12] 固定AudioService:任务、队列、解码/重采样与输出[14] 固定Protocol内存packet与wire边界[18] ESP-IDF5.5.5 esp_timer:64位开机单调微秒

13 / 语音项目

完整本地时间线分析器

以下与下载包当前文件逐字对应。Application/音频/协议的完整原文件、所有新增头、完整差异、配置/依赖锁、README和许可均随包;CURRENT-SOURCE.json核完整来源树,不靠截断片段重建工程。

代码 / 配置
# ---- tools/analyze_trace.py ----
#!/usr/bin/env python3
# SPDX-License-Identifier: MIT
"""Analyze locally observed A09 stages; never derives server first-token/acoustic time."""
from pathlib import Path
import argparse,csv,hashlib,json,math,re,statistics
PATTERN=re.compile(r'A09v1 boot=([0-9a-f]{8}) seq=(\d+) turn=(\d+) event=([A-Z_]+) us=(\d+) detail=(-?\d+)(?:\s|$)')
EVENTS={'BEGIN','LISTEN_CMD_RETURN','MIC_CHUNK_RETURN','FIRST_SEND_RETURN','STOP_LISTEN_RETURN','STT_JSON','TTS_START_JSON','FIRST_TTS_SENTENCE_JSON','FIRST_TTS_AUDIO_CALLBACK','FIRST_PCM_WRITE_ENTER','FIRST_PCM_WRITE_RETURN','NETWORK_FAILURE','SEND_FAILURE','DECODE_QUEUE_REJECT','TIMEOUT','REENTRY','CLOCK_INVALID','END'}
METRICS={'setup_to_mic_ms':('BEGIN','MIC_CHUNK_RETURN'),'mic_to_send_call_return_ms':('MIC_CHUNK_RETURN','FIRST_SEND_RETURN'),'stop_to_subtitle_ms':('STOP_LISTEN_RETURN','FIRST_TTS_SENTENCE_JSON'),'stop_to_audio_callback_ms':('STOP_LISTEN_RETURN','FIRST_TTS_AUDIO_CALLBACK'),'audio_callback_to_pcm_call_return_ms':('FIRST_TTS_AUDIO_CALLBACK','FIRST_PCM_WRITE_RETURN'),'local_begin_to_pcm_call_return_ms':('BEGIN','FIRST_PCM_WRITE_RETURN')}
def load(path):
 data=path.read_text();turns={};seq={};boots=set()
 for number,line in enumerate(data.splitlines(),1):
  if 'A09v1_OVERFLOW' in line:raise ValueError(f'recorder overflow at line{number}; reject capture')
  if 'A09v1 'not in line:continue
  m=PATTERN.search(line)
  if not m:raise ValueError(f'malformed trace line{number}')
  boot,sequence,turn,event,us,detail=m.groups();sequence,turn,us,detail=map(int,(sequence,turn,us,detail));boots.add(boot)
  if event not in EVENTS or not 0<sequence<=0xffffffffffffffff or not 0<turn<=0xffffffffffffffff or us>0x7fffffffffffffff or not -0x80000000<=detail<=0x7fffffff:raise ValueError(f'event/range error line{number}')
  if boot in seq and sequence!=seq[boot]+1:raise ValueError(f'missing/duplicate/reordered serial sequence line{number}')
  seq[boot]=sequence;row=turns.setdefault((boot,turn),{})
  if event in row:raise ValueError(f'duplicate stage line{number}')
  row[event]=(us,detail)
 if len(boots)>1:raise ValueError('multiple boot labels: split captures and record resets in attempts.csv')
 rows=[]
 for (boot,turn),stages in turns.items():
  if 'BEGIN'not in stages or 'END'not in stages:raise ValueError('truncated turn: preserve raw file and recapture')
  begin=stages['BEGIN'][0];end,reason=stages['END'];failures={'NETWORK_FAILURE','SEND_FAILURE','DECODE_QUEUE_REJECT','TIMEOUT','REENTRY','CLOCK_INVALID'};status='complete'if reason==0 and'FIRST_PCM_WRITE_RETURN'in stages and not failures.intersection(stages) else'failed'
  if end<begin or any(us<begin or us>end for us,_ in stages.values()):raise ValueError('stage outside local window')
  values={};missing=[]
  for name,(a,b)in METRICS.items():
   if a not in stages or b not in stages:values[name]=None;missing.append(name)
   elif stages[b][0]<stages[a][0]:values[name]=None;missing.append(name+':ordering');status='ambiguous'
   else:values[name]=(stages[b][0]-stages[a][0])/1000
  rows.append({'boot':boot,'turn':turn,'status':status,'end_reason':reason,'missing':missing,**values})
 return rows

def percentile(values,p):
 values=sorted(values);position=(len(values)-1)*p;lo=math.floor(position);hi=math.ceil(position)
 return values[lo]+(values[hi]-values[lo])*(position-lo)
def main():
 parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('capture',type=Path);parser.add_argument('--origin',choices=['serial-unverified','synthetic-fixture'],required=True);parser.add_argument('--attempts',type=Path);parser.add_argument('--min-complete',type=int,default=20);parser.add_argument('--output',type=Path,required=True);args=parser.parse_args()
 if args.min_complete<20:parser.error('min-complete must be at least20')
 if args.origin=='serial-unverified'and not args.attempts:parser.error('physical method requires the complete manual attempts.csv including reset/no-report rows')
 try:
  rows=load(args.capture);attempts=list(csv.DictReader(args.attempts.open(newline='')))if args.attempts else[]
  if args.attempts and len(attempts)<len(rows):raise ValueError('manual attempts cannot be fewer than reports')
 except (ValueError,OSError)as error:parser.exit(2,str(error)+'\n')
 complete=[r for r in rows if r['status']=='complete'];metrics={}
 for name in METRICS:
  values=[r[name]for r in complete if r[name]is not None]
  metrics[name]={'validSamples':len(values),'median':statistics.median(values)if len(values)>=args.min_complete else None,'p90':percentile(values,.9)if len(values)>=args.min_complete else None,'p95':percentile(values,.95)if len(values)>=args.min_complete else None}
 summary={'origin':args.origin,'physicalValidation':'not established by this tool; origin is a caller label','captureSha256':hashlib.sha256(args.capture.read_bytes()).hexdigest(),'reportedTurns':len(rows),'completeReports':len(complete),'completeDefinition':'normal END+firstPCMcallreturn withoutfailureevent; not allmetricstages, entireaudio, or successfuldialogue','failedOrAmbiguousReports':len(rows)-len(complete),'manualAttemptRows':len(attempts),'unreportedAttempts':len(attempts)-len(rows)if args.attempts else None,'minimumSamplesPerMetric':args.min_complete,'quantileMethod':'linear interpolation at (n-1)*p; failures/missing stages counted, not assigned zero','metrics':metrics,'turns':rows,'notMeasured':['server STT/LLM/TTS internals','server first token','acoustic onset','wireless ACK or remote receipt','trace probe overhead']}
 args.output.write_text(json.dumps(summary,ensure_ascii=False,indent=2)+'\n');print(json.dumps({k:summary[k]for k in ['origin','reportedTurns','completeReports','failedOrAmbiguousReports','manualAttemptRows','unreportedAttempts']},ensure_ascii=False));print(args.output)
if __name__=='__main__':main()

参考:[13] 固定Application:事件、手动采音、协议回调与主循环[12] 固定AudioService:任务、队列、解码/重采样与输出[14] 固定Protocol内存packet与wire边界[18] ESP-IDF5.5.5 esp_timer:64位开机单调微秒

BACK TO THE SOURCE

资料来源与版本说明

固定上游、SDK/Registry实际文件及具体厂家范围已核;主机合成记录、文件/字体算术与构建不替代指定硬件、后端、声学、显示或光学实验。资料核对日期:。资料核对、程序构建与实板测试分别记录,不互相替代。

  1. S3 DevKitC1 v1.1固定文档:J1/J3、电源与USB
  2. ICS43434 DS-000069 v1.2:电气/边沿/24bit/启动
  3. PID6049固定PCB:3V、SEL与47k数据下拉
  4. MAX98357A/MAX98357B Rev7:供电/采样率/SD/GAIN/BTL
  5. PID3006固定单声道PCB:VIN/SD1M/BTL端子
  6. PID1313扬声器2017-02-14厂家规格:8Ω额定1W
  7. PID6049产品页:具体型号与停产边界
  8. ESP32-S3-WROOM-1/1U v1.8:N16R8规格表
  9. PID4440固定revB PCB:上拉/电平转换/自动复位/128×32
  10. 固定本板config:I2S/按钮/OLED/未接GPIO48
  11. 固定Simplex codec:板参数/32bit槽/右移限幅/音量
  12. 固定AudioService:任务、队列、解码/重采样与输出
  13. 固定Application:事件、手动采音、协议回调与主循环
  14. 固定Protocol内存packet与wire边界
  15. 固定WebSocket SendAudio三协议版本
  16. 固定MQTT/UDP SendAudio加密nonce与输出
  17. 固定AfeAudioEngine:raw Feed/OutputRawAudio同步路径
  18. ESP-IDF5.5.5 esp_timer:64位开机单调微秒
  19. 固定CMake:本板工厂、14px字体与lang_header
  20. 固定DeviceStateMachine:合法状态转换与监听
  21. 固定OledDisplay:128×32布局/字幕滚动
  22. 固定LvglDisplay:状态/通知/锁与字形
  23. 固定McpServer:解析、调度、回包/异常
  24. 固定McpTool/PropertyList与callback契约
  25. 实际Registry 78/xiaozhi-fonts2.0.0发布包:字体/许可元数据
  26. 实际esp_audio_effects1.3.0~1来源:rate converter格式/采样率/API约束
  27. IDF5.5.5 LCD I2C真实字段顺序
  28. 固定v2.4.0应用manifest:依赖与IDF最低约束
  29. 固定Assets::Apply:common字体资源替换与字形能力
  30. 固定default assets:basic→common CBIN打包