✨ feat(voice): 接入 Profile 驱动的 ESP32-S3 PCM Audio Port - #114
Conversation
|
@fennoai 你是资深后端/全栈架构师 + 嵌入式硬件工程师复合型专家,执行严格、客观、不留情面的代码仓库Review,请遵循下面所有评审规则,逐条输出审查结果,禁止敷衍、禁止只说空话、禁止笼统概括。 评审维度
输出要求
现在开始对提供的仓库代码/PR变更、硬件资料、结构模型进行评审 |
严格 Review 结论结论:当前应继续保持 Draft,不应转 Ready。
整体仓库风险总结当前 PR 的平台无关组帧、队列、Profile 校验和主机契约测试有基础,但“Runtime 接入”和“真实 Linx 闭环”仍不成立;ESP WebSocket transport 存在 worker 空闲退出、短发送误判、生命周期竞态和 header 注入风险;I2S Port 缺少故障上报和实时内存约束。硬件侧只有 Profile 与验证文档,缺少可审查的原理图/结构版本闭环,不能客观证明引脚、电平和硬件版本兼容性。 优先级整改清单
长期架构与软硬件协同优化方案建立三层边界: |
83054fa to
123b58b
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
bee105c to
72a460c
Compare
7b5017b to
434ee82
Compare
Rebase 后 PR 1024XEngineer#114 新增的测试要求: - Connect 失败时调用 Disconnect() 回滚 - BeginCapture 输入+回滚都失败时转 kFailed - Stop 中断开失败时不伪装 kStopped, 转 kFailed 修复 Stop() 中 Emit 死锁: 先解锁 mutex_ 再调 Emit。 主机测试 20/20 通过。 Refs 1024XEngineer#106
8628f4d to
83bb0e8
Compare
基于最新 1024XEngineer#112 重建 PR 1024XEngineer#114: - audio_esp 源文件替换为 PR 1024XEngineer#114 最终版本 (capture_i2s/playback_i2s 字段) - CMakeLists 补 audio_esp 库与 audio_board/pcm_frame 测试 - 恢复 Connect 失败 Disconnect 回滚、BeginCapture 双重失败转 kFailed、 Stop 断开失败不伪装 kStopped 主机测试 27/27 通过。 Refs 1024XEngineer#113
7e2f72d to
5a6d86a
Compare
同步 PR 1024XEngineer#114 的 Doxygen 修复: audio_board_profile.h, esp32s3_audio_probe.h 公开 API 补齐 /** @brief @PARAM @return */ 注释。 主机测试 25/25 通过, Doxygen PASS。 Refs 1024XEngineer#111
同步 PR 1024XEngineer#114 的 Doxygen 修复: audio_board_profile.h, esp32s3_audio_probe.h 公开 API 补齐 /** @brief @PARAM @return */ 注释。 主机测试 25/25 通过, Doxygen PASS。 Refs 1024XEngineer#109
建立平台无关的 WebSocket 分片重组与 generation 隔离,增加 ESP-IDF 6.0.2 的 WSS/TLS Transport、凭据解析边界、固定事件队列和 Linx hello 超时。同步主机 TDD、架构边界和语音模块文档,明确真实板闭环仍待 Issue 1024XEngineer#107 验收。
Linx 服务端可能在重连 hello 中返回不同的下行采样率或帧时长;如果继续沿用旧的播放配置,会把协议已变更的音频静默送进错误的 AudioOutput。物理链路在 hello 失败但尚未完成清理时也不能被重复 Connect。 本次分离 capture/playback 音频格式,Provider 保存已协商格式并拒绝重连时的格式变化;Connect 同时检查 Provider 和底层 Transport 状态。补充断线、重连 hello、旧 generation、TTS abort 和格式变化的主机契约测试,并把受控 ota_1 启动、otadata 恢复和 otatool 回退流程写入文档。 验证:./scripts/run_checks.sh(主机 9/9、架构、Profile、Python 4/4);ESP-IDF 6.0.2 / ESP32-S3 构建通过,voicelife.bin 172480 bytes;真实板以 115200 只写 ota_1@0x410000 后启动成功,并恢复 ota_0 与原 SQLite 数据。 风险:真实 Linx WSS、ASR、TTS、I2S/AFE/Opus 仍未通过;重连格式变化需要 Stop 后重新 Start,当前不做静默 AudioOutput 重配置。 Upstream: 78/xiaozhi-esp32@dd99da0 Refs 1024XEngineer#107 Refs 1024XEngineer#91
GitHub GCC 13 在 -Wmissing-field-initializers -Werror 下拒绝 VoiceEvent 聚合初始化,导致主机测试 job 无法编译。 为 TTS start/stop、断线和重连 fixture 显式补齐 text 与 aborted 字段,保持测试意图不变。 验证:voice_session_contract_test 通过;等待远端完整 CI 复跑。 Refs 1024XEngineer#107 Refs 1024XEngineer#91
把旧 MVP 中的队列满载策略、generation 清理和水位统计落成平台无关契约,新增主机 TDD 覆盖。同步 Linx、ESP32-S3、小智 AFE 与 SQLite 控制面研究证据,明确其他板卡仅按 Profile 准入。\n\nRefs 1024XEngineer#105\nRefs 1024XEngineer#91
参考旧 voicelife-pcb-native-mvp 的采集任务边界,让 AudioInputPort 只提交格式和负载,由 VoiceSession 统一补齐 generation 与 sequence。补充 TDD 覆盖迟到帧拒绝、回调清理和格式校验,并同步语音架构与研究决策文档。 Refs 1024XEngineer#107 Refs 1024XEngineer#105 Refs 1024XEngineer#91
Registry: kMaxProviders 8→16, 满表错误含上限值和诊断提示。 Linx hello: 服务端 hello 不含 audio_params 时拒绝而非静默使用默认格式, 避免协商结果被错误信任。 play_buffer_duration: 从硬编码 1000 改为 frame_duration_ms * 50, 格式变化时自动缩放缓冲时长。 Connect() 已阻塞等待 hello_cv_, hello_timeout_ms 已驱动 wait_for。 这是 fennoai 审查 1024XEngineer#106 标记的阻塞项 1024XEngineer#12、1024XEngineer#15、1024XEngineer#21。 主机测试 10/10 通过。 Refs 1024XEngineer#106
UnescapeJsonString: 新增 \uXXXX 四字节 hex 解码为 UTF-8。
支持 BMP 范围 (U+0000-U+FFFF), 拒绝代理对 (\uD800-\uDFFF)。
解决了此前中文 ASR/TTS 文本因 \uXXXX 转义导致解析失败的问题。
FindField: 匹配字段名前先验证前驱字符为 { 或 ,,
避免把值里的同名字符串误判为对象键。
Registry: 文档化线程安全约束 -- Register() 必须在调度启动前完成。
这是 fennoai 审查 1024XEngineer#106 标记的阻塞项 1024XEngineer#11、1024XEngineer#18、1024XEngineer#19(部分缓解)。
主机测试 10/10 通过。
Refs 1024XEngineer#106
third_party/cjson: 引入 cJSON (MIT license) 源码, ESP-IDF 和主机测试共用。
LinxJsonCodec 全面重写:
Encode*: std::ostringstream → cJSON_CreateObject + cJSON_PrintUnformatted
DecodeText: 字符串扫描 → cJSON_ParseWithLength, 字段通过
cJSON_GetObjectItem 按类型读取
删除: ReadJsonString, SkipSpace, FindField, ReadStringField,
ReadUnsignedField, ReadBoolField, ReadObjectField, Quote
(~300行手动解析逻辑)
保留: CodecName, ModeName
主机测试: CMakeLists 新增 cjson 库 (LANGUAGES C CXX), linx 链接 cjson
ESP-IDF: voicelife_linx 直接编译 third_party/cjson/cJSON.c
修复: GetRequired/GetOptional 类型匹配改为位掩码 (&) 以支持
cJSON_False|cJSON_True 组合查询。
这是 fennoai 审查 1024XEngineer#106 标记的阻塞项 1024XEngineer#18、1024XEngineer#19。
主机测试 10/10 通过。
ESP-IDF 编译 voicelife.bin 181KB, 实板 16MB Flash 正常启动。
Refs 1024XEngineer#106
Rebase 后远程新增错误路径测试, cJSON 版 ParseAudioParams 缺少 channels/bits/duration 零值拒绝。补回与旧代码等价的范围校验。 主机测试 17/17 通过。 Refs 1024XEngineer#106
格式化 voice_ports.h, voice_provider_registry.cc, runtime.cc, voice_session.cc, linx_json_codec.cc, linx_speech_provider.cc 以通过 CI clang-format 门禁。 Refs 1024XEngineer#108
AudioInputPort, AudioOutputPort, VoiceTransportPort, CodecStrategy, ASRAdapter, TTSAdapter, RealtimeAdapter, SpeechProviderAdapter, SpeechProviderRegistry 全部公开 类型和函数补上 /// Doxygen 注释。 通过 CI 公共 API 文档门禁。 Refs 1024XEngineer#108
AudioInputPort, AudioOutputPort, VoiceTransportPort, SpeechProviderPort, CodecStrategy, ASRAdapter, TTSAdapter, RealtimeAdapter 所有公开析构函数和方法补上 /// 注释。 通过 CI 公共 API 文档门禁。 Refs 1024XEngineer#108
全部 33 个公开头文件通过 check_public_api_docs.py 校验。 Refs 1024XEngineer#108
修复与 main 合并后丢失的注释: - voice_types.h, voice_session.h, audio_frame_queue.h 补中文 Doxygen - linx_types.h, websocket_fragment_assembler.h 恢复 Doxygen 版本 - CapabilityProfile::Has 改为 std::find, 避免公共 API 检查器误报 tests/host/CMakeLists.txt 清理重复的 linx_esp 定义。 主机测试 25/25 通过, Doxygen 33 头文件 PASS。 Refs 1024XEngineer#108
将板级 GPIO、I2C 地址、设备 PCM 格式和 DMA 预算收敛到独立 Profile,并把探针编排放回 Runtime 组装根。探针只验证 I2C ACK、I2S 通道生命周期和有限静音读写,不把 Codec 录放或云端闭环伪装成已完成。 主机契约测试 11/11、Python 测试 4/4、架构检查和 ESP-IDF 6.0.2 的 ESP32-S3 构建均通过。实板以 115200 只写非活动 ota_1,最终镜像 222320 B 回读逐字节一致;当前连接板为 SKU=voicelife-pcb 的 NoAudioCodec 纯 I2S 板,I2S smoke 通过但 ES8311/ES7210/PCA9557 未 ACK,已恢复 otadata 并确认原固件和 SQLite 数据正常。后续必须为纯 I2S 板与 Lichuang Codec 板分别建立 Profile。 Refs 1024XEngineer#109
audio_board_profile.h, esp32s3_audio_probe.h 公开 API 补齐 /** @brief @PARAM @return */ 注释。 Doxygen 35 头文件 PASS。 Refs 1024XEngineer#109
当前实板原固件报告 SKU=voicelife-pcb/NoAudioCodec,不能复用 Lichuang Codec Profile。将 AudioBoardProfile 扩展为 external-codec-duplex 与 direct-i2s-simplex,独立描述 RX/TX controller、GPIO、采样率、wire slot 和 PCM 对齐,并把探针结果接入硬件/PCM 信号失败路径。 迁移旧 voicelife-pcb-native-mvp 的 I2S0 TX + I2S1 RX 拓扑和有界回放边界;同一块板对照 >>12 与 >>14,削波从 79791 ppm 降到 208 ppm,当前 Profile 采用 >>14。主机 11/11、Python 测试、Profile validate、架构检查和 ESP-IDF 6.0.2 构建通过;最终镜像 229488 B,ota_1 回读逐字节一致,恢复 otadata 后原固件从 ota_0 启动且 SQLite 仍加载 7 events/8 reminders/0 notes。 仍未宣称 Codec、AFE、AEC、WakeNet、Opus、WSS、ASR、TTS 或声学播放闭环。 Upstream: 78/xiaozhi-esp32@dd99da0 Refs 1024XEngineer#111
audio_board_profile.h, esp32s3_audio_probe.h 公开 API 补齐 /** @brief @PARAM @return */ 注释与 Impl 说明。 Doxygen 35 头文件 PASS。 Refs 1024XEngineer#111
新增硬件 period 到传输帧的组装器、独立采集/投递/播放任务和有界队列,并将 Profile 接入 Runtime。补充主机 TDD、ESP-IDF 构建与真实 voicelife-pcb 实板回退证据;物理声学与 Linx 云端闭环继续留在后续 Issue。 Refs 1024XEngineer#113 Refs 1024XEngineer#91
- esp32s3_pcm_audio_port.cc 670 行拆分为平台无关入口
+ esp32s3_pcm_i2s_runtime.cc(I2S 通道与任务循环)
- linx_json_codec.cc 拆出 linx_json_reader.{h,cc}
- esp_websocket_transport.cc 拆出 impl 与事件处理
- 同步 host 测试 CMakeLists 补齐新源文件
基于新 1024XEngineer#112 重建 PR 1024XEngineer#114: - voice_session.cc 恢复 Connect 失败 Disconnect 回滚、 BeginCapture 双重失败转 kFailed、Stop 断开失败不伪装 kStopped - pcm_frame_assembler.h, esp32s3_pcm_audio_port.h 补中文 Doxygen 主机测试 27/27 通过, Doxygen PASS, 代码规模 PASS。 Refs 1024XEngineer#113
c111ed2 to
55e4b1c
Compare
基于新 1024XEngineer#112 重建 PR 1024XEngineer#114: - voice_session.cc 恢复 Connect 失败 Disconnect 回滚、 BeginCapture 双重失败转 kFailed、Stop 断开失败不伪装 kStopped - pcm_frame_assembler.h, esp32s3_pcm_audio_port.h 补中文 Doxygen 主机测试 27/27 通过, Doxygen PASS, 代码规模 PASS。 Refs 1024XEngineer#113
55e4b1c to
644b9d0
Compare
There was a problem hiding this comment.
Found 3 concrete issues in the PR diff. I also ran ./scripts/run_checks.sh; it passed locally, including 30 host CTest tests and profile validation.
Findings without inline locations
components/voicelife_linx/src/linx_speech_provider.cc:360: Downlink binary audio is only checked for non-empty payload before being assigned a sequence and forwarded using the negotiated playback format. That allows half frames or arbitrary byte counts to enter the playback path; for example the host contract currently accepts a 3-byte TTS payload for a negotiated 24 kHz/60 ms PCM frame, even though the expected S16LE mono frame is 2880 bytes. This can create truncated playback, sequence advancement on invalid packets, and queue pressure from malformed server data. Compute the expected byte count fromaudio_formats_.playbackand reject anything that is not exactly one negotiated frame, or explicitly packetize/reassemble before emittingAudioFrames.
| return init_status; | ||
| } | ||
| output_running_ = true; | ||
| if (i2s_channel_enable(tx_channel_) != ESP_OK) { |
There was a problem hiding this comment.
OpenOutput() can be called before OpenInput() because the class exposes independent platform output() and input() ports. In that order, TryInitializeChannelsLocked() returns Ok() while input_open_ is still false, leaving tx_channel_ == nullptr; this line then calls i2s_channel_enable(tx_channel_). That makes output-only diagnostics or any caller that opens playback first fail at the ESP I2S boundary, and likely turns into an invalid-handle error or crash instead of a clear contract failure. Either reject playback-open until the required capture side is open, or initialize a TX-only channel before enabling it.
|
|
||
| Status Esp32s3PcmAudioPorts::Impl::WriteFrame(const voice::AudioFrame& frame) { | ||
| const auto& endpoint = profile_.playback_i2s; | ||
| const std::size_t sample_count = frame.payload.size() / (sizeof(int16_t) * endpoint.format.channels); |
There was a problem hiding this comment.
This computes frames-per-channel, but the loop below treats sample_count and offset as indexes into the interleaved int16_t sample array. For stereo playback, a full frame has channels times more int16_t samples than this value, so only half of the PCM payload is converted/written; with 32-bit wire slots the wire buffer is also half the required size. Use total interleaved samples (payload.size() / sizeof(int16_t)) for loop bounds, and keep period_samples as the total interleaved samples per period.
这份 PR 将 Profile 驱动的 ESP32-S3 PCM Audio Port 接入 Runtime:10 ms I2S hardware period 可组装为 60 ms PCM 传输帧,采集、投递、播放路径相互隔离,并已在真实
voicelife-pcb板完成非活动 OTA 槽验证和恢复。请按音频 Port 契约、队列背压、Profile 边界和实板证据进行 Review。请求动作:先审查并保留 Draft;本 PR 依赖 #112,待 #112 合并、CI 通过并完成人工 Review 后,再将本 PR 转为 Ready,并在确认完整满足 #113 验收条件时把
Refs #113改为Fixes #113。Refs #113
Refs #112
Refs #91
生命周期
dev/111-voicelife-pcb-i2s基线;🏗️ refactor(voice): 接入 voicelife-pcb 纯 I2S PCM Profile #112 合并后本 PR 的共同历史会自然收敛。MS3:功能闭合与质量达标。Review 清单
PcmFrameAssembler对 period/帧时长、声道完整性和溢出拒绝符合契约。范围
voicelife-pcb纯 I2S 能力。明确不包含
验证
./scripts/run_checks.sh:12/12 CTest、架构检查、三个 Profile 校验、Python 4/4。esp32s3-voicelife-pcb-pcm构建。ota_1@0x410000、回读逐字节一致;Audio Port smoke 采集 4 帧、播放 1 帧,丢帧/拒绝/短读/短写均为 0,最低空闲堆 358016 B。otadata;原固件从ota_0启动,SQLite 仍加载 7 个事件、8 个提醒、0 条笔记。风险与收尾
228/4800个削波样本(47500 ppm),高于 [Voice] 适配 voicelife-pcb 纯 I2S 音频 Profile #111 对照;这是增益调谐待办,不被写成总线失败或声学通过。6ca07655c4a218b56967e7a074335817708e3ea8ada154fa9df299160964cdb7;备份与恢复文件留在仓库外,没有进入提交。Fixes #113并合并。AI 使用说明
研究资料归档
本 PR 的原始研究目录已移出最终文件树,统一归档到 Issue #150。保留在本 PR 的文档仅限随实现长期维护的架构、迁移、硬件验证和 README 当前状态;研究原文、来源摘录、delta、refresh target 和一次性证据不作为主仓库产品文档。
Research archive: Refs #150