Skip to content

🐛 fix(sparkbot): 稳定表情动画与字幕时序 - #377

Open
ZhaoXingPeng wants to merge 1 commit into
1024XEngineer:mainfrom
ZhaoXingPeng:fix/sparkbot-emoji-scroll-fix
Open

🐛 fix(sparkbot): 稳定表情动画与字幕时序#377
ZhaoXingPeng wants to merge 1 commit into
1024XEngineer:mainfrom
ZhaoXingPeng:fix/sparkbot-emoji-scroll-fix

Conversation

@ZhaoXingPeng

Copy link
Copy Markdown
Collaborator

结论:本 PR 收敛 SparkBot 的中央说话动画、字幕滚动/时序和 Linx emotion 事件处理,并修复 ESP-IDF 工具链未加载时固件脚本无法构建的问题。当前版本已在 SparkBot 实板、Wi-Fi zxp、百炼真实 TTS 注入下完成 3/3 回合测试,链路、TTS、字幕和资源加载均通过。

请 Review 并按 Issue #376 验收;这是替代 PR #362 的最终实现,PR #362 不再承载后续代码。

Fixes #376

修复内容

  • 说话阶段强制使用 speaking.gif;Linx emotion/action 只在非 speaking 阶段更新,不会覆盖说话中的牛动画。
  • 增加 Linx emotion 受控 key 映射和独立 kLlmEmotion 事件,未知 key 回退本地 VoiceMood。
  • 首句字幕等待 tts_first_audio 后显示,后续句段按 tts_sentence_started 顺序更新。
  • 替换 LVGL 往返/循环滚动为显式单向左滚动;正文未变化时不重启动画,按 UTF-8 字符、标点和像素溢出估算时长。
  • 增加“矽”单字字体 fallback,并把中央 GIF 资源与底部字幕文字路径明确分离。
  • 提醒动作 worker 栈迁移到 PSRAM,减少 TLS/MCP 可用内部堆压力。
  • scripts/firmware.py 自动发现 ESP-IDF 6.x 与匹配 Python 环境,向构建子进程注入 IDF_PATHIDF_TOOLS_PATHIDF_PYTHON_ENV_PATHESP_IDF_VERSIONIDF_VERSION
  • 补充显示、Linx emotion、VoiceSession 和工具链契约测试。

验证

  • Host:94/94 通过。
  • Python:198 通过,1 个按条件跳过。
  • Ruff、Clang-format、架构检查、双端契约检查、Profile validate:全部通过。
  • ESP-IDF:6.0.2;清空 ESP-IDF 环境变量后仍可由 firmware.py 自动发现并构建。
  • 固件:voicelife.bin SHA256 6982e0dda5b4f7f1cae8b625349e59e7401b6c27be3471d314106ff4b4b1d3ce
  • assets:SHA256 7bc16f4712b7750bbbb04ca60cea69947c2e00ae7898387c84ef7cbe1b4badcb;model:SHA256 87c77c650bbafbfb63f7cca4979f70302ef34078429635d01f716f5ae01b8e5b
  • 实板:SparkBot MAC 98:a3:16:c7:81:b0,Wi-Fi zxp,端口 /dev/cu.usbmodem14201;刷写启动链、分区表、应用、assets、model,保留 NVS/FATFS。

实板百炼日志

测试时间:2026-08-26 20:18--20:20(本地时间);模型 cosyvoice-v3-flash,音色 longanhuan_v3

日志:test-artifacts/v376/after-fix/multiturn-20260826-201823.log

结果:

  • 3/3 回合完成;每回合均有 tts_startedtts_first_audio、多个 tts_sentence_startedtts_stopped
  • 1 个长故事回合产生 8 个完整句段,字幕滚动证据 scroll_observed=true;日志中的 scroll_mode=explicit_left、负 scroll_endscroll_duration_ms 均符合单向滚动设计。
  • 说话期间反复出现 SPARKBOT_GIF_STARTED asset=speaking,Linx emotion 只记录 DISPLAY_EMOTION_DEFERRED reason=speaking,没有覆盖 speaking GIF。
  • MCP 初始化和 tools/list 分页完整:首包约 2.7 KB,后续 cursor 页约 651/2991/2220 字节,均成功发送。
  • in_drop=0in_pool_fail=0out_reject=0short_write=0in_i2s_err=0out_i2s_err=0;无 LINX_WS_ERROR_EVENTLINX_TX_SEND_FAIL、panic 或 RST。
  • ASR 前两回合逐字匹配;第三回合服务端将输入中的“八”规范化为“8”,语义和回合流程完整,未发生截断。该字面差异不影响本次链路验收。

结果 JSON:test-artifacts/v376/after-fix/multiturn-20260826-201823.json

随后发起的等价儿童视角复测由操作者中止,未计入门禁,不覆盖上述已完成的 3/3 实板证据。

工作树与敏感信息

  • 提交范围不包含 test-artifacts/、构建目录或任何 API token/Authorization。
  • 明文串口日志仅保留在本地受控目录,供复核使用。

@codecov

codecov Bot commented Aug 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 85.63830% with 54 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...rc/infrastructure/reminder-action-expiry-worker.ts 80.71% 27 Missing ⚠️
components/voicelife_board_esp/src/sparkbot_imu.cc 83.33% 10 Missing and 6 partials ⚠️
...ife_display_sparkbot/src/sparkbot_lvgl_renderer.cc 78.57% 2 Missing and 4 partials ⚠️
...omponents/voicelife_runtime/src/linx_mcp_bridge.cc 0.00% 1 Missing and 1 partial ⚠️
services/im-gateway/src/application/services.ts 91.66% 2 Missing ⚠️
services/im-gateway/src/app/gateway-process.ts 96.42% 1 Missing ⚠️
@@            Coverage Diff             @@
##             main     #377      +/-   ##
==========================================
+ Coverage   90.27%   90.38%   +0.10%     
==========================================
  Files         213      216       +3     
  Lines       25697    26061     +364     
  Branches     6728     6859     +131     
==========================================
+ Hits        23199    23554     +355     
- Misses       1151     1166      +15     
+ Partials     1347     1341       -6     
Flag Coverage Δ
cpp 90.38% <85.63%> (+0.10%) ⬆️
typescript 91.96% <83.64%> (-0.01%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
...ard_esp/include/voicelife/board_esp/sparkbot_imu.h 100.00% <100.00%> (ø)
...onents/voicelife_board_esp/src/sparkbot_profile.cc 87.50% <ø> (ø)
...oicelife/display_sparkbot/sparkbot_lvgl_renderer.h 100.00% <100.00%> (ø)
...life_display_sparkbot/src/sparkbot_emoji_assets.cc 90.90% <ø> (ø)
...time/include/voicelife/runtime/platform_assembly.h 88.88% <ø> (ø)
services/im-gateway/src/application/api.ts 100.00% <100.00%> (ø)
...s/im-gateway/src/infrastructure/http/device-api.ts 95.02% <100.00%> (+1.22%) ⬆️
...way/src/infrastructure/http/gateway-http-server.ts 91.69% <100.00%> (+0.01%) ⬆️
...ateway/src/infrastructure/persistence/in-memory.ts 97.09% <100.00%> (+0.05%) ⬆️
...tructure/persistence/postgres/action-repository.ts 98.76% <100.00%> (+0.12%) ⬆️
... and 7 more

... and 8 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

审阅了显示渲染、Linx emotion 事件、VoiceSession 时序、Runtime 事件队列以及固件工具链发现逻辑。下面两处事件优先级/状态门控交互会导致真实回合中的字幕丢失或表情残留,建议在合入前修正。已运行 python3 -m unittest tests/python/test_firmware.py tests/python/test_im_wifi_credential_isolation.py(25/25 通过);完整主机构建受当前 GCC 对既有 [[maybe_unused]] 成员的 -Werror=attributes 报错阻塞。

EnqueueDisplayText(evidence.detail);
// 首句在首个真实音频包到达后显示,避免字幕领先声音;后续
// 句段沿用协议的 sentence-start 顺序,不伪造采样级时间戳。
if (!tts_first_audio_seen_) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Preserve subtitle events until first audio is handled

tts_first_audio and tts_sentence_started are queued as best-effort items, while tts_stopped remains a control item and the event loop always drains control items first. If Linx delivers the first-audio/sentence/stop burst while the loop is busy, tts_stopped is processed first: it clears pending_tts_sentences_ and moves the interaction out of kSpeaking; the queued sentence is then discarded by the stale-state check. This makes an otherwise valid response show no subtitle under normal queue backlog. Keep the first-audio/sentence ordering coupled with TTS completion (or make the gate state part of the ordered control path) so stop cannot overtake the pending subtitle work.

ESP_LOGI(kTag, "DISPLAY_EMOTION_DEFERRED reason=speaking key=%s", item.emotion_key.c_str());
continue;
}
snapshot_.mood = item.mood;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Drop deferred emotions from completed speaking turns

Emotion updates are explicitly placed in the best-effort queue, but interrupt/TTS-stop events use the control queue, which is drained first. An emotion received during speaking can therefore be applied only after an interrupt or tts_stopped has already returned the session to standby/ready. This branch then writes the old turn's emotion_key into the standby snapshot, so the robot can remain on a stale happy/thinking animation until a later display update. Associate the mood update with the speaking generation/phase and discard it when that turn is no longer active (or process it in the same ordered queue).

@ZhaoXingPeng

Copy link
Copy Markdown
Collaborator Author

IMU 官方实现核对结论(以 SparkBot 官方工厂示例为准)

结论:SparkBot 板载惯性传感器应按 BMI270 接入,不采用早期分支中的自定义加速度计方案。

已核对来源:

官方事实:

  • I2C 为 SDA=GPIO4、SCL=GPIO5、400 kHz,默认地址 0x68
  • 初始化走官方 BMI270 驱动;同时启用加速度计和陀螺仪。
  • 两个传感器均为 200 Hz;加速度计范围 ±2g、normal average-4,陀螺仪范围 ±2000 dps;任务每 10 ms 轮询。
  • 运行时将读取的加速度、角速度转换为 m/s²、dps,工厂示例以其支撑姿态/交互功能。

实现约束:

  • VoiceLife 的 ES8311 已创建 I2C0;IMU 只获取并复用该总线,绝不创建或销毁共享总线。
  • 强制启用 CONFIG_SENSOR_BMI270,启动时校验实际 chip ID 0x24。官方默认配置中的 BMI220 与代码/硬件资料矛盾,不能据此误接入。
  • 首笔只交付独立 SparkBotImu、可测的摇晃事件和串口证据;不把“摇晃后动画/说话”作为未验证的 UI 行为一并引入。
  • 历史提交 0ae06dd 仅作对比:其 5 ms 单加速度计、自定义适配和生命周期处理都不满足上述官方基线,不直接 cherry-pick。

验收证据要求:实板必须出现 SPARKBOT_IMU_READY sensor=BMI270 addr=0x68、实际 chip ID 与样本日志;只编译通过不等于确认板载 IMU 可用。

@ZhaoXingPeng

Copy link
Copy Markdown
Collaborator Author

WakeNet / MultiNet 唤醒优化核对结论

当前固件走的是 MultiNet7 mn7_cn,不是可直接在仓库内微调的 WakeNet 权重。当前命令为 你好牛牛牛来别说了;门限 0.20,ES8311 麦克风增益 30 dB,输入 16 kHz。

已完成的可重复评估资产保存在仓库外:

  • 数据集:/Users/mac/Desktop/VoiceLife-audio-artifacts/2026-08-26/multinet-wake-dataset/
  • 实板结果:/Users/mac/Desktop/VoiceLife-audio-artifacts/2026-08-26/multinet-board-eval-20260826/
  • 该数据集含 7 种百炼音色、你好牛牛 的三档语速/音调变体,以及 你好妞妞 负例;共 28 份原始音频及归一化 WAV/manifest,不进入仓库。

当前板端归一化播放结果:longcheng_v3、longshu_v3、longwan_v3、longxiaochun_v3、longanlingxi 可稳定检出;longanhuan_v3、longyue_v3 目前没有有效检出证据。说明“你好牛牛”确实存在发音覆盖缺口,但不能据此直接把门限继续下调。

建议的优化顺序:

  1. 保留命令词,补充其声调、连读和停顿变体,先用真实人与儿童语音评估召回/误唤醒。
  2. 固定播放响度,分别标定 ES8311 输入增益和 MultiNet 阈值,记录 false accept / false reject;不凭单次命中调参。
  3. 百炼 TTS 可用于回归测试和数据增强,不能直接微调现有 mn7_cn 二进制。
  4. 定制 WakeNet/MultiNet 训练需另建训练任务:大量真实、多说话人、儿童/成人、远近场、噪声和 hard-negative 语料,并以独立测试集决定是否替换模型。现有 28 个 TTS 样本不足以构成训练完成证据。

因此本 PR 不会伪称“已用百炼多音色训练模型”;它先保留可复现实验资产和基于测量的阈值/变体优化路径。

@ZhaoXingPeng

Copy link
Copy Markdown
Collaborator Author

MultiNet7 多音色实板复测(bb5756a)

本次提交只包含:

  • esp_multinet_wake_detector.cc:记录每个候选的 candidate_id/phrase_id/prob/string/raw,用于区分漏检与误触发。
  • voice_linx_wake_injection_test.py:支持外部 WAV、正/负例门禁,并修正 MultiNet 在 WAKE_END 前已命中时被测试脚本漏记的问题。
  • evaluate_sparkbot_multinet.py:按 manifest 对外部数据集逐条复位、注入、保存明文串口日志和汇总 JSON。

数据与环境

  • 设备:SparkBot,MAC 98:a3:16:c7:81:b0
  • Wi-Fi:zxp
  • 串口:/dev/cu.usbmodem14201
  • ESP-IDF:6.0.2
  • 数据集:7 个百炼音色,语速/音调 0.85/1.00/1.15,21 条正例“你好牛牛”,7 条近负例“你好妞妞”。
  • 固件构建使用当前工作树并刷写应用分区;测试日志未提交仓库,仅保存在本地受控目录。

结果

  • 总体:15/28
  • 正例命中:13/21
  • 近负例正确未命中:2/7(即 5/7 近音误触发)
  • 观测候选概率约 0.20--0.51;近负例最高约 0.70;当前阈值 0.20
  • 概率区间重叠,单纯提高阈值会增加正例漏检,降低阈值会增加近音误触发。因此本轮不盲调生产阈值,也不宣称“灵敏度已解决”。后续应进入自定义 WakeNet/更有区分力的唤醒模型路线,并继续用同一数据集验证。

复核证据

  • 汇总:test-artifacts/multinet-evaluation-20260827-retest/multinet-evaluation.json
  • 每条明文串口日志:同目录 run01-*.log
  • 长对话复测(同一固件):test-artifacts/long-dialog-retry14/multiturn-20260827-032531.log 与对应 JSON;3/3 回合完成,无重连、写失败、PCM 丢弃或 I2S 错误,但首句 ASR 将“牛牛”识别为“宁宁”,严格匹配为 2/3。

主机侧 tests/python/test_voice_linx_serial_multiturn_test.py1 passed;新增脚本 py_compilegit diff --check 通过。

@ZhaoXingPeng

Copy link
Copy Markdown
Collaborator Author

WakeNet 语料准备与可行性验证(2026-08-27)

本轮提交:1620cddfeat(wake): add WakeNet corpus packaging checks)。

已完成:

  • 新增 scripts/prepare_wakenet_corpus.py:严格校验 16 kHz/单声道/16-bit PCM WAV、正例目标词、近负例、说话人分组、距离/语速元数据、SHA-256,并按说话人切分 train/validation/test,防止说话人泄漏。
  • 新增 tests/python/test_prepare_wakenet_corpus.py3 passedruffpy_compilegit diff --check 均通过。
  • 外部 pilot 数据未入仓库:21 条样本、3 个百炼音色(longanhuan/longcheng/longshu),你好牛牛 正例 9 条、牛来 正例 9 条、你好妞妞 近负例 3 条,速度 slow/normal/fast 均覆盖,格式校验全部通过。证据保存在本机 test-artifacts/wakenet-pilot//Users/mac/Desktop/VoiceLife-wake-artifacts/2026-08-27/

校验器明确警告:当前 pilot 仅 21 条、3 个说话人、无儿童样本、只有 1 米元数据,低于官方至少 2 万条/推荐 500 说话人(含至少 100 儿童)及 1 米+3 米采集要求。因此当前产物只是提交给乐鑫官方 WakeNet 定制流程的输入,不是可刷写 WakeNet 模型;本地没有把 TTS 音频冒充成模型,也没有改动现有 MultiNet/WakeNet 固件模型。

结论:多音色、多语速/音调的语料生成和可审计打包流程已就绪;要得到“你好牛牛/牛来”的生产级自定义 WakeNet,下一步必须补齐真实说话人、距离和样本规模后提交官方训练/交付,不能在本地直接训练出等价 srmodels.bin

@ZhaoXingPeng

Copy link
Copy Markdown
Collaborator Author

MultiNet7 多音色实板复测与长对话复测(2026-08-27)

本次提交

  • Commit: 08aab5f feat(wake): add bounded MultiNet audio conditioning
  • 仅包含 MultiNet 输入 conditioning、检测器接入和主机测试;没有混入 IMU、显示或其他工作树改动。
  • conditioning 仅作用于本地 MultiNet 支路:每帧计算 RMS/峰值,噪声底以下不放大,语音帧使用 0.75x--4.0x 有界增益,峰值限制 30000;云端语音 PCM 和硬件采集链路不改变。
  • 新增 multinet_audio_conditioning_test,覆盖噪声门控、低响度增益、过响衰减、满幅限幅和自定义噪声底。

构建与刷写

  • Profile: esp32s3-esp-sparkbot-serial-voice
  • ESP-IDF: 6.0.2
  • SparkBot MAC: 98:a3:16:c7:81:b0
  • 应用镜像 SHA-256: 72801e7e053cfa0254431d38198ce5ebfc2a1ca2cc7b0bb617180b3e71b63561
  • 已刷写应用 0x10000、显示 assets 0x300000、MultiNet model 0x400000,保留 NVS/Wi-Fi/SQLite。
  • 启动证据:MCP_TOOLS_READY count=9SPARKBOT_ASSETS_MMAP_OK=1SPARKBOT_COMMON_FONT_ASSET_OKMODEL_LOADER Successfully load srmodelsSPARKBOT_IMU_READY sensor=BMI270SERIAL_VOICE_TEST_READY=1
  • 主机 conditioning 测试通过;python3 scripts/firmware.py validate 全部 profile PASS。

MultiNet 28 条实板评估

数据集包含 7 种百炼音色、语速/音调 0.85/1.0/1.15,正样本“你好牛牛”,近负样本“你好妞妞”。

指标 conditioning 前(b969354) 本次 变化
总通过 13/28 22/28 +9
正样本召回 12/21 19/21 +7
近负样本正确拒绝 1/7 3/7 +2

按语速/音调组合统计:0.85=7/71.0=9/141.15=6/7。音色分组中 longcheng_v3longwan_v3 为 4/4;longanhuan_v3 为 2/4。conditioning 有明确改善,但近负误触发仍为 4/7,不能宣称唤醒模型已完成训练或达到最终灵敏度门禁。下一步需要真正的 WakeNet/唤醒模型训练或更严格的二阶段判别,不能继续只降阈值。

完整逐条串口日志和 JSON 报告只保存在本地,不提交仓库:
/Users/mac/Desktop/VoiceLife-audio-artifacts/2026-08-27/multinet-board-conditioning-1620cdd/multinet-evaluation.json

百炼实板长对话复测

固件刷写后使用真实百炼 TTS(cosyvoice-v3-flash / longanhuan_v3)复位测试,儿童视角 3 回合:彩虹解释 -> 约一分钟小牛牛追彩虹故事 -> 明早三件安全活动。

  • 3/3 回合完成,ASR 均语义匹配;第三回合“故事讲完了”与输入“故事讲完啦”为标点/口语差异,脚本归一化后通过。
  • 每回合均有 capture_started -> speech_started -> capture_stopped -> stt_text_received -> tts_started -> tts_first_audio -> tts_stopped 证据;长句字幕出现 overflow_width 并完成滚动。
  • 最终串口统计:in_drop=0in_pool_fail=0out_reject=0short_write=0in_i2s_err=0out_i2s_err=0;无重连、PCM reject 或 provider error。
  • 之前一次 65 秒窗口失败是长故事仍在持续下发 TTS,非断链;将等待窗口调整为 180 秒后完整通过。

本次结果与明文串口日志:

  • /tmp/voicelife-bailian-tests/retry-20260827-conditioning-long2/multiturn-20260827-060545.json
  • /tmp/voicelife-bailian-tests/retry-20260827-conditioning-long2/multiturn-20260827-060545.log

结论:语音长对话链路本轮通过;MultiNet conditioning 作为输入电平校准已验证有效,但“你好牛牛”多音色/近负样本的最终唤醒灵敏度仍未达标,后续 WakeNet 训练任务保持未完成状态。

@ZhaoXingPeng

Copy link
Copy Markdown
Collaborator Author

当前阶段提交与实板回归(2026-08-27)

已提交并推送:2efe1ed fix(sparkbot): harden shake reaction and listen timeout recovery

本提交包含:

  • IMU 摇动反馈使用独立 dizzy.gif,复用屏保视口并隐藏顶部状态栏/字幕;提示音结束或超时后收口到“聆听中”。
  • 监听超时使用不可丢失的合并标志,空监听直接回待机,已有语音的长句仍进入最终 STT,不会因控制队列满而永久卡在“聆听中/处理中”。
  • 资源清单、构建依赖和对应 Host 测试同步更新。

验证:

  • Host:97/97 通过。
  • ESP-IDF 6.0.2 固件成功构建,应用 0x2c8fd0,分区余量 0x7030(约 1%)。
  • SparkBot MAC 98:a3:16:c7:81:b0,Wi-Fi zxp;应用/assets/model 均刷写并校验通过。
  • 百炼儿童视角短多轮:2/2 回合完成,ASR 2/2,MCP 分页完整,长句字幕单向滚动可见。
  • 串口门禁:无 LINX_WS_ERROR_EVENTLINX_TX_SEND_FAIL、PCM reject、in_dropin_pool_failout_rejectshort_write、I2S 错误或交互队列丢弃。

原始明文串口日志和结果 JSON 仅保留在本机 /tmp/voicelife-bailian-tests/current-stage-20260827/,未提交仓库。唤醒词/WakeNet 优化按当前决策暂缓。

@ZhaoXingPeng

Copy link
Copy Markdown
Collaborator Author

重复提醒根因与修复(0f1aabc)

本次“三次提醒”不是 IM 复制发送,而是提醒服务的有界重试链:第一次触发后立即注册下一次任务,间隔 10 分钟,最多 3 次(components/voicelife_schedule/src/service/schedule_reminder_service.cc:571-578)。成功的 acknowledge 必须取消同一 chain_id 的所有后续任务并提交动作结果。

旧固件在公众号点击“知道了”后已收到 SSE 命令,但执行 SQLite/FATFS 持久化时 action worker 使用 PSRAM 栈,ESP-IDF 在关闭 Flash cache 时触发 esp_task_stack_is_sane_cache_disabled() 断言并复位。设备因此没有发出 IM_ACTION_STREAM_RESULT,Gateway 动作停留在 processing,后续两次任务未被取消,最终同一提醒发送三次。证据:本地 /private/tmp/voice-pr359-ack-findings.md,串口顺序为 IM_ACTION_COMMAND_RECEIVED=1 action=acknowledge -> assert failed -> rst:0x15,服务端 status=processing/result=NULL

本提交将 action worker 的 8 KiB 栈从 MALLOC_CAP_SPIRAM 改为 MALLOC_CAP_INTERNAL,并增加迟到 tts.start 与监听计时器代次测试,避免相关状态被旧事件再次占用。未修改 Gateway,也未将本地 test-artifacts/ 纳入提交。

验证:

  • ./scripts/run_host_tests.sh:97/97 通过
  • pytest tests/python/test_im_wifi_credential_isolation.py tests/python/test_prepare_wakenet_corpus.py:10/10 通过
  • 固件已构建并刷写 SparkBot;启动日志出现 IM_ACTION_WORKER_READY stack_bytes=8192 caps=internal

待补实板闭环:需新建一个短时提醒,只点击一次“知道了”,确认无复位、出现 IM_ACTION_STREAM_RESULT executed=1 confirmed=1,且 10 分钟后没有第二次提醒。

@ZhaoXingPeng

Copy link
Copy Markdown
Collaborator Author

2026-08-27 实板证据:IM 点击确认与设备确认不等价,导致同一提醒发送三次

结论

本次三次提醒不是微信公众号重复投递,也不是 Gateway 把同一条消息复制发送,而是同一条提醒链的 3 个独立重试任务:

  1. IM 侧“知道了”按钮确实被点击,Gateway 创建并下发了 acknowledge 命令;
  2. 旧固件的设备 action worker 收到 SSE 命令后,在本地持久化路径发生断言复位,没有回传执行结果;
  3. Gateway 没有收到设备的成功结果,因此无法确认硬件已经完成 acknowledge,也无法触发设备侧取消后续任务;
  4. 调度器按既定策略在 +10 分钟、+20 分钟再次投递,最终同一提醒链发送了 3 次。

因此当前契约下:

  • IM 点击 = 用户提交了一个待执行命令;
  • 设备执行并回传 succeeded = 业务事实真正完成。

两者在旧链路中不是等价状态。

1. Gateway 生产日志时间线

以下时间是 UTC;北京时间分别为次日 07:04、07:14、07:24。

第一次提醒

2026-08-26T23:04:44.361Z  route=device.notification.create
  correlationId=schedule-reminder-chain-28
  status=202

2026-08-26T23:04:46.147Z  event=delivery.worker.dispatched
  deliveryId=delivery-7a33edeb-6150-48d6-99d2-3d406836de61

2026-08-26T23:04:57.563Z  event=action.submitted
  correlationId=schedule-reminder-chain-28
  actionId=action-delivery-7a33edeb-6150-48d6-99d2-3d406836de61

2026-08-26T23:04:57.570Z  event=action.stream.sent
  correlationId=schedule-reminder-chain-28

这证明 IM 按钮已经被提交,并且命令已经进入设备 SSE 下发流程。

第二次提醒

2026-08-26T23:14:45.012Z  route=device.notification.create
  correlationId=schedule-reminder-chain-28
  status=202

deliveryId=delivery-4557734f-d397-44be-9bce-b57f5445d7b0

第三次提醒

2026-08-26T23:24:43.584Z  route=device.notification.create
  correlationId=schedule-reminder-chain-28
  status=202

deliveryId=delivery-71d78867-8679-4730-a07f-6cb5c63f9db5

三条 Delivery 的 deliveryIdbusiness_event_id 和微信外部消息 ID 都不同,但 correlationId 全部是 schedule-reminder-chain-28。这排除了“同一个微信消息被重复显示”的解释,确认是服务端收到三次独立通知请求。

2. PostgreSQL 状态证据

生产数据库 im_deliveries 中对应记录均为 status=delivered

delivery-7a33edeb-6150-48d6-99d2-3d406836de61
  business_event_id=schedule-reminder-device-...-task-45
  created_at=2026-08-26 23:04:44.349+00
  status=delivered

delivery-4557734f-d397-44be-9bce-b57f5445d7b0
  business_event_id=schedule-reminder-device-...-task-46
  created_at=2026-08-26 23:14:44.987+00
  status=delivered

delivery-71d78867-8679-4730-a07f-6cb5c63f9db5
  business_event_id=schedule-reminder-device-...-task-47
  created_at=2026-08-26 23:24:43.574+00
  status=delivered

第一次 IM action 的数据库状态为:

correlation_id=schedule-reminder-chain-28
action_type=acknowledge
status=expired
created_at=2026-08-26 23:04:57.550+00
updated_at=2026-08-26 23:14:50.066+00
result=NULL

对应的 im_reminder_action_facts 查询为 0 行,说明 Gateway 没有收到独立的设备动作事实回传。也没有任何 device.action-result.create 成功记录可以把该 action 收口为 succeeded

3. 设备侧失败证据

旧固件串口日志顺序如下:

IM_ACTION_STREAM_READ bytes=64 frames=1 pending=0 buffered=5
IM_ACTION_COMMAND_RECEIVED=1
  action=acknowledge

assert failed: 0x4037501d
Setting breakpoint at 0x40380694
rst:0x15 (USB_UART_CHIP_RESET)

没有出现:

IM_ACTION_STREAM_RESULT

也没有提交 action result。对应的旧固件日志为本机受控目录中的:

/private/tmp/voice-pr359-ack-findings.md
test-results/v5-live-im-device-20260826/current-retest/reminder-action-live.log

使用对应 voicelife.elf 解码:

0x4037501d -> spi_flash_disable_interrupts_caches_and_other_cpu
                 esp-idf-v6.0.2/components/spi_flash/cache_utils.c:126
0x40380694 -> panic_abort

ESP-IDF 6.0.2 的该位置执行:

assert(esp_task_stack_is_sane_cache_disabled());

当时设备启动日志仍是:

IM_ACTION_WORKER_READY stack_bytes=8192 caps=spiram

action 收到后会进入 SQLite/FATFS 持久化路径。Flash cache 关闭期间,任务栈必须位于内部 DRAM;旧 action worker 使用 PSRAM 栈,因而在持久化路径触发断言并复位。问题不在微信公众号按钮、SSE 命令内容、MCP 工具目录或网络连接。

4. 本地代码对应关系

设备调度器会预注册后续提醒

schedule_reminder_service.cc:563-578 在第一次触发时:

  • 将当前任务置为 kWaitingAcknowledgement
  • 如果尚未达到 3 次上限,立即注册 attempt + 1
  • 后续任务间隔为 10 分钟;
  • 最多形成 3 个提醒任务。

当前常量为:

kMaximumAttempts = 3
kFollowUpDelay = 10min

只有设备成功执行 acknowledge 才会取消后续任务

schedule_reminder_service.cc:373-416 ExecuteReminderAction() 在设备真正执行 acknowledge 时才会:

  1. 遍历同一 chain_id 的 pending 任务;
  2. 调用 timing_service_->CancelTask()
  3. 将后续任务置为 cancelled/acknowledged;
  4. 完成本地日程;
  5. 持久化 action_operation_idaction_kindaction_occurred_at
  6. 返回成功结果。

旧固件在第 2 步之前已经复位,所以后续两个任务仍然存在。

Gateway 也把设备结果作为终态依据

services.ts:1086-1136recordResultAndClose() 只有收到设备回传后才把 action 变为 succeededfailedpending(retryable)。

services.ts:1139-1159expireDue() 在没有设备结果时只会把 Gateway action 标为 expired;它不能替设备取消本地调度器已经注册的后续任务。

因此“IM 页面显示已提交”不能推导出“硬件已经确认并取消重试”。

5. 修复版对照证据

0f1aabc 已将 action worker 的任务栈从 PSRAM 改为内部 DRAM:

xTaskCreateWithCaps(..., MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT)

启动日志由:

caps=spiram

改为:

IM_ACTION_WORKER_READY stack_bytes=8192 caps=internal

修复版实验 schedule-reminder-chain-29 的服务端时间线:

2026-08-27T00:35:01.098Z  device.notification.create  status=202
2026-08-27T00:35:02.202Z  delivery.worker.dispatched
2026-08-27T00:35:13.351Z  action.submitted
2026-08-27T00:35:13.357Z  action.stream.sent
2026-08-27T00:35:51.463Z  device.action-result.create  status=200

数据库中该 action 为:

correlation_id=schedule-reminder-chain-29
action_type=acknowledge
status=succeeded
created_at=2026-08-27 00:35:13.338+00
updated_at=2026-08-27 00:35:51.454+00

在后续日志窗口内没有出现 chain-29 的第二次或第三次 device.notification.create。这与“设备成功回传后取消后续任务”的预期一致。

6. 当前契约和后续验收门禁

这次问题的准确状态模型应记录为:

IM click
  -> action submitted
  -> SSE delivered to device
  -> device executes local acknowledge
  -> device posts result=succeeded
  -> Gateway action=succeeded
  -> device cancels same chain's pending follow-ups

验收时必须同时满足:

  1. IM 点击后能看到 IM_ACTION_COMMAND_RECEIVED=1
  2. 设备不发生 assert、panic、RST;
  3. 出现 IM_ACTION_STREAM_RESULT,并且执行成功;
  4. Gateway action 进入 succeeded,而不是 processingexpired
  5. 同一 chain_id 的 pending 后续任务被取消;
  6. 10 分钟和 20 分钟后没有第二、第三条通知;
  7. 设备重启或网络短暂中断时,结果仍可恢复上报且不会重复执行。

本评论只记录问题和证据,没有修改生产 Gateway,也没有提交本地明文日志或 test-artifacts/

@ZhaoXingPeng

Copy link
Copy Markdown
Collaborator Author

本轮提交与实板验证(2026-08-27)

本轮已提交并推送:2253d63 fix(imu): require dynamic alternating shake pulses。提交范围仅包含 IMU 检测、摇动打断语音及其主机契约测试;本地 test-artifacts/ 未提交。

修改内容

  1. IMU 摇动判定重做

    • 使用三轴低通重力估计(alpha=0.08),先分离线性加速度,避免把翻转/姿态变化当作摇动。
    • 有效脉冲同时满足:线性加速度峰值 >=3.8 m/s²、总加速度模长偏差 >=1.5 m/s²、角速度 >=80 dps
    • 必须出现 3 个交替方向脉冲;脉冲前必须经过低力谷值重新 arm,脉冲间隔 35--260 ms,总动作窗口 650 ms,触发冷却 2.5 s
    • 非法浮点、时间戳回退、序列超窗都会清空状态,防止一次异常样本或无符号时间下溢误触发。
    • 保留受限 SPARKBOT_IMU_MOTION 诊断日志,并只在最终判定时输出 SPARKBOT_IMU_SHAKE
  2. 摇动打断语音顺序修复

    • 新增 VoiceSession::InterruptAndSpeak()BoardRequestKind::kInterruptAndSystemSpeech
    • 摇动时先停止采集、失效旧 generation、Abort 旧 Linx 音频并 Flush 输出队列;旧 TTS 仍在播报时等待 tts_stopped 顺序栅栏。
    • 栅栏到达后再提交“别摇了,牛牛来了”,提示播报结束后重新进入真实开麦,避免旧音频代次覆盖新提示、或打断后停在处理中/重连中。
  3. 测试覆盖

    • IMU host 测试覆盖静止、慢速翻转、快速翻转、单次碰撞、有效三脉冲摇动、冷却、间隔超时、时间戳回退和 host 无硬件构建。
    • VoiceSession 合同测试验证旧 TTS 播放期间系统语音不立即提交,tts_stopped 后只提交一次且文本准确。

验证结果

  • Host:ctest --test-dir build-host --output-on-failure97/97 通过,0 失败。
  • git diff --check:通过。
  • 固件:build/esp32s3-esp-sparkbot-serial-voice/voicelife.bin,SHA256 b13273b49d49d58f3d6b8f929b5acabbb7e5a4a378f64c2f2cf1b1d26f887237
  • 环境:ESP-IDF 6.0.2;SparkBot MAC 98:a3:16:c7:81:b0;Wi-Fi zxp;串口 /dev/cu.usbmodem14201
  • 启动证据:SPARKBOT_IMU_READY sensor=BMI270 addr=0x68 chip_id=0x24,随后 SPARKBOT_IMU_WARMUP_DONE=1 samples=100

实板摇动实验

明文日志:test-artifacts/imu-dizzy-live-20260827-v4/serial-shake-test.log

唯一一次有效摇动证据:

I (113930) sparkbot_imu: SPARKBOT_IMU_SHAKE detected=1 acc_mag_ms2=6.99 gyro_rate_dps=208.0
I (113930) VoiceLifeRuntime: SPARKBOT_IMU_SHAKE_EVENT=1 reaction=dizzy speech_bytes=36

随后同一回合的完整顺序为:

VOICE_EVENT generation=3 event=interrupted
VOICE_EVENT generation=3 event=system_speech_requested
LINX_RX ... text=别摇了,别摇了,牛牛来了
VOICE_EVENT generation=3 event=tts_started
VOICE_EVENT generation=3 event=tts_sentence_started ... 别摇了,别摇了,
VOICE_EVENT generation=3 event=tts_sentence_started ... 牛牛来了
VOICE_EVENT generation=3 event=tts_stopped
SPARKBOT_IMU_DIZZY_FINISHED=1
VOICE_EVENT generation=3 event=capture_started
VOICE_EVENT generation=3 event=speech_started

判定:本次动作只产生 1 个 SPARKBOT_IMU_SHAKE_EVENT,没有重复事件;眩晕提示完整播报,播报结束后重新开麦并收到 speech_started,没有卡在处理中。

音频统计(摇动窗口及结束后)均为 0:in_drop=0in_pool_fail=0out_reject=0short_write=0in_i2s_err=0out_i2s_err=0

日志边界说明

这份连续串口采集在摇动前的首次 MCP 连接阶段,曾出现一次独立的 tools/list 发送失败:LINX_TX_SEND_FAIL sent=-132 want=2991(约 7510 ms),随后客户端自动重连,LINX_HELLO_RESPONSE 在约 9670 ms 成功。该事件发生在 IMU 摇动之前;从重连完成到摇动、提示播报和重新开麦结束,未再出现 LINX_WS_ERROR_EVENTLINX_TX_SEND_FAILtransport_disconnected。因此本提交的结论是:IMU 摇动路径和语音打断恢复已由实板证实;早期 MCP 传输瞬断仍作为独立链路观测保留,不能被本次 IMU 修复掩盖。

请按上述提交和日志复核本 PR。

@ZhaoXingPeng

Copy link
Copy Markdown
Collaborator Author

IMU 灵敏度小调(commit 518be6f

已将 SparkBot 摇动检测调得稍微灵敏一些:

  • 线性加速度触发阈值:3.8 -> 3.4 m/s²
  • 动态加速度偏差阈值:1.5 -> 1.3 m/s²
  • 角速度阈值:80 -> 70 dps
  • 三次交替脉冲、重新 arm、单脉冲间隔 35--260 ms、总窗口 650 ms、触发冷却 2.5 s 全部保持不变,避免把一次翻转误判成摇动。

新增中等幅度三脉冲回归用例(13.6 / 5.9 / 13.6 m/s²75 dps),验证新门槛生效且仍需完整三脉冲序列。

验证:cmake --build build-host -j4 通过;ctest --test-dir build-host --output-on-failure97/97 通过。该提交尚未进行新的实板摇动复测,实板灵敏度结论待刷入本固件后确认。

@ZhaoXingPeng

Copy link
Copy Markdown
Collaborator Author

IM 闭环复核进展

已按本 PR 现有问题记录复核。重复提醒的直接根因仍是 0f1aabc 已修复的 action worker PSRAM 栈:该 worker 会进入 SQLite/FATFS 持久化,cache-disabled 期间不能使用 PSRAM 栈。当前分支仍使用 MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT,并在 storage_.Start() 前预留 8 KiB 栈。

本轮没有再叠加新的 Gateway 状态机改动,原因是现有状态机已经具备所需语义:IM 点击只创建待设备执行的 command;设备回传成功才使 action 进入 succeeded,本地同时取消同一 chain_id 的后续任务。继续另造一套确认逻辑会破坏这一事实源边界。

本地验证:

  • python3 -m unittest tests/python/test_im_wifi_credential_isolation.py7/7 通过。
  • Gateway 定向回归:corepack pnpm build && node --test --test-concurrency=1 test/action-application.test.mjs test/device-api-contract.test.mjs54/54 通过,覆盖 IM 先/语音先、设备结果回传、重复与冲突动作、SSE 重放和终态不可回退。

完整 Gateway 套件为 410/411;唯一失败项是 test/device-management.test.mjspnpm device 参数错误时 stdout/exit code 的环境行为断言,与提醒 action、SSE 或 Gateway 状态无关,未为掩盖该失败修改 IM 代码。

仍待实板验收:新建短时强提醒,只点击一次“知道了”,确认 IM_ACTION_COMMAND_RECEIVED=1IM_ACTION_STREAM_RESULT executed=1 confirmed=1,无 assert/RST,Gateway action 为 succeeded,并等待超过 10 分钟确认同一 chain_id 不再产生第二次通知。

@ZhaoXingPeng
ZhaoXingPeng force-pushed the fix/sparkbot-emoji-scroll-fix branch 2 times, most recently from f4592f3 to 34c3cd3 Compare August 27, 2026 08:10
@ZhaoXingPeng
ZhaoXingPeng force-pushed the fix/sparkbot-emoji-scroll-fix branch from 34c3cd3 to 5d2f29d Compare August 27, 2026 08:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Display/Voice] 收敛 SparkBot 说话动画、字幕滚动与连接稳定性

2 participants