wangzhibo
2026-09-11 659c470eba542383987aad227b7f918100f131fa
小智终端 5种 场景模式 OK
50个文件已修改
41个文件已添加
12384 ■■■■■ 已修改文件
.cursor/rules/xiaozhi-locked-modes.mdc 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Xiaozhi Device Capability Specification v1.0.md 622 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
phase-1-review.md 475 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
phase-2-plan.md 425 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
phase-2-review.md 388 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
phase-2-voice-test.md 175 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
phase-3-plan.md 380 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
phase-3-review.md 345 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
phase-3-test.md 216 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
phase-4-plan.md 349 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
phase-4-test.md 149 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
roadmap.md 603 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-2.2.6/main/CMakeLists.txt 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-2.2.6/main/Kconfig.projbuild 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-2.2.6/main/application.cc 609 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-2.2.6/main/application.h 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-2.2.6/main/audio/audio_codec.cc 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-2.2.6/main/audio/audio_codec.h 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-2.2.6/main/audio/audio_service.cc 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-2.2.6/main/audio/audio_service.h 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-2.2.6/main/audio/wake_words/afe_wake_word.cc 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-2.2.6/main/audio/wake_words/custom_wake_word.cc 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-2.2.6/main/boards/common/wifi_board.cc 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/app_task.cc 513 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/app_task.h 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/mp3_player.cc 529 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/mqtt.c 97 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/mqtt_interface.md 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/sening_xiaozhi_s3.cc 141 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/system.c 89 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/system.h 54 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-2.2.6/main/boards/waveshare/esp32-s3-touch-lcd-4.3c/sdkconfig.4_3c 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-2.2.6/main/protocols/protocol.cc 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-2.2.6/main/protocols/protocol.h 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-2.2.6/main/protocols/websocket_protocol.cc 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-2.2.6/main/protocols/websocket_protocol.h 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-2.2.6/scripts/patch_wifi_connect.py 110 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-2.2.6/sdkconfig 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentChatHistoryReportDTO.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/agent/service/biz/impl/AgentChatHistoryBizServiceImpl.java 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/security/config/ShiroConfig.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/task/controller/TaskInstanceController.java 53 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/task/dao/TaskEventDao.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/task/dao/TaskInstanceDao.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/task/dao/TaskStepDao.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/task/dto/TaskEventDTO.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/task/dto/TaskInstanceListDTO.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/task/dto/TaskInstanceListItemDTO.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/task/dto/TaskInstanceUpsertDTO.java 53 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/task/dto/TaskMetricDTO.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/task/dto/TaskTurnDTO.java 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/task/entity/TaskEventEntity.java 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/task/entity/TaskInstanceEntity.java 54 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/task/entity/TaskStepEntity.java 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/task/service/TaskInstanceService.java 259 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/main/resources/db/changelog/202504151206.sql 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/main/resources/db/changelog/202608311800.sql 52 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/main/resources/db/changelog/202609011100.sql 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/config.yaml 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/config/assets/wakeup_words/ed76d459636c2481aec828516c1b4f54.wav 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/config/manage_api_client.py 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/__init__.py 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/apps.json 113 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/assessment.py 387 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/playback.py 312 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/registry.py 101 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/runtime.py 1477 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/session.py 275 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/core/audio_archive.py 103 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/core/connection.py 90 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/core/handle/helloHandle.py 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/core/handle/receiveAudioHandle.py 84 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/core/handle/reportHandle.py 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/core/handle/sendAudioHandle.py 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/core/handle/textHandler/listenMessageHandler.py 75 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/core/providers/asr/base.py 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/core/providers/tools/device_mcp/mcp_handler.py 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/core/providers/tts/base.py 152 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/core/utils/audioRateController.py 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/core/utils/modules_initialize.py 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/core/websocket_handshake.py 56 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/core/websocket_server.py 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/plugins_func/functions/start_repeater.py 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/plugins_func/functions/start_shadowing.py 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/plugins_func/functions/switch_app.py 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/tests/conftest.py 66 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/tests/test_app_runtime.py 474 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/tests/test_assessment.py 544 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/tests/test_audio_archive.py 59 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/tests/test_playback.py 384 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.cursor/rules/xiaozhi-locked-modes.mdc
New file
@@ -0,0 +1,32 @@
---
description: 已验收模式不可回退;只改复读/听读清单与播放逻辑
alwaysApply: true
---
# 已锁定:不要改这些已正确路径
开机欢迎词、对话模式、翻译模式、中英对话已经验收通过。后续只许改复读机 / 听读(跟读)。不要为修复读/听读去改 chat、translation、bilingual、hello/TTS 欢迎词时序。
不要动:
- 开机:开通道后保持 idle,Server 播欢迎词,说完再 listening。不要无条件 `ResumeListening` / `WakeWordInvoke`。
- `interaction_mode == CHAT | TRANSLATION | BILINGUAL` 的 ASR→LLM/翻译 路径。
- 欢迎词 TTS:先有 opus 再发 start/字幕。
# 复读机 PLAY_AUDIO
JSON task 下发播放列表,每项 `{url|name, repeat, speed}`,speed 默认 1.0。测试清单:
- `/english_learning/shadowing/lessons/unit01/01.mp3` × 10
- `/english_learning/shadowing/lessons/unit01/02.mp3` × 5
播完自动回对话,播「复读结束」。播放中忽略闲聊,只响应离线口令:退出复读、我们来聊天吧。
# 听读 SHADOWING
JSON task 一次性下发整份列表,每项 `{url|name, repeat, speed}`,speed 默认 1.0。之后在设备上排序执行,不要每播一句再 MCP 一次。测试清单:
- `/english_learning/shadowing/lessons/unit01/01.mp3` × 5(每遍后录音)
- `/english_learning/shadowing/lessons/unit01/02.mp3` × 10
设备本地循环:播一句 → 听窗录音(墙钟播放时长 × 1.2)→ 录音上传 → 再播下一句,直到清单结束。ASR 只暂存/评估,不进 chat/LLM。清单结束播「跟读结束」再回对话。
Xiaozhi Device Capability Specification v1.0.md
New file
@@ -0,0 +1,622 @@
# Xiaozhi Device Capability Specification v1.0
> Status: **Freeze Analysis(钉死语义)**
> Date: 2026-08-31
> Based on: Phase 1–2 真实代码 · Master Plan · 教育 / 益智 / 陪伴 / 助手四类 App 反向归纳
> 本文不是又一份 Master Plan,也不是某一 Phase 的 Coding Plan。
> 本文回答:**以后新增 App,固件原则上不再改什么。**
**Phase 2 已完成(见 `phase-2-review.md`):** D1 完成事件、D6/D7 v1 跟读采集、D8 v1 文本归档;SD 按 App 唯一名建树。
**下一编码阶段是 `phase-3-plan.md`:** TaskInstance 落 manager-api,**无新 Device 能力、不改固件。**
覆盖矩阵的可视化见 Canvas:[能力覆盖矩阵](C:\Users\shi\.cursor\projects\d-xiaozhiAI\canvases\xiaozhi-device-capability-spec.canvas.tsx)。
---
## 0. 冻结原则
```text
新增 App
  → 只改 Server:App / Task / Step / Assessment / Resource
  → Device:读取配置或执行已有 Capability
  → 禁止:为单个 App 改 C++、改协议字段含义、强制 OTA
```
允许 OTA 的唯一理由:补齐 **本 Spec 已声明、当前固件尚未具备** 的原子能力(例如统一 `play_audio` 完成事件、listen 完成事件)。不允许为「成语接龙」单独 OTA。
三层模型:
```text
Application          教育 / 益智 / 陪伴 / 助手     ← 无限扩展,全在 Server + 资源
        ↓
Task Runtime         Task / Step / Flow / 评估     ← 尽快稳定,权威在 Server
        ↓
Device Capability    播 / 听 / VAD / 等待 / 指示   ← 现在冻结语义
```
再拆一层,避免把「麦克风」直接当 API:
```text
Device Primitive          I2S / 按键 GPIO / LED / SD / 网络
        ↓
Interaction Capability    play_audio / listen / vad / speak / wait / indicate
        ↓
Task Capability           跟读一轮、口算一题、接龙一回合   ← 禁止写进固件
```
**判定规则:** 若完成条件依赖「答案是否正确 / 语义是否合格 / 该不该下一题」,这是 Server Task Capability,不是 Device Capability。
---
## 1. Device 原子能力清单
### 1.1 必须冻结(Device Interaction Capability)
| ID | 能力 | 一句话 |
|----|------|--------|
| D1 | `play_audio` | 播放一项音频资源,可循环,必有完成/停止事件 |
| D2 | `audio.pause` / `resume` / `stop` | 打断或挂起当前播放源(与 TTS 互斥) |
| D3 | `speak` | 朗读文本;实现可以是云 TTS,设备只负责出声与打断 |
| D4 | `barge_in` | 播放中检测到用户说话 → 停播 → Listening |
| D5 | `play_system_sound` | 统一提示音,App 不得自带 ding 实现 |
| D6 | `listen` | 上行语音流(现有 Opus),可按 Mode 不进 Chat |
| D7 | `vad` | 上报用户开始说 / 停止说(硬件可观测) |
| D8 | `record_clip` | v1 可用 listen 流 + Server 归档;v1.1 本地文件 + 上传 |
| D9 | `button` | 短按 / 长按 / 双击 → 开始、停止、退出 Task |
| D10 | `indicate` | 把逻辑态映射到 LED/屏,App 不写 GPIO |
| D11 | `wait` | 可取消的本地延时 |
| D12 | `volume` | 播放音量 |
| D13 | `resource.fetch` | 把资源落到 SD/缓存(现有 DOWNLOAD 的语义升级) |
| D14 | `task_control` | start / pause / resume / stop / restart **当前 Task 执行器** |
### 1.2 允许在 Device 执行、但参数来自配置的控制结构
| ID | 能力 | 范围 |
|----|------|------|
| D15 | `loop`(播放级) | `play_audio(loop=N)`,完成条件 = 播完 N 次。Phase 1 已有 `repeat` |
| D16 | `loop`(步骤级) | **Server Task Runtime** 重复下发 Step;设备不解释「再做一题」 |
### 1.3 禁止进入 Device 的能力(Server 原子能力)
| ID | 能力 | 原因 |
|----|------|------|
| S1 | ASR | 设备只有 PCM/Opus |
| S2 | LLM | — |
| S3 | Translation | Phase 1 已证明走 Server |
| S4 | Assessment / Parser / 判题 | 口算、倒背、接龙对错都不在固件 |
| S5 | Memory / Knowledge / RAG | — |
| S6 | Task Generation / 动态出题 | — |
| S7 | Speech feature → 临床诊断 | 特征可后抽,诊断永不上设备 |
| S8 | `branch(if correct)` | 设备不知道 correct;若将来做精练模式,只执行「再播 / 下一题」命令。**默认教育 App 热路径不用 S8**:定时结束即下一步(见 phase-4-plan) |
---
## 2. 每个能力的输入 / 输出
下列为 **语义契约**。MCP 名、MQTT action、未来 `type=command` 都只是运输。改运输不算引进新能力。
### D1 `play_audio`
```text
in:
  resource: { kind: sd_path | http | asset_id, uri }
  volume?: 0-100
  loop?: int >= 1          # 默认 1;在设备上循环
  speed?: 0.5-2.0 float   # 默认 1.0;超出夹紧。重采样实现,任意浮点,非离散档;非 1.0 会变调
  power_off_after?: bool   # 已有软关机挂钩,可保留为播放策略而非 App 逻辑
out (events):
  audio.started
  audio.completed     # 自然结束(含 loop 全部完成)
  audio.stopped       # 被 stop / 抢占 / 用户退出
  audio.paused
  audio.resumed
  audio.failed        # 缺文件、解码失败
```
**当前代码:** Sening `self.mp3.play(name, speed, repeat, power_off_after)`;Phase 2 已上报 `audio.completed` / `audio.stopped`(运输:`[skill_event]...|duration_ms`)。无统一 WAV/HTTP play。
**冻结缺口(仍开放):** 资源 ID 而不是业务路径散落;非 Sening 板无此 Skill;`audio.paused/resumed/failed` 未作为独立事件。
### D2 播放控制
```text
audio.pause | audio.resume | audio.stop
```
当前:`self.mp3.stop`、TTS 时 `PauseSdCardMp3`。pause/resume 对 SD MP3 已有「TTS 暂停」语义,需提升为可被 Task 调用,而不仅是 Chat 副作用。
### D3 `speak`
```text
in:  text, optional interruptible: bool
out: speak.started, speak.completed, speak.interrupted
```
当前:Server TTS → `type=tts` + Opus。设备没有独立「本地 TTS Skill」。**冻结:设备负责出声与 barge_in,不负责合成。** App/Task 只写 `speak("请回答")`。
### D4 `barge_in`
```text
policy (由 InteractionMode 配置,不是 App 名):
  CHAT / BILINGUAL     允许打断 TTS
  PLAY_AUDIO           默认不允许把喇叭当用户插话(复读)
  FOLLOW_READ/SHADOWING 播放范读时忽略拾音;范读结束后才 listen
  TRANSLATION          播译文时忽略回声
```
当前:Server 已按 mode 限制 AEC 打断。这是 **Mode 策略**,必须冻结进 Runtime,禁止每个 App 自己 if。
### D5 `play_system_sound`
```text
in:  type = ding | beep | success | error | ...
out: sound.completed
```
当前:`AudioService::PlaySound` OGG。需编成稳定 type,而不是各板路径字符串。
### D6 `listen` / D7 `vad`
```text
listen.start(mode)
listen.stop
vad → user.speech.started | user.speech.stopped
```
当前:上行 Opus + Server VAD;设备端也有 AFE/VAD 用于唤醒。
**冻结 v1:** 跟读/口算继续用现有听流 + Server VAD 事件即可,不强制设备再做一套文件录音。
**必须补的事件:** Server(或设备)在 Mode≠CHAT 时也要能区分「用户说完一句」,否则封闭式评估无法切 Step。
### D8 `record_clip`
分两档,避免把冻结卡死在「设备写 WAV」:
| 档 | 行为 | 覆盖 |
|----|------|------|
| v1 | listen 流 + Server `report` 归档(已有 chat audio BLOB) | 跟读、口算、倒背、接龙 |
| v1.1 | `save_local` + `upload` + `record.completed(audio_ref)` | 弱网、会议纪要、律师询问长录音 |
v1.1 **不阻塞** 用 5 个代表性 Task 冻结硬件。会议纪要类只增加「长录音上传」,不增加新传感器。
### D9 `button`
```text
short → toggle listen / 确认
long  → 退出当前 Task 回 chat(可配)
double → 预留 pause
```
当前:BOOT 键绑 `ToggleChatState`。冻结时要允许「退出 Task」而不拆掉 Chat 键。
### D10 `indicate`
```text
in: IDLE | LISTENING | PROCESSING | PLAYING | ERROR | TASK_RUNNING | TASK_COMPLETE
```
映射到 LED/屏是板级实现。App 只发逻辑态。当前 DeviceState 是硬件占用态,**不要**把 TASK_RUNNING 塞进 `Listening/Speaking` 迁移表。`indicate` 与 `DeviceState` 正交。
### D11 `wait`
```text
in:  ms, cancellable
out: wait.elapsed | wait.cancelled
```
当前:**无**一等 Skill。复读循环把等待藏在播放器内部。冻结后「播放 → 等待 800ms → listen」不得靠 Server 连发两次再自己 sleep 充数(可先 Server 睡,但协议要留 `wait`,避免每个 App 各写各的)。
### D12 `volume` / D13 `resource.fetch`
当前:MCP 音量、snlp `DOWNLOAD`。冻结为资源到达 SD 后 `play_audio` 只认本地 `resource_id`/`sd_path`。
### D14 `task_control`
```text
TASK_START / PAUSE / RESUME / STOP / RESTART
```
当前:`START_APP_TASK`、`STOP_MEDIA`、优先级抢占。没有统一 PAUSE/RESUME **Task**(只有 MP3 被 TTS 暂停)。
PAUSE 语义:停播放与拾音,保留 pending 与 loop 剩余次数。STOP:清执行器,pending 回 `chat.conversation`。
### D15 vs D16 Loop
```text
同一音频听 10 遍     → Device play_audio.loop=10     (已验证,保持)
10 道不同口算        → Server 循环 Step,每步 speak+listen
跟读 10 句不同音频   → Server 循环 Step(或 Task JSON 列表)
```
禁止 Server 对同一文件连发 10 次 `play`(Phase 1 已否决)。
---
## 3. Client Runtime API(逻辑,不是立刻改函数名)
设备执行器只暴露:
```text
Capability.execute(name, args) -> accepted
Capability.cancel(name)
on Event(name, payload)
```
对照现状:
| 逻辑 API | 今日落点 |
|----------|----------|
| `play_audio` | `self.mp3.play` / `PLAY_MEDIA` |
| `audio.stop` | `self.mp3.stop` / `STOP_MEDIA` |
| `speak` | 被动收 `type=tts` |
| `listen` | `SendStartListening` / 现有拾音 |
| `wait` | 无 |
| `indicate` | Display/LED 散落 |
| `task_control` | `Board_StartAppTask` + preempt |
| `resource.fetch` | MQTT DOWNLOAD |
**冻结策略:** 新 App 只允许调用上表。板级 `self.mp3.*` 作为 `play_audio` 的 Sening 实现,逐步改名为逻辑名或保留别名。禁止再新增 `self.english.*`。
---
## 4. Server Command(逻辑)
Server → Device,最终至少这些 **命令语义**(运输可变):
```text
TASK_START        {app_id, task_id, params, source, instance_id?}
TASK_PAUSE
TASK_RESUME
TASK_STOP
TASK_RESTART
PLAY_AUDIO        {resource, loop, volume, speed}
AUDIO_PAUSE | AUDIO_RESUME | AUDIO_STOP
SPEAK             {text}          # 也可继续走现有 tts 帧,算同一能力
LISTEN_START | LISTEN_STOP
WAIT              {ms}
INDICATE          {state}
SOUND             {type}
FETCH             {url, dest}
```
今日等价物:`type=mcp tools/call`、`type=tts`、MQTT `START_APP_TASK`。
**v1 运输冻结:可以继续 MCP + MQTT,不强制新 WS type。**
**v1 语义冻结:上表集合不再为新 App 追加命令种类。**
---
## 5. Device Event(逻辑)
Device → Server:
```text
TASK_STARTED | TASK_COMPLETED | TASK_FAILED | TASK_CANCELLED
STEP_STARTED | STEP_COMPLETED | STEP_FAILED     # Step 权威仍在 Server;设备可只报 skill 完成
AUDIO_STARTED | AUDIO_COMPLETED | AUDIO_STOPPED | AUDIO_FAILED
SPEAK_COMPLETED | SPEAK_INTERRUPTED
LISTEN_STARTED
USER_SPEECH_STARTED | USER_SPEECH_STOPPED
RECORD_COMPLETED {audio_ref?}                   # v1.1
WAIT_ELAPSED
BUTTON {kind: short|long|double}
FETCH_PROGRESS | FETCH_COMPLETED | FETCH_FAILED
```
今日等价物:MCP result、`[app_task]` / 规划中的 `[skill_event]`、MQTT `rpt/ack`。
跟读 v1 允许 USER_SPEECH_* 由 **Server VAD** 产生(设备未报也不算缺传感器)。
---
## 6. 状态机(两套,禁止合并)
### 6.1 DeviceState(已有,冻结迁移表)
```text
Idle ↔ Connecting ↔ Listening ↔ Speaking
+ Upgrading / WifiConfiguring / ...
```
只回答:麦和喇叭现在归谁。Task 不得新增 `kDeviceStateEnglish`。
### 6.2 InteractionMode(Server 已有字符串,固件可不枚举)
```text
CHAT
PLAY_AUDIO
LISTEN            # 只听,不自动 chat(跟读的听半拍;口算听答案)
FOLLOW_READ       # 先播后听;与今日 SHADOWING 对齐,建议最终统一命名
TRANSLATION
BILINGUAL         # 仍走 LLM,但是 Mode 策略(打断/提示词)不同
```
QUIZ/GAME **不是**新 Mode。口算/倒背/接龙 = `speak + listen + Server 评估`,Mode 用 LISTEN 或 CHAT 策略开关 `skip_llm=true`。
### 6.3 Task Executor(设备缓存)
```text
IDLE → EXECUTING → WAITING_USER → COMPLETED | FAILED | CANCELLED
PAUSE 可从 EXECUTING/WAITING_USER 进入,RESUME 返回
```
权威状态在 Server;设备缓存仅用于断线 UI 与离线复读。
---
## 7. 中断 / 暂停 / 恢复
| 来源 | 行为 |
|------|------|
| 用户 barge_in(Mode 允许) | 停 TTS/音频 → listen |
| 用户按钮长按 / 「退出」 | TASK_STOP → chat.conversation |
| 家长 MQTT 即时 | 优先级 3 抢占,STOP 当前 Skill 再 START |
| 计划任务 | 优先级 2,不可抢 MQTT 即时 |
| 语音切 App | 优先级 1,不可抢 2/3 |
| Chat TTS vs 课程 MP3 | 已有 PauseSdCardMp3,保持为音频互斥,不是 Task Pause |
`TASK_PAUSE`:保留 loop 剩余、当前 step 索引(索引在 Server)。设备只暂停播放/拾音。
---
## 8. 离线执行
分类冻结(Phase 1 已用事实证明):
| 类型 | 没网 | 例子 |
|------|------|------|
| 可离线 Skill | 设备直接执行 | 复读 `play_audio` + loop |
| 仅在线 Mode | 禁止 WakeWordInvoke 干等 | 翻译、跟读评估、接龙、口算 |
| 入口口令 | MultiNet 只保留总入口 + 可离线 Skill | 系统词表按 `device.locale` 配置(Phase 6b);默认中文「切换模式」「复读机」。**禁止**把跟读/翻译/语言对口令塞进固件 |
**禁止**把所有 App 口令塞进 MultiNet。
**禁止**把 `apps.json` 当作离线权威业务引擎;SD 上的 JSON 若存在,只是缓存的声明式配置 + 资源,对错仍不能离线判(除非以后做本地 parser——默认不做)。
---
## 9. SD Card Resource
Phase 1 决议仍然有效:**定义权威在 Server,不下发 App 商店。**
资源层与定义层分开。Phase 2 已落地的业务路径(经 `SDCard_Map_Business_Path` 接到 `MOUNT_POINT`):
```text
/{app_id}/{task_id}/lessons/{lesson_id}/file.mp3
例:
  /english_learning/repeater/lessons/unit01/full.mp3
  /english_learning/shadowing/lessons/unit01/01.mp3
```
`app_id` 是 App 唯一名。用户订阅新 App 后由程序 mkdir(MCP `self.sd.ensure_app` / MQTT `ENSURE_APP_DIRS`),**不重烧固件**。这是运输暴露已有 `mkdir`,不是新传感器。
长期仍可增加缓存目录(Phase 6),不必再搬家上面这棵树:
```text
/sdcard/
  {app_id}/{task_id}/lessons/   # 音频(Phase 2 事实)
  cache/
  recordings/pending/           # 仅 v1.1
  apps/{app_id}/                # 可选:manifest 缓存(Phase 6);设备仍只执行 skills[]
```
设备 **不知道**「这是英语 App」。它只知道:
```text
resource_id → 文件
play_audio(resource_id)
```
Sening 现有 `/data/courses/...` 允许映射,不强迫一次搬家。
---
## 10. 音频录制与上传
```text
v1  热路径:Mic → Opus 流 → Server ASR → 可选 WAV BLOB(现有 report)
v1  跟读:同一条听流;禁止为跟读再开第二套编解码
v1.1 文件:SD pending → 联网 upload → audio_ref
```
热路径禁止改:16k / 60ms Opus。`record_clip` 不得换一套 AAC 引擎来「优化会议」。
---
## 11. VAD
- 唤醒 / 命令词:设备 AFE(已有)
- 句边界(跟读、口算):Server VAD 可用;设备可后补 `user.speech.stopped`
- Mode 策略:范读/复读播放中 **不得** 把喇叭当 VAD 命中
这是所有封闭式语音评估的共同前提。
---
## 12. TTS
Task JSON 只出现 `speak(text)` 或 `play_audio(resource)`。
合成落点(云 / 服务端 / 未来本地)由 Runtime 选。固件禁止写死「跟读必须云 TTS」。
---
## 13. Timer / Loop / Branch
```text
Timer   Device wait(ms)                    播放与听之间的间隙
Loop    Device:同一 play_audio N 次
        Server:不同 Step 重复
Branch  仅 Server:if assessment.pass then next else retry
        Device 只收到 PLAY 或 SPEAK 或 STOP
```
**Branch 进入固件 = 口算判题进 C++。绝对禁止。**
---
## 14. App Manifest(声明,不是固件)
目标形态(Phase 6 落地,现在只冻结字段方向):
```text
app_id
name
default_task
tasks[]:
  task_id
  interaction_mode
  skills[] / steps[]     # steps 在 Server 执行
  resources[]            # 要下载的音频
```
固件 **不解析业务规则**。最多:离线时读 cached task 的 `skills: [play_audio]` 以便复读。
---
## 15. Task DSL(Server,设备不解释完整 DSL)
冻结方向:
```text
Task
  Step: speak | play_audio | wait | listen | stop
  on listen.completed → Server ASR → (可选) Assessment → next Step
```
设备看到的永远是 **单步 Capability**,不是「如果孩子答错就降难度」。
成语接龙 / 口算 / 倒背 的差异只在 Server 的 Parser 与 Prompt,不在固件。
---
## 16. 哪些能力必须 Firmware 固化
一次(或少数几次)OTA 补齐后应长期稳定:
```text
Codec / AEC / 唤醒 / MultiNet 宿主(词表可配,词义不写业务)
WS + 色宁 MQTT 栈
MCP / Skill 宿主
SD 文件系统 + 下载
音频互斥(TTS vs play_audio)
play_audio + loop + stop + 完成事件
listen 流
barge_in 与 Mode 策略表
button 事件
indicate 映射
wait
task_control 抢占
软关机 / RTC 闹钟执行 pending(已有)
```
---
## 17. 哪些能力绝对不能进入 Firmware
```text
ASR / LLM / 翻译算法 / 评分 / 口算解析 / 成语词库
情绪与临床判断
if (app_id == "english") { ... } 业务树
为单个 App 增加 GPIO 协议
把 QUIZ 做成新 DeviceState
把 apps.json 编译进固件当唯一词表
```
固件里出现 `english_learning` 字符串仅允许作为 **Skill 查找表键**(Phase 1 已有),不允许在旁边写音频逻辑。长期应用 `task.skills[]` 缓存取代硬编码表。
---
## 18. 用 5 个代表性 App/Task 做能力覆盖验证
不是因为这 5 个最赚钱,而是 **覆盖率最高**。产品化顺序仍可先复读/跟读;倒背/口算/接龙用于 **验证 Runtime 是否真冻结**,不必先做漂亮 UI。
| 代表 Task | 验证的 Device 能力 | 验证的 Server 能力 | 现状 |
|-----------|-------------------|-------------------|------|
| 英语复读机 | play_audio, loop, stop, 完成事件, resource, 离线 | Task 启动、优先级 | Phase 2:播放+loop+完成事件已有 |
| 英语跟读 | play/speak, wait, listen, VAD, barge_in 策略, 音频归档 | ASR(旁路), 后评估定量, Session | Phase 2 采集已有;Phase 4:时钟步进 + txt 契约,比对不挡播放 |
| 数字倒背 | speak, listen, VAD, wait | ASR, Sequence parser, **流内** score + 及时 speak | **未做**(口令槽已预留;Phase 4b inline) |
| 10 以内口算 | speak, listen, wait, timer | 出题, parser, 默认流内反馈 | **未做** |
| 成语接龙 | speak, listen, barge_in(CHAT 策略) | ASR, LLM/词库, **流内** 合法性与下一提示 | **未做** |
这 5 个做完(哪怕后三个只做 Server 编排 + 现有 listen/speak),Device 侧不应再出现新传感器类能力。
其后:
```text
故事复述 / AI 主持人 / 回忆录 / 心理陪伴 / 会议纪要 / 律师询问
```
只增加 Task Definition、Prompt、Memory、Assessment、(v1.1)长录音上传。
心理陪伴:**长期行为信号**(响应时延、完成率)来自 Event 时间戳,不是新硬件;**禁止**当临床诊断。
---
## 19. 能力覆盖矩阵(归纳结果)
图例:`D` = 需要已冻结的 Device 能力;`S` = Server;空 = 不需要。
| App/Task | 播 | TTS | VAD | 听/录 | ASR | LLM | 评估 | wait/loop | 动态下一步 |
|----------|----|-----|-----|-------|-----|-----|------|-----------|------------|
| 纯读 / 复读 | D | D | | | | | | D | |
| 英语跟读 | D | D | D | D | S | | S | D | S |
| 中英翻译 | | D | D | D | S | S* | | | |
| 中英对话 | | D | D | D | S | S | | | S |
| 成语接龙 | | D | D | D | S | S | S | | S |
| 口算 | | D | D | D | S | 可选 | S | D | S |
| 数字倒背 | | D | D | D | S | | S | D | S |
| 快问快答 | | D | D | D | S | S | S | D | S |
| 故事复述 | D | D | D | D | S | S | S | | S |
| AI 主持人 | | D | D | D | S | S | S | | S |
| 回忆录 | | D | D | D | S | S | S | | S |
| 会议纪要 | | | | D† | S | S | S | | |
| 心理陪伴 | | D | D | D | S | S | S‡ | | S |
\* 翻译可用专用模型,仍是 Server。
† 长录音偏 v1.1 `record_clip`。
‡ 只做参与度/行为信号,不做诊断。
**硬件列只有:播、TTS 出声、VAD、听/录、wait/loop、按钮/指示。** 其余全是 Server。
---
## 20. 与 Phase 1 代码的差距(冻结清单,不是现在就写完)
必须在「宣称 Device 已冻结」之前补齐的固件/协议语义:
1. `audio.completed` / `audio.stopped`(逻辑事件;运输可用 `[skill_event]` 或 MCP)  ✅ Phase 2
2. Mode≠CHAT 时可靠的「一句说完」(VAD/listen.completed)  ✅ Phase 2 v1(Server VAD + ASR + 听窗上限)
3. 跟读/评估路径的音频归档(现有 report 即可)  ✅ Phase 2 文本 + report;Phase 3 可选 `audio_id` 挂 step
4. `wait` 是否一等能力:可先 Server 侧延时,但不要再为每个 App 复制
5. `task pause/resume` 语义(可后于 1–3)
6. `play_audio` 资源 ID 统一(可与 Phase 6 资源管理一起;音频路径继续用 `/{app}/{task}/lessons`)
**不需要为冻结而做:** Assessment 算法、成语词库、口算出题器、type=task 全套、合并双 MQTT。
**Java 表是 Task Runtime(Phase 3),不是 Device 冻结条件。**
---
## 21. 对 Roadmap 的插入点
```text
Phase 1  薄 App Runtime                    ✅
    ↓
本 Spec  Device Capability Freeze          ✅ 文档
    ↓
Phase 2  用跟读/复读验证 listen/VAD/事件   ✅ D1 / D6 / D7 v1 / D8 v1;见 phase-2-review.md
    ↓
Phase 3  TaskInstance 落库                 ✅ 无新 Device 能力;见 phase-3-review.md
    ↓
Phase 4  时钟步进 + 后评估                 ← 下一编码;见 phase-4-plan.md
    ↓
倒背 / 口算 / 接龙                         (Phase 4b:流内评估 + 及时 speak;反证固件冻结)
```
Phase 2 的工程价值是:**把采集 + 完成事件钉进 Runtime。** 已完成。
Phase 3 的工程价值是:**Instance 可查询。** 已完成。
跟读后评估是 Phase 4;倒背/接龙的及时反馈是 Phase 4b,不能混成后评估。施工单:`phase-4-plan.md`、`phase-4-test.md`、`roadmap.md`。
---
## 22. 接受本 Spec 的标准
当且仅当团队同意下面四句,才允许进入后续编码(Phase 3 起同样适用):
1. 新 App 不得要求新的传感器或新的 WS 消息种类;只许用本文件命令/事件表。
2. 对错、出题、翻译、接龙词库只在 Server。
3. 离线只保证「可离线 Skill」(播放),不保证智能 Task。
4. SD 放资源;声明式 Task JSON 若缓存到 SD,也不等于固件懂英语。
phase-1-review.md
New file
@@ -0,0 +1,475 @@
# Phase 1 Completion Review
> Date: 2026-08-31
> Basis: **current code** (not the original Phase 1 wish-list)
> Compared against: `phase-1.md` · Master Plan · Architecture Audit · Client Plan V1.1 · Server Plan V1.1
> Repositories: `xiaozhi-esp32-2.2.6` (Client) · `xiaozhi-esp32-server` (Server)
本文件只记录事实、差异、债务与对 Master Plan 的修正建议。不包含下一阶段的详细编码任务(见 `phase-2-plan.md`)。
---
## 1. 当前代码盘点(以仓库为准)
### 1.1 Phase 1 实际改动的文件
#### Client(`xiaozhi-esp32-2.2.6`)
| 文件 | 角色 |
|------|------|
| `main/boards/sening-xiaozhi-s3/app_task.h` | **新增。** 设备侧 Task 启动 / pending / 离线口令 / 复读记忆 |
| `main/boards/sening-xiaozhi-s3/app_task.cc` | **新增。** `Board_StartAppTask`、优先级、NVS pending、`last_rep`、技能分发 |
| `main/boards/sening-xiaozhi-s3/mqtt.c` | `START_APP_TASK`;`SET_ALARM` 可附带 `app_id/task_id/params` 写入 pending |
| `main/boards/sening-xiaozhi-s3/sening_xiaozhi_s3.cc` | MCP `self.mp3.play` 增加 `repeat` / `power_off_after` / 空 name 回退;开机 `Board_ResolveBootOrRestore` |
| `main/boards/sening-xiaozhi-s3/mp3_player.cc` | 整首循环 N 次;自然结束才 `power_off`;`repeat_left` |
| `main/boards/sening-xiaozhi-s3/system.h` / `system.c` | 离线口令宏;`SDCard_Play_MP3_With_Speed_Repeat` |
| `main/audio/wake_words/afe_wake_word.cc` | MultiNet 注册 4 条离线口令 |
| `main/audio/wake_words/custom_wake_word.cc` | 同上 |
| `main/application.cc` | `SendListenDetect`:没网且通道未开时不 `WakeWordInvoke` |
未改:Opus 热路径、`DeviceState` 迁移表、`hello/listen/tts/stt` 字段含义。非 Sening 板无 App Task 执行器。
#### Server(`xiaozhi-esp32-server`,仅 Python `xiaozhi-server`)
| 文件 | 角色 |
|------|------|
| `core/app_runtime/__init__.py` | **新增模块。** |
| `core/app_runtime/registry.py` | 加载 `apps.json`(bundled;可选 `data/apps.json`) |
| `core/app_runtime/apps.json` | App / Task 注册表 |
| `core/app_runtime/runtime.py` | `start_app_task` / `switch_app` / mode 分流 / 口令 / 跟读轮次 / 翻译 |
| `plugins_func/functions/switch_app.py` | LLM 可调的切 App |
| `plugins_func/functions/start_repeater.py` | 打开复读机 |
| `plugins_func/functions/start_shadowing.py` | 打开跟读 |
| `plugins_func/functions/translate.py` | 连接 LLM 翻译(智控台 Key) |
| `mcp_servers/translate/server.py` | 独立 translate MCP(回退) |
| `mcp_server_settings.json` | 注册 `english-translate` |
| `core/connection.py` | 连接内存:`active_app_id` / `interaction_mode` / `tool_policy` 等 |
| `core/handle/receiveAudioHandle.py` | `startToChat` 前按 mode 分流;口令优先于 LLM |
| `core/handle/textHandler/listenMessageHandler.py` | `[app_task]` / `[voice_entry]` |
| `core/providers/tools/unified_tool_manager.py` | `filter_tools` 白名单 |
| `config.yaml` | 加载 `switch_app` / `start_repeater` / `start_shadowing` |
#### manager-api / manager-web
**无 App Runtime 相关改动。** 无新表、无新 REST。智控台仍只管理人设 / 插件绑定。
(历史对话里 manager-web 的 `vue.config.js` / teleport 修复与 Phase 1 业务无关,不计入本阶段架构。)
### 1.2 实际增加的模块
| 模块 | 位置 | 职责 |
|------|------|------|
| App Runtime | Server `core/app_runtime/` | 注册表 + 统一 `start_app_task` + mode 行为 |
| Device Task Gateway | Client `app_task.cc` | 三通道汇合、抢占、pending、本地 Skill 执行 |
| Translate plugin + MCP | Server plugins + `mcp_servers/translate` | 翻译旁路 `chat()` |
| Repeater / Shadowing plugins | Server plugins | 进入对应 Task |
没有:Task Runtime 状态机类、Step 引擎、Assessment 服务、Scheduler 服务、App Store。
### 1.3 实际增加的 API
| 类型 | 名称 | 说明 |
|------|------|------|
| REST | **无** | manager-api 未增接口 |
| Python 插件 | `switch_app` | `app_id` + 可选 `task_id` |
| Python 插件 | `start_repeater` | 可选 `name/repeat/power_off_after` |
| Python 插件 | `start_shadowing` | 无参数 |
| Python 插件 | `translate` | `text` + 可选语言 |
| Device MCP | `self.mp3.play` 扩展 | `repeat`、`power_off_after`;`name` 可空 |
| Device MCP | `self.mp3.get_status` | 已有;增加 `repeat_left` |
### 1.4 实际增加的协议
**没有**新的 Xiaozhi WS `type`。热路径语义冻结得到遵守。实际采用的是「复用 listen detect 文本」和「色宁 MQTT 新 action」。
| 通道 | 载荷 | 用途 |
|------|------|------|
| WS `listen` `state=detect` | `[app_task]{app}\|{task}\|{source}` | 设备通知 Server 进入某 Task(`execute_skills=false`,避免复读双播) |
| WS `listen` `state=detect` | `[voice_entry]` | 离线「切换模式」且**已联网**时要菜单 |
| MQTT `snlp/v1/.../cmd` | `action=START_APP_TASK` | 通道①即时启动 |
| MQTT `SET_ALARM` | 可选 `app_id/task_id/params` | 写入设备 pending,通道② |
未增加:`type=task`、`mode.set`、`play.completed` 独立消息、`speech.stopped`。
### 1.5 实际增加的数据库结构
**无。** App 状态在 `ConnectionHandler` 内存。设备 pending / `last_rep` 在 NVS。WS 断线后 Server 回到 default chat;设备 pending 仍在本地。
### 1.6 Client 架构变化
Phase 1 之前(Audit):
```text
Application 单例 + DeviceState + Opus 双工 + MCP
Sening 旁路:self.mp3.* / snlp MQTT PLAY_MEDIA / RTC 闹钟
离线「切换模式」只打日志
```
Phase 1 之后:
```text
Application(Chat 热路径不变)
  └── Board_StartAppTask(Sening 板级 Task Gateway)
        ├── source 优先级 mqtt_immediate > schedule > voice > default_chat
        ├── NVS pending + last_rep
        ├── 技能查找表 k_task_skills[](固件内硬编码 id,不是业务 if 树)
        └── 本地执行 self.mp3.play / 或只 notify Server
MultiNet:切换模式 / 复读机 / 开始复读 / 退出复读
没网:切换模式不连云;复读机可播上次音频或 SD 第一首
```
InteractionMode **没有**成为固件枚举。设备不持久化 App 历史。DeviceState 仍只管听/说/升级。
### 1.7 Server 架构变化
Phase 1 之前:
```text
ConnectionHandler.chat() 是唯一对话编排
Agent + Plugin + MCP
无 App / 无 InteractionMode
```
Phase 1 之后:
```text
receiveAudioHandle.startToChat
  → 口令 match_voice_switch(先于 LLM)
  → mode 分流:
       PLAY_AUDIO 忽略上行
       SHADOWING  handle_shadowing_turn(不 chat)
       TRANSLATION run_translation(不 chat)
       BILINGUAL  wrap 后仍 chat()
       CHAT       现网 intent/LLM
App Runtime:apps.json + start_app_task
工具:inherit_agent | allowlist
```
权威状态仍在 **当前 WS 连接内存**,不是 TaskInstance 表。
### 1.8 Client ↔ Server 实际通信流程
```text
① 小程序 / MQTT 调试客户端
     START_APP_TASK
          ↓ snlp MQTT
     Board_StartAppTask
          ├─ 本地 Skill(复读:SD MP3)
          └─ 若 Xiaozhi 通道已开:listen detect [app_task]
                    ↓
               Server start_app_task(execute_skills=false)
               更新 active_app_id / interaction_mode,TTS 播报
② SET_ALARM + pending
     到点 RTC 唤醒 → Board_ResolveBootOrRestore
     非 chat 计划 → 直接 StartAppTask(schedule)
     否则 → chat.conversation
③ 离线语音
     MultiNet 复读机 → 本地播放,不依赖 Server
     MultiNet 切换模式 → 仅 IsNetworkReady 时 [voice_entry]
     在线 ASR 短句 → match_voice_switch → start_app_task
④ Chat 热路径(未离开 CHAT)
     Mic → Opus → ASR → LLM → TTS → Speaker
     与 Phase 1 前相同
```
### 1.9 Task Runtime 当前实现状态
| 能力 | 状态 |
|------|------|
| 统一入口 `start_app_task` | **有**(Server + 设备各一份) |
| App / Task 注册表 | **有**(Server JSON;固件另有技能表) |
| InteractionMode 分流 | **有**(连接字符串,5 种 mode) |
| Step / StepInstance | **无** |
| TaskInstance 持久化 | **无** |
| Event(play.completed 等) | **无正式协议**;播放结束设备会 `[app_task]chat` 通知回聊 |
| 跟读 | **有 Demo**:TTS 范读 + ASR 轮次 + 回声抑制;无归档、无评分 |
| Assessment / Metric | **无** |
结论:这是 **薄 App Runtime + 单 Task 动作**,不是 Master Plan 的 Task Runtime。
### 1.10 测试情况
| 项 | 现状 |
|----|------|
| `phase-1.md` §10 验收清单 | 文档存在,**仓库内无对应自动化用例** |
| xiaozhi-server pytest / unittest(App Runtime) | **无** |
| 固件单元测试(app_task / MultiNet) | **无** |
| 现有测试资产 | 仅 ASR/TTS/LLM performance_tester |
| 实际验证方式 | 人工烧录 + 重启 Server + MQTT/口令联调 |
**Phase 1 在工程上「做完了功能」,没有「测完了回归」。** 这是进入 Phase 2 前最大的过程风险,不是功能缺口。
---
## 2. Phase 1 Implementation Review(计划 vs 代码)
### 2.1 对照表
| 项目 | 原计划(`phase-1.md`) | 实际实现 | 差异原因 | 后续处理 |
|------|------------------------|----------|----------|----------|
| 薄 App 框架 | Server JSON + `active_app_id`;设备无 App 目录 | 已实现;注册表在 `core/app_runtime/apps.json` | — | 保持;不要下发到 SD |
| Default `chat.conversation` | 配网/开机进入并主动开口 | pending 默认 chat;`agent_proactive` + 进入 CHAT 时 TTS 招呼 | 冷场再催促未做成可配循环 | Phase 2 不优先;可配次数留债 |
| 三通道 + 优先级 | MQTT > Schedule > Voice | 设备 `Board_AppTask_SourcePriority`;Server `SOURCE_PRIORITY` | 两端各算一次,可能短暂不一致 | Phase 2 用 skill_event 对齐结束态即可,不合并 MQTT |
| `START_APP_TASK` | 新 MQTT action | 已实现 | — | 保持 |
| pending + 闹钟唤醒 | NVS;非 chat 计划不先闲聊 | `Board_ResolveBootOrRestore` + `SET_ALARM` 写 pending | Server **无** Schedule 权威表 | 接受;权威表放到 Scheduler Phase |
| 离线「切换模式」 | 不再空 log;进菜单 | 有网才 `[voice_entry]`;没网跳过 | 实施中发现 WakeWordInvoke 会干等 Server | **正确修正**,写入架构 V2 |
| 复读 N 次在设备 | `repeat` 参数 | mp3_player 循环 | — | 保持 |
| `power_off_after` | 自然结束才软关机 | 已实现 | — | 保持 |
| 翻译旁路 LLM chat | 独立 MCP | **主路径改为 `conn.llm` + translate 插件**;MCP 仅回退 | 独立 MCP 依赖占位 Key,智控台 Key 才是现网 | **正确修正**;MCP 可降级为可选 |
| 工具白名单 | allowlist | `filter_tools` | — | 保持 |
| Xiaozhi `type=task` | 明确不做 | 未做 | — | Phase 2 仍不做完整 type=task |
| Java 新表 | 明确不做 | 未做 | — | Phase 3 再做 Instance 表 |
| 三个 App | chat / 复读 / 翻译 | **四个 App + 跟读 Task**:另增 `english_conversation`、`shadowing` | 产品要把跟读/中英对话尽早可演示 | 跟读仍是 Demo;正式采集是 Phase 2 |
| 固件无业务名 | 禁止 `if (app == english)` | 固件 `k_task_skills[]` **含 app_id 字符串** | MQTT 离线启动必须能查 Skill,无法只问 Server | 接受为「配置表」;禁止再加英语评分/音频逻辑 |
| 口令表在 Assets JSON | 映射表可扩 | 固件硬编码 4 条拼音;在线口令在 `runtime.py` | MultiNet 容量与误触发 | **正确收窄**;不要把翻译/跟读塞进 MultiNet |
| apps.json 路径 | 建议 `data/apps.json` | bundled `core/app_runtime/apps.json`,可选 data 覆盖 | 部署更简单 | 保持 |
| PLAY_AUDIO 忽略 ASR | 是 | 是 | — | 保持 |
| 用户唤醒复位 chat | 是;闹钟不复位 | `on_user_wakeup`;`[app_task]` 不走唤醒 | — | 保持 |
| 无 play.completed | 接受 | 播完设备直接切回 chat 并 detect | Server 仍不知道第几遍 | Phase 2 补 skill_event |
| 小程序 UI | 后置 | 未做 | — | Parent UI 很后 |
| 测试清单 | 详细 checkbox | 无自动化、无书面跑测记录 | 赶功能 | **Phase 2 必须带 pytest + 手工回归表** |
### 2.2 哪些设计被修改(相对原文 Plan)
1. **离线入口收窄**:只保留「切换模式 + 复读机/开始复读/退出复读」。翻译/跟读/中英对话只走在线 ASR。
2. **没网不连云**:`SendListenDetect` 在无网且通道未开时直接 return。
3. **复读可完全离线**:NVS `last_rep` + SD 第一首;注册表仍只在 Server。
4. **翻译实现**:连接 LLM 优先于独立 MCP 进程。
5. **Mode 集合扩大**:`SHADOWING`、`BILINGUAL` 提前落地(Client Plan 把 TRANSLATION/GAME 放在 Phase 3)。
6. **跟读提前**:不是 SD 范读 + 录音对象,而是 TTS 句子列表 + 文本回声过滤。
7. **协议挂钩**:用 `[app_task]` / `[voice_entry]` 寄生在 `listen/detect`,而不是新 type。
这些修改应视为 **Phase 1 的真实架构**,后续 Phase 不得假装「设备仍只有 CHAT+PLAY_AUDIO」。
### 2.3 哪些设计没有实现
- 冷场多次主动开口(可配间隔)
- 口令→id 的设备端 JSON/Assets 表
- Server 侧 Schedule 权威存储
- `play.completed` / 遍数上报
- 跟读音频对象与 step 关联
- Assessment
- 非法 `app_id` 的系统化错误码(MQTT 有 ack;语音多为 TTS 句子)
- Phase 1 测试计划的自动化
- 非 Sening 板的复读
### 2.4 实施中发现的问题
1. `self.mp3.play` 立即 `accepted`,Server 无法编排 N 次循环 → 循环必须在设备(Plan 已预见,代码证实)。
2. 独立翻译 MCP 的 yaml Key 与智控台 Key 分裂 → 改走 `conn.llm`。
3. ASR 常把结果包成 `{content,language,emotion}` → 必须拆 `content`,否则翻译/口令失败。
4. 跟读/翻译时喇叭回声会当用户插话 → AEC 打断仅限 `CHAT`/`BILINGUAL`;跟读再加文本相似度过滤。
5. 「切换模式」在没网时 `WakeWordInvoke` 会挂起 → 必须短路。
6. 设备本地启动复读后再通知 Server,若 `execute_skills=true` 会 **播两遍** → `[app_task]` 必须 `execute_skills=false`。
7. MultiNet 词表不能塞满业务口令,否则误触发且没网也无法跑翻译。
### 2.5 技术债务(实施产生)
见第 5 节分级。核心:双技能表、寄生协议、无测试、跟读不是音频任务、App 状态不落库。
### 2.6 原先没考虑到、但已出现的问题
- 用户口语「模式」与内部 `InteractionMode` / App 命名冲突(已规定:对用户仍说「模式」,对代码仍用 App/Task)。
- 离线复读与在线权威状态会分叉:没网播完后 Server 仍以为在 CHAT。
- 固件必须认识 `english_learning` 才能离线执行,与「业务名不上固件」理想不完全一致。
- manager-web 开发构建问题(teleport/`usedExports`)与 App 无关,但说明控制台栈也有债,不要在 Phase 2 顺手大改。
---
## 3. 与 Master Plan 核心抽象对照
| 抽象 | 当前是否存在 | 实现位置 | 完成程度 | 是否需要调整 | 未来是否扩展 |
|------|--------------|----------|----------|--------------|--------------|
| **App** | 是(薄) | Server `apps.json` + `active_app_id`;设备技能表有 app_id | 连接级容器,无安装/订阅/资源包 | 保持「Server 权威、不下发 SD」 | Phase 6 再做资源与清单 |
| **Task** | 是(薄) | `apps.json` tasks + `start_app_task` | 一个 Task ≈ 一种动作,无 Step 图 | 不要把跟读 Demo 当成完整 Task Definition | Phase 3 起 JSON Step |
| **Step** | 否 | — | 0% | Phase 2 **不要**先上完整 Step 引擎 | Phase 3+ 跟读一步一 Step |
| **Skill** | 是(MCP + 板级) | 设备 MCP;Server 调 DeviceMCP;固件 `k_task_skills` | 能跑 play/stop/power_off/translate | 双表必须当契约维护 | 核心 `play_audio` 平台化后移 |
| **InteractionMode** | 是(Server 字符串) | `conn.interaction_mode` | `CHAT/PLAY_AUDIO/TRANSLATION/SHADOWING/BILINGUAL` | 固件仍无 Mode 枚举,可保持正交 | 正式 FOLLOW_READ 可与 SHADOWING 合并命名 |
| **TaskInstance** | 否 | — | 0% | Phase 2 只用内存/文件 Session,不建 Java 表 | Phase 3 落 manager-api |
| **StepInstance** | 否 | — | 0% | 同上 | 跟读采集后才有意义 |
| **Event** | 部分 | `[app_task]` / `[voice_entry]`;MQTT ack | 无统一 Event 模型 | Phase 2 增加 `[skill_event]` 即可,不上第四套总线 | 以后再考虑 `type=task` |
| **Audio** | 聊天附件级 | `ai_agent_chat_audio` LONGBLOB;跟读未强制归档 | 跟读轮次未必走 report | Phase 2 必须把跟读 ASR/音频挂上 Session | 再迁对象存储 |
| **Assessment** | 否 | — | 0% | 不要在 Phase 2 做评分 | Phase 4 文本比对 v0 |
| **Metric** | 否 | — | 0% | — | Assessment 之后 |
| **Schedule** | 半截 | 设备 RTC + pending;无 Server 日历 | 能 7 点唤醒复读 | 不要用 RTC 冒充家长计划权威 | Phase 5 Server Scheduler |
| **Plan** | 否 | — | 0% | — | 家长任务管理 |
| **Subscription** | 否 | — | 0% | — | 平台后期 |
**符合 Master Plan 的方向:** 权威在 Server、设备执行、Chat 热路径保留、App 不是固件、复读不走 `play_music`。
**不符合或提前/滞后:**
- Server Plan 说 Phase 1 可以没有 App 实体 → 实际做了薄 App,这是 **有益提前**,应写进 Master Plan。
- Client Plan 说 Phase 1 甚至不需要 `activeAppId` → 实际设备有 pending app_id,Server 有 active_app_id。
- 跟读/中英对话/翻译 Mode 提前 → Master Plan 的 Phase 边界要按代码改,而不是把已落地功能假装「未开始」。
- TaskInstance / Assessment / Scheduler 仍未开始 → 与 Master Plan 一致,不要在下一阶段一次做完。
---
## 4. Current Architecture V2(基于代码)
### 4.1 Current Client Architecture V2
```text
ESP32-S3 (sening-xiaozhi-s3)
├── Application::Run                    DeviceState: Idle/Listening/Speaking/...
│     ├── AudioService                  Opus 16k 热路径(未改语义)
│     ├── Protocol (Xiaozhi WS/MQTT)    hello/listen/abort/tts/stt/mcp
│     └── MCP Server                    self.mp3.* / self.power_off / alarm / ...
│
├── WakeNet + MultiNet
│     口令: 切换模式 | 复读机 | 开始复读 | 退出复读
│     → Board_Handle_Voice_Command
│
└── Task Gateway (app_task.cc)          ← Phase 1 新增
      Board_StartAppTask(app, task, params, source)
      ├── 优先级抢占 / preempt skills
      ├── NVS pending + last_rep
      ├── k_task_skills[] 查 Skill
      └── 执行: SD MP3 循环 | notify Server | 空 Skill 只切态
旁路(仍并存):
  snlp MQTT  START_APP_TASK / SET_ALARM / PLAY_MEDIA / DOWNLOAD
  DS3231 RTC 闹钟唤醒 → OnRestoredFromPowerOff → ResolveBootOrRestore
```
非 Sening 板:无 Task Gateway;翻译等纯 Server Mode 仍可走 Xiaozhi WS。
### 4.2 Current Server Architecture V2
```text
xiaozhi-server (Python)
├── ConnectionHandler
│     session_id (WS 连接级,≠ TaskInstance)
│     active_app_id / current_task_id / interaction_mode / tool_policy
│     shadowing_index / shadowing_prompt
│
├── receiveAudioHandle.startToChat
│     1. match_voice_switch
│     2. mode 分流(不进 chat 的: PLAY_AUDIO / SHADOWING / TRANSLATION)
│     3. CHAT / BILINGUAL → intent + LLM
│
├── app_runtime
│     registry(apps.json)
│     start_app_task / switch_app / filter_tools
│     handle_shadowing_turn / run_translation
│
├── Plugins: switch_app, start_repeater, start_shadowing, translate
├── Device MCP executor → self.mp3.play/stop
└── 可选 Server MCP translate 进程
manager-api (Java)     用户/设备/Agent/聊天 WAV   ← 未参与 App Runtime
manager-web            智控台                     ← 未参与
```
### 4.3 Client ↔ Server Architecture V2
```text
                    ┌──────────── 小程序 / MQTT 客户端 ────────────┐
                    │  START_APP_TASK / SET_ALARM                 │
                    └──────────────────┬──────────────────────────┘
                                       │ snlp MQTT
                                       ▼
┌──────── Client ────────┐     Xiaozhi WS      ┌──────── Server ────────┐
│ Task Gateway           │◄── listen detect ──►│ App Runtime            │
│  [app_task] [voice_entry]                    │  start_app_task        │
│ SD MP3 Skill           │◄── type=mcp ───────►│  DeviceMCP play/stop   │
│ Chat AudioService      │◄── opus/tts/stt ───►│  ASR / LLM / TTS       │
│ pending NVS            │                     │  connection memory     │
└────────────────────────┘                     └──────────┬─────────────┘
                                                          │ 现有 report
                                                          ▼
                                                   manager-api
                                                   chat_history + audio BLOB
```
同一时刻:一个设备一个 active App/Task。Chat 与 Task 音频互斥沿用 `PauseSdCardMp3`。
---
## 5. 技术债务
### P0 — 不解决就无法安全继续开发
| ID | 债务 | 立即修? |
|----|------|----------|
| P0-1 | **无 App Runtime 自动化测试**,Phase 2 极易回归 Chat / 口令 / 复读双播 | **是。** Phase 2 第一刀就是 pytest + 手工回归表,否则不准加表/加协议 |
| P0-2 | 跟读 Demo 被误当成「已有 FOLLOW_READ 采集」——没有 prompt/asr/audio 绑定,Assessment 无处落点 | **是(作为 Phase 2 目标)。** 允许暂时存在评分,不允许继续假装已采集 |
P0 不是「系统不能跑」,而是「下一阶段会建在流沙上」。
### P1 — 近期应该解决
| ID | 债务 | 立即修? |
|----|------|----------|
| P1-1 | 无 `play.completed`:Server 靠设备主动切回 chat | Phase 2 用 `[skill_event]`,不要新 WS type |
| P1-2 | 双技能表:`apps.json` vs `k_task_skills[]` 会漂 | Phase 2 写契约检查清单;不同步下载 JSON |
| P1-3 | 寄生协议 `[app_task]` 与唤醒词/ASR 文本空间重叠 | 保持前缀约定;文档化;Phase 2 加 skill_event 前缀 |
| P1-4 | 离线复读与 Server 连接态分叉 | 允许暂时存在;重连后以设备 notify 或下次 detect 对齐 |
| P1-5 | 跟读范读走 TTS 而非音频音频,和复读资源模型不一致 | Phase 2 可先采集 TTS 跟读;SD 范读放到资源 Phase |
| P1-6 | 翻译双实现(plugin + MCP) | 允许暂时存在;Phase 2 不删 MCP,测试以 plugin 为准 |
| P1-7 | `BILINGUAL` 仍走 `chat()`,靠 prompt 约束,易夹杂语言 | 允许暂时存在;不是 Phase 2 主路径 |
### P2 — 平台化时再解决
| ID | 债务 | 立即修? |
|----|------|----------|
| P2-1 | 双 MQTT(Xiaozhi vs snlp) | 否 |
| P2-2 | App 状态不落库 | 否(Phase 3 Instance) |
| P2-3 | 复读仅 Sening | 否 |
| P2-4 | 聊天 WAV LONGBLOB | 否 |
| P2-5 | smartlearning 课程云未吸收 | 否(Phase 6) |
| P2-6 | 固件认识业务 app_id 字符串 | 否;保持查找表,禁止业务分支膨胀 |
| P2-7 | 无 `type=task` / `mode.set` | 否;等 Instance 真正需要步进再加 |
| P2-8 | 小程序 / 家长日历 UI | 否 |
---
## 6. Master Plan 再审视
### 已完成(相对长期愿景的「第一刀」)
- Chat 作为 default App/Task 活下来
- 设备可执行非 Chat 的播放 Skill
- Server 可按 Mode 跳过 LLM
- 三通道进入 Task 的骨架
- App 与 Agent 分离(Agent 仍是人设;App 是 `apps.json`)
### 正在进行
- 英语学习:复读可用;跟读仅 Demo
- 翻译 / 中英对话:可用,未产品化评估
### 需要调整的设计
1. **「Phase 1 不做 App」作废。** 薄 App Runtime 已是 Current Architecture V2 的核心,后续只加深,不推倒重来。
2. **「跟读 = Client Phase 2 才出现」作废。** Mode 和口令已在;缺的是采集与 Instance。
3. **「用 type=task 才能做 Task」暂缓。** 寄生 detect + MQTT 已够启动;先补 Event 与 Session,再考虑新 WS type。
4. **「apps 下到 SD」明确拒绝。** 与 Phase 1 后期结论一致,Master Plan 的 App Sync 改为「资源下发、定义留云」。
5. **离线策略写入 Master Plan:** MultiNet 只保留总入口 + 可离线执行的 Skill(复读);云能力口令走 ASR。
### 已不合理或应降优先级
- 第一阶段就上 QUIZ/GAME 完整 Mode
- Phase 2 一次做完 Task 表 + 评分 + 对象存储 + 新协议
- 合并双 MQTT 作为近期目标
- 把设备建成完整 Task Runtime
### 需要新加入的能力(代码已逼出来)
- 离线可执行 Skill 与「仅在线 Mode」的明确分类
- ASR JSON `content` 规范化(所有非 Chat Mode 都要)
- 喇叭回声策略按 Mode 配置(打断 / 忽略 / 文本过滤)
---
## 7. 结论:Phase 1 是否 Done
**功能 Done(带条件):** 薄框架 + chat 默认 + 复读 + 翻译 + 三通道挂钩 + 离线复读,且未破坏协议语义、未改 Java DDL。
**工程未 Done:** 无自动化测试、无跟读采集、无播放完成事件、双表漂移风险。
**因此:** 不开启 Java Task 表、不开启 Assessment、不开启 App Store。
下一阶段只做一件事:把「音频任务」从 Demo 变成 **可归档的跟读会话**(见 `roadmap.md` / `phase-2-plan.md`)。
phase-2-plan.md
New file
@@ -0,0 +1,425 @@
# Phase 2 Coding Plan — 冻结面验证(采集 + 完成事件)
> File: `phase-2-plan.md`
> Date: 2026-08-31
> **状态:已实施。** 对照事实见 `phase-2-review.md`。下一阶段 `phase-3-plan.md`。
> 约束文档:`Xiaozhi Device Capability Specification v1.0.md`(语义冻结)
> 路线图:`roadmap.md`
> 代码事实(开工时):`phase-1-review.md`
> **与原文 V1.1 Phase 2(type=task + Java 表 + 评分)冲突时,以本文 + Spec 为准。**
本阶段 **不是** 再做一个 App,也 **不是** 做倒背/口算/接龙。
本阶段用 **已有** 复读机 + 跟读 Demo,把 Spec 已声明、固件尚未具备的原子能力补齐。
给 Coding Agent:先读 Spec §1、§4–5、§16–17、§20,再读 `runtime.py`、`receiveAudioHandle.py`、`listenMessageHandler.py`、`reportHandle.py`、`app_task.cc`、`Board_OnSkillPlaybackFinished`。
允许 OTA 的唯一理由(Spec):补齐已声明能力。本阶段允许的固件改动仅限 **D1 完成/停止事件**。禁止为跟读音频或评分改 C++。
---
## 0. 与 Spec 的对照(本阶段只钉这些)
| Spec ID | 能力 | Phase 2 做什么 | 不做什么 |
|---------|------|----------------|----------|
| D1 | `play_audio` 完成事件 | 上报 `AUDIO_COMPLETED` / `AUDIO_STOPPED` | 不改 `repeat` loop(D15 已有);不统一 resource_id;不上 HTTP/WAV play |
| D2 | pause/resume/stop | 仅用现有 stop;stopped 事件 | 不做 TASK 级 pause;不把 TTS-Pause-MP3 改成新协议 |
| D3 | `speak` | 继续现有 `type=tts` | 不在设备做 TTS Skill |
| D4 | barge_in 策略 | **保持** Phase 1:CHAT/BILINGUAL 可打断;PLAY_AUDIO/SHADOWING/TRANSLATION 防回声 | 不按 App 名写 if |
| D6 | `listen` 不进 Chat | 跟读继续走现有听流 + `handle_shadowing_turn` | 不改 Opus 热路径 |
| D7 | VAD / 一句说完 | v1 用 **Server VAD + ASR 出句** 作为句边界 | 不强制设备新报 `user.speech.stopped` |
| D8 | `record_clip` v1 | 有效跟读走现有 `enqueue_asr_report` | 不做本地 WAV 文件 + upload(v1.1) |
| D11 | `wait` | **不做**;跟读句间用 TTS 自然间隔 | 不为每个 App 复制 sleep |
| D14 | task_control | 只用已有 START + 结束回 chat | 不做 PAUSE/RESUME/RESTART 新命令 |
| D15 | 播放级 loop | **禁止改** mp3 `repeat` 语义 | — |
| D16 | 步骤级 loop | 跟读下一句由 Server `shadowing_index++` | 设备不得「自己循环十句英语」 |
| S4/S8 | 评估 / branch | **禁止** | 对错下一阶段;设备不判题 |
代码里 Mode 名可仍为 `SHADOWING`。文档与 Event payload 视同 Spec `FOLLOW_READ`。不要本阶段全局重命名,以免无意义 diff。
---
## 1. 目标与非目标
### 1.1 Objective
1. 复读(D1):自然结束 / 用户停止必须变成 **逻辑事件**,Server 不再靠 `[app_task]chat` 猜测。
2. 跟读(D6/D7/D8 v1):一次会话可归档为 prompt + asr_text + 可选音频,且 **不进 LLM chat()**。
3. 运输可变、语义不变:新前缀只承载 Spec 已有 Event 名,**不增加命令种类**。
```text
task_session_id
  turns[]: index, prompt_text, asr_text, echo_rejected, recorded_at
  events[]: audio.completed | audio.stopped | ...
```
### 1.2 Why Now
Spec §20 冻结缺口前三条:完成事件、非 Chat 句边界、跟读归档。不补这三项,不能宣称 Device Capability 已冻结,Phase 3 建表也没有内容。
### 1.3 Acceptance Criteria(必须全部满足)
**能力:**
1. 复读自然结束:Server 收到逻辑 `audio.completed`(运输见 §4),回 CHAT **只招呼一次**。
2. 复读被 stop/抢占:`audio.stopped`,**不**主动招呼。
3. 离线复读 / 离线「切换模式」:仍不 `WakeWordInvoke`(Phase 1 纪律)。
4. 跟读全程不走 `chat()`;讲笑话不会当闲聊回答。
5. 完整跟读或「退出跟读」后,`data/task_sessions/*.json` 轮次与实际一致;**最后一轮 asr 必须写入后再回 chat**。
6. `report_asr_enable` 且 `chat_history_conf != 0` 时,有效跟读入队 report;回声拒绝与 PLAY_AUDIO 忽略上行 **不得** 当作用户话语上报。
**冻结纪律(Spec §22):**
7. 不新增 WS `type`、不新增 MQTT action 种类、不新增 MCP 工具种类(别名映射除外,本阶段也不改 MCP 名)。
8. 不对错、出题、翻译算法、接龙词库进固件。
9. 不把 `apps.json` 下发到 SD。
10. 不改 `hello/listen/tts/stt` 字段含义,不改 Opus 帧。
11. 无 Java DDL、无正式分数字段。
### 1.4 Out of Scope
- Phase 4b:倒背 / 口算 / 接龙
- `task_instance` 表、`mode.set`、`type=task` / `type=command`
- Assessment、Device `if correct`
- 跟读必播 SD MP3(TTS 范读合法;标准音是 Phase 6)
- MultiNet 增加跟读/翻译口令
- `wait` Skill、`TASK_PAUSE`、`indicate` 新逻辑态、按钮长按退出 Task
- `record_clip` 本地文件
- 合并双 MQTT、manager-web / 小程序
- 冷场多次主动聊天、非 Sening 板 play_audio 平台化
---
## 2. 架构(本阶段)
```text
Device Capability(冻结语义)     运输(本阶段)              Server
play_audio loop=N (已有 D15)  →  MCP self.mp3.play      →  不轮询遍数
audio.completed|stopped       →  [skill_event]...       →  Session.events + 回 chat
listen 流 (D6)                →  现有 Opus              →  ASR;Mode≠CHAT 不 chat()
句边界 (D7 v1)                →  ASR 出句               →  handle_shadowing_turn
record_clip v1 (D8)           →  现有 report HTTP       →  chat_history WAV
speak (D3)                    →  type=tts               →  跟读范读 / 回聊招呼
```
TaskSession **不是** TaskInstance(那是 Phase 3)。`task_session_id` = uuid4,不要用 WS `session_id` 当长期主键。
跟读下一句 = Spec **D16 步骤级 loop(Server)**,禁止做成设备内句子列表。
---
## 3. TaskSession schema
路径:`xiaozhi-esp32-server/main/xiaozhi-server/data/task_sessions/{task_session_id}.json`
```json
{
  "task_session_id": "uuid",
  "ws_session_id": "conn.session_id",
  "device_id": "mac",
  "app_id": "english_learning",
  "task_id": "shadowing",
  "source": "voice",
  "interaction_mode": "SHADOWING",
  "started_at": 0,
  "ended_at": 0,
  "status": "running",
  "end_reason": "",
  "turns": [
    {
      "index": 0,
      "prompt_text": "Hello, how are you today?",
      "asr_text": "hello how are you today",
      "echo_rejected": false,
      "recorded_at": 0
    }
  ],
  "events": [
    {
      "name": "audio.completed",
      "app_id": "english_learning",
      "task_id": "repeater",
      "at": 0
    }
  ]
}
```
`status`: `running | completed | cancelled`
`end_reason`: `finished | user_exit | preempted | disconnected | play_completed | play_stopped`
事件 `name` 用 Spec 逻辑名:`audio.completed` / `audio.stopped`(不要再用含糊的 `play.xxx` 当档案字段;运输字符串见 §4)。
断线:`ConnectionHandler` 清理路径 flush,`cancelled` + `disconnected`,已有 turns 不得丢。
---
## 4. 协议:运输映射到 Spec Event
**禁止**新增 Xiaozhi `type`。本阶段运输 = `listen` `state=detect` 文本前缀(Phase 1 已用)。
改运输以后(`type=event`)不算引进新能力;本阶段不要做那一步。
### 4.1 `[skill_event]` ↔ Spec Device Event
| detect 文本 | Spec Event | 何时 |
|-------------|------------|------|
| `[skill_event]audio.completed|{app_id}|{task_id}` | `AUDIO_COMPLETED` | `Board_OnSkillPlaybackFinished(stopped=false)` |
| `[skill_event]audio.stopped|{app_id}|{task_id}` | `AUDIO_STOPPED` | `stopped=true`(用户/抢占/stop) |
规则:
- 三段,`|` 分隔。`app_id`/`task_id` 来自当前 pending(`s_current_app` / `s_current_task`),**不是**新的 `if (english)` 分支。
- Server **禁止**因 completed 再调一次 `self.mp3.play`。
- **只留一条回聊路径**,禁止 completed 后再发 `[app_task]chat` 导致两次招呼。
**推荐:**
```text
自然结束:只发 [skill_event]audio.completed|english_learning|repeater
          Server start_app_task(chat, execute_skills=false) 并 TTS 招呼
用户停止:只发 [skill_event]audio.stopped|...
          不主动招呼;本地 pending 仍写回 chat.conversation
```
没网:`SendListenDetect` 已短路,不为本事件强行连云。离线播完只改 NVS pending。
兼容:旧固件若仍发 `[app_task]chat`,Server 可当回聊处理。**新固件不要两种都发。**
### 4.2 保持不变(不增加命令种类)
```text
[app_task]app|task|source     态同步,execute_skills=false
[voice_entry]                 菜单
hello / listen start|stop / tts / mcp / START_APP_TASK / SET_ALARM
```
`listenMessageHandler` 解析顺序:
```text
[skill_event] → [app_task] → [voice_entry] → 唤醒词
```
未知前缀不得送进 LLM。
---
## 5. Server 改动清单
仓库:`xiaozhi-esp32-server/main/xiaozhi-server`
只动 Python。manager-api **禁止改表**。
| ID | 任务 | 文件 | 说明 |
|----|------|------|------|
| S1 | `TaskSession` | `core/app_runtime/session.py` | dataclass + load/save;`data/task_sessions/`;mkdir 失败只打日志 |
| S2 | 连接持有 session | `core/connection.py` | `self.task_session = None` |
| S3 | begin/end | `runtime.py` `start_app_task` | 离开 chat 时 `begin_session`;进入 chat 时 `end_session` |
| S4 | 跟读记 turn | `handle_shadowing_turn` | 有效轮 report;echo **不上报**;**先 add_turn 再**在最后一轮回 chat |
| S5 | 结束原因 | 句子耗尽 `finished`;退出 `user_exit` | flush |
| S6 | 吃 skill_event | `listenMessageHandler.py` | completed → 回 chat + `end_reason=play_completed`;stopped 不招呼 |
| S7 | 断线 flush | `connection.py` 清理路径 | cancelled |
| S8 | 导出 | `__init__.py` | 无循环导入 |
| S9 | pytest | `tests/test_app_runtime.py` | §8 |
| S10 | ASR JSON | `_plain_user_text` 单测 | 不改 FunASR |
跟读 turn 策略(简单):用户 **有效**开口才 `add_turn(index, current_prompt, asr)`,再 `index+1` 并 `speak` 下一句(D3)。当前代码最后一轮直接回 chat、不记 asr —— 必须修。
`enqueue_asr_report` 去重:先 grep `startToChat` / ASR stop 调用链。已上报则跟读有效轮不要再入队。PLAY_AUDIO 忽略上行与 echo 轮禁止 report。
---
## 6. Client 改动清单
仓库:`xiaozhi-esp32-2.2.6`
仅 Sening Task Gateway。禁止新 DeviceState、禁止英语评分、禁止跟读句子表进固件。
| ID | 任务 | 文件 | 说明 |
|----|------|------|------|
| C1 | D1 completed | `app_task.cc` `Board_OnSkillPlaybackFinished` | 只 detect `audio.completed`;**删除** `notify_server(chat)` |
| C2 | D1 stopped | 同上 | `open_if_idle=false` |
| C3 | 离线 | 已有 `SendListenDetect` 短路 | 不要为事件强连云 |
| C4 | D15 | `mp3_player.cc` | **禁止改** repeat / power_off_after 语义 |
| C5 | 禁区 | — | 不新增 MultiNet 词;不改 Opus;不加 `wait`/PAUSE |
今日:
```text
pending → chat
was_repeater && !stopped → notify_server(chat, true)
```
改为:
```text
pending → chat.conversation          // 离线默认不变
若 was_repeater 且(通道已开或网络就绪):
  completed → SendListenDetect("[skill_event]audio.completed|...|...", open_if_idle=true)
  stopped   → SendListenDetect("[skill_event]audio.stopped|...|...", open_if_idle=false)
```
`app_id|task_id` 用结束前的 `s_current_app` / `s_current_task`(函数开头先拷贝再改 pending)。
---
## 7. 跟读采集(D6 / D7 / D8 v1)
今日顺序:
```text
exit? → speaking? drop → echo? drop → send_stt → index+1 → 无句则回 chat
```
目标:
```text
exit? → end_session(user_exit) → start_app_task(chat)
speaking? drop(不记 turn,D4 范读中忽略拾音)
echo? 可记 events.echo_rejected;不 report;不推进
有效:
  add_turn
  enqueue_asr_report(若 ASR 层尚未报)
  send_stt_message 可保留
  无下一句:end_session(finished) → chat
  否则:index+1,speak 下一句
```
范读仍用 Server TTS(D3)。不要本阶段改成 SD 标准音。
---
## 8. 测试
### 8.1 pytest(P0)
`xiaozhi-esp32-server/main/xiaozhi-server/tests/test_app_runtime.py`
假 `conn`,不启 WS,不改 Opus。
| 用例 | 断言 |
|------|------|
| `match_voice_switch("开始跟读")` | `english_learning, shadowing` |
| `match_voice_switch("切换模式")` | `__menu__` |
| `match_voice_switch("打开翻译")` | translation |
| `is_echo_of_prompt` 相同句 | True |
| `TaskSession` save/load | 轮次一致 |
| `_plain_user_text` JSON | 抽出 content |
| 解析 `[skill_event]audio.completed|english_learning|repeater` | 得到逻辑事件名 |
mode 分流抽 `route_user_text` **仅当改动很小**。否则口令 + session 单测 + 手工表。
### 8.2 手工回归(PR 勾选)
Chat:
- [ ] 唤醒对话、打断(D4 CHAT)、天气插件
- [ ] 「我们来聊天吧」从翻译/跟读回来
跟读(冻结验证件):
- [ ] 不进闲聊 LLM
- [ ] 退出跟读回 chat
- [ ] JSON 含全部有效轮(含最后一轮)
- [ ] 喇叭回声不推进句子
复读(D1):
- [ ] MQTT START_APP_TASK 不双播
- [ ] 自然结束只招呼一次
- [ ] 离线复读、离线切换模式不连云
- [ ] `repeat` N 次行为与 Phase 1 相同(D15 未破坏)
翻译:
- [ ] 中↔英;退出后笑话恢复(证明未把翻译塞进固件)
**不要**本阶段验收倒背/口算/接龙。
---
## 9. 实施顺序
```text
P2-A  session.py + begin/end + 断线 flush
      进跟读/复读有 running JSON;回 chat 变 completed
P2-B  handle_shadowing_turn 记 turn + report 去重
      完整跟读轮次正确(含最后一轮)
P2-C  [skill_event] audio.completed|stopped + Handler
      复读结束单次招呼;stopped 不招呼
P2-D  pytest + 手工回归(含离线与 D15 repeat)
```
未完成 A 不要改播放器。未完成 C 不得宣称 D1 已冻结。未完成 D 不得宣称 Phase 2 Done。
---
## 10. 编码约束(Spec §16–17 落到本 PR)
1. 固件不得新增 `if (app == english)` 业务树;事件里的 id 来自 pending 查找表。
2. 不得把 ASR/LLM/评分/口算解析/成语词库写入 Client。
3. 不得把 QUIZ/FOLLOW_READ 做成新 `DeviceState`。
4. 不得新增 WS type / MQTT action / MCP 工具。
5. 不得改 `repeat` 语义、不得下发 `apps.json`、不得写 Redis/MySQL Session。
6. manager-api / manager-web 零提交(report 现有 HTTP 不够用时先在 PR 说明,仍优先 `manage_report`)。
7. 每个 PR 先 Chat 回归再合跟读/复读事件。
---
## 11. 风险与回归
| 风险 | 缓解 |
|------|------|
| 回声当跟读成功 | 保持 speaking 忽略 + echo 文本过滤;echo 不 report |
| completed + `[app_task]chat` 双招呼 | §4.1 只留一条路径 |
| report 双份 | grep 调用链,只留一处 |
| 旧固件无 skill_event | Server 仍接受旧 `[app_task]chat`;新固件只发事件 |
| 把 Phase 2 做成新 App | 验收表不含倒背/口算/接龙 |
| 把步骤循环下沉设备 | 句子列表只在 Server `apps.json` / config |
---
## 12. Data Flow
```text
复读(验证 D1 + D15)
  START_APP_TASK / 离线口令
    → play_audio loop=N(设备内循环)
    → audio.completed|stopped
    → Server Session.events
    → completed:Server 回 chat;stopped:不招呼
跟读(验证 D3/D4/D6/D7/D8v1 + D16)
  「开始跟读」→ start_app_task(shadowing) → begin_session
    → speak 第一句(Server TTS)
    → listen 流 → ASR 出句 → handle_shadowing_turn
    → add_turn + report → Server 决定下一句或结束
    → 结束 → chat 招呼
```
---
## 13. State Machine
```text
DeviceState:  仍只有 Idle / Listening / Speaking / ...   (禁止加 SHADOWING 边)
InteractionMode:
  CHAT ──start repeater──► PLAY_AUDIO ──audio.completed──► CHAT
                         └──audio.stopped──────────────► CHAT(无招呼)
  CHAT ──start shadowing─► SHADOWING(=FOLLOW_READ)
           prompting(speak) → listening → turn_recorded → …
           user_exit | finished ──► CHAT
```
---
## 14. 完成后(人类流程,不是本 PR 代码)
```text
Coding
  → Completion Review(对照本文 §1.3 + Spec §20 前三条)  ✅ phase-2-review.md
  → Architecture V3(事件运输、Session 文件、SD 按 App 名建树)
  → Roadmap Review
  → phase-3-plan.md(Instance 表;仍无新 Device 能力)
```
禁止本阶段「顺便」Java 表、Assessment、倒背/口算/接龙、`wait` Skill、本地录音上传。
phase-2-review.md
New file
@@ -0,0 +1,388 @@
# Phase 2 Completion Review
> Date: 2026-08-31
> Basis: **current code**(不是 `phase-2-plan.md` 的原文愿望)
> Compared against: `phase-2-plan.md` · `phase-2-voice-test.md` · `roadmap.md` · `Xiaozhi Device Capability Specification v1.0.md` · `phase-1-review.md`
> Repositories: `xiaozhi-esp32-2.2.6`(Client)· `xiaozhi-esp32-server`(Server)
本文件只记录事实、差异、债务与对 Spec / Roadmap 的修正。下一阶段施工见 `phase-3-plan.md`,验收见 `phase-3-test.md`。
相关文档索引:
| 文档 | 角色 |
|------|------|
| `Xiaozhi Device Capability Specification v1.0.md` | Device 语义冻结 |
| `roadmap.md` | 阶段切分 |
| `phase-1.md` / `phase-1-review.md` | 薄 App Runtime 的计划与事实 |
| `phase-2-plan.md` | 本阶段原文施工单(采集 + 完成事件) |
| `phase-2-voice-test.md` | 烧录后口令回归 |
| Client / Server Plan V1.1 | 长期愿景;与 Spec 冲突时以 Spec + 本 Review 为准 |
---
## 1. 当前代码盘点(以仓库为准)
### 1.1 Phase 2 实际改动的文件
#### Client(`xiaozhi-esp32-2.2.6`,仅 Sening)
| 文件 | 角色 |
|------|------|
| `main/boards/sening-xiaozhi-s3/app_task.cc` | `[skill_event]audio.{completed\|stopped}\|app\|task\|duration_ms`;`Board_EnsureAppLayout` |
| `main/boards/sening-xiaozhi-s3/app_task.h` | 声明 `Board_EnsureAppLayout` |
| `main/boards/sening-xiaozhi-s3/mp3_player.cc` | 播放时长 `duration_ms`(`xTaskGetTickCount`);**未改** `repeat` / `power_off_after` |
| `main/boards/sening-xiaozhi-s3/system.c` / `system.h` | `SDCard_Ensure_Business_Dir`(包装已有 `mkdir_recursive`) |
| `main/boards/sening-xiaozhi-s3/sening_xiaozhi_s3.cc` | MCP `self.sd.ensure_app` |
| `main/boards/sening-xiaozhi-s3/mqtt.c` | `ENSURE_APP_DIRS`;`START_APP_TASK` 补进 ctl 白名单 |
未改:Opus 热路径、`DeviceState` 迁移表、`hello/listen/tts/stt` 字段含义、MultiNet 词表、`repeat` 循环语义。非 Sening 板仍无 Task Gateway。
#### Server(仅 Python `xiaozhi-server`)
| 文件 | 角色 |
|------|------|
| `core/app_runtime/session.py` | **新增。** `TaskSession` 文件归档;`parse_skill_event`;ASR report 推迟策略 |
| `core/app_runtime/runtime.py` | begin/end session;吃 skill_event;跟读 SD 逐句 play;听窗 `1.2 × duration_ms`;订阅 App 建目录 |
| `core/app_runtime/apps.json` | 跟读 enter_prompt 改为 SD 路径;allowlist 增加 `self_sd_ensure_app` |
| `core/connection.py` | `task_session`;断线 `end_session(disconnected)` |
| `core/handle/textHandler/listenMessageHandler.py` | `[skill_event]` 优先于 `[app_task]` |
| `core/providers/asr/base.py` | PLAY_AUDIO / SHADOWING 不立即 `enqueue_asr_report` |
| `core/providers/tools/device_mcp/mcp_handler.py` | tools/list 就绪后调度建目录 |
| `tests/test_app_runtime.py` | pytest(口令 / session / skill_event / SD 路径) |
#### manager-api / manager-web
**无 App Runtime 相关改动。** 无新表、无新 REST。跟读音频仍走现有 `POST /agent/chat-history/report`。
### 1.2 实际增加的模块
| 模块 | 位置 | 职责 |
|------|------|------|
| TaskSession | Server `session.py` | 连接级归档:turns + events → `data/task_sessions/{uuid}.json` |
| Skill Event 运输 | detect 文本 `[skill_event]` | Spec `AUDIO_COMPLETED` / `AUDIO_STOPPED` |
| SD App Layout | 固件 mkdir + Server ensure | `/{app_id}/{task_id}/lessons` |
| 跟读步进 | Server `runtime.py` | D16:一句一 MP3 → 听窗 → 下一句 |
没有:TaskInstance 表、Step 引擎、Assessment、Scheduler、App Store、`type=task`。
### 1.3 实际增加的 API / 协议
| 类型 | 名称 | 说明 |
|------|------|------|
| REST | **无** | — |
| Device MCP | `self.sd.ensure_app` | **计划外。** `app_id` + 可选 `task_id` → mkdir |
| MQTT | `ENSURE_APP_DIRS` | **计划外。** payload `{app_id, tasks:[]}` |
| WS type | **无新 type** | 仍寄生 `listen/detect` |
| detect 前缀 | `[skill_event]audio.completed\|app\|task\|duration_ms` | Spec D1;第 4 段时长可选 |
| detect 前缀 | `[skill_event]audio.stopped\|...` | 用户/抢占停止 |
`duration_ms` 是原文计划没有、实施中补上的字段;语义仍是 D1 完成事件,不是新命令种类。
### 1.4 数据库
**无新表。** Session 在本地 JSON。Chat WAV 仍在 `ai_agent_chat_audio`。WS 断线会 flush JSON(`disconnected`),不落 manager-api。
### 1.5 Client 架构变化(相对 Phase 1 V2)
```text
Task Gateway
  Board_OnSkillPlaybackFinished(stopped, power_off, duration_ms)
    ├── 仅当当前 task 的 skill 含 self.mp3.play(复读)时:pending 写回 chat
    ├── 跟读:播完只上报 completed,不停任务
    └── SendListenDetect([skill_event]...)  不再发 [app_task]chat
  Board_EnsureAppLayout(app_id, task_id)
    └── SDCard_Ensure_Business_Dir("/{app}" 或 "/{app}/{task}/lessons")
MCP  self.sd.ensure_app
MQTT ENSURE_APP_DIRS(ctl 主题)
```
DeviceState 仍只管听/说。固件仍无 InteractionMode 枚举。跟读句子表 **不在** 固件。
### 1.6 Server 架构变化
```text
listen detect
  [skill_event] → handle_skill_event
      PLAY_AUDIO completed → end_session(play_completed) → 回 chat 招呼一次
      PLAY_AUDIO stopped   → end_session(play_stopped)  → 回 chat 不招呼
      SHADOWING  completed → 开听窗 1.2×duration_ms(最少 800ms)
      SHADOWING  stopped   → 结束跟读回 chat
start_app_task(shadowing)
  ensure_app_sd_dirs
  self_mp3_get_list → lesson_mp3_clips(/{app}/{task}/lessons/)
  逐句 self_mp3_play(repeat=1)
  有效 ASR → add_turn + report → 下一句
  听窗超时 → listen_timeout 事件 → 下一句(不记成功 turn)
MCP tools/list 就绪 → 后台为注册表里需 SD 的 App mkdir
```
权威状态仍在 **当前 WS 连接内存 + JSON 文件**,不是 TaskInstance 表。
### 1.7 Client ↔ Server 实际通信流程
```text
复读
  口令 / MQTT START_APP_TASK
    → 设备 play_audio loop=N(D15)
    → [skill_event]audio.completed|english_learning|repeater|{ms}
    → Server Session.events + 回 chat(只招呼一次)
跟读
  「开始跟读」
    → Server ensure dirs + list MP3
    → MCP play 01.mp3
    → completed + duration_ms → 听窗
    → 用户跟读 / 超时 → Server D16 下一句
    → 句子耗尽 → JSON flush → chat
订阅新 App(不烧录)
  apps.json 增加 app_id
    → 下次 MCP 就绪或 start_app_task
    → self.sd.ensure_app 创建 /{app_id}/{task}/lessons
```
### 1.8 Task Runtime 当前实现状态
| 能力 | 状态 |
|------|------|
| 统一入口 `start_app_task` | 有(Phase 1) |
| App / Task 注册表 | 有(Server JSON) |
| InteractionMode 分流 | 有 |
| D1 `audio.completed/stopped` | **有**(detect 运输 + duration_ms) |
| D6 跟读不进 `chat()` | **有** |
| D7 句边界 | **有(v1)**:Server VAD + ASR 出句;另有时长听窗上限 |
| D8 v1 归档 | **部分**:有效跟读 `enqueue_asr_report`;Session JSON 有文本;**未**把 `audio_id` 写回 turn |
| D16 步骤级 loop | **有**:句子列表只在 Server |
| D13 资源目录 | **提前落地**:按 App 唯一名建树;仍不是 `resource_id` 抽象 |
| Step / TaskInstance | **无** |
| Assessment / Metric | **无** |
结论:这是 **可归档的薄 Runtime(文件 Session)**,仍不是 Master Plan / Server Plan V1.1 原文里的「Phase 2 = Java 表 + type=task + 评分」。后者已由 Spec 拆到 Phase 3 / 4。
### 1.9 测试情况
| 项 | 现状 |
|----|------|
| pytest `tests/test_app_runtime.py` | **有**(13 passed:口令、echo、session save/load、skill_event、lesson 路径、layout 路径) |
| 固件单元测试 | **无** |
| `phase-2-voice-test.md` | **有书面口令表**;仓库内无跑测记录 |
| Java / REST | 不适用(本阶段无表) |
**Phase 2 在工程上补上了 Phase 1 的 P0-1(pytest)。** 语音表仍靠人工烧录勾选。
---
## 2. Phase 2 Implementation Review(计划 vs 代码)
### 2.1 对照 `phase-2-plan.md` §1.3 验收
| # | 计划验收 | 代码事实 | 结论 |
|---|---------|----------|------|
| 1 | 复读自然结束 → `audio.completed`,回 CHAT 只招呼一次 | `Board_OnSkillPlaybackFinished` 只发 skill_event,删除 `[app_task]chat`;Server `handle_skill_event` 招呼 | **满足**(需按 `phase-2-voice-test.md` §5 人工确认单次招呼) |
| 2 | stop/抢占 → `audio.stopped`,不主动招呼 | stopped 路径 `speak_switch=False` | **满足** |
| 3 | 离线复读 / 离线切换模式不 WakeWordInvoke | Phase 1 短路未改 | **满足** |
| 4 | 跟读全程不 `chat()` | `handle_shadowing_turn` 不调用 chat | **满足** |
| 5 | JSON 含全部有效轮(含最后一轮) | 先 `add_turn` 再回 chat | **满足** |
| 6 | 有效跟读 report;echo / PLAY_AUDIO 不上报 | ASR 层推迟;跟读有效轮才 enqueue | **满足** |
| 7 | 不新增 WS type / MQTT action / MCP 工具 | **新增了** `self.sd.ensure_app` 与 `ENSURE_APP_DIRS` | **纪律破例**,见 2.2 |
| 8–11 | 不对错进固件、不下发 apps.json、不改热路径、无 Java DDL | 遵守 | **满足** |
原文计划里跟读范读是 **Server TTS**。实施改为 **一句一个 SD MP3**(见 `phase-2-voice-test.md`)。这比原文更接近 Spec 的 `play_audio` + D16,也提前动了 Phase 6 的资源形态。
### 2.2 哪些设计被修改(相对 `phase-2-plan.md`)
1. **跟读媒体:TTS → SD 句文件。** 原因:整段带静默的音频在播放态会丢掉跟读拾音;一句一 MP3 才能「播完 → 再听」。Spec D3 `speak` 仍可用于无音频时的提示,不再当范读主路径。
2. **完成事件带 `duration_ms`。** Server 用它做听窗上限(`1.2 ×`,最少 800ms)。VAD 说完可提前结束。这是 D7 v1 的增强,不是设备 `wait` Skill(D11 仍未做)。
3. **新增 MCP / MQTT 建目录。** 产品要求:每个 App 唯一名;用户订阅后 **不重烧 bin**,由程序创建 `/{app}/{task}/lessons`。运输变了,语义是已有 `mkdir` + Spec §9 资源树,不是新传感器。
4. **`START_APP_TASK` 补进 MQTT ctl 白名单。** 实施中发现它原先不在 `BoardMqtt_ActionIsCtl`,ctl 主题会拒收。属于 Phase 1 漏网,本阶段修好。
5. **跟读播完不停任务。** 只有 skill 表含 `self.mp3.play` 的 Task(复读)才在播放结束时把 pending 写回 chat。符合 D16:步骤循环在 Server。
这些修改应视为 **Phase 2 的真实架构(Current Architecture V3)**。后续不得假装「跟读仍是 TTS Demo」或「SD 根目录随便堆文件」。
### 2.3 哪些设计没有实现(原文有、代码无)
- 跟读 TTS 范读主路径(有意替换,不是漏做)
- `wait` 一等 Device Skill(D11):用 Server 听窗代替
- 把 `audio_id` 写进 Session turn
- 设备 `user.speech.stopped` 事件(继续用 Server VAD)
- Java 表 / `type=task` / `mode.set`(按 Spec 本就不该在 Phase 2)
- 倒背 / 口算 / 接龙
### 2.4 实施中发现的问题
1. 整课 MP3 含句间静默时,播放态会忽略跟读拾音 → 必须一句一文件。
2. 完成事件若不带时长,听窗只能拍脑袋 → 补 `duration_ms`。
3. 新 App 若把目录写死在 C++ 或要求重烧,冻结就是空话 → 暴露已有 mkdir。
4. MQTT action 必须同时进 **Dispatch 与 ctl/task 白名单**,否则 ack 主题不匹配。
5. 在 `tools/list` 处理函数里同步 `call_mcp_tool` 会和 WS 收包死锁 → 建目录必须 `create_task` 后台跑。
6. 无 SD / mkdir 失败不得断开 WS。
### 2.5 与 Spec v1.0 的对照
| Spec ID | Phase 2 计划钉死? | 代码 | 宣称冻结? |
|---------|-------------------|------|------------|
| D1 完成/停止事件 | 是 | 有;另有 duration_ms | **可宣称语义已落地**(运输仍是 detect 前缀) |
| D2 pause/resume | 否(只用 stop) | 仍是 TTS-Pause-MP3 | 未冻结为 Task 调用 |
| D3 speak | 保持 type=tts | 提示/回聊仍 TTS;跟读范读改 play_audio | 设备仍不合成 |
| D4 barge_in 按 Mode | 保持 | 未改坏 | 保持 |
| D6 listen 不进 Chat | 是 | 有 | **可宣称** |
| D7 一句说完 | v1 = Server VAD+ASR | 有;加听窗上限 | **v1 可宣称**;设备 USER_SPEECH_* 仍缺 |
| D8 record_clip v1 | 有效跟读 report | 有文本+可选 WAV;turn 无 audio_id | **采集有**;对象关联留给 Phase 3 |
| D11 wait | 明确不做 | Server sleep 听窗 | 仍是缺口,不挡 Phase 3 |
| D13 fetch / SD | 计划外 | **提前**:ensure dir + 路径约定 | 布局可用;resource_id / DOWNLOAD 权威仍是 Phase 6 |
| D14 PAUSE/RESUME Task | 不做 | 无 | — |
| D15 播放级 loop | 禁止改 | 未改 | **保持** |
| D16 步骤级 loop | Server 步进 | 有 | **可宣称** |
| S4/S8 评估/branch | 禁止 | 无 | 正确 |
Spec §9 原文示例是 `/sdcard/resources/<app_id>/`。代码采用:
```text
/{app_id}/{task_id}/lessons/...
例:/english_learning/shadowing/lessons/unit01/01.mp3
```
经 `SDCard_Map_Business_Path` 映射到 `MOUNT_POINT + business_path`。这应写进 Spec / Architecture V3,不要在 Phase 3 再搬一次目录。
Spec §22 四条纪律:新 App 不要求新传感器;对错只在 Server;离线只保证播放;SD 放资源。**仍成立。** 本阶段新增的 MCP/MQTT 是运输,mkdir 不是新能力种类。
### 2.6 与原文 V1.1 Client/Server Plan 的差异
V1.1 把 Phase 2 写成 `type=task` + Java 表 + 评分。Spec 已把它拆开。本阶段 **没有** 按 V1.1 做表和评分,这是正确的。
V1.1 Client Phase 3 才做「App 资源目录」——实际在 Phase 2 因订阅不烧录提前做了布局。Phase 6 应缩小为「清单/DOWNLOAD/resource_id」,不要重做 mkdir。
---
## 3. 与 Master Plan 核心抽象对照(更新)
| 抽象 | 当前是否存在 | 实现位置 | 完成程度 | Phase 3 是否动 |
|------|--------------|----------|----------|----------------|
| **App** | 是 | `apps.json` + 唯一 `app_id` 目录 | 连接级容器 + SD 树;无订阅表 | 不升级成商店 |
| **Task** | 是(薄) | `apps.json` tasks | 一个 Task ≈ 一种动作 | 仍无 JSON Step 图 |
| **Step** | 仅跟读轮次 | `TaskSession.turns[]` | 文件里的 index,不是引擎 | **映射为 `step_instance` 行** |
| **Skill** | 是 | MCP + `k_task_skills[]` | 双表仍在 | 不合并;新 App 运行走 Server MCP play |
| **InteractionMode** | 是 | `conn.interaction_mode` | 代码名仍 `SHADOWING` = Spec `FOLLOW_READ` | 不全局重命名 |
| **TaskInstance** | 否 | JSON `task_session_id` | 有 uuid,无库 | **本阶段目标** |
| **Event** | 部分 | `[skill_event]` + session.events | 无第四套总线 | 入库,不加新 WS type |
| **Audio** | 聊天附件级 | report → LONGBLOB | 未挂 step | **尽量挂 audio_id;允许空** |
| **Assessment** | 否 | — | 0% | **禁止** |
| **Schedule** | 半截 | RTC + pending | — | **禁止**(Phase 5) |
---
## 4. Current Architecture V3(基于代码)
### 4.1 Client V3
```text
ESP32-S3 (sening-xiaozhi-s3)
├── Application 热路径          未改语义
├── MCP
│     self.mp3.* / self.power_off / self.sd.ensure_app
└── Task Gateway
      Board_StartAppTask
      Board_OnSkillPlaybackFinished → [skill_event] + duration_ms
      Board_EnsureAppLayout → mkdir /{app}/.../lessons
      k_task_skills[] 仅离线/MQTT 查 Skill;新订阅 App 不靠此表运行
```
### 4.2 Server V3
```text
xiaozhi-server
├── App Runtime
│     apps.json(权威定义)
│     start_app_task / filter_tools / ensure_app_sd_dirs
│     SHADOWING:SD 句文件 + 听窗 + D16
│     TaskSession → data/task_sessions/*.json
├── detect:[skill_event] → [app_task] → [voice_entry]
└── report:现有 manage_api chat-history(与 Task 表仍脱节)
manager-api     仍未参与 Task Runtime
```
### 4.3 SD 布局(V3 事实)
```text
/{app_id}/{task_id}/lessons/{lesson_id}/file.mp3
```
- `app_id` / `task_id`:`[A-Za-z0-9_-]`,长度 < 32
- 跟读:该 task 下字典序第一个 lesson 文件夹内全部 MP3
- 复读空 `name`:优先 `/{app}/{task}/lessons/`,再 NVS `last_rep`,再全卡第一首
- 订阅后 mkdir,失败只打日志
### 4.4 通信 V3
```text
小程序/MQTT
  START_APP_TASK | SET_ALARM | ENSURE_APP_DIRS
        ↓ snlp
Device Task Gateway
        ↓ Xiaozhi WS listen detect  [skill_event] / [app_task]
Server App Runtime
        ↓ 现有 report HTTP
manager-api chat_history + audio BLOB     ← 尚未按 instance 查询
        ↓ 本地文件
data/task_sessions/{uuid}.json            ← Phase 2 唯一可查询归档
```
---
## 5. 技术债务(Phase 2 之后)
### P0 — 不解决就无法安全做 Phase 3
无。pytest 与 Session schema 已足够建表。
**进入 Phase 3 的前提是:不要在建表同时做评分 / 新 Device 能力。**
### P1 — 近期应该解决(Phase 3 范围或紧挨着)
| ID | 债务 | 放哪 |
|----|------|------|
| P1-1 | Session 只在设备旁 JSON,断线换机/换 Python 进程无法查询 | **Phase 3 Instance 表** |
| P1-2 | 跟读 WAV 在 `chat_audio`,turn 无 `audio_id` | Phase 3 尽量关联;评分为 Phase 4 |
| P1-3 | 双技能表 `apps.json` vs `k_task_skills[]` | 保持;新 App 不靠固件表运行;禁止再加业务分支 |
| P1-4 | 寄生 `[skill_event]` 与 ASR 文本空间重叠 | 保持前缀;Phase 3 **仍不上** `type=event` |
| P1-5 | 建目录 MCP/MQTT 与 Spec 运输表尚未写进 Spec 正文 | 更新 Spec §9;Phase 3 不改这些接口 |
### P2 — 以后再说
| ID | 债务 |
|----|------|
| P2-1 | D11 `wait` 一等 Skill |
| P2-2 | D14 TASK_PAUSE/RESUME |
| P2-3 | D8 v1.1 本地 WAV 上传 |
| P2-4 | 双 MQTT 合并 |
| P2-5 | `SHADOWING` 重命名为 `FOLLOW_READ` |
| P2-6 | 非 Sening 板 `play_audio` |
| P2-7 | 对象存储、家长 UI、App Store |
| P2-8 | 固件查找表仍含 `english_learning` 字符串 |
---
## 6. 结论:Phase 2 是否 Done
**功能 Done(带条件):**
- D1 完成/停止事件已从「猜 `[app_task]chat`」变成逻辑事件
- 跟读是可归档会话(JSON turns,含最后一轮),不进 LLM
- 步骤循环在 Server(D16)
- 新 App 目录可在不烧录的前提下创建
- Chat / 翻译 / 离线复读纪律未故意破坏;pytest 覆盖核心纯函数
**工程未 Done:**
- 语音回归依赖人工勾选 `phase-2-voice-test.md`
- `audio_id` 未挂到 turn
- 没有可跨进程查询的 Instance
**因此:** 可以宣称 Spec §20 前三条(完成事件、非 Chat 句边界、跟读归档 v1)**在 Runtime 层已钉住**。
**不可以**宣称 Device Capability 已 100% 实现(缺 wait / pause Task / 统一 resource_id)。
**不可以**开启 Assessment、倒背/口算/接龙、`type=task`。
下一阶段只做一件事:把已经稳定的 `TaskSession` **持久化成可查询的 TaskInstance**(见 `phase-3-plan.md`)。
phase-2-voice-test.md
New file
@@ -0,0 +1,175 @@
# Phase 2 语音测试(烧录后怎么说)
烧录新固件并连上 Wi‑Fi / Server 后,按下面顺序对设备说话。
每一步先看「你应该听到」,再打勾。本阶段**不要**测数字倒背、口算、成语接龙。
唤醒词用:**你好小智**(等它开始听再讲下一句)。
---
## 0. 烧录后先确认
1. 设备开机后进入**对话模式**(chat),不要自己播音频。
2. 说「你好小智」,能进对话。
3. 说「今天天气怎么样」——会当闲聊/查天气回答(证明现在是 Chat,不是翻译机)。
不过这一关,后面的模式切换都没有意义。
---
## 1. 菜单切换(推荐主路径)
对设备说:
| 步 | 你说 | 你应该听到 | 通过? |
|----|------|------------|--------|
| 1 | **你好小智** | 唤醒,进入聆听 | |
| 2 | **切换模式** | 「想聊天、听复读机、跟读、翻译,还是中英对话?也可以说我们来聊天吧。」 | |
| 3 | **复读机** | 「现在已经切换至复读机模式…」然后 **SD 音频开始循环播放**,不再跟你闲聊 | |
| 4 | 音频播放中随便说一句「讲个笑话」 | **继续播音频,不要讲笑话** | |
| 5 | **你好小智**,再说 **退出复读** | 停播,并招呼一次回到对话(类似「已经退出复读机模式,现在是对话模式…」) | |
| 6 | **讲个笑话** | 会讲笑话(证明已回 Chat) | |
同一套菜单,再测另外三个入口(每次先「你好小智」→「切换模式」):
| 你说 | 你应该听到 | 退出时说 |
|------|------------|----------|
| **跟读** 或 **开始跟读** | 「现在已经切换至跟读模式。请跟我读:Hello, how are you today?」 | **退出跟读** |
| **打开翻译** | 「现在已经切换至翻译模式…」 | **退出翻译** |
| **中英对话** | 「现在已经切换至中英对话模式…」 | **退出中英对话** |
也可以不经过菜单,唤醒后直接说口令(效果相同):
- 复读:`复读机` / `开始复读`
- 跟读:`开始跟读` / `我要跟读`
- 翻译:`打开翻译` / `翻译模式`
- 中英:`中英对话`
- 回聊:`我们来聊天吧` / `回到聊天`
---
## 2. 跟读(必须整段走完)
SD 卡先放好音频,**一句一个 MP3**,不要整课带静默:
```text
/english_learning/shadowing/lessons/unit01/01.mp3
/english_learning/shadowing/lessons/unit01/02.mp3
/english_learning/shadowing/lessons/unit01/03.mp3
```
复读机音频放在另一棵目录,不要混进跟读:
```text
/english_learning/repeater/lessons/unit01/full.mp3
```
从对话模式开始。
| 步 | 你说 / 你做 | 你应该听到或看到 | 通过? |
|----|-------------|------------------|--------|
| 1 | **开始跟读** | 开始播 `01.mp3`(不要用闲聊 TTS 念英文) | |
| 2 | **等它播完**,再用自己的声音跟读(不要和喇叭叠在一起) | 跟读被收下后,再播下一句 MP3 | |
| 3 | 每一句都「播完 → 你跟读」 | 不会讲笑话、不会当聊天 | |
| 4 | 最后一句跟读完 | **回到对话模式并招呼一次** | |
| 5 | **讲个笑话** | 会讲笑话 | |
听窗大约是该句时长的 **1.2 倍**:说完会提前进入下一句;一直不说会超时后进下一句。
另测一次「中途退出」:第二句播完跟读时说 **退出跟读** → 立刻回 Chat。
跟读时**不要**测这些:
- 喇叭还在播时抢着说话 → 应被忽略
- 说「给我讲个笑话」→ **禁止**开始讲笑话
查档案:`data/task_sessions/` 最新 JSON 里 `turns` 条数应等于你实际跟读成功的句数(含最后一轮)。
---
## 3. 翻译
| 步 | 你说 | 你应该听到 | 通过? |
|----|------|------------|--------|
| 1 | **打开翻译** | 切换至翻译模式 | |
| 2 | **今天天气真好** | 只给英文译文,不要闲聊、不要解释 | |
| 3 | **How are you?** | 只给中文译文 | |
| 4 | **退出翻译** | 招呼回到对话 | |
| 5 | **讲个笑话** | 会讲笑话(证明翻译没进固件、Chat 已恢复) | |
---
## 4. 中英对话(不是翻译机)
| 步 | 你说 | 你应该听到 | 通过? |
|----|------|------------|--------|
| 1 | **中英对话** | 切换说明 | |
| 2 | **我喜欢读书** | **用英文回答你**,不是把原句译回去交差 | |
| 3 | **I like music** | **用中文回答你** | |
| 4 | **退出中英对话** | 回到对话 | |
---
## 5. 复读结束事件(D1,烧录后必测)
SD 卡上要有 MP3。音频尽量短,或接受循环几遍。
**A. 自然播完(只招呼一次)**
1. 说 **复读机**,让它播。
2. **不要说话**,等音频按 `repeat` 遍数全部结束。
3. 结束时应 **只招呼一次** 回到对话(类似「已经退出复读机模式…」)。
4. 若连续说两遍「现在是对话模式」,即失败(旧固件会 `[app_task]chat` 再招呼一次)。
**B. 中途退出(不主动招呼)**
1. 再说 **复读机**,播起来。
2. 说 **退出复读**。
3. 应立刻停播并回 Chat。
   若你是用「退出复读」口令停的,会有回聊招呼;若是设备 stop 事件单独上来、没有这句口令,则**不应再额外招呼一遍**。
**C. 播放中不能当闲聊**
播放时说「你好吗」→ 继续播,不回答。
查档案:对应 session 的 `events` 里应有 `audio.completed`(自然结束)或 `audio.stopped`(被停)。
---
## 6. 离线(拔网或关路由器后再测)
MultiNet 只认这几句,**不要**指望离线跟读/翻译。
| 你说 | 期望 |
|------|------|
| **复读机** | 播 NVS 里上次音频,或 SD 第一首;**不要**去连云 |
| **切换模式** | **不要**连云、不要播菜单;日志里应跳过 voice_entry |
| **退出复读** / **我们来聊天吧** | 停播,本地回到 chat |
插回网后,从第 1 节再走一遍在线菜单。
---
## 7. 失败时先看哪
| 现象 | 多半是 |
|------|--------|
| 「切换模式」没菜单 | 没网,或还在播音频且优先级更高 |
| 跟读开始讲笑话 | 没进 SHADOWING,或最后一轮丢了直接 chat |
| 复读结束招呼两次 | 新固件仍在发 `[app_task]chat`,或 Server 收了两次切换 |
| 跟读 JSON 少最后一轮 | `handle_shadowing_turn` 未按 Phase 2 顺序记 turn |
| 离线「切换模式」仍连云 | `SendListenDetect` 没网短路被破坏 |
---
## 口令速查
| 意图 | 说其中一句即可 |
|------|----------------|
| 唤醒 | 你好小智 |
| 菜单 | 切换模式 |
| 复读 | 复读机、开始复读 |
| 跟读 | 开始跟读、我要跟读 |
| 翻译 | 打开翻译、翻译模式 |
| 中英 | 中英对话 |
| 回聊 | 我们来聊天吧、回到聊天、退出复读、退出跟读、退出翻译、退出中英对话 |
phase-3-plan.md
New file
@@ -0,0 +1,380 @@
# Phase 3 Coding Plan — TaskInstance 持久化
> File: `phase-3-plan.md`
> Date: 2026-08-31
> **状态:已实施。** 对照事实见 `phase-3-review.md`。下一阶段 `phase-4-plan.md`。
> 约束文档:`Xiaozhi Device Capability Specification v1.0.md`
> 代码事实(开工时):`phase-2-review.md`(Current Architecture V3)
> 路线图:`roadmap.md`
> 验收:`phase-3-test.md`
> **与原文 V1.1「Phase 2 = type=task + 表 + 评分」冲突时,以 Spec + 本文为准:本阶段只有表和查询,没有新 Device 能力,没有评分。**
本阶段 **不是** 再做一个 App,也 **不是** 做倒背/口算/接龙,也 **不是** 改固件。
本阶段把 Phase 2 已经写进 `data/task_sessions/*.json` 的字段,落到 manager-api,让一次跟读/复读可以按 id 查到。
给 Coding Agent:先读 Spec §0、§4–5、§17、§20;再读 `phase-2-review.md` §4;再读 `session.py`、`runtime.py`、`manage_api_client.py`、`AgentChatHistoryController.java`、`ShiroConfig.java`、现有 Liquibase 风格。
---
## 0. 与 Spec 的对照(本阶段只钉这些)
| Spec ID | 能力 | Phase 3 做什么 | 不做什么 |
|---------|------|----------------|----------|
| — | Task Runtime 中层 | Session → `task_instance` / `task_step` / `task_event` | 不把 Instance 权威放到设备 |
| D1 / D6 / D16 | 已落地 | **只入库** events / turns | 不改完成事件运输、不改跟读步进 |
| D8 v1 | 音频归档 | 尽量把现有 report 的 `audio_id` 写到 step | 不做 v1.1 本地文件上传;不改 Opus |
| D13 | SD 布局 | **沿用** Phase 2 `/{app}/{task}/lessons` | 不改 mkdir MCP/MQTT;不做 resource_id |
| D11 / D14 | wait / TASK_PAUSE | **不做** | — |
| S4 / S8 | 评估 / branch | **禁止** | 表上可以留空 `score` 列,但本阶段不写值、不跑比对 |
| §22 | 新 App 零新传感器 | 遵守 | 不新增 WS type、不为 Instance 加 Device 命令 |
运输可变、语义不变:Instance 走 **HTTP(已有 manage_api 通道)**。禁止为了 `instance_id` 新增 `type=task`。设备 **不必** 缓存 `instance_id`。
---
## 1. 目标与非目标
### 1.1 Objective
1. 一次跟读或复读结束后,用 HTTP GET 能查到与 JSON 文件一致的:app/task、status、turns(prompt + asr)、events(含 `audio.completed` / `listen_timeout`)。
2. 主键 = 现有 `task_session_id`(uuid),不要再用 WS `session_id` 当长期主键。
3. Agent ≠ App:Task 行 **不得** 塞进 `ai_agent_chat_history` 冒充正式模型;chat report 管道保留。
4. manager-api 挂了或上报失败:**不得**影响语音(与现有 chat report 相同:只打日志)。
### 1.2 Why Now
Phase 2 字段已稳定。没有 Instance,Phase 4 分数无处挂,Phase 5 调度无法对账。现在建表不会空。
### 1.3 Acceptance Criteria(必须全部满足)
**能力:**
1. 完整跟读一次 → `GET /task/instance/{id}` 的 `steps` 条数 = 有效跟读句数(含最后一轮),`prompt_text` / `asr_text` 与 `data/task_sessions/{id}.json` 一致。
2. 复读自然结束 → instance `end_reason=play_completed`,`events` 含 `audio.completed`。
3. 退出跟读 / 断 WS → `cancelled` + 对应 `end_reason`(`user_exit` / `disconnected`),已有 turns 不丢。
4. `GET` 按 `macAddress` 可列出最近 instance(最少 20 条)。
5. 旧路径仍在:JSON 文件继续写(双写);chat 天气/笑话仍走 `ai_agent_chat_history`。
6. 关闭或配错 manage_api:**跟读/复读/Chat 仍能跑完**。
**冻结纪律:**
7. **固件零提交**(含 `app_task.cc` / MCP / MQTT)。Phase 2 已烧录的 bin 必须能对 Phase 3 Server 工作。
8. 不新增 Xiaozhi WS `type`、不新增 MQTT action、不新增 MCP 工具。
9. 不评分、不出题、不改 `repeat`、不改 SD 路径约定、不把 `apps.json` 下发到 SD。
10. 不改 `hello/listen/tts/stt` 与 Opus。
### 1.4 Out of Scope
- Phase 4:文本比对、score、retry JSON
- Phase 4b:倒背 / 口算 / 接龙
- Phase 5:Server 日历
- Phase 6:DOWNLOAD / resource_id / 搬家 SD
- manager-web / 小程序 UI(本阶段 curl / 智控台 Swagger 即可)
- 对象存储、Redis Session、微服务拆分
- `type=task` / `mode.set` / 设备缓存 instance_id
- `wait` Skill、TASK_PAUSE、indicate、长按退出
---
## 2. 架构(本阶段)
```text
Device(Phase 2 bin,不动)
  [skill_event] / Opus listen / MCP play
        ↓
xiaozhi-server App Runtime
  TaskSession.save()  →  data/task_sessions/{id}.json     (保留)
                  ↘ HTTP upsert(server secret)
                    manager-api
                      ai_task_instance
                      ai_task_step
                      ai_task_event
                      (可选)step.audio_id → ai_agent_chat_audio.id
查询:oauth2 GET(家长/控制台以后用;本阶段用 token 或先 server 内网测)
```
`task_session_id` 在 `begin_session` 已经生成。Phase 3 **不要**再发一个 id 给设备。
跟读下一句仍然是 Spec **D16 Server loop**。表只是投影,不是新的 Step 引擎。
---
## 3. 表结构(最小)
Liquibase:`manager-api/src/main/resources/db/changelog/202608311800.sql`
必须登记 `db.changelog-master.yaml`。**禁止改已经执行过的 changeset**(含 `202504151206.sql` 的 URL 占位)。
命名与现网一致:`utf8mb4`、`DATETIME(3)`、InnoDB。
**不要** `DROP TABLE IF EXISTS` 业务表。
### 3.1 `ai_task_instance`
| 列 | 类型 | 说明 |
|----|------|------|
| `id` | VARCHAR(32) PK | = `task_session_id` |
| `mac_address` | VARCHAR(50) | 设备 |
| `agent_id` | VARCHAR(32) NULL | 若连接能取到则填;没有就空 |
| `ws_session_id` | VARCHAR(64) | Phase 2 `ws_session_id` |
| `app_id` | VARCHAR(32) | |
| `task_id` | VARCHAR(32) | |
| `source` | VARCHAR(24) | voice / mqtt_immediate / schedule / … |
| `interaction_mode` | VARCHAR(32) | 存代码名 `SHADOWING` 即可 |
| `status` | VARCHAR(16) | `running` / `completed` / `cancelled` |
| `end_reason` | VARCHAR(32) | 与 JSON 相同 |
| `started_at` | BIGINT | epoch ms |
| `ended_at` | BIGINT NULL | |
| `created_at` / `updated_at` | DATETIME(3) | |
索引:`(mac_address, started_at)`、`(app_id, task_id, started_at)`、`(status)`。
### 3.2 `ai_task_step`
对应 `turns[]`。一条有效跟读一轮一行。
| 列 | 类型 | 说明 |
|----|------|------|
| `id` | BIGINT PK AI | |
| `instance_id` | VARCHAR(32) | FK 逻辑关联即可(InnoDB FK 可选;建议有) |
| `step_index` | INT | `turns[].index` |
| `prompt_text` | TEXT | 跟读可能是 SD 路径 |
| `asr_text` | TEXT | |
| `echo_rejected` | TINYINT(1) | 默认 0 |
| `audio_id` | VARCHAR(32) NULL | 指向 `ai_agent_chat_audio.id` |
| `recorded_at` | BIGINT | |
唯一:`(instance_id, step_index)`。
复读没有 turns 是合法的(只有 events)。
### 3.3 `ai_task_event`
对应 `events[]`。
| 列 | 类型 | 说明 |
|----|------|------|
| `id` | BIGINT PK AI | |
| `instance_id` | VARCHAR(32) | |
| `name` | VARCHAR(64) | `audio.completed` / `audio.stopped` / `listen_timeout` / `echo_rejected` |
| `app_id` / `task_id` | VARCHAR(32) | 可空则继承 instance |
| `at_ms` | BIGINT | JSON `at` |
禁止把 score、parser、difficulty 做成 NOT NULL。若预留 `score` 列,必须可空且本阶段恒为 NULL。
---
## 4. HTTP API
鉴权对齐现网:
| 路径 | 过滤器 | 调用方 |
|------|--------|--------|
| `PUT /task/instance/{id}` | `server`(`ServerSecretFilter`,与 chat report 相同) | xiaozhi-server |
| `GET /task/instance/{id}` | `oauth2`(控制台用户) | 测试 / 以后家长 |
| `GET /task/instance` | `oauth2` | 列表;query: `macAddress` 必填,可选 `appId`、`limit` |
**不要**把 PUT 设成 `anon`。
**不要**新建第二套密钥。
### 4.1 Upsert 载荷
与 `TaskSession.to_dict()` 对齐,camelCase 或 snake 二选一,**Python / Java 必须同一套**。推荐 **snake_case 与 JSON 文件一致**,减少映射 bug:
```json
{
  "task_session_id": "uuid",
  "ws_session_id": "",
  "device_id": "mac",
  "agent_id": "",
  "app_id": "english_learning",
  "task_id": "shadowing",
  "source": "voice",
  "interaction_mode": "SHADOWING",
  "started_at": 0,
  "ended_at": 0,
  "status": "running",
  "end_reason": "",
  "turns": [
    {
      "index": 0,
      "prompt_text": "/english_learning/shadowing/lessons/unit01/01.mp3",
      "asr_text": "...",
      "echo_rejected": false,
      "recorded_at": 0,
      "audio_id": null
    }
  ],
  "events": [
    { "name": "audio.completed", "app_id": "", "task_id": "", "at": 0 }
  ]
}
```
语义:按 `id` **整单覆盖** steps/events(先删后插或按 unique key upsert)。调用方每次 `session.save()` 都可 PUT 全文。不要做复杂 patch 协议。
幂等:同一 id 重复 PUT 成功,最终与最后一次 body 一致。
### 4.2 GET 响应
返回上表同一 JSON,外加 `mac_address` 若与 `device_id` 需要并存则只保留一个字段并在文档写死。
列表:`{ "list": [ { id, app_id, task_id, status, end_reason, started_at } ], "total": n }`
不要在列表里返回全部 turns。
### 4.3 `audio_id`(尽力而为)
现有 `POST /agent/chat-history/report` 返回 `Result<Boolean>`,Python 拿不到 id。
本阶段允许两条路径,**选工作量小的一条,不要两条都做**:
**A(推荐)。** report DTO 增加可选 `taskInstanceId` + `stepIndex`;Java 写入 chat_audio 后更新对应 `ai_task_step.audio_id`。跟读 `enqueue_asr_report` 带上这两个可选字段。
**B。** 本阶段 step.audio_id 恒空;GET 仍能查文本。在 PR 说明留给 Phase 4。验收 #1 不强制音频。
禁止为了挂音频再上传一份 WAV。
---
## 5. Server 改动清单
### 5.1 manager-api(Java)
| ID | 任务 | 说明 |
|----|------|------|
| J1 | Liquibase SQL + master yaml | 只增新文件 |
| J2 | Entity / Dao / Service | 模块建议 `xiaozhi.modules.task`,不要塞进 agent chat 包当「一种聊天」 |
| J3 | `TaskInstanceController` | PUT upsert、GET one、GET list |
| J4 | `ShiroConfig` | `PUT /task/instance/**` → `server`;GET 走默认 oauth2 |
| J5 | 校验 | `app_id`/`task_id` 长度与字符与固件约定一致;非法 400;缺 id 400 |
| J6 | 可选 A | report DTO 扩展 + 回写 audio_id |
manager-web:**零提交**(除非 Swagger 自动生成,不要手改 Vue)。
### 5.2 xiaozhi-server(Python)
| ID | 任务 | 文件 | 说明 |
|----|------|------|------|
| S1 | HTTP 客户端 | `config/manage_api_client.py` | `upsert_task_instance(payload)`;无 client 或异常 → log,return None |
| S2 | 投影 | `core/app_runtime/session.py` | `TaskSession.save()` 成功写盘后 **异步/同步均可** 调用 upsert;失败不影响 save |
| S3 | 连接字段 | 若 `conn.agent_id` 已有则写入 payload | 没有就空 |
| S4 | 可选 A | `enqueue_asr_report` / `report()` | 附带 instance/step |
| S5 | pytest | `tests/test_app_runtime.py` | `to_dict` 字段;upsert 被调用;upsert 抛错时 save 仍成功(mock) |
**不要**在 `runtime.py` 再复制一套 schema。upsert 只从 `TaskSession.to_dict()` 出发。
跟读/复读/skill_event 的 begin/end/add_turn **不要改行为**,只借现有 `save()` 挂钩。
---
## 6. Client 改动清单
**空。** 禁止改 `xiaozhi-esp32-2.2.6`。
旧固件无 `duration_ms`、无 `ensure_app` 时:Server 仍按 Phase 2 兼容(时长缺省、建目录失败忽略)。Phase 3 不得依赖新固件字段。
---
## 7. 测试
详见 `phase-3-test.md`。本 PR 最低限度:
1. pytest:schema 投影 + upsert 失败隔离。
2. 手工:跟读整段 → GET instance 对得上 JSON;Chat 笑话仍在 chat_history。
3. 回归:`phase-2-voice-test.md` 第 0、2、5 节(跟读 + 复读结束事件)不得回退。
不要本阶段验收倒背/口算/接龙。
---
## 8. 实施顺序
```text
P3-A  Liquibase + Entity + PUT upsert + GET
      用 curl + server secret 写入一条手工 JSON,能读回
P3-B  Python manage_api_client + TaskSession.save() 挂钩
      跟读一次,库里出现行;关掉 manager-api 再跟读,设备不停
P3-C  (可选)report 回写 audio_id
      做不完就留空列,不要卡住 A/B
P3-D  pytest + phase-3-test.md 勾选
```
未完成 A 不要改 Python 热路径。未完成 B 不得宣称 Phase 3 Done。未完成 D 不得开 Phase 4 评分。
---
## 9. 编码约束(Spec §16–17 落到本 PR)
1. 固件零 diff。
2. 不得把 ASR/LLM/评分/口算/词库写入 Client 或 Java「设备逻辑」。
3. 不得新增 WS type / MQTT / MCP。
4. 不得把 Task 行写入 `ai_agent_chat_history` 当权威。
5. 不得修改已执行 Liquibase 的 checksum 内容。
6. Chat 热路径与 Mode 分流逻辑原则上不改;只在 `save()` / report 旁路加上报。
7. 每个 PR 先 Chat 回归,再查 GET。
---
## 10. 风险与回归
| 风险 | 缓解 |
|------|------|
| 每次 save 全量删插 steps 并发丢行 | 单连接单 session;不要并行 save 同一 id |
| manage_api 超时拖死语音 | upsert 必须 catch;必要时 `create_task` 后台发,但要保证 end 时最终一致(可在 `end_session` 再 PUT 一次) |
| 与 chat_history 双写让产品以为「聊天记录就是跟读」 | API 路径分开;文档写清 Agent ≠ App |
| 把 Phase 3 做成评分 | 验收表不含 score;列可空 |
| 为 instance_id 改 detect 文本 | 禁止 |
| 新 App 又去改 C++ 建目录 | 已有 `ensure_app`;只改 `apps.json` |
---
## 11. Data Flow
```text
开始跟读
  begin_session → 写 JSON + PUT running(turns=[])
每句有效 ASR
  add_turn → save → PUT 全文
  enqueue_asr_report(可选带 instance/step)
audio.completed(跟读)
  add_event → save → PUT;开听窗(逻辑不变)
结束
  finish(reason) → save → PUT completed/cancelled
  GET /task/instance/{id} 可查
```
复读:可能零 turn、若干 `audio.*` event。同样 upsert。
---
## 12. State Machine
```text
DeviceState / InteractionMode:与 Phase 2 相同,禁止加边。
Instance:
  (无)──begin──► running ──finish(finished|play_completed)──► completed
                      └──finish(user_exit|disconnected|play_stopped|preempted)──► cancelled
```
设备执行器状态不是本阶段工作。
---
## 13. 完成后(人类流程)
```text
Coding
  → 勾选 phase-3-test.md
  → Completion Review(对照本文 §1.3)  ✅ phase-3-review.md
  → Architecture V4(表 + HTTP)
  → Roadmap Review
  → phase-4-plan.md(时钟步进 + 后评估;无 JUDGE、无当场再播)
```
禁止本阶段「顺便」评分、倒背、Scheduler、SD 搬家、`type=task`、manager-web 跟读页。
phase-3-review.md
New file
@@ -0,0 +1,345 @@
# Phase 3 Completion Review
> Date: 2026-08-31
> Basis: **current code**(不是 `phase-3-plan.md` 的原文愿望)
> Compared against: `phase-3-plan.md` · `phase-3-test.md` · `phase-2-review.md` · `roadmap.md` · `Xiaozhi Device Capability Specification v1.0.md`
> Repositories: `xiaozhi-esp32-2.2.6`(Client,本阶段零 diff)· `xiaozhi-esp32-server`(Python + manager-api)
本文件只记录事实、差异、债务与对 Spec / Roadmap 的修正。下一阶段施工见 `phase-4-plan.md`,验收见 `phase-4-test.md`。
相关文档索引:
| 文档 | 角色 |
|------|------|
| `Xiaozhi Device Capability Specification v1.0.md` | Device 语义冻结 |
| `roadmap.md` | 阶段切分 |
| `phase-2-review.md` | Current Architecture V3(文件 Session) |
| `phase-3-plan.md` | 本阶段原文施工单(Instance 落库) |
| `phase-3-test.md` | GET 对账 + 语音回归 + 失败隔离 |
| `phase-2-voice-test.md` | 口令底表(本阶段不重烧) |
---
## 1. 当前代码盘点(以仓库为准)
### 1.1 Phase 3 实际改动的文件
#### Client(`xiaozhi-esp32-2.2.6`)
**无业务提交。** 跟读/复读仍用 Phase 2 bin:`[skill_event]`、一句一 MP3、`ensure_app` mkdir。
#### Server Python(`xiaozhi-server`)
| 文件 | 角色 |
|------|------|
| `core/app_runtime/session.py` | `agent_id`;`save()` 写盘后 `_schedule_upsert`(`create_task`,无 running loop 则跳过) |
| `config/manage_api_client.py` | `upsert_task_instance`:单次 `_async_request` PUT;`report()` 可选 `taskInstanceId`/`stepIndex` |
| `core/handle/reportHandle.py` | `enqueue_asr_report` 六元组透传 instance/step |
| `core/connection.py` | `_process_report` 默认参数,转发到 manage_api |
| `core/app_runtime/runtime.py` | 跟读有效轮 `enqueue_asr_report(..., task_session_id, step_index)` |
| `tests/test_app_runtime.py` | `to_dict` 含 Phase 3 字段;upsert 调度抛错时 `save` 仍成功 |
跟读步进、听窗、`begin_session`/`add_turn` 语义 **未改**。upsert 只挂在已有 `save()` 上。
#### manager-api(Java)
| 文件 | 角色 |
|------|------|
| `db/changelog/202608311800.sql` | 新表;已登记 `db.changelog-master.yaml`。**未改**已执行 changeset |
| `modules/task/**` | Entity / Dao / DTO / `TaskInstanceService` / `TaskInstanceController` |
| `security/config/ShiroConfig.java` | `/task/instance` 与 `/task/instance/**` → `server` |
| `agent/dto/AgentChatHistoryReportDTO.java` | 可选 `taskInstanceId` / `stepIndex` |
| `agent/.../AgentChatHistoryBizServiceImpl.java` | report 成功后 `attachTaskStepAudio` |
#### manager-web / manager-mobile
**零提交。** 查询靠 curl / Swagger / server secret。
### 1.2 实际增加的模块
| 模块 | 位置 | 职责 |
|------|------|------|
| Task Instance 投影 | manager-api `xiaozhi.modules.task` | Session JSON → 三表;权威仍是 Server Runtime 内存 + 文件,库是查询副本 |
| HTTP upsert | `PUT /xiaozhi/task/instance/{id}` | 全文覆盖 turns/events |
| HTTP GET | `GET .../{id}`、`GET ...?macAddress=` | 详情含 turns;列表不含正文 |
| audio_id 挂钩 | 计划 §4.3 **路径 A** | chat report 回写 `ai_task_step.audio_id` |
没有:Assessment、`type=task`、设备缓存 `instance_id`、新 WS/MQTT/MCP、租户列、manager-web 跟读页。
### 1.3 实际增加的 API / 协议
| 类型 | 名称 | 说明 |
|------|------|------|
| REST PUT | `/task/instance/{id}` | server secret;snake_case 与 JSON 文件对齐 |
| REST GET | `/task/instance/{id}` | **计划写 oauth2,实施为 server**(Shiro 按路径不能按方法拆) |
| REST GET | `/task/instance?macAddress=&appId=&limit=` | 同上 |
| REST | 现有 `POST /agent/chat-history/report` | 增可选字段,无新路径 |
| WS / MQTT / MCP | **无** | 设备仍不知 instance_id |
响应包在现网 `Result<T>` 里(`code` + `data`),不是裸 DTO。context-path:`/xiaozhi`。
### 1.4 数据库
| 表 | 主键 | 内容 |
|----|------|------|
| `ai_task_instance` | `id` = `task_session_id`(32 hex) | mac / agent_id / app_id / task_id / status / end_reason / 时间戳 |
| `ai_task_step` | BIGINT + UNIQUE `(instance_id, step_index)` | prompt / asr / echo / audio_id / recorded_at |
| `ai_task_event` | BIGINT | name / app / task / at_ms;FK CASCADE |
**没有** `score` / `expected_text` / `tenant_id` 列(计划允许预留 score 且恒 NULL;实施选择 **不建该列**,留给 Phase 4 新 changeset)。
覆盖 steps 时:新 `audio_id` 为空则保留旧行(避免与 report 竞态)。
### 1.5 Client 架构变化
相对 V3:**无。** 设备不持有 instance_id,不读库。
### 1.6 Server 架构变化
```text
TaskSession.save()
  ├── data/task_sessions/{id}.json     (保留,失败只 log)
  └── create_task → PUT /task/instance/{id}
        失败 print/log,不抛回热路径
        无 ManageApiClient / 无 running loop → 跳过
跟读有效 ASR
  add_turn → save → PUT
  enqueue_asr_report(instance, step) → 现有 WAV BLOB → Java attach audio_id
```
权威状态:**当前连接内存 + JSON 文件**;manager-api 是最终一致投影。manage_api 挂了,跟读仍按 Phase 2 跑。
### 1.7 Client ↔ Server 实际通信流程
与 Phase 2 相同。新增的只是 Server→manager-api 的 HTTP,设备看不见。
```text
开始跟读
  begin_session → JSON + PUT running(turns=[])
每句有效 ASR
  add_turn → save → PUT 全文
  report(可选 audio_id 回写)
audio.completed
  add_event → save → PUT;开听窗(逻辑不变)
结束
  finish → save → PUT completed/cancelled
  GET /xiaozhi/task/instance/{id} 可查(需 server secret)
```
复读:零 turn、若干 `audio.*` event,同样 upsert。Chat **不** `begin_session`。
### 1.8 Task Runtime 当前实现状态
| 能力 | 状态 |
|------|------|
| TaskSession JSON | **有**(Phase 2) |
| TaskInstance 可查询 | **有**(HTTP GET;双写) |
| Step 行 = 跟读一轮 | **有**(投影,不是 Step 引擎) |
| Event 入库 | **有** |
| D8 audio 挂 step | **尽力**:路径 A 已接;取决于 report 配置与竞态 |
| Assessment / score | **无** |
| expected_text(可评分原文) | **无**;`prompt_text` 跟读时是 **SD 路径** |
| Scheduler / 资源商店 / 租户 | **无** |
结论:这是 **可查询的薄 Runtime(JSON + 三表)**,仍不是 V1.1 的「Phase 2 = 评分」。评分已正确留到 Phase 4。
### 1.9 测试情况
| 项 | 现状 |
|----|------|
| pytest `tests/test_app_runtime.py` | **有**(15 条量级:口令、session、skill_event、SD 路径、Phase 3 schema、upsert 失败隔离) |
| Java 单测 / Maven 在本仓库对话中的编译记录 | **无** |
| `phase-3-test.md` 语音 + GET 对账 | **有书面清单**;仓库内无跑测勾选记录 |
| 固件测试 | 不适用(零 diff) |
---
## 2. Phase 3 Implementation Review(计划 vs 代码)
### 2.1 对照 `phase-3-plan.md` §1.3 验收
| # | 计划验收 | 代码事实 | 结论 |
|---|---------|----------|------|
| 1 | 跟读 GET steps = 有效句数,prompt/asr 与 JSON 一致 | upsert 全文覆盖;prompt 为 SD 路径 | **代码满足**(需按 test.md 人工 GET) |
| 2 | 复读 `end_reason=play_completed` + `audio.completed` | 未改结束路径,只多 PUT | **代码满足** |
| 3 | 退出/断线 cancelled + 已有 turns 不丢 | `finish` + save;断线 `end_session(disconnected)` | **代码满足** |
| 4 | GET 按 mac 列出最近 ≥20 | `limit` 默认 20、上限 100 | **满足** |
| 5 | JSON 双写;Chat 仍走 chat_history | chat 不 begin_session | **满足** |
| 6 | manage_api 挂了语音仍跑 | upsert catch + 后台 task | **代码满足** |
| 7 | 固件零提交 | 无 Client 业务 diff | **满足** |
| 8 | 无新 WS/MQTT/MCP | 无 | **满足** |
| 9 | 不评分、不改 repeat/SD、不下发 apps.json | 无 score 列、无评分调用 | **满足** |
| 10 | 不改 hello/listen/tts/stt 与 Opus | 未改 | **满足** |
计划 §4 GET 走 oauth2:**未按原文做**。见 2.2。
计划 §4.3 选 A 或 B:实施了 **A**(report 回写 audio_id)。
### 2.2 哪些设计被修改(相对 `phase-3-plan.md`)
1. **GET 鉴权 = server,不是 oauth2。** Shiro 按路径挂过滤器,不能按 HTTP 方法拆。与 chat report 同一密钥即可测通。家长/控制台 oauth2 留给 Phase 7,不要为本阶段再做第二套过滤器。
2. **未预留 `score` 列。** 计划允许可空且恒 NULL。实施选择不建,避免空列被当成「已经有评估」。Phase 4 用 **新 changeset** 加列。
3. **PUT 单次请求,不做 6×10s 重试。** 避免刷爆 manage_api;失败只 log。
4. **覆盖 steps 合并旧 `audio_id`。** 计划写先删后插;实施删插但空 audio 保留旧值,减少与 report 竞态。
这些应视为 **Phase 3 的真实架构(Current Architecture V4)**。
### 2.3 哪些设计没有实现(原文有、代码无或未验证)
- GET oauth2(有意改为 server)
- 表上的空 `score` 列(有意推迟)
- 仓库内 Java 编译 / 真实 HTTP 联调记录
- `phase-3-test.md` 人工勾选(语音 + GET)
- manager-web 页面(计划本就 Out of Scope)
### 2.4 实施中发现的问题(对 Phase 4 有约束)
1. **`prompt_text` 在 SD 跟读下是文件路径**(如 `/english_learning/shadowing/lessons/unit01/01.mp3`),不是英语原文。Phase 4 **禁止**拿路径做文本比对。必须另有 `expected_text`。
2. **回声检测对路径是关掉的**(`_is_clip_path`)。不要把「评分用 expected_text」和「0.82 回声拒绝」绑成同一条:听窗内用户跟对了也会像范读。评分阈值必须独立,且只在已经过听窗门闩的有效轮上算。
3. **`uk_task_step_instance_index` 唯一。** 同一听窗多次 ASR 必须 **按 index 更新同一 turn**,不能追加。
4. 无 running asyncio loop 时 upsert 静默跳过(例如某些同步测试路径);热路径有 loop,正常。
5. 全量删插同一 id 不要并行 `save`(单连接单 session,现网如此)。
### 2.5 与 Spec v1.0 的对照
| Spec ID | Phase 3 计划 | 代码 | 宣称 |
|---------|--------------|------|------|
| 中层 Task Runtime | Session → 表 | 有 | **可查询副本已落地**;权威仍在 Server 进程 |
| D1 / D6 / D16 | 只入库,不改运输/步进 | 遵守 | 保持 |
| D8 v1 | 尽量挂 audio_id | 路径 A | **关联管道有**;非每条必有 id |
| D13 | 沿用 `/{app}/{task}/lessons` | 未搬目录 | 保持 |
| D11 / D14 | 不做 | 无 | — |
| S4 / S8 | 禁止 | 无评分、无 JUDGE | **正确** |
| §22 | 无新传感器 | 固件零 diff | **满足** |
### 2.6 与原文 V1.1 / 租户讨论的差异
V1.1 把评分和表绑在同一 Phase。Spec 已拆开。本阶段 **没有** 评分,正确。
场景切换词、租户:**本阶段未做、也不该做。** 词表仍在 `runtime.py`;`tenant_id` 未进表、未进固件。见 §5 纪律。
---
## 3. 与 Master Plan 核心抽象对照(更新)
| 抽象 | 当前是否存在 | 实现位置 | 完成程度 | Phase 4 是否动 |
|------|--------------|----------|----------|----------------|
| **App** | 是 | `apps.json` | 连接级;无订阅表 | 可加 `assessment` 块;不升级商店 |
| **Task** | 是(薄) | `apps.json` tasks | 一种动作 | 跟读加评估策略 JSON |
| **Step** | 是(行) | `ai_task_step` / `turns[]` | 投影 | **写入 score / expected_text** |
| **Skill** | 是 | MCP + 固件查找表 | 双表仍在 | 不合并;不新命令 |
| **InteractionMode** | 是 | `SHADOWING` | = Spec FOLLOW_READ | 不重命名 |
| **TaskInstance** | **是** | 三表 + JSON | 可 GET | 不改主键;不把分数改挂 chat_history |
| **Event** | 是 | 表 + JSON | 无第四套总线 | 可加 `assessment.*` 事件名(仍走现有 events[]) |
| **Audio** | 聊天附件 + 可选挂 step | report | 尽力 | 不新上传通道 |
| **Assessment** | 否 | — | 0% | **后评估定量 metrics;不指挥步进** |
| **Schedule / Tenant** | 否 | — | — | **禁止** |
---
## 4. Current Architecture V4(基于代码)
### 4.1 Client V4
与 V3 相同。固件冻结面不因落库而移动。
### 4.2 Server V4
```text
xiaozhi-server
├── App Runtime
│     apps.json
│     SHADOWING:SD 句文件 + 听窗 + D16(无对错)
│     TaskSession → JSON + 异步 PUT
├── detect:[skill_event] → [app_task] → [voice_entry]
└── report:chat-history;可选回写 step.audio_id
manager-api
├── PUT/GET /task/instance  (server secret)
├── ai_task_instance / step / event
└── 不计算分数、不执行 Skill
```
### 4.3 通信 V4
```text
Device  ──WS──►  xiaozhi-server Runtime
                    ├── JSON 文件
                    └── HTTP PUT ──► manager-api Instance
                    └── HTTP report ──► chat_audio ──► step.audio_id
查询:server secret GET /xiaozhi/task/instance/{id}
设备从不调用上述 HTTP。
```
### 4.4 SD 布局
仍为 V3:
```text
/{app_id}/{task_id}/lessons/{lesson_id}/file.mp3
```
---
## 5. 技术债务与架构纪律(Phase 3 之后)
### P0 — 不解决就无法安全做 Phase 4
| ID | 债务 | 放哪 |
|----|------|------|
| P0-1 | 跟读 `prompt_text` = SD 路径,不能当评分原文 | **Phase 4 用同 stem 的 `01.txt` / transcripts 作 `expected_text`;评估不挡播放** |
### P1 — 近期
| ID | 债务 | 放哪 |
|----|------|------|
| P1-1 | GET 仅 server secret | Phase 7 家长 UI 再拆 oauth2 |
| P1-2 | 人工未勾选 `phase-3-test.md` | 开 Phase 4 编码前建议先对一次 GET;不挡写计划 |
| P1-3 | 无 Java 编译记录 | 加列 changeset 时用现网风格,禁止改 `202608311800.sql` |
| P1-4 | 词表硬编码在 `runtime.py` | 保持到 Phase 6 catalog;**禁止**把「开始跟读」写进固件 |
| P1-5 | 双技能表 | 保持 |
### P2 — 以后再说
| ID | 债务 |
|----|------|
| P2-1 | D11 `wait` / D14 PAUSE |
| P2-2 | 租户 / 机构教材隔离(最早缝在 Phase 6,产品化 Phase 7) |
| P2-3 | 音素/发音/情绪分析 |
| P2-4 | 倒背 / 口算 / 接龙(Phase 4b) |
| P2-5 | `type=task`、对象存储、manager-web 跟读页 |
**现在就遵守、不必编码:**
- 固件不再增加业务 App 口令;系统级仅 Chat + 离线复读机 + 「切换模式」总入口
- 设备永不持久化 `tenant_id`
- 打卡/分析只消费 Instance + audio,不加新 Device 能力
---
## 6. 结论:Phase 3 是否 Done
**功能 Done(带条件):**
- `task_session_id` 可在 manager-api 按 id / mac 查询
- JSON 双写保留;PUT 失败不拖死语音(pytest 覆盖调度失败)
- Agent ≠ App:Chat 不进 `ai_task_instance`
- 固件零 diff;无新运输种类;无评分
- audio_id 挂钩(路径 A)已接
**工程未 Done:**
- `phase-3-test.md` 依赖人工 GET / 语音勾选
- Java 侧无仓库内编译证明
- 跟读原文与路径未分离(P0-1,交给 Phase 4)
**因此:** 可以宣称 Task Runtime **可归档且可查询**。
**不可以**宣称已评估、已打卡产品化、已多租户。
**不可以**为分数新增 `JUDGE` 命令或改 C++。
下一阶段只做一件事:把跟读热路径收成 **MP3 序列 + 定时录音**,分析全部后置为定量 metrics。见 `phase-4-plan.md`。
phase-3-test.md
New file
@@ -0,0 +1,216 @@
# Phase 3 测试
> 对应施工单:`phase-3-plan.md`
> 代码事实:`phase-2-review.md`
> 语音回归底表:`phase-2-voice-test.md`(本阶段固件 **不必重烧**,除非你还停留在 Phase 1 bin)
本阶段验收两件事:**库里能查到跟读/复读实例**,以及 **Phase 2 语音行为没有回退**。
不要测数字倒背、口算、成语接龙,不要测分数。
---
## 0. 开始前
1. manager-api 已跑 Liquibase(存在 `ai_task_instance` / `ai_task_step` / `ai_task_event`)。
2. xiaozhi-server 能连上 manage_api(与现网 chat report 同一套密钥)。
3. 设备仍是 Phase 2 固件:复读结束只招呼一次;跟读一句一 MP3。
4. SD 音频仍按:
```text
/english_learning/shadowing/lessons/unit01/01.mp3
/english_learning/shadowing/lessons/unit01/02.mp3
/english_learning/repeater/lessons/unit01/full.mp3
```
5. pytest:
```text
cd xiaozhi-esp32-server/main/xiaozhi-server
python -m pytest tests/test_app_runtime.py -q
```
应通过(含 upsert 失败不阻断 `save` 的新用例)。不过这一关,不要上设备。
准备:
- 控制台登录 token(GET 用)
- server secret(PUT 调试用,与 `/agent/chat-history/report` 相同)
- 设备 `mac` / `device_id`
---
## 1. 语音回归(必须先过,再查库)
只跑 `phase-2-voice-test.md` 的这些节,勾选标准不变:
| 节 | 内容 | 通过? |
|----|------|--------|
| §0 | 开机对话模式;唤醒;「今天天气怎么样」当闲聊 | |
| §2 | 跟读整段:播完再跟;不讲笑话;最后一轮回 Chat;JSON 含全部有效轮 | |
| §5-A | 复读自然结束 **只招呼一次** | |
| §5-C | 播放中说「你好吗」继续播,不回答 | |
本阶段 **新增** 的语音失败标准:
- 一跟读就卡死 / 一直不播下一句 → 多半是 upsert 同步堵住了 WS 线程(见计划 §10)。
- 跟读正常但 GET 404 → 只是上报失败,先记 §3,不要改口令逻辑。
翻译 / 中英 / 离线:若时间紧可抽测「打开翻译 → 退出翻译 → 讲个笑话」。离线复读仍不得连云。
---
## 2. 跟读 Instance(核心)
从对话模式说 **开始跟读**,按音频跟完(或至少两句后说 **退出跟读**)。
### 2.1 文件仍在
打开 `xiaozhi-esp32-server/main/xiaozhi-server/data/task_sessions/` 最新 JSON:
| 检查 | 通过? |
|------|--------|
| `task_session_id` 为 32 位 hex | |
| `app_id=english_learning`,`task_id=shadowing` | |
| `turns` 条数 = 你跟读成功的句数(退出则少于音频总数) | |
| 最后一轮 `asr_text` 不是空(若你说了最后一句) | |
| `status` / `end_reason` 为 `completed`+`finished` 或 `cancelled`+`user_exit` | |
| `events` 里有 `audio.completed`(每句播完) | |
记下 `task_session_id` = `{id}`。
### 2.2 GET 与文件一致
```text
        GET /task/instance/{id}
        Authorization: Bearer {oauth token 或与 chat report 相同的 server secret}
        (本阶段 Shiro 将 /task/instance/** 配成 server 过滤器,用 server secret 即可验通)
```
| 检查 | 通过? |
|------|--------|
| HTTP 200 | |
| `status` / `end_reason` / `app_id` / `task_id` 与 JSON 相同 | |
| `turns`(或响应里的 `steps`)条数与 JSON `turns` 相同 | |
| 第 0 句 `prompt_text` 仍是 SD 路径,不是 TTS 英文范读 | |
| 对应 `asr_text` 对得上你说的话(允许 ASR 误差) | |
| `events` 含 `audio.completed` | |
若实现选了计划 §4.3-A:有效轮的 `audio_id` 非空,且能在 `ai_agent_chat_audio` 查到。选 B 则允许全空。
### 2.3 列表
```text
GET /task/instance?macAddress={mac}&limit=20
```
| 检查 | 通过? |
|------|--------|
| 刚那条 `{id}` 出现在 list | |
| list 项 **没有** 整份 turns 正文(避免把跟读当聊天记录 dump) | |
---
## 3. 复读 Instance
说 **复读机**,等自然结束(或音频很短)。
| 检查 | 通过? |
|------|--------|
| 新 JSON:`task_id=repeater`,`end_reason=play_completed` | |
| GET:`turns` 可以为空 | |
| GET:`events` 含 `audio.completed` | |
| 设备只招呼回 Chat **一次**(回归) | |
中途 **退出复读**:`end_reason` 为 `user_exit` 或 `play_stopped`(与 Phase 2 谁先到有关),GET 能看到,不丢。
---
## 4. 失败隔离(必须测)
**A. manage_api 不可用**
停掉 manager-api(或改错密钥),再说 **开始跟读** 跟一句再 **退出跟读**。
| 检查 | 通过? |
|------|--------|
| 设备仍播音频、仍能退出回 Chat | |
| 本地 JSON **仍然**写出 | |
| xiaozhi-server 日志有 upsert 失败,**没有**把 WS 打挂 | |
| GET 可以 404 / 旧数据;这不算语音失败 | |
测完恢复 manager-api。
**B. 非法 PUT(可选)**
用 server secret PUT 一个 `app_id` 含 `../` 或超长 id → 400,不写库。
---
## 5. Agent ≠ App
在 **对话模式** 说「今天天气怎么样」或「讲个笑话」。
| 检查 | 通过? |
|------|--------|
| `ai_agent_chat_history` 仍增加聊天行(现网 report) | |
| **没有**因此新增一条 `app_id=chat` 的 task_instance(chat 不 begin_session;若实现误报了 chat instance 则失败) | |
| 跟读 instance 仍然只在 `/task/instance` 查,不出现在「聊天记录就是音频」的错觉里 | |
---
## 6. 断线
跟读说到第二句过程中,拔掉设备 Wi‑Fi 或杀 xiaozhi-server 连接。
| 检查 | 通过? |
|------|--------|
| 本地 JSON `end_reason=disconnected` 或等价 cancelled | |
| 若断线前至少成功 save/PUT 过一次:GET 能看到已有 turns,不是空实例把成功轮抹掉 | |
(若断线发生在第一次 PUT 之前,库里可能没有行:以 JSON 为准,并在 PR 说明。)
---
## 7. 固件冻结抽检
| 检查 | 通过? |
|------|--------|
| `xiaozhi-esp32-2.2.6` 相对 Phase 2 **无业务 diff**(本阶段 PR 不应含该目录) | |
| 未要求重烧 bin 即可完成 §1–§3 | |
| 未出现新的 WS `type`、新 MQTT action、新 MCP 名 | |
新订阅 App 建目录属于 Phase 2 已交付能力,本阶段 **不要**当新功能再测一遍;若回归时可看连上后 SD 是否仍有 `/english_learning/.../lessons`。
---
## 8. 不要测什么
- 分数、对错、再读一遍的评估策略
- 倒背 / 口算 / 接龙
- 家长小程序 UI、manager-web 新页面
- `TASK_PAUSE`、`wait` Skill、本地录音上传
- 把 SD 从 `/{app}/{task}/lessons` 搬到 `/resources/`
---
## 9. 失败时先看哪
| 现象 | 多半是 |
|------|--------|
| 跟读卡死 | upsert 同步阻塞;改为 end 时再 PUT 或后台任务 |
| JSON 有、GET 404 | Python 未挂钩 / Shiro 把 PUT 拦了 / 密钥不一致 |
| GET 200 但 turns 少最后一轮 | `end_session` 没在最后一轮 `add_turn` 之后 save(Phase 2 回归) |
| 复读结束招呼两次 | 不是 Phase 3 问题;固件又发了 `[app_task]chat` |
| chat 笑话进了 task_instance | begin_session 误绑 chat |
| Liquibase checksum 失败 | 改了旧 SQL;应只加新 changeset |
---
## 口令速查(与 Phase 2 相同)
| 意图 | 说其中一句即可 |
|------|----------------|
| 唤醒 | 你好小智 |
| 跟读 | 开始跟读、我要跟读 |
| 复读 | 复读机、开始复读 |
| 回聊 | 退出跟读、退出复读、我们来聊天吧 |
phase-4-plan.md
New file
@@ -0,0 +1,349 @@
# Phase 4 Coding Plan — 时钟步进 + 后评估(定量)
> File: `phase-4-plan.md`
> Date: 2026-08-31
> 约束文档:`Xiaozhi Device Capability Specification v1.0.md`
> 代码事实:`phase-3-review.md`(Current Architecture V4)
> 路线图:`roadmap.md`
> 验收:`phase-4-test.md`
> **覆盖**此前「S8 当场判对错再播同一句」的草案。热路径是 **MP3 序列 + 定时录音**;分析全部后置、定量、允许失败。
本阶段 **不是** 再做一个 App,也 **不是** 做倒背/口算/接龙(那是 Phase 4b,且那些是 **流内及时评估**,不能套本阶段后评估),也 **不是** 改固件。
给 Coding Agent:先读 Spec §1.3 S4、§15、§17、§22;再读 `phase-3-review.md` §2.4(`prompt_text` 是路径);再读 `runtime.py` 听窗 / `_schedule_shadowing_listen_timeout` / `handle_shadowing_turn`、`session.py`、`TaskTurnDTO.java`、`202608311800.sql`(**禁止改已执行 changeset**)。
---
## 0. 产品模型(本阶段钉死,后面很多 App 共用)
```text
已知:每个范读 01.mp3 有对应 01.txt(同 stem,资源包契约)
      播放时长 duration_ms 来自已有 D1 完成事件(设备已报)
热路径(必须流畅,任何分析失败都不得打断):
  play 01.mp3
  audio.completed
  立即开录音/听窗 = listen_ratio × duration_ms   (v0:1.2~1.5,默 1.2)
  定时结束 → 上传尽力而为 → play 02.mp3
  … 直到序列结束 → 回 Chat
禁止:
  等 ASR 成功才播下一句
  等上传成功才播下一句
  用文本比对决定再播同一句或下一句
  在设备上读出「正确/错误」「超过 82% 算好」
```
跟读、故事复述、打卡录音:**后评估**——给定听窗,时间到就下一步;家长稍后看 metrics。
倒背数字、成语接龙、默认口算:**不是本阶段模型**。听窗结束后必须马上评估并 `speak` 反馈,属于 Phase 4b 的 `assessment.timing=inline`(见 `roadmap.md`「评估时机」)。不要在本 PR 里把它们做成后评估。
Spec **S8 `branch(if correct)` 不是本产品默认热路径。** 留给以后可选的「精练模式」,本阶段不做。S4 评估仍在 Server,但是 **异步/可延迟**,写在 Instance 上,不指挥喇叭。
---
## 1. 与 Spec 的对照
| Spec ID | 能力 | Phase 4 做什么 | 不做什么 |
|---------|------|----------------|----------|
| S4 | Assessment | 后评估:对已归档 ASR/音频相对 `expected_text` 算 **定量指标** | 及格线、好坏结论、临床诊断 |
| S8 | branch if correct | **不做** | 再播同一句、JUDGE、retry |
| D1 | duration_ms | 用完成事件时长算听窗 | 不改固件报时 |
| D16 | 步骤级 loop | Server 仍按 clip 序列下发下一句 play;**触发器只有时钟** | 设备自己播整课;评估驱动步进 |
| D6/D7/D8 | 听/归档 | 听窗内尽力 ASR + report;失败忽略 | 不改 Opus |
| D11 wait | | 仍用 Server 定时,不等待 Skill | |
| D13 | 资源 | `01.mp3` ↔ `01.txt` 为已知契约;热路径不 MCP 读卡上 txt | 不搬目录 |
| 表 | | 可空 `expected_text` + `metrics` JSON | 不建 `passed` 当产品结论 |
---
## 2. 目标与非目标
### 2.1 Objective
1. 跟读 = **预定 MP3 序列**。句间只等 `listen_ratio × duration_ms`(下限保持现网最少 800ms,除非配置显式改)。
2. **有效 ASR 不再提前切下一句**(相对 Phase 2 的行为变更:说完也要等到时钟;时钟到了没说完也切)。
3. 录音/ASR/PUT/比对 **全部旁路**:失败只 log,序列照走。
4. `expected_text` 来自与 basename 对应的 txt 契约(见 §4),供后评估使用;**不**拿 SD 路径当原文。
5. 后评估写入 **定量** `metrics[]`(方法名 + 数值),不写「好/坏/及格」。评估可在本句时钟已经进入下一句之后才完成。
6. `listen_ratio` 可配置;预留按用户水平取值的函数口(v0 用 task/JSON 常数即可)。
7. 复读 / Chat / 翻译不评估、不改时钟语义。
### 2.2 Why Now
Phase 3 已有 Instance 可挂结果。若不把「分析」从热路径拆开,后面每个 App 都会把喇叭卡在云端推理上。跟读是最便宜的钉钉。
### 2.3 Acceptance Criteria
**热路径:**
1. 说错、不说、ASR 失败、关掉 manage_api:听窗结束后 **仍播下一句**,不重播同一句、不卡死。
2. 跟对也不提前切句(等到时钟)。允许 ±200ms 量级定时误差。
3. 全程不讲笑话;退出跟读仍立刻回 Chat。
4. 复读结束仍只招呼一次。
**后评估:**
5. 有 txt 原文且本句有 ASR 时:在「一定时间内」(本阶段允许会话结束后数秒内后台完成即可)GET 到该 step 的 `expected_text` + `metrics`(至少一项 `text_similarity_v0` 的 0–1 数值)。
6. 无 ASR / 无 txt / 评估抛错:`metrics` 空,**不影响**已发生的播放。
7. 任何 API/页面 **不得**把单一阈值渲染成必须的对错结论(本阶段无 UI;字段里不要 `passed`/`grade`)。
**纪律:**
8. 固件零提交。无新 WS/MQTT/MCP。无 JUDGE。不改 Opus。不加租户。业务口令不进 C++。
### 2.4 Out of Scope
- 当场纠错再播、max_retry、及格线产品化
- 音素/流利度/情绪/智商结论/心理诊断(以后也是新 method 的定量值,不是本阶段)
- 4b 三个 Task 的题库
- 完整用户等级引擎(只留 `resolve_listen_ratio` 口)
- 热路径 MCP 读设备上的 `01.txt`
- manager-web 打卡页、租户
- 设备默认语言 / 翻译语言对(Roadmap Phase 6 / 6b / 7)
---
## 3. 架构
```text
Device(Phase 2 bin)
  play clip i → [skill_event]completed|duration_ms → 听流
        ↑                                      ↓
xiaozhi-server 时钟                              旁路(失败忽略)
  sleep(listen_ratio * duration_ms)              ASR 尽力
  play clip i+1  (不等旁路)                    report 音频尽力
                                                 后台 assess → metrics
                                                 PUT Instance 尽力
```
**步进权威是时钟,不是评估器。**
评估可以在 xiaozhi-server 用 `create_task` 做(本阶段允许),也可以以后改成 manager-api Worker;**不得** `await assess()` 再 play。
---
## 4. expected_text = 与 MP3 同名的 txt(已知条件)
资源包约定(SD 与云端清单同一套 stem,Phase 6 下载两者;本阶段不新做 DOWNLOAD):
```text
.../lessons/unit01/01.mp3
.../lessons/unit01/01.txt    # UTF-8,范读原文
```
Server **解析顺序**(热路径不读设备 SD):
1. `task.transcripts["01.mp3"]` 或 `["01"]`(manifest 已带正文,等于 txt 已同步到云)。
2. 进程内音频缓存目录若存在同 stem 的 `.txt` 则读文件(仅 Server 本地/已下载包,**禁止**为此加 MCP)。
3. 兼容现网:`sentences[i]` 与排序后 clips 下标对齐。
4. 都没有 → `expected_text=null`,本句不评估,播放不变。
禁止对 `prompt_text`(路径)做相似度。
`apps.json` 跟读 task 示例:
```json
"listen_ratio": 1.2,
"assessment": {
  "enabled": true,
  "methods": ["text_similarity_v0"],
  "defer": true
}
```
不要 `threshold`、`max_retry`、`passed`。
`listen_ratio` 允许 1.2–1.5;超出则夹紧。以后 `resolve_listen_ratio(conn, task)` 可按用户水平返回不同值;v0 读 JSON。
---
## 5. 定量指标(禁止做成结论)
`core/app_runtime/assessment.py` 纯函数:
```text
normalize(s): 小写、去标点空白;ASR 先 _plain_user_text
metrics = [
  { "method": "text_similarity_v0", "value": <0-1 SequenceMatcher ratio> }
]
```
- **只产出 method + value**(可选 `unit` 省略为 ratio)。
- **禁止**函数返回 `passed` / `grade` / `level=good`。
- 家长端以后若要展示「建议再练」,那是 **另一个产品层** 用同一数字做解释,不是本阶段字段。
- 益智/心理:以后加 `method=...` 新行,仍然只定量。
评估抛错或缺少 asr/expected:返回 `[]`,不抛到热路径。
与 `is_echo_of_prompt`(0.82)完全分开。听窗门闩保持。不要用 echo 阈值当任何产品含义。
---
## 6. 热路径相对 Phase 2/3 的行为变更
| 现网 Phase 2/3 | Phase 4 |
|----------------|---------|
| 听窗内有效 ASR → **立刻**下一句 | ASR 只归档;**时钟到了**才下一句 |
| 超时才下一句 | 超时 = 时钟正常开火(主路径) |
| 无后评估 | 时钟后/会话后写 metrics |
实施:`handle_shadowing_turn` **不要**再调用 `_play_shadowing_clip` / `index++`。只 `upsert_turn` + `enqueue_asr_report`。
下一句只放在听窗 timeout 回调里(已有 `_schedule_shadowing_listen_timeout`)。退出口令仍立即回 Chat。
播放中 ignore、未到听窗 ignore:保持。
---
## 7. Turn / 表
同一 `step_index` 一行。窗口内多次 ASR:v0 **保留最后一次** 文本与音频意图(更新同一 turn)。
### 7.1 JSON / PUT(snake_case)
| 字段 | 说明 |
|------|------|
| `prompt_text` | 仍为 SD 路径 |
| `expected_text` | txt 正文,可空 |
| `asr_text` | 可空(没说/没认出) |
| `metrics` | 数组,可 `[]`;元素 `{method, value}` |
| `assessed_at` | epoch ms,可空表示尚未评估 |
**不要** `passed` / `retry_count` / `grade`。
可选 event:`listen_window_closed`(时钟开火)。不是新 WS。
### 7.2 Liquibase
新文件,例如 `db/changelog/202609011100.sql`,登记 master。**禁止改** `202608311800.sql`。
```text
ai_task_step
  expected_text TEXT NULL
  metrics JSON NULL
  assessed_at BIGINT NULL
```
MySQL JSON 类型与现网其它表一致即可;读回为 list。
缺 metrics 的旧 PUT 必须 200。
覆盖插入:若新 payload `metrics` 为空且旧行已有,**保留旧 metrics**(后评估晚于一次空 PUT 时不被冲掉)。显式非空数组以新为准。
Java DTO `@JsonIgnoreProperties(ignoreUnknown=true)` 继续。
---
## 8. HTTP
不新增路径。PUT/GET 仍 `/task/instance/{id}`,server secret。
后评估完成后再 PUT 一次全文(或只靠那次带 metrics 的 save)即可。不要为 metrics 单独做第二套鉴权。
---
## 9. 改动清单
### 9.1 manager-api
| ID | 任务 |
|----|------|
| J1 | 新列 + DTO |
| J2 | upsert 合并空 metrics(见 §7.2) |
| J3 | 不要校验「必须有 score」;不要出现 passed 列 |
manager-web 零提交。
### 9.2 xiaozhi-server
| ID | 任务 |
|----|------|
| S1 | `listen_ratio` 夹紧 1.2–1.5;timeout = max(800, ratio * duration_ms) |
| S2 | ASR 有效 → 只 upsert_turn + report,**不** play next |
| S3 | 仅 timeout 回调:index++ 并 play next / 结束 |
| S4 | `resolve_expected_text`(§4) |
| S5 | `assessment.py` 只返回 metrics 列表;`create_task` 里跑,**await 不挡 play** |
| S6 | pytest |
| S7 | 复读/Chat/翻译不碰评估 |
---
## 10. 测试
见 `phase-4-test.md`。最低限度:
1. pytest:时钟推进与 ASR 解耦;metrics 无 passed;路径不能当 expected。
2. 语音:说错仍进下一句;说对也不秒切(等到窗)。
3. manage_api 停掉:序列仍走完。
4. Phase 3 GET 字段仍在;多了 expected_text/metrics。
---
## 11. 实施顺序
```text
P4-A  Liquibase + Java 新列(无 passed)
P4-B  时钟与 ASR 解耦 + listen_ratio + pytest
P4-C  后台 metrics(txt 契约)+ 空 metrics 不冲旧值
P4-D  phase-4-test.md
```
宣称 Done = B+D(解耦是产品核心)。C 做不完则 metrics 全空,但 **不得**回退到「比对失败就重播」。未完成 D 不得开 4b。
---
## 12. 编码约束
1. 固件零 diff。
2. 热路径不得 `await` 评估/比对/HTTP。
3. 不得把对错、及格、retry 写入设备或 `speak`。
4. 不得新增 WS/MQTT/MCP。
5. 不得改已执行 Liquibase。
6. 不得对 SD 路径做 SequenceMatcher。
7. 指标字段禁止 `passed`/`good`/`bad`/`grade`。
8. 先回归复读/Chat,再测「闭嘴等到下一句」。
---
## 13. Data Flow
```text
completed(duration_ms)
  开窗 T = clamp(ratio)*duration_ms
  窗内 ASR? → upsert asr + 异步 report + 异步 assess
  T 到 → 下一 clip(无论上面成功与否)
assess(可晚于下一 clip)
  expected_text from 01.txt 契约
  metrics=[{method, value}] → save/PUT
```
---
## 14. State Machine
```text
跟读句(Server):
  play → (completed) → recording_window --timer--> next play | finish
                          └── ASR/report/assess 旁路
Instance:running / completed / cancelled(不加 graded)
```
---
## 15. 风险
| 风险 | 缓解 |
|------|------|
| 把草稿「说错重播」做回去 | 验收明确禁止;pytest 说错也 index++ |
| 空 PUT 冲掉晚到的 metrics | §7.2 合并 |
| 听窗过长体验差 | ratio 1.2–1.5;以后按水平调,不靠判题缩短 |
| 用户以为设备在判对错 | 不要 TTS 分数 |
---
## 16. 完成后
```text
Review → Architecture V5(跟读:时钟 + 后评估)
      → 4b:倒背/接龙/口算 = inline 评估 + 及时 speak;施工单 phase-4b-plan.md
```
禁止本阶段「顺便」口算出题、倒背及时反馈、Scheduler、租户、SD 搬家、`type=task`。
phase-4-test.md
New file
@@ -0,0 +1,149 @@
# Phase 4 测试
> 对应施工单:`phase-4-plan.md`
> 代码事实:`phase-3-review.md`
> 语音回归:`phase-3-test.md` §1 + `phase-2-voice-test.md` §0 / §5
> 固件 **不必重烧**
本阶段验收:**跟读是 MP3 序列 + 定时录音**;分析失败不影响下一句;后评估只有定量 metrics,没有及格结论。
不要测当场「说错重播」、倒背/口算产品、音素、租户。
相对 Phase 2 口令表的刻意变化:听窗内说完 **不会马上** 下一句,要等到 `listen_ratio × 该句 duration_ms`。
---
## 0. 开始前
1. Liquibase 新 changeset:`expected_text` / `metrics` / `assessed_at`。**没有** `passed` 列。未改 `202608311800.sql`。
2. 音频契约(至少 unit01 两句):
```text
/english_learning/shadowing/lessons/unit01/01.mp3
/english_learning/shadowing/lessons/unit01/01.txt
/english_learning/shadowing/lessons/unit01/02.mp3
/english_learning/shadowing/lessons/unit01/02.txt
```
云端 `transcripts` 或 `sentences[]` 与上述 txt 正文一致(热路径不读卡上 txt 也可以测评估,但 **stem 必须能对上**)。
3. `listen_ratio` 在 1.2–1.5。
4. pytest:
```text
cd xiaozhi-esp32-server/main/xiaozhi-server
python -m pytest tests/test_app_runtime.py tests/test_assessment.py -q
```
准备 server secret、mac。GET:`/xiaozhi/task/instance/{id}`。
---
## 1. pytest(必须先过)
| 用例 | 通过? |
|------|--------|
| `resolve_expected_text`:basename `01.mp3` → txt 正文;**路径字符串不得当原文** | |
| `text_similarity_v0` 返回 `{method, value}`,**对象不含** `passed`/`grade` | |
| 评估缺 asr 或缺 expected → `metrics=[]`,不抛 | |
| 模拟听窗:注入 ASR **不**触发 play next;只有 timer 回调才 index+1 | |
| timer 回调在 ASR 失败、assess 抛错、upsert 抛错后仍 index+1 | |
| `listen_ratio` 0.5 → 夹紧到 1.2;3.0 → 夹紧到 1.5 | |
| Phase 3:upsert 调度失败仍能 save | |
---
## 2. 语音:序列时钟(核心)
从 Chat 说 **开始跟读**。记下 01 播完到 02 响起的间隔,应大约是 `ratio × 01 的时长`(不要短到「一说完就切」)。
| 检查 | 通过? |
|------|--------|
| 01 播完后立刻进入可跟读(不要先讲笑话) | |
| 听窗内认真跟读:02 **不会立刻**响,要等到时钟 | |
| 听窗内说「今天天气怎么样」或不说话:时钟到了仍播 **02**,**不**再播 01 | |
| 说 **退出跟读** 可立刻回 Chat | |
| 整段结束后招呼回 Chat;**讲个笑话**可用 | |
---
## 3. 失败不得挡播放(必须)
| 场景 | 通过? |
|------|--------|
| 停 manage_api,跟读至少两句:02 仍会在时钟后出现 | |
| 无 01.txt / 无 transcripts:播放两句仍完成;metrics 可空 | |
| 全程闭嘴:两句仍按时钟播完(turns 的 asr 可空) | |
恢复 manage_api。
---
## 4. 后评估对账(允许晚几秒)
跟读至少一句(有说话),等会话结束或再等几秒,GET `{id}`。
| 检查 | 通过? |
|------|--------|
| `prompt_text` 仍以 `.mp3` 结尾 | |
| `expected_text` 等于对应 txt 正文(不是路径) | |
| 若有 asr:`metrics` 含 `method=text_similarity_v0` 且 `value` 在 0–1 | |
| JSON 与 GET **没有** `passed` / `grade` / 「正确」字段 | |
| 列表接口仍不返回整份 turns | |
说错那句:`value` 可以很低——这是定量结果,**不要**因此期望设备重播。
---
## 5. Agent ≠ App;复读
| 检查 | 通过? |
|------|--------|
| Chat 笑话只进 chat_history | |
| 复读:无 metrics 要求;结束仍只招呼一次 | |
| 复读默认会带 `speed`(省略则为按天数算出的值,第一天约 0.8) | |
| `repeat=5` 时每一遍倍速约 0.8→0.9→1.0→1.1→1.2,**不会**卡在 0.75/1.0 档 | |
| 显式指定 `speed=1.05` 时五遍都用 1.05 | |
---
## 6. 固件
本阶段跟读评估仍不改固件协议。**允许的补充:** `play_audio.speed` 从离散档改为 `[0.5, 2.0]` 夹紧(重采样本来就支持任意倍率)。
| 检查 | 通过? |
|------|--------|
| 无 JUDGE / 新 MQTT action / 新 MCP 工具名 | |
| `self.mp3.play` 仍是 `name/speed/repeat/power_off_after`;speed 可传 0.8 / 1.05 | |
| 说错后日志里 **没有** 对同一路径的立即第二次 play(只有时钟后的 02) | |
---
## 7. 不要测什么
- 说错重播同一句(那是已废弃草案)
- 「>82% 算好」的产品结论
- 倒背 / 口算 / 接龙 / 心理诊断
- 家长 UI、租户
- 把切换词写入固件
---
## 8. 失败时先看哪
| 现象 | 多半是 |
|------|--------|
| 一说完就切下一句 | ASR 仍在 `handle_shadowing_turn` 里 play next(未解耦) |
| 说错重播 01 | 把 S8 retry 做回去了 |
| 停 API 后卡在第一句 | upsert/assess 又变成同步 await |
| metrics 有 passed | DTO 没按计划 |
| expected 是路径 | 用 prompt_text 当了原文 |
| 时钟明显短于 1.2× | 仍用固定 800ms 而忽略 duration_ms |
---
## 口令(与 Phase 2 相同)
| 意图 | 说 |
|------|----|
| 跟读 | 开始跟读 |
| 复读 | 复读机 |
| 回聊 | 退出跟读 / 我们来聊天吧 |
roadmap.md
New file
@@ -0,0 +1,603 @@
# Roadmap(Current Architecture V4 + Capability Spec v1.0)
> Date: 2026-09-02
> 输入:`phase-1-review.md` · `phase-2-review.md` · `phase-3-review.md` · `Xiaozhi Device Capability Specification v1.0.md` · 设备默认语言 / 语言对评估 · **产品代际 / 端云版本匹配**
> 粒度:架构与项目管理。不是 Coding Plan。
> Phase 3 已实施:`phase-3-review.md`。下一编码阶段:`phase-4-plan.md`(验收 `phase-4-test.md`)。
> 语言:见本文 **Phase 6 / 6b / 7**;**不要**塞进 Phase 4。
> 版本与兼容:见本文 **Phase V**;摄像头 SKU(小智 AI 2)之前必须落地握手字段与兼容矩阵,**不要**等 Phase 8。
原则:每完成一个 Phase → Completion Review → Architecture 更新 → Roadmap Review → 下一份 Phase Plan。
**禁止**把本文当成锁死到 Phase 9 的施工图。后面 Phase 只保证目标、边界与 **对应的 Spec 能力 ID**。
---
## 0. 冻结之后怎么切 Phase
Capability Spec 把系统分成三层:
```text
Application        以后才无限扩展(教育 / 益智 / 陪伴 / 助手)
Task Runtime       尽快稳定,权威在 Server
Device Capability  语义已冻结:播 / 听 / VAD / speak 出声 / wait / loop / 指示 / 抢占
```
因此路线图不再按「再做 10 个 App」排列,而按:
```text
1. 把已声明、固件尚未具备的原子能力补齐(只允许因此 OTA)
2. 把 Task Runtime 做成可归档、可查询
3. 把评估 / 调度 / 资源放到 Server
4. 用倒背 / 口算 / 接龙反证:新 App 零固件增量
```
原 V1.1「Phase 2 = type=task + Java 表 + 跟读 + 评分」仍然拆开。Spec 额外要求:
- 运输可变(MCP / MQTT / detect 前缀),**语义集合不再为新 App 加命令种类**
- `loop`(同一音频 N 次)在设备;`branch(答对了吗)` 只在 Server,且 **仅 Task 声明为流内评估时**才走进业务(倒背/接龙);跟读默认不走
- 离线只保证可离线 Skill(播放);翻译/评估/接龙必须在线
- 评估时机是 Task 属性(后评估 vs 流内),不是新传感器
- 离线只保证可离线 Skill(播放);翻译/评估/接龙必须在线
- SD 放资源;`apps.json` 不是离线业务引擎
- `record_clip` v1 = 听流 + Server 归档;本地 WAV 上传是 v1.1
| 编号 | 一句话 | Spec 主要验证 / 交付 |
|------|--------|----------------------|
| Phase 1 | 薄 App Runtime | 已完成:D15 设备 loop、D4 Mode 策略雏形、翻译走 Server |
| Freeze | Capability Spec v1.0 | 文档冻结;不编码 |
| Phase 2 | 用复读+跟读钉死事件与采集 | **已完成(见 phase-2-review.md)**:D1、D6、D7 v1、D8 v1 文本;另提前 D13 目录 `/{app}/{task}/lessons` |
| Phase 3 | TaskInstance 持久化 | **已完成(见 phase-3-review.md)**:三表 + PUT/GET;固件零 diff |
| Phase 4 | 跟读:时钟步进 + **后评估** | 听窗结束即下一句;metrics 不指挥喇叭 |
| Phase V | 产品代际 + 端云兼容 | 小智 AI 1 / 1-1 / 2;hello 报代际与 cap_set;Server 按矩阵降级或 OTA |
| Phase 4b | 倒背 / 口算 / 接龙 | **流内评估 + 及时 speak 反馈**;反证固件冻结 |
| Phase 5 | Server Scheduler | D14 TASK_START 的云端权威 |
| Phase 6 | App / 资源 + **在线 Locale** | D13 fetch;phrase pack;翻译/双语的 language_pair |
| Phase 6b | 离线 Locale(可选 OTA) | MultiNet/唤醒词表按 locale;**仅 ESP-SR 已有模型的语种(先 zh/en)** |
| Phase 7 | 家长任务管理 | 含:设备默认语言、语言对选择;只消费已有 Command |
| Phase 8 | 开发者 App 平台 | Manifest 只能调已冻结 Skill |
| Phase 9 | Subscription / Marketplace | 账号,不改音频 |
---
## 1. Phase 依赖图
```text
Phase 1 薄 App Runtime                    ✅
    ↓
Capability Spec v1.0                      ✅ 文档
    ↓
Phase 2 钉死 D1 事件 + D6/D7/D8v1 采集    ✅ 见 phase-2-review.md
    ↓
Phase 3 TaskInstance 持久化               ✅ 见 phase-3-review.md
    ├────────────────┬────────────────┐
    ↓                ↓                ↓
Phase 4 跟读后评估    Phase 5 调度     Phase V 代际/兼容
下一编码             可后做            可与 4 并行;摄像头 SKU 前必须完成
    │
    ↓
Phase 4b 倒背/口算/接龙(流内及时反馈;可与 5 后期重叠)
    └────────┬───────┘
             ↓
        Phase 6 资源 + 在线 Locale(可与 4b/5 后期重叠)
             │
             ├─ Phase 6b 离线 Locale(zh/en 词表 OTA,不挡 7)
             ↓
        Phase 7 家长 UI(设 device.locale / 语言对)
             ↓
        Phase 8 开发者平台
             ↓
        Phase 9 订阅
```
**必须串行:** 1 → Freeze → 2 → 3 → 4。跟读后评估挂 Instance。Phase 4b **另外**要流内评估:听窗结束后先 Server 算完再 speak,不能套跟读的「分析永不挡喇叭」。
**Phase V 必须在摄像头 SKU / 破坏性 API 之前完成**;不挡 Phase 4 跟读施工。
**可并行:** Phase 5、Phase V 与 Phase 4 无数据依赖。产品顺序仍先做 4(跟读时钟)。
**Phase 4b** 依赖 Phase 4 的听窗/Instance 原语,不依赖 Phase 6。禁止为此改 C++。评估时机见下文「评估时机」表。
**Phase 6:** 目录树与 `ensure_app` 已在 Phase 2 提前落地。本阶段做清单 / DOWNLOAD / `resource_id`,并做 **在线 Locale**(phrase pack + 语言对)。不要重做 mkdir,也不要把路径再搬一次。
**Phase 6b:** 仅当需要「没网也用德语/英语喊切换」时才 OTA 离线词表。德语等 **没有** 官方 MultiNet 的语种,离线入口保持 zh 或 en,在线菜单仍可用该 locale。
**明确后置、不挡冻结宣称:**
| Spec 能力 | 最早 Phase |
|-----------|------------|
| D11 `wait` 一等 Skill | 4b 前可仍用 Server 延时;需要「播完停 800ms 再听」且不想云端睡时再下固件 |
| D14 TASK_PAUSE/RESUME | 5 或 7 家长控制需要时 |
| D8 v1.1 本地文件上传 | 会议纪要 / 律师询问产品化时 |
| D9 长按退出 Task | 可与 2 的按钮现状并存;正式绑定放 7 前 |
| D10 `indicate` 逻辑态 | 有空再做;禁止塞进 DeviceState 迁移表 |
| 新 WS `type=command` | 仅当 detect 前缀不够表达步进;**不加新语义** |
| 在线 Locale / 语言对 | Phase 6(phrase pack + pair);控制台 Phase 7 |
| 摄像头 / 新传感器 Skill | **Phase V 之后**;新 SKU = 小智 AI 2,禁止混进 1 的固件包 |
---
## 2. 各 Phase(Roadmap 级)
### Phase 2 — 冻结面验证:采集 + 完成事件  ✅
1. **Objective**
   不是做一个新 App。用 **已有** 复读机 + 跟读 Demo,把 Spec 里尚未落地的三块钉进 Runtime:
   `audio.completed/stopped`(D1)、非 Chat 的 listen/句边界(D6/D7)、跟读归档 v1(D8)。
   **完成事实:** 见 `phase-2-review.md`。跟读改为 SD 一句一 MP3;订阅 App 可 mkdir 不烧录。
2. **Why Now**
   Spec §20:这三项不补齐就不能宣称 Device 已冻结。跟读 Mode 已在线,继续堆倒背/口算只会在没有事件的底座上复制 Demo。
3. **Dependencies**
   Phase 1 执行器、`SHADOWING`/`PLAY_AUDIO` 分流、Opus 听流、`report`、设备 `repeat` loop(D15,本阶段 **不改**)。
4. **Scope**
   逻辑事件(运输:`[skill_event]`,对应 Spec `AUDIO_COMPLETED/STOPPED`);TaskSession 文件;跟读 turn = prompt + asr + 可选 WAV;pytest。Mode 名代码里可仍叫 `SHADOWING`,文档对齐 Spec `FOLLOW_READ`。
5. **Out of Scope**
   评分(S4)、Java 表、`type=task`、倒背/口算/接龙产品、SD 范读必修、`wait` Skill、`TASK_PAUSE`、`record_clip` 本地文件、apps.json 下发、新 DeviceState、MultiNet 加业务口令。
6. **Client**
   复读结束上报完成/停止事件;不改 Opus;不把跟读句子表写进固件。
7. **Server**
   Session + 跟读记轮次 + 吃事件后回 chat;ASR/LLM/翻译仍在 Server。
8. **Protocol**
   **不增加命令种类。** 只把已声明的 Event 用现有 `listen/detect` 文本运过去。语义见 Spec §4–5。
9. **Database**
   无新表。归档走现有 `ai_agent_chat_audio`(D8 v1)。
10. **API**
   无新 REST。
11. **Data Flow**
   见 `phase-2-plan.md`。跟读下一步由 **Server 步进**(D16),不是设备 loop。
12. **State Machine**
   DeviceState 不动。InteractionMode:`PLAY_AUDIO` / `SHADOWING(FOLLOW_READ)` / 回 `CHAT`。
13. **Acceptance Criteria**
   Spec §22 四条纪律 + 本文 Phase 2 验收:完成事件单次回聊、跟读 JSON 轮次完整、Chat/离线复读/翻译不回归。
14. **Risks**
   回声当跟读;事件与 `[app_task]chat` 双招呼。
15. **Regression Risks**
   复读双播、离线「切换模式」连云、Chat barge_in。
详细施工:`phase-2-plan.md`。
---
### Phase 3 — TaskInstance Persistence  ✅
1. **Objective**
   中层 Task Runtime 可查询:Session JSON → `ai_task_instance` / `ai_task_step` / `ai_task_event`。不新增 Device Capability。
   **完成事实:** 见 `phase-3-review.md`。GET 走 server secret(非计划中的 oauth2)。未建空 score 列。跟读 `prompt_text` 仍是 SD 路径。
2. **Why Now**
   Phase 2 字段已稳定,建表不会空。评估和调度需要主键。
3. **Dependencies**
   Phase 2 `TaskSession` schema;D8 v1 report 管道。固件保持 Phase 2 bin。
4. **Scope**
   manager-api 最小 DDL;`TaskSession.save()` upsert;只读 GET。Agent ≠ App。JSON 文件双写保留。
5. **Out of Scope**
   评分算法、对象存储、家长 UI、新 WS type、设备缓存 instance_id、改 SD 布局。
6–10.
   Client **零改动**。Server Python 上报 + Java CRUD。协议不加新命令种类。音频挂 step 尽力而为。
13. **Acceptance**
   一次跟读可在 API 查到 steps + 文本 + 可选 audio_id;manage_api 挂了语音仍可用。
14–15.
   与 `chat_history` 双写但表分开;旧固件无 instance_id 必须兼容(id 只在云端)。
详细施工:`phase-3-plan.md`。验收:`phase-3-test.md`。
---
### Phase 4 — 跟读:时钟步进 + 后评估(定量)
1. **Objective**
   **仅跟读这一类**:预定 MP3 序列 + 定时录音。分析全部后置、定量、允许失败。设备不宣读对错。
   **不要**把倒背、接龙套进本阶段的「分析永不挡喇叭」。
2. **Why Now**
   跟读/打卡录音的价值在归档与事后看指标。若把云端比对绑在句间,喇叭会卡。
3. **Dependencies**
   Phase 3 Instance;Phase 2 `duration_ms` 听窗。原文来自 txt 契约,**不能**用 `prompt_text` 路径。
4. **Scope**
   ASR 与 play next 解耦;`listen_ratio` 1.2–1.5;后台 `metrics[{method,value}]`。无 `passed`、无 retry、无当场 TTS 分数。
5. **Out of Scope**
   倒背/接龙的及时反馈(4b);Spec S8 再播同一句;及格线产品化;音素;租户;口令进固件。
6. **Client**
   零改动。
8. **Protocol**
   下一句只由时钟触发。不加 `JUDGE`。
13. **Acceptance**
   说错/不说/API 挂了仍播下一句。见 `phase-4-test.md`。
详细施工:`phase-4-plan.md`。
---
### Phase V — 产品代际 + 端云版本匹配
> 现状结论(2026-09-02 核对代码):**现在没有「小智 AI 1 / 1-1 / 2」这套产品代际,也没有客户端与 Server 的兼容矩阵。**
> 已有的只是「能不能升 bin」和「握手里开没开 mcp/aec」。摄像头 SKU 或破坏性 API 之前必须补齐。
> 不挡 Phase 4 跟读。施工单以后写 `phase-v-plan.md`。
1. **Objective**
   把三套独立的编号钉死,并在连接时对上:
   **产品代际**(小智 AI 1 / 1-1 / 2)≠ **固件 semver**(OTA 包)≠ **协议/能力集**(Server 决定开哪些业务)。
   Server 对过旧设备降级或引导 OTA;对过新设备拒绝未知能力,而不是 silently 当聊天。
2. **Why Now**
   不加摄像头也可以出 1-1(跟读/Task Runtime 已是大功能面)。不加代际字段,OTA 只能按板型比 `2.2.6` vs `1.0.0` 两个对不上的数字,Server 无法判断「这台有没有相机 MCP、能不能跑 cap.v1.1 的跟读听窗」。
3. **Dependencies**
   现网已有:OTA 上报 `application.version` + `board.type`;WS hello `version`(帧格式 1/2/3)+ `features.mcp|aec`;MQTT `snlp/v1` + header `"1.0"`;manager-api Liquibase 日期 changeset。
   **不依赖** Phase 4 完成。摄像头硬件不在本阶段。
4. **编号模型(冻结语义,实现可后写)**
```text
product.generation     1 / 2 / 3        硬件族。1=现网音箱;2=加摄像头(或其它新传感器)
product.revision       0 / 1 / 2        同代硬件上的软件大改。对外:小智 AI 1、小智 AI 1-1、小智 AI 2
firmware.semver        1.4.2            每一次可 OTA 的 bin;只升这个数字去升包
capability.set         cap.v1 / v1.1    与 Spec 修订对齐(播/听/跟读听窗/…);新 App 不加 cap 号
protocol.ws            1                已有 hello.version,只为二进制帧;禁止拿它当产品代际
protocol.mqtt          snlp/v1          topic 大改才升 v2;header.version 只升破坏性字段
server.release         2026.09.x 或 semver    xiaozhi-server + manager-api 同发
api.rest               /xiaozhi/v1/     破坏性 REST 才 /v2/;旧路径保留到兼容窗结束
```
对外称呼:
| 对外 | generation | revision | 含义 |
|------|------------|----------|------|
| 小智 AI 1 | 1 | 0 | 现网:无摄像头,当前能力面 |
| 小智 AI 1-1 | 1 | 1 | **同一块硬件**,能力集或业务面大改(例如 Task Runtime 成为默认) |
| 小智 AI 2 | 2 | 0 | **新硬件**(摄像头等);固件包与 1.x **分轨**,禁止烧到对方板上 |
5. **现状缺口(对照代码,不要假装已有)**
| 层 | 现在有什么 | 缺什么 |
|----|------------|--------|
| 终端产品号 | `system.h` 里 `APP_FIRMWARE_VERSION "1.0.0"` 几乎不 bump;CMake `PROJECT_VER "2.2.6"` 是上游仓库号;User-Agent 是 `BOARD_NAME/esp_app.version` | 没有 `product.generation/revision`;1 和 1-1 无法区分 |
| WS 握手 | hello:`version=1` + `features.{mcp,aec}` + 音频参数 | 不报固件、不报代际、不报 cap_set;Server 不拒不匹配 |
| OTA | 按 **板型** 比 semver,新则给下载 URL | 不按产品代际分轨;不检查 Server 最低 cap;1.0.0 vs 2.2.6 两套数字并存 |
| MQTT snlp | topic `v1`、header `"1.0"` 写死 | 无协商;无能力位 |
| Server | `config.yaml` `xiaozhi.version: 1`;manager-api 靠日期 SQL | 无 `server.release`;REST 无兼容窗;无「此 Server 支持的 generation 范围」 |
| 业务匹配 | MCP 工具列表运行时发现 | 没有「这台 gen1 永远不要 vision」的矩阵;新 API 默认所有旧设备都能打 |
6. **Scope**
   - 设备 OTA JSON + WS hello **增补**(不改旧字段含义):`product_generation`、`product_revision`、`firmware`、`capability_set`。
   - Server 维护一张 **compat 矩阵**(哪一档 Server 接受哪一档设备;过旧 → 只开旧业务或下发 OTA;过新/未知 cap → 拒绝或忽略未知 MCP)。
   - manager-api:固件包绑定 `(board_type, generation)`,禁止 gen2 包推给 gen1。
   - REST:现有 `/task/instance` 视为 v1;破坏性变更走新路径或带 `Accept-Version`,旧客户端有兼容窗。
   - 文档:一张表写清「小智 AI 1 最低 Server」「Server x 最低固件」。
7. **Out of Scope**
   摄像头驱动、vision MCP 实现、把 cap_set 当 App 商店、为每个 App 升 generation、把 `hello.version` 改成 2 来表示 1-1。
8. **Protocol**
   **只加字段,不加新 WS type。** 旧固件不带这些字段时:Server 视为 **小智 AI 1 / revision 0 / cap.v1**,保持现网能连。
   匹配发生在 hello 与 OTA check,不在每句 ASR。
9. **Client / Server 匹配规则(产品语义)**
```text
连接时
  Device  →  generation + revision + firmware + cap_set
  Server  →  查矩阵:
      未知 generation          → 拒绝或只开 CHAT
      cap_set < server.min     → 引导 OTA;OTA 前只跑 cap 交集(例如没有跟读听窗就当 PLAY_AUDIO)
      cap_set > server.max     → Server 忽略未知能力,不崩溃
      generation=1 无 camera   → 永不下发 vision 工具
      generation=2             → 允许 vision;固件包与 1 分轨
```
同一硬件出 1-1:只升 `product.revision` + `capability_set` + `firmware.semver`,**不**升 generation。
新传感器:升 generation,新 board_type,新 OTA 目录。
13. **Acceptance**
   - 现网未改字段的固件仍能连当前 Server(默认 1.0 / cap.v1)。
   - 控制台能看到设备是 1 还是 1-1,OTA 不会把 gen2 bin 推给 gen1。
   - hello 日志能打印两端的 generation / cap_set / 采用的交集。
   - 文档有兼容矩阵;破坏性 REST 有旧路径或明确截止日期。
14. **Risks**
   把业务版本和协议版本混成一个数;1-1 误当成必须换机;Server 静默吞掉未知字段导致「以为支持摄像头」。
15. **Regression Risks**
   hello 加字段导致旧 Server 解析失败(必须忽略未知键);OTA 板型字符串与 generation 不一致导致升错包。
---
### 评估时机(Task 声明,后续 4b 施工单再细化)
不是新 Device 能力。Manifest / Task JSON 标明 `assessment.timing`:
| timing | 听窗结束后 | 代表 | 实现阶段 |
|--------|------------|------|----------|
| **`deferred`** | 立刻下一步;metrics 后台可晚到 | 跟读、故事复述、打卡录音 | **Phase 4** |
| **`inline`** | **先** Server 评估,**再** `speak` 反馈,然后下一题 | 倒背数字、成语接龙、口算 | **Phase 4b** |
共同点:判题只在 Server;设备只收到已有 `speak` / `play_audio`;固件无 `if correct`、无 `JUDGE`。
差别:跟读可以评估失败仍往下播;倒背若评估失败应 speak「没听清」之类再进入下一题,**不能**把反馈拖到家长事后打开 App 才算完成这一回合。
倒背示例(Server 编排,可后补进 4b-plan):
```text
speak 数字,间隔 ~1s(Server 睡或以后 D11 wait)
speak「开始」
listen 固定时长
ASR + parser → metrics(对了几个、准确率)
speak 反馈(及时、可含定量,如「倒对了 4 个」)
下一题或结束
```
成语接龙:听完用户一句 → Server 判是否合法接龙 → 马上 speak 下一成语或纠错提示。同样是 `inline`。
口算:默认同 `inline`(对/错或报出正确答案属于业务流)。若某练习模式只要交卷后看报表,再在 Task JSON 改成 `deferred`。
---
### Phase 4b — 冻结验收集(倒背 / 口算 / 接龙)· 流内评估
1. **Objective**
   三个代表 Task **只加 Server Task / Parser / Prompt**。其中倒背、接龙(及默认口算)必须是 **`inline`**:听窗结束就要评估并出声反馈,**禁止**做成跟读那样的后评估。
2. **Why Now**
   跟读只覆盖「录完继续」。规则型/游戏型回合若不及时反馈,产品就不是那个 App。
3. **Dependencies**
   Phase 4 的听窗、Instance、metrics 字段。另增:听窗结束 → await 短评估 → speak(仍不改固件)。
4. **Scope**
   倒背:间隔出题 → 「开始」→ 录音窗 → 立刻报对了几个/准确率。
   接龙:用户说完 → 立刻合法性与下一提示。
   口算:默认同 inline。
   出题 TTS 语言 = `device.locale`(无则 zh)。Parser 仍产出定量 `metrics[]`;反馈文案由 Server 生成后 `speak`,不是固件模板。
5. **Out of Scope**
   新 Mode、新 DeviceState、MultiNet 业务词、新 WS、`JUDGE` 命令、难度引擎产品化、小程序、phrase pack、把跟读改回流内纠错。
6. **Client**
   **禁止改 C++。**
13. **Acceptance**
   倒背一轮:录音结束后 **数秒内** 设备说出评估结果,再出下一题或结束。断评估服务时有超时兜底 speak,不得死等。固件 git diff 为空。
施工单:以后写 `phase-4b-plan.md`(本文件只钉时机差异)。
### Phase 5 — Server Scheduler
1. **Objective**
   家长「每天 07:00 复读」权威在 Server。设备 RTC + pending 是 D14 执行器,不是日历真相。
2. **Why Now**
   设备闹钟能 Demo,不能对账 Instance。
3. **Dependencies**
   Phase 3;Phase 1 `SET_ALARM` / `START_APP_TASK`。
4. **Scope**
   Schedule 表;到点 `TASK_START`(MQTT);跳过 mqtt_immediate。
5. **Out of Scope**
   完整家长 UI;Device 上的复杂 Plan DSL。
8. **Protocol**
   复用已有 MQTT 命令,不新增种类。PAUSE/RESUME 若做,必须是 Spec D14,不是第五套协议。
---
### Phase 6 — App / Resource Management(D13)+ 在线 Locale
1. **Objective**
   资源权威在 Server;设备只缓存文件。同时落地 **设备默认语言(在线半)** 与 **翻译/双语的可替换语言对**。
2. **Why Now**
   跟读换标准音、多 App 音频,不能靠手工拷 SD。词表若继续写死中文,德语机联网后仍只能喊「开始跟读」。
3. **Dependencies**
   Phase 3。不依赖 4b、不依赖 6b 固件。现网 `ai_agent.language` / `lang_code` 可先当 Chat 语种来源。
4. **Scope — 资源**
   清单 / DOWNLOAD / `resource_id`。可选缓存 manifest。设备仍只执行 skills[]。
5. **Scope — Locale(在线,本阶段必须做)**
   见下文 **「语言模型」**。phrase pack、`device.locale`、翻译/双语 `language_pair`。热路径:ASR 文本用该 locale 的 enter_phrases 匹配;TTS 进场语用该 locale;Chat 系统提示与音色跟 locale。
6. **Out of Scope**
   App Store;把判题 JSON 当固件逻辑;德语离线唤醒(6b / 可能永久做不到);Phase 4 时钟重做。
---
### Phase 6b — 离线 Locale(可选 OTA)
1. **Objective**
   没网时,唤醒词与系统级口令(切换入口 / 复读机 / 回 Chat)与 `device.locale` 一致。**不是新 Device 能力种类**(Spec:MultiNet 词表可配)。
2. **Why Now / 为何单独**
   ESP-SR 的 WakeNet / MultiNet **按语种出模型**。中文口令是拼音音素;英语是另一套 MN;官方 **没有** 德语 MultiNet 可预期。把「烧一套德语句表」塞进 Phase 6 会假装离线德语已经存在。
3. **Scope**
   NVS `locale`;从 assets 加载该语种的 wake + 系统命令表。v1 **只承诺 zh 与 en**(有模型才做)。切换仍只允许系统级词,禁止把「开始跟读」写进固件。
4. **Out of Scope**
   为泰/法/德训练唤醒模型(产品/数据项目,不是编码 Phase);运行时从云下载声学模型进 PSRAM(除非以后单独立项)。
5. **接受现实**
   `locale=de-DE` 的设备:在线菜单/Chat 可以是德语;离线唤醒可以仍是英文或中文 SKU,直到有模型。文档必须写清,不要当 bug。
---
### Phase 7 — Parent Task Management
1. **Objective**
   小程序调用已有 Command:即时 START、看 Instance/定量结果、改 Schedule;**设置设备默认语言与翻译/双语语言对**。
2. **Dependencies**
   Phase 5 + Phase 6;Phase 4 metrics 可选。6b 不是前提:控制台改 locale 后,在线立即生效。
4. **Scope**
   UI。D9 长按退出若未做可一并(仍是已列能力)。语言对只能选 App Manifest 声明的 supported_pairs。
5. **Out of Scope**
   开发者平台、支付、在小程序里训练唤醒词。
---
### Phase 8 — Developer App Platform
1. **Objective**
   第三方 Task JSON **只能引用 Spec 已冻结 Skill**。校验失败即拒。
2. **Dependencies**
   Phase 6;InteractionMode 集合稳定。
5. **Out of Scope**
   GPIO、设备脚本引擎、新传感器。
---
### Phase 9 — Subscription / Marketplace
账号与权益。不改 Opus,不上情绪诊断。
---
### 语言模型(评估结论;实现落在 6 / 6b / 7)
分三层,不要混成「一个 language 字段搞定唤醒和翻译」。
| 层 | 是什么 | 例子 | 难度 |
|----|--------|------|------|
| **A 设备默认语言 `device.locale`** | 开机 Chat、进场 TTS、在线场景口令、无语言对的 App(倒背、口算、故事复述) | `zh-CN` / `en-US` / `de-DE` | 在线:**容易**(Server phrase pack + 已有 `ai_agent.language`)。离线唤醒/口令:**不容易**,见 6b |
| **B App 语言对 `language_pair`** | 翻译、双语对话的可替换选项 | 翻译 `th↔fr`;对话 `en↔de` | **容易**,纯 Server;一个 App + 参数,不要每种语言对一个 `app_id` |
| **C 资源内容语言** | 音频 MP3/txt 本身 | 英语跟读课永远是英文音频 | 跟 lesson 包走,不跟 locale。德语机练英语跟读:菜单德语、喇叭音频英语 |
**功能 1 好不好做:**
- 联网后的中文改德语 Chat / 「切换模式」菜单 / 「开始跟读」的德语同义句:把 `runtime.py` 硬编码词表换成 `locales/{locale}/phrases.json`,hello 时带上 `device.locale`。**工作量中等、风险低,Phase 6。**
- 开机离线就用德语喊唤醒、喊「复读机」:必须换 WakeNet + MultiNet 模型与音素格式。中文现网是拼音编译进 `system.h`。英语官方 MN 可做第二套 assets。**德语没有现成 ESP-SR 模型时,离线入口做不到**,只能在线。不要承诺「改一个配置全世界离线德语」。
- 工厂 SKU(出厂烧中文机 / 英文机)比运行时热切换离线模型容易得多。运行时切换 locale:在线立刻生效;离线要 6b 且仅 zh/en。
**功能 2 怎么配:**
不要再复制 `english_translation` / `english_conversation` 两个 App 名。Manifest:
```text
app_id: translation
  default_pair: ["zh","en"]          # 设备 locale 为 zh 时的缺省
  supported_pairs: [["zh","en"],["th","fr"],["en","de"], ...]
  instance.options.language_pair     # 家长/控制台覆盖
app_id: bilingual_chat
  default_pair: ["zh","en"]
  supported_pairs: [["zh","en"],["en","de"], ...]
  语义:用户说 lang_a → 只答 lang_b,反之
```
翻译机:未指定时仍可 auto 检测,目标语取 pair 的另一侧或 `target_lang`。
缺省 pair 的推导:`[device.locale 的主语言, 机构配置的 L2]`,没有 L2 则 `zh-en`。
倒背 / 故事复述 / 口算:只有 A,TTS 与听窗提示用 `device.locale`。不要给它们配 language_pair。
**权威:** `device.locale` 在 manager-api(绑定/出厂);Agent.language 与其对齐,避免两套打架。xiaozhi-server 连接时读取,固件 NVS 只服务 6b 离线。设备 **仍然没有 tenant_id**。
**禁止:** 为每种语言对改 C++;把「开始跟读」的德语写进固件;Phase 4 做 Locale。
---
## 3. 跨端依赖(Command / Event / State)
对齐 Spec §4–5。每个 Phase 必须同时有 Server→Device 与 Device→Server,禁止单端表。
```text
Phase 2
  Device  AUDIO_COMPLETED|STOPPED  ──detect──►  Server
  Device  listen 流 + VAD          ────────►  Server ASR / D8 v1 report
  Server  SPEAK / PLAY_AUDIO / 回 CHAT ──tts/mcp──► Device
  Server  ensure_app dirs          ──MCP/MQTT──► Device mkdir(运输,非新能力)
Phase 3  ✅
  Server  TaskSession JSON         ──HTTP upsert──►  manager-api Instance
  无新 Device 能力;设备不持有 instance_id
Phase 4
  Server  仅时钟到点 PLAY_AUDIO 下一句 ──► Device
  Device  听流(旁路 ASR/report)     ──► Server 后评估 metrics
  跟读:分析失败不挡下一句;无 JUDGE
Phase V
  Device  hello/OTA 带 generation+revision+cap_set  ──► Server 矩阵
  Server  交集业务 / 引导 OTA / 拒未知 SKU          ──► Device
  旧固件缺字段 = 小智 AI 1 / cap.v1;不加新 WS type
Phase 4b
  Device  听流                       ──► Server ASR + parser(inline,短超时)
  Server  SPEAK 反馈 / 下一题        ──► Device
  仍无 JUDGE、无固件 if correct;评估在业务流内,不能拖到后评估
Phase 5
  Server  TASK_START               ──MQTT──► Device
  Device  TASK_STARTED/COMPLETED   ──ack/event──► Server
Phase 6
  Server  FETCH                    ──DOWNLOAD──► Device SD
  Device  PLAY_AUDIO(resource)     ──event──► Server
```
---
## 4. Master Plan / Spec 对照
| 长期目标 | Roadmap |
|----------|---------|
| Device 能力冻结 | Freeze 文档 + Phase 2 补事件/采集 |
| Task Runtime 在 Server | P2 Session → P3 Instance ✅ → P4 跟读后评估 → P4b 流内评估 |
| 新 App 不改 C++ | P4b 三个代表 Task 验收 |
| 跟读语音对象 | P2 D8 v1 → P3 挂 step → P6 标准音 |
| Scheduler | P5 / P7 |
| App ≠ Firmware | 贯穿;P6 资源、P8 沙箱 |
| 设备默认语言 / 语言对 | P6 在线 pack + pair;P6b 离线 zh/en;P7 控制台;P4b 继承 locale |
| 产品代际 / 端云匹配 | **Phase V**;摄像头 SKU 之前;现网缺字段视为 小智 AI 1 |
| 心理陪伴 / 主持人 | 无新硬件;复用 listen/speak + Server Memory |
| Subscription | P9 |
---
## 5. 刻意不做进近期的事项
来自 Spec §17 与 §21:
- 为成语/口算/接龙改 C++ 或加 MultiNet 业务口令
- 把 `branch(if correct)` 放进固件
- 合并 Xiaozhi MQTT 与 snlp(改运输 ≠ 加能力,但不作为近期项目)
- 把 apps.json 当离线权威引擎
- Phase 2 做 `wait` Skill、`TASK_PAUSE`、本地 WAV 上传、`indicate` 大改
- Phase 2 拆微服务 / 对象存储 / 情绪分析 / 临床诊断
- 一次性写死 Phase 5–9 的类名与 REST 路径
- 把业务 App 切换词写入固件(系统级仅 Chat + 离线复读 + 「切换模式」总入口)
- 在 Phase 4 引入租户 / `tenant_id`(最早缝在 Phase 6,产品化 Phase 7)
- 在 Phase 4 做多语言 / 把德语唤醒写进固件
- 为每种翻译语言对新增一个 `app_id` 或新 WS type
- 用一个数字同时表示产品代际、固件 semver 和协议版本
- 把摄像头固件包按 semver 推给无摄像头的小智 AI 1
- 在 Phase 4 做兼容矩阵 / 改 hello 字段(放到 Phase V)
**下一编码阶段:** 只打开 `phase-4-plan.md`,验收对照 `phase-4-test.md`。固件冻结。跟读按 MP3 序列定时录音;后评估只定量、不指挥喇叭。Locale 等到 Phase 6。端云版本匹配等到 **Phase V**(可与 4 并行设计,摄像头 SKU 前必须落地)。
xiaozhi-esp32-2.2.6/main/CMakeLists.txt
@@ -1201,3 +1201,21 @@
else()
    message(STATUS "Assets partition not found, using v1 partition table")
endif()
# Patch 78/esp-wifi-connect so the captive portal accepts zh_CN and reclaims
# httpd sockets stolen by WeChat /mmtls captive-DNS probes.
find_component_by_pattern("esp-wifi-connect" WIFI_CONNECT_COMPONENT WIFI_CONNECT_PATH)
if(WIFI_CONNECT_PATH)
    execute_process(
        COMMAND python "${PROJECT_DIR}/scripts/patch_wifi_connect.py" "${WIFI_CONNECT_PATH}"
        RESULT_VARIABLE WIFI_CONNECT_PATCH_RESULT
        OUTPUT_VARIABLE WIFI_CONNECT_PATCH_OUTPUT
        ERROR_VARIABLE WIFI_CONNECT_PATCH_OUTPUT
    )
    if(WIFI_CONNECT_PATCH_OUTPUT)
        message(STATUS "${WIFI_CONNECT_PATCH_OUTPUT}")
    endif()
    if(NOT WIFI_CONNECT_PATCH_RESULT EQUAL 0)
        message(WARNING "Failed to patch esp-wifi-connect at ${WIFI_CONNECT_PATH}")
    endif()
endif()
xiaozhi-esp32-2.2.6/main/Kconfig.projbuild
@@ -2,7 +2,7 @@
config OTA_URL
    string "Default OTA URL"
    default "https://api.tenclass.net/xiaozhi/ota/"
    default "http://192.168.0.107:8002/xiaozhi/ota/"
    help
        The application will access this URL to check for new firmwares and server address.
xiaozhi-esp32-2.2.6/main/application.cc
@@ -6,6 +6,7 @@
#include "mqtt_protocol.h"
#include "websocket_protocol.h"
#include "boards/sening-xiaozhi-s3/mqtt.h"
#include "boards/sening-xiaozhi-s3/app_task.h"
#include "assets/lang_config.h"
#include "mcp_server.h"
#include "assets.h"
@@ -97,7 +98,7 @@
        .arg = this,
        .dispatch_method = ESP_TIMER_TASK,
        .name = "clock_timer",
        .skip_unhandled_events = true
        .skip_unhandled_events = false
    };
    esp_timer_create(&clock_timer_args, &clock_timer_handle_);
}
@@ -143,9 +144,14 @@
        }
    };
    callbacks.on_offline_command_detected = [this](const std::string& command) {
        if (!power_off_standby_active_.load()) {
            Board_Handle_Offline_Command(command.c_str());
        if (power_off_standby_active_.load()) {
            return;
        }
        // AFE audio_detection only has a 4KB stack. Starting repeater (NVS, cJSON,
        // SD list, MP3, JPG) on that task overflows and reboots.
        Schedule([command]() {
            Board_Handle_Offline_Command(command.c_str());
        });
    };
    callbacks.on_vad_change = [this](bool speaking) {
        xEventGroupSetBits(event_group_, MAIN_EVENT_VAD_CHANGE);
@@ -314,13 +320,66 @@
            for (auto& task : tasks) {
                task();
            }
            if (GetDeviceState() == kDeviceStateSpeaking) {
                MaybeFinishTtsAfterPlayback();
            }
        }
        if (bits & MAIN_EVENT_CLOCK_TICK) {
            clock_ticks_++;
            auto display = Board::GetInstance().GetDisplay();
            display->UpdateStatusBar();
            auto state = GetDeviceState();
            if (state == kDeviceStateConnecting || state == kDeviceStateSpeaking ||
                (state == kDeviceStateIdle && suppress_wake_until_listen_)) {
                audio_service_.KeepCodecReadyForPlayback();
            }
            MaybeFinishTtsAfterPlayback();
            if (suppress_wake_until_listen_ && state == kDeviceStateIdle) {
                if (protocol_ && protocol_->IsAudioChannelConnected()) {
                    boot_wake_restore_ticks_ = 0;
                    if (greeting_listen_wait_ticks_ > 0) {
                        greeting_listen_wait_ticks_--;
                        if (greeting_listen_wait_ticks_ == 0) {
                            ESP_LOGW(TAG, "greeting TTS missing, start listening");
                            SetListeningMode(GetDefaultListeningMode());
                        }
                    }
                } else if (++boot_wake_restore_ticks_ >= 20) {
                    ESP_LOGW(TAG, "boot websocket not opened, restore wake word");
                    suppress_wake_until_listen_ = false;
                    boot_wake_restore_ticks_ = 0;
                    audio_service_.EnableWakeWordDetection(true);
                }
            } else {
                boot_wake_restore_ticks_ = 0;
            }
            if (state == kDeviceStateSpeaking && tts_audio_expected_ && !tts_audio_received_ &&
                speaking_started_us_ > 0) {
                const int64_t wait_ms = (esp_timer_get_time() - speaking_started_us_) / 1000;
                if (wait_ms >= 1000 && (wait_ms / 1000) <= 8) {
                    ESP_LOGW(TAG, "TTS audio still missing after %lldms", (long long)wait_ms);
                }
            }
            if (protocol_ && IsSdCardMp3Playing() && protocol_->IsAudioChannelConnected()) {
                protocol_->MarkIncomingActivity();
            }
            if (!pending_detect_text_.empty() &&
                GetDeviceState() == kDeviceStateIdle &&
                protocol_ && !protocol_->IsAudioChannelConnected()) {
                if (pending_detect_retry_ticks_ > 0) {
                    pending_detect_retry_ticks_--;
                } else if (pending_detect_retries_left_ > 0) {
                    pending_detect_retries_left_--;
                    pending_detect_retry_ticks_ = 25;
                    ESP_LOGW(TAG, "retry listen detect after handshake fail: %s left=%d",
                             pending_detect_text_.c_str(), pending_detect_retries_left_);
                    SendListenDetect(pending_detect_text_, true);
                } else {
                    pending_detect_text_.clear();
                }
            }
            // Print debug info every 10 seconds
            if (clock_ticks_ % 10 == 0) {
                SystemInfo::PrintHeapStats();
@@ -376,6 +435,10 @@
    ESP_LOGI(TAG, "Activation done");
    SystemInfo::PrintHeapStats();
    // 开机马上要连语音 WebSocket。先不要拉起 AFE:内部 SRAM 被 WakeNet 占满时
    // 第一次握手会 10 秒超时,重试虽能连上,欢迎词 opus 也容易丢。
    suppress_wake_until_listen_ = true;
    boot_wake_restore_ticks_ = 0;
    SetDeviceState(kDeviceStateIdle);
    has_server_time_ = ota_->HasServerTime();
@@ -388,7 +451,9 @@
    // Release OTA object after activation is complete
    ota_.reset();
    auto& board = Board::GetInstance();
    board.SetPowerSaveLevel(PowerSaveLevel::LOW_POWER);
    // 开机马上就要连语音 WebSocket 播欢迎词。这里切 LOW_POWER 会让随后的握手
    // 卡在 handshake timeout,欢迎词根本发不出来。保持高性能直到通道打开。
    board.SetPowerSaveLevel(PowerSaveLevel::PERFORMANCE);
    Schedule([this]() {
        // Play the success sound to indicate the device is ready
@@ -582,6 +647,10 @@
    protocol_->OnNetworkError([this](const std::string& message) {
        last_error_message_ = message;
        if (network_error_suppressed_) {
            ESP_LOGW(TAG, "network error suppressed during retry: %s", message.c_str());
            return;
        }
        xEventGroupSetBits(event_group_, MAIN_EVENT_ERROR);
    });
    
@@ -589,14 +658,25 @@
        auto shared_packet = std::shared_ptr<AudioStreamPacket>(packet.release());
        Schedule([this, shared_packet]() {
            auto state = GetDeviceState();
            if (state != kDeviceStateSpeaking && !tts_audio_expected_) {
            // speak_txt 不发独立 tts start,首句 opus 可能早于 sentence_start。
            // 待命/聆听时不要丢包,交给 ScheduleTtsAudio 等到 speaking 就绪再播。
            if (state != kDeviceStateSpeaking && !tts_audio_expected_ &&
                state != kDeviceStateListening && state != kDeviceStateIdle) {
                return;
            }
            if (state == kDeviceStateListening || state == kDeviceStateIdle) {
                tts_audio_expected_ = true;
            }
            tts_response_started_ = true;
            ESP_LOGI(TAG, "TTS audio packet bytes=%u expected=%d ready=%d state=%d",
                     (unsigned)shared_packet->payload.size(),
                     tts_audio_expected_ ? 1 : 0,
                     tts_speaking_state_ready_ ? 1 : 0,
                     static_cast<int>(state));
            ScheduleTtsAudio(shared_packet);
            if (state == kDeviceStateListening) {
            if (state == kDeviceStateListening || state == kDeviceStateIdle) {
                SetDeviceState(kDeviceStateSpeaking);
            }
        });
@@ -611,10 +691,25 @@
    });
    
    protocol_->OnAudioChannelClosed([this, &board]() {
        board.SetPowerSaveLevel(PowerSaveLevel::LOW_POWER);
        if (opening_audio_channel_) {
            ESP_LOGI(TAG, "Skip channel-closed teardown while opening/reusing websocket");
            return;
        }
        // 跟读/复读还在播时不要切 LOW_POWER:后面补发 playlist_completed 会 handshake timeout。
        if ((Board_IsShadowingTask() || Board_IsRepeaterTask()) && IsSdCardMp3Playing()) {
            board.SetPowerSaveLevel(PowerSaveLevel::PERFORMANCE);
        } else {
            board.SetPowerSaveLevel(PowerSaveLevel::LOW_POWER);
        }
        Schedule([this]() {
            auto display = Board::GetInstance().GetDisplay();
            opening_audio_channel_ = false;
            if ((Board_IsShadowingTask() || Board_IsRepeaterTask()) && IsSdCardMp3Playing()) {
                if (GetDeviceState() == kDeviceStateListening) {
                    SetDeviceState(kDeviceStateIdle);
                }
                return;
            }
            tts_audio_expected_ = false;
            tts_response_started_ = false;
            tts_audio_received_ = false;
@@ -656,14 +751,24 @@
                    tts_audio_expected_ = true;
                    tts_response_started_ = false;
                    tts_audio_received_ = false;
                    tts_stop_pending_ = false;
                    tts_stop_waiting_ = false;
                    tts_playback_empty_ticks_ = 0;
                    tts_stop_wait_ticks_ = 0;
                    tts_activity_generation_++;
                    audio_service_.SetAudioPowerInhibit(true);
                    audio_service_.KeepCodecReadyForPlayback();
                    auto state = GetDeviceState();
                    if (state == kDeviceStateListening || state == kDeviceStateIdle) {
                        SetDeviceState(kDeviceStateSpeaking);
                    }
                    ESP_LOGI(TAG, "TTS start output=%d",
                             Board::GetInstance().GetAudioCodec()->output_enabled() ? 1 : 0);
                });
            } else if (strcmp(state->valuestring, "stop") == 0) {
                Schedule([this]() {
                    if (!tts_response_started_ && !standby_requested_ && !power_off_requested_) {
                        tts_stop_pending_ = true;
                        return;
                    }
                    ESP_LOGI(TAG, "TTS stop started=%d received=%d",
                             tts_response_started_, tts_audio_received_);
                    tts_stop_pending_ = true;
                    FinishTtsResponseAfterPlayback();
                });
@@ -683,14 +788,17 @@
                        tts_audio_expected_ = true;
                        tts_response_started_ = true;
                        tts_activity_generation_++;
                        tts_playback_empty_ticks_ = 0;
                        audio_service_.SetAudioPowerInhibit(true);
                        audio_service_.KeepCodecReadyForPlayback();
                        if (IsSdCardMp3Playing() && !IsSdCardMp3Paused()) {
                            PauseSdCardMp3();
                            resume_sdcard_mp3_after_tts_ = true;
                        }
                        if (GetDeviceState() == kDeviceStateListening) {
                        auto state = GetDeviceState();
                        if (state == kDeviceStateListening || state == kDeviceStateIdle) {
                            SetDeviceState(kDeviceStateSpeaking);
                        }
                        ScheduleTtsIdleFallback();
                        display->SetChatMessage("assistant", message.c_str());
                    });
                }
@@ -844,18 +952,58 @@
    xEventGroupSetBits(event_group_, MAIN_EVENT_STOP_LISTENING);
}
bool Application::SendListenDetectNow(const std::string& text) {
    if (!protocol_ || text.empty()) {
        return false;
    }
    ESP_LOGI(TAG, "Send listen detect now: %s connected=%d",
             text.c_str(), protocol_->IsAudioChannelConnected() ? 1 : 0);
    if (!protocol_->IsAudioChannelConnected()) {
        return false;
    }
    Board::GetInstance().SetPowerSaveLevel(PowerSaveLevel::PERFORMANCE);
    protocol_->MarkIncomingActivity();
    return protocol_->SendWakeWordDetected(text);
}
void Application::SendListenDetect(const std::string& text, bool open_if_idle) {
    Schedule([this, text, open_if_idle]() {
        if (!protocol_ || text.empty()) {
            return;
        }
        if (protocol_->IsAudioChannelOpened()) {
            protocol_->SendWakeWordDetected(text);
        ESP_LOGI(TAG, "Send listen detect: %s connected=%d",
                 text.c_str(), protocol_->IsAudioChannelConnected() ? 1 : 0);
        if (protocol_->IsAudioChannelConnected()) {
            Board::GetInstance().SetPowerSaveLevel(PowerSaveLevel::PERFORMANCE);
            protocol_->MarkIncomingActivity();
            if (protocol_->SendWakeWordDetected(text)) {
                return;
            }
            ESP_LOGW(TAG, "detect send failed, reopen audio channel");
        }
        if (!open_if_idle) {
            return;
        }
        if (open_if_idle) {
            WakeWordInvoke(text);
        if (!Board::GetInstance().IsNetworkReady()) {
            ESP_LOGW(TAG, "skip listen detect, network not ready: %s", text.c_str());
            return;
        }
        auto state = GetDeviceState();
        // 开机/TTS/配网时不要 WakeWordInvoke:那会进 listening、开 AFE、ResetDecoder,
        // 欢迎词被清掉,FEED 环缓冲也会立刻撑满。
        if (state == kDeviceStateSpeaking || state == kDeviceStateWifiConfiguring ||
            state == kDeviceStateAudioTesting || state == kDeviceStateConnecting ||
            state == kDeviceStateStarting || state == kDeviceStateActivating) {
            ESP_LOGI(TAG, "defer listen detect until idle: state=%d", static_cast<int>(state));
            return;
        }
        if (state != kDeviceStateIdle) {
            return;
        }
        SetDeviceState(kDeviceStateConnecting);
        Schedule([this, text]() {
            ContinueOpenAudioChannelForDetect(text);
        });
    });
}
@@ -913,26 +1061,103 @@
    if (GetDeviceState() != kDeviceStateConnecting) {
        return;
    }
    if (opening_audio_channel_) {
    if (!OpenAudioChannelWithRetry(2)) {
        SetDeviceState(kDeviceStateIdle);
        return;
    }
    SetListeningMode(mode);
}
bool Application::OpenAudioChannelWithRetry(int attempts) {
    if (!protocol_) {
        return false;
    }
    if (protocol_->IsAudioChannelConnected()) {
        protocol_->MarkIncomingActivity();
        return true;
    }
    if (opening_audio_channel_) {
        return false;
    }
    if (attempts < 1) {
        attempts = 1;
    }
    opening_audio_channel_ = true;
    if (!protocol_->IsAudioChannelOpened()) {
        if (!Board::GetInstance().EnsureNetworkReadyForWake(30000)) {
            opening_audio_channel_ = false;
            SetDeviceState(kDeviceStateIdle);
            return;
    // 握手期间停 AFE,避免和 Wi-Fi 抢内部 SRAM/CPU。不要另建任务:
    // AFE 起来后内部堆只剩几 KB,xTaskCreate(8KB) 会直接失败。
    audio_service_.EnableVoiceProcessing(false);
    audio_service_.EnableWakeWordDetection(false);
    Board::GetInstance().SetPowerSaveLevel(PowerSaveLevel::PERFORMANCE);
    audio_service_.SetAudioPowerInhibit(true);
    bool ok = false;
    for (int i = 0; i < attempts; ++i) {
        if (protocol_->IsAudioChannelConnected()) {
            ok = true;
            break;
        }
        if (!protocol_->OpenAudioChannel()) {
            opening_audio_channel_ = false;
            SetDeviceState(kDeviceStateIdle);
            return;
        if (!Board::GetInstance().EnsureNetworkReadyForWake(30000)) {
            ESP_LOGW(TAG, "OpenAudioChannel aborted, network not ready");
            break;
        }
        Board::GetInstance().SetPowerSaveLevel(PowerSaveLevel::PERFORMANCE);
        const bool last = (i == attempts - 1);
        network_error_suppressed_ = !last;
        ok = protocol_->OpenAudioChannel();
        network_error_suppressed_ = false;
        if (ok) {
            break;
        }
        ESP_LOGW(TAG, "OpenAudioChannel failed (%d/%d)", i + 1, attempts);
        protocol_->CloseAudioChannel(false);
        if (!last) {
            ESP_LOGI(TAG, "Retry websocket handshake in 500ms");
            vTaskDelay(pdMS_TO_TICKS(500));
            audio_service_.EnableWakeWordDetection(false);
            Board::GetInstance().SetPowerSaveLevel(PowerSaveLevel::PERFORMANCE);
            audio_service_.KeepCodecReadyForPlayback();
        }
    }
    opening_audio_channel_ = false;
    SetListeningMode(mode);
    opening_audio_channel_ = false;
    audio_service_.SetAudioPowerInhibit(false);
    return ok;
}
void Application::ContinueOpenAudioChannelForDetect(const std::string& detect_text) {
    if (power_off_standby_active_.load()) {
        return;
    }
    if (GetDeviceState() != kDeviceStateConnecting) {
        return;
    }
    listening_mode_ = GetDefaultListeningMode();
    suppress_wake_until_listen_ = true;
    if (!OpenAudioChannelWithRetry(2)) {
        pending_detect_text_ = detect_text;
        if (pending_detect_retries_left_ <= 0) {
            pending_detect_retries_left_ = 3;
        }
        pending_detect_retry_ticks_ = 3;
        suppress_wake_until_listen_ = false;
        audio_service_.EnableWakeWordDetection(true);
        SetDeviceState(kDeviceStateIdle);
        ESP_LOGW(TAG, "boot websocket failed, will retry detect in 3s");
        return;
    }
    pending_detect_text_.clear();
    pending_detect_retries_left_ = 0;
    pending_detect_retry_ticks_ = 0;
    greeting_listen_wait_ticks_ = 8;
    if (!detect_text.empty()) {
        protocol_->MarkIncomingActivity();
        protocol_->SendWakeWordDetected(detect_text);
    }
    // 保持待命且不开 AFE:欢迎词从 idle 进 speaking。说完再 listening 开麦。
    SetDeviceState(kDeviceStateIdle);
}
void Application::PauseSdCardMp3ForVoice() {
@@ -946,8 +1171,87 @@
    if (resume_sdcard_mp3_after_voice_ && !sdcard_mp3_stop_requested_) {
        resume_sdcard_mp3_after_voice_ = false;
        ResumeSdCardMp3();
        ApplyMp3CommandOnlyMic();
    } else {
        resume_sdcard_mp3_after_voice_ = false;
    }
}
void Application::ApplyMp3CommandOnlyMic() {
    if (GetDeviceState() == kDeviceStateWifiConfiguring ||
        GetDeviceState() == kDeviceStateSpeaking) {
        return;
    }
    if (!IsSdCardMp3Playing() || IsSdCardMp3Paused()) {
        return;
    }
    // 音频在播:不上行录音/闲聊,只留离线口令(退出复读/跟读、我们聊天吧)。
    audio_service_.EnableVoiceProcessing(false);
    audio_service_.EnableWakeWordDetection(audio_service_.IsAfeWakeWord());
    if (GetDeviceState() != kDeviceStateListening) {
        return;
    }
    if (protocol_ && protocol_->IsAudioChannelOpened()) {
        protocol_->SendStopListening();
    }
    // 播课文时切待命:关掉 AFE/上行,只留离线口令。跟读句间由播完后再 ResumeListening。
    Schedule([this]() {
        if (GetDeviceState() == kDeviceStateListening &&
            IsSdCardMp3Playing() && !IsSdCardMp3Paused()) {
            SetDeviceState(kDeviceStateIdle);
        }
    });
}
void Application::ResumeListeningAfterMp3Clip() {
    if (power_off_standby_active_.load()) {
        return;
    }
    if (IsSdCardMp3Playing() && !IsSdCardMp3Paused() && !Board_IsShadowingTask()) {
        return;
    }
    auto state = GetDeviceState();
    // 开机欢迎词 / 进场 TTS 期间禁止开麦:EnableVoiceProcessing 会 ResetDecoder 并喂 AFE。
    if (state == kDeviceStateSpeaking || state == kDeviceStateWifiConfiguring ||
        state == kDeviceStateAudioTesting || state == kDeviceStateConnecting ||
        state == kDeviceStateStarting || state == kDeviceStateActivating) {
        return;
    }
    if (!protocol_) {
        return;
    }
    ESP_LOGI(TAG, "Resume listening after MP3 clip");
    if (state == kDeviceStateListening) {
        protocol_->SendStartListening(listening_mode_);
        audio_service_.EnableVoiceProcessing(true);
#ifdef CONFIG_WAKE_WORD_DETECTION_IN_LISTENING
        audio_service_.EnableWakeWordDetection(audio_service_.IsAfeWakeWord());
#else
        audio_service_.EnableWakeWordDetection(false);
#endif
        return;
    }
    if (state == kDeviceStateIdle && !protocol_->IsAudioChannelConnected()) {
        return;
    }
    skip_playback_wait_on_listen_ = true;
    SetListeningMode(GetDefaultListeningMode());
}
void Application::CloseShadowingListenWindow()
{
    audio_service_.EnableVoiceProcessing(false);
    audio_service_.EnableWakeWordDetection(audio_service_.IsAfeWakeWord());
    if (protocol_ && protocol_->IsAudioChannelOpened()) {
        while (auto packet = audio_service_.PopPacketFromSendQueue()) {
            if (!protocol_->SendAudio(std::move(packet))) {
                break;
            }
        }
        protocol_->SendStopListening();
    }
    if (GetDeviceState() == kDeviceStateListening) {
        SetDeviceState(kDeviceStateIdle);
    }
}
@@ -1074,37 +1378,23 @@
        return;
    }
    opening_audio_channel_ = true;
    if (!Board::GetInstance().EnsureNetworkReadyForWake(30000)) {
        opening_audio_channel_ = false;
        SetDeviceState(kDeviceStateIdle);
    if (!OpenAudioChannelWithRetry(2)) {
        ResumeSdCardMp3AfterVoice();
        audio_service_.EnableWakeWordDetection(true);
        SetDeviceState(kDeviceStateIdle);
        return;
    }
    if (!protocol_->IsAudioChannelOpened()) {
        if (!protocol_->OpenAudioChannel()) {
            opening_audio_channel_ = false;
            ResumeSdCardMp3AfterVoice();
            audio_service_.EnableWakeWordDetection(true);
            return;
        }
    }
    opening_audio_channel_ = false;
    ESP_LOGI(TAG, "Wake word detected: %s", wake_word.c_str());
#if CONFIG_SEND_WAKE_WORD_DATA
    // Encode and send the wake word data to the server
    while (auto packet = audio_service_.PopWakeWordPacket()) {
        if (!protocol_->SendAudio(std::move(packet))) {
            break;
        }
    }
    // Set the chat state to wake word detected
    protocol_->SendWakeWordDetected(wake_word);
    SetListeningMode(GetDefaultListeningMode());
#else
    // Set flag to play popup sound after state changes to listening
    // (PlaySound here would be cleared by ResetDecoder in EnableVoiceProcessing)
    play_popup_on_listening_ = true;
    SetListeningMode(GetDefaultListeningMode());
#endif
@@ -1132,20 +1422,35 @@
        case kDeviceStateUnknown:
        case kDeviceStateIdle:
            opening_audio_channel_ = false;
            tts_audio_expected_ = false;
            tts_response_started_ = false;
            tts_audio_received_ = false;
            tts_stop_pending_ = false;
            tts_stop_waiting_ = false;
            tts_idle_waiting_ = false;
            tts_speaking_state_ready_ = false;
            tts_activity_generation_++;
            if (!suppress_wake_until_listen_) {
                tts_audio_expected_ = false;
                tts_response_started_ = false;
                tts_audio_received_ = false;
                tts_stop_pending_ = false;
                tts_stop_waiting_ = false;
                tts_idle_waiting_ = false;
                tts_speaking_state_ready_ = false;
                tts_activity_generation_++;
            }
            ResumeSdCardMp3AfterVoice();
            display->SetStatus(Lang::Strings::STANDBY);
            display->ClearChatMessages();  // Clear messages first
            display->SetEmotion("neutral"); // Then set emotion (wechat mode checks child count)
            audio_service_.EnableVoiceProcessing(false);
            audio_service_.EnableWakeWordDetection(true);
            if (suppress_wake_until_listen_) {
                audio_service_.EnableWakeWordDetection(false);
                audio_service_.SetAudioPowerInhibit(true);
                audio_service_.KeepCodecReadyForPlayback();
            } else if (Board_IsRepeaterTask()) {
                audio_service_.EnableVoiceProcessing(false);
                audio_service_.EnableWakeWordDetection(audio_service_.IsAfeWakeWord());
                audio_service_.SetAudioPowerInhibit(false);
            } else if (IsSdCardMp3Playing() && !IsSdCardMp3Paused()) {
                ApplyMp3CommandOnlyMic();
            } else {
                audio_service_.EnableWakeWordDetection(true);
                audio_service_.SetAudioPowerInhibit(false);
            }
            break;
        case kDeviceStateConnecting:
            display->SetStatus(Lang::Strings::CONNECTING);
@@ -1157,28 +1462,25 @@
            display->SetStatus(Lang::Strings::LISTENING);
            display->SetEmotion("neutral");
            // Make sure the audio processor is running
            if (play_popup_on_listening_ || !audio_service_.IsAudioProcessorRunning()) {
                // For auto mode, wait for playback queue to be empty before enabling voice processing
                // This prevents audio truncation when STOP arrives late due to network jitter
                if (listening_mode_ == kListeningModeAutoStop) {
                    audio_service_.WaitForPlaybackQueueEmpty();
            if (IsSdCardMp3Playing() && !IsSdCardMp3Paused() && !Board_IsShadowingTask()) {
                ApplyMp3CommandOnlyMic();
            } else {
                if (play_popup_on_listening_ || !audio_service_.IsAudioProcessorRunning()) {
                    if (listening_mode_ == kListeningModeAutoStop && !skip_playback_wait_on_listen_) {
                        audio_service_.WaitForPlaybackQueueEmpty();
                    }
                    protocol_->SendStartListening(listening_mode_);
                }
                // Send the start listening command
                protocol_->SendStartListening(listening_mode_);
                audio_service_.EnableVoiceProcessing(true);
            }
#ifdef CONFIG_WAKE_WORD_DETECTION_IN_LISTENING
            // Enable wake word detection in listening mode (configured via Kconfig)
            audio_service_.EnableWakeWordDetection(audio_service_.IsAfeWakeWord());
                audio_service_.EnableWakeWordDetection(audio_service_.IsAfeWakeWord());
#else
            // Disable wake word detection in listening mode
            audio_service_.EnableWakeWordDetection(false);
                audio_service_.EnableWakeWordDetection(false);
#endif
            // Play popup sound after ResetDecoder (in EnableVoiceProcessing) has been called
            }
            audio_service_.SetAudioPowerInhibit(false);
            suppress_wake_until_listen_ = false;
            skip_playback_wait_on_listen_ = false;
            if (play_popup_on_listening_) {
                play_popup_on_listening_ = false;
                audio_service_.PlaySound(Lang::Sounds::OGG_POPUP);
@@ -1186,19 +1488,35 @@
            break;
        case kDeviceStateSpeaking:
            tts_speaking_state_ready_ = false;
            tts_playback_empty_ticks_ = 0;
            tts_stop_wait_ticks_ = 0;
            greeting_listen_wait_ticks_ = 0;
            speaking_started_us_ = esp_timer_get_time();
            display->SetStatus(Lang::Strings::SPEAKING);
            if (listening_mode_ != kListeningModeRealtime) {
                audio_service_.EnableVoiceProcessing(false);
                // Only AFE wake word can be detected in speaking mode
                audio_service_.EnableWakeWordDetection(audio_service_.IsAfeWakeWord());
                // TTS 回声会把菜单里的「跟读/我们聊天吧」听成口令,而且 AFE 会挤爆内部 SRAM。
                audio_service_.EnableWakeWordDetection(false);
            }
            audio_service_.ResetDecoder();
            // 欢迎词的 opus 可能已经在待命时入队。这里 ResetDecoder 会清掉刚到的 TTS,
            // 听起来像没播,随后又等不到 tts stop,一直停在说话中。
            if (!tts_audio_expected_ && !tts_audio_received_ && audio_service_.IsPlaybackQueueEmpty()) {
                audio_service_.ResetDecoder();
            }
            audio_service_.SetAudioPowerInhibit(true);
            audio_service_.KeepCodecReadyForPlayback();
            ESP_LOGI(TAG, "Speaking: codec ready output=%d",
                     Board::GetInstance().GetAudioCodec()->output_enabled() ? 1 : 0);
            tts_speaking_state_ready_ = true;
            break;
        case kDeviceStateWifiConfiguring:
            audio_service_.EnableVoiceProcessing(false);
            audio_service_.EnableWakeWordDetection(true);
            // MultiNet/AFE wake word consumes internal SRAM. The captive portal HTML
            // and /scan both fail when free SRAM drops to hundreds of bytes
            // (httpd send EAGAIN, blank page, empty AP list). Keep it off until
            // WiFi is configured; Idle re-enables detection afterwards.
            audio_service_.EnableWakeWordDetection(false);
            break;
        default:
            // Do nothing
@@ -1459,19 +1777,31 @@
    tts_stop_waiting_ = false;
    tts_idle_waiting_ = false;
    tts_speaking_state_ready_ = false;
    if (GetDeviceState() == kDeviceStateSpeaking) {
        if (listening_mode_ == kListeningModeManualStop) {
            SetDeviceState(kDeviceStateIdle);
        } else {
            SetDeviceState(kDeviceStateListening);
        }
    }
    tts_playback_empty_ticks_ = 0;
    tts_stop_wait_ticks_ = 0;
    speaking_started_us_ = 0;
    audio_service_.SetAudioPowerInhibit(false);
    suppress_wake_until_listen_ = false;
    pending_detect_text_.clear();
    pending_detect_retries_left_ = 0;
    ResumeSdCardMp3AfterVoice();
    if (resume_sdcard_mp3_after_tts_ && !sdcard_mp3_stop_requested_) {
        resume_sdcard_mp3_after_tts_ = false;
        ResumeSdCardMp3();
    } else {
        resume_sdcard_mp3_after_tts_ = false;
    }
    if (GetDeviceState() == kDeviceStateSpeaking) {
        if (listening_mode_ == kListeningModeManualStop ||
            (IsSdCardMp3Playing() && !IsSdCardMp3Paused())) {
            SetDeviceState(kDeviceStateIdle);
        } else {
            listening_mode_ = GetDefaultListeningMode();
            SetListeningMode(listening_mode_);
        }
    }
    if (IsSdCardMp3Playing() && !IsSdCardMp3Paused()) {
        ApplyMp3CommandOnlyMic();
    }
    if (power_off_requested_) {
        power_off_requested_ = false;
@@ -1486,18 +1816,22 @@
void Application::ScheduleTtsAudio(std::shared_ptr<AudioStreamPacket> packet) {
    Schedule([this, packet]() {
        if (GetDeviceState() != kDeviceStateSpeaking || !tts_audio_expected_) {
        auto state = GetDeviceState();
        if (state != kDeviceStateSpeaking && state != kDeviceStateIdle &&
            state != kDeviceStateListening) {
            ESP_LOGW(TAG, "TTS audio ignored state=%d expected=%d",
                     static_cast<int>(state), tts_audio_expected_ ? 1 : 0);
            return;
        }
        if (!tts_speaking_state_ready_) {
            ScheduleTtsAudio(packet);
            return;
        }
        tts_audio_expected_ = true;
        tts_audio_received_ = true;
        tts_activity_generation_++;
        tts_playback_empty_ticks_ = 0;
        audio_service_.KeepCodecReadyForPlayback();
        auto audio_packet = std::make_unique<AudioStreamPacket>(std::move(*packet));
        audio_service_.PushPacketToDecodeQueue(std::move(audio_packet));
        ScheduleTtsIdleFallback();
        if (!audio_service_.PushPacketToDecodeQueue(std::move(audio_packet), true)) {
            ESP_LOGW(TAG, "TTS audio dropped, decode queue full");
        }
        if (tts_stop_pending_) {
            FinishTtsResponseAfterPlayback();
        }
@@ -1505,61 +1839,54 @@
}
void Application::FinishTtsResponseAfterPlayback() {
    if (tts_stop_waiting_) {
        return;
    }
    if (tts_audio_expected_ && !tts_audio_received_) {
        ScheduleTtsIdleFallback();
        return;
    }
    tts_stop_pending_ = true;
    tts_stop_waiting_ = true;
    xTaskCreate([](void* arg) {
        auto* app = static_cast<Application*>(arg);
        uint32_t generation = app->tts_activity_generation_;
        app->audio_service_.WaitForPlaybackQueueEmpty();
        vTaskDelay(pdMS_TO_TICKS(800));
        app->Schedule([app, generation]() {
            if (app->tts_activity_generation_ == generation) {
                app->FinishTtsResponse();
            } else {
                app->tts_stop_waiting_ = false;
                if (app->GetDeviceState() == kDeviceStateSpeaking) {
                    if (app->tts_stop_pending_) {
                        app->FinishTtsResponseAfterPlayback();
                    } else {
                        app->ScheduleTtsIdleFallback();
                    }
                }
            }
        });
        vTaskDelete(NULL);
    }, "tts_finish", 4096, this, 2, NULL);
    tts_stop_wait_ticks_ = 0;
}
void Application::ScheduleTtsIdleFallback() {
    if (tts_idle_waiting_ || tts_stop_waiting_) {
void Application::MaybeFinishTtsAfterPlayback() {
    if (GetDeviceState() != kDeviceStateSpeaking) {
        tts_playback_empty_ticks_ = 0;
        tts_stop_wait_ticks_ = 0;
        return;
    }
    tts_idle_waiting_ = true;
    xTaskCreate([](void* arg) {
        auto* app = static_cast<Application*>(arg);
        uint32_t generation = app->tts_activity_generation_;
        vTaskDelay(pdMS_TO_TICKS(1500));
        app->Schedule([app, generation]() {
            app->tts_idle_waiting_ = false;
            if (app->tts_activity_generation_ != generation || app->GetDeviceState() != kDeviceStateSpeaking) {
                return;
            }
            if (app->tts_audio_received_) {
                app->FinishTtsResponseAfterPlayback();
                return;
            }
            if (app->tts_stop_pending_ || app->tts_audio_expected_) {
                app->FinishTtsResponse();
            }
        });
        vTaskDelete(NULL);
    }, "tts_idle", 4096, this, 2, NULL);
    const bool playback_empty = audio_service_.IsPlaybackQueueEmpty();
    if (playback_empty) {
        tts_playback_empty_ticks_++;
    } else {
        tts_playback_empty_ticks_ = 0;
    }
    const int64_t elapsed_ms = speaking_started_us_ > 0
        ? (esp_timer_get_time() - speaking_started_us_) / 1000
        : 0;
    if (tts_stop_waiting_ || tts_stop_pending_) {
        tts_stop_wait_ticks_++;
        const int empty_needed = tts_audio_received_ ? 1 : 2;
        if (playback_empty && tts_playback_empty_ticks_ >= empty_needed) {
            ESP_LOGI(TAG, "TTS done, return to listen empty=%d wait=%d received=%d",
                     tts_playback_empty_ticks_, tts_stop_wait_ticks_, tts_audio_received_);
            FinishTtsResponse();
        } else if (!playback_empty && tts_stop_wait_ticks_ >= 30) {
            ESP_LOGW(TAG, "TTS playback stuck after stop, return to listen");
            FinishTtsResponse();
        }
        return;
    }
    // 欢迎词/长回答:stop 没到时,只在「一直没收到 opus」或「播完后长时间空队列」才回听。
    // 不要用说话墙钟 15s/队列空 2s:中英对话长句会在句间空隙或 15 秒处被掐断。
    if (!tts_audio_received_ && elapsed_ms >= 5000) {
        ESP_LOGW(TAG, "TTS stop missing, return to listen elapsed=%lldms received=0 empty=%d",
                 (long long)elapsed_ms, tts_playback_empty_ticks_);
        FinishTtsResponse();
    } else if (playback_empty && tts_audio_received_ && tts_playback_empty_ticks_ >= 8) {
        ESP_LOGW(TAG, "TTS stop missing after idle playback elapsed=%lldms empty=%d",
                 (long long)elapsed_ms, tts_playback_empty_ticks_);
        FinishTtsResponse();
    }
}
bool Application::CanEnterSleepMode() {
xiaozhi-esp32-2.2.6/main/application.h
@@ -105,6 +105,7 @@
     */
    void StopListening();
    void SendListenDetect(const std::string& text, bool open_if_idle = true);
    bool SendListenDetectNow(const std::string& text);
    void Reboot();
    void WakeWordInvoke(const std::string& wake_word);
@@ -125,6 +126,9 @@
    AecMode GetAecMode() const { return aec_mode_; }
    void PlaySound(const std::string_view& sound);
    AudioService& GetAudioService() { return audio_service_; }
    void ApplyMp3CommandOnlyMic();
    void ResumeListeningAfterMp3Clip();
    void CloseShadowingListenWindow();
    
    /**
     * Reset protocol resources (thread-safe)
@@ -154,10 +158,18 @@
    bool assets_version_checked_ = false;
    bool local_assets_applied_ = false;
    bool play_popup_on_listening_ = false;  // Flag to play popup sound after state changes to listening
    bool skip_playback_wait_on_listen_ = false;
    bool resume_sdcard_mp3_after_tts_ = false;
    bool resume_sdcard_mp3_after_voice_ = false;
    bool sdcard_mp3_stop_requested_ = false;
    bool opening_audio_channel_ = false;
    bool suppress_wake_until_listen_ = false;
    bool network_error_suppressed_ = false;
    std::string pending_detect_text_;
    int pending_detect_retry_ticks_ = 0;
    int pending_detect_retries_left_ = 0;
    int greeting_listen_wait_ticks_ = 0;
    int boot_wake_restore_ticks_ = 0;
    bool tts_audio_expected_ = false;
    bool tts_response_started_ = false;
    bool tts_audio_received_ = false;
@@ -166,6 +178,9 @@
    bool tts_idle_waiting_ = false;
    bool tts_speaking_state_ready_ = false;
    uint32_t tts_activity_generation_ = 0;
    int tts_playback_empty_ticks_ = 0;
    int tts_stop_wait_ticks_ = 0;
    int64_t speaking_started_us_ = 0;
    bool standby_requested_ = false;
    bool power_off_requested_ = false;
    std::atomic_bool power_off_standby_active_{false};
@@ -184,7 +199,9 @@
    void HandleActivationDoneEvent();
    void HandleWakeWordDetectedEvent();
    void ContinueOpenAudioChannel(ListeningMode mode);
    void ContinueOpenAudioChannelForDetect(const std::string& detect_text);
    void ContinueWakeWordInvoke(const std::string& wake_word);
    bool OpenAudioChannelWithRetry(int attempts);
    // Activation task (runs in background)
    void ActivationTask();
@@ -197,8 +214,8 @@
    void ShowActivationCode(const std::string& code, const std::string& message);
    void FinishTtsResponse();
    void FinishTtsResponseAfterPlayback();
    void MaybeFinishTtsAfterPlayback();
    void ScheduleTtsAudio(std::shared_ptr<AudioStreamPacket> packet);
    void ScheduleTtsIdleFallback();
    void PauseSdCardMp3ForVoice();
    void ResumeSdCardMp3AfterVoice();
    void SetListeningMode(ListeningMode mode);
xiaozhi-esp32-2.2.6/main/audio/audio_codec.cc
@@ -18,6 +18,12 @@
    Write(data.data(), data.size());
}
void AudioCodec::OutputPcm(const int16_t* data, int samples) {
    if (data && samples > 0) {
        Write(data, samples);
    }
}
bool AudioCodec::InputData(std::vector<int16_t>& data) {
    int samples = Read(data.data(), data.size());
    if (samples > 0) {
xiaozhi-esp32-2.2.6/main/audio/audio_codec.h
@@ -25,6 +25,7 @@
    virtual void EnableOutput(bool enable);
    virtual void OutputData(std::vector<int16_t>& data);
    void OutputPcm(const int16_t* data, int samples);
    virtual bool InputData(std::vector<int16_t>& data);
    virtual void Start();
xiaozhi-esp32-2.2.6/main/audio/audio_service.cc
@@ -1,6 +1,7 @@
#include "audio_service.h"
#include <esp_log.h>
#include <cstring>
#include <chrono>
#define RATE_CVT_CFG(_src_rate, _dest_rate, _channel)        \
    (esp_ae_rate_cvt_cfg_t)                                  \
@@ -554,6 +555,11 @@
        return;
    }
    const bool running = IsWakeWordRunning();
    if (enable == running && (!enable || wake_word_initialized_)) {
        return;
    }
    ESP_LOGD(TAG, "%s wake word detection", enable ? "Enabling" : "Disabling");
    if (enable) {
        if (!wake_word_initialized_) {
@@ -577,6 +583,15 @@
        wake_word_->Stop();
        xEventGroupClearBits(event_group_, AS_EVENT_WAKE_WORD_RUNNING);
    }
}
void AudioService::EnsureVoiceProcessorReady() {
    if (audio_processor_initialized_ || audio_processor_ == nullptr) {
        return;
    }
    ESP_LOGI(TAG, "Preload voice processor");
    audio_processor_->Initialize(codec_, OPUS_FRAME_DURATION_MS, models_list_);
    audio_processor_initialized_ = true;
}
void AudioService::EnableVoiceProcessing(bool enable) {
@@ -663,9 +678,14 @@
void AudioService::WaitForPlaybackQueueEmpty() {
    std::unique_lock<std::mutex> lock(audio_queue_mutex_);
    audio_queue_cv_.wait(lock, [this]() {
        return service_stopped_ || (audio_decode_queue_.empty() && audio_playback_queue_.empty());
    audio_queue_cv_.wait_for(lock, std::chrono::milliseconds(5000), [this]() {
        return service_stopped_ || (audio_decode_queue_.empty() && audio_playback_queue_.empty());
    });
}
bool AudioService::IsPlaybackQueueEmpty() {
    std::lock_guard<std::mutex> lock(audio_queue_mutex_);
    return audio_decode_queue_.empty() && audio_playback_queue_.empty();
}
void AudioService::ResetDecoder() {
@@ -682,7 +702,32 @@
    audio_queue_cv_.notify_all();
}
void AudioService::KeepCodecReadyForPlayback() {
    last_output_time_ = std::chrono::steady_clock::now();
    last_input_time_ = std::chrono::steady_clock::now();
    if (codec_ == nullptr) {
        return;
    }
    if (!codec_->output_enabled()) {
        esp_timer_stop(audio_power_timer_);
        esp_timer_start_periodic(audio_power_timer_, AUDIO_POWER_CHECK_INTERVAL_MS * 1000);
        codec_->EnableOutput(true);
    }
}
void AudioService::SetAudioPowerInhibit(bool inhibit) {
    audio_power_inhibit_ = inhibit;
    if (inhibit) {
        KeepCodecReadyForPlayback();
    }
}
void AudioService::CheckAndUpdateAudioPowerState() {
    if (audio_power_inhibit_) {
        last_input_time_ = std::chrono::steady_clock::now();
        last_output_time_ = last_input_time_;
        return;
    }
    auto now = std::chrono::steady_clock::now();
    auto input_elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(now - last_input_time_).count();
    auto output_elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(now - last_output_time_).count();
xiaozhi-esp32-2.2.6/main/audio/audio_service.h
@@ -117,12 +117,14 @@
    bool IsVoiceDetected() const { return voice_detected_; }
    bool IsIdle();
    void WaitForPlaybackQueueEmpty();
    bool IsPlaybackQueueEmpty();
    bool IsWakeWordRunning() const { return xEventGroupGetBits(event_group_) & AS_EVENT_WAKE_WORD_RUNNING; }
    bool IsAudioProcessorRunning() const { return xEventGroupGetBits(event_group_) & AS_EVENT_AUDIO_PROCESSOR_RUNNING; }
    bool IsAfeWakeWord();
    void EnableWakeWordDetection(bool enable);
    void EnableVoiceProcessing(bool enable);
    void EnsureVoiceProcessorReady();
    void EnableAudioTesting(bool enable);
    void EnableDeviceAec(bool enable);
@@ -133,6 +135,8 @@
    void PlaySound(const std::string_view& sound);
    bool ReadAudioData(std::vector<int16_t>& data, int sample_rate, int samples);
    void ResetDecoder();
    void KeepCodecReadyForPlayback();
    void SetAudioPowerInhibit(bool inhibit);
    void SetModelsList(srmodel_list_t* models_list);
private:
@@ -180,6 +184,7 @@
    bool voice_detected_ = false;
    bool service_stopped_ = true;
    bool audio_input_need_warmup_ = false;
    bool audio_power_inhibit_ = false;
    esp_timer_handle_t audio_power_timer_ = nullptr;
    std::chrono::steady_clock::time_point last_input_time_;
xiaozhi-esp32-2.2.6/main/audio/wake_words/afe_wake_word.cc
@@ -79,6 +79,13 @@
        multinet_->set_det_threshold(multinet_data_, 0.2f);
        esp_mn_commands_clear();
        esp_mn_commands_add(1, OFFLINE_COMMAND_WORD_PINYIN);
        esp_mn_commands_add(2, OFFLINE_REPEATER_WORD_PINYIN);
        esp_mn_commands_add(3, OFFLINE_START_REPEATER_WORD_PINYIN);
        esp_mn_commands_add(4, OFFLINE_EXIT_REPEATER_WORD_PINYIN);
        esp_mn_commands_add(5, OFFLINE_COMMAND_WORD_ALT_PINYIN);
        esp_mn_commands_add(6, OFFLINE_CHAT_WORD_PINYIN);
        esp_mn_commands_add(7, OFFLINE_EXIT_SHADOWING_WORD_PINYIN);
        esp_mn_commands_add(8, OFFLINE_CHAT_WORD_FULL_PINYIN);
        esp_mn_commands_update();
        multinet_->print_active_speech_commands(multinet_data_);
    } else {
@@ -106,7 +113,7 @@
        auto this_ = (AfeWakeWord*)arg;
        this_->AudioDetectionTask();
        vTaskDelete(NULL);
    }, "audio_detection", 4096, this, 3, nullptr);
    }, "audio_detection", 8192, this, 3, nullptr);
    return true;
}
@@ -187,10 +194,27 @@
                if (mn_state == ESP_MN_STATE_DETECTED) {
                    esp_mn_results_t *mn_result = multinet_->get_results(multinet_data_);
                    for (int i = 0; i < mn_result->num; i++) {
                        int command_id = mn_result->command_id[i];
                        const char *command_text = nullptr;
                        if (command_id == 1 || command_id == 5) {
                            command_text = OFFLINE_COMMAND_WORD;
                        } else if (command_id == 2) {
                            command_text = OFFLINE_REPEATER_WORD;
                        } else if (command_id == 3) {
                            command_text = OFFLINE_START_REPEATER_WORD;
                        } else if (command_id == 4) {
                            command_text = OFFLINE_EXIT_REPEATER_WORD;
                        } else if (command_id == 6) {
                            command_text = OFFLINE_CHAT_WORD;
                        } else if (command_id == 7) {
                            command_text = OFFLINE_EXIT_SHADOWING_WORD;
                        } else if (command_id == 8) {
                            command_text = OFFLINE_CHAT_WORD_FULL;
                        }
                        ESP_LOGI(TAG, "Offline command detected: command_id=%d, string=%s, prob=%f",
                            mn_result->command_id[i], mn_result->string, mn_result->prob[i]);
                        if (mn_result->command_id[i] == 1 && offline_command_detected_callback_) {
                            offline_command_detected_callback_(OFFLINE_COMMAND_WORD);
                            command_id, mn_result->string, mn_result->prob[i]);
                        if (command_text && offline_command_detected_callback_) {
                            offline_command_detected_callback_(command_text);
                        }
                    }
                    multinet_->clean(multinet_data_);
xiaozhi-esp32-2.2.6/main/audio/wake_words/custom_wake_word.cc
@@ -98,6 +98,13 @@
        commands_.push_back({CONFIG_CUSTOM_WAKE_WORD, CONFIG_CUSTOM_WAKE_WORD_DISPLAY, "wake"});
#endif
        commands_.push_back({OFFLINE_COMMAND_WORD_PINYIN, OFFLINE_COMMAND_WORD, "offline_command"});
        commands_.push_back({OFFLINE_COMMAND_WORD_ALT_PINYIN, OFFLINE_COMMAND_WORD_ALT, "offline_command"});
        commands_.push_back({OFFLINE_REPEATER_WORD_PINYIN, OFFLINE_REPEATER_WORD, "offline_command"});
        commands_.push_back({OFFLINE_START_REPEATER_WORD_PINYIN, OFFLINE_START_REPEATER_WORD, "offline_command"});
        commands_.push_back({OFFLINE_EXIT_REPEATER_WORD_PINYIN, OFFLINE_EXIT_REPEATER_WORD, "offline_command"});
        commands_.push_back({OFFLINE_EXIT_SHADOWING_WORD_PINYIN, OFFLINE_EXIT_SHADOWING_WORD, "offline_command"});
        commands_.push_back({OFFLINE_CHAT_WORD_PINYIN, OFFLINE_CHAT_WORD, "offline_command"});
        commands_.push_back({OFFLINE_CHAT_WORD_FULL_PINYIN, OFFLINE_CHAT_WORD_FULL, "offline_command"});
    } else {
        models_ = models_list;
        ParseWakenetModelConfig();
@@ -113,6 +120,13 @@
        commands_.push_back({BOARD_CUSTOM_WAKE_WORD_PINYIN, BOARD_CUSTOM_WAKE_WORD_DISPLAY, "wake"});
#endif
        commands_.push_back({OFFLINE_COMMAND_WORD_PINYIN, OFFLINE_COMMAND_WORD, "offline_command"});
        commands_.push_back({OFFLINE_COMMAND_WORD_ALT_PINYIN, OFFLINE_COMMAND_WORD_ALT, "offline_command"});
        commands_.push_back({OFFLINE_REPEATER_WORD_PINYIN, OFFLINE_REPEATER_WORD, "offline_command"});
        commands_.push_back({OFFLINE_START_REPEATER_WORD_PINYIN, OFFLINE_START_REPEATER_WORD, "offline_command"});
        commands_.push_back({OFFLINE_EXIT_REPEATER_WORD_PINYIN, OFFLINE_EXIT_REPEATER_WORD, "offline_command"});
        commands_.push_back({OFFLINE_EXIT_SHADOWING_WORD_PINYIN, OFFLINE_EXIT_SHADOWING_WORD, "offline_command"});
        commands_.push_back({OFFLINE_CHAT_WORD_PINYIN, OFFLINE_CHAT_WORD, "offline_command"});
        commands_.push_back({OFFLINE_CHAT_WORD_FULL_PINYIN, OFFLINE_CHAT_WORD_FULL, "offline_command"});
    }
    if (models_ == nullptr || models_->num == -1) {
@@ -194,9 +208,13 @@
        if (mn_state == ESP_MN_STATE_DETECTED) {
            esp_mn_results_t *mn_result = multinet_->get_results(multinet_model_data_);
            for (int i = 0; i < mn_result->num && running_; i++) {
                ESP_LOGI(TAG, "Custom wake word detected: command_id=%d, string=%s, prob=%f",
                        mn_result->command_id[i], mn_result->string, mn_result->prob[i]);
                auto& command = commands_[mn_result->command_id[i] - 1];
                int command_id = mn_result->command_id[i];
                ESP_LOGI(TAG, "Custom wake word detected: command_id=%d, string=%s, prob=%f",
                        command_id, mn_result->string, mn_result->prob[i]);
                if (command_id < 1 || command_id > static_cast<int>(commands_.size())) {
                    continue;
                }
                auto& command = commands_[command_id - 1];
                if (command.action == "wake") {
                    last_detected_wake_word_ = command.text;
                    running_ = false;
xiaozhi-esp32-2.2.6/main/boards/common/wifi_board.cc
@@ -10,6 +10,7 @@
#include <freertos/task.h>
#include <esp_network.h>
#include <esp_log.h>
#include <string>
#include <utility>
#include <font_awesome.h>
@@ -55,7 +56,16 @@
    // Initialize WiFi manager
    WifiManagerConfig config;
    config.ssid_prefix = "Xiaozhi";
    config.language = Lang::CODE;
    // Captive portal languageMap keys use BCP-47 hyphens (zh-CN). Android/WeChat
    // often report zh_CN; passing the underscore form leaves the <select> on the
    // first option and can crash loadAPList() via translations[lang].scanning.
    std::string language = Lang::CODE ? Lang::CODE : "zh-CN";
    for (char& ch : language) {
        if (ch == '_') {
            ch = '-';
        }
    }
    config.language = language;
    wifi_manager.Initialize(config);
    // Set unified event callback - forward to NetworkEvent with SSID data
@@ -186,6 +196,7 @@
    auto& wifi_manager = WifiManager::GetInstance();
    wifi_manager.StartConfigAp();
    ESP_LOGI(TAG, "WiFi config portal at %s", wifi_manager.GetApWebUrl().c_str());
    // Show config prompt after a short delay
    Application::GetInstance().Schedule([&wifi_manager]() {
xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/app_task.cc
@@ -1,6 +1,7 @@
#include "app_task.h"
#include "application.h"
#include "board.h"
#include "mqtt.h"
#include "system.h"
@@ -15,6 +16,7 @@
#define TAG "app_task"
#define APP_TASK_NVS_NS "app_task"
#define APP_TASK_NVS_KEY "pending"
#define APP_TASK_NVS_LAST_REP "last_rep"
#define APP_TASK_JSON_MAX 768
#define APP_TASK_ID_MAX 32
#define APP_TASK_SOURCE_MAX 24
@@ -23,6 +25,8 @@
static char s_current_task[APP_TASK_ID_MAX] = "conversation";
static char s_current_source[APP_TASK_SOURCE_MAX] = "default_chat";
static int s_current_priority = 0;
static bool s_finish_task_after_mp3 = true;
static float s_shadowing_listen_ratio = 1.2f;
typedef struct {
    const char *app_id;
@@ -34,7 +38,9 @@
static const app_task_skill_map_t k_task_skills[] = {
    {"chat", "conversation", {NULL, NULL, NULL, NULL}},
    {"english_learning", "repeater", {"self.mp3.play", NULL, NULL, NULL}},
    {"english_learning", "shadowing", {NULL, NULL, NULL, NULL}},
    {"english_translation", "live_translate", {"translate", NULL, NULL, NULL}},
    {"english_conversation", "bilingual_chat", {NULL, NULL, NULL, NULL}},
};
int Board_AppTask_SourcePriority(const char *source)
@@ -82,10 +88,292 @@
           (!task_id || task_id[0] == '\0' || strcmp(task_id, "conversation") == 0);
}
bool Board_IsShadowingTask(void)
{
    return strcmp(s_current_task, "shadowing") == 0;
}
bool Board_IsRepeaterTask(void)
{
    return strcmp(s_current_task, "repeater") == 0;
}
void Board_SetShadowingListenRatio(float ratio)
{
    if (ratio < 1.2f) {
        ratio = 1.2f;
    }
    if (ratio > 1.5f) {
        ratio = 1.5f;
    }
    s_shadowing_listen_ratio = ratio;
}
unsigned Board_ShadowingListenWindowMs(unsigned duration_ms)
{
    if (duration_ms == 0) {
        return 800;
    }
    unsigned wait_ms = static_cast<unsigned>(static_cast<float>(duration_ms) * s_shadowing_listen_ratio);
    if (wait_ms < 800) {
        wait_ms = 800;
    }
    return wait_ms;
}
static void copy_id(char *dest, size_t dest_size, const char *src, const char *fallback)
{
    const char *value = (src && src[0]) ? src : fallback;
    strlcpy(dest, value, dest_size);
}
void Board_SetActiveTask(const char *app_id, const char *task_id)
{
    if (!app_id || app_id[0] == '\0' || !task_id || task_id[0] == '\0') {
        return;
    }
    if (strcmp(s_current_app, app_id) == 0 && strcmp(s_current_task, task_id) == 0) {
        return;
    }
    copy_id(s_current_app, sizeof(s_current_app), app_id, "chat");
    copy_id(s_current_task, sizeof(s_current_task), task_id, "conversation");
    ESP_LOGI(TAG, "set active task %s/%s", s_current_app, s_current_task);
}
static void normalize_media_path(char *path)
{
    if (!path) {
        return;
    }
    for (char *p = path; *p; ++p) {
        if (*p == '\\') {
            *p = '/';
        }
    }
}
static const char *path_without_leading_slash(const char *path)
{
    if (!path) {
        return "";
    }
    return path[0] == '/' ? path + 1 : path;
}
static bool path_has_folder_prefix(const char *path, const char *prefix)
{
    if (!path || !prefix || prefix[0] == '\0') {
        return false;
    }
    char normalized[SDCARD_PATH_MAX_LEN];
    char needle[96];
    strlcpy(normalized, path, sizeof(normalized));
    strlcpy(needle, prefix, sizeof(needle));
    normalize_media_path(normalized);
    normalize_media_path(needle);
    const char *hay = path_without_leading_slash(normalized);
    const char *pre = path_without_leading_slash(needle);
    size_t n = strlen(pre);
    if (n == 0 || strncmp(hay, pre, n) != 0) {
        return false;
    }
    return hay[n] == '\0' || hay[n] == '/';
}
static bool path_belongs_to_current_task(const char *path)
{
    if (is_chat_task(s_current_app, s_current_task)) {
        return true;
    }
    char prefix[96];
    snprintf(prefix, sizeof(prefix), "/%s/%s/", s_current_app, s_current_task);
    return path_has_folder_prefix(path, prefix);
}
static bool basename_is_full_mp3(const char *name)
{
    if (!name || name[0] == '\0') {
        return false;
    }
    const char *base = strrchr(name, '/');
    base = base ? base + 1 : name;
    if (strcasecmp(base, "full.mp3") == 0) {
        return true;
    }
    size_t n = strlen(base);
    return n > 9 && strcasecmp(base + n - 9, "_full.mp3") == 0;
}
void Board_BindTaskFromMediaPath(const char *path)
{
    // 课文文件可能都在 /english_learning/shadowing/ 下。任务只由 MCP app_id/task_id 决定。
    (void)path;
}
static bool task_owns_play_audio(const char *app_id, const char *task_id)
{
    const app_task_skill_map_t *spec = find_task_spec(app_id, task_id);
    if (!spec) {
        return false;
    }
    for (int i = 0; i < 4 && spec->skills[i]; ++i) {
        if (strcmp(spec->skills[i], "self.mp3.play") == 0) {
            return true;
        }
    }
    return false;
}
static bool first_sd_mp3_name(char *name, size_t name_size, const char *prefix, bool prefer_full)
{
    if (!name || name_size == 0) {
        return false;
    }
    name[0] = '\0';
    const char *list = SDCard_Get_MP3_List();
    if (!list || list[0] == '\0') {
        return false;
    }
    cJSON *root = cJSON_Parse(list);
    if (!root) {
        return false;
    }
    const cJSON *files = cJSON_GetObjectItem(root, "files");
    if (!cJSON_IsArray(files)) {
        cJSON_Delete(root);
        return false;
    }
    const cJSON *best = NULL;
    bool best_full = false;
    const cJSON *file = NULL;
    cJSON_ArrayForEach(file, files) {
        const cJSON *item = cJSON_GetObjectItem(file, "name");
        if (!cJSON_IsString(item) || !item->valuestring || !item->valuestring[0]) {
            continue;
        }
        if (prefix && prefix[0] && !path_has_folder_prefix(item->valuestring, prefix)) {
            continue;
        }
        bool is_full = basename_is_full_mp3(item->valuestring);
        if (!best) {
            best = item;
            best_full = is_full;
            continue;
        }
        if (prefer_full && is_full && !best_full) {
            best = item;
            best_full = true;
            continue;
        }
        if ((!prefer_full || is_full == best_full) &&
            strcmp(item->valuestring, best->valuestring) < 0) {
            best = item;
            best_full = is_full;
        }
    }
    bool ok = best != NULL;
    if (ok) {
        strlcpy(name, best->valuestring, name_size);
    }
    cJSON_Delete(root);
    return ok;
}
void Board_SetFinishTaskAfterMp3(bool finish)
{
    s_finish_task_after_mp3 = finish;
}
void Board_RememberRepeaterPlayback(const char *name, float speed, int repeat, bool power_off_after)
{
    if (!name || name[0] == '\0') {
        return;
    }
    if (strcmp(s_current_task, "repeater") != 0) {
        return;
    }
    cJSON *root = cJSON_CreateObject();
    if (!root) {
        return;
    }
    cJSON_AddStringToObject(root, "name", name);
    cJSON_AddNumberToObject(root, "speed", speed);
    cJSON_AddNumberToObject(root, "repeat", repeat < 1 ? 1 : repeat);
    cJSON_AddBoolToObject(root, "power_off_after", power_off_after);
    char *printed = cJSON_PrintUnformatted(root);
    cJSON_Delete(root);
    if (!printed) {
        return;
    }
    nvs_handle_t handle;
    if (nvs_open(APP_TASK_NVS_NS, NVS_READWRITE, &handle) == ESP_OK) {
        if (nvs_set_str(handle, APP_TASK_NVS_LAST_REP, printed) == ESP_OK) {
            nvs_commit(handle);
        }
        nvs_close(handle);
    }
    free(printed);
}
bool Board_ResolveRepeaterPlayback(char *name, size_t name_size, float *speed, int *repeat, bool *power_off_after)
{
    if (!name || name_size == 0) {
        return false;
    }
    if (name[0] != '\0') {
        if (speed) {
            *speed = SDCard_Clamp_MP3_Speed(*speed);
        }
        return true;
    }
    nvs_handle_t handle;
    if (nvs_open(APP_TASK_NVS_NS, NVS_READONLY, &handle) == ESP_OK) {
        char buf[APP_TASK_JSON_MAX];
        size_t length = sizeof(buf);
        if (nvs_get_str(handle, APP_TASK_NVS_LAST_REP, buf, &length) == ESP_OK) {
            cJSON *root = cJSON_Parse(buf);
            if (root) {
                const cJSON *name_item = cJSON_GetObjectItem(root, "name");
                const cJSON *repeat_item = cJSON_GetObjectItem(root, "repeat");
                const cJSON *power_item = cJSON_GetObjectItem(root, "power_off_after");
                if (cJSON_IsString(name_item) && name_item->valuestring && name_item->valuestring[0]) {
                    strlcpy(name, name_item->valuestring, name_size);
                    if (repeat && cJSON_IsNumber(repeat_item)) {
                        *repeat = repeat_item->valueint;
                    }
                    if (power_off_after) {
                        if (cJSON_IsBool(power_item)) {
                            *power_off_after = cJSON_IsTrue(power_item);
                        } else if (cJSON_IsNumber(power_item)) {
                            *power_off_after = power_item->valueint != 0;
                        }
                    }
                }
                cJSON_Delete(root);
            }
        }
        nvs_close(handle);
    }
    if (name[0] != '\0' && !path_belongs_to_current_task(name)) {
        ESP_LOGW(TAG, "discard last_rep from other task: %s", name);
        name[0] = '\0';
    }
    if (name[0] == '\0') {
        char prefix[96];
        snprintf(prefix, sizeof(prefix), "/%s/%s/lessons", s_current_app, s_current_task);
        bool prefer_full = strcmp(s_current_task, "repeater") == 0;
        if (!first_sd_mp3_name(name, name_size, prefix, prefer_full)) {
            if (is_chat_task(s_current_app, s_current_task)) {
                first_sd_mp3_name(name, name_size, NULL, false);
            }
        }
    }
    if (speed) {
        *speed = SDCard_Clamp_MP3_Speed(*speed);
    }
    return name[0] != '\0';
}
esp_err_t Board_SavePendingTask(const char *app_id, const char *task_id, const char *params_json,
@@ -195,6 +483,7 @@
                           const char *params_json, const char *source)
{
    if (!skill || skill[0] == '\0') {
        // 上报 [app_task]。SendListenDetect 会开通道但保持 idle,不开 listening/AFE。
        notify_server(app_id, task_id, source, true);
        return;
    }
@@ -214,7 +503,7 @@
                    strlcpy(name, name_item->valuestring, sizeof(name));
                }
                if (cJSON_IsNumber(speed_item)) {
                    speed = static_cast<float>(speed_item->valuedouble);
                    speed = SDCard_Clamp_MP3_Speed(static_cast<float>(speed_item->valuedouble));
                }
                if (cJSON_IsNumber(repeat_item)) {
                    repeat = repeat_item->valueint;
@@ -227,19 +516,29 @@
                cJSON_Delete(params);
            }
        }
        if (name[0] == '\0') {
            ESP_LOGW(TAG, "self.mp3.play missing name");
        if (!Board_ResolveRepeaterPlayback(name, sizeof(name), &speed, &repeat, &power_off_after)) {
            ESP_LOGW(TAG, "self.mp3.play has no last lesson and no SD MP3");
            notify_server(app_id, task_id, source, true);
            return;
        }
        if (repeat < 1) {
            repeat = 1;
        }
        speed = SDCard_Clamp_MP3_Speed(speed);
        Board_SetFinishTaskAfterMp3(true);
        // 有网的语音入口:先让 Server 播报进场词,音频由 MCP 在 TTS 结束后再播。
        if (source && strcmp(source, "voice") == 0 && Board::GetInstance().IsNetworkReady()) {
            Board_RememberRepeaterPlayback(name, speed, repeat, power_off_after);
            notify_server(app_id, task_id, source, true);
            return;
        }
        esp_err_t err = SDCard_Play_MP3_With_Speed_Repeat(name, speed, repeat, power_off_after);
        if (err != ESP_OK) {
            ESP_LOGW(TAG, "self.mp3.play failed: %s", esp_err_to_name(err));
            Board_OnSkillPlaybackFinished(true, false);
            Board_OnSkillPlaybackFinished(true, false, 0);
            return;
        }
        Board_RememberRepeaterPlayback(name, speed, repeat, power_off_after);
        notify_server(app_id, task_id, source, false);
        return;
    }
@@ -256,18 +555,31 @@
        return ESP_ERR_INVALID_ARG;
    }
    const char *src = (source && source[0]) ? source : "mqtt_immediate";
    const char *tid = (task_id && task_id[0]) ? task_id : "conversation";
    const char *tid = (task_id && task_id[0]) ? task_id : NULL;
    const app_task_skill_map_t *spec = find_task_spec(app_id, tid);
    if (!spec) {
        ESP_LOGW(TAG, "unknown app/task: %s/%s", app_id, tid);
        ESP_LOGW(TAG, "unknown app/task: %s/%s", app_id, tid ? tid : "");
        return ESP_ERR_NOT_FOUND;
    }
    tid = spec->task_id;
    int new_priority = Board_AppTask_SourcePriority(src);
    if (new_priority < s_current_priority) {
        ESP_LOGW(TAG, "reject %s/%s source=%s prio %d < current %d",
                 app_id, tid, src, new_priority, s_current_priority);
        return ESP_ERR_INVALID_STATE;
    }
    if (strcmp(s_current_app, app_id) == 0 && strcmp(s_current_task, tid) == 0 &&
        strcmp(app_id, "chat") == 0 && strcmp(src, "voice") == 0) {
        if (SDCard_Is_Playing()) {
            ESP_LOGI(TAG, "stop leftover MP3 while already in chat");
            preempt_current_skills();
        }
        // 跟读由 MCP 起播时本地仍可能是 chat。「退出跟读」必须通知 Server 取消听窗。
        // 不要在这里 ResumeListening:会 ResetDecoder、提前开 AFE,欢迎词/结束播报会被清掉。
        notify_server("chat", "conversation", "voice", true);
        return ESP_OK;
    }
    preempt_current_skills();
@@ -288,15 +600,107 @@
    return ESP_OK;
}
void Board_OnSkillPlaybackFinished(bool stopped, bool power_off_requested)
void Board_OnSkillPlaybackFinished(bool stopped, bool power_off_requested, unsigned duration_ms)
{
    (void)power_off_requested;
    ESP_LOGI(TAG, "skill playback %s", stopped ? "stopped" : "finished");
    copy_id(s_current_app, sizeof(s_current_app), "chat", "chat");
    copy_id(s_current_task, sizeof(s_current_task), "conversation", "conversation");
    copy_id(s_current_source, sizeof(s_current_source), "default_chat", "default_chat");
    s_current_priority = 0;
    Board_SavePendingTask("chat", "conversation", "{}", "default_chat", NULL);
    // MP3 任务栈在 PSRAM,不能在这里 nvs_set_str / 改状态机。
    Application::GetInstance().Schedule([stopped, duration_ms]() {
        ESP_LOGI(TAG, "skill playback %s duration_ms=%u task=%s/%s",
                 stopped ? "stopped" : "finished", duration_ms, s_current_app, s_current_task);
        char app_id[APP_TASK_ID_MAX];
        char task_id[APP_TASK_ID_MAX];
        copy_id(app_id, sizeof(app_id), s_current_app, "chat");
        copy_id(task_id, sizeof(task_id), s_current_task, "conversation");
        const bool shadowing = strcmp(task_id, "shadowing") == 0;
        const bool repeater = strcmp(task_id, "repeater") == 0;
        // 复读清单是否结束由 Server 决定。设备不要在此写 NVS 切回 chat。
        const bool end_task = !shadowing && !repeater &&
            task_owns_play_audio(app_id, task_id) && s_finish_task_after_mp3;
        if (end_task) {
            copy_id(s_current_app, sizeof(s_current_app), "chat", "chat");
            copy_id(s_current_task, sizeof(s_current_task), "conversation", "conversation");
            copy_id(s_current_source, sizeof(s_current_source), "default_chat", "default_chat");
            s_current_priority = 0;
            Board_SavePendingTask("chat", "conversation", "{}", "default_chat", NULL);
        }
        char detect[208];
        snprintf(detect, sizeof(detect), "[skill_event]audio.%s|%s|%s|%u",
                 stopped ? "stopped" : "completed", app_id, task_id, duration_ms);
        const bool open_mic = !stopped && shadowing;
        // 听窗从课文一停就开始。不要等开麦/AFE 初始化后再上报 completed。
        if (!Application::GetInstance().SendListenDetectNow(detect)) {
            Application::GetInstance().SendListenDetect(detect, false);
        }
        if (open_mic) {
            Application::GetInstance().GetAudioService().ResetDecoder();
            Application::GetInstance().ResumeListeningAfterMp3Clip();
        } else if (repeater && !stopped) {
            // 清单已播完,马上要播「复读结束」:保持功放,不要再 ResetDecoder。
            Application::GetInstance().GetAudioService().KeepCodecReadyForPlayback();
            Application::GetInstance().GetAudioService().SetAudioPowerInhibit(true);
        } else if (!repeater) {
            Application::GetInstance().GetAudioService().ResetDecoder();
        }
    });
}
void Board_OnShadowingClipFinished(unsigned duration_ms)
{
    Application::GetInstance().Schedule([duration_ms]() {
        ESP_LOGI(TAG, "shadowing clip finished duration_ms=%u task=%s/%s",
                 duration_ms, s_current_app, s_current_task);
        char app_id[APP_TASK_ID_MAX];
        char task_id[APP_TASK_ID_MAX];
        copy_id(app_id, sizeof(app_id), s_current_app, "chat");
        copy_id(task_id, sizeof(task_id), s_current_task, "shadowing");
        char detect[208];
        snprintf(detect, sizeof(detect), "[skill_event]audio.completed|%s|%s|%u",
                 app_id, task_id, duration_ms);
        if (!Application::GetInstance().SendListenDetectNow(detect)) {
            Application::GetInstance().SendListenDetect(detect, false);
        }
        Application::GetInstance().ResumeListeningAfterMp3Clip();
    });
}
void Board_OnShadowingPlaylistFinished(void)
{
    Application::GetInstance().Schedule([]() {
        ESP_LOGI(TAG, "shadowing playlist finished task=%s/%s", s_current_app, s_current_task);
        char app_id[APP_TASK_ID_MAX];
        char task_id[APP_TASK_ID_MAX];
        copy_id(app_id, sizeof(app_id), s_current_app, "chat");
        copy_id(task_id, sizeof(task_id), s_current_task, "shadowing");
        char detect[208];
        snprintf(detect, sizeof(detect), "[skill_event]audio.playlist_completed|%s|%s",
                 app_id, task_id);
        if (!Application::GetInstance().SendListenDetectNow(detect)) {
            ESP_LOGW(TAG, "playlist_completed not sent, return to chat locally");
            copy_id(s_current_app, sizeof(s_current_app), "chat", "chat");
            copy_id(s_current_task, sizeof(s_current_task), "conversation", "conversation");
            copy_id(s_current_source, sizeof(s_current_source), "default_chat", "default_chat");
            s_current_priority = 0;
            Board_SavePendingTask("chat", "conversation", "{}", "default_chat", NULL);
            auto &app = Application::GetInstance();
            app.GetAudioService().SetAudioPowerInhibit(false);
            app.GetAudioService().EnableVoiceProcessing(false);
            app.GetAudioService().EnableWakeWordDetection(true);
            if (app.GetDeviceState() == kDeviceStateListening ||
                app.GetDeviceState() == kDeviceStateConnecting) {
                app.SetDeviceState(kDeviceStateIdle);
            }
            return;
        }
        Application::GetInstance().GetAudioService().KeepCodecReadyForPlayback();
        Application::GetInstance().GetAudioService().SetAudioPowerInhibit(true);
    });
}
void Board_ResolveBootOrRestore(bool from_alarm_wake)
@@ -313,9 +717,12 @@
        return;
    }
    if (from_alarm_wake && strcmp(source, "schedule") == 0 && !is_chat_task(app_id, task_id)) {
        ESP_LOGI(TAG, "alarm restore runs pending %s/%s", app_id, task_id);
        Board_StartAppTask(app_id, task_id, params, "schedule");
    const bool scheduled = strcmp(source, "schedule") == 0 || strcmp(source, "mqtt_immediate") == 0;
    const bool offline = !Board::GetInstance().IsNetworkReady();
    if (!is_chat_task(app_id, task_id) && (from_alarm_wake || scheduled || offline)) {
        ESP_LOGI(TAG, "restore pending %s/%s source=%s offline=%d",
                 app_id, task_id, source, offline ? 1 : 0);
        Board_StartAppTask(app_id, task_id, params, source);
        return;
    }
@@ -328,6 +735,10 @@
        ESP_LOGI(TAG, "ignore voice_entry, current priority=%d", s_current_priority);
        return;
    }
    if (!Board::GetInstance().IsNetworkReady()) {
        ESP_LOGW(TAG, "voice_entry skipped, network not ready; say 复读机 to play locally");
        return;
    }
    Application::GetInstance().SendListenDetect("[voice_entry]", true);
}
@@ -336,36 +747,66 @@
    if (!command || command[0] == '\0') {
        return false;
    }
    if (strcmp(command, "切换模式") == 0 || strcmp(command, "qie huan mo shi") == 0) {
    const bool speaking = Application::GetInstance().GetDeviceState() == kDeviceStateSpeaking;
    if (Board_Is_Mode_Switch_Command(command)) {
        Board_EnterVoiceEntry();
        return true;
    }
    if (strstr(command, "我们来聊天吧") || strstr(command, "聊会天吧")) {
        if (s_current_priority >= 2) {
            ESP_LOGI(TAG, "ignore voice chat switch, priority=%d", s_current_priority);
            return true;
        }
    if (Board_Is_Chat_Return_Command(command)) {
        s_current_priority = 0;
        Board_StartAppTask("chat", "conversation", "{}", "voice");
        return true;
    }
    if (strstr(command, "打开翻译") || strstr(command, "开始翻译")) {
    if (speaking) {
        if (Board_Is_App_Launch_Command(command)) {
            ESP_LOGI(TAG, "ignore app launch while TTS is speaking: %s", command);
            return true;
        }
        return false;
    }
    if (strcmp(command, OFFLINE_REPEATER_WORD) == 0 ||
        strcmp(command, OFFLINE_START_REPEATER_WORD) == 0 ||
        strstr(command, "开始复读") || strstr(command, "复读机")) {
        if (s_current_priority >= 2) {
            return true;
        }
        Board_StartAppTask("english_translation", "live_translate", "{}", "voice");
        return true;
    }
    if (strstr(command, "开始复读") || strstr(command, "打开复读")) {
        if (s_current_priority >= 2) {
            return true;
        }
        Board_EnterVoiceEntry();
        return true;
    }
    if (strstr(command, "数字背诵") || strstr(command, "成语接龙") || strstr(command, "口算")) {
        ESP_LOGI(TAG, "reserved voice slot: %s", command);
        Board_EnterVoiceEntry();
        Board_StartAppTask("english_learning", "repeater", "{}", "voice");
        return true;
    }
    return false;
}
static bool layout_id_is_safe(const char *id)
{
    if (!id || id[0] == '\0' || strlen(id) >= APP_TASK_ID_MAX) {
        return false;
    }
    for (const char *p = id; *p; ++p) {
        if (!((*p >= 'a' && *p <= 'z') || (*p >= 'A' && *p <= 'Z') || (*p >= '0' && *p <= '9') ||
              *p == '_' || *p == '-')) {
            return false;
        }
    }
    return true;
}
esp_err_t Board_EnsureAppLayout(const char *app_id, const char *task_id)
{
    if (!layout_id_is_safe(app_id)) {
        return ESP_ERR_INVALID_ARG;
    }
    char path[SDCARD_PATH_MAX_LEN];
    int written;
    if (task_id && task_id[0]) {
        if (!layout_id_is_safe(task_id)) {
            return ESP_ERR_INVALID_ARG;
        }
        written = snprintf(path, sizeof(path), "/%s/%s/lessons", app_id, task_id);
    } else {
        written = snprintf(path, sizeof(path), "/%s", app_id);
    }
    if (written <= 0 || (size_t)written >= sizeof(path)) {
        return ESP_ERR_INVALID_SIZE;
    }
    return SDCard_Ensure_Business_Dir(path);
}
xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/app_task.h
@@ -3,6 +3,7 @@
#include "esp_err.h"
#include <stdbool.h>
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
@@ -14,8 +15,20 @@
void Board_ResolveBootOrRestore(bool from_alarm_wake);
void Board_EnterVoiceEntry(void);
bool Board_Handle_Voice_Command(const char *command);
void Board_OnSkillPlaybackFinished(bool stopped, bool power_off_requested);
void Board_OnSkillPlaybackFinished(bool stopped, bool power_off_requested, unsigned duration_ms);
void Board_OnShadowingClipFinished(unsigned duration_ms);
void Board_OnShadowingPlaylistFinished(void);
void Board_SetShadowingListenRatio(float ratio);
unsigned Board_ShadowingListenWindowMs(unsigned duration_ms);
bool Board_IsShadowingTask(void);
bool Board_IsRepeaterTask(void);
void Board_BindTaskFromMediaPath(const char *path);
void Board_SetActiveTask(const char *app_id, const char *task_id);
int Board_AppTask_CurrentPriority(void);
bool Board_ResolveRepeaterPlayback(char *name, size_t name_size, float *speed, int *repeat, bool *power_off_after);
void Board_RememberRepeaterPlayback(const char *name, float speed, int repeat, bool power_off_after);
void Board_SetFinishTaskAfterMp3(bool finish);
esp_err_t Board_EnsureAppLayout(const char *app_id, const char *task_id);
#ifdef __cplusplus
}
xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/mp3_player.cc
@@ -6,7 +6,9 @@
#include "application.h"
#include <esp_log.h>
#include <esp_heap_caps.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <esp_audio_simple_dec.h>
@@ -18,32 +20,60 @@
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <vector>
#define TAG "sdcard_mp3"
#define MP3_READ_BUFFER_SIZE 512
#define MP3_OUTPUT_BUFFER_SIZE 4096
#define MP3_PLAYER_STACK_SIZE 8192
#define MP3_PLAYER_PRIORITY 5
#define MP3_PLAYER_PRIORITY 2
#define MP3_PSRAM_CACHE_MAX (768 * 1024)
typedef struct {
    char path[SDCARD_PATH_MAX_LEN];
    float speed;
    int repeat;
    bool power_off_after;
    int count;
    sdcard_mp3_playlist_item_t items[SDCARD_MP3_PLAYLIST_MAX];
} mp3_player_task_arg_t;
static TaskHandle_t mp3_player_task_handle;
static StackType_t *mp3_player_stack;
static StaticTask_t *mp3_player_tcb;
static volatile bool mp3_stop_requested;
static volatile bool mp3_pause_requested;
static volatile int mp3_repeat_left;
static TickType_t s_mp3_audible_started;
static SemaphoreHandle_t s_shadowing_listen_closed;
static std::vector<int16_t> convert_channels(const int16_t *input, size_t frame_count, uint8_t src_channels, uint8_t dst_channels)
static int16_t *s_pcm_a = nullptr;
static int16_t *s_pcm_b = nullptr;
static size_t s_pcm_a_cap = 0;
static size_t s_pcm_b_cap = 0;
static int16_t *ensure_psram_samples(int16_t **buf, size_t *cap, size_t samples)
{
    std::vector<int16_t> output(frame_count * dst_channels);
    if (samples == 0) {
        return *buf;
    }
    if (*buf && *cap >= samples) {
        return *buf;
    }
    int16_t *p = static_cast<int16_t *>(
        heap_caps_realloc(*buf, samples * sizeof(int16_t), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT));
    if (!p) {
        return nullptr;
    }
    *buf = p;
    *cap = samples;
    return p;
}
static bool convert_channels(const int16_t *input, size_t frame_count, uint8_t src_channels, uint8_t dst_channels,
                             int16_t *output)
{
    if (!input || !output || frame_count == 0 || src_channels == 0 || dst_channels == 0) {
        return false;
    }
    if (src_channels == dst_channels) {
        std::memcpy(output.data(), input, output.size() * sizeof(int16_t));
        return output;
        std::memcpy(output, input, frame_count * dst_channels * sizeof(int16_t));
        return true;
    }
    for (size_t frame = 0; frame < frame_count; ++frame) {
@@ -63,24 +93,47 @@
            }
        }
    }
    return output;
    return true;
}
static bool is_supported_speed(float speed)
static size_t stretch_for_speed(const int16_t *input, size_t in_frames, uint8_t channels, float speed,
                                int16_t *output, size_t out_cap_frames)
{
    const float supported_speeds[] = {0.5f, 0.75f, 1.0f, 1.25f, 1.5f, 2.0f};
    for (size_t i = 0; i < sizeof(supported_speeds) / sizeof(supported_speeds[0]); ++i) {
        float diff = speed - supported_speeds[i];
        if (diff < 0) {
            diff = -diff;
    if (!input || !output || channels == 0 || in_frames == 0) {
        return 0;
    }
    if (speed >= 0.999f && speed <= 1.001f) {
        size_t n = in_frames * channels;
        std::memcpy(output, input, n * sizeof(int16_t));
        return in_frames;
    }
    size_t out_frames = static_cast<size_t>(static_cast<double>(in_frames) / static_cast<double>(speed) + 0.5);
    if (out_frames < 1) {
        out_frames = 1;
    }
    if (out_frames > out_cap_frames) {
        out_frames = out_cap_frames;
    }
    for (size_t i = 0; i < out_frames; ++i) {
        const double src = static_cast<double>(i) * static_cast<double>(speed);
        size_t i0 = static_cast<size_t>(src);
        size_t i1 = i0 + 1;
        if (i0 >= in_frames) {
            i0 = in_frames - 1;
        }
        if (diff < 0.001f) {
            return true;
        if (i1 >= in_frames) {
            i1 = in_frames - 1;
        }
        const float t = static_cast<float>(src - static_cast<double>(i0));
        for (uint8_t c = 0; c < channels; ++c) {
            const float a = input[i0 * channels + c];
            const float b = input[i1 * channels + c];
            output[i * channels + c] = static_cast<int16_t>(a + (b - a) * t);
        }
    }
    return false;
    return out_frames;
}
static bool wait_if_paused()
{
@@ -90,8 +143,68 @@
    return !mp3_stop_requested;
}
static void wait_for_sd_dma(void)
{
    for (int i = 0; i < 20; ++i) {
        size_t largest = heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA);
        if (largest >= 4096) {
            return;
        }
        ESP_LOGW(TAG, "wait SD DMA largest=%u try=%d", (unsigned)largest, i);
        vTaskDelay(pdMS_TO_TICKS(40));
    }
}
static uint8_t *load_mp3_to_psram(const char *path, size_t *out_len)
{
    *out_len = 0;
    wait_for_sd_dma();
    FILE *fp = fopen(path, "rb");
    if (!fp) {
        return nullptr;
    }
    size_t cap = 32 * 1024;
    uint8_t *buf = static_cast<uint8_t *>(
        heap_caps_malloc(cap, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT));
    if (!buf) {
        fclose(fp);
        return nullptr;
    }
    size_t n = 0;
    while (n < MP3_PSRAM_CACHE_MAX) {
        if (n == cap) {
            size_t ncap = cap * 2;
            if (ncap > MP3_PSRAM_CACHE_MAX) {
                ncap = MP3_PSRAM_CACHE_MAX;
            }
            uint8_t *grown = static_cast<uint8_t *>(
                heap_caps_realloc(buf, ncap, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT));
            if (!grown) {
                break;
            }
            buf = grown;
            cap = ncap;
        }
        size_t got = fread(buf + n, 1, cap - n, fp);
        if (got == 0) {
            break;
        }
        n += got;
    }
    const bool at_eof = feof(fp) != 0;
    fclose(fp);
    if (n == 0 || !at_eof) {
        ESP_LOGW(TAG, "MP3 PSRAM cache miss n=%u eof=%d: %s", (unsigned)n, at_eof ? 1 : 0, path);
        heap_caps_free(buf);
        return nullptr;
    }
    *out_len = n;
    ESP_LOGI(TAG, "cached MP3 in PSRAM %u bytes: %s", (unsigned)n, path);
    return buf;
}
static bool output_pcm(AudioCodec *codec, esp_ae_rate_cvt_handle_t rate_cvt_handle, const uint8_t *buffer,
    uint32_t size, uint8_t src_channels, uint32_t src_rate)
    uint32_t size, uint8_t src_channels, uint32_t src_rate, float speed)
{
    uint8_t dst_channels = codec->output_channels();
    uint32_t dst_rate = codec->output_sample_rate();
@@ -100,65 +213,189 @@
        return true;
    }
    auto channel_converted = convert_channels(reinterpret_cast<const int16_t *>(buffer), frame_count, src_channels, dst_channels);
    int16_t *converted = ensure_psram_samples(&s_pcm_a, &s_pcm_a_cap, frame_count * dst_channels);
    if (!converted) {
        ESP_LOGE(TAG, "mp3 pcm alloc failed");
        return false;
    }
    if (!convert_channels(reinterpret_cast<const int16_t *>(buffer), frame_count, src_channels, dst_channels, converted)) {
        return false;
    }
    const int16_t *play = converted;
    size_t play_frames = frame_count;
    if (speed < 0.999f || speed > 1.001f) {
        size_t out_frames = static_cast<size_t>(static_cast<double>(frame_count) / static_cast<double>(speed) + 0.5);
        if (out_frames < 1) {
            out_frames = 1;
        }
        int16_t *stretched = ensure_psram_samples(&s_pcm_b, &s_pcm_b_cap, out_frames * dst_channels);
        if (!stretched) {
            ESP_LOGE(TAG, "mp3 stretch alloc failed");
            return false;
        }
        play_frames = stretch_for_speed(converted, frame_count, dst_channels, speed, stretched, out_frames);
        play = stretched;
    }
    if (play_frames == 0) {
        return true;
    }
    if (src_rate == dst_rate || !rate_cvt_handle) {
        codec->OutputData(channel_converted);
        codec->OutputPcm(play, static_cast<int>(play_frames * dst_channels));
        if (s_mp3_audible_started == 0) {
            s_mp3_audible_started = xTaskGetTickCount();
        }
        return true;
    }
    uint32_t max_out_frames = 0;
    if (esp_ae_rate_cvt_get_max_out_sample_num(rate_cvt_handle, frame_count, &max_out_frames) != ESP_AE_ERR_OK) {
    if (esp_ae_rate_cvt_get_max_out_sample_num(rate_cvt_handle, play_frames, &max_out_frames) != ESP_AE_ERR_OK) {
        ESP_LOGE(TAG, "get resample output size failed");
        return false;
    }
    std::vector<int16_t> resampled(max_out_frames * dst_channels);
    int16_t **out_slot = (play == s_pcm_b) ? &s_pcm_a : &s_pcm_b;
    size_t *out_cap = (play == s_pcm_b) ? &s_pcm_a_cap : &s_pcm_b_cap;
    int16_t *resampled = ensure_psram_samples(out_slot, out_cap, max_out_frames * dst_channels);
    if (!resampled) {
        ESP_LOGE(TAG, "mp3 resample alloc failed");
        return false;
    }
    uint32_t actual_out_frames = max_out_frames;
    if (esp_ae_rate_cvt_process(rate_cvt_handle, reinterpret_cast<esp_ae_sample_t>(channel_converted.data()), frame_count,
            reinterpret_cast<esp_ae_sample_t>(resampled.data()), &actual_out_frames) != ESP_AE_ERR_OK) {
    if (esp_ae_rate_cvt_process(rate_cvt_handle, reinterpret_cast<esp_ae_sample_t>(const_cast<int16_t *>(play)), play_frames,
            reinterpret_cast<esp_ae_sample_t>(resampled), &actual_out_frames) != ESP_AE_ERR_OK) {
        ESP_LOGE(TAG, "resample MP3 PCM failed");
        return false;
    }
    resampled.resize(actual_out_frames * dst_channels);
    codec->OutputData(resampled);
    codec->OutputPcm(resampled, static_cast<int>(actual_out_frames * dst_channels));
    if (s_mp3_audible_started == 0) {
        s_mp3_audible_started = xTaskGetTickCount();
    }
    return true;
}
static void pause_wake_for_sd(void)
{
    Application::GetInstance().Schedule([]() {
        auto &audio = Application::GetInstance().GetAudioService();
        audio.EnableVoiceProcessing(false);
        audio.EnableWakeWordDetection(false);
    });
    vTaskDelay(pdMS_TO_TICKS(40));
}
static void wait_shadowing_listen(unsigned duration_ms)
{
    unsigned wait_ms = Board_ShadowingListenWindowMs(duration_ms);
    // Server 听窗与设备同为 1.2×。多等 200ms,避免下一句课文开头被上一句 ASR 吃掉。
    wait_ms += 200;
    ESP_LOGI(TAG, "shadowing listen window %ums after clip %ums", wait_ms, duration_ms);
    unsigned waited = 0;
    while (waited < wait_ms && !mp3_stop_requested) {
        unsigned slice = wait_ms - waited;
        if (slice > 50) {
            slice = 50;
        }
        vTaskDelay(pdMS_TO_TICKS(slice));
        waited += slice;
    }
}
static void ensure_shadowing_listen_sem(void)
{
    if (s_shadowing_listen_closed == nullptr) {
        s_shadowing_listen_closed = xSemaphoreCreateBinary();
    }
}
static void wait_shadowing_mic_off(void)
{
    ensure_shadowing_listen_sem();
    xSemaphoreTake(s_shadowing_listen_closed, 0);
    Application::GetInstance().Schedule([]() {
        Application::GetInstance().CloseShadowingListenWindow();
        if (s_shadowing_listen_closed != nullptr) {
            xSemaphoreGive(s_shadowing_listen_closed);
        }
    });
    // 必须等 AFE 真正关掉再解码下一句。只 delay 80ms 会和录音重叠,内部 SRAM 会被打满。
    if (xSemaphoreTake(s_shadowing_listen_closed, pdMS_TO_TICKS(2500)) != pdTRUE) {
        ESP_LOGW(TAG, "shadowing wait mic off timeout, force AFE stop");
        Application::GetInstance().GetAudioService().EnableVoiceProcessing(false);
    }
}
static void mp3_player_task(void *arg)
{
    mp3_player_task_handle = xTaskGetCurrentTaskHandle();
    mp3_player_task_arg_t *task_arg = static_cast<mp3_player_task_arg_t *>(arg);
    char path[SDCARD_PATH_MAX_LEN];
    strlcpy(path, task_arg->path, sizeof(path));
    float speed = task_arg->speed;
    int repeat = task_arg->repeat < 1 ? 1 : task_arg->repeat;
    bool power_off_after = task_arg->power_off_after;
    free(task_arg);
    FILE *fp = fopen(path, "rb");
    if (!fp) {
        ESP_LOGE(TAG, "open MP3 failed: %s", path);
        mp3_player_task_handle = nullptr;
        mp3_stop_requested = false;
        mp3_repeat_left = 0;
        vTaskDelete(nullptr);
        return;
    sdcard_mp3_playlist_item_t items[SDCARD_MP3_PLAYLIST_MAX];
    int count = task_arg->count;
    if (count < 0) {
        count = 0;
    }
    if (count > SDCARD_MP3_PLAYLIST_MAX) {
        count = SDCARD_MP3_PLAYLIST_MAX;
    }
    if (count > 0) {
        memcpy(items, task_arg->items, sizeof(items[0]) * static_cast<size_t>(count));
    }
    bool power_off_after = count > 0 ? items[count - 1].power_off_after : false;
    heap_caps_free(task_arg);
    auto codec = Board::GetInstance().GetAudioCodec();
    if (!codec) {
        ESP_LOGE(TAG, "audio codec unavailable");
        fclose(fp);
    if (!codec || count < 1) {
        ESP_LOGE(TAG, "audio codec unavailable or empty playlist");
        mp3_player_task_handle = nullptr;
        mp3_stop_requested = false;
        mp3_repeat_left = 0;
        Board_OnSkillPlaybackFinished(true, false, 0);
        vTaskDelete(nullptr);
        return;
    }
    esp_audio_dec_register_default();
    esp_audio_simple_dec_register_default();
    uint8_t *in_buf = static_cast<uint8_t *>(
        heap_caps_malloc(MP3_READ_BUFFER_SIZE, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT));
    uint8_t *out_buf = static_cast<uint8_t *>(
        heap_caps_malloc(MP3_OUTPUT_BUFFER_SIZE, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT));
    uint32_t out_buf_size = MP3_OUTPUT_BUFFER_SIZE;
    bool output_enabled_before = codec->output_enabled();
    TickType_t play_started = xTaskGetTickCount();
    s_mp3_audible_started = 0;
    const bool shadowing = Board_IsShadowingTask();
    char path[SDCARD_PATH_MAX_LEN] = {0};
    if (!in_buf || !out_buf) {
        ESP_LOGE(TAG, "no memory for MP3 playback");
    }
    for (int item_i = 0; in_buf && out_buf && item_i < count && !mp3_stop_requested; ++item_i) {
    strlcpy(path, items[item_i].path, sizeof(path));
    float speed = SDCard_Clamp_MP3_Speed(items[item_i].speed);
    int repeat = items[item_i].repeat < 1 ? 1 : items[item_i].repeat;
    if (item_i > 0) {
        pause_wake_for_sd();
    }
    size_t file_len = 0;
    uint8_t *file_buf = load_mp3_to_psram(path, &file_len);
    FILE *fp = nullptr;
    if (!file_buf) {
        wait_for_sd_dma();
        fp = fopen(path, "rb");
    }
    if (!file_buf && !fp) {
        ESP_LOGE(TAG, "open MP3 failed: %s", path);
        continue;
    }
    if (!mp3_pause_requested) {
        Application::GetInstance().Schedule([]() {
            Application::GetInstance().ApplyMp3CommandOnlyMic();
        });
    }
    esp_audio_simple_dec_handle_t decoder = nullptr;
    esp_audio_simple_dec_cfg_t dec_cfg = {
@@ -171,30 +408,24 @@
    esp_audio_err_t ret = esp_audio_simple_dec_open(&dec_cfg, &decoder);
    if (ret != ESP_AUDIO_ERR_OK) {
        ESP_LOGE(TAG, "open MP3 decoder failed: %d", ret);
        esp_audio_simple_dec_unregister_default();
        esp_audio_dec_unregister_default();
        fclose(fp);
        mp3_player_task_handle = nullptr;
        mp3_stop_requested = false;
        mp3_repeat_left = 0;
        vTaskDelete(nullptr);
        return;
        if (fp) {
            fclose(fp);
            fp = nullptr;
        }
        if (file_buf) {
            heap_caps_free(file_buf);
            file_buf = nullptr;
        }
        continue;
    }
    uint8_t *in_buf = static_cast<uint8_t *>(malloc(MP3_READ_BUFFER_SIZE));
    uint8_t *out_buf = static_cast<uint8_t *>(malloc(MP3_OUTPUT_BUFFER_SIZE));
    uint32_t out_buf_size = MP3_OUTPUT_BUFFER_SIZE;
    bool output_enabled_before = codec->output_enabled();
    bool audio_info_checked = false;
    uint32_t mp3_sample_rate = 0;
    uint8_t mp3_channels = 0;
    esp_ae_rate_cvt_handle_t rate_cvt_handle = nullptr;
    if (!in_buf || !out_buf) {
        ESP_LOGE(TAG, "no memory for MP3 playback");
        ret = ESP_AUDIO_ERR_MEM_LACK;
    } else {
        ESP_LOGI(TAG, "play MP3: %s, speed: %.2fx, repeat: %d, power_off_after: %d", path, speed, repeat, power_off_after ? 1 : 0);
    ESP_LOGI(TAG, "play MP3: %s, speed: %.2fx, repeat: %d, item=%d/%d",
             path, speed, repeat, item_i + 1, count);
        if (wait_if_paused()) {
            codec->EnableOutput(true);
@@ -202,13 +433,26 @@
        for (int pass = 0; pass < repeat && ret == ESP_AUDIO_ERR_OK && !mp3_stop_requested; ++pass) {
            mp3_repeat_left = repeat - pass;
            s_mp3_audible_started = 0;
            size_t mem_off = 0;
            if (pass > 0) {
                if (fseek(fp, 0, SEEK_SET) != 0) {
                    ESP_LOGE(TAG, "rewind MP3 failed: %s", path);
                    break;
                if (!file_buf) {
                    if (fp) {
                        fclose(fp);
                        fp = nullptr;
                    }
                    vTaskDelay(pdMS_TO_TICKS(30));
                    wait_for_sd_dma();
                    fp = fopen(path, "rb");
                    if (!fp) {
                        ESP_LOGE(TAG, "reopen MP3 failed: %s", path);
                        break;
                    }
                }
                esp_audio_simple_dec_close(decoder);
                decoder = nullptr;
                if (decoder) {
                    esp_audio_simple_dec_close(decoder);
                    decoder = nullptr;
                }
                ret = esp_audio_simple_dec_open(&dec_cfg, &decoder);
                if (ret != ESP_AUDIO_ERR_OK || !decoder) {
                    ESP_LOGE(TAG, "reopen MP3 decoder failed: %d", ret);
@@ -221,7 +465,21 @@
                break;
            }
            int read_size = fread(in_buf, 1, MP3_READ_BUFFER_SIZE, fp);
            int read_size = 0;
            if (file_buf) {
                if (mem_off >= file_len) {
                    break;
                }
                size_t chunk = file_len - mem_off;
                if (chunk > MP3_READ_BUFFER_SIZE) {
                    chunk = MP3_READ_BUFFER_SIZE;
                }
                std::memcpy(in_buf, file_buf + mem_off, chunk);
                mem_off += chunk;
                read_size = static_cast<int>(chunk);
            } else {
                read_size = fread(in_buf, 1, MP3_READ_BUFFER_SIZE, fp);
            }
            if (read_size <= 0) {
                break;
            }
@@ -229,7 +487,7 @@
            esp_audio_simple_dec_raw_t raw = {
                .buffer = in_buf,
                .len = static_cast<uint32_t>(read_size),
                .eos = read_size < MP3_READ_BUFFER_SIZE,
                .eos = file_buf ? (mem_off >= file_len) : (read_size < MP3_READ_BUFFER_SIZE),
                .consumed = 0,
                .frame_recover = ESP_AUDIO_SIMPLE_DEC_RECOVERY_NONE,
            };
@@ -248,7 +506,8 @@
                ret = esp_audio_simple_dec_process(decoder, &raw, &out_frame);
                if (ret == ESP_AUDIO_ERR_BUFF_NOT_ENOUGH) {
                    uint8_t *new_buf = static_cast<uint8_t *>(realloc(out_buf, out_frame.needed_size));
                    uint8_t *new_buf = static_cast<uint8_t *>(
                        heap_caps_realloc(out_buf, out_frame.needed_size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT));
                    if (!new_buf) {
                        ESP_LOGE(TAG, "resize MP3 output buffer failed");
                        ret = ESP_AUDIO_ERR_MEM_LACK;
@@ -274,7 +533,7 @@
                                ret = ESP_AUDIO_ERR_NOT_SUPPORT;
                                break;
                            }
                            mp3_sample_rate = static_cast<uint32_t>(info.sample_rate * speed);
                            mp3_sample_rate = info.sample_rate;
                            mp3_channels = info.channel;
                            if (mp3_sample_rate != static_cast<uint32_t>(codec->output_sample_rate())) {
                                esp_ae_rate_cvt_cfg_t rate_cvt_cfg = {
@@ -295,10 +554,11 @@
                        audio_info_checked = true;
                    }
                    if (!output_pcm(codec, rate_cvt_handle, out_frame.buffer, out_frame.decoded_size, mp3_channels, mp3_sample_rate)) {
                    if (!output_pcm(codec, rate_cvt_handle, out_frame.buffer, out_frame.decoded_size, mp3_channels, mp3_sample_rate, speed)) {
                        ret = ESP_AUDIO_ERR_FAIL;
                        break;
                    }
                    taskYIELD();
                }
                if (raw.consumed == 0) {
@@ -313,27 +573,55 @@
                break;
            }
        }
            if (shadowing && !mp3_stop_requested && ret == ESP_AUDIO_ERR_OK) {
                unsigned duration_ms = s_mp3_audible_started
                    ? pdTICKS_TO_MS(xTaskGetTickCount() - s_mp3_audible_started)
                    : 0;
                Board_OnShadowingClipFinished(duration_ms);
                wait_shadowing_listen(duration_ms);
                wait_shadowing_mic_off();
                s_mp3_audible_started = 0;
            }
        }
    }
    if (rate_cvt_handle) {
        esp_ae_rate_cvt_close(rate_cvt_handle);
        rate_cvt_handle = nullptr;
    }
    if (decoder) {
        esp_audio_simple_dec_close(decoder);
        decoder = nullptr;
    }
    if (fp) {
        fclose(fp);
        fp = nullptr;
    }
    if (file_buf) {
        heap_caps_free(file_buf);
        file_buf = nullptr;
    }
    ESP_LOGI(TAG, "MP3 item %s: %s", mp3_stop_requested ? "stopped" : "finished", path);
    }
    if (!output_enabled_before) {
        codec->EnableOutput(false);
    }
    free(in_buf);
    free(out_buf);
    if (decoder) {
        esp_audio_simple_dec_close(decoder);
    if (in_buf) {
        heap_caps_free(in_buf);
    }
    if (out_buf) {
        heap_caps_free(out_buf);
    }
    esp_audio_simple_dec_unregister_default();
    esp_audio_dec_unregister_default();
    fclose(fp);
    ESP_LOGI(TAG, "MP3 playback %s: %s", mp3_stop_requested ? "stopped" : "finished", path);
    ESP_LOGI(TAG, "MP3 playlist %s items=%d", mp3_stop_requested ? "stopped" : "finished", count);
    bool stopped = mp3_stop_requested;
    bool should_power_off = power_off_after && !stopped;
    unsigned duration_ms = s_mp3_audible_started
        ? pdTICKS_TO_MS(xTaskGetTickCount() - s_mp3_audible_started)
        : pdTICKS_TO_MS(xTaskGetTickCount() - play_started);
    mp3_player_task_handle = nullptr;
    mp3_stop_requested = false;
    mp3_pause_requested = false;
@@ -341,7 +629,11 @@
    if (should_power_off) {
        Application::GetInstance().RequestPowerOffStandby();
    }
    Board_OnSkillPlaybackFinished(stopped, power_off_after);
    if (shadowing && !stopped) {
        Board_OnShadowingPlaylistFinished();
    } else {
        Board_OnSkillPlaybackFinished(stopped, power_off_after, duration_ms);
    }
    vTaskDelete(nullptr);
}
@@ -357,11 +649,24 @@
extern "C" esp_err_t SDCard_Play_MP3_File_With_Speed_Repeat(const char *path, float speed, int repeat, bool power_off_after)
{
    if (!path || path[0] == '\0' || !is_supported_speed(speed)) {
    if (!path || path[0] == '\0') {
        return ESP_ERR_INVALID_ARG;
    }
    if (repeat < 1) {
        repeat = 1;
    sdcard_mp3_playlist_item_t item = {};
    strlcpy(item.path, path, sizeof(item.path));
    item.speed = speed;
    item.repeat = repeat;
    item.power_off_after = power_off_after;
    return SDCard_Play_MP3_File_Playlist(&item, 1);
}
extern "C" esp_err_t SDCard_Play_MP3_File_Playlist(const sdcard_mp3_playlist_item_t *items, int count)
{
    if (!items || count < 1) {
        return ESP_ERR_INVALID_ARG;
    }
    if (count > SDCARD_MP3_PLAYLIST_MAX) {
        count = SDCARD_MP3_PLAYLIST_MAX;
    }
    bool start_paused = mp3_pause_requested;
@@ -373,22 +678,46 @@
        }
    }
    mp3_player_task_arg_t *arg = static_cast<mp3_player_task_arg_t *>(calloc(1, sizeof(mp3_player_task_arg_t)));
    mp3_player_task_arg_t *arg = static_cast<mp3_player_task_arg_t *>(
        heap_caps_calloc(1, sizeof(mp3_player_task_arg_t), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT));
    if (!arg) {
        return ESP_ERR_NO_MEM;
    }
    strlcpy(arg->path, path, sizeof(arg->path));
    arg->speed = speed;
    arg->repeat = repeat;
    arg->power_off_after = power_off_after;
    arg->count = count;
    for (int i = 0; i < count; ++i) {
        strlcpy(arg->items[i].path, items[i].path, sizeof(arg->items[i].path));
        arg->items[i].speed = SDCard_Clamp_MP3_Speed(items[i].speed);
        arg->items[i].repeat = items[i].repeat < 1 ? 1 : items[i].repeat;
        arg->items[i].power_off_after = items[i].power_off_after;
    }
    mp3_stop_requested = false;
    mp3_pause_requested = start_paused;
    mp3_repeat_left = repeat;
    mp3_repeat_left = arg->items[0].repeat;
    BaseType_t ret = xTaskCreate(mp3_player_task, "mp3_player", MP3_PLAYER_STACK_SIZE, arg, MP3_PLAYER_PRIORITY, &mp3_player_task_handle);
    if (ret != pdPASS) {
        free(arg);
        mp3_player_task_handle = nullptr;
    if (mp3_player_stack == nullptr) {
        mp3_player_stack = static_cast<StackType_t *>(
            heap_caps_malloc(MP3_PLAYER_STACK_SIZE, MALLOC_CAP_SPIRAM));
    }
    if (mp3_player_tcb == nullptr) {
        mp3_player_tcb = static_cast<StaticTask_t *>(
            heap_caps_malloc(sizeof(StaticTask_t), MALLOC_CAP_INTERNAL));
    }
    if (mp3_player_stack == nullptr || mp3_player_tcb == nullptr) {
        ESP_LOGE(TAG, "mp3 task alloc failed internal=%u psram=%u",
                 (unsigned)heap_caps_get_free_size(MALLOC_CAP_INTERNAL),
                 (unsigned)heap_caps_get_free_size(MALLOC_CAP_SPIRAM));
        heap_caps_free(arg);
        mp3_repeat_left = 0;
        return ESP_ERR_NO_MEM;
    }
    mp3_player_task_handle = xTaskCreateStatic(
        mp3_player_task, "mp3_player", MP3_PLAYER_STACK_SIZE, arg,
        MP3_PLAYER_PRIORITY, mp3_player_stack, mp3_player_tcb);
    if (mp3_player_task_handle == nullptr) {
        ESP_LOGE(TAG, "mp3 xTaskCreateStatic failed internal=%u",
                 (unsigned)heap_caps_get_free_size(MALLOC_CAP_INTERNAL));
        heap_caps_free(arg);
        mp3_repeat_left = 0;
        return ESP_FAIL;
    }
@@ -400,7 +729,6 @@
{
    mp3_pause_requested = false;
    if (!mp3_player_task_handle) {
        ESP_LOGW(TAG, "MP3 player is not running");
        return;
    }
@@ -415,6 +743,9 @@
extern "C" void SDCard_Resume_Play(void)
{
    mp3_pause_requested = false;
    Application::GetInstance().Schedule([]() {
        Application::GetInstance().ApplyMp3CommandOnlyMic();
    });
}
extern "C" bool SDCard_Is_Playing(void)
xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/mqtt.c
@@ -18,7 +18,7 @@
#include "app_task.h"
#define BOARD_MQTT_KEEPALIVE_SEC 120
#define BOARD_MQTT_STATE_REPORT_INTERVAL_MS 5000
#define BOARD_MQTT_STATE_REPORT_INTERVAL_MS 30000
#define BOARD_MQTT_RECONNECT_DELAY_MS 30000
#define BOARD_MQTT_NETWORK_TIMEOUT_MS 15000
#define BOARD_MQTT_FINAL_REPORT_WAIT_MS 10000
@@ -80,7 +80,6 @@
static void BoardMqtt_WaitConnectedForFinalReport(void);
static bool BoardMqtt_BeginTask(board_mqtt_task_type_t type);
static void BoardMqtt_FinishTask(board_mqtt_task_type_t type);
static bool BoardMqtt_PlaybackSpeedIsSupported(float speed);
static void BoardMqtt_StopPlaylistTask(void);
static esp_err_t BoardMqtt_StartPlaylistTask(void);
@@ -145,6 +144,7 @@
    int volume;
    bool has_volume;
    float speed;
    int repeat;
    int index;
    int count;
} board_mqtt_playlist_item_t;
@@ -616,7 +616,8 @@
        strcmp(action, "SET_CLOCK") == 0 || strcmp(action, "PLAY_MEDIA") == 0 ||
        strcmp(action, "STOP_MEDIA") == 0 || strcmp(action, "DELETE_FILE") == 0 ||
        strcmp(action, "SET_ALARM") == 0 || strcmp(action, "UNSET_ALARM") == 0 ||
        strcmp(action, "REBOOT") == 0;
        strcmp(action, "REBOOT") == 0 || strcmp(action, "START_APP_TASK") == 0 ||
        strcmp(action, "ENSURE_APP_DIRS") == 0;
}
static bool BoardMqtt_ActionIsTask(const char *action)
@@ -675,21 +676,6 @@
    esp_err_t err = DS2312_Set_Unix_Time_Saved((time_t)ts->valuedouble);
    return err == ESP_OK ? BoardMqtt_ResultOk("clock updated") :
        BoardMqtt_ResultFail(BOARD_MQTT_ERR_EXEC_FAILED, esp_err_to_name(err));
}
static bool BoardMqtt_PlaybackSpeedIsSupported(float speed)
{
    const float supported_speeds[] = {0.5f, 0.75f, 1.0f, 1.25f, 1.5f, 2.0f};
    for (size_t i = 0; i < sizeof(supported_speeds) / sizeof(supported_speeds[0]); ++i) {
        float diff = speed - supported_speeds[i];
        if (diff < 0) {
            diff = -diff;
        }
        if (diff < 0.001f) {
            return true;
        }
    }
    return false;
}
static char *BoardMqtt_ReadPlaylistFile(void)
@@ -850,13 +836,22 @@
                cJSON_Delete(root);
                return ESP_ERR_INVALID_ARG;
            }
            speed = (float)speed_json->valuedouble;
            speed = SDCard_Clamp_MP3_Speed((float)speed_json->valuedouble);
        }
        if (!BoardMqtt_PlaybackSpeedIsSupported(speed)) {
            cJSON_Delete(root);
            return ESP_ERR_INVALID_ARG;
        int repeat = 1;
        cJSON *repeat_json = cJSON_GetObjectItem(item, "repeat");
        if (!repeat_json) {
            repeat_json = cJSON_GetObjectItem(item, "count");
        }
        if (!repeat_json) {
            repeat_json = cJSON_GetObjectItem(item, "play_count");
        }
        if (repeat_json) {
            if (!BoardMqtt_NumberToInt(repeat_json, &repeat) || repeat < 1 || repeat > 99) {
                cJSON_Delete(root);
                return ESP_ERR_INVALID_ARG;
            }
        }
        cJSON *stored_item = cJSON_CreateObject();
        if (!stored_item) {
            cJSON_Delete(root);
@@ -868,6 +863,7 @@
            cJSON_AddNumberToObject(stored_item, "volume", volume_value);
        }
        cJSON_AddNumberToObject(stored_item, "speed", speed);
        cJSON_AddNumberToObject(stored_item, "repeat", repeat);
        cJSON_AddBoolToObject(stored_item, "completed", false);
        cJSON_AddItemToArray(playlist_items, stored_item);
    }
@@ -901,6 +897,7 @@
            cJSON *sd_path = cJSON_GetObjectItem(item, "sd_path");
            cJSON *volume = cJSON_GetObjectItem(item, "volume");
            cJSON *speed_json = cJSON_GetObjectItem(item, "speed");
            cJSON *repeat_json = cJSON_GetObjectItem(item, "repeat");
            if (!cJSON_IsString(path) || !cJSON_IsString(sd_path)) {
                cJSON_Delete(root);
                return false;
@@ -925,10 +922,14 @@
                }
                next_item->has_volume = true;
            }
            next_item->speed = cJSON_IsNumber(speed_json) ? (float)speed_json->valuedouble : 1.0f;
            if (!BoardMqtt_PlaybackSpeedIsSupported(next_item->speed)) {
                cJSON_Delete(root);
                return false;
            next_item->speed = SDCard_Clamp_MP3_Speed(
                cJSON_IsNumber(speed_json) ? (float)speed_json->valuedouble : 1.0f);
            next_item->repeat = 1;
            if (repeat_json) {
                int repeat = 1;
                if (BoardMqtt_NumberToInt(repeat_json, &repeat) && repeat >= 1) {
                    next_item->repeat = repeat > 99 ? 99 : repeat;
                }
            }
            next_item->index = index;
            next_item->count = count;
@@ -992,8 +993,10 @@
        BoardMqtt_ShowPlaylistAlert(alert_message, "music");
        SDCard_Show_Matching_JPG_For_MP3_Path(item.sd_path);
        ESP_LOGI(TAG, "playlist play item %d/%d: %s speed=%.2fx", item.index + 1, item.count, item.sd_path, item.speed);
        esp_err_t err = SDCard_Play_MP3_File_With_Speed(item.sd_path, item.speed);
        ESP_LOGI(TAG, "playlist play item %d/%d: %s speed=%.2fx repeat=%d",
                 item.index + 1, item.count, item.sd_path, item.speed, item.repeat);
        esp_err_t err = SDCard_Play_MP3_File_With_Speed_Repeat(
            item.sd_path, item.speed, item.repeat > 0 ? item.repeat : 1, false);
        if (err != ESP_OK) {
            ESP_LOGE(TAG, "playlist item playback failed: %s", esp_err_to_name(err));
            snprintf(alert_message, sizeof(alert_message), "播放失败 %d/%d: %s", item.index + 1, item.count, esp_err_to_name(err));
@@ -1894,6 +1897,38 @@
    return BoardMqtt_ResultOk("app task started");
}
static board_mqtt_action_result_t BoardMqtt_HandleEnsureAppDirs(cJSON *payload)
{
    if (!payload) {
        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "missing payload");
    }
    cJSON *app_id = cJSON_GetObjectItem(payload, "app_id");
    if (!cJSON_IsString(app_id) || !app_id->valuestring || app_id->valuestring[0] == '\0') {
        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "missing app_id");
    }
    esp_err_t err = Board_EnsureAppLayout(app_id->valuestring, NULL);
    if (err == ESP_ERR_INVALID_ARG || err == ESP_ERR_INVALID_SIZE) {
        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid app_id");
    }
    if (err != ESP_OK) {
        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_EXEC_FAILED, esp_err_to_name(err));
    }
    cJSON *tasks = cJSON_GetObjectItem(payload, "tasks");
    if (cJSON_IsArray(tasks)) {
        cJSON *task = NULL;
        cJSON_ArrayForEach(task, tasks) {
            if (!cJSON_IsString(task) || !task->valuestring) {
                return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid tasks");
            }
            err = Board_EnsureAppLayout(app_id->valuestring, task->valuestring);
            if (err != ESP_OK) {
                return BoardMqtt_ResultFail(BOARD_MQTT_ERR_EXEC_FAILED, esp_err_to_name(err));
            }
        }
    }
    return BoardMqtt_ResultOk("app dirs ensured");
}
static board_mqtt_action_result_t BoardMqtt_DispatchAction(const char *req_id, const char *action, cJSON *payload)
{
    if (strcmp(action, "SET_VOLUME") == 0) return BoardMqtt_HandleSetVolume(payload);
@@ -1909,6 +1944,7 @@
    if (strcmp(action, "DOWNLOAD") == 0) return BoardMqtt_HandleDownload(req_id, payload);
    if (strcmp(action, "OTA_UPDATE") == 0) return BoardMqtt_HandleOtaUpdate(req_id, payload);
    if (strcmp(action, "START_APP_TASK") == 0) return BoardMqtt_HandleStartAppTask(payload);
    if (strcmp(action, "ENSURE_APP_DIRS") == 0) return BoardMqtt_HandleEnsureAppDirs(payload);
    return BoardMqtt_ResultFail(BOARD_MQTT_ERR_UNSUPPORTED, "unsupported action");
}
@@ -2098,6 +2134,9 @@
    (void)arg;
    while (s_mqtt_client) {
        vTaskDelay(pdMS_TO_TICKS(BOARD_MQTT_STATE_REPORT_INTERVAL_MS));
        if (Board_Is_Audio_Busy()) {
            continue;
        }
        BoardMqtt_PublishState();
    }
    s_state_task_handle = NULL;
xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/mqtt_interface.md
@@ -242,7 +242,7 @@
| SET_VOLUME | `{"level": 50}` | 设置音量 0–100 |
| SET_POWER | `{"state": "wake"}` | 唤醒 / `sleep` 休眠 |
| SET_CLOCK | `{"ts": 1718365200}` | 同步 Unix 时间戳 |
| PLAY_MEDIA | `{"items":[{"path":"courses/course01/chapter01/resources/001.mp3","volume":50,"speed":1.25,"loop":false}]}` | 保存并播放本地 MP3 清单;只保留一份清单,后续覆盖;speed 可选,仅支持 0.5/0.75/1.0/1.25/1.5/2.0,非 1.0 会变调;ttl 和 loop 忽略 |
| PLAY_MEDIA | `{"items":[{"path":"courses/course01/chapter01/resources/001.mp3","volume":50,"speed":1.05,"loop":false}]}` | 保存并播放本地 MP3 清单;只保留一份清单,后续覆盖;speed 可选,范围 0.5~2.0 任意浮点(默认 1.0,超出夹紧),非 1.0 会变调;ttl 和 loop 忽略 |
| STOP_MEDIA | `{}` | 停止播放 |
| SET_ALARM | `{"id": "alarm_01", "time": "07:00", "repeat": [1,2,3,4,5], "action": "add"}` | 设置闹钟 |
| UNSET_ALARM | `{"id": "alarm_01"}` | 删除闹钟 |
@@ -250,7 +250,7 @@
**PLAY_MEDIA 清单行为:**
- `payload.items` 为播放清单数组,每项 `path` 必填,`volume` 和 `speed` 可选。
- `payload.items` 为播放清单数组,每项 `path` 必填,`volume` 和 `speed` 可选。`speed` 范围 0.5~2.0(默认 1.0,超出夹紧),不是离散档。
- 收到清单后终端保存到 SD 卡本地,仅保留一份;后续新清单会覆盖旧清单。
- 每播放完一首,终端会在本地清单中标记该项完成,并通过 `rpt/progress` 上报进度。
- `ttl` 和每项 `loop` 当前忽略。
xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/sening_xiaozhi_s3.cc
@@ -121,6 +121,15 @@
    return state == kDeviceStateConnecting || state == kDeviceStateListening || state == kDeviceStateSpeaking;
}
extern "C" bool Board_Is_Audio_Busy(void)
{
    if (Board_IsShadowingTask() || Board_IsRepeaterTask()) {
        return true;
    }
    DeviceState state = Application::GetInstance().GetDeviceState();
    return state == kDeviceStateConnecting || state == kDeviceStateSpeaking;
}
extern "C" void Board_Show_JPG(const char *jpg_name)
{
    if (!jpg_name || jpg_name[0] == '\0') {
@@ -719,31 +728,138 @@
                return std::string(SDCard_Get_MP3_List());
            });
        mcp_server.AddTool("self.sd.ensure_app",
            "Create SD directories for a subscribed app without flashing firmware. Unique app_id creates /{app_id}. Optional task_id also creates /{app_id}/{task_id}/lessons.",
            PropertyList({
                Property("app_id", kPropertyTypeString),
                Property("task_id", kPropertyTypeString, std::string(""))
            }),
            [](const PropertyList& properties) -> ReturnValue {
                std::string app_id = properties["app_id"].value<std::string>();
                std::string task_id = properties["task_id"].value<std::string>();
                esp_err_t err = Board_EnsureAppLayout(app_id.c_str(), task_id.c_str());
                if (err != ESP_OK) {
                    return std::string("{\"ok\":false,\"error\":\"") + esp_err_to_name(err) + "\"}";
                }
                return std::string("{\"ok\":true,\"app_id\":\"") + app_id +
                    "\",\"task_id\":\"" + task_id + "\"}";
            });
        static std::string last_mp3_play_name;
        static float last_mp3_play_speed = 1.0f;
        static int64_t last_mp3_play_time = 0;
        mcp_server.AddTool("self.mp3.play",
            "Play one MP3 file from the SD card. The name argument must exactly match a files[].name from self.mp3.get_list. Optional speed supports only 0.5, 0.75, 1.0, 1.25, 1.5, or 2.0; non-1.0 speeds change pitch and affect only SD card MP3 playback. Optional repeat is the number of full-file loops (default 1). Optional power_off_after requests existing power-off standby after a natural finish, not after stop. For play, random play, switch song, or named-song requests, call this tool before telling the user playback has started. Do not call this tool repeatedly for the same user request after it has returned accepted=true.",
            "Play one MP3 file from the SD card. If name is omitted or empty, play the last lesson stored on device, or the first MP3 on the SD card. Otherwise name must exactly match a files[].name from self.mp3.get_list. Optional speed is a playback rate in [0.5, 2.0], default 1.0; values outside the range are clamped. Non-1.0 speeds change pitch and affect only SD card MP3 playback. Optional repeat is the number of full-file loops (default 1). Optional power_off_after requests existing power-off standby after a natural finish, not after stop. For play, random play, switch song, or named-song requests, call this tool before telling the user playback has started. Do not call this tool repeatedly for the same user request after it has returned accepted=true.",
            PropertyList({
                Property("name", kPropertyTypeString),
                Property("name", kPropertyTypeString, std::string("")),
                Property("items", kPropertyTypeString, std::string("")),
                Property("speed", kPropertyTypeNumber, 1.0),
                Property("repeat", kPropertyTypeInteger, 1, 1, 99),
                Property("power_off_after", kPropertyTypeBoolean, false)
                Property("power_off_after", kPropertyTypeBoolean, false),
                Property("finish_task", kPropertyTypeBoolean, true),
                Property("app_id", kPropertyTypeString, std::string("")),
                Property("task_id", kPropertyTypeString, std::string("")),
                Property("force", kPropertyTypeBoolean, false),
                Property("listen_ratio", kPropertyTypeNumber, 1.2)
            }),
            [](const PropertyList& properties) -> ReturnValue {
                std::string name = properties["name"].value<std::string>();
                float speed = static_cast<float>(properties["speed"].value<double>());
                std::string items_json = properties["items"].value<std::string>();
                float speed = SDCard_Clamp_MP3_Speed(static_cast<float>(properties["speed"].value<double>()));
                int repeat = properties["repeat"].value<int>();
                bool power_off_after = properties["power_off_after"].value<bool>();
                if (repeat < 1) {
                    repeat = 1;
                std::string task_id = properties["task_id"].value<std::string>();
                bool force = properties["force"].value<bool>();
                Board_SetFinishTaskAfterMp3(properties["finish_task"].value<bool>());
                Board_SetActiveTask(
                    properties["app_id"].value<std::string>().c_str(),
                    task_id.c_str());
                Board_SetShadowingListenRatio(
                    static_cast<float>(properties["listen_ratio"].value<double>()));
                sdcard_mp3_playlist_item_t playlist[SDCARD_MP3_PLAYLIST_MAX];
                int playlist_n = 0;
                if (!items_json.empty()) {
                    cJSON *root = cJSON_Parse(items_json.c_str());
                    cJSON *arr = root;
                    if (root && cJSON_IsObject(root)) {
                        arr = cJSON_GetObjectItem(root, "items");
                    }
                    if (cJSON_IsArray(arr)) {
                        cJSON *it = nullptr;
                        cJSON_ArrayForEach(it, arr) {
                            if (playlist_n >= SDCARD_MP3_PLAYLIST_MAX) {
                                break;
                            }
                            const cJSON *name_item = cJSON_GetObjectItem(it, "name");
                            if (!cJSON_IsString(name_item) || !name_item->valuestring ||
                                name_item->valuestring[0] == '\0') {
                                name_item = cJSON_GetObjectItem(it, "url");
                            }
                            if (!cJSON_IsString(name_item) || !name_item->valuestring ||
                                name_item->valuestring[0] == '\0') {
                                continue;
                            }
                            if (!SDCard_Resolve_MP3_Path(name_item->valuestring,
                                                         playlist[playlist_n].path,
                                                         sizeof(playlist[playlist_n].path))) {
                                ESP_LOGW(TAG, "playlist skip missing %s", name_item->valuestring);
                                continue;
                            }
                            const cJSON *rep = cJSON_GetObjectItem(it, "repeat");
                            playlist[playlist_n].repeat = cJSON_IsNumber(rep) ? rep->valueint : 1;
                            if (playlist[playlist_n].repeat < 1) {
                                playlist[playlist_n].repeat = 1;
                            }
                            const cJSON *spd = cJSON_GetObjectItem(it, "speed");
                            playlist[playlist_n].speed = cJSON_IsNumber(spd)
                                ? SDCard_Clamp_MP3_Speed(static_cast<float>(spd->valuedouble))
                                : 1.0f;
                            const cJSON *po = cJSON_GetObjectItem(it, "power_off_after");
                            playlist[playlist_n].power_off_after = cJSON_IsTrue(po);
                            playlist_n++;
                        }
                    }
                    cJSON_Delete(root);
                }
                if (playlist_n < 1) {
                    char resolved[SDCARD_PATH_MAX_LEN] = {0};
                    strlcpy(resolved, name.c_str(), sizeof(resolved));
                    if (!Board_ResolveRepeaterPlayback(resolved, sizeof(resolved), &speed, &repeat, &power_off_after)) {
                        return std::string("{\"accepted\":false,\"error\":\"no_mp3\"}");
                    }
                    name = resolved;
                    if (repeat < 1) {
                        repeat = 1;
                    }
                } else {
                    name = playlist[0].path;
                    speed = playlist[0].speed;
                    repeat = playlist[0].repeat;
                    power_off_after = playlist[playlist_n - 1].power_off_after;
                }
                int64_t now = esp_timer_get_time();
                if (name == last_mp3_play_name && std::fabs(speed - last_mp3_play_speed) < 0.001f && now - last_mp3_play_time < 15000000) {
                // 跟读/复读会连续播同一文件;不要按路径去重。force 给旧固件重试用。
                bool lesson = (task_id == "shadowing" || task_id == "repeater");
                if (!force && !lesson && playlist_n < 2 && name == last_mp3_play_name &&
                    std::fabs(speed - last_mp3_play_speed) < 0.001f &&
                    now - last_mp3_play_time < 400000) {
                    return std::string("{\"accepted\":true,\"duplicate_ignored\":true}");
                }
                esp_err_t err = SDCard_Play_MP3_With_Speed_Repeat(name.data(), speed, repeat, power_off_after);
                auto &audio = Application::GetInstance().GetAudioService();
                if (task_id == "shadowing") {
                    audio.EnsureVoiceProcessorReady();
                }
                audio.EnableVoiceProcessing(false);
                audio.EnableWakeWordDetection(false);
                esp_err_t err = ESP_FAIL;
                if (playlist_n > 0) {
                    err = SDCard_Play_MP3_File_Playlist(playlist, playlist_n);
                    if (err == ESP_OK) {
                        SDCard_Show_Matching_JPG_For_MP3_Path(playlist[0].path);
                    }
                } else {
                    err = SDCard_Play_MP3_With_Speed_Repeat(name.data(), speed, repeat, power_off_after);
                }
                if (err != ESP_OK) {
                    return std::string("{\"accepted\":false,\"error\":\"") + esp_err_to_name(err) +
                        "\",\"retry\":\"use exact files[].name from self.mp3.get_list\"}";
@@ -751,7 +867,12 @@
                last_mp3_play_name = name;
                last_mp3_play_speed = speed;
                last_mp3_play_time = now;
                std::string remembered = name;
                Application::GetInstance().Schedule([remembered, speed, repeat, power_off_after]() {
                    Board_RememberRepeaterPlayback(remembered.c_str(), speed, repeat, power_off_after);
                });
                return std::string("{\"accepted\":true,\"repeat\":") + std::to_string(repeat) +
                    ",\"items\":" + std::to_string(playlist_n > 0 ? playlist_n : 1) +
                    ",\"power_off_after\":" + (power_off_after ? "true" : "false") + "}";
            });
@@ -868,7 +989,7 @@
    void OnDeviceReady() override {
        xTaskCreate([](void *arg) {
            (void)arg;
            vTaskDelay(pdMS_TO_TICKS(1500));
            vTaskDelay(pdMS_TO_TICKS(2500));
            Board_ResolveBootOrRestore(false);
            vTaskDelete(nullptr);
        }, "boot_app_task", 4096, nullptr, 3, nullptr);
xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/system.c
@@ -126,6 +126,45 @@
    ESP_LOGI(TAG, "offline command triggered: %s", OFFLINE_COMMAND_WORD);
}
bool Board_Is_Mode_Switch_Command(const char *command)
{
    return command &&
           (strcmp(command, OFFLINE_COMMAND_WORD) == 0 ||
            strcmp(command, OFFLINE_COMMAND_WORD_PINYIN) == 0 ||
            strcmp(command, OFFLINE_COMMAND_WORD_ALT) == 0 ||
            strcmp(command, OFFLINE_COMMAND_WORD_ALT_PINYIN) == 0);
}
bool Board_Is_Chat_Return_Command(const char *command)
{
    if (!command || command[0] == '\0') {
        return false;
    }
    return strcmp(command, OFFLINE_CHAT_WORD) == 0 ||
           strcmp(command, OFFLINE_CHAT_WORD_PINYIN) == 0 ||
           strcmp(command, OFFLINE_CHAT_WORD_FULL) == 0 ||
           strcmp(command, OFFLINE_CHAT_WORD_FULL_PINYIN) == 0 ||
           strcmp(command, OFFLINE_EXIT_REPEATER_WORD) == 0 ||
           strcmp(command, OFFLINE_EXIT_REPEATER_WORD_PINYIN) == 0 ||
           strcmp(command, OFFLINE_EXIT_SHADOWING_WORD) == 0 ||
           strcmp(command, OFFLINE_EXIT_SHADOWING_WORD_PINYIN) == 0 ||
           strstr(command, OFFLINE_CHAT_WORD_FULL) ||
           strstr(command, OFFLINE_CHAT_WORD) ||
           strstr(command, OFFLINE_EXIT_REPEATER_WORD) ||
           strstr(command, OFFLINE_EXIT_SHADOWING_WORD);
}
bool Board_Is_App_Launch_Command(const char *command)
{
    if (!command || command[0] == '\0') {
        return false;
    }
    return strcmp(command, OFFLINE_REPEATER_WORD) == 0 ||
           strcmp(command, OFFLINE_START_REPEATER_WORD) == 0 ||
           strstr(command, OFFLINE_REPEATER_WORD) ||
           strstr(command, OFFLINE_START_REPEATER_WORD);
}
bool Board_Handle_Offline_Command(const char *command)
{
    if (!command || Board_Is_Power_Off()) {
@@ -134,7 +173,7 @@
    if (Board_Handle_Voice_Command(command)) {
        return true;
    }
    if (strcmp(command, OFFLINE_COMMAND_WORD) != 0 && strcmp(command, OFFLINE_COMMAND_WORD_PINYIN) != 0) {
    if (!Board_Is_Mode_Switch_Command(command)) {
        return false;
    }
@@ -530,19 +569,23 @@
    return build_media_list_json(mp3_list_buffer, sizeof(mp3_list_buffer), mp3_name_has_extension);
}
static bool is_supported_mp3_speed(float speed)
float SDCard_Clamp_MP3_Speed(float speed)
{
    const float supported_speeds[] = {0.5f, 0.75f, 1.0f, 1.25f, 1.5f, 2.0f};
    for (size_t i = 0; i < sizeof(supported_speeds) / sizeof(supported_speeds[0]); ++i) {
        float diff = speed - supported_speeds[i];
        if (diff < 0) {
            diff = -diff;
        }
        if (diff < 0.001f) {
            return true;
        }
    if (speed != speed) {
        return SDCARD_MP3_SPEED_DEFAULT;
    }
    return false;
    if (speed < SDCARD_MP3_SPEED_MIN) {
        return SDCARD_MP3_SPEED_MIN;
    }
    if (speed > SDCARD_MP3_SPEED_MAX) {
        return SDCARD_MP3_SPEED_MAX;
    }
    return speed;
}
bool SDCard_Resolve_MP3_Path(const char *mp3_name, char *path, size_t path_size)
{
    return resolve_mp3_path(mp3_name, path, path_size);
}
esp_err_t SDCard_Play_MP3(char *mp3_name)
@@ -557,11 +600,7 @@
esp_err_t SDCard_Play_MP3_With_Speed_Repeat(char *mp3_name, float speed, int repeat, bool power_off_after)
{
    if (!is_supported_mp3_speed(speed)) {
        ESP_LOGW(TAG, "invalid MP3 playback speed: %.2f", speed);
        SDCard_Resume_Play();
        return ESP_ERR_INVALID_ARG;
    }
    speed = SDCard_Clamp_MP3_Speed(speed);
    char path[SDCARD_PATH_MAX_LEN];
    if (!resolve_mp3_path(mp3_name, path, sizeof(path))) {
@@ -610,6 +649,22 @@
    return ESP_OK;
}
esp_err_t SDCard_Ensure_Business_Dir(const char *business_path)
{
    char sd_path[SDCARD_PATH_MAX_LEN];
    esp_err_t err = SDCard_Map_Business_Path(business_path, sd_path, sizeof(sd_path));
    if (err != ESP_OK) {
        return err;
    }
    err = mkdir_recursive(sd_path);
    if (err != ESP_OK) {
        ESP_LOGE(TAG, "ensure dir failed: %s", sd_path);
        return err;
    }
    ESP_LOGI(TAG, "ensure dir: %s", sd_path);
    return ESP_OK;
}
esp_err_t SDCard_Prepare_Business_File(const char *business_path,
                                       char *final_path,
                                       size_t final_path_size,
xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/system.h
@@ -36,6 +36,14 @@
#define MOUNT_POINT "/sdcard"
#define SDCARD_PATH_MAX_LEN 256
#define SDCARD_MP3_PLAYLIST_MAX 16
typedef struct {
    char path[SDCARD_PATH_MAX_LEN];
    float speed;
    int repeat;
    bool power_off_after;
} sdcard_mp3_playlist_item_t;
#define SCREEN_MIRROR_X false
#define SCREEN_MIRROR_Y false
@@ -75,14 +83,32 @@
#define BOARD_CUSTOM_WAKE_WORD_THRESHOLD_PERCENT 20
/**
 * @brief 板级 MultiNet 离线命令词配置。
 *
 * @brief 板级 MultiNet 离线命令词。只留没网也必须能用的入口:
 * 「切换模式」「模式切换」「复读机」「开始复读」「退出复读」「退出跟读」「我们聊天吧」「我们来聊天吧」。
 * 复读/跟读播音频时不上行 ASR,退出口令必须能离线识别。其它 App 的进入词仍只在 Server apps.json。
 */
#define OFFLINE_COMMAND_WORD "切换模式"
#define OFFLINE_COMMAND_WORD_PINYIN "qie huan mo shi"
#define OFFLINE_COMMAND_WORD_ALT "模式切换"
#define OFFLINE_COMMAND_WORD_ALT_PINYIN "mo shi qie huan"
#define OFFLINE_REPEATER_WORD "复读机"
#define OFFLINE_REPEATER_WORD_PINYIN "fu du ji"
#define OFFLINE_START_REPEATER_WORD "开始复读"
#define OFFLINE_START_REPEATER_WORD_PINYIN "kai shi fu du"
#define OFFLINE_EXIT_REPEATER_WORD "退出复读"
#define OFFLINE_EXIT_REPEATER_WORD_PINYIN "tui chu fu du"
#define OFFLINE_EXIT_SHADOWING_WORD "退出跟读"
#define OFFLINE_EXIT_SHADOWING_WORD_PINYIN "tui chu gen du"
#define OFFLINE_CHAT_WORD "我们聊天吧"
#define OFFLINE_CHAT_WORD_PINYIN "wo men liao tian ba"
#define OFFLINE_CHAT_WORD_FULL "我们来聊天吧"
#define OFFLINE_CHAT_WORD_FULL_PINYIN "wo men lai liao tian ba"
void Board_Offline_Command_Callback(void);
bool Board_Handle_Offline_Command(const char *command);
bool Board_Is_Mode_Switch_Command(const char *command);
bool Board_Is_Chat_Return_Command(const char *command);
bool Board_Is_App_Launch_Command(const char *command);
/**
 * @brief 使用 SNTP 网络时间同步 DS3231 RTC。
@@ -192,14 +218,26 @@
 */
esp_err_t SDCard_Play_MP3(char *mp3_name);
#define SDCARD_MP3_SPEED_MIN 0.5f
#define SDCARD_MP3_SPEED_MAX 2.0f
#define SDCARD_MP3_SPEED_DEFAULT 1.0f
/**
 * @brief 将播放倍率夹紧到 [0.5, 2.0];NaN/非法则回退 1.0。
 *
 * 实现是先按 speed 做线性时域拉伸,再用文件原始采样率转到喇叭采样率。
 * 0.5~2.0 内任意浮点均可;非 1.0 会改变音调。不要把 sample_rate*speed 交给 AE 变采样。
 */
float SDCard_Clamp_MP3_Speed(float speed);
/**
 * @brief 按文件名和速度启动 SD 卡 MP3 播放。
 *
 * speed 仅支持 0.5、0.75、1.0、1.25、1.5、2.0。非 1.0 速度通过重采样实现,会改变音调。
 * speed 取值 [0.5, 2.0],缺省 1.0;超出范围会被夹紧。非 1.0 通过重采样实现,会改变音调。
 *
 * @param mp3_name MP3 文件名或匹配关键字,不能为空。
 * @param speed 播放速度倍率。
 * @return ESP_OK 表示播放任务已成功启动;ESP_ERR_INVALID_ARG 表示速度或文件名非法。
 * @return ESP_OK 表示播放任务已成功启动;ESP_ERR_INVALID_ARG 表示文件名非法。
 */
esp_err_t SDCard_Play_MP3_With_Speed(char *mp3_name, float speed);
esp_err_t SDCard_Play_MP3_With_Speed_Repeat(char *mp3_name, float speed, int repeat, bool power_off_after);
@@ -266,16 +304,19 @@
/**
 * @brief 按完整路径和速度启动 MP3 文件播放。
 *
 * speed 仅支持 0.5、0.75、1.0、1.25、1.5、2.0。非 1.0 速度通过重采样实现,会改变音调。
 * speed 取值 [0.5, 2.0],缺省 1.0;超出范围会被夹紧。非 1.0 通过重采样实现,会改变音调。
 *
 * @param path MP3 文件完整路径,不能为空。
 * @param speed 播放速度倍率。
 * @return ESP_OK 表示播放任务已成功启动;ESP_ERR_INVALID_ARG 表示路径或速度非法。
 * @return ESP_OK 表示播放任务已成功启动;ESP_ERR_INVALID_ARG 表示路径非法。
 */
esp_err_t SDCard_Play_MP3_File_With_Speed(const char *path, float speed);
esp_err_t SDCard_Play_MP3_File_With_Speed_Repeat(const char *path, float speed, int repeat, bool power_off_after);
bool SDCard_Resolve_MP3_Path(const char *mp3_name, char *path, size_t path_size);
esp_err_t SDCard_Play_MP3_File_Playlist(const sdcard_mp3_playlist_item_t *items, int count);
int SDCard_Get_Repeat_Left(void);
esp_err_t SDCard_Map_Business_Path(const char *business_path, char *sd_path, size_t sd_path_size);
esp_err_t SDCard_Ensure_Business_Dir(const char *business_path);
esp_err_t SDCard_Prepare_Business_File(const char *business_path,
                                       char *final_path,
                                       size_t final_path_size,
@@ -394,6 +435,7 @@
void Board_Acquire_Sleep_Inhibit(void);
void Board_Release_Sleep_Inhibit(void);
bool Board_Is_Voice_Session_Active(void);
bool Board_Is_Audio_Busy(void);
void Board_Show_JPG(const char *jpg_name);
void Board_Show_Black_Screen(void);
void Board_Show_Alert(const char *status, const char *message, const char *emotion);
xiaozhi-esp32-2.2.6/main/boards/waveshare/esp32-s3-touch-lcd-4.3c/sdkconfig.4_3c
@@ -597,7 +597,8 @@
#
# Xiaozhi Assistant
#
CONFIG_OTA_URL="https://api.tenclass.net/xiaozhi/ota/"
#CONFIG_OTA_URL="https://api.tenclass.net/xiaozhi/ota/"
CONFIG_OTA_URL="http://192.168.0.107:8002/xiaozhi/ota/"
# CONFIG_FLASH_NONE_ASSETS is not set
CONFIG_FLASH_DEFAULT_ASSETS=y
# CONFIG_FLASH_CUSTOM_ASSETS is not set
xiaozhi-esp32-2.2.6/main/protocols/protocol.cc
@@ -48,10 +48,20 @@
    SendText(message);
}
void Protocol::SendWakeWordDetected(const std::string& wake_word) {
    std::string json = "{\"session_id\":\"" + session_id_ +
                      "\",\"type\":\"listen\",\"state\":\"detect\",\"text\":\"" + wake_word + "\"}";
    SendText(json);
bool Protocol::SendWakeWordDetected(const std::string& wake_word) {
    cJSON* root = cJSON_CreateObject();
    cJSON_AddStringToObject(root, "session_id", session_id_.c_str());
    cJSON_AddStringToObject(root, "type", "listen");
    cJSON_AddStringToObject(root, "state", "detect");
    cJSON_AddStringToObject(root, "text", wake_word.c_str());
    char* json = cJSON_PrintUnformatted(root);
    cJSON_Delete(root);
    if (json == nullptr) {
        return false;
    }
    bool ok = SendText(json);
    cJSON_free(json);
    return ok;
}
void Protocol::SendStartListening(ListeningMode mode) {
@@ -78,13 +88,21 @@
    SendText(message);
}
bool Protocol::IsAudioChannelConnected() const {
    return IsAudioChannelOpened();
}
void Protocol::MarkIncomingActivity() {
    last_incoming_time_ = std::chrono::steady_clock::now();
}
bool Protocol::IsTimeout() const {
    const int kTimeoutSeconds = 120;
    auto now = std::chrono::steady_clock::now();
    auto duration = std::chrono::duration_cast<std::chrono::seconds>(now - last_incoming_time_);
    bool timeout = duration.count() > kTimeoutSeconds;
    if (timeout) {
        ESP_LOGE(TAG, "Channel timeout %ld seconds", (long)duration.count());
        ESP_LOGW(TAG, "Channel idle %ld seconds (socket may still be up)", (long)duration.count());
    }
    return timeout;
}
xiaozhi-esp32-2.2.6/main/protocols/protocol.h
@@ -67,8 +67,12 @@
    virtual bool OpenAudioChannel() = 0;
    virtual void CloseAudioChannel(bool send_goodbye = true) = 0;
    virtual bool IsAudioChannelOpened() const = 0;
    // Socket is up and can send. Unlike IsAudioChannelOpened(), this ignores idle timeout
    // so long local MP3 playback does not force a reconnect.
    virtual bool IsAudioChannelConnected() const;
    void MarkIncomingActivity();
    virtual bool SendAudio(std::unique_ptr<AudioStreamPacket> packet) = 0;
    virtual void SendWakeWordDetected(const std::string& wake_word);
    virtual bool SendWakeWordDetected(const std::string& wake_word);
    virtual void SendStartListening(ListeningMode mode);
    virtual void SendStopListening();
    virtual void SendAbortSpeaking(AbortReason reason);
xiaozhi-esp32-2.2.6/main/protocols/websocket_protocol.cc
@@ -8,6 +8,7 @@
#include <cJSON.h>
#include <esp_log.h>
#include <arpa/inet.h>
#include <freertos/task.h>
#include "assets/lang_config.h"
#define TAG "WS"
@@ -71,8 +72,14 @@
    return true;
}
bool WebsocketProtocol::IsAudioChannelConnected() const {
    return websocket_ != nullptr && websocket_->IsConnected() && !error_occurred_;
}
bool WebsocketProtocol::IsAudioChannelOpened() const {
    return websocket_ != nullptr && websocket_->IsConnected() && !error_occurred_ && !IsTimeout();
    // Local MP3 can idle the websocket for minutes. Treat a live socket as open
    // so detect/TTS reuse it instead of tearing it down and handshake-timing-out.
    return IsAudioChannelConnected();
}
void WebsocketProtocol::CloseAudioChannel(bool send_goodbye) {
@@ -81,6 +88,18 @@
}
bool WebsocketProtocol::OpenAudioChannel() {
    Board::GetInstance().SetPowerSaveLevel(PowerSaveLevel::PERFORMANCE);
    if (IsAudioChannelConnected()) {
        MarkIncomingActivity();
        ESP_LOGI(TAG, "Reuse existing websocket channel");
        return true;
    }
    // MODEM 从 MAX_MODEM 切回 NONE 不是瞬时完成的。立刻握手会
    // WebSocket handshake timeout,开机欢迎词和复读结束后回 chat 都会失败。
    vTaskDelay(pdMS_TO_TICKS(400));
    Settings settings("websocket", false);
    std::string url = settings.GetString("url");
    std::string token = settings.GetString("token");
@@ -186,7 +205,7 @@
    }
    // Wait for server hello
    EventBits_t bits = xEventGroupWaitBits(event_group_handle_, WEBSOCKET_PROTOCOL_SERVER_HELLO_EVENT, pdTRUE, pdFALSE, pdMS_TO_TICKS(10000));
    EventBits_t bits = xEventGroupWaitBits(event_group_handle_, WEBSOCKET_PROTOCOL_SERVER_HELLO_EVENT, pdTRUE, pdFALSE, pdMS_TO_TICKS(20000));
    if (!(bits & WEBSOCKET_PROTOCOL_SERVER_HELLO_EVENT)) {
        ESP_LOGE(TAG, "Failed to receive server hello");
        SetError(Lang::Strings::SERVER_TIMEOUT);
xiaozhi-esp32-2.2.6/main/protocols/websocket_protocol.h
@@ -20,6 +20,7 @@
    bool OpenAudioChannel() override;
    void CloseAudioChannel(bool send_goodbye = true) override;
    bool IsAudioChannelOpened() const override;
    bool IsAudioChannelConnected() const override;
private:
    EventGroupHandle_t event_group_handle_;
xiaozhi-esp32-2.2.6/scripts/patch_wifi_connect.py
New file
@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""Idempotent patches for 78/esp-wifi-connect captive portal.
The managed component is gitignored; this script is applied at CMake configure
so Android/WeChat locale codes (zh_CN) and socket exhaustion from captive DNS
hijack (WeChat /mmtls) do not break the config page.
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
HTML_MARKER = "xiaozhi-portal-lang-patch"
HTTPD_MARKER = "xiaozhi-portal-httpd-patch"
def _normalize_ws(text: str) -> str:
    return text.replace("\r\n", "\n")
def patch_html(path: Path) -> bool:
    raw = _normalize_ws(path.read_text(encoding="utf-8"))
    original = raw
    if HTML_MARKER not in raw:
        raw, n = re.subn(
            r"function getSupportedLanguage\(lang\) \{\s*"
            r"// First try exact match",
            "function getSupportedLanguage(lang) {\n"
            f" // {HTML_MARKER}\n"
            " if (!lang) { return 'en-US'; }\n"
            " lang = String(lang).replace(/_/g, '-');\n"
            " // First try exact match",
            raw,
            count=1,
        )
        if n == 0:
            print(f"warn: could not patch getSupportedLanguage in {path}")
    if "getSupportedLanguage(langParam ||" not in raw:
        raw, n = re.subn(
            r"const savedLang = langParam \|\|\s*"
            r"localStorage\.getItem\('preferred_language'\) \|\|\s*"
            r"getSupportedLanguage\(browserLang\);",
            "const savedLang = getSupportedLanguage(langParam ||\n"
            " localStorage.getItem('preferred_language') ||\n"
            " browserLang);",
            raw,
            count=1,
        )
        if n == 0:
            print(f"warn: could not patch savedLang in {path}")
    if raw == original:
        print(f"already patched {path}")
        return False
    path.write_text(raw, encoding="utf-8", newline="\n")
    print(f"patched {path}")
    return True
def patch_httpd(path: Path) -> bool:
    raw = _normalize_ws(path.read_text(encoding="utf-8"))
    if HTTPD_MARKER in raw:
        print(f"already patched {path}")
        return False
    raw2, n = re.subn(
        r"httpd_config_t config = HTTPD_DEFAULT_CONFIG\(\);\s*"
        r"config\.max_uri_handlers = 24;\s*"
        r"config\.uri_match_fn = httpd_uri_match_wildcard;",
        "httpd_config_t config = HTTPD_DEFAULT_CONFIG();\n"
        " config.max_uri_handlers = 24;\n"
        f" config.lru_purge_enable = true; // {HTTPD_MARKER}\n"
        " config.uri_match_fn = httpd_uri_match_wildcard;",
        raw,
        count=1,
    )
    if n == 0:
        print(f"warn: could not patch httpd config in {path}")
        return False
    path.write_text(raw2, encoding="utf-8", newline="\n")
    print(f"patched {path}")
    return True
def main() -> int:
    if len(sys.argv) != 2:
        print("usage: patch_wifi_connect.py <esp-wifi-connect component dir>")
        return 2
    root = Path(sys.argv[1])
    if not root.is_dir():
        print(f"error: not a directory: {root}")
        return 1
    html = root / "assets" / "wifi_configuration.html"
    cc = root / "wifi_configuration_ap.cc"
    if html.is_file():
        patch_html(html)
    else:
        print(f"warn: missing {html}")
    if cc.is_file():
        patch_httpd(cc)
    else:
        print(f"warn: missing {cc}")
    return 0
if __name__ == "__main__":
    raise SystemExit(main())
xiaozhi-esp32-2.2.6/sdkconfig
@@ -600,7 +600,8 @@
#
# Xiaozhi Assistant
#
CONFIG_OTA_URL="https://api.tenclass.net/xiaozhi/ota/"
#CONFIG_OTA_URL="https://api.tenclass.net/xiaozhi/ota/"
CONFIG_OTA_URL="http://192.168.0.107:8002/xiaozhi/ota/"
# CONFIG_FLASH_NONE_ASSETS is not set
CONFIG_FLASH_DEFAULT_ASSETS=y
# CONFIG_FLASH_CUSTOM_ASSETS is not set
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentChatHistoryReportDTO.java
@@ -30,4 +30,8 @@
    private String audioBase64;
    @Schema(description = "上报时间,十位时间戳,空时默认使用当前时间", example = "1745657732")
    private Long reportTime;
    @Schema(description = "可选:挂到 TaskInstance 的 id")
    private String taskInstanceId;
    @Schema(description = "可选:挂到 Task step_index")
    private Integer stepIndex;
}
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/agent/service/biz/impl/AgentChatHistoryBizServiceImpl.java
@@ -22,6 +22,7 @@
import xiaozhi.modules.agent.service.biz.AgentChatHistoryBizService;
import xiaozhi.modules.device.entity.DeviceEntity;
import xiaozhi.modules.device.service.DeviceService;
import xiaozhi.modules.task.service.TaskInstanceService;
/**
 * {@link AgentChatHistoryBizService} impl
@@ -40,6 +41,7 @@
    private final AgentChatSummaryService agentChatSummaryService;
    private final RedisUtils redisUtils;
    private final DeviceService deviceService;
    private final TaskInstanceService taskInstanceService;
    /**
     * 处理聊天记录上报,包括文件上传和相关信息记录
@@ -65,12 +67,14 @@
        Integer chatHistoryConf = agentEntity.getChatHistoryConf();
        String agentId = agentEntity.getId();
        String audioId = null;
        if (Objects.equals(chatHistoryConf, Constant.ChatHistoryConfEnum.RECORD_TEXT.getCode())) {
            saveChatText(report, agentId, macAddress, null, reportTimeMillis);
        } else if (Objects.equals(chatHistoryConf, Constant.ChatHistoryConfEnum.RECORD_TEXT_AUDIO.getCode())) {
            String audioId = saveChatAudio(report);
            audioId = saveChatAudio(report);
            saveChatText(report, agentId, macAddress, audioId, reportTimeMillis);
        }
        attachTaskStepAudio(report, audioId);
        // 更新设备最后对话时间
        redisUtils.set(RedisKeys.getAgentDeviceLastConnectedAtById(agentId), new Date());
@@ -127,4 +131,16 @@
        log.info("设备 {} 对应智能体 {} 上报成功", macAddress, agentId);
    }
    private void attachTaskStepAudio(AgentChatHistoryReportDTO report, String audioId) {
        if (report == null || audioId == null || report.getTaskInstanceId() == null || report.getStepIndex() == null) {
            return;
        }
        try {
            taskInstanceService.attachAudio(report.getTaskInstanceId(), report.getStepIndex(), audioId);
        } catch (Exception e) {
            log.warn("回写 Task step audio_id 失败 instance={} step={}", report.getTaskInstanceId(),
                    report.getStepIndex(), e);
        }
    }
}
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/security/config/ShiroConfig.java
@@ -93,6 +93,8 @@
        filterMap.put("/config/**", "server");
        filterMap.put("/device/address-book/call", "server");
        filterMap.put("/agent/chat-history/report", "server");
        filterMap.put("/task/instance", "server");
        filterMap.put("/task/instance/**", "server");
        filterMap.put("/agent/chat-history/download/**", "anon");
        filterMap.put("/agent/chat-summary/**", "server");
        filterMap.put("/agent/chat-title/**", "server");
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/task/controller/TaskInstanceController.java
New file
@@ -0,0 +1,53 @@
package xiaozhi.modules.task.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import xiaozhi.common.exception.ErrorCode;
import xiaozhi.common.exception.RenException;
import xiaozhi.common.utils.Result;
import xiaozhi.modules.task.dto.TaskInstanceListDTO;
import xiaozhi.modules.task.dto.TaskInstanceUpsertDTO;
import xiaozhi.modules.task.service.TaskInstanceService;
@Tag(name = "Task Instance")
@RestController
@RequestMapping("/task/instance")
@RequiredArgsConstructor
public class TaskInstanceController {
    private final TaskInstanceService taskInstanceService;
    @Operation(summary = "xiaozhi-server 上报 TaskSession 全文(幂等覆盖)")
    @PutMapping("/{id}")
    public Result<Boolean> upsert(@PathVariable("id") String id, @RequestBody TaskInstanceUpsertDTO body) {
        taskInstanceService.upsert(id, body);
        return new Result<Boolean>().ok(Boolean.TRUE);
    }
    @Operation(summary = "查询一条 TaskInstance(含 turns/events)")
    @GetMapping("/{id}")
    public Result<TaskInstanceUpsertDTO> get(@PathVariable("id") String id) {
        TaskInstanceUpsertDTO data = taskInstanceService.getDetail(id);
        if (data == null) {
            throw new RenException(ErrorCode.RESOURCE_NOT_FOUND);
        }
        return new Result<TaskInstanceUpsertDTO>().ok(data);
    }
    @Operation(summary = "按设备列出最近 TaskInstance(不含 turns 正文)")
    @GetMapping
    public Result<TaskInstanceListDTO> list(
            @RequestParam("macAddress") String macAddress,
            @RequestParam(value = "appId", required = false) String appId,
            @RequestParam(value = "limit", required = false) Integer limit) {
        return new Result<TaskInstanceListDTO>().ok(taskInstanceService.listByMac(macAddress, appId, limit));
    }
}
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/task/dao/TaskEventDao.java
New file
@@ -0,0 +1,11 @@
package xiaozhi.modules.task.dao;
import org.apache.ibatis.annotations.Mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import xiaozhi.modules.task.entity.TaskEventEntity;
@Mapper
public interface TaskEventDao extends BaseMapper<TaskEventEntity> {
}
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/task/dao/TaskInstanceDao.java
New file
@@ -0,0 +1,11 @@
package xiaozhi.modules.task.dao;
import org.apache.ibatis.annotations.Mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import xiaozhi.modules.task.entity.TaskInstanceEntity;
@Mapper
public interface TaskInstanceDao extends BaseMapper<TaskInstanceEntity> {
}
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/task/dao/TaskStepDao.java
New file
@@ -0,0 +1,17 @@
package xiaozhi.modules.task.dao;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import xiaozhi.modules.task.entity.TaskStepEntity;
@Mapper
public interface TaskStepDao extends BaseMapper<TaskStepEntity> {
    @Update("UPDATE ai_task_step SET audio_id = #{audioId} WHERE instance_id = #{instanceId} AND step_index = #{stepIndex}")
    int updateAudioId(@Param("instanceId") String instanceId, @Param("stepIndex") Integer stepIndex,
            @Param("audioId") String audioId);
}
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/task/dto/TaskEventDTO.java
New file
@@ -0,0 +1,20 @@
package xiaozhi.modules.task.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class TaskEventDTO {
    private String name;
    @JsonProperty("app_id")
    private String appId;
    @JsonProperty("task_id")
    private String taskId;
    private Long at;
}
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/task/dto/TaskInstanceListDTO.java
New file
@@ -0,0 +1,12 @@
package xiaozhi.modules.task.dto;
import java.util.ArrayList;
import java.util.List;
import lombok.Data;
@Data
public class TaskInstanceListDTO {
    private List<TaskInstanceListItemDTO> list = new ArrayList<>();
    private long total;
}
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/task/dto/TaskInstanceListItemDTO.java
New file
@@ -0,0 +1,24 @@
package xiaozhi.modules.task.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
@Data
public class TaskInstanceListItemDTO {
    private String id;
    @JsonProperty("app_id")
    private String appId;
    @JsonProperty("task_id")
    private String taskId;
    private String status;
    @JsonProperty("end_reason")
    private String endReason;
    @JsonProperty("started_at")
    private Long startedAt;
}
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/task/dto/TaskInstanceUpsertDTO.java
New file
@@ -0,0 +1,53 @@
package xiaozhi.modules.task.dto;
import java.util.ArrayList;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
@Schema(description = "TaskInstance upsert / GET 详情,字段与 TaskSession JSON 对齐")
public class TaskInstanceUpsertDTO {
    @JsonProperty("task_session_id")
    private String taskSessionId;
    @JsonProperty("ws_session_id")
    private String wsSessionId;
    @JsonProperty("device_id")
    private String deviceId;
    @JsonProperty("agent_id")
    private String agentId;
    @JsonProperty("app_id")
    private String appId;
    @JsonProperty("task_id")
    private String taskId;
    private String source;
    @JsonProperty("interaction_mode")
    private String interactionMode;
    @JsonProperty("started_at")
    private Long startedAt;
    @JsonProperty("ended_at")
    private Long endedAt;
    private String status;
    @JsonProperty("end_reason")
    private String endReason;
    private List<TaskTurnDTO> turns = new ArrayList<>();
    private List<TaskEventDTO> events = new ArrayList<>();
}
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/task/dto/TaskMetricDTO.java
New file
@@ -0,0 +1,12 @@
package xiaozhi.modules.task.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Data;
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class TaskMetricDTO {
    private String method;
    private Double value;
}
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/task/dto/TaskTurnDTO.java
New file
@@ -0,0 +1,37 @@
package xiaozhi.modules.task.dto;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class TaskTurnDTO {
    private Integer index;
    @JsonProperty("prompt_text")
    private String promptText;
    @JsonProperty("expected_text")
    private String expectedText;
    @JsonProperty("asr_text")
    private String asrText;
    @JsonProperty("echo_rejected")
    private Boolean echoRejected;
    @JsonProperty("recorded_at")
    private Long recordedAt;
    @JsonProperty("audio_id")
    private String audioId;
    private List<TaskMetricDTO> metrics;
    @JsonProperty("assessed_at")
    private Long assessedAt;
}
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/task/entity/TaskEventEntity.java
New file
@@ -0,0 +1,29 @@
package xiaozhi.modules.task.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
@Data
@TableName("ai_task_event")
public class TaskEventEntity {
    @TableId(type = IdType.AUTO)
    private Long id;
    @TableField("instance_id")
    private String instanceId;
    private String name;
    @TableField("app_id")
    private String appId;
    @TableField("task_id")
    private String taskId;
    @TableField("at_ms")
    private Long atMs;
}
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/task/entity/TaskInstanceEntity.java
New file
@@ -0,0 +1,54 @@
package xiaozhi.modules.task.entity;
import java.util.Date;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
@Data
@TableName("ai_task_instance")
public class TaskInstanceEntity {
    @TableId(type = IdType.INPUT)
    private String id;
    @TableField("mac_address")
    private String macAddress;
    @TableField("agent_id")
    private String agentId;
    @TableField("ws_session_id")
    private String wsSessionId;
    @TableField("app_id")
    private String appId;
    @TableField("task_id")
    private String taskId;
    private String source;
    @TableField("interaction_mode")
    private String interactionMode;
    private String status;
    @TableField("end_reason")
    private String endReason;
    @TableField("started_at")
    private Long startedAt;
    @TableField("ended_at")
    private Long endedAt;
    @TableField("created_at")
    private Date createdAt;
    @TableField("updated_at")
    private Date updatedAt;
}
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/task/entity/TaskStepEntity.java
New file
@@ -0,0 +1,49 @@
package xiaozhi.modules.task.entity;
import java.util.List;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import lombok.Data;
import xiaozhi.modules.task.dto.TaskMetricDTO;
@Data
@TableName(value = "ai_task_step", autoResultMap = true)
public class TaskStepEntity {
    @TableId(type = IdType.AUTO)
    private Long id;
    @TableField("instance_id")
    private String instanceId;
    @TableField("step_index")
    private Integer stepIndex;
    @TableField("prompt_text")
    private String promptText;
    @TableField("expected_text")
    private String expectedText;
    @TableField("asr_text")
    private String asrText;
    @TableField("echo_rejected")
    private Integer echoRejected;
    @TableField("audio_id")
    private String audioId;
    @TableField(value = "metrics", typeHandler = JacksonTypeHandler.class)
    private List<TaskMetricDTO> metrics;
    @TableField("assessed_at")
    private Long assessedAt;
    @TableField("recorded_at")
    private Long recordedAt;
}
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/task/service/TaskInstanceService.java
New file
@@ -0,0 +1,259 @@
package xiaozhi.modules.task.service;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import xiaozhi.common.exception.ErrorCode;
import xiaozhi.common.exception.RenException;
import xiaozhi.modules.task.dao.TaskEventDao;
import xiaozhi.modules.task.dao.TaskInstanceDao;
import xiaozhi.modules.task.dao.TaskStepDao;
import xiaozhi.modules.task.dto.TaskEventDTO;
import xiaozhi.modules.task.dto.TaskInstanceListDTO;
import xiaozhi.modules.task.dto.TaskInstanceListItemDTO;
import xiaozhi.modules.task.dto.TaskInstanceUpsertDTO;
import xiaozhi.modules.task.dto.TaskTurnDTO;
import xiaozhi.modules.task.entity.TaskEventEntity;
import xiaozhi.modules.task.entity.TaskInstanceEntity;
import xiaozhi.modules.task.entity.TaskStepEntity;
@Service
@RequiredArgsConstructor
public class TaskInstanceService {
    private static final Pattern LAYOUT_ID = Pattern.compile("^[A-Za-z0-9_-]{1,31}$");
    private static final int LIST_MAX = 100;
    private final TaskInstanceDao instanceDao;
    private final TaskStepDao stepDao;
    private final TaskEventDao eventDao;
    @Transactional(rollbackFor = Exception.class)
    public void upsert(String id, TaskInstanceUpsertDTO body) {
        if (StringUtils.isBlank(id) || body == null) {
            throw new RenException(ErrorCode.PARAM_VALUE_NULL);
        }
        if (StringUtils.isBlank(body.getTaskSessionId())) {
            body.setTaskSessionId(id);
        }
        if (!id.equals(body.getTaskSessionId())) {
            throw new RenException("path id must match task_session_id");
        }
        validateLayoutId(body.getAppId(), "app_id");
        validateLayoutId(body.getTaskId(), "task_id");
        String status = StringUtils.defaultIfBlank(body.getStatus(), "running");
        if (!status.equals("running") && !status.equals("completed") && !status.equals("cancelled")) {
            throw new RenException("invalid status");
        }
        try {
            persistInstance(id, body, status);
        } catch (RenException ex) {
            throw ex;
        } catch (Exception ex) {
            throw new RenException("TaskInstance 写入失败: " + ex.getMessage(), ex);
        }
    }
    private void persistInstance(String id, TaskInstanceUpsertDTO body, String status) {
        Map<Integer, TaskStepEntity> existingSteps = loadExistingSteps(id);
        TaskInstanceEntity existing = instanceDao.selectById(id);
        Date now = new Date();
        TaskInstanceEntity entity = new TaskInstanceEntity();
        entity.setId(id);
        entity.setMacAddress(StringUtils.left(StringUtils.trimToNull(body.getDeviceId()), 50));
        entity.setAgentId(StringUtils.left(StringUtils.trimToNull(body.getAgentId()), 32));
        entity.setWsSessionId(StringUtils.left(StringUtils.trimToEmpty(body.getWsSessionId()), 64));
        entity.setAppId(body.getAppId());
        entity.setTaskId(body.getTaskId());
        entity.setSource(StringUtils.left(StringUtils.defaultIfBlank(body.getSource(), "voice"), 24));
        entity.setInteractionMode(StringUtils.left(StringUtils.trimToEmpty(body.getInteractionMode()), 32));
        entity.setStatus(status);
        entity.setEndReason(StringUtils.left(StringUtils.trimToEmpty(body.getEndReason()), 32));
        entity.setStartedAt(body.getStartedAt() == null ? 0L : body.getStartedAt());
        entity.setEndedAt(body.getEndedAt());
        entity.setUpdatedAt(now);
        if (existing == null) {
            entity.setCreatedAt(now);
            instanceDao.insert(entity);
        } else {
            entity.setCreatedAt(existing.getCreatedAt());
            instanceDao.updateById(entity);
        }
        stepDao.delete(new LambdaQueryWrapper<TaskStepEntity>().eq(TaskStepEntity::getInstanceId, id));
        eventDao.delete(new LambdaQueryWrapper<TaskEventEntity>().eq(TaskEventEntity::getInstanceId, id));
        List<TaskTurnDTO> turns = body.getTurns() == null ? List.of() : body.getTurns();
        for (TaskTurnDTO turn : turns) {
            if (turn == null || turn.getIndex() == null) {
                continue;
            }
            TaskStepEntity step = new TaskStepEntity();
            step.setInstanceId(id);
            step.setStepIndex(turn.getIndex());
            step.setPromptText(turn.getPromptText());
            step.setAsrText(turn.getAsrText());
            step.setEchoRejected(Boolean.TRUE.equals(turn.getEchoRejected()) ? 1 : 0);
            step.setRecordedAt(turn.getRecordedAt());
            TaskStepEntity previous = existingSteps.get(turn.getIndex());
            String audioId = StringUtils.trimToNull(turn.getAudioId());
            if (audioId == null && previous != null) {
                audioId = previous.getAudioId();
            }
            step.setAudioId(audioId);
            String expected = StringUtils.trimToNull(turn.getExpectedText());
            if (expected == null && previous != null) {
                expected = previous.getExpectedText();
            }
            step.setExpectedText(expected);
            if (turn.getMetrics() != null && !turn.getMetrics().isEmpty()) {
                step.setMetrics(turn.getMetrics());
                step.setAssessedAt(turn.getAssessedAt());
            } else if (previous != null) {
                step.setMetrics(previous.getMetrics());
                step.setAssessedAt(
                        turn.getAssessedAt() != null ? turn.getAssessedAt() : previous.getAssessedAt());
            } else {
                step.setMetrics(null);
                step.setAssessedAt(turn.getAssessedAt());
            }
            stepDao.insert(step);
        }
        List<TaskEventDTO> events = body.getEvents() == null ? List.of() : body.getEvents();
        for (TaskEventDTO event : events) {
            if (event == null || StringUtils.isBlank(event.getName())) {
                continue;
            }
            TaskEventEntity row = new TaskEventEntity();
            row.setInstanceId(id);
            row.setName(event.getName());
            row.setAppId(StringUtils.defaultIfBlank(event.getAppId(), body.getAppId()));
            row.setTaskId(StringUtils.defaultIfBlank(event.getTaskId(), body.getTaskId()));
            row.setAtMs(event.getAt());
            eventDao.insert(row);
        }
    }
    public TaskInstanceUpsertDTO getDetail(String id) {
        if (StringUtils.isBlank(id)) {
            return null;
        }
        TaskInstanceEntity entity = instanceDao.selectById(id);
        if (entity == null) {
            return null;
        }
        TaskInstanceUpsertDTO dto = new TaskInstanceUpsertDTO();
        dto.setTaskSessionId(entity.getId());
        dto.setWsSessionId(entity.getWsSessionId());
        dto.setDeviceId(entity.getMacAddress());
        dto.setAgentId(entity.getAgentId());
        dto.setAppId(entity.getAppId());
        dto.setTaskId(entity.getTaskId());
        dto.setSource(entity.getSource());
        dto.setInteractionMode(entity.getInteractionMode());
        dto.setStartedAt(entity.getStartedAt());
        dto.setEndedAt(entity.getEndedAt());
        dto.setStatus(entity.getStatus());
        dto.setEndReason(entity.getEndReason());
        List<TaskStepEntity> steps = stepDao.selectList(
                new LambdaQueryWrapper<TaskStepEntity>().eq(TaskStepEntity::getInstanceId, id)
                        .orderByAsc(TaskStepEntity::getStepIndex));
        for (TaskStepEntity step : steps) {
            TaskTurnDTO turn = new TaskTurnDTO();
            turn.setIndex(step.getStepIndex());
            turn.setPromptText(step.getPromptText());
            turn.setExpectedText(step.getExpectedText());
            turn.setAsrText(step.getAsrText());
            turn.setEchoRejected(step.getEchoRejected() != null && step.getEchoRejected() == 1);
            turn.setRecordedAt(step.getRecordedAt());
            turn.setAudioId(step.getAudioId());
            turn.setMetrics(step.getMetrics());
            turn.setAssessedAt(step.getAssessedAt());
            dto.getTurns().add(turn);
        }
        List<TaskEventEntity> events = eventDao.selectList(
                new LambdaQueryWrapper<TaskEventEntity>().eq(TaskEventEntity::getInstanceId, id)
                        .orderByAsc(TaskEventEntity::getId));
        for (TaskEventEntity event : events) {
            TaskEventDTO item = new TaskEventDTO();
            item.setName(event.getName());
            item.setAppId(event.getAppId());
            item.setTaskId(event.getTaskId());
            item.setAt(event.getAtMs());
            dto.getEvents().add(item);
        }
        return dto;
    }
    public TaskInstanceListDTO listByMac(String macAddress, String appId, Integer limit) {
        if (StringUtils.isBlank(macAddress)) {
            throw new RenException(ErrorCode.PARAM_VALUE_NULL);
        }
        int size = limit == null ? 20 : Math.min(Math.max(limit, 1), LIST_MAX);
        LambdaQueryWrapper<TaskInstanceEntity> wrapper = new LambdaQueryWrapper<TaskInstanceEntity>()
                .eq(TaskInstanceEntity::getMacAddress, macAddress)
                .eq(StringUtils.isNotBlank(appId), TaskInstanceEntity::getAppId, appId)
                .orderByDesc(TaskInstanceEntity::getStartedAt)
                .last("LIMIT " + size);
        List<TaskInstanceEntity> rows = instanceDao.selectList(wrapper);
        Long total = instanceDao.selectCount(new LambdaQueryWrapper<TaskInstanceEntity>()
                .eq(TaskInstanceEntity::getMacAddress, macAddress)
                .eq(StringUtils.isNotBlank(appId), TaskInstanceEntity::getAppId, appId));
        TaskInstanceListDTO result = new TaskInstanceListDTO();
        result.setTotal(total == null ? 0 : total);
        for (TaskInstanceEntity row : rows) {
            TaskInstanceListItemDTO item = new TaskInstanceListItemDTO();
            item.setId(row.getId());
            item.setAppId(row.getAppId());
            item.setTaskId(row.getTaskId());
            item.setStatus(row.getStatus());
            item.setEndReason(row.getEndReason());
            item.setStartedAt(row.getStartedAt());
            result.getList().add(item);
        }
        return result;
    }
    @Transactional(rollbackFor = Exception.class)
    public void attachAudio(String instanceId, Integer stepIndex, String audioId) {
        if (StringUtils.isBlank(instanceId) || stepIndex == null || StringUtils.isBlank(audioId)) {
            return;
        }
        stepDao.updateAudioId(instanceId, stepIndex, audioId);
    }
    private Map<Integer, TaskStepEntity> loadExistingSteps(String instanceId) {
        Map<Integer, TaskStepEntity> map = new HashMap<>();
        List<TaskStepEntity> steps = stepDao.selectList(
                new LambdaQueryWrapper<TaskStepEntity>().eq(TaskStepEntity::getInstanceId, instanceId));
        for (TaskStepEntity step : steps) {
            if (step.getStepIndex() != null) {
                map.put(step.getStepIndex(), step);
            }
        }
        return map;
    }
    private void validateLayoutId(String id, String field) {
        if (StringUtils.isBlank(id) || !LAYOUT_ID.matcher(id).matches()) {
            throw new RenException("invalid " + field);
        }
    }
}
xiaozhi-esp32-server/main/manager-api/src/main/resources/db/changelog/202504151206.sql
@@ -20,7 +20,7 @@
-- 增加是否允许用户注册参数
delete from `sys_params` where  id in (103,104);
INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark) VALUES (103, 'server.allow_user_register', 'false', 'boolean', 1, '是否运行管理员以外的人注册');
INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark) VALUES (104, 'server.fronted_url', 'http://192.168.0.107', 'string', 1, '下发六位验证码时显示的控制面板地址');
INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark) VALUES (104, 'server.fronted_url', 'http://xiaozhi.server.com', 'string', 1, '下发六位验证码时显示的控制面板地址');
-- 修正CosyVoiceSiliconflow音色
delete from `ai_tts_voice` where tts_model_id = 'TTS_CosyVoiceSiliconflow';
xiaozhi-esp32-server/main/manager-api/src/main/resources/db/changelog/202608311800.sql
New file
@@ -0,0 +1,52 @@
-- Phase 3 TaskInstance persistence (Session JSON projection). Do not DROP.
CREATE TABLE `ai_task_instance`
(
    `id`                VARCHAR(32)  NOT NULL COMMENT '主键=task_session_id',
    `mac_address`       VARCHAR(50)  DEFAULT NULL COMMENT '设备MAC',
    `agent_id`          VARCHAR(32)  DEFAULT NULL COMMENT '智能体id,可空',
    `ws_session_id`     VARCHAR(64)  DEFAULT NULL COMMENT 'WS连接级session_id',
    `app_id`            VARCHAR(32)  NOT NULL COMMENT 'App唯一名',
    `task_id`           VARCHAR(32)  NOT NULL COMMENT 'Task id',
    `source`            VARCHAR(24)  DEFAULT NULL COMMENT 'voice/mqtt_immediate/schedule',
    `interaction_mode`  VARCHAR(32)  DEFAULT NULL COMMENT 'SHADOWING/PLAY_AUDIO/...',
    `status`            VARCHAR(16)  NOT NULL DEFAULT 'running' COMMENT 'running/completed/cancelled',
    `end_reason`        VARCHAR(32)  DEFAULT NULL COMMENT 'finished/user_exit/disconnected/...',
    `started_at`        BIGINT       DEFAULT NULL COMMENT 'epoch ms',
    `ended_at`          BIGINT       DEFAULT NULL COMMENT 'epoch ms',
    `created_at`        DATETIME(3)  DEFAULT CURRENT_TIMESTAMP(3) NOT NULL COMMENT '创建时间',
    `updated_at`        DATETIME(3)  DEFAULT CURRENT_TIMESTAMP(3) NOT NULL ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
    PRIMARY KEY (`id`),
    KEY `idx_task_instance_mac_started` (`mac_address`, `started_at`),
    KEY `idx_task_instance_app_task_started` (`app_id`, `task_id`, `started_at`),
    KEY `idx_task_instance_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Task实例(跟读/复读会话)';
CREATE TABLE `ai_task_step`
(
    `id`             BIGINT       NOT NULL AUTO_INCREMENT COMMENT '主键',
    `instance_id`    VARCHAR(32)  NOT NULL COMMENT 'ai_task_instance.id',
    `step_index`     INT          NOT NULL COMMENT 'turns[].index',
    `prompt_text`    TEXT         COMMENT '范读文本或SD路径',
    `asr_text`       TEXT         COMMENT '用户ASR',
    `echo_rejected`  TINYINT(1)   NOT NULL DEFAULT 0 COMMENT '是否回声拒绝',
    `audio_id`       VARCHAR(32)  DEFAULT NULL COMMENT 'ai_agent_chat_audio.id',
    `recorded_at`    BIGINT       DEFAULT NULL COMMENT 'epoch ms',
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_task_step_instance_index` (`instance_id`, `step_index`),
    KEY `idx_task_step_audio` (`audio_id`),
    CONSTRAINT `fk_task_step_instance` FOREIGN KEY (`instance_id`) REFERENCES `ai_task_instance` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Task步骤(跟读一轮)';
CREATE TABLE `ai_task_event`
(
    `id`           BIGINT       NOT NULL AUTO_INCREMENT COMMENT '主键',
    `instance_id`  VARCHAR(32)  NOT NULL COMMENT 'ai_task_instance.id',
    `name`         VARCHAR(64)  NOT NULL COMMENT 'audio.completed/audio.stopped/listen_timeout/...',
    `app_id`       VARCHAR(32)  DEFAULT NULL,
    `task_id`      VARCHAR(32)  DEFAULT NULL,
    `at_ms`        BIGINT       DEFAULT NULL COMMENT 'JSON at,epoch ms',
    PRIMARY KEY (`id`),
    KEY `idx_task_event_instance` (`instance_id`),
    CONSTRAINT `fk_task_event_instance` FOREIGN KEY (`instance_id`) REFERENCES `ai_task_instance` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Task事件';
xiaozhi-esp32-server/main/manager-api/src/main/resources/db/changelog/202609011100.sql
New file
@@ -0,0 +1,6 @@
-- Phase 4 deferred assessment columns. Do not DROP. Do not add passed/grade.
ALTER TABLE `ai_task_step`
    ADD COLUMN `expected_text` TEXT NULL COMMENT '范读原文(txt 契约,非 SD 路径)' AFTER `prompt_text`,
    ADD COLUMN `metrics` JSON NULL COMMENT '定量指标 [{method,value}]' AFTER `audio_id`,
    ADD COLUMN `assessed_at` BIGINT NULL COMMENT '后评估完成 epoch ms' AFTER `metrics`;
xiaozhi-esp32-server/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml
@@ -739,3 +739,17 @@
        - sqlFile:
            encoding: utf8
            path: classpath:db/changelog/202608131030.sql
  - changeSet:
      id: 202608311800
      author: xiaozhi
      changes:
        - sqlFile:
            encoding: utf8
            path: classpath:db/changelog/202608311800.sql
  - changeSet:
      id: 202609011100
      author: xiaozhi
      changes:
        - sqlFile:
            encoding: utf8
            path: classpath:db/changelog/202609011100.sql
xiaozhi-esp32-server/main/xiaozhi-server/config.yaml
@@ -56,7 +56,13 @@
  data_dir: data
# 使用完声音文件后删除文件(Delete the sound file when you are done using it)
# 这只影响 ASR/TTS 临时文件,不影响下面的 audio_archive 归档,也不影响智控台聊天记录
delete_audio: true
# 按设备编号 / 场景模式 / 日期归档用户音频,便于回放和分析
# 路径:data/recordings/{设备MAC}/{chat|shadowing|repeater|translation|bilingual}/{日期}/{时分秒毫秒}_asr.wav
audio_archive:
  enabled: true
  dir: data/recordings
# 没有语音输入多久后断开连接(秒),默认2分钟,即120秒
close_connection_no_voice_time: 120
# TTS请求超时时间(秒)
@@ -70,6 +76,14 @@
# Phase 1:进入 chat.conversation 后主动开口
enable_proactive_greeting: true
proactive_chat_greeting: 我们来聊天吧。
# 跟读模式默认句子,可按课程替换
app_runtime:
  shadowing_sentences:
    - "Hello, how are you today?"
    - "I like to read English books."
    - "The weather is nice this morning."
    - "Can you say that again, please?"
    - "Practice a little every day."
# 说完话是否开启提示音
enable_stop_tts_notify: false
# 说完话是否开启提示音,音效地址
@@ -275,6 +289,7 @@
      - play_music
      - switch_app
      - start_repeater
      - start_shadowing
  function_call:
    # 不需要动type
    type: function_call
@@ -293,6 +308,7 @@
      - play_music
      - switch_app
      - start_repeater
      - start_shadowing
      #- hass_state
      #- hass_play_music
xiaozhi-esp32-server/main/xiaozhi-server/config/assets/wakeup_words/ed76d459636c2481aec828516c1b4f54.wav
Binary files differ
xiaozhi-esp32-server/main/xiaozhi-server/config/manage_api_client.py
@@ -221,31 +221,55 @@
async def report(
    mac_address: str, session_id: str, chat_type: int, content: str, audio, report_time
    mac_address: str, session_id: str, chat_type: int, content: str, audio, report_time,
    task_instance_id: str = None, step_index: int = None,
) -> Optional[Dict]:
    """异步聊天记录上报"""
    if not content or not ManageApiClient._instance:
        return None
    try:
        payload = {
            "macAddress": mac_address,
            "sessionId": session_id,
            "chatType": chat_type,
            "content": content,
            "reportTime": report_time,
            "audioBase64": (
                base64.b64encode(audio).decode("utf-8") if audio else None
            ),
        }
        if task_instance_id:
            payload["taskInstanceId"] = task_instance_id
        if step_index is not None:
            payload["stepIndex"] = step_index
        return await ManageApiClient._instance._execute_async_request(
            "POST",
            f"/agent/chat-history/report",
            json={
                "macAddress": mac_address,
                "sessionId": session_id,
                "chatType": chat_type,
                "content": content,
                "reportTime": report_time,
                "audioBase64": (
                    base64.b64encode(audio).decode("utf-8") if audio else None
                ),
            },
            json=payload,
        )
    except Exception as e:
        print(f"TTS上报失败: {e}")
        return None
async def upsert_task_instance(payload: Dict) -> Optional[Dict]:
    """上报 TaskSession 全文。失败返回 None,不抛给语音热路径。未配置管理端视为成功。"""
    if not payload or not ManageApiClient._instance:
        return {"skipped": True}
    instance_id = payload.get("task_session_id") or ""
    if not instance_id:
        return {"skipped": True}
    try:
        return await ManageApiClient._instance._async_request(
            "PUT",
            f"/task/instance/{instance_id}",
            json=payload,
        )
    except Exception as e:
        print(f"TaskInstance 上报失败: {e}")
        return None
async def lookup_address_book(caller_mac: str, nickname: str) -> Optional[Dict]:
    """根据昵称查找目标设备"""
    if not ManageApiClient._instance:
xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/__init__.py
@@ -1,7 +1,8 @@
"""Phase 1 App Runtime: chat / english_learning / english_translation."""
"""App Runtime: chat / english_learning / english_translation / english_conversation."""
from .registry import get_app, get_task, get_registry, default_app_id
from .runtime import start_app_task, switch_app, filter_tools, on_user_wakeup
from .session import TaskSession, parse_skill_event
__all__ = [
    "get_app",
@@ -12,4 +13,6 @@
    "switch_app",
    "filter_tools",
    "on_user_wakeup",
    "TaskSession",
    "parse_skill_event",
]
xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/apps.json
@@ -1,47 +1,131 @@
{
  "default_app_id": "chat",
  "voice_menu_phrases": ["切换模式", "模式切换", "qie huan mo shi", "mo shi qie huan"],
  "reserved_voice_enter": ["数字背诵", "成语接龙", "口算", "加减法"],
  "apps": {
    "chat": {
      "name": "聊天",
      "display_name": "对话模式",
      "mode": "CHAT",
      "default_task": "conversation",
      "tool_policy": "inherit_agent",
      "system_prompt_mode": "agent_proactive",
      "tasks": {
        "conversation": {
          "display_name": "对话模式",
          "menu_name": "聊天",
          "interaction_mode": "CHAT",
          "enter_prompt": "",
          "voice_enter": [
            "我们来聊天吧",
            "我们聊天吧",
            "聊会天吧",
            "聊聊天吧",
            "回到聊天",
            "返回聊天",
            "回去聊天",
            "回到默认",
            "退出模式",
            "结束模式",
            "退出对话",
            "结束对话"
          ],
          "voice_exit": [],
          "skills": []
        }
      }
    },
    "english_learning": {
      "name": "英语学习",
      "name": "英语听力",
      "mode": "PLAY_AUDIO",
      "default_task": "repeater",
      "tool_policy": "allowlist",
      "tools": [
        "switch_app",
        "start_repeater",
        "start_shadowing",
        "self_mp3_play",
        "self_mp3_stop",
        "self_mp3_get_list",
        "self_sd_ensure_app",
        "self_power_off"
      ],
      "system_prompt_mode": "none",
      "tasks": {
        "repeater": {
          "display_name": "复读机模式",
          "menu_name": "听复读机",
          "enter_prompt": "现在已经切换至复读机模式,只循环播放音频。",
          "voice_enter": ["开始复读", "打开复读", "英语学习", "听复读", "复读机"],
          "voice_exit": ["退出复读", "结束复读", "停止复读", "关掉复读", "退出复读机"],
          "interaction_mode": "PLAY_AUDIO",
          "skills": [
            {
              "name": "self.mp3.play",
              "args_from": ["name", "repeat", "power_off_after"]
              "args_from": ["name", "repeat", "power_off_after", "speed"]
            }
          ],
          "playback": {
            "speed": 1.0,
            "daily_speeds": [0.8, 0.9, 1.0, 1.05, 1.1, 1.15, 1.2],
            "repeat_speeds": [0.8, 0.9, 1.0, 1.1, 1.2]
          },
          "default_playlist": [
            {
              "name": "/english_learning/shadowing/lessons/unit01/01.mp3",
              "repeat": 10,
              "speed": 1.0
            },
            {
              "name": "/english_learning/shadowing/lessons/unit01/02.mp3",
              "repeat": 5,
              "speed": 1.0
            }
          ]
        },
        "shadowing": {
          "display_name": "跟读模式",
          "menu_name": "跟读",
          "enter_prompt": "现在已经切换至跟读模式,请跟着音频读。",
          "voice_enter": ["开始跟读", "打开跟读", "跟读模式", "我要跟读", "跟读", "开始听读", "打开听读", "听读模式", "听读"],
          "voice_exit": ["退出跟读", "结束跟读", "停止跟读", "关掉跟读", "不想跟读", "退出听读", "结束听读"],
          "interaction_mode": "SHADOWING",
          "listen_ratio": 1.2,
          "playback": {
            "speed": 1.0
          },
          "assessment": {
            "enabled": true,
            "methods": ["text_similarity_v0"],
            "defer": true,
            "endpoint": ""
          },
          "skills": [],
          "default_playlist": [
            {
              "name": "/english_learning/shadowing/lessons/unit01/01.mp3",
              "repeat": 5,
              "speed": 1.0
            },
            {
              "name": "/english_learning/shadowing/lessons/unit01/02.mp3",
              "repeat": 10,
              "speed": 1.0
            }
          ],
          "sentences": [
            "Hello, how are you today?",
            "I like to read English books.",
            "The weather is nice this morning.",
            "Can you say that again, please?",
            "Practice a little every day."
          ]
        }
      }
    },
    "english_translation": {
      "name": "英语翻译",
      "display_name": "翻译模式",
      "mode": "TRANSLATION",
      "default_task": "live_translate",
      "tool_policy": "allowlist",
@@ -49,6 +133,11 @@
      "system_prompt_mode": "translator",
      "tasks": {
        "live_translate": {
          "display_name": "翻译模式",
          "menu_name": "翻译",
          "enter_prompt": "现在已经切换至翻译模式,自动进行中英互译。",
          "voice_enter": ["打开翻译", "开始翻译", "英语翻译", "翻译模式", "翻译"],
          "voice_exit": ["退出翻译", "结束翻译", "停止翻译", "关掉翻译", "不想翻译"],
          "interaction_mode": "TRANSLATION",
          "skills": [
            {
@@ -58,6 +147,26 @@
          ]
        }
      }
    },
    "english_conversation": {
      "name": "中英对话",
      "display_name": "中英对话模式",
      "mode": "BILINGUAL",
      "default_task": "bilingual_chat",
      "tool_policy": "inherit_agent",
      "tools": ["switch_app"],
      "system_prompt_mode": "bilingual",
      "tasks": {
        "bilingual_chat": {
          "display_name": "中英对话模式",
          "menu_name": "中英对话",
          "enter_prompt": "现在已经切换至中英对话模式。你说中文,我用英文回答;你说英文,我用中文回答。",
          "voice_enter": ["打开中英对话", "中英对话模式", "中英对话"],
          "voice_exit": ["退出中英对话", "结束中英对话"],
          "interaction_mode": "BILINGUAL",
          "skills": []
        }
      }
    }
  }
}
xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/assessment.py
New file
@@ -0,0 +1,387 @@
"""Deferred quantitative assessment. Never drives playback or speak()."""
from __future__ import annotations
import difflib
import os
import re
from typing import Any, Dict, List, Optional
from .playback import PLAY_SPEED_DEFAULT, clamp_play_speed
LISTEN_RATIO_DEFAULT = 1.2
LISTEN_RATIO_MIN = 1.2
LISTEN_RATIO_MAX = 1.5
LISTEN_WINDOW_MIN_MS = 800
METHOD_TEXT_SIMILARITY_V0 = "text_similarity_v0"
def looks_like_path(text: str) -> bool:
    """True if text is an SD / file path, not a transcript line."""
    raw = (text or "").strip().replace("\\", "/")
    if not raw:
        return False
    lower = raw.lower()
    if lower.endswith((".mp3", ".txt", ".wav", ".opus", ".p3")):
        return True
    if "/lessons/" in lower:
        return True
    if raw.startswith("/") and "/" in raw[1:]:
        return True
    return False
def clip_stem(clip: str) -> str:
    base = os.path.basename((clip or "").replace("\\", "/"))
    return os.path.splitext(base)[0]
def clip_basename(clip: str) -> str:
    return os.path.basename((clip or "").replace("\\", "/"))
def normalize(text: str) -> str:
    from core.utils.util import remove_punctuation_and_length
    _, filtered = remove_punctuation_and_length(text or "")
    return re.sub(r"\s+", "", (filtered or "").lower())
def text_similarity_v0(asr_text: str, expected_text: str) -> float:
    a = normalize(asr_text)
    b = normalize(expected_text)
    if not a or not b:
        return 0.0
    value = float(difflib.SequenceMatcher(None, a, b).ratio())
    if value < 0.0:
        return 0.0
    if value > 1.0:
        return 1.0
    return value
def build_metrics(asr_text: str, expected_text: str) -> List[Dict[str, Any]]:
    """Return [{method, value}] or []. Never includes passed/grade."""
    expected = (expected_text or "").strip()
    asr = (asr_text or "").strip()
    if not asr or not expected or looks_like_path(expected):
        return []
    return [
        {
            "method": METHOD_TEXT_SIMILARITY_V0,
            "value": text_similarity_v0(asr, expected),
        }
    ]
def silent_pcm_snapshot(duration_ms: int = 200, sample_rate: int = 16000) -> list:
    """Placeholder PCM so a silent follow-read still writes a wav."""
    samples = max(1, int(sample_rate * max(duration_ms, 1) / 1000.0))
    return [b"\x00\x00" * samples]
def pcm_snapshot_to_wav(snapshot: Any, sample_rate: int = 16000) -> bytes:
    """UDP/ASR PCM frames (list[bytes] or bytes) → WAV bytes. Empty input → b''."""
    if not snapshot:
        return b""
    if isinstance(snapshot, (bytes, bytearray, memoryview)):
        pcm = bytes(snapshot)
    elif isinstance(snapshot, list):
        parts = []
        for item in snapshot:
            if isinstance(item, (bytes, bytearray, memoryview)):
                parts.append(bytes(item))
            elif isinstance(item, str):
                continue
            else:
                try:
                    parts.append(bytes(item))
                except Exception:
                    continue
        pcm = b"".join(parts)
    else:
        return b""
    if len(pcm) % 2 == 1:
        pcm = pcm[:-1]
    if not pcm:
        return b""
    header = bytearray()
    header.extend(b"RIFF")
    header.extend((36 + len(pcm)).to_bytes(4, "little"))
    header.extend(b"WAVE")
    header.extend(b"fmt ")
    header.extend((16).to_bytes(4, "little"))
    header.extend((1).to_bytes(2, "little"))
    header.extend((1).to_bytes(2, "little"))
    header.extend(int(sample_rate).to_bytes(4, "little"))
    header.extend((int(sample_rate) * 2).to_bytes(4, "little"))
    header.extend((2).to_bytes(2, "little"))
    header.extend((16).to_bytes(2, "little"))
    header.extend(b"data")
    header.extend(len(pcm).to_bytes(4, "little"))
    return bytes(header) + pcm
def save_turn_wav(
    session_id: str,
    step_index: int,
    wav_bytes: bytes,
    conn: Any = None,
) -> Optional[str]:
    """Persist the follow-read utterance. Returns relative audio_id or None."""
    if not wav_bytes:
        return None
    extra = f"{session_id}_{int(step_index)}" if session_id else str(int(step_index))
    if conn is not None:
        from core.audio_archive import archive_wav
        archived = archive_wav(conn, wav_bytes, kind="asr", extra=extra)
        if archived:
            return archived
    if not session_id:
        return None
    try:
        from config.config_loader import get_project_dir
        directory = os.path.join(get_project_dir(), "data", "task_sessions", "audio")
        os.makedirs(directory, exist_ok=True)
        filename = f"{session_id}_{int(step_index)}.wav"
        path = os.path.join(directory, filename)
        with open(path, "wb") as handle:
            handle.write(wav_bytes)
        return filename
    except Exception:
        return None
def _metrics_from_api_payload(data: Any) -> List[Dict[str, Any]]:
    if not isinstance(data, dict):
        return []
    raw = data.get("metrics")
    if not isinstance(raw, list):
        if data.get("method") and data.get("value") is not None:
            raw = [data]
        else:
            raw = []
    out: List[Dict[str, Any]] = []
    for item in raw:
        if not isinstance(item, dict):
            continue
        method = str(item.get("method") or "").strip()
        if not method:
            continue
        try:
            value = float(item.get("value"))
        except (TypeError, ValueError):
            continue
        if value < 0.0:
            value = 0.0
        if value > 1.0:
            value = 1.0
        out.append({"method": method, "value": value})
    return out
def call_assessment_endpoint(
    endpoint: str,
    asr_text: str,
    expected_text: str,
    wav_bytes: bytes,
    extra: Optional[Dict[str, Any]] = None,
) -> List[Dict[str, Any]]:
    """POST ASR text + WAV to the scoring API. Failures return [] and never raise."""
    url = (endpoint or "").strip()
    if not url:
        return []
    try:
        import httpx
    except Exception:
        return []
    files = None
    data = {
        "asr_text": asr_text or "",
        "expected_text": expected_text or "",
    }
    if extra:
        for key, value in extra.items():
            if value is None:
                continue
            data[str(key)] = str(value)
    if wav_bytes:
        files = {"audio": ("utterance.wav", wav_bytes, "audio/wav")}
    try:
        with httpx.Client(timeout=8.0) as client:
            response = client.post(url, data=data, files=files)
        if response.status_code >= 400:
            return []
        try:
            payload = response.json()
        except Exception:
            return []
        return _metrics_from_api_payload(payload)
    except Exception:
        return []
def evaluate_turn(
    asr_text: str,
    expected_text: str,
    wav_bytes: bytes = b"",
    cfg: Optional[Dict[str, Any]] = None,
    extra: Optional[Dict[str, Any]] = None,
) -> List[Dict[str, Any]]:
    """Quantitative follow-read score. Text similarity always; HTTP endpoint optional.
    Never speaks, never advances playback. Missing expected text still saves audio
    and may still call the remote API.
    """
    cfg = cfg or {}
    by_method = {item["method"]: item for item in build_metrics(asr_text, expected_text)}
    endpoint = str(cfg.get("endpoint") or cfg.get("url") or "").strip()
    if endpoint:
        for item in call_assessment_endpoint(
            endpoint, asr_text or "", expected_text or "", wav_bytes or b"", extra
        ):
            by_method[item["method"]] = item
    return list(by_method.values())
def clamp_listen_ratio(raw: Any) -> float:
    try:
        value = float(raw)
    except (TypeError, ValueError):
        return LISTEN_RATIO_DEFAULT
    if value < LISTEN_RATIO_MIN:
        return LISTEN_RATIO_MIN
    if value > LISTEN_RATIO_MAX:
        return LISTEN_RATIO_MAX
    return value
def resolve_listen_ratio(conn: Any = None, task: Optional[Dict[str, Any]] = None) -> float:
    """v0: task JSON constant. Hook for later per-user ratios."""
    if task and task.get("listen_ratio") is not None:
        return clamp_listen_ratio(task.get("listen_ratio"))
    if conn is not None:
        cfg = (getattr(conn, "config", None) or {}).get("app_runtime") or {}
        if cfg.get("listen_ratio") is not None:
            return clamp_listen_ratio(cfg.get("listen_ratio"))
    return LISTEN_RATIO_DEFAULT
def listen_window_ms(
    duration_ms: Any,
    ratio: Any = None,
    min_ms: int = LISTEN_WINDOW_MIN_MS,
    speed: Any = None,
    wall_clock: bool = True,
) -> int:
    """Pause after a shadowing clip. Not a fixed 2.5s.
    Firmware reports wall-clock play time, which already includes speed
    (slower rate → longer duration_ms). Window = that time × listen_ratio (1.2).
    If duration_ms is the original file length, pass wall_clock=False and speed.
    """
    clamped = clamp_listen_ratio(ratio if ratio is not None else LISTEN_RATIO_DEFAULT)
    duration = 0
    try:
        duration = int(duration_ms or 0)
    except (TypeError, ValueError):
        duration = 0
    if duration <= 0:
        return int(min_ms)
    played = duration
    if not wall_clock:
        rate = clamp_play_speed(speed) if speed is not None else PLAY_SPEED_DEFAULT
        if rate <= 0:
            rate = PLAY_SPEED_DEFAULT
        played = int(round(duration / rate))
    return max(int(min_ms), int(played * clamped))
def transcript_cache_dir(app_id: str = "", task_id: str = "") -> Optional[str]:
    try:
        from config.config_loader import get_project_dir
        path = os.path.join(
            get_project_dir(),
            "data",
            "lesson_cache",
            app_id or "",
            task_id or "",
        )
        if os.path.isdir(path):
            return path
    except Exception:
        return None
    return None
def _usable_text(value: Any) -> Optional[str]:
    text = str(value).strip() if value is not None else ""
    if not text or looks_like_path(text):
        return None
    return text
def _read_txt_file(path: str) -> Optional[str]:
    try:
        if not path or not os.path.isfile(path):
            return None
        with open(path, "r", encoding="utf-8") as handle:
            return _usable_text(handle.read())
    except Exception:
        return None
def _read_cached_transcript(clip: str, cache_dir: Optional[str]) -> Optional[str]:
    if not cache_dir or not os.path.isdir(cache_dir) or not clip:
        return None
    stem = clip_stem(clip)
    candidates = [
        os.path.join(cache_dir, f"{stem}.txt"),
        os.path.join(cache_dir, os.path.splitext(clip_basename(clip))[0] + ".txt"),
    ]
    name = (clip or "").replace("\\", "/")
    marker = "/lessons/"
    if marker in name:
        rest = name.split(marker, 1)[1]
        rest_txt = os.path.splitext(rest)[0] + ".txt"
        candidates.append(os.path.join(cache_dir, rest_txt.replace("/", os.sep)))
    for path in candidates:
        text = _read_txt_file(path)
        if text:
            return text
    return None
def resolve_expected_text(
    task: Optional[Dict[str, Any]],
    clips: Optional[List[str]],
    index: int,
    cache_dir: Optional[str] = None,
) -> Optional[str]:
    """01.mp3 ↔ 01.txt contract. Never treat an SD path as expected_text."""
    clips = list(clips or [])
    task = task or {}
    transcripts = task.get("transcripts") if isinstance(task.get("transcripts"), dict) else {}
    sentences = task.get("sentences") or []
    clip = ""
    if 0 <= index < len(clips):
        clip = str(clips[index] or "")
        stem = clip_stem(clip)
        basename = clip_basename(clip)
        for key in (basename, stem, clip):
            found = _usable_text(transcripts.get(key) if key else None)
            if found:
                return found
        cached = _read_cached_transcript(clip, cache_dir)
        if cached:
            return cached
    if 0 <= index < len(sentences):
        found = _usable_text(sentences[index])
        if found:
            return found
    return None
xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/playback.py
New file
@@ -0,0 +1,312 @@
"""SD MP3 playback speed: clamp [0.5, 2.0], default 1.0. Gradients live on Server."""
from __future__ import annotations
import json
import os
import re
import time
from typing import Any, Dict, List, Optional
from config.config_loader import get_project_dir
from config.logger import setup_logging
TAG = __name__
logger = setup_logging()
PLAY_SPEED_MIN = 0.5
PLAY_SPEED_MAX = 2.0
PLAY_SPEED_DEFAULT = 1.0
# 小程序/定时任务下发清单之前的临时课文。路径相对 SD 卡根(/english_learning/...)。
TEMP_REPEATER_PLAYLIST = (
    {
        "name": "/english_learning/shadowing/lessons/unit01/01.mp3",
        "repeat": 10,
        "speed": PLAY_SPEED_DEFAULT,
    },
    {
        "name": "/english_learning/shadowing/lessons/unit01/02.mp3",
        "repeat": 5,
        "speed": PLAY_SPEED_DEFAULT,
    },
)
TEMP_SHADOWING_PLAYLIST = (
    {
        "name": "/english_learning/shadowing/lessons/unit01/01.mp3",
        "repeat": 5,
        "speed": PLAY_SPEED_DEFAULT,
    },
    {
        "name": "/english_learning/shadowing/lessons/unit01/02.mp3",
        "repeat": 10,
        "speed": PLAY_SPEED_DEFAULT,
    },
)
def clamp_play_speed(raw: Any) -> float:
    try:
        value = float(raw)
    except (TypeError, ValueError):
        return PLAY_SPEED_DEFAULT
    if value != value:  # NaN
        return PLAY_SPEED_DEFAULT
    if value < PLAY_SPEED_MIN:
        return PLAY_SPEED_MIN
    if value > PLAY_SPEED_MAX:
        return PLAY_SPEED_MAX
    return value
def playback_config(task: Optional[Dict[str, Any]]) -> Dict[str, Any]:
    cfg = (task or {}).get("playback")
    return cfg if isinstance(cfg, dict) else {}
def _progress_path(device_id: str) -> str:
    safe = re.sub(r"[^A-Za-z0-9_-]+", "_", device_id or "unknown") or "unknown"
    return os.path.join(get_project_dir(), "data", "playback_progress", f"{safe}.json")
def _load_progress(device_id: str) -> Dict[str, Any]:
    path = _progress_path(device_id)
    if not os.path.isfile(path):
        return {}
    try:
        with open(path, "r", encoding="utf-8") as handle:
            data = json.load(handle)
        return data if isinstance(data, dict) else {}
    except Exception as exc:
        logger.bind(tag=TAG).warning(f"读取播放进度失败: {exc}")
        return {}
def _save_progress(device_id: str, data: Dict[str, Any]) -> None:
    path = _progress_path(device_id)
    try:
        os.makedirs(os.path.dirname(path), exist_ok=True)
        with open(path, "w", encoding="utf-8") as handle:
            json.dump(data, handle, ensure_ascii=False, indent=2)
    except Exception as exc:
        logger.bind(tag=TAG).warning(f"保存播放进度失败: {exc}")
def _lesson_key(app_id: str, task_id: str) -> str:
    return f"{app_id or ''}/{task_id or ''}"
def lesson_day_index(
    device_id: str,
    app_id: str,
    task_id: str,
    lesson: str = "",
) -> int:
    """0-based count of distinct calendar days this lesson has already been played."""
    data = _load_progress(device_id)
    bucket = data.get(_lesson_key(app_id, task_id)) or {}
    lessons = bucket.get("lessons") if isinstance(bucket, dict) else {}
    key = lesson or "_default"
    days = lessons.get(key) if isinstance(lessons, dict) else None
    if not isinstance(days, list):
        return 0
    return len([d for d in days if d])
def mark_lesson_played_today(
    device_id: str,
    app_id: str,
    task_id: str,
    lesson: str = "",
) -> None:
    today = time.strftime("%Y-%m-%d")
    data = _load_progress(device_id)
    key = _lesson_key(app_id, task_id)
    bucket = data.get(key)
    if not isinstance(bucket, dict):
        bucket = {}
    lessons = bucket.get("lessons")
    if not isinstance(lessons, dict):
        lessons = {}
    lesson_key = lesson or "_default"
    days = lessons.get(lesson_key)
    if not isinstance(days, list):
        days = []
    if today not in days:
        days.append(today)
    lessons[lesson_key] = days
    bucket["lessons"] = lessons
    data[key] = bucket
    _save_progress(device_id, data)
def daily_base_speed(task: Optional[Dict[str, Any]], day_index: int = 0) -> float:
    cfg = playback_config(task)
    daily = cfg.get("daily_speeds")
    if isinstance(daily, list) and daily:
        idx = max(0, int(day_index or 0))
        if idx >= len(daily):
            idx = len(daily) - 1
        return clamp_play_speed(daily[idx])
    if cfg.get("speed") is not None:
        return clamp_play_speed(cfg.get("speed"))
    return PLAY_SPEED_DEFAULT
def speeds_for_repeats(
    task: Optional[Dict[str, Any]],
    repeat_n: int = 1,
    day_index: int = 0,
) -> List[float]:
    """One speed per pass. repeat=1 uses daily curve; repeat>1 samples repeat_speeds if set."""
    n = 1
    try:
        n = int(repeat_n or 1)
    except (TypeError, ValueError):
        n = 1
    if n < 1:
        n = 1
    base = daily_base_speed(task, day_index)
    curve = playback_config(task).get("repeat_speeds")
    if n == 1 or not isinstance(curve, list) or not curve:
        return [base] * n
    values = [clamp_play_speed(item) for item in curve]
    if n == len(values):
        return values
    if n == 1:
        return [values[0]]
    out: List[float] = []
    last = len(values) - 1
    for i in range(n):
        pos = (i / (n - 1)) * last
        lo = int(pos)
        hi = min(lo + 1, last)
        frac = pos - lo
        out.append(clamp_play_speed(values[lo] * (1.0 - frac) + values[hi] * frac))
    return out
def resolve_play_speed(
    task: Optional[Dict[str, Any]],
    day_index: int = 0,
    explicit: Any = None,
) -> float:
    if explicit is not None:
        return clamp_play_speed(explicit)
    return daily_base_speed(task, day_index)
def normalize_media_name(raw: Any) -> str:
    """SD files[].name, MQTT path, or HTTP url → /app/task/lessons/... form."""
    name = str(raw or "").strip().replace("\\", "/")
    if not name:
        return ""
    for marker in ("/english_learning/", "english_learning/"):
        idx = name.find(marker)
        if idx >= 0:
            rest = name[idx:]
            return rest if rest.startswith("/") else "/" + rest
    if "://" in name:
        path = name.split("://", 1)[1]
        _, _, rest = path.partition("/")
        if rest:
            return "/" + rest.lstrip("/")
        return ""
    if not name.startswith("/"):
        name = "/" + name.lstrip("/")
    return name
def _item_repeat(raw: Dict[str, Any]) -> int:
    for key in ("repeat", "count", "play_count"):
        if raw.get(key) is None:
            continue
        try:
            value = int(raw.get(key))
        except (TypeError, ValueError):
            continue
        return max(1, value)
    return 1
def playlist_item_from_value(raw: Any) -> Optional[Dict[str, Any]]:
    if isinstance(raw, str):
        name = normalize_media_name(raw)
        if not name:
            return None
        return {"name": name, "repeat": 1}
    if not isinstance(raw, dict):
        return None
    name = normalize_media_name(
        raw.get("name")
        or raw.get("path")
        or raw.get("target_path")
        or raw.get("url")
        or ""
    )
    if not name:
        return None
    item: Dict[str, Any] = {"name": name, "repeat": _item_repeat(raw)}
    if raw.get("speed") is not None:
        item["speed"] = clamp_play_speed(raw.get("speed"))
    if raw.get("power_off_after") is not None:
        item["power_off_after"] = bool(raw.get("power_off_after"))
    return item
def parse_playlist(params: Optional[Dict[str, Any]]) -> List[Dict[str, Any]]:
    """Repeater/shadowing playlist. Each item is {name, repeat, optional speed}."""
    params = params or {}
    raw_items = params.get("items") or params.get("playlist") or params.get("clips")
    if isinstance(raw_items, list) and raw_items:
        items = []
        for raw in raw_items:
            item = playlist_item_from_value(raw)
            if item:
                items.append(item)
        return items
    item = playlist_item_from_value(
        {
            "name": params.get("name") or params.get("path") or params.get("url") or "",
            "repeat": params.get("repeat") or params.get("count") or 1,
            "speed": params.get("speed"),
            "power_off_after": params.get("power_off_after"),
        }
    )
    if item and item["name"]:
        return [item]
    return []
def expand_playlist_plays(items: Optional[List[Dict[str, Any]]]) -> List[str]:
    """Flatten {name, repeat} into one path per play. Shadowing uses this so each pass has its own listen window."""
    clips: List[str] = []
    for item in items or []:
        if not isinstance(item, dict):
            continue
        name = str(item.get("name") or "")
        if not name:
            continue
        clips.extend([name] * _item_repeat(item))
    return clips
def default_repeater_playlist(task: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]:
    """No downlink yet: task.default_playlist, else the unit01 01×10 / 02×5 stub."""
    raw = (task or {}).get("default_playlist")
    if isinstance(raw, list) and raw:
        items = parse_playlist({"items": raw})
        if items:
            return items
    return [dict(item) for item in TEMP_REPEATER_PLAYLIST]
def default_shadowing_playlist(task: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]:
    """No downlink yet: task.default_playlist, else the unit01 01×5 / 02×10 stub."""
    raw = (task or {}).get("default_playlist")
    if isinstance(raw, list) and raw:
        items = parse_playlist({"items": raw})
        if items:
            return items
    return [dict(item) for item in TEMP_SHADOWING_PLAYLIST]
xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/registry.py
@@ -1,4 +1,10 @@
"""Load the Phase 1 App / Task registry from JSON."""
"""Load the Phase 1 App / Task registry from JSON.
Registry lives on xiaozhi-server only (bundled apps.json, optional data/apps.json).
Task voice_enter / voice_exit / enter_prompt live here so new Apps do not need a
firmware flash. The device only keeps a small offline MultiNet set (mode switch,
repeater, exit repeater, return to chat) plus NVS pending / last-repeater state.
"""
import json
import os
@@ -11,8 +17,14 @@
logger = setup_logging()
_REGISTRY: Optional[Dict[str, Any]] = None
_VOICE_INDEX: Optional[list] = None
_BUNDLED_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "apps.json")
_MENU_PRIO = 0
_EXIT_PRIO = 1
_CHAT_ENTER_PRIO = 2
_ENTER_PRIO = 3
_RESERVED_PRIO = 4
def _load_registry() -> Dict[str, Any]:
@@ -54,6 +66,93 @@
    return _REGISTRY
def reset_registry_cache() -> None:
    """Tests may swap the registry; drop derived indexes."""
    global _VOICE_INDEX
    _VOICE_INDEX = None
def iter_tasks():
    apps = get_registry().get("apps") or {}
    for app_id, app in apps.items():
        if not isinstance(app, dict):
            continue
        for task_id, task in (app.get("tasks") or {}).items():
            if isinstance(task, dict):
                yield app_id, app, task_id, task
def _phrase_list(raw) -> list:
    if raw is None:
        return []
    if isinstance(raw, str):
        text = raw.strip()
        return [text] if text else []
    phrases = []
    for item in raw:
        text = str(item or "").strip()
        if text:
            phrases.append(text)
    return phrases
def _build_voice_index() -> list:
    """Longer phrase first, then menu/exit/chat-enter/enter/reserved."""
    entries = []
    registry = get_registry()
    for phrase in _phrase_list(registry.get("voice_menu_phrases")):
        entries.append((phrase, ("__menu__", ""), _MENU_PRIO))
    for app_id, app, task_id, task in iter_tasks():
        default_task = app.get("default_task") or task_id
        for phrase in _phrase_list(task.get("voice_exit")):
            entries.append((phrase, ("chat", "conversation"), _EXIT_PRIO))
        for phrase in _phrase_list(task.get("voice_enter")):
            if app_id == "chat":
                entries.append((phrase, ("chat", default_task or task_id), _CHAT_ENTER_PRIO))
            else:
                entries.append((phrase, (app_id, task_id), _ENTER_PRIO))
    for phrase in _phrase_list(registry.get("reserved_voice_enter")):
        entries.append((phrase, ("__reserved__", ""), _RESERVED_PRIO))
    entries.sort(key=lambda item: (-len(item[0]), item[2]))
    return entries
def _voice_index() -> list:
    global _VOICE_INDEX
    if _VOICE_INDEX is None:
        _VOICE_INDEX = _build_voice_index()
    return _VOICE_INDEX
def match_registry_voice(filtered: str) -> Optional[Tuple[str, str]]:
    """Map stripped ASR text to (app_id, task_id) or ('__menu__'|'__reserved__', '')."""
    if not filtered:
        return None
    for phrase, target, _prio in _voice_index():
        if phrase and phrase in filtered:
            return target
    return None
def voice_menu_text() -> str:
    labels = []
    chat_hint = "我们来聊天吧"
    for app_id, _app, task_id, task in iter_tasks():
        enters = _phrase_list(task.get("voice_enter"))
        if app_id == "chat" and task_id == "conversation" and enters:
            chat_hint = enters[0]
        name = str(task.get("menu_name") or "").strip()
        if name:
            labels.append(name)
    if not labels:
        return f"想聊天?也可以说{chat_hint}。"
    if len(labels) == 1:
        body = labels[0]
    else:
        body = "、".join(labels[:-1]) + f",还是{labels[-1]}"
    return f"想{body}?也可以说{chat_hint}。"
def default_app_id() -> str:
    return get_registry().get("default_app_id") or "chat"
xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/runtime.py
@@ -2,13 +2,48 @@
from __future__ import annotations
import asyncio
import json
import difflib
import queue
import random
import re
import uuid
from typing import Any, Dict, Optional, TYPE_CHECKING
from typing import Any, Dict, List, Optional, TYPE_CHECKING
from config.logger import setup_logging
from plugins_func.register import Action, ActionResponse
from .registry import default_app_id, get_app, get_task
from .assessment import (
    evaluate_turn,
    listen_window_ms,
    pcm_snapshot_to_wav,
    resolve_expected_text,
    resolve_listen_ratio,
    save_turn_wav,
    silent_pcm_snapshot,
    transcript_cache_dir,
)
from .playback import (
    clamp_play_speed,
    default_repeater_playlist,
    default_shadowing_playlist,
    expand_playlist_plays,
    lesson_day_index,
    mark_lesson_played_today,
    parse_playlist,
    resolve_play_speed,
    speeds_for_repeats,
)
from .registry import (
    default_app_id,
    get_app,
    get_registry,
    get_task,
    match_registry_voice,
    voice_menu_text,
)
from .session import begin_session, current_session, end_session, parse_skill_event
if TYPE_CHECKING:
    from core.connection import ConnectionHandler
@@ -28,19 +63,37 @@
    "未指定语言时:中文译成英文,英文译成中文;其它语言默认译成中文。"
)
BILINGUAL_PROMPT = (
    "你正在进行中英对照对话练习,不是翻译机,不要把用户的话原样译回去交差。"
    "严格执行:用户说中文时你只用英文完整回答;用户说英文时你只用中文完整回答。"
    "不要中英夹杂,不要同时给出两种语言,不要问要不要翻译。"
    "用户说退出中英对话、回到聊天时调用 switch_app,app_id=chat。"
)
CHAT_PROACTIVE_HINT = (
    "你是用户的语音伙伴。进入对话后可以先主动短句邀请聊天,例如「我们来聊天吧」。"
    "用户开口后按正常对话回答,不要反复催促。"
)
VOICE_MENU = "想聊天、听复读,还是打开翻译?也可以说我们来聊天吧。"
CHAT_RETURN_TOPICS = ("今天过得怎么样", "你最近在忙什么", "有什么新鲜事")
CHAT_PHRASES = ("我们来聊天吧", "聊会天吧", "回到聊天", "退出翻译", "结束翻译", "退出复读")
TRANSLATE_PHRASES = ("打开翻译", "开始翻译", "英语翻译", "翻译模式")
REPEATER_PHRASES = ("开始复读", "打开复读", "英语学习", "听复读")
DEFAULT_SHADOWING_SENTENCES = (
    "Hello, how are you today?",
    "I like to read English books.",
    "The weather is nice this morning.",
    "Can you say that again, please?",
    "Practice a little every day.",
)
DEVICE_PLAY_TOOL = "self_mp3_play"
DEVICE_STOP_TOOL = "self_mp3_stop"
DEVICE_LIST_TOOL = "self_mp3_get_list"
DEVICE_ENSURE_APP_TOOL = "self_sd_ensure_app"
SD_LAYOUT_MODES = ("PLAY_AUDIO", "SHADOWING")
SHADOWING_LISTEN_RATIO = 1.2
SHADOWING_LISTEN_MIN_MS = 800
REPEATER_END_PHRASE = "复读结束"
SHADOWING_END_PHRASE = "跟读结束"
def source_priority(source: str) -> int:
@@ -62,6 +115,150 @@
        conn.tool_allowlist = []
    if not hasattr(conn, "original_system_prompt"):
        conn.original_system_prompt = conn.prompt
    if not hasattr(conn, "shadowing_index"):
        conn.shadowing_index = 0
    if not hasattr(conn, "shadowing_prompt"):
        conn.shadowing_prompt = ""
    if not hasattr(conn, "shadowing_clips"):
        conn.shadowing_clips = []
    if not hasattr(conn, "shadowing_awaiting_user"):
        conn.shadowing_awaiting_user = False
    if not hasattr(conn, "shadowing_clip_gen"):
        conn.shadowing_clip_gen = 0
    if not hasattr(conn, "shadowing_timeout_task"):
        conn.shadowing_timeout_task = None
    if not hasattr(conn, "last_asr_language"):
        conn.last_asr_language = None
    if not hasattr(conn, "task_session"):
        conn.task_session = None
    if not hasattr(conn, "last_asr_audio_snapshot"):
        conn.last_asr_audio_snapshot = None
    if not hasattr(conn, "shadowing_listen_pcm"):
        conn.shadowing_listen_pcm = []
    if not hasattr(conn, "shadowing_audio_saved_index"):
        conn.shadowing_audio_saved_index = -1
    if not hasattr(conn, "ensured_app_dirs"):
        conn.ensured_app_dirs = set()
    if not hasattr(conn, "repeater_speed_queue"):
        conn.repeater_speed_queue = []
    if not hasattr(conn, "repeater_play_name"):
        conn.repeater_play_name = ""
    if not hasattr(conn, "repeater_power_off_after"):
        conn.repeater_power_off_after = False
    if not hasattr(conn, "repeater_playlist"):
        conn.repeater_playlist = []
    if not hasattr(conn, "shadowing_last_speed"):
        conn.shadowing_last_speed = 1.0
    if not hasattr(conn, "shadowing_assess_index"):
        conn.shadowing_assess_index = 0
    if not hasattr(conn, "chat_greeted"):
        conn.chat_greeted = False
    if not hasattr(conn, "pending_play_after_tts"):
        conn.pending_play_after_tts = None
def _task_needs_sd_layout(task: Optional[Dict[str, Any]]) -> bool:
    if not task:
        return False
    mode = task.get("interaction_mode") or ""
    if mode in SD_LAYOUT_MODES:
        return True
    for skill in task.get("skills") or []:
        name = (skill or {}).get("name") or ""
        if name in ("self.mp3.play", DEVICE_PLAY_TOOL):
            return True
    return False
def app_sd_layout_paths(app_id: str, app: Optional[Dict[str, Any]] = None) -> list:
    """Return SD lesson dirs for an App: /{app_id}/{task_id}/lessons."""
    if not app_id:
        return []
    if app is None:
        app = get_app(app_id)
    if not app:
        return []
    paths = []
    for task_id, task in (app.get("tasks") or {}).items():
        if _task_needs_sd_layout(task):
            paths.append(f"/{app_id}/{task_id}/lessons")
    return paths
def _mcp_layout_ok(result) -> bool:
    if result is None:
        return False
    if result.action in (Action.ERROR, Action.NOTFOUND):
        return False
    blob = str(getattr(result, "response", None) or getattr(result, "result", None) or "")
    compact = blob.replace(" ", "").lower()
    if '"ok":false' in compact:
        return False
    return True
async def ensure_app_sd_dirs(conn: "ConnectionHandler", app_id: str) -> bool:
    """Create /{app} and /{app}/{task}/lessons on device SD. Never raises; missing SD is ignored."""
    _ensure_runtime_fields(conn)
    app = get_app(app_id)
    paths = app_sd_layout_paths(app_id, app)
    if not paths:
        return True
    already = getattr(conn, "ensured_app_dirs", None)
    if already is None:
        conn.ensured_app_dirs = set()
        already = conn.ensured_app_dirs
    if app_id in already:
        return True
    try:
        root = await _execute_named_tool(conn, DEVICE_ENSURE_APP_TOOL, {"app_id": app_id, "task_id": ""})
        if not _mcp_layout_ok(root):
            logger.bind(tag=TAG).warning(f"创建设备 App 目录失败 {app_id}: {getattr(root, 'response', root)}")
            return False
        for task_id, task in (app.get("tasks") or {}).items():
            if not _task_needs_sd_layout(task):
                continue
            result = await _execute_named_tool(
                conn, DEVICE_ENSURE_APP_TOOL, {"app_id": app_id, "task_id": task_id}
            )
            if not _mcp_layout_ok(result):
                logger.bind(tag=TAG).warning(
                    f"创建设备 Task 目录失败 {app_id}/{task_id}: {getattr(result, 'response', result)}"
                )
                return False
        already.add(app_id)
        logger.bind(tag=TAG).info(f"已确保 SD 目录 {paths}")
        return True
    except Exception as e:
        logger.bind(tag=TAG).warning(f"创建设备 App 目录异常 {app_id}: {e}")
        return False
async def ensure_all_subscribed_app_sd_dirs(conn: "ConnectionHandler") -> None:
    """After MCP tools/list, mkdir for every registry App that needs SD. Failures do not drop WS."""
    try:
        apps = (get_registry().get("apps") or {})
        for app_id, app in apps.items():
            if app_sd_layout_paths(app_id, app):
                await ensure_app_sd_dirs(conn, app_id)
    except Exception as e:
        logger.bind(tag=TAG).warning(f"订阅 App 建目录失败(忽略): {e}")
def schedule_ensure_subscribed_app_sd_dirs(conn: "ConnectionHandler") -> None:
    """Fire-and-forget so tools/list handling is not blocked waiting for MCP mkdir."""
    async def _run():
        await ensure_all_subscribed_app_sd_dirs(conn)
    try:
        loop = getattr(conn, "loop", None)
        if loop:
            loop.create_task(_run())
        else:
            asyncio.create_task(_run())
    except Exception as e:
        logger.bind(tag=TAG).warning(f"无法调度 App 建目录: {e}")
def filter_tools(conn: "ConnectionHandler", tools: Dict[str, Any]) -> Dict[str, Any]:
@@ -94,6 +291,8 @@
            "你现在是英语翻译机。用户说话后应调用 translate;用户说退出翻译、回到聊天时调用 switch_app,app_id=chat。"
            "不要讲笑话、不要查天气、不要闲聊。"
        )
    elif mode == "bilingual":
        conn.change_system_prompt(BILINGUAL_PROMPT)
    elif mode == "agent_proactive":
        base = conn.original_system_prompt or conn.prompt or ""
        if CHAT_PROACTIVE_HINT not in (base or ""):
@@ -118,13 +317,58 @@
async def _stop_current_skills(conn: "ConnectionHandler") -> None:
    mode = getattr(conn, "interaction_mode", "CHAT")
    if mode == "PLAY_AUDIO":
    _cancel_shadowing_timeout(conn)
    if mode in ("PLAY_AUDIO", "SHADOWING"):
        await _execute_named_tool(conn, DEVICE_STOP_TOOL, {})
    conn.awaiting_voice_app_choice = False
    conn.pending_play_after_tts = None
    conn.shadowing_index = 0
    conn.shadowing_prompt = ""
    conn.shadowing_clips = []
    conn.shadowing_awaiting_user = False
    conn.shadowing_clip_gen = int(getattr(conn, "shadowing_clip_gen", 0) or 0) + 1
    conn.shadowing_assess_index = 0
    conn.shadowing_listen_pcm = []
    conn.shadowing_audio_saved_index = -1
    conn.repeater_speed_queue = []
    conn.repeater_play_name = ""
    conn.repeater_power_off_after = False
    conn.repeater_playlist = []
def tts_channels_ready(conn: "ConnectionHandler") -> bool:
    tts = getattr(conn, "tts", None)
    if tts is None:
        return False
    play = getattr(tts, "audio_play_priority_thread", None)
    text_th = getattr(tts, "tts_priority_thread", None)
    return bool(
        play is not None
        and play.is_alive()
        and text_th is not None
        and text_th.is_alive()
    )
async def wait_for_tts_channels(conn: "ConnectionHandler", timeout: float = 8.0) -> bool:
    loop = asyncio.get_running_loop()
    start = loop.time()
    while (loop.time() - start) < timeout:
        if tts_channels_ready(conn):
            return True
        await asyncio.sleep(0.1)
    return tts_channels_ready(conn)
def _speak(conn: "ConnectionHandler", text: str) -> None:
    if not text:
        return
    tts = getattr(conn, "tts", None)
    if tts is None:
        logger.bind(tag=TAG).warning("TTS未初始化,跳过播报")
        return
    if not tts_channels_ready(conn):
        logger.bind(tag=TAG).warning("TTS通道未就绪,跳过播报")
        return
    from core.handle.intentHandler import speak_txt
@@ -140,6 +384,877 @@
    return (conn.config or {}).get("proactive_chat_greeting") or "我们来聊天吧。"
def _display_name(app: Optional[Dict[str, Any]], task: Optional[Dict[str, Any]] = None) -> str:
    if task and task.get("display_name"):
        return str(task["display_name"])
    if app and app.get("display_name"):
        return str(app["display_name"])
    if app and app.get("name"):
        return str(app["name"])
    return "当前模式"
def _enter_prompt(app: Dict[str, Any], task: Dict[str, Any]) -> str:
    return (task.get("enter_prompt") or app.get("enter_prompt") or "").strip()
def _switch_announcement(
    prev_app: Optional[Dict[str, Any]],
    prev_task: Optional[Dict[str, Any]],
    app: Dict[str, Any],
    task: Dict[str, Any],
    app_id: str,
) -> str:
    if app_id == "chat":
        prev_name = _display_name(prev_app, prev_task)
        topic = random.choice(CHAT_RETURN_TOPICS)
        return f"已经退出{prev_name},现在是对话模式,我们来聊点{topic}吧。"
    return _enter_prompt(app, task) or f"现在已经切换至{_display_name(app, task)}。"
def _shadowing_sentences(conn: "ConnectionHandler") -> list:
    cfg = (conn.config or {}).get("app_runtime") or {}
    sentences = cfg.get("shadowing_sentences")
    if not sentences:
        _, task = get_task("english_learning", "shadowing")
        sentences = (task or {}).get("sentences")
    if not sentences:
        sentences = DEFAULT_SHADOWING_SENTENCES
    return [str(s).strip() for s in sentences if str(s).strip()]
def lesson_mp3_clips(names, app_id: str, task_id: str, lesson_id: str = "") -> list:
    """Pick sentence MP3s under /{app}/{task}/lessons/{lesson}/..."""
    prefix = f"/{app_id}/{task_id}/lessons/"
    clips = []
    for raw in names or []:
        name = str(raw or "").replace("\\", "/")
        if not name.lower().endswith(".mp3"):
            continue
        if not name.startswith("/"):
            name = "/" + name.lstrip("/")
        if not name.startswith(prefix):
            continue
        rest = name[len(prefix) :]
        if not rest:
            continue
        base = rest.rsplit("/", 1)[-1].lower()
        if base == "full.mp3" or base.endswith("_full.mp3"):
            continue
        if lesson_id:
            if rest != lesson_id and not rest.startswith(str(lesson_id) + "/"):
                continue
        clips.append(name)
    clips.sort()
    if lesson_id or not clips:
        return clips
    first_lesson = clips[0][len(prefix) :].split("/", 1)[0]
    return [
        name
        for name in clips
        if name[len(prefix) :] == first_lesson
        or name[len(prefix) :].startswith(first_lesson + "/")
    ]
def repeater_lesson_mp3(names, app_id: str, task_id: str) -> str:
    """Pick the full-lesson MP3 under /{app}/{task}/lessons/, else the first file there."""
    prefix = f"/{app_id}/{task_id}/lessons/"
    fulls = []
    others = []
    for raw in names or []:
        name = str(raw or "").replace("\\", "/")
        if not name.lower().endswith(".mp3"):
            continue
        if not name.startswith("/"):
            name = "/" + name.lstrip("/")
        if not name.startswith(prefix):
            continue
        base = name.rsplit("/", 1)[-1].lower()
        if base == "full.mp3" or base.endswith("_full.mp3"):
            fulls.append(name)
        else:
            others.append(name)
    fulls.sort()
    others.sort()
    if fulls:
        return fulls[0]
    return others[0] if others else ""
def _is_clip_path(text: str) -> bool:
    t = (text or "").replace("\\", "/").lower()
    return t.endswith(".mp3") or t.startswith("/")
def _shadowing_items(conn: "ConnectionHandler") -> list:
    clips = getattr(conn, "shadowing_clips", None) or []
    if clips:
        return list(clips)
    return _shadowing_sentences(conn)
def _begin_shadowing(conn: "ConnectionHandler", enter_text: str) -> str:
    sentences = _shadowing_sentences(conn)
    conn.shadowing_index = 0
    if not sentences:
        conn.shadowing_prompt = ""
        return enter_text or "现在已经切换至跟读模式,但还没有跟读句子。"
    conn.shadowing_prompt = sentences[0]
    prefix = enter_text or "现在已经切换至跟读模式。"
    return f"{prefix}请跟我读:{sentences[0]}"
def _cancel_shadowing_timeout(conn: "ConnectionHandler") -> None:
    task = getattr(conn, "shadowing_timeout_task", None)
    if task is not None:
        try:
            if not task.done():
                task.cancel()
        except Exception:
            pass
    conn.shadowing_timeout_task = None
def _task_for(conn: "ConnectionHandler") -> Optional[Dict[str, Any]]:
    session = current_session(conn) or getattr(conn, "task_session", None)
    app_id = getattr(conn, "active_app_id", None) or getattr(session, "app_id", None) or "english_learning"
    task_id = getattr(conn, "current_task_id", None) or getattr(session, "task_id", None) or "shadowing"
    _, task = get_task(app_id, task_id)
    return task
def _expected_for_index(conn: "ConnectionHandler", index: int) -> Optional[str]:
    session = current_session(conn) or getattr(conn, "task_session", None)
    app_id = getattr(conn, "active_app_id", None) or getattr(session, "app_id", "") or ""
    task_id = getattr(conn, "current_task_id", None) or getattr(session, "task_id", "") or ""
    clips = list(getattr(conn, "shadowing_clips", None) or [])
    return resolve_expected_text(
        _task_for(conn),
        clips,
        index,
        transcript_cache_dir(app_id, task_id),
    )
def apply_deferred_assessment(
    conn: "ConnectionHandler",
    step_index: int,
    session: Any = None,
    clips: Optional[list] = None,
    app_id: str = "",
    task_id: str = "",
    audio_snapshot: Any = None,
    persist_audio: bool = True,
) -> None:
    """Write expected_text + metrics onto the captured session. Never raises into play."""
    import time as _time
    session = session or getattr(conn, "task_session", None)
    if not session:
        return
    app_id = app_id or getattr(conn, "active_app_id", None) or getattr(session, "app_id", "") or ""
    task_id = task_id or getattr(conn, "current_task_id", None) or getattr(session, "task_id", "") or ""
    _, task = get_task(app_id or "english_learning", task_id or "shadowing")
    cfg = (task or {}).get("assessment") or {}
    clip_list = list(clips if clips is not None else (getattr(conn, "shadowing_clips", None) or []))
    turn = session.find_turn(step_index)
    asr = _plain_user_text((turn or {}).get("asr_text") or "")
    expected = (turn or {}).get("expected_text") or resolve_expected_text(
        task, clip_list, step_index, transcript_cache_dir(app_id, task_id)
    )
    snapshot = audio_snapshot
    if snapshot is None:
        snapshot = getattr(conn, "last_asr_audio_snapshot", None)
    wav_bytes = pcm_snapshot_to_wav(snapshot)
    existing_audio = (turn or {}).get("audio_id")
    audio_id = existing_audio
    if persist_audio and not existing_audio:
        audio_id = save_turn_wav(
            getattr(session, "task_session_id", "") or "",
            step_index,
            wav_bytes,
            conn=conn,
        )
    try:
        from core.handle.reportHandle import enqueue_asr_report
        enqueue_asr_report(
            conn,
            asr or "",
            snapshot,
            getattr(session, "task_session_id", None),
            int(step_index),
        )
    except Exception:
        pass
    clip = ""
    if 0 <= int(step_index) < len(clip_list):
        clip = str(clip_list[step_index] or "")
    metrics = []
    if not (cfg and cfg.get("enabled") is False):
        metrics = evaluate_turn(
            asr,
            expected or "",
            wav_bytes,
            cfg,
            extra={"clip": clip, "step_index": step_index},
        )
    session.upsert_turn(
        index=step_index,
        prompt_text=(turn or {}).get("prompt_text") or getattr(conn, "shadowing_prompt", "") or "",
        expected_text=expected,
        metrics=metrics or None,
        assessed_at=int(_time.time() * 1000) if metrics else None,
        audio_id=audio_id,
    )
    session.save()
def _schedule_deferred_assessment(conn: "ConnectionHandler", step_index: int) -> None:
    session = getattr(conn, "task_session", None)
    clips = list(getattr(conn, "shadowing_clips", None) or [])
    app_id = getattr(conn, "active_app_id", None) or getattr(session, "app_id", "") or ""
    task_id = getattr(conn, "current_task_id", None) or getattr(session, "task_id", "") or ""
    audio_snapshot = getattr(conn, "last_asr_audio_snapshot", None)
    async def _run():
        try:
            apply_deferred_assessment(
                conn,
                step_index,
                session=session,
                clips=clips,
                app_id=app_id,
                task_id=task_id,
                audio_snapshot=audio_snapshot,
                persist_audio=False,
            )
        except Exception as exc:
            logger.bind(tag=TAG).warning(f"跟读后评估失败: {exc}")
    loop = getattr(conn, "loop", None)
    try:
        loop = loop or asyncio.get_running_loop()
        loop.create_task(_run())
    except RuntimeError:
        try:
            apply_deferred_assessment(
                conn,
                step_index,
                session=session,
                clips=clips,
                app_id=app_id,
                task_id=task_id,
                audio_snapshot=audio_snapshot,
                persist_audio=False,
            )
        except Exception as exc:
            logger.bind(tag=TAG).warning(f"跟读后评估失败: {exc}")
def _schedule_shadowing_listen_timeout(conn: "ConnectionHandler", duration_ms: int) -> None:
    _cancel_shadowing_timeout(conn)
    ratio = resolve_listen_ratio(conn, _task_for(conn))
    wait_ms = listen_window_ms(
        duration_ms or 0,
        ratio,
        SHADOWING_LISTEN_MIN_MS,
        speed=getattr(conn, "shadowing_last_speed", None),
        wall_clock=True,
    )
    conn.shadowing_awaiting_user = True
    conn.shadowing_assess_index = int(getattr(conn, "shadowing_index", 0) or 0)
    conn.shadowing_audio_saved_index = -1
    conn.shadowing_listen_pcm = []
    gen = int(getattr(conn, "shadowing_clip_gen", 0) or 0)
    logger.bind(tag=TAG).info(
        f"跟读听窗 {wait_ms}ms = 实际播放 {duration_ms}ms × {ratio}"
        f"(倍速已含在实际播放时长里) gen={gen}"
    )
    async def _fire():
        try:
            await asyncio.sleep(wait_ms / 1000.0)
        except asyncio.CancelledError:
            return
        if int(getattr(conn, "shadowing_clip_gen", 0) or 0) != gen:
            return
        if getattr(conn, "interaction_mode", "") != "SHADOWING":
            return
        if not getattr(conn, "shadowing_awaiting_user", False):
            return
        await _on_shadowing_listen_window_closed(conn)
    loop = getattr(conn, "loop", None)
    if loop:
        conn.shadowing_timeout_task = loop.create_task(_fire())
    else:
        conn.shadowing_timeout_task = asyncio.create_task(_fire())
async def close_shadowing_listen_window(conn: "ConnectionHandler") -> None:
    """Device listen stop: archive this step once, cancel the clock, advance index."""
    _cancel_shadowing_timeout(conn)
    await _on_shadowing_listen_window_closed(conn)
def _drain_asr_queue_into_shadowing_pcm(conn: "ConnectionHandler") -> int:
    """Binary opus is queued; listen stop/timeout run on the WS loop first. Take pending PCM now."""
    q = getattr(conn, "asr_audio_queue", None)
    if q is None or not getattr(conn, "shadowing_awaiting_user", False):
        return 0
    drained = 0
    while True:
        try:
            frame = q.get_nowait()
        except (queue.Empty, EOFError, OSError):
            break
        if frame:
            append_shadowing_listen_pcm(conn, frame)
            drained += 1
    if drained:
        logger.bind(tag=TAG).info(f"跟读关窗前抽干 ASR 队列 frames={drained}")
    return drained
def append_shadowing_listen_pcm(conn: "ConnectionHandler", pcm_frame: Any) -> None:
    if getattr(conn, "interaction_mode", "") != "SHADOWING":
        return
    if not getattr(conn, "shadowing_awaiting_user", False):
        return
    if not pcm_frame:
        return
    buf = getattr(conn, "shadowing_listen_pcm", None)
    if buf is None:
        conn.shadowing_listen_pcm = []
        buf = conn.shadowing_listen_pcm
    buf.append(pcm_frame)
def _take_shadowing_listen_pcm(conn: "ConnectionHandler") -> list:
    frames = list(getattr(conn, "shadowing_listen_pcm", None) or [])
    conn.shadowing_listen_pcm = []
    return frames
def save_shadowing_listen_audio(conn: "ConnectionHandler", step_index: Optional[int] = None) -> None:
    """Persist exactly one wav per follow-read step. Never merge neighboring windows."""
    if getattr(conn, "interaction_mode", "") != "SHADOWING":
        return
    index = int(
        step_index
        if step_index is not None
        else (getattr(conn, "shadowing_assess_index", getattr(conn, "shadowing_index", 0)) or 0)
    )
    if int(getattr(conn, "shadowing_audio_saved_index", -1)) == index:
        conn.shadowing_listen_pcm = []
        return
    snapshot = _take_shadowing_listen_pcm(conn)
    if not snapshot:
        logger.bind(tag=TAG).warning(f"跟读听窗无 PCM,写静音占位 step={index}")
        snapshot = silent_pcm_snapshot()
    conn.shadowing_audio_saved_index = index
    from core.app_runtime.session import stash_asr_audio
    stash_asr_audio(conn, snapshot)
    try:
        apply_deferred_assessment(conn, index, audio_snapshot=snapshot)
    except Exception as exc:
        logger.bind(tag=TAG).warning(f"跟读听窗录音落盘失败 step={index}: {exc}")
async def _on_shadowing_listen_window_closed(conn: "ConnectionHandler") -> None:
    """Clock or listen stop: archive this listen. Device already sequences play → record → play."""
    if not getattr(conn, "shadowing_awaiting_user", False):
        return
    _drain_asr_queue_into_shadowing_pcm(conn)
    conn.shadowing_awaiting_user = False
    listened_idx = int(getattr(conn, "shadowing_index", 0) or 0)
    listened_prompt = getattr(conn, "shadowing_prompt", "") or ""
    save_shadowing_listen_audio(conn, listened_idx)
    items = _shadowing_items(conn)
    nxt = listened_idx + 1
    conn.shadowing_index = nxt
    if nxt < len(items):
        conn.shadowing_prompt = items[nxt]
    logger.bind(tag=TAG).info(f"跟读:听窗结束 step={listened_idx}")
    try:
        session = current_session(conn) or getattr(conn, "task_session", None)
        if session:
            if session.find_turn(listened_idx) is None:
                session.upsert_turn(
                    index=listened_idx,
                    prompt_text=listened_prompt,
                    asr_text="",
                    expected_text=_expected_for_index(conn, listened_idx),
                )
            session.add_event("listen_window_closed")
            session.add_event("listen_timeout")
            session.save()
    except Exception as exc:
        logger.bind(tag=TAG).warning(f"跟读听窗归档失败: {exc}")
def _parse_mp3_list_names(raw: Any) -> list:
    text = ""
    if raw is None:
        return []
    if hasattr(raw, "response") or hasattr(raw, "result"):
        text = str(getattr(raw, "response", None) or getattr(raw, "result", None) or "")
    else:
        text = str(raw)
    text = text.strip()
    if not text:
        return []
    try:
        import json
        data = json.loads(text)
    except Exception:
        return []
    files = data.get("files") if isinstance(data, dict) else None
    names = []
    if isinstance(files, list):
        for item in files:
            if isinstance(item, dict) and item.get("name"):
                names.append(str(item["name"]))
            elif isinstance(item, str):
                names.append(item)
    return names
async def _list_sd_mp3_names(conn: "ConnectionHandler") -> list:
    result = await _execute_named_tool(conn, DEVICE_LIST_TOOL, {})
    return _parse_mp3_list_names(result)
def _is_full_lesson_mp3(name: str) -> bool:
    base = str(name or "").replace("\\", "/").rsplit("/", 1)[-1].lower()
    return base == "full.mp3" or base.endswith("_full.mp3")
def _compact_shadowing_playlist(
    conn: "ConnectionHandler",
    params: Optional[Dict[str, Any]],
) -> List[Dict[str, Any]]:
    """Keep {name, repeat, speed}. Device expands repeats locally with a listen window each pass."""
    items = parse_playlist(params)
    if not items:
        items = default_shadowing_playlist(_task_for(conn))
    compact: List[Dict[str, Any]] = []
    for raw in items:
        if not isinstance(raw, dict):
            continue
        name = str(raw.get("name") or "")
        if not name or _is_full_lesson_mp3(name):
            continue
        try:
            repeat_n = int(raw.get("repeat") or 1)
        except (TypeError, ValueError):
            repeat_n = 1
        if repeat_n < 1:
            repeat_n = 1
        compact.append(
            {
                "name": name,
                "repeat": repeat_n,
                "speed": clamp_play_speed(
                    raw["speed"] if raw.get("speed") is not None else 1.0
                ),
                "power_off_after": False,
                "finish_task": False,
            }
        )
    return compact
def _begin_shadowing_playlist(
    conn: "ConnectionHandler",
    params: Optional[Dict[str, Any]],
) -> Dict[str, Any]:
    """One shot: send the whole shadowing playlist. Device plays, records, plays."""
    items = _compact_shadowing_playlist(conn, params)
    clips = expand_playlist_plays(items)
    conn.shadowing_clips = clips
    conn.shadowing_index = 0
    conn.shadowing_prompt = clips[0] if clips else ""
    conn.shadowing_awaiting_user = False
    first = items[0] if items else dict(params or {})
    play = dict(first)
    play["items"] = items
    play["finish_task"] = False
    play["listen_ratio"] = resolve_listen_ratio(conn, _task_for(conn))
    if items:
        conn.shadowing_last_speed = items[0].get("speed")
    return play
async def _play_shadowing_clip(conn: "ConnectionHandler", name: str) -> bool:
    if getattr(conn, "interaction_mode", "") != "SHADOWING":
        logger.bind(tag=TAG).info(f"跳过跟读播放,当前已离开跟读: {name}")
        return False
    conn.shadowing_awaiting_user = False
    conn.shadowing_clip_gen = int(getattr(conn, "shadowing_clip_gen", 0) or 0) + 1
    speed = resolve_play_speed(_task_for(conn))
    conn.shadowing_last_speed = speed
    notice = await _execute_named_tool_play(
        conn,
        {
            "name": name,
            "repeat": 1,
            "power_off_after": False,
            "speed": speed,
            "finish_task": False,
            "listen_ratio": resolve_listen_ratio(conn, _task_for(conn)),
        },
    )
    if notice and notice.action in (Action.ERROR, Action.NOTFOUND):
        return False
    return True
async def _prepare_shadowing_sd(conn: "ConnectionHandler", params: Optional[Dict[str, Any]]) -> bool:
    params = params or {}
    items = _compact_shadowing_playlist(conn, params)
    clips = expand_playlist_plays(items)
    if not clips:
        names = await _list_sd_mp3_names(conn)
        lesson = str(params.get("lesson") or "").strip()
        single = str(params.get("name") or params.get("path") or params.get("url") or "")
        if not lesson and single and not single.lower().endswith(".mp3"):
            lesson = single
        if lesson.lower().endswith(".mp3"):
            lesson = ""
        clips = lesson_mp3_clips(
            names,
            getattr(conn, "active_app_id", "english_learning"),
            getattr(conn, "current_task_id", "shadowing"),
            lesson,
        )
        clips = [name for name in clips if not _is_full_lesson_mp3(name)]
    if not clips:
        conn.shadowing_clips = []
        conn.shadowing_prompt = ""
        return False
    conn.shadowing_clips = clips
    conn.shadowing_index = 0
    conn.shadowing_prompt = clips[0]
    conn.shadowing_awaiting_user = False
    return True
async def _start_shadowing_sd(conn: "ConnectionHandler", params: Optional[Dict[str, Any]]) -> bool:
    if not await _prepare_shadowing_sd(conn, params):
        return False
    play = _begin_shadowing_playlist(conn, params)
    conn.shadowing_clip_gen = int(getattr(conn, "shadowing_clip_gen", 0) or 0) + 1
    notice = await _execute_named_tool_play(conn, play)
    if notice and notice.action in (Action.ERROR, Action.NOTFOUND):
        return False
    return True
async def _fill_repeater_play_name(
    conn: "ConnectionHandler", params: Optional[Dict[str, Any]]
) -> Dict[str, Any]:
    play = dict(params or {})
    if play.get("items"):
        return play
    if str(play.get("name") or "").strip():
        return play
    stub = default_repeater_playlist(_task_for(conn))
    if stub:
        first = stub[0]
        play["name"] = first.get("name") or ""
        if play.get("repeat") is None:
            play["repeat"] = first.get("repeat") or 1
        if play.get("speed") is None and first.get("speed") is not None:
            play["speed"] = first.get("speed")
        conn.repeater_play_name = play["name"]
        return play
    try:
        names = await _list_sd_mp3_names(conn)
    except Exception as exc:
        logger.bind(tag=TAG).warning(f"列举复读课文失败: {exc}")
        return play
    picked = repeater_lesson_mp3(
        names,
        getattr(conn, "active_app_id", "") or "english_learning",
        getattr(conn, "current_task_id", "") or "repeater",
    )
    if picked:
        play["name"] = picked
        conn.repeater_play_name = picked
    return play
async def maybe_flush_pending_play_after_tts(conn: "ConnectionHandler") -> None:
    """Start queued lesson audio only after enter-prompt TTS has finished on device."""
    _ensure_runtime_fields(conn)
    pending = getattr(conn, "pending_play_after_tts", None)
    if not pending:
        return
    conn.pending_play_after_tts = None
    kind = pending.get("kind") if isinstance(pending, dict) else None
    try:
        if kind == "repeater":
            play_params = await _fill_repeater_play_name(conn, pending.get("params") or {})
            await _execute_named_tool_play(conn, play_params)
        elif kind == "shadowing":
            play_params = pending.get("params") if isinstance(pending, dict) else None
            if not play_params:
                play_params = _begin_shadowing_playlist(conn, {})
            conn.shadowing_awaiting_user = False
            conn.shadowing_clip_gen = int(getattr(conn, "shadowing_clip_gen", 0) or 0) + 1
            await _execute_named_tool_play(conn, play_params)
    except Exception as exc:
        logger.bind(tag=TAG).warning(f"延后播放课文失败: {exc}")
def _lesson_end_speech(conn: "ConnectionHandler", phrase: str) -> str:
    """Short lesson-end cue plus the same default chat greeting used after 翻译/中英对话."""
    greeting = (_greeting_text(conn) or "").strip()
    phrase = (phrase or "").strip()
    if phrase and greeting:
        if phrase.endswith("。") or phrase.endswith("."):
            return f"{phrase}{greeting}"
        return f"{phrase}。{greeting}"
    return phrase or greeting
async def _return_to_chat_after_lesson(
    conn: "ConnectionHandler",
    phrase: str,
    reason: str = "play_completed",
) -> None:
    """Playlist already finished on device: speak the end cue now, then chat. Do not MCP-stop."""
    conn.repeater_speed_queue = []
    conn.repeater_playlist = []
    end_session(conn, reason)
    if getattr(conn, "tts", None) is not None:
        await wait_for_tts_channels(conn, timeout=3)
    conn.sentence_id = uuid.uuid4().hex
    _speak(conn, _lesson_end_speech(conn, phrase))
    await start_app_task(
        conn,
        "chat",
        "conversation",
        {},
        source="voice",
        execute_skills=False,
        speak_switch=False,
        stop_skills=False,
    )
async def _shadowing_play_next_or_finish(conn: "ConnectionHandler") -> None:
    """Legacy helper: device now sequences clips. Only used if a caller still asks to finish."""
    conn.shadowing_index = 0
    conn.shadowing_prompt = ""
    conn.shadowing_clips = []
    await _return_to_chat_after_lesson(conn, SHADOWING_END_PHRASE, "finished")
def _mark_repeater_day(conn: "ConnectionHandler") -> None:
    try:
        mark_lesson_played_today(
            str(getattr(conn, "device_id", "") or ""),
            getattr(conn, "active_app_id", "") or "",
            getattr(conn, "current_task_id", "") or "",
            getattr(conn, "repeater_play_name", "") or "",
        )
    except Exception as exc:
        logger.bind(tag=TAG).warning(f"记录复读天数失败: {exc}")
def _apply_playback_speed(
    conn: "ConnectionHandler",
    task: Optional[Dict[str, Any]],
    params: Optional[Dict[str, Any]],
) -> Dict[str, Any]:
    """Attach clamped speed. Multi-speed repeats are Server-driven (one pass per play)."""
    play = dict(params or {})
    try:
        repeat_n = int(play.get("repeat") or 1)
    except (TypeError, ValueError):
        repeat_n = 1
    if repeat_n < 1:
        repeat_n = 1
    device_id = str(getattr(conn, "device_id", "") or "")
    app_id = getattr(conn, "active_app_id", "") or ""
    task_id = getattr(conn, "current_task_id", "") or ""
    lesson = str(play.get("name") or "")
    day_index = lesson_day_index(device_id, app_id, task_id, lesson)
    if play.get("speed") is not None:
        speeds = [clamp_play_speed(play.get("speed"))] * repeat_n
    else:
        speeds = speeds_for_repeats(task, repeat_n, day_index)
    conn.repeater_play_name = lesson
    conn.repeater_power_off_after = bool(play.get("power_off_after") or False)
    varied = len(speeds) > 1 and any(abs(item - speeds[0]) > 0.001 for item in speeds[1:])
    if varied:
        conn.repeater_speed_queue = speeds[1:]
        play["speed"] = speeds[0]
        play["repeat"] = 1
        play["power_off_after"] = False
    else:
        conn.repeater_speed_queue = []
        play["speed"] = speeds[0]
        play["repeat"] = repeat_n
    # 复读清单结束由 Server 收 audio.completed 后切回对话。设备不要 finish_task 写 NVS。
    play["finish_task"] = False
    return play
def _begin_repeater_playlist(
    conn: "ConnectionHandler",
    task: Optional[Dict[str, Any]],
    params: Optional[Dict[str, Any]],
) -> Dict[str, Any]:
    """One shot: send the whole playlist. Repeat is just the same clip again, no mode switch."""
    items = parse_playlist(params)
    if not items:
        items = default_repeater_playlist(task)
    normalized = []
    for raw in items:
        if not isinstance(raw, dict):
            continue
        name = str(raw.get("name") or "")
        if not name:
            continue
        try:
            repeat_n = int(raw.get("repeat") or 1)
        except (TypeError, ValueError):
            repeat_n = 1
        if repeat_n < 1:
            repeat_n = 1
        item = {
            "name": name,
            "repeat": repeat_n,
            "speed": clamp_play_speed(
                raw["speed"] if raw.get("speed") is not None else 1.0
            ),
            "power_off_after": bool(raw.get("power_off_after") or False),
            "finish_task": False,
        }
        normalized.append(item)
    conn.repeater_playlist = []
    conn.repeater_speed_queue = []
    first = normalized[0] if normalized else dict(params or {})
    conn.repeater_play_name = str(first.get("name") or "")
    conn.repeater_power_off_after = bool(first.get("power_off_after") or False)
    play = dict(first)
    play["items"] = normalized
    play["finish_task"] = False
    return play
async def _continue_repeater_if_needed(conn: "ConnectionHandler") -> bool:
    queue = list(getattr(conn, "repeater_speed_queue", None) or [])
    if queue:
        nxt = queue.pop(0)
        conn.repeater_speed_queue = queue
        last_speed = len(queue) == 0 and not list(getattr(conn, "repeater_playlist", None) or [])
        try:
            play_params = await _fill_repeater_play_name(
                conn,
                {
                    "name": getattr(conn, "repeater_play_name", "") or "",
                    "speed": nxt,
                    "repeat": 1,
                    "power_off_after": last_speed
                    and bool(getattr(conn, "repeater_power_off_after", False)),
                    "finish_task": False,
                },
            )
            await _execute_named_tool_play(conn, play_params)
        except Exception as exc:
            logger.bind(tag=TAG).warning(f"复读下一遍失败: {exc}")
            conn.repeater_speed_queue = []
            return False
        return True
    rest = list(getattr(conn, "repeater_playlist", None) or [])
    if not rest:
        return False
    nxt, conn.repeater_playlist = rest[0], rest[1:]
    play_params = _apply_playback_speed(conn, _task_for(conn), nxt)
    play_params["finish_task"] = False
    try:
        play_params = await _fill_repeater_play_name(conn, play_params)
        await _execute_named_tool_play(conn, play_params)
    except Exception as exc:
        logger.bind(tag=TAG).warning(f"复读下一首失败: {exc}")
        conn.repeater_playlist = []
        return False
    return True
async def _execute_named_tool_play(conn, params):
    items = params.get("items") if isinstance(params.get("items"), list) else []
    first = items[0] if items else params
    play_args = {
        "name": (first.get("name") if isinstance(first, dict) else None) or params.get("name") or "",
        "repeat": int((first.get("repeat") if isinstance(first, dict) else None) or params.get("repeat") or 1),
        "power_off_after": bool(params.get("power_off_after") or False),
        "speed": clamp_play_speed(
            (first.get("speed") if isinstance(first, dict) and first.get("speed") is not None else None)
            if items
            else (params["speed"] if params.get("speed") is not None else 1.0)
        ),
        "finish_task": bool(params.get("finish_task", True)),
        "app_id": str(getattr(conn, "active_app_id", "") or ""),
        "task_id": str(getattr(conn, "current_task_id", "") or ""),
        "force": True,
    }
    if items:
        play_args["items"] = json.dumps(items, ensure_ascii=False)
        play_args["speed"] = clamp_play_speed(
            first["speed"] if first.get("speed") is not None else 1.0
        )
        play_args["power_off_after"] = bool(
            items[-1].get("power_off_after") if items else False
        )
    if params.get("listen_ratio") is not None:
        try:
            play_args["listen_ratio"] = float(params["listen_ratio"])
        except (TypeError, ValueError):
            pass
    if play_args["task_id"] in ("repeater", "shadowing"):
        play_args["finish_task"] = False
    result = await _execute_named_tool(conn, DEVICE_PLAY_TOOL, play_args)
    blob = ""
    if result:
        blob = str(result.response or result.result or "")
    compact = blob.replace(" ", "")
    if '"duplicate_ignored":true' in compact:
        logger.bind(tag=TAG).warning("设备忽略了同一课文的下一遍,强制再播一次")
        play_args["speed"] = clamp_play_speed(float(play_args["speed"]) + 0.02)
        play_args["force"] = True
        result = await _execute_named_tool(conn, DEVICE_PLAY_TOOL, play_args)
        blob = ""
        if result:
            blob = str(result.response or result.result or "")
        compact = blob.replace(" ", "")
    rejected = (result and result.action in (Action.ERROR, Action.NOTFOUND)) or (
        "no_mp3" in blob
        or '"accepted":false' in compact
        or '"duplicate_ignored":true' in compact
    )
    if rejected:
        if not play_args["name"]:
            return ActionResponse(
                action=Action.RESPONSE,
                response="复读机已打开,但 SD 卡上还没有可播放的课文。",
            )
        return ActionResponse(
            action=Action.RESPONSE,
            response=(result.response if result else None) or "这台设备没有本地复读播放能力",
        )
    return None
async def start_app_task(
    conn: "ConnectionHandler",
    app_id: str,
@@ -147,6 +1262,8 @@
    params: Optional[Dict[str, Any]] = None,
    source: str = "voice",
    execute_skills: bool = True,
    speak_switch: bool = True,
    stop_skills: bool = True,
) -> ActionResponse:
    """Single entry for MQTT / schedule / voice / default chat."""
    _ensure_runtime_fields(conn)
@@ -176,8 +1293,16 @@
            response="当前有更高优先级的任务,先完成后再切换",
        )
    if not same_task or new_p >= cur_p:
        await _stop_current_skills(conn)
    prev_app_id = getattr(conn, "active_app_id", "chat")
    prev_task_id = getattr(conn, "current_task_id", "conversation")
    prev_app, prev_task = get_task(prev_app_id, prev_task_id)
    switching = (prev_app_id != app_id) or (prev_task_id != resolved_task_id)
    if not same_task:
        if stop_skills:
            await _stop_current_skills(conn)
        if app_id != "chat":
            conn.chat_greeted = False
    conn.active_app_id = app_id
    conn.current_task_id = resolved_task_id
@@ -192,51 +1317,92 @@
        f"start_app_task app={app_id} task={resolved_task_id} mode={conn.interaction_mode} source={source}"
    )
    if app_id == "chat":
        end_session(conn, "user_exit")
    elif switching:
        begin_session(conn, app_id, resolved_task_id, source, conn.interaction_mode)
    pending_play = None
    if execute_skills:
        if app_sd_layout_paths(app_id, app):
            await ensure_app_sd_dirs(conn, app_id)
        for skill in task.get("skills") or []:
            skill_name = (skill or {}).get("name") or ""
            if skill_name in ("self.mp3.play", DEVICE_PLAY_TOOL):
                play_args = {
                    "name": params.get("name") or "",
                    "repeat": int(params.get("repeat") or 1),
                    "power_off_after": bool(params.get("power_off_after") or False),
                }
                if params.get("speed") is not None:
                    play_args["speed"] = params.get("speed")
                if not play_args["name"]:
                    return ActionResponse(
                        action=Action.RESPONSE,
                        response="没有指定要复读的文件名,请先用 self.mp3.get_list 确认 files[].name",
                    )
                result = await _execute_named_tool(conn, DEVICE_PLAY_TOOL, play_args)
                if result and result.action in (Action.ERROR, Action.NOTFOUND):
                    return ActionResponse(
                        action=Action.RESPONSE,
                        response=result.response or "这台设备没有本地复读播放能力",
                    )
                play_params = _begin_repeater_playlist(conn, task, params)
                pending_play = {"kind": "repeater", "params": play_params}
            elif skill_name == "self.power_off":
                await _execute_named_tool(conn, "self_power_off", {})
    if conn.interaction_mode == "CHAT":
    announce = ""
    boot_chat = app_id == "chat" and source == "default_chat"
    if boot_chat:
        # 开机进 chat:必须播欢迎词。不要被 same_task / just_woken_up 跳过。
        enable = (conn.config or {}).get("enable_proactive_greeting", True)
        if enable and not getattr(conn, "chat_greeted", False):
            if not await wait_for_tts_channels(conn, timeout=10):
                logger.bind(tag=TAG).error("开机欢迎词跳过:TTS通道未就绪")
            else:
                conn.chat_greeted = True
                conn.sentence_id = uuid.uuid4().hex
                _speak(conn, _greeting_text(conn))
    elif switching:
        announce = _switch_announcement(prev_app, prev_task, app, task, app_id)
        if conn.interaction_mode == "SHADOWING":
            started = await _prepare_shadowing_sd(conn, params)
            if not started:
                prefix = announce or "现在已经切换至跟读模式。"
                announce = f"{prefix}跟读课文还没有放到SD卡。"
            else:
                announce = announce or "现在已经切换至跟读模式,请跟着课文读。"
                pending_play = {
                    "kind": "shadowing",
                    "params": _begin_shadowing_playlist(conn, params),
                }
        if speak_switch and announce:
            conn.sentence_id = uuid.uuid4().hex
            conn.pending_play_after_tts = pending_play
            _speak(conn, announce)
        elif pending_play:
            conn.pending_play_after_tts = pending_play
            await maybe_flush_pending_play_after_tts(conn)
        if app_id == "chat":
            conn.chat_greeted = True
    elif conn.interaction_mode == "CHAT":
        enable = (conn.config or {}).get("enable_proactive_greeting", True)
        woken = bool(getattr(conn, "just_woken_up", False))
        if enable and not woken:
        if enable and not woken and not getattr(conn, "chat_greeted", False):
            conn.chat_greeted = True
            conn.sentence_id = uuid.uuid4().hex
            _speak(conn, _greeting_text(conn))
    elif conn.interaction_mode == "SHADOWING" and not getattr(conn, "shadowing_clips", None):
        started = await _start_shadowing_sd(conn, params)
        if not started and speak_switch:
            conn.sentence_id = uuid.uuid4().hex
            _speak(conn, "跟读课文还没有放到SD卡。")
    elif pending_play:
        conn.pending_play_after_tts = pending_play
        await maybe_flush_pending_play_after_tts(conn)
    app_name = app.get("name") or app_id
    app_name = _display_name(app, task)
    return ActionResponse(
        action=Action.NONE,
        result=f"{app_id}/{resolved_task_id}",
        response=f"已打开{app_name}",
        response=announce or f"已打开{app_name}",
    )
async def switch_app(conn: "ConnectionHandler", app_id: str, source: str = "voice") -> ActionResponse:
async def switch_app(
    conn: "ConnectionHandler",
    app_id: str,
    source: str = "voice",
    task_id: str = "",
) -> ActionResponse:
    app = get_app(app_id)
    if not app:
        return ActionResponse(action=Action.ERROR, response=f"不支持的 App: {app_id}")
    task_id = app.get("default_task") or ""
    return await start_app_task(conn, app_id, task_id, {}, source)
    resolved = task_id or app.get("default_task") or ""
    return await start_app_task(conn, app_id, resolved, {}, source)
async def on_user_wakeup(conn: "ConnectionHandler") -> None:
@@ -248,24 +1414,11 @@
def match_voice_switch(text: str) -> Optional[tuple]:
    """Map a short phrase to (app_id, task_id) or ('__menu__', '')."""
    """Map a short phrase to (app_id, task_id) using apps.json voice_enter / voice_exit."""
    if not text:
        return None
    _, filtered = _strip(text)
    if filtered in ("切换模式", "qie huan mo shi"):
        return ("__menu__", "")
    for p in CHAT_PHRASES:
        if p in filtered:
            return ("chat", "conversation")
    for p in TRANSLATE_PHRASES:
        if p in filtered:
            return ("english_translation", "live_translate")
    for p in REPEATER_PHRASES:
        if p in filtered:
            return ("english_learning", "repeater")
    if any(k in filtered for k in ("数字背诵", "成语接龙", "口算", "加减法")):
        return ("__reserved__", "")
    return None
    return match_registry_voice(filtered)
def _strip(text: str) -> tuple:
@@ -274,9 +1427,23 @@
    return remove_punctuation_and_length(text)
def is_exit_to_chat(text: str) -> bool:
    mapped = match_voice_switch(text)
    return bool(mapped and mapped[0] == "chat")
def should_skip_listen_stop_asr(conn: Any) -> bool:
    """True when listen stop is closing the mic for lesson playback, not a follow-read."""
    mode = getattr(conn, "interaction_mode", "CHAT")
    if mode == "PLAY_AUDIO":
        return True
    if mode == "SHADOWING" and not getattr(conn, "shadowing_awaiting_user", False):
        return True
    return False
def is_exit_translation(text: str) -> bool:
    _, filtered = _strip(text)
    return any(p in filtered for p in ("退出翻译", "结束翻译", "回到聊天", "我们来聊天吧", "聊会天吧"))
    return is_exit_to_chat(text)
def _plain_user_text(text: str) -> str:
@@ -296,6 +1463,40 @@
    return raw
def _norm_for_echo(text: str) -> str:
    _, filtered = _strip(text or "")
    return re.sub(r"\s+", "", (filtered or "").lower())
def is_echo_of_prompt(user_text: str, prompt: str) -> bool:
    a = _norm_for_echo(user_text)
    b = _norm_for_echo(prompt)
    if not a or not b:
        return False
    if a == b or a in b or b in a:
        return True
    return difflib.SequenceMatcher(None, a, b).ratio() >= 0.82
def detect_user_language(text: str, hint: Optional[str] = None) -> str:
    if hint:
        h = str(hint).lower()
        if h.startswith("zh") or h in ("cn", "yue", "chinese"):
            return "zh"
        if h.startswith("en") or h in ("english",):
            return "en"
    body = _plain_user_text(text)
    cjk = len(re.findall(r"[\u4e00-\u9fff]", body))
    latin = len(re.findall(r"[A-Za-z]", body))
    return "zh" if cjk >= max(latin, 1) else "en"
def wrap_bilingual_user_text(text: str, user_lang: str) -> str:
    if user_lang == "zh":
        return "Respond in English only. Do not use Chinese characters.\n\n" + text
    return "请只用中文回答,不要出现英文单词或句子。\n\n" + text
def _looks_like_tool_error(text: str) -> bool:
    if not text:
        return True
@@ -307,6 +1508,180 @@
    return False
def _record_shadowing_turn(
    conn: "ConnectionHandler",
    text: str,
    echo_rejected: bool = False,
    index: Optional[int] = None,
) -> None:
    session = current_session(conn)
    if not session:
        return
    if index is None:
        index = int(getattr(conn, "shadowing_assess_index", getattr(conn, "shadowing_index", 0)) or 0)
    session.upsert_turn(
        index=index,
        prompt_text=getattr(conn, "shadowing_prompt", "") or "",
        asr_text=_plain_user_text(text),
        echo_rejected=echo_rejected,
        expected_text=_expected_for_index(conn, index),
    )
    session.save()
def apply_shadowing_user_text(conn: "ConnectionHandler", text: str) -> str:
    """Apply one ASR utterance. Returns exit | ignore | echo | recorded.
    ASR never advances the clip index; the listen-window clock does.
    Late ASR after the window still scores the sentence that was just listened to.
    Device plays the next clip itself; Server does not MCP the next sentence.
    """
    if is_exit_to_chat(text):
        end_session(conn, "user_exit")
        return "exit"
    if getattr(conn, "client_is_speaking", False) and not getattr(
        conn, "shadowing_awaiting_user", False
    ):
        logger.bind(tag=TAG).info("跟读:播放中忽略拾音,避免喇叭回声触发对话")
        return "ignore"
    prompt = getattr(conn, "shadowing_prompt", "") or ""
    if (
        getattr(conn, "shadowing_awaiting_user", False)
        and prompt
        and not _is_clip_path(prompt)
        and is_echo_of_prompt(text, prompt)
    ):
        logger.bind(tag=TAG).info("跟读:忽略与范读过于相似的识别结果")
        session = current_session(conn)
        if session:
            session.add_event("echo_rejected")
            session.save()
        return "echo"
    _record_shadowing_turn(conn, text, echo_rejected=False)
    return "recorded"
async def handle_shadowing_turn(conn: "ConnectionHandler", text: str) -> None:
    """Score this utterance off the chat path. Next clip is sequenced on device."""
    outcome = apply_shadowing_user_text(conn, text)
    if outcome == "exit":
        _cancel_shadowing_timeout(conn)
        await start_app_task(conn, "chat", "conversation", {}, source="voice")
        return
    if outcome in ("ignore", "echo"):
        return
    step_index = int(
        getattr(conn, "shadowing_assess_index", getattr(conn, "shadowing_index", 0)) or 0
    )
    logger.bind(tag=TAG).info(f"跟读 ASR 仅评估不上对话 step={step_index}")
    _schedule_deferred_assessment(conn, step_index)
async def handle_skill_event(conn: "ConnectionHandler", text: str) -> bool:
    """Handle `[skill_event]audio.completed|app|task|duration_ms`. Returns True if consumed."""
    parsed = parse_skill_event(text)
    if parsed is None:
        return False
    _ensure_runtime_fields(conn)
    name = parsed.get("name") or ""
    app_id = parsed.get("app_id") or ""
    task_id = parsed.get("task_id") or ""
    try:
        duration_ms = int(parsed.get("duration_ms") or 0)
    except (TypeError, ValueError):
        duration_ms = 0
    logger.bind(tag=TAG).info(f"skill_event {name} {app_id}/{task_id} duration_ms={duration_ms}")
    mode = getattr(conn, "interaction_mode", "CHAT")
    # 听窗时钟必须先于 session.save():磁盘/HTTP 不能挡住 1.2×duration 的热路径。
    shadowing_playlist_done = name in ("audio.playlist_completed", "playlist.completed") and (
        mode == "SHADOWING" or task_id == "shadowing"
    )
    if shadowing_playlist_done:
        _cancel_shadowing_timeout(conn)
        if getattr(conn, "shadowing_awaiting_user", False):
            await _on_shadowing_listen_window_closed(conn)
        else:
            save_shadowing_listen_audio(conn)
        session = getattr(conn, "task_session", None)
        if session:
            session.add_event(name, app_id, task_id)
        await _return_to_chat_after_lesson(conn, SHADOWING_END_PHRASE, "finished")
        return True
    if mode == "SHADOWING" and name == "audio.completed":
        if getattr(conn, "shadowing_awaiting_user", False):
            await _on_shadowing_listen_window_closed(conn)
        conn.shadowing_clip_gen = int(getattr(conn, "shadowing_clip_gen", 0) or 0) + 1
        _schedule_shadowing_listen_timeout(conn, duration_ms)
        session = getattr(conn, "task_session", None)
        if session:
            session.add_event(name, app_id, task_id)
        return True
    if mode == "SHADOWING" and name == "audio.stopped":
        session = getattr(conn, "task_session", None)
        if session:
            session.add_event(name, app_id, task_id)
        end_session(conn, "play_stopped")
        await start_app_task(
            conn,
            "chat",
            "conversation",
            {},
            source="voice",
            execute_skills=False,
            speak_switch=False,
        )
        return True
    if mode == "PLAY_AUDIO" and name == "audio.completed":
        continued = await _continue_repeater_if_needed(conn)
        session = getattr(conn, "task_session", None)
        if session:
            session.add_event(name, app_id, task_id)
        if continued:
            if session:
                session.save()
            return True
        _mark_repeater_day(conn)
        await _return_to_chat_after_lesson(conn, REPEATER_END_PHRASE, "play_completed")
        return True
    if mode == "PLAY_AUDIO" and name == "audio.stopped":
        conn.repeater_speed_queue = []
    session = getattr(conn, "task_session", None)
    if session:
        session.add_event(name, app_id, task_id)
        session.save()
    if name == "audio.completed":
        end_session(conn, "play_completed")
        await start_app_task(
            conn,
            "chat",
            "conversation",
            {},
            source="voice",
            execute_skills=False,
            speak_switch=True,
        )
    elif name == "audio.stopped":
        end_session(conn, "play_stopped")
        if getattr(conn, "active_app_id", "chat") != "chat":
            await start_app_task(
                conn,
                "chat",
                "conversation",
                {},
                source="voice",
                execute_skills=False,
                speak_switch=False,
            )
    else:
        logger.bind(tag=TAG).warning(f"未知 skill_event: {name}")
    return True
async def run_translation(conn: "ConnectionHandler", text: str) -> str:
    """Translate with the connection LLM first (智控台已配的模型). MCP is fallback only."""
    text = _plain_user_text(text)
xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/session.py
New file
@@ -0,0 +1,275 @@
"""Phase 2 TaskSession: in-memory + JSON file. Not a TaskInstance."""
from __future__ import annotations
import asyncio
import json
import os
import time
import uuid
from dataclasses import asdict, dataclass, field
from typing import Any, Dict, List, Optional, TYPE_CHECKING
from config.config_loader import get_project_dir
from config.logger import setup_logging
if TYPE_CHECKING:
    from core.connection import ConnectionHandler
TAG = __name__
logger = setup_logging()
SKILL_EVENT_PREFIX = "[skill_event]"
DEFER_ASR_REPORT_MODES = ("PLAY_AUDIO", "SHADOWING")
def _now_ms() -> int:
    return int(time.time() * 1000)
def session_dir() -> str:
    return os.path.join(get_project_dir(), "data", "task_sessions")
def parse_skill_event(text: str) -> Optional[Dict[str, str]]:
    """Parse `[skill_event]audio.completed|app_id|task_id` into a dict."""
    if not text:
        return None
    raw = str(text).strip()
    if not raw.startswith(SKILL_EVENT_PREFIX):
        return None
    payload = raw[len(SKILL_EVENT_PREFIX) :].strip()
    parts = [p.strip() for p in payload.split("|")]
    name = parts[0] if parts else ""
    if not name:
        return None
    return {
        "name": name,
        "app_id": parts[1] if len(parts) > 1 else "",
        "task_id": parts[2] if len(parts) > 2 else "",
        "duration_ms": parts[3] if len(parts) > 3 else "",
    }
def should_report_asr_now(conn: Any) -> bool:
    """PLAY_AUDIO / SHADOWING defer report so echo and ignored uplink are not archived."""
    mode = getattr(conn, "interaction_mode", "CHAT")
    return mode not in DEFER_ASR_REPORT_MODES
def stash_asr_audio(conn: Any, audio_snapshot: Any) -> None:
    conn.last_asr_audio_snapshot = audio_snapshot
@dataclass
class TaskSession:
    task_session_id: str
    ws_session_id: str = ""
    device_id: str = ""
    agent_id: str = ""
    app_id: str = ""
    task_id: str = ""
    source: str = "voice"
    interaction_mode: str = ""
    started_at: int = 0
    ended_at: int = 0
    status: str = "running"
    end_reason: str = ""
    turns: List[Dict[str, Any]] = field(default_factory=list)
    events: List[Dict[str, Any]] = field(default_factory=list)
    def find_turn(self, index: int) -> Optional[Dict[str, Any]]:
        for turn in self.turns:
            if turn.get("index") == index:
                return turn
        return None
    def upsert_turn(
        self,
        index: int,
        prompt_text: str = "",
        asr_text: Optional[str] = None,
        echo_rejected: bool = False,
        expected_text: Optional[str] = None,
        metrics: Optional[List[Dict[str, Any]]] = None,
        assessed_at: Optional[int] = None,
        audio_id: Optional[str] = None,
    ) -> Dict[str, Any]:
        """Same step_index stays one row; last ASR wins. Empty metrics do not wipe old."""
        existing = self.find_turn(index)
        if existing is None:
            turn: Dict[str, Any] = {
                "index": index,
                "prompt_text": prompt_text or "",
                "asr_text": asr_text or "",
                "echo_rejected": bool(echo_rejected),
                "recorded_at": _now_ms(),
            }
            if expected_text:
                turn["expected_text"] = expected_text
            if metrics:
                turn["metrics"] = metrics
            if assessed_at is not None:
                turn["assessed_at"] = assessed_at
            if audio_id:
                turn["audio_id"] = audio_id
            self.turns.append(turn)
            return turn
        if prompt_text:
            existing["prompt_text"] = prompt_text
        if asr_text is not None:
            existing["asr_text"] = asr_text
            existing["echo_rejected"] = bool(echo_rejected)
            existing["recorded_at"] = _now_ms()
        if expected_text:
            existing["expected_text"] = expected_text
        if metrics:
            existing["metrics"] = metrics
        if assessed_at is not None:
            existing["assessed_at"] = assessed_at
        if audio_id:
            existing["audio_id"] = audio_id
        return existing
    def add_turn(
        self,
        index: int,
        prompt_text: str,
        asr_text: str,
        echo_rejected: bool = False,
    ) -> None:
        self.upsert_turn(
            index=index,
            prompt_text=prompt_text,
            asr_text=asr_text,
            echo_rejected=echo_rejected,
        )
    def add_event(self, name: str, app_id: str = "", task_id: str = "") -> None:
        self.events.append(
            {
                "name": name,
                "app_id": app_id or self.app_id,
                "task_id": task_id or self.task_id,
                "at": _now_ms(),
            }
        )
    def finish(self, reason: str) -> None:
        if self.status != "running":
            return
        self.status = "completed" if reason in ("finished", "play_completed") else "cancelled"
        self.end_reason = reason
        self.ended_at = _now_ms()
    def to_dict(self) -> Dict[str, Any]:
        return asdict(self)
    def save(self) -> None:
        try:
            directory = session_dir()
            os.makedirs(directory, exist_ok=True)
            path = os.path.join(directory, f"{self.task_session_id}.json")
            with open(path, "w", encoding="utf-8") as handle:
                json.dump(self.to_dict(), handle, ensure_ascii=False, indent=2)
        except Exception as exc:
            logger.bind(tag=TAG).warning(f"保存 TaskSession 失败: {exc}")
            return
        if getattr(self, "_skip_upsert", False):
            return
        try:
            _schedule_upsert(self.to_dict(), self)
        except Exception as exc:
            logger.bind(tag=TAG).warning(f"TaskInstance 上报调度失败: {exc}")
    @classmethod
    def load(cls, task_session_id: str) -> Optional["TaskSession"]:
        path = os.path.join(session_dir(), f"{task_session_id}.json")
        if not os.path.isfile(path):
            return None
        with open(path, "r", encoding="utf-8") as handle:
            data = json.load(handle)
        return cls(
            task_session_id=data.get("task_session_id") or task_session_id,
            ws_session_id=data.get("ws_session_id") or "",
            device_id=data.get("device_id") or "",
            agent_id=data.get("agent_id") or "",
            app_id=data.get("app_id") or "",
            task_id=data.get("task_id") or "",
            source=data.get("source") or "voice",
            interaction_mode=data.get("interaction_mode") or "",
            started_at=int(data.get("started_at") or 0),
            ended_at=int(data.get("ended_at") or 0),
            status=data.get("status") or "running",
            end_reason=data.get("end_reason") or "",
            turns=list(data.get("turns") or []),
            events=list(data.get("events") or []),
        )
def begin_session(conn: "ConnectionHandler", app_id: str, task_id: str, source: str, mode: str) -> TaskSession:
    existing = getattr(conn, "task_session", None)
    if existing and getattr(existing, "status", "") == "running":
        end_session(conn, "preempted")
    session = TaskSession(
        task_session_id=uuid.uuid4().hex,
        ws_session_id=str(getattr(conn, "session_id", "") or ""),
        device_id=str(getattr(conn, "device_id", "") or ""),
        agent_id=str(getattr(conn, "agent_id", "") or ""),
        app_id=app_id,
        task_id=task_id,
        source=source or "voice",
        interaction_mode=mode or "",
        started_at=_now_ms(),
        status="running",
    )
    conn.task_session = session
    session.save()
    logger.bind(tag=TAG).info(
        f"TaskSession 开始 {session.task_session_id} {app_id}/{task_id} mode={mode}"
    )
    return session
def end_session(conn: "ConnectionHandler", reason: str) -> None:
    session = getattr(conn, "task_session", None)
    if not session or getattr(session, "status", "") != "running":
        return
    session.finish(reason)
    session.save()
    logger.bind(tag=TAG).info(
        f"TaskSession 结束 {session.task_session_id} status={session.status} reason={reason}"
    )
def current_session(conn: "ConnectionHandler") -> Optional[TaskSession]:
    session = getattr(conn, "task_session", None)
    if session and getattr(session, "status", "") == "running":
        return session
    return None
async def _upsert_task_instance(payload: Dict[str, Any]) -> bool:
    from config.manage_api_client import upsert_task_instance
    data = await upsert_task_instance(payload)
    return data is not None
def _schedule_upsert(payload: Dict[str, Any], session: Optional["TaskSession"] = None) -> None:
    async def _run():
        try:
            ok = await _upsert_task_instance(payload)
            if not ok and session is not None:
                session._skip_upsert = True
                logger.bind(tag=TAG).warning("TaskInstance 上报失败,本会话不再重复上报")
        except Exception as exc:
            if session is not None:
                session._skip_upsert = True
            logger.bind(tag=TAG).warning(f"TaskInstance 上报失败: {exc}")
    try:
        loop = asyncio.get_running_loop()
    except RuntimeError:
        return
    loop.create_task(_run())
xiaozhi-esp32-server/main/xiaozhi-server/core/audio_archive.py
New file
@@ -0,0 +1,103 @@
"""Persist user/agent audio by device, scene mode, and time for later replay."""
from __future__ import annotations
import os
import time
from typing import Any, Optional
from config.config_loader import get_project_dir
from config.logger import setup_logging
TAG = __name__
logger = setup_logging()
MODE_DIR = {
    "CHAT": "chat",
    "SHADOWING": "shadowing",
    "PLAY_AUDIO": "repeater",
    "TRANSLATION": "translation",
    "BILINGUAL": "bilingual",
}
def _safe_segment(value: Any, fallback: str = "unknown") -> str:
    raw = str(value or "").strip() or fallback
    out = []
    for ch in raw:
        if ch.isalnum() or ch in "-_":
            out.append(ch)
        elif ch in (":", " ", "."):
            out.append("-")
        else:
            out.append("_")
    text = "".join(out).strip("-_") or fallback
    return text[:80]
def _mode_dir(conn: Any) -> str:
    mode = str(getattr(conn, "interaction_mode", "") or "").strip().upper()
    return MODE_DIR.get(mode, _safe_segment(mode.lower() or "unknown"))
def _archive_cfg(conn: Any) -> dict:
    return dict(((getattr(conn, "config", None) or {}).get("audio_archive") or {}))
def archive_enabled(conn: Any = None) -> bool:
    cfg = _archive_cfg(conn)
    if cfg.get("enabled") is False:
        return False
    return True
def archive_root(conn: Any = None) -> str:
    cfg = _archive_cfg(conn)
    rel = str(cfg.get("dir") or "data/recordings")
    if os.path.isabs(rel):
        return rel
    return os.path.join(get_project_dir(), rel)
def archive_wav(
    conn: Any,
    wav_bytes: bytes,
    *,
    kind: str = "asr",
    extra: str = "",
) -> Optional[str]:
    """Write WAV under recordings/{device}/{mode}/{date}/{time}_{kind}_{extra}.wav."""
    if not wav_bytes or not archive_enabled(conn):
        return None
    try:
        now = time.time()
        local = time.localtime(now)
        day = time.strftime("%Y-%m-%d", local)
        stamp = time.strftime("%H%M%S", local) + f"{int((now % 1) * 1000):03d}"
        device = _safe_segment(getattr(conn, "device_id", "") or "", "unknown")
        mode = _mode_dir(conn)
        parts = [stamp, _safe_segment(kind, "asr")]
        extra_part = _safe_segment(extra, "") if extra else ""
        if extra_part:
            parts.append(extra_part)
        filename = "_".join(parts) + ".wav"
        directory = os.path.join(archive_root(conn), device, mode, day)
        os.makedirs(directory, exist_ok=True)
        path = os.path.join(directory, filename)
        with open(path, "wb") as handle:
            handle.write(wav_bytes)
        relative = os.path.join(device, mode, day, filename).replace("\\", "/")
        logger.bind(tag=TAG).info(f"音频归档 {relative} bytes={len(wav_bytes)}")
        return relative
    except Exception as exc:
        logger.bind(tag=TAG).warning(f"音频归档失败: {exc}")
        return None
def archive_pcm_snapshot(conn: Any, snapshot: Any, *, extra: str = "") -> Optional[str]:
    from core.app_runtime.assessment import pcm_snapshot_to_wav
    wav_bytes = pcm_snapshot_to_wav(snapshot)
    if not wav_bytes:
        return None
    return archive_wav(conn, wav_bytes, kind="asr", extra=extra)
xiaozhi-esp32-server/main/xiaozhi-server/core/connection.py
@@ -45,6 +45,7 @@
from core.utils.voiceprint_provider import VoiceprintProvider
from core.utils.util import get_system_error_response
from core.utils import textUtils
from core.websocket_handshake import is_client_hello, is_priority_listen_detect
TAG = __name__
@@ -122,6 +123,7 @@
        self.loop = None  # 在 handle_connection 中获取运行中的事件循环
        self.stop_event = threading.Event()
        self.executor = ThreadPoolExecutor(max_workers=5)
        self._tts_channel_lock = threading.Lock()
        # 添加上报线程池
        self.report_queue = queue.Queue()
@@ -209,6 +211,18 @@
        self.tool_allowlist = []
        self.original_system_prompt = None
        self.awaiting_voice_app_choice = False
        self.shadowing_index = 0
        self.shadowing_prompt = ""
        self.shadowing_clips = []
        self.shadowing_awaiting_user = False
        self.shadowing_clip_gen = 0
        self.shadowing_timeout_task = None
        self.shadowing_assess_index = 0
        self.last_asr_language = None
        self.task_session = None
        self.last_asr_audio_snapshot = None
        self.shadowing_listen_pcm = []
        self.shadowing_audio_saved_index = -1
    async def handle_connection(self, ws: websockets.ServerConnection):
        try:
@@ -232,6 +246,8 @@
            # 认证通过,继续处理
            self.websocket = ws
            if self.server is not None:
                await self.server.register_device_handler(self.device_id, self)
            # 检查是否来自MQTT连接
            request_path = ws.request.path
@@ -249,7 +265,7 @@
            # 启动AEC缓存清理任务
            self._aec_cache_cleanup_task = asyncio.create_task(self._check_aec_cache_expiry())
            self.welcome_msg = self.config["xiaozhi"]
            self.welcome_msg = copy.deepcopy(self.config.get("xiaozhi") or {})
            self.welcome_msg["session_id"] = self.session_id
            # 从配置中读取采样率
@@ -354,6 +370,12 @@
    async def _route_message(self, message):
        """消息路由"""
        # hello 必须立刻应答。绑定状态还在拉配置时如果丢掉 hello,
        # 设备会卡在 handshake/hello 等待,表现为连不上语音服务。
        if is_client_hello(message) or is_priority_listen_detect(message):
            await handleTextMessage(self, message)
            return
        # 检查是否已经获取到真实的绑定状态
        if not self.bind_completed_event.is_set():
            # 还没有获取到真实状态,等待直到获取到真实状态或超时
@@ -610,14 +632,45 @@
                )
            )
    def _initialize_components(self):
        try:
    def _tts_config_ready(self) -> bool:
        selected = (self.config.get("selected_module") or {}).get("TTS")
        providers = self.config.get("TTS") or {}
        return bool(selected) and selected in providers
    async def _open_tts_channels(self):
        """Open TTS worker threads. Must run on the connection event loop after TTS config exists."""
        if self.tts is None and not self._tts_config_ready():
            self.logger.bind(tag=TAG).info("TTS配置尚未从智控台加载,延后打开通道")
            return
        with self._tts_channel_lock:
            if self.tts is None:
                self.tts = self._initialize_tts()
            # 打开语音合成通道
            asyncio.run_coroutine_threadsafe(
                self.tts.open_audio_channels(self), self.loop
            )
            tts = self.tts
        if tts is None:
            self.logger.bind(tag=TAG).warning("TTS实例不可用,无法打开通道")
            return
        await tts.open_audio_channels(self)
        play = getattr(tts, "audio_play_priority_thread", None)
        text_th = getattr(tts, "tts_priority_thread", None)
        if play and play.is_alive() and text_th and text_th.is_alive():
            self.logger.bind(tag=TAG).info("TTS通道已打开")
        else:
            self.logger.bind(tag=TAG).error("TTS通道打开后工作线程未运行")
    def _ensure_tts_channels(self):
        """Start TTS worker threads once; used after private-config init."""
        try:
            if self.loop is None:
                return
            fut = asyncio.run_coroutine_threadsafe(self._open_tts_channels(), self.loop)
            fut.result(timeout=15)
        except Exception as e:
            self.logger.bind(tag=TAG).error(f"打开TTS通道失败: {e}")
    def _initialize_components(self):
        try:
            if self.tts is not None:
                self._ensure_tts_channels()
            if self.need_bind:
                self.bind_completed_event.set()
                return
@@ -755,8 +808,11 @@
    def _initialize_tts(self):
        """初始化TTS"""
        tts = None
        if not self.need_bind:
        if not self.need_bind and self._tts_config_ready():
            tts = initialize_tts(self.config)
        elif not self._tts_config_ready():
            self.logger.bind(tag=TAG).info("跳过TTS初始化:配置中没有可用的TTS模块")
            return None
        if tts is None:
            tts = DefaultTTS(self.config, delete_audio_file=True)
@@ -954,6 +1010,11 @@
            modules = {}
        if modules.get("tts", None) is not None:
            self.tts = modules["tts"]
        if self.tts is not None:
            try:
                await self._open_tts_channels()
            except Exception as e:
                self.logger.bind(tag=TAG).error(f"打开TTS通道失败: {e}")
        if modules.get("vad", None) is not None:
            self.vad = modules["vad"]
        if modules.get("asr", None) is not None:
@@ -1534,11 +1595,11 @@
        self.logger.bind(tag=TAG).info("聊天记录上报线程已退出")
    def _process_report(self, type, text, audio_data, report_time):
    def _process_report(self, type, text, audio_data, report_time, task_instance_id=None, step_index=None):
        """处理上报任务"""
        try:
            # 执行异步上报(在事件循环中运行)
            asyncio.run(report(self, type, text, audio_data, report_time))
            asyncio.run(report(self, type, text, audio_data, report_time, task_instance_id, step_index))
        except Exception as e:
            self.logger.bind(tag=TAG).error(f"上报处理异常: {e}")
        finally:
@@ -1552,6 +1613,15 @@
    async def close(self, ws=None):
        """资源清理方法"""
        try:
            if self.server is not None:
                await self.server.unregister_device_handler(self.device_id, self)
            try:
                from core.app_runtime.session import end_session
                end_session(self, "disconnected")
            except Exception as session_error:
                self.logger.bind(tag=TAG).warning(f"断线 flush TaskSession 失败: {session_error}")
            # 清理 VAD 连接资源
            if (
                    hasattr(self, "vad")
xiaozhi-esp32-server/main/xiaozhi-server/core/handle/helloHandle.py
@@ -14,6 +14,7 @@
from core.handle.sendAudioHandle import sendAudioMessage, send_tts_message
from core.utils.util import remove_punctuation_and_length, opus_datas_to_wav_bytes
from core.providers.tools.device_mcp import MCPClient, send_mcp_initialize_message
from core.websocket_handshake import merge_hello_audio_params
TAG = __name__
@@ -46,7 +47,12 @@
        format = audio_params.get("format")
        conn.logger.bind(tag=TAG).debug(f"客户端音频格式: {format}")
        conn.audio_format = format
        conn.welcome_msg["audio_params"] = audio_params
        conn.welcome_msg["audio_params"] = merge_hello_audio_params(
            conn.welcome_msg.get("audio_params"), audio_params
        )
        output_rate = (conn.welcome_msg.get("audio_params") or {}).get("sample_rate")
        if output_rate:
            conn.sample_rate = output_rate
    features = msg_json.get("features")
    if features:
        conn.logger.bind(tag=TAG).debug(f"客户端特性: {features}")
xiaozhi-esp32-server/main/xiaozhi-server/core/handle/receiveAudioHandle.py
@@ -17,16 +17,21 @@
async def handleAudioMessage(conn: "ConnectionHandler", pcm_frame):
    # 当前片段是否有人说话
    have_voice = conn.vad.is_vad(conn, pcm_frame)
    # 如果设备刚刚被唤醒,短暂忽略VAD检测
    if hasattr(conn, "just_woken_up") and conn.just_woken_up:
    mode = getattr(conn, "interaction_mode", "CHAT")
    # 跟读听窗必须收齐 PCM。just_woken_up 的 2s 丢帧只用于对话,避免跟读前几句落成空 wav。
    if mode != "SHADOWING" and hasattr(conn, "just_woken_up") and conn.just_woken_up:
        have_voice = False
        # 设置一个短暂延迟后恢复VAD检测
        if not hasattr(conn, "vad_resume_task") or conn.vad_resume_task.done():
            conn.vad_resume_task = asyncio.create_task(resume_vad_detection(conn))
        return
    # 服务端AEC功能需要实时触发打断
    # 服务端AEC:仅对话类模式允许打断。复读/跟读/翻译时喇叭回声不能当成插话。
    if conn.client_aec and have_voice:
        if conn.client_is_speaking and conn.client_listen_mode != "manual":
        if (
            conn.client_is_speaking
            and conn.client_listen_mode != "manual"
            and mode in ("CHAT", "BILINGUAL")
        ):
            await handleAbortMessage(conn)
    # 设备长时间空闲检测,用于say goodbye
    await no_voice_close_connect(conn, have_voice)
@@ -44,6 +49,7 @@
    # 检查输入是否是JSON格式(包含说话人信息)
    speaker_name = None
    actual_text = text
    conn.last_asr_language = getattr(conn, "last_asr_language", None)
    try:
        # FunASR 等会把结果包成 {"content","language","emotion"};声纹场景还有 speaker
@@ -52,6 +58,8 @@
            if isinstance(data, dict) and "content" in data:
                speaker_name = data.get("speaker")
                actual_content = data.get("content") or ""
                if data.get("language"):
                    conn.last_asr_language = data.get("language")
                if speaker_name:
                    conn.logger.bind(tag=TAG).info(f"解析到说话人信息: {speaker_name}")
                    # 仅在该说话人首次出现时保留 {"speaker":...} JSON,让模型自然称呼一次;
@@ -86,15 +94,28 @@
            return
    from core.app_runtime.runtime import (
        is_exit_translation,
        detect_user_language,
        handle_shadowing_turn,
        is_exit_to_chat,
        match_voice_switch,
        run_translation,
        start_app_task,
        VOICE_MENU,
        wrap_bilingual_user_text,
        voice_menu_text,
    )
    from core.handle.intentHandler import speak_txt
    mode = getattr(conn, "interaction_mode", "CHAT")
    # 菜单 TTS 里的「跟读/复读机/翻译」不能当成切 App;退出聊天口令仍要生效
    if conn.client_is_speaking and getattr(conn, "awaiting_voice_app_choice", False):
        mapped = match_voice_switch(actual_text)
        if mapped and mapped[0] == "chat":
            conn.awaiting_voice_app_choice = False
            await start_app_task(conn, "chat", "conversation", {}, source="voice")
            return
        conn.logger.bind(tag=TAG).info("语音菜单播报中,忽略切 App")
        return
    if getattr(conn, "awaiting_voice_app_choice", False):
        mapped = match_voice_switch(actual_text)
@@ -105,39 +126,50 @@
            return
        if mapped and mapped[0] == "__menu__":
            conn.awaiting_voice_app_choice = True
            speak_txt(conn, VOICE_MENU)
            speak_txt(conn, voice_menu_text())
            return
        if mapped and mapped[0] != "__menu__":
            params = {}
            await start_app_task(conn, mapped[0], mapped[1], params, source="voice")
        if mapped and mapped[0] == "chat":
            await start_app_task(conn, "chat", "conversation", {}, source="voice")
            return
        speak_txt(conn, VOICE_MENU)
        conn.awaiting_voice_app_choice = True
        if mapped:
            await start_app_task(conn, mapped[0], mapped[1], {}, source="voice")
            return
        conn.logger.bind(tag=TAG).info("语音菜单未匹配,保持 chat")
        return
    mapped = match_voice_switch(actual_text)
    if mapped and mapped[0] == "__menu__":
        conn.awaiting_voice_app_choice = True
        speak_txt(conn, VOICE_MENU)
        speak_txt(conn, voice_menu_text())
        return
    if mapped and mapped[0] == "__reserved__":
        speak_txt(conn, "这个还没开放,先继续当前内容吧。")
        return
    if mapped and mapped[0] in ("english_translation", "english_learning", "chat"):
        if mapped[0] == "english_learning" and mapped[1] == "repeater":
            speak_txt(conn, "复读需要指定 SD 卡上的文件名,或用小程序开始。")
            return
    if conn.client_is_speaking and mapped and mapped[0] not in ("chat",):
        conn.logger.bind(tag=TAG).info(f"TTS 播放中忽略切 App: {mapped}")
        return
    if mapped and mapped[0] in (
        "english_translation",
        "english_learning",
        "english_conversation",
        "chat",
    ):
        await start_app_task(conn, mapped[0], mapped[1], {}, source="voice")
        return
    if mode != "CHAT" and is_exit_to_chat(actual_text):
        await start_app_task(conn, "chat", "conversation", {}, source="voice")
        return
    if mode == "PLAY_AUDIO":
        conn.logger.bind(tag=TAG).info("PLAY_AUDIO 模式忽略上行对话")
        conn.logger.bind(tag=TAG).info("复读机模式忽略上行对话")
        return
    if mode == "SHADOWING":
        await handle_shadowing_turn(conn, actual_text)
        return
    if mode == "TRANSLATION":
        if is_exit_translation(actual_text):
            await start_app_task(conn, "chat", "conversation", {}, source="voice")
            return
        await send_stt_message(conn, actual_text)
        conn.client_abort = False
        translated = await run_translation(conn, actual_text)
@@ -149,6 +181,16 @@
        speak_txt(conn, translated)
        return
    if mode == "BILINGUAL":
        await send_stt_message(conn, actual_text)
        conn.client_abort = False
        user_lang = detect_user_language(
            actual_text, getattr(conn, "last_asr_language", None)
        )
        wrapped = wrap_bilingual_user_text(actual_text, user_lang)
        conn.executor.submit(conn.chat, wrapped)
        return
    # manual 模式下不打断正在播放的内容
    if conn.client_is_speaking and conn.client_listen_mode != "manual":
        await handleAbortMessage(conn)
xiaozhi-esp32-server/main/xiaozhi-server/core/handle/reportHandle.py
@@ -22,7 +22,8 @@
TAG = __name__
async def report(conn: "ConnectionHandler", chat_type, text, audio_data, report_time):
async def report(conn: "ConnectionHandler", chat_type, text, audio_data, report_time,
                 task_instance_id=None, step_index=None):
    """执行聊天记录上报操作
    Args:
@@ -43,6 +44,13 @@
                wav_data = None
        else:
            wav_data = None
        if wav_data and chat_type == 2:
            try:
                from core.audio_archive import archive_wav
                archive_wav(conn, wav_data, kind="tts")
            except Exception:
                pass
        # 执行异步上报
        await manage_report(
            mac_address=conn.device_id,
@@ -51,6 +59,8 @@
            content=text,
            audio=wav_data,
            report_time=report_time,
            task_instance_id=task_instance_id,
            step_index=step_index,
        )
    except Exception as e:
        conn.logger.bind(tag=TAG).error(f"聊天记录上报失败: {e}")
@@ -224,7 +234,7 @@
        conn.logger.bind(tag=TAG).error(f"加入工具上报队列失败: {e}")
def enqueue_asr_report(conn: "ConnectionHandler", text, opus_data):
def enqueue_asr_report(conn: "ConnectionHandler", text, opus_data, task_instance_id=None, step_index=None):
    """将ASR数据加入上报队列
    Args:
@@ -239,12 +249,12 @@
    try:
        # 使用连接对象的队列,传入文本和二进制数据而非文件路径
        if conn.chat_history_conf == 2:
            conn.report_queue.put((1, text, opus_data, int(time.time() * 1000)))
            conn.report_queue.put((1, text, opus_data, int(time.time() * 1000), task_instance_id, step_index))
            conn.logger.bind(tag=TAG).debug(
                f"ASR数据已加入上报队列: {conn.device_id}, 音频大小: {len(opus_data)} "
            )
        else:
            conn.report_queue.put((1, text, None, int(time.time() * 1000)))
            conn.report_queue.put((1, text, None, int(time.time() * 1000), task_instance_id, step_index))
            conn.logger.bind(tag=TAG).debug(
                f"ASR数据已加入上报队列: {conn.device_id}, 不上报音频"
            )
xiaozhi-esp32-server/main/xiaozhi-server/core/handle/sendAudioHandle.py
@@ -9,7 +9,7 @@
from core.utils import textUtils
from core.utils.util import audio_to_data
from core.providers.tts.dto.dto import SentenceType
from core.utils.audioRateController import AudioRateController
from core.utils.audioRateController import AudioRateController, audio_completion_timeout_sec
TAG = __name__
# 音频帧时长(毫秒)
@@ -21,11 +21,16 @@
async def sendAudioMessage(conn: "ConnectionHandler", sentenceType, audios, text, sentence_id=None):
    # 跳过旧句子残留音频
    if sentence_id is not None and sentence_id != conn.sentence_id:
        conn.logger.bind(tag=TAG).warning(
            f"丢弃过期TTS音频 sentence={sentence_id} current={conn.sentence_id} type={sentenceType}"
        )
        return
    if conn.tts.tts_audio_first_sentence:
        conn.logger.bind(tag=TAG).info(f"发送第一段语音: {text}")
        conn.tts.tts_audio_first_sentence = False
        # speak_txt 不走 STT,补一条 tts start,让设备先进入 speaking 再收 opus。
        await send_tts_message(conn, "start")
    if sentenceType == SentenceType.FIRST:
        # 同一句子的后续消息加入流控队列,其他情况立即发送
@@ -45,7 +50,10 @@
    await sendAudio(conn, audios)
    # 发送句子开始消息
    if sentenceType is not SentenceType.MIDDLE:
        conn.logger.bind(tag=TAG).info(f"发送音频消息: {sentenceType}, {text}")
        sent = (getattr(conn, "audio_flow_control", None) or {}).get("packet_count", 0)
        conn.logger.bind(tag=TAG).info(
            f"发送音频消息: {sentenceType}, {text}, opus包={sent}"
        )
    # 发送结束消息(如果是最后一个文本)
    # 通话需要维持speaking状态
@@ -58,19 +66,22 @@
async def _wait_for_audio_completion(conn: "ConnectionHandler"):
    """
    等待音频队列清空并等待预缓冲包播放完成
    Args:
        conn: 连接对象
    """
    if hasattr(conn, "audio_rate_controller") and conn.audio_rate_controller:
        rate_controller = conn.audio_rate_controller
        conn.logger.bind(tag=TAG).debug(
            f"等待音频发送完成,队列中还有 {len(rate_controller.queue)} 个包"
        )
        await rate_controller.queue_empty_event.wait()
        try:
            queued = len(rate_controller.queue)
            timeout = audio_completion_timeout_sec(queued, rate_controller.frame_duration)
            conn.logger.bind(tag=TAG).debug(
                f"等待音频发送完成,队列中还有 {queued} 个包,timeout={timeout:.1f}s"
            )
            await asyncio.wait_for(rate_controller.queue_empty_event.wait(), timeout=timeout)
        except asyncio.TimeoutError:
            leftover = len(rate_controller.queue) if rate_controller else -1
            conn.logger.bind(tag=TAG).warning(
                f"等待音频发送完成超时 leftover={leftover},继续下发 tts stop"
            )
        # 等待预缓冲包播放完成
        # 前N个包直接发送,增加2个网络抖动包,需要额外等待它们在客户端播放完成
        frame_duration_ms = rate_controller.frame_duration
        pre_buffer_playback_time = (PRE_BUFFER_COUNT + 2) * frame_duration_ms / 1000.0
        await asyncio.sleep(pre_buffer_playback_time)
@@ -263,12 +274,12 @@
    sequence = flow_control.get("sequence", 0)
    if conn.conn_from_mqtt_gateway:
        # 计算时间戳(基于播放位置)
        start_time = time.time()
        timestamp = int(start_time * 1000) % (2**32)
        await _send_to_mqtt_gateway(conn, opus_packet, timestamp, sequence)
    else:
        # 直接发送opus数据包
        if packet_index == 0:
            conn.logger.bind(tag=TAG).info(f"开始下发TTS opus,首包bytes={len(opus_packet)}")
        await conn.websocket.send(opus_packet)
    # 更新流控状态
xiaozhi-esp32-server/main/xiaozhi-server/core/handle/textHandler/listenMessageHandler.py
@@ -35,24 +35,46 @@
        if msg_json["state"] == "start":
            # 设备从播放模式切回录音模式,清除所有音频状态和缓冲区
            conn.reset_audio_states()
            from core.app_runtime.runtime import maybe_flush_pending_play_after_tts
            await maybe_flush_pending_play_after_tts(conn)
        elif msg_json["state"] == "stop":
            # 收到stop但asr未初始化,跳过处理
            if conn.asr is None:
                return
            conn.client_voice_stop = True
            if conn.asr.interface_type == InterfaceType.STREAM:
                # 流式模式下,发送结束请求
                asyncio.create_task(conn.asr._send_stop_request())
            else:
                # 非流式模式:直接触发ASR识别
                if len(conn.asr_audio) > 0:
                    asr_audio_task = conn.asr_audio.copy()
                    conn.reset_audio_states()
            from core.app_runtime.runtime import close_shadowing_listen_window, should_skip_listen_stop_asr
                    if len(asr_audio_task) > 0:
                        await conn.asr.handle_voice_stop(conn, asr_audio_task)
            mode = getattr(conn, "interaction_mode", "CHAT")
            if mode == "SHADOWING":
                await close_shadowing_listen_window(conn)
            if should_skip_listen_stop_asr(conn):
                conn.reset_audio_states()
                return
            if conn.asr.interface_type == InterfaceType.STREAM:
                asyncio.create_task(conn.asr._send_stop_request())
            elif len(conn.asr_audio) > 0:
                asr_audio_task = conn.asr_audio.copy()
                conn.reset_audio_states()
                if not asr_audio_task:
                    return
                if mode == "SHADOWING":
                    asyncio.create_task(conn.asr.handle_voice_stop(conn, asr_audio_task))
                else:
                    await conn.asr.handle_voice_stop(conn, asr_audio_task)
        elif msg_json["state"] == "detect":
            original_text = msg_json.get("text") or ""
            # 课文播完事件先落盘上一句,再清 ASR 缓冲,避免把上一句听窗抹掉或并进下一句。
            if original_text.startswith("[skill_event]"):
                conn.last_activity_time = time.time() * 1000
                from core.app_runtime.runtime import handle_skill_event
                await handle_skill_event(conn, original_text)
                conn.client_have_voice = False
                conn.reset_audio_states()
                return
            conn.client_have_voice = False
            conn.reset_audio_states()
            if "text" in msg_json:
@@ -72,23 +94,33 @@
                    conn.logger.bind(tag=TAG).info(
                        f"收到 app_task: {app_id}/{task_id} source={source}"
                    )
                    from core.app_runtime.runtime import start_app_task
                    from core.app_runtime.runtime import start_app_task, wait_for_tts_channels
                    start_time = time.time()
                    while time.time() - start_time < 3:
                        if conn.tts:
                            break
                        await asyncio.sleep(0.1)
                    await start_app_task(conn, app_id, task_id, {}, source=source, execute_skills=False)
                    if not await wait_for_tts_channels(conn, timeout=8):
                        conn.logger.bind(tag=TAG).warning(
                            "TTS通道未就绪,仍尝试启动 app_task"
                        )
                    # 口令从设备上来:固件有网时不再先播 MP3,由 Server 先报进场词再放音频。
                    await start_app_task(
                        conn,
                        app_id,
                        task_id,
                        {},
                        source=source,
                        execute_skills=(source == "voice"),
                    )
                    return
                if original_text.startswith("[voice_entry]"):
                    from core.app_runtime.runtime import VOICE_MENU
                    from core.app_runtime.runtime import voice_menu_text, wait_for_tts_channels
                    from core.handle.intentHandler import speak_txt
                    conn.awaiting_voice_app_choice = True
                    conn.sentence_id = uuid.uuid4().hex
                    speak_txt(conn, VOICE_MENU)
                    if await wait_for_tts_channels(conn, timeout=8):
                        speak_txt(conn, voice_menu_text())
                    else:
                        conn.logger.bind(tag=TAG).warning("TTS通道未就绪,跳过语音菜单")
                    return
                # 检查是否是设备呼叫指令 [device_call]
@@ -122,6 +154,11 @@
                    conn.dialogue.put(Message(role="assistant", content=call_text))
                    return
                if original_text.startswith("[") and "]" in original_text:
                    prefix = original_text.split("]", 1)[0] + "]"
                    conn.logger.bind(tag=TAG).warning(f"忽略未知 detect 前缀: {prefix}")
                    return
                # 识别是否是唤醒词
                is_wakeup_words = filtered_text in conn.config.get("wakeup_words")
                # 是否开启唤醒词回复
xiaozhi-esp32-server/main/xiaozhi-server/core/providers/asr/base.py
@@ -66,7 +66,16 @@
            # 自动/实时模式:使用VAD检测
            conn.asr_audio.append(pcm_frame)
            # 如果没有语音,且之前也没有声音,缓存部分音频
            shadowing_listen = (
                getattr(conn, "interaction_mode", "") == "SHADOWING"
                and getattr(conn, "shadowing_awaiting_user", False)
            )
            if shadowing_listen:
                from core.app_runtime.runtime import append_shadowing_listen_pcm
                append_shadowing_listen_pcm(conn, pcm_frame)
            # 跟读整句录音走 shadowing_listen_pcm。asr_audio 仍按 VAD 裁剪,避免跨句粘连。
            if not audio_have_voice and not conn.client_have_voice:
                conn.asr_audio = conn.asr_audio[-10:]
                return
@@ -77,7 +86,11 @@
                pcm_bytes = b"".join(conn.asr_audio)
                # 检查是否有足够的音频数据(每帧1920字节,15帧约28800字节)
                if len(pcm_bytes) > 1920 * 15:
                    await self.handle_voice_stop(conn, [pcm_bytes])
                    mode = getattr(conn, "interaction_mode", "CHAT")
                    if mode == "SHADOWING":
                        asyncio.create_task(self.handle_voice_stop(conn, [pcm_bytes]))
                    else:
                        await self.handle_voice_stop(conn, [pcm_bytes])
                conn.reset_audio_states()
    # 处理语音停止
@@ -163,10 +176,26 @@
            text_len, _ = remove_punctuation_and_length(content_for_length_check)
            self.stop_ws_connection()
            audio_snapshot = asr_audio_task.copy() if asr_audio_task else []
            mode = getattr(conn, "interaction_mode", "")
            if mode == "SHADOWING":
                from core.app_runtime.session import stash_asr_audio
                stash_asr_audio(conn, audio_snapshot)
            if text_len > 0:
                audio_snapshot = asr_audio_task.copy()
                enqueue_asr_report(conn, enhanced_text, audio_snapshot)
                # 使用自定义模块进行上报
                from core.app_runtime.session import should_report_asr_now, stash_asr_audio
                if mode != "SHADOWING":
                    stash_asr_audio(conn, audio_snapshot)
                if should_report_asr_now(conn):
                    try:
                        from core.audio_archive import archive_pcm_snapshot
                        archive_pcm_snapshot(conn, audio_snapshot)
                    except Exception:
                        pass
                    enqueue_asr_report(conn, enhanced_text, audio_snapshot)
                await startToChat(conn, enhanced_text)
        except Exception as e:
            logger.bind(tag=TAG).error(f"处理语音停止失败: {e}")
xiaozhi-esp32-server/main/xiaozhi-server/core/providers/tools/device_mcp/mcp_handler.py
@@ -216,6 +216,15 @@
                    if hasattr(conn, "func_handler") and conn.func_handler:
                        conn.func_handler.tool_manager.refresh_tools()
                        conn.func_handler.current_support_functions()
                    try:
                        from core.app_runtime.runtime import (
                            schedule_ensure_subscribed_app_sd_dirs,
                        )
                        schedule_ensure_subscribed_app_sd_dirs(conn)
                    except Exception as e:
                        logger.bind(tag=TAG).warning(f"调度订阅 App 建目录失败: {e}")
            return
    # Handle method calls (requests from the client)
xiaozhi-esp32-server/main/xiaozhi-server/core/providers/tts/base.py
@@ -117,8 +117,38 @@
        )
    def handle_opus(self, opus_data: bytes):
        self.last_stream_had_audio = True
        if not getattr(self, "_logged_first_opus", False):
            self._logged_first_opus = True
            logger.bind(tag=TAG).info(f"首帧opus已入队 bytes={len(opus_data)}")
        logger.bind(tag=TAG).debug(f"推送数据到队列里面帧数~~ {len(opus_data)}")
        self.tts_audio_queue.put((SentenceType.MIDDLE, opus_data, None, getattr(self, 'current_sentence_id', None)))
    def _run_text_to_speak(self, text, output_file=None):
        """Generate TTS with a hard timeout so LAST/stop is not blocked forever.
        Edge TTS (and some HTTP TTS APIs) can hang past asyncio.wait_for cancel.
        Run synthesis in a daemon thread and abandon it if the join times out.
        """
        result = {"data": None, "error": None}
        def worker():
            try:
                result["data"] = asyncio.run(self.text_to_speak(text, output_file))
            except Exception as e:
                result["error"] = e
        worker_thread = threading.Thread(target=worker, name="tts-synth", daemon=True)
        worker_thread.start()
        worker_thread.join(timeout=self.tts_timeout)
        if worker_thread.is_alive():
            logger.bind(tag=TAG).error(
                f"TTS timed out after {self.tts_timeout}s: {text}"
            )
            raise TimeoutError(f"TTS timed out after {self.tts_timeout}s")
        if result["error"] is not None:
            raise result["error"]
        return result["data"]
    def handle_audio_file(self, file_audio: bytes, text):
        self.before_stop_play_files.append((file_audio, text))
@@ -130,21 +160,42 @@
        # 使用正则一次性替换,避免重复遍历和部分匹配问题
        if self._correct_words_pattern:
            text = self._correct_words_pattern.sub(lambda m: self.correct_words[m.group(0)], text)
        max_repeat_time = 5
        # 先合成 opus,再下发 sentence_start。先发字幕再合成时,设备会进入说话中却收不到音频。
        max_repeat_time = 2
        opus_frames = {"n": 0}
        pending_opus = []
        self.last_stream_had_audio = False
        def _count_opus(data):
            opus_frames["n"] += 1
            pending_opus.append(bytes(data) if not isinstance(data, bytes) else data)
        def _flush_ready_audio():
            sid = getattr(self, "current_sentence_id", None)
            self.tts_audio_queue.put((SentenceType.FIRST, None, original_text, sid))
            for frame in pending_opus:
                if opus_handler:
                    opus_handler(frame)
                else:
                    self.tts_audio_queue.put((SentenceType.MIDDLE, frame, None, sid))
            self.last_stream_had_audio = True
            pending_opus.clear()
        counted_handler = _count_opus
        if self.delete_audio_file:
            # 需要删除文件的直接转为音频数据
            audio_bytes = None
            while max_repeat_time > 0:
                try:
                    audio_bytes = asyncio.run(self.text_to_speak(text, None))
                    audio_bytes = self._run_text_to_speak(text, None)
                    if audio_bytes:
                        # 使用原始文本用于显示/上报
                        self.tts_audio_queue.put((SentenceType.FIRST, None, original_text, getattr(self, 'current_sentence_id', None)))
                        encode_rate = getattr(self.opus_encoder, "sample_rate", None) or self.conn.sample_rate
                        audio_bytes_to_data_stream(
                            audio_bytes,
                            file_type=self.audio_file_type,
                            is_opus=True,
                            callback=opus_handler,
                            sample_rate=self.conn.sample_rate,
                            callback=counted_handler,
                            sample_rate=encode_rate,
                            opus_encoder=self.opus_encoder,
                        )
                        break
@@ -155,9 +206,14 @@
                        f"语音生成失败{5 - max_repeat_time + 1}次: {original_text},错误: {e}"
                    )
                    max_repeat_time -= 1
            if max_repeat_time > 0:
            if max_repeat_time > 0 and opus_frames["n"] > 0:
                logger.bind(tag=TAG).info(
                    f"语音生成成功: {original_text},重试{5 - max_repeat_time}次"
                    f"语音生成成功: {original_text},opus帧={opus_frames['n']},重试{5 - max_repeat_time}次"
                )
                _flush_ready_audio()
            elif max_repeat_time > 0:
                logger.bind(tag=TAG).error(
                    f"语音生成无opus帧: {original_text},bytes={0 if not audio_bytes else len(audio_bytes)}"
                )
            else:
                logger.bind(tag=TAG).error(
@@ -169,7 +225,7 @@
            try:
                while not os.path.exists(tmp_file) and max_repeat_time > 0:
                    try:
                        asyncio.run(self.text_to_speak(text, tmp_file))
                        self._run_text_to_speak(text, tmp_file)
                    except Exception as e:
                        logger.bind(tag=TAG).warning(
                            f"语音生成失败{5 - max_repeat_time + 1}次: {original_text},错误: {e}"
@@ -183,12 +239,17 @@
                    logger.bind(tag=TAG).info(
                        f"语音生成成功: {original_text}:{tmp_file},重试{5 - max_repeat_time}次"
                    )
                    self._process_audio_file_stream(tmp_file, callback=counted_handler)
                    if opus_frames["n"] > 0:
                        _flush_ready_audio()
                    else:
                        logger.bind(tag=TAG).error(
                            f"语音生成无opus帧: {original_text}:{tmp_file}"
                        )
                else:
                    logger.bind(tag=TAG).error(
                        f"语音生成失败: {original_text},请检查网络或服务是否正常"
                    )
                self.tts_audio_queue.put((SentenceType.FIRST, None, original_text, getattr(self, 'current_sentence_id', None)))
                self._process_audio_file_stream(tmp_file, callback=opus_handler)
            except Exception as e:
                logger.bind(tag=TAG).error(f"Failed to generate TTS file: {e}")
                return None
@@ -204,7 +265,7 @@
            # 需要删除文件的直接转为音频数据
            while max_repeat_time > 0:
                try:
                    audio_bytes = asyncio.run(self.text_to_speak(text, None))
                    audio_bytes = self._run_text_to_speak(text, None)
                    if audio_bytes:
                        audio_datas = []
                        audio_bytes_to_data_stream(
@@ -236,7 +297,7 @@
            try:
                while not os.path.exists(tmp_file) and max_repeat_time > 0:
                    try:
                        asyncio.run(self.text_to_speak(text, tmp_file))
                        self._run_text_to_speak(text, tmp_file)
                    except Exception as e:
                        logger.bind(tag=TAG).warning(
                            f"语音生成失败{5 - max_repeat_time + 1}次: {original_text},错误: {e}"
@@ -306,24 +367,32 @@
    async def open_audio_channels(self, conn):
        self.conn = conn
        if not hasattr(self, "_open_lock"):
            self._open_lock = threading.Lock()
        # 根据conn的sample_rate创建编码器,如果子类已经创建则不覆盖(IndexTTS接口返回为24kHZ-待重采样处理)
        if not hasattr(self, 'opus_encoder') or self.opus_encoder is None:
            self.opus_encoder = opus_encoder_utils.OpusEncoderUtils(
                sample_rate=conn.sample_rate, channels=1, frame_size_ms=60
        with self._open_lock:
            encoder = getattr(self, "opus_encoder", None)
            encoder_rate = getattr(encoder, "sample_rate", None) if encoder is not None else None
            if encoder is None or encoder_rate != conn.sample_rate:
                self.opus_encoder = opus_encoder_utils.OpusEncoderUtils(
                    sample_rate=conn.sample_rate, channels=1, frame_size_ms=60
                )
            if getattr(self, "_channels_opened", False):
                play = getattr(self, "audio_play_priority_thread", None)
                text_th = getattr(self, "tts_priority_thread", None)
                if play and play.is_alive() and text_th and text_th.is_alive():
                    return
            self.tts_priority_thread = threading.Thread(
                target=self.tts_text_priority_thread, daemon=True
            )
        # tts 消化线程
        self.tts_priority_thread = threading.Thread(
            target=self.tts_text_priority_thread, daemon=True
        )
        self.tts_priority_thread.start()
        # 音频播放 消化线程
        self.audio_play_priority_thread = threading.Thread(
            target=self._audio_play_priority_thread, daemon=True
        )
        self.audio_play_priority_thread.start()
            self.tts_priority_thread.start()
            self.audio_play_priority_thread = threading.Thread(
                target=self._audio_play_priority_thread, daemon=True
            )
            self.audio_play_priority_thread.start()
            self._channels_opened = True
    def store_tts_text(self, sentence_id, text):
        """存储指定 sentence_id 对应的文本,用于流式TTS获取正确的字幕文本
@@ -385,11 +454,16 @@
                    self.tts_text_buff = []
                    self.is_first_sentence = True
                    self.tts_audio_first_sentence = True
                    self._logged_first_opus = False
                    self.last_stream_had_audio = False
                elif ContentType.TEXT == message.content_type:
                    self.tts_text_buff.append(message.content_detail)
                    segment_text = self._get_segment_text()
                    if segment_text:
                        self.to_tts_stream(segment_text, opus_handler=self.handle_opus)
                        try:
                            self.to_tts_stream(segment_text, opus_handler=self.handle_opus)
                        except Exception as e:
                            logger.bind(tag=TAG).error(f"TTS分段合成失败: {e}")
                elif ContentType.FILE == message.content_type:
                    self._process_remaining_text_stream(opus_handler=self.handle_opus)
                    tts_file = message.content_file
@@ -398,10 +472,18 @@
                            tts_file, callback=self.handle_opus
                        )
                if message.sentence_type == SentenceType.LAST:
                    self._process_remaining_text_stream(opus_handler=self.handle_opus)
                    self.tts_audio_queue.put(
                        (message.sentence_type, [], message.content_detail, message.sentence_id)
                    )
                    try:
                        self._process_remaining_text_stream(opus_handler=self.handle_opus)
                    except Exception as e:
                        logger.bind(tag=TAG).error(
                            f"处理剩余TTS文本失败: {e}",
                        )
                    if getattr(self, "last_stream_had_audio", False):
                        self.tts_audio_queue.put(
                            (message.sentence_type, [], message.content_detail, message.sentence_id)
                        )
                    else:
                        logger.bind(tag=TAG).error("跳过TTS stop:本句没有opus音频")
            except queue.Empty:
                continue
@@ -462,7 +544,7 @@
                    sendAudioMessage(self.conn, sentence_type, audio_datas, text, sentence_id),
                    self.conn.loop,
                )
                future.result()
                future.result(timeout=20)
                # 记录输出和报告
                if self.conn.max_output_size > 0 and text:
xiaozhi-esp32-server/main/xiaozhi-server/core/utils/audioRateController.py
@@ -6,6 +6,30 @@
TAG = __name__
logger = setup_logging()
AUDIO_FRAME_DURATION = 60
AUDIO_COMPLETION_TIMEOUT_MIN = 8.0
AUDIO_COMPLETION_TIMEOUT_MAX = 180.0
def audio_completion_timeout_sec(queue_len: int, frame_ms: int = AUDIO_FRAME_DURATION) -> float:
    """Wait long enough for remaining opus frames, plus a small drain margin."""
    try:
        remaining = int(queue_len or 0)
    except (TypeError, ValueError):
        remaining = 0
    if remaining < 0:
        remaining = 0
    try:
        frame = int(frame_ms or AUDIO_FRAME_DURATION)
    except (TypeError, ValueError):
        frame = AUDIO_FRAME_DURATION
    if frame <= 0:
        frame = AUDIO_FRAME_DURATION
    return min(
        AUDIO_COMPLETION_TIMEOUT_MAX,
        max(AUDIO_COMPLETION_TIMEOUT_MIN, remaining * frame / 1000.0 + AUDIO_COMPLETION_TIMEOUT_MIN),
    )
class AudioRateController:
    """
xiaozhi-esp32-server/main/xiaozhi-server/core/utils/modules_initialize.py
@@ -99,8 +99,15 @@
def initialize_tts(config):
    select_tts_module = config["selected_module"]["TTS"]
    tts_config = config["TTS"][select_tts_module].copy()
    selected = (config.get("selected_module") or {}).get("TTS")
    providers = config.get("TTS") or {}
    if not selected or selected not in providers:
        logger.bind(tag=TAG).warning(
            f"跳过TTS初始化:配置中没有可用的TTS模块 selected={selected}"
        )
        return None
    select_tts_module = selected
    tts_config = providers[select_tts_module].copy()
    tts_config.setdefault("tts_timeout", config.get("tts_timeout", 15))
    tts_type = (
        select_tts_module
xiaozhi-esp32-server/main/xiaozhi-server/core/websocket_handshake.py
New file
@@ -0,0 +1,56 @@
"""WebSocket handshake helpers. Keep this module free of ASR/TTS imports."""
import json
def is_websocket_upgrade(headers) -> bool:
    """True when the HTTP request is a WebSocket handshake, not a health check."""
    if headers is None:
        return False
    try:
        connection_hdr = str(headers.get("connection", "") or "")
        upgrade_hdr = str(headers.get("upgrade", "") or "")
    except Exception:
        return False
    return "upgrade" in connection_hdr.lower() or "websocket" in upgrade_hdr.lower()
def is_client_hello(message) -> bool:
    """Device hello must be answered even while private config is still loading."""
    if not isinstance(message, str):
        return False
    try:
        data = json.loads(message)
    except Exception:
        return False
    return isinstance(data, dict) and data.get("type") == "hello"
def merge_hello_audio_params(welcome_params, client_params) -> dict:
    """Keep server TTS output rate. Device hello sample_rate is capture-only."""
    merged = dict(welcome_params or {})
    if not isinstance(client_params, dict):
        return merged
    fmt = client_params.get("format")
    if fmt:
        merged["format"] = fmt
    channels = client_params.get("channels")
    if channels:
        merged["channels"] = channels
    return merged
def is_priority_listen_detect(message) -> bool:
    """Boot/app-task detect must not be dropped while bind config is still loading."""
    if not isinstance(message, str):
        return False
    try:
        data = json.loads(message)
    except Exception:
        return False
    if not isinstance(data, dict) or data.get("type") != "listen":
        return False
    if data.get("state") != "detect":
        return False
    text = str(data.get("text") or "")
    return text.startswith("[app_task]") or text.startswith("[voice_entry]") or text.startswith("[skill_event]")
xiaozhi-esp32-server/main/xiaozhi-server/core/websocket_server.py
@@ -35,6 +35,7 @@
from core.auth import AuthManager, AuthenticationError
from core.utils.modules_initialize import initialize_modules
from core.utils.util import check_vad_update, check_asr_update
from core.websocket_handshake import is_websocket_upgrade
TAG = __name__
@@ -67,6 +68,33 @@
        secret_key = self.config["server"]["auth_key"]
        expire_seconds = auth_config.get("expire_seconds", None)
        self.auth = AuthManager(secret_key=secret_key, expire_seconds=expire_seconds)
        self.device_handlers = {}
        self.device_handlers_lock = asyncio.Lock()
    async def register_device_handler(self, device_id, handler):
        if not device_id:
            return
        async with self.device_handlers_lock:
            old = self.device_handlers.get(device_id)
            self.device_handlers[device_id] = handler
        if old is not None and old is not handler:
            self.logger.bind(tag=TAG).info(f"关闭同设备旧连接: {device_id}")
            asyncio.create_task(self._close_stale_handler(old))
    async def _close_stale_handler(self, handler):
        try:
            handler.stop_event.set()
            if handler.websocket:
                await handler.websocket.close()
        except Exception as e:
            self.logger.bind(tag=TAG).warning(f"关闭旧连接失败: {e}")
    async def unregister_device_handler(self, device_id, handler):
        if not device_id:
            return
        async with self.device_handlers_lock:
            if self.device_handlers.get(device_id) is handler:
                self.device_handlers.pop(device_id, None)
    async def start(self):
        server_config = self.config["server"]
@@ -144,13 +172,10 @@
                )
    async def _http_response(self, websocket, request_headers):
        # 检查是否为 WebSocket 升级请求
        if request_headers.headers.get("connection", "").lower() == "upgrade":
            # 如果是 WebSocket 请求,返回 None 允许握手继续
        headers = getattr(request_headers, "headers", None) or request_headers
        if is_websocket_upgrade(headers):
            return None
        else:
            # 如果是普通 HTTP 请求,返回 "server is running"
            return websocket.respond(200, "Server is running\n")
        return websocket.respond(200, "Server is running\n")
    async def update_config(self) -> bool:
        """更新服务器配置并重新初始化组件
xiaozhi-esp32-server/main/xiaozhi-server/plugins_func/functions/start_repeater.py
@@ -9,24 +9,28 @@
    "type": "function",
    "function": {
        "name": "start_repeater",
        "description": "在设备 SD 卡上复读指定 MP3。name 必须与 self.mp3.get_list 返回的 files[].name 完全一致。repeat 是整首循环次数。不要用 play_music。",
        "description": "打开复读机,在设备 SD 卡上循环播放 MP3。name 与 self.mp3.get_list 的 files[].name 一致;省略 name 时设备会播放上次音频或 SD 卡第一首。repeat 是整首循环次数。speed 是播放倍率 0.5~2.0,默认由音频熟悉天数决定。不要用 play_music。播放期间不会聊天。",
        "parameters": {
            "type": "object",
            "properties": {
                "name": {
                    "type": "string",
                    "description": "SD 卡 MP3 文件名,必须与 get_list 的 files[].name 一致",
                    "description": "可选。SD 卡 MP3 文件名,必须与 get_list 的 files[].name 一致;省略则播上次或第一首",
                },
                "repeat": {
                    "type": "integer",
                    "description": "整首循环次数,默认 1",
                    "description": "整首循环次数,默认 1。大于 1 且配置了 repeat_speeds 时,每一遍可以用不同倍速",
                },
                "speed": {
                    "type": "number",
                    "description": "可选。播放倍率 0.5~2.0,省略则按熟悉天数自动选择",
                },
                "power_off_after": {
                    "type": "boolean",
                    "description": "播完后是否软关机,默认 false",
                },
            },
            "required": ["name"],
            "required": [],
        },
    },
}
@@ -35,9 +39,10 @@
@register_function("start_repeater", start_repeater_function_desc, ToolType.SYSTEM_CTL)
async def start_repeater(
    conn: "ConnectionHandler",
    name: str,
    name: str = "",
    repeat: int = 1,
    power_off_after: bool = False,
    speed: float = None,
):
    from core.app_runtime.runtime import start_app_task
@@ -48,15 +53,19 @@
    if repeat_n < 1:
        repeat_n = 1
    params = {
        "name": name,
        "repeat": repeat_n,
        "power_off_after": bool(power_off_after),
    }
    if speed is not None and speed != "":
        params["speed"] = speed
    result = await start_app_task(
        conn,
        "english_learning",
        "repeater",
        {
            "name": name,
            "repeat": repeat_n,
            "power_off_after": bool(power_off_after),
        },
        params,
        source="voice",
    )
    if result.action in (Action.ERROR, Action.RESPONSE):
xiaozhi-esp32-server/main/xiaozhi-server/plugins_func/functions/start_shadowing.py
New file
@@ -0,0 +1,42 @@
from typing import TYPE_CHECKING
from plugins_func.register import register_function, ToolType, ActionResponse, Action
if TYPE_CHECKING:
    from core.connection import ConnectionHandler
start_shadowing_function_desc = {
    "type": "function",
    "function": {
        "name": "start_shadowing",
        "description": "开始跟读:设备用语音读一句英语,用户跟着读,录音会上报,但不会进入聊天。用户说退出跟读或回到聊天时结束。",
        "parameters": {
            "type": "object",
            "properties": {},
        },
    },
}
@register_function("start_shadowing", start_shadowing_function_desc, ToolType.SYSTEM_CTL)
async def start_shadowing(conn: "ConnectionHandler"):
    from core.app_runtime.runtime import start_app_task
    result = await start_app_task(
        conn,
        "english_learning",
        "shadowing",
        {},
        source="voice",
    )
    if result.action in (Action.ERROR, Action.RESPONSE):
        return ActionResponse(
            action=Action.RESPONSE,
            result=result.result,
            response=result.response or "无法开始跟读",
        )
    return ActionResponse(
        action=Action.NONE,
        result=result.result,
        response=result.response,
    )
xiaozhi-esp32-server/main/xiaozhi-server/plugins_func/functions/switch_app.py
@@ -9,14 +9,23 @@
    "type": "function",
    "function": {
        "name": "switch_app",
        "description": "切换设备当前 App。打开翻译、打开英语学习/复读、回到聊天、退出翻译时调用。app_id 只能是 chat、english_learning、english_translation。",
        "description": (
            "切换设备当前 App。"
            "chat=对话;english_translation=翻译;english_conversation=中英对话;"
            "english_learning 需配合 task_id:repeater=复读机,shadowing=跟读。"
            "退出当前模式、回到聊天时 app_id=chat。"
        ),
        "parameters": {
            "type": "object",
            "properties": {
                "app_id": {
                    "type": "string",
                    "description": "目标 App:chat(聊天)、english_learning(复读)、english_translation(翻译)",
                }
                    "description": "chat、english_learning、english_translation、english_conversation",
                },
                "task_id": {
                    "type": "string",
                    "description": "可选。english_learning 下 repeater 或 shadowing;其它 App 可空",
                },
            },
            "required": ["app_id"],
        },
@@ -25,10 +34,10 @@
@register_function("switch_app", switch_app_function_desc, ToolType.SYSTEM_CTL)
async def switch_app(conn: "ConnectionHandler", app_id: str):
async def switch_app(conn: "ConnectionHandler", app_id: str, task_id: str = ""):
    from core.app_runtime.runtime import switch_app as runtime_switch
    result = await runtime_switch(conn, app_id, source="voice")
    result = await runtime_switch(conn, app_id, source="voice", task_id=task_id or "")
    if result.action == Action.ERROR:
        return ActionResponse(
            action=Action.RESPONSE,
xiaozhi-esp32-server/main/xiaozhi-server/tests/conftest.py
New file
@@ -0,0 +1,66 @@
"""Lightweight stubs so App Runtime tests run without the full server venv."""
import os
import sys
from types import ModuleType
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if ROOT not in sys.path:
    sys.path.insert(0, ROOT)
class _SilentLogger:
    def bind(self, **kwargs):
        return self
    def info(self, *args, **kwargs):
        return None
    def warning(self, *args, **kwargs):
        return None
    def error(self, *args, **kwargs):
        return None
    def debug(self, *args, **kwargs):
        return None
_LOG = _SilentLogger()
def _install(name, module):
    sys.modules.setdefault(name, module)
if "config.logger" not in sys.modules:
    logger_mod = ModuleType("config.logger")
    logger_mod.setup_logging = lambda *args, **kwargs: _LOG
    logger_mod.build_module_string = lambda *args, **kwargs: "0" * 14
    logger_mod.create_connection_logger = lambda *args, **kwargs: _LOG
    logger_mod.logger = _LOG
    _install("config.logger", logger_mod)
if "config.config_loader" not in sys.modules:
    loader_mod = ModuleType("config.config_loader")
    loader_mod.get_project_dir = lambda: ROOT.replace("\\", "/") + "/"
    _install("config.config_loader", loader_mod)
def _remove_punctuation_and_length(text):
    full_width = "!"#$%&'()*+,-。/:;<=>?@[\]^_`{|}~"
    half_width = r'!"#$%&\'()*+,-./:;<=>?@[\]^_`{|}~'
    result = "".join(
        ch
        for ch in (text or "")
        if ch not in full_width and ch not in half_width and ch not in (" ", " ")
    )
    if result == "Yeah":
        return 0, ""
    return len(result), result
if "core.utils.util" not in sys.modules:
    util_mod = ModuleType("core.utils.util")
    util_mod.remove_punctuation_and_length = _remove_punctuation_and_length
    _install("core.utils.util", util_mod)
xiaozhi-esp32-server/main/xiaozhi-server/tests/test_app_runtime.py
New file
@@ -0,0 +1,474 @@
"""Phase 2 App Runtime unit tests. No websocket, no Opus."""
from types import SimpleNamespace
from core.app_runtime.runtime import (
    _plain_user_text,
    app_sd_layout_paths,
    is_echo_of_prompt,
    lesson_mp3_clips,
    match_voice_switch,
    repeater_lesson_mp3,
)
from core.app_runtime.session import (
    TaskSession,
    parse_skill_event,
    should_report_asr_now,
)
def test_match_voice_switch_shadowing():
    assert match_voice_switch("开始跟读") == ("english_learning", "shadowing")
def test_match_voice_switch_menu():
    assert match_voice_switch("切换模式") == ("__menu__", "")
    assert match_voice_switch("模式切换") == ("__menu__", "")
def test_match_voice_switch_chat_return():
    assert match_voice_switch("我们来聊天吧") == ("chat", "conversation")
    assert match_voice_switch("我们聊天吧") == ("chat", "conversation")
    assert match_voice_switch("不想跟读了") == ("chat", "conversation")
    assert match_voice_switch("退出翻译") == ("chat", "conversation")
    assert match_voice_switch("返回聊天") == ("chat", "conversation")
    assert match_voice_switch("退出跟读") == ("chat", "conversation")
    assert match_voice_switch("退出复读") == ("chat", "conversation")
def test_enter_prompts_announce_mode_without_exit_echo():
    from core.app_runtime.registry import get_task
    _, repeater = get_task("english_learning", "repeater")
    _, shadowing = get_task("english_learning", "shadowing")
    repeater_prompt = (repeater or {}).get("enter_prompt") or ""
    shadowing_prompt = (shadowing or {}).get("enter_prompt") or ""
    assert "现在已经切换至复读机模式" in repeater_prompt
    assert "退出复读" not in repeater_prompt
    assert "现在已经切换至跟读模式" in shadowing_prompt
    assert "退出跟读" not in shadowing_prompt
def test_voice_menu_text_uses_task_menu_names():
    from core.app_runtime.runtime import voice_menu_text
    menu = voice_menu_text()
    assert "听复读机" in menu
    assert "跟读" in menu
    assert "翻译" in menu
    assert "中英对话" in menu
    assert "我们来聊天吧" in menu
def test_exit_phrases_beat_short_enter_phrases():
    from core.app_runtime.runtime import is_exit_to_chat
    assert match_voice_switch("跟读") == ("english_learning", "shadowing")
    assert match_voice_switch("听读") == ("english_learning", "shadowing")
    assert match_voice_switch("我想退出跟读") == ("chat", "conversation")
    assert is_exit_to_chat("退出翻译") is True
    assert is_exit_to_chat("开始跟读") is False
    assert is_exit_to_chat("退出听读") is True
def test_new_task_phrases_come_from_registry_only(monkeypatch):
    from copy import deepcopy
    from core.app_runtime import registry as reg
    data = deepcopy(reg.get_registry())
    data["apps"]["story_retell"] = {
        "name": "故事复述",
        "default_task": "play",
        "tasks": {
            "play": {
                "menu_name": "故事复述",
                "voice_enter": ["开始故事复述", "故事复述"],
                "voice_exit": ["退出复述"],
                "enter_prompt": "现在已经切换至故事复述。",
            }
        },
    }
    monkeypatch.setattr(reg, "_REGISTRY", data)
    monkeypatch.setattr(reg, "_VOICE_INDEX", None)
    assert match_voice_switch("开始故事复述") == ("story_retell", "play")
    assert match_voice_switch("退出复述") == ("chat", "conversation")
    menu = reg.voice_menu_text()
    assert "故事复述" in menu
def test_match_voice_switch_select_after_menu():
    assert match_voice_switch("跟读") == ("english_learning", "shadowing")
    assert match_voice_switch("翻译") == ("english_translation", "live_translate")
def test_match_voice_switch_translate():
    assert match_voice_switch("打开翻译") == ("english_translation", "live_translate")
def test_reserved_voice_enter_stays_in_registry():
    assert match_voice_switch("成语接龙") == ("__reserved__", "")
    assert match_voice_switch("数字背诵") == ("__reserved__", "")
def test_is_echo_of_prompt_same_sentence():
    prompt = "Hello, how are you today?"
    assert is_echo_of_prompt(prompt, prompt) is True
    assert is_echo_of_prompt("unrelated park visit", prompt) is False
def test_plain_user_text_extracts_json_content():
    assert _plain_user_text('{"content":"hello world","language":"en"}') == "hello world"
    assert _plain_user_text("just text") == "just text"
def test_parse_skill_event_completed():
    event = parse_skill_event(
        "[skill_event]audio.completed|english_learning|repeater|1234"
    )
    assert event is not None
    assert event["name"] == "audio.completed"
    assert event["app_id"] == "english_learning"
    assert event["task_id"] == "repeater"
    assert event["duration_ms"] == "1234"
def test_parse_skill_event_stopped_and_reject_plain():
    event = parse_skill_event("[skill_event]audio.stopped|english_learning|repeater")
    assert event["name"] == "audio.stopped"
    assert parse_skill_event("我们来聊天吧") is None
    assert parse_skill_event("[app_task]chat|conversation|voice") is None
def test_task_session_save_load(tmp_path, monkeypatch):
    monkeypatch.setattr("core.app_runtime.session.session_dir", lambda: str(tmp_path))
    session = TaskSession(
        task_session_id="sess-demo",
        ws_session_id="ws-1",
        device_id="mac",
        app_id="english_learning",
        task_id="shadowing",
        source="voice",
        interaction_mode="SHADOWING",
        started_at=1,
        status="running",
    )
    session.add_turn(0, "Hello, how are you today?", "hello how are you today", False)
    session.add_turn(1, "I like to read English books.", "i like to read", False)
    session.finish("finished")
    session.save()
    loaded = TaskSession.load("sess-demo")
    assert loaded is not None
    assert loaded.status == "completed"
    assert loaded.end_reason == "finished"
    assert len(loaded.turns) == 2
    assert loaded.turns[0]["prompt_text"] == "Hello, how are you today?"
    assert loaded.turns[1]["asr_text"] == "i like to read"
def test_should_report_asr_now_defers_play_and_shadowing():
    assert should_report_asr_now(SimpleNamespace(interaction_mode="CHAT")) is True
    assert should_report_asr_now(SimpleNamespace(interaction_mode="PLAY_AUDIO")) is False
    assert should_report_asr_now(SimpleNamespace(interaction_mode="SHADOWING")) is False
    assert should_report_asr_now(SimpleNamespace(interaction_mode="TRANSLATION")) is True
def test_last_shadowing_turn_is_saved_before_chat(tmp_path, monkeypatch):
    from core.app_runtime.runtime import apply_shadowing_user_text
    from core.app_runtime.session import begin_session
    monkeypatch.setattr("core.app_runtime.session.session_dir", lambda: str(tmp_path))
    conn = SimpleNamespace(
        session_id="ws-1",
        device_id="mac",
        interaction_mode="SHADOWING",
        client_is_speaking=False,
        shadowing_index=0,
        shadowing_prompt="Hello, how are you today?",
        shadowing_clips=[],
        shadowing_awaiting_user=True,
        config={"app_runtime": {"shadowing_sentences": ["Hello, how are you today?"]}},
        last_asr_audio_snapshot=[],
        sentence_id=None,
        task_session=None,
        active_app_id="english_learning",
        current_task_id="shadowing",
    )
    begin_session(conn, "english_learning", "shadowing", "voice", "SHADOWING")
    outcome = apply_shadowing_user_text(conn, "I went to the park yesterday")
    assert outcome == "recorded"
    assert conn.shadowing_index == 0
    loaded = TaskSession.load(conn.task_session.task_session_id)
    assert loaded.status == "running"
    assert len(loaded.turns) == 1
    assert loaded.turns[0]["asr_text"] == "I went to the park yesterday"
def test_lesson_mp3_clips_picks_first_lesson_folder():
    names = [
        "/english_learning/repeater/lessons/unit01/full.mp3",
        "/english_learning/shadowing/lessons/unit02/02.mp3",
        "/english_learning/shadowing/lessons/unit01/02.mp3",
        "/english_learning/shadowing/lessons/unit01/01.mp3",
        "/english_learning/shadowing/lessons/unit01/full.mp3",
        "/english_learning/shadowing/lessons/unit01/audio/03.mp3",
        "loose.mp3",
    ]
    clips = lesson_mp3_clips(names, "english_learning", "shadowing")
    assert clips == [
        "/english_learning/shadowing/lessons/unit01/01.mp3",
        "/english_learning/shadowing/lessons/unit01/02.mp3",
        "/english_learning/shadowing/lessons/unit01/audio/03.mp3",
    ]
    only_unit2 = lesson_mp3_clips(names, "english_learning", "shadowing", "unit02")
    assert only_unit2 == ["/english_learning/shadowing/lessons/unit02/02.mp3"]
    assert repeater_lesson_mp3(names, "english_learning", "repeater") == (
        "/english_learning/repeater/lessons/unit01/full.mp3"
    )
    assert repeater_lesson_mp3(names, "english_learning", "shadowing") == (
        "/english_learning/shadowing/lessons/unit01/full.mp3"
    )
def test_task_session_to_dict_has_phase3_fields():
    session = TaskSession(
        task_session_id="abc123",
        ws_session_id="ws-1",
        device_id="mac",
        agent_id="",
        app_id="english_learning",
        task_id="shadowing",
        source="voice",
        interaction_mode="SHADOWING",
        started_at=1,
        status="running",
    )
    session.add_turn(0, "/english_learning/shadowing/lessons/unit01/01.mp3", "hello", False)
    session.add_event("audio.completed", "english_learning", "shadowing")
    data = session.to_dict()
    assert data["task_session_id"] == "abc123"
    assert data["device_id"] == "mac"
    assert "agent_id" in data
    assert data["turns"][0]["prompt_text"].endswith("01.mp3")
    assert data["events"][0]["name"] == "audio.completed"
def test_save_survives_upsert_scheduler_error(tmp_path, monkeypatch):
    monkeypatch.setattr("core.app_runtime.session.session_dir", lambda: str(tmp_path))
    def boom(_payload):
        raise RuntimeError("manage_api down")
    monkeypatch.setattr("core.app_runtime.session._schedule_upsert", boom)
    session = TaskSession(
        task_session_id="sess-upsert-fail",
        ws_session_id="ws-1",
        device_id="mac",
        app_id="english_learning",
        task_id="shadowing",
        source="voice",
        interaction_mode="SHADOWING",
        started_at=1,
        status="running",
    )
    session.add_turn(0, "Hello", "hello", False)
    session.finish("finished")
    session.save()
    loaded = TaskSession.load("sess-upsert-fail")
    assert loaded is not None
    assert loaded.status == "completed"
    assert len(loaded.turns) == 1
def test_repeater_queues_play_until_listen_start(monkeypatch):
    import asyncio
    from core.app_runtime.runtime import maybe_flush_pending_play_after_tts, start_app_task
    plays = []
    async def fake_play(conn, params):
        plays.append(params)
        return None
    async def fake_dirs(conn, app_id):
        return None
    async def fake_list(conn):
        return [
            "/english_learning/shadowing/lessons/unit01/full.mp3",
            "/english_learning/repeater/lessons/unit01/full.mp3",
            "/english_learning/shadowing/lessons/unit01/01.mp3",
        ]
    monkeypatch.setattr("core.app_runtime.runtime._execute_named_tool_play", fake_play)
    monkeypatch.setattr("core.app_runtime.runtime.ensure_app_sd_dirs", fake_dirs)
    monkeypatch.setattr("core.app_runtime.runtime._list_sd_mp3_names", fake_list)
    spoken = []
    monkeypatch.setattr("core.app_runtime.runtime._speak", lambda conn, text: spoken.append(text))
    conn = SimpleNamespace(config={}, prompt="", func_handler=None)
    asyncio.run(start_app_task(conn, "english_learning", "repeater", {}, source="voice"))
    assert spoken and "复读机" in spoken[0]
    assert plays == []
    assert conn.pending_play_after_tts and conn.pending_play_after_tts["kind"] == "repeater"
    asyncio.run(maybe_flush_pending_play_after_tts(conn))
    assert len(plays) == 1
    assert plays[0]["name"] == "/english_learning/shadowing/lessons/unit01/01.mp3"
    assert plays[0]["repeat"] == 10
    assert plays[0].get("finish_task") is False
    assert len(plays[0]["items"]) == 2
    assert plays[0]["items"][1]["name"].endswith("/02.mp3")
    assert plays[0]["items"][1]["repeat"] == 5
    assert conn.repeater_playlist == []
    assert conn.pending_play_after_tts is None
def test_shadowing_queues_whole_playlist_until_listen_start(monkeypatch):
    import asyncio
    from core.app_runtime.runtime import maybe_flush_pending_play_after_tts, start_app_task
    plays = []
    async def fake_play(conn, params):
        plays.append(params)
        return None
    async def fake_dirs(conn, app_id):
        return None
    monkeypatch.setattr("core.app_runtime.runtime._execute_named_tool_play", fake_play)
    monkeypatch.setattr("core.app_runtime.runtime.ensure_app_sd_dirs", fake_dirs)
    spoken = []
    monkeypatch.setattr("core.app_runtime.runtime._speak", lambda conn, text: spoken.append(text))
    conn = SimpleNamespace(config={}, prompt="", func_handler=None)
    asyncio.run(start_app_task(conn, "english_learning", "shadowing", {}, source="voice"))
    assert spoken and "跟读" in spoken[0]
    assert plays == []
    assert conn.pending_play_after_tts and conn.pending_play_after_tts["kind"] == "shadowing"
    queued = conn.pending_play_after_tts["params"]["items"]
    assert len(queued) == 2
    assert queued[0]["repeat"] == 5
    assert queued[1]["name"].endswith("/02.mp3")
    assert queued[1]["repeat"] == 10
    asyncio.run(maybe_flush_pending_play_after_tts(conn))
    assert len(plays) == 1
    assert len(plays[0]["items"]) == 2
    assert plays[0]["items"][0]["repeat"] == 5
    assert plays[0]["items"][1]["repeat"] == 10
    assert plays[0].get("finish_task") is False
    assert plays[0].get("listen_ratio") == 1.2
    assert conn.pending_play_after_tts is None
def test_shadowing_expands_default_playlist_into_listen_turns():
    import asyncio
    from core.app_runtime.runtime import _prepare_shadowing_sd
    conn = SimpleNamespace(
        config={},
        active_app_id="english_learning",
        current_task_id="shadowing",
    )
    ok = asyncio.run(_prepare_shadowing_sd(conn, {}))
    assert ok is True
    unit01_01 = "/english_learning/shadowing/lessons/unit01/01.mp3"
    unit01_02 = "/english_learning/shadowing/lessons/unit01/02.mp3"
    assert conn.shadowing_clips.count(unit01_01) == 5
    assert conn.shadowing_clips.count(unit01_02) == 10
    assert len(conn.shadowing_clips) == 15
    assert conn.shadowing_prompt == unit01_01
def test_app_sd_layout_paths_from_registry():
    from core.app_runtime.registry import get_app
    paths = app_sd_layout_paths("english_learning", get_app("english_learning"))
    assert set(paths) == {
        "/english_learning/repeater/lessons",
        "/english_learning/shadowing/lessons",
    }
    assert app_sd_layout_paths("chat", get_app("chat")) == []
    assert app_sd_layout_paths("english_translation", get_app("english_translation")) == []
def test_boot_default_chat_speaks_greeting(monkeypatch):
    import asyncio
    from core.app_runtime.runtime import start_app_task
    spoken = []
    monkeypatch.setattr("core.app_runtime.runtime._speak", lambda conn, text: spoken.append(text))
    async def _ready(conn, timeout=8):
        return True
    monkeypatch.setattr("core.app_runtime.runtime.wait_for_tts_channels", _ready)
    conn = SimpleNamespace(
        config={
            "enable_proactive_greeting": True,
            "proactive_chat_greeting": "我们来聊天吧。",
        },
        prompt="",
        func_handler=None,
        just_woken_up=True,
        chat_greeted=False,
        active_app_id="chat",
        current_task_id="conversation",
        current_source="default_chat",
        interaction_mode="CHAT",
    )
    conn.change_system_prompt = lambda text: setattr(conn, "prompt", text)
    asyncio.run(start_app_task(conn, "chat", "conversation", {}, source="default_chat"))
    assert spoken == ["我们来聊天吧。"]
    assert conn.chat_greeted is True
def test_client_hello_is_recognized_before_bind():
    from core.websocket_handshake import is_client_hello
    assert is_client_hello('{"type":"hello","version":1}') is True
    assert is_client_hello('{"type":"listen","state":"detect"}') is False
    assert is_client_hello(b"\x00\x01") is False
def test_merge_hello_audio_params_keeps_server_output_rate():
    from core.websocket_handshake import merge_hello_audio_params
    merged = merge_hello_audio_params(
        {"format": "opus", "sample_rate": 24000, "channels": 1, "frame_duration": 60},
        {"format": "opus", "sample_rate": 16000, "channels": 1, "frame_duration": 60},
    )
    assert merged["sample_rate"] == 24000
    assert merged["frame_duration"] == 60
    assert merged["format"] == "opus"
def test_priority_listen_detect_is_not_dropped_during_bind():
    from core.websocket_handshake import is_priority_listen_detect
    assert is_priority_listen_detect(
        '{"type":"listen","state":"detect","text":"[app_task]chat|conversation|default_chat"}'
    ) is True
    assert is_priority_listen_detect(
        '{"type":"listen","state":"detect","text":"你好小智"}'
    ) is False
def test_websocket_upgrade_accepts_keep_alive_connection_header():
    from core.websocket_handshake import is_websocket_upgrade
    assert is_websocket_upgrade({"connection": "Upgrade", "upgrade": "websocket"}) is True
    assert is_websocket_upgrade({"connection": "keep-alive, Upgrade", "upgrade": "websocket"}) is True
    assert is_websocket_upgrade({"connection": "keep-alive"}) is False
def test_app_sd_layout_paths_custom_subscribed_app():
    app = {
        "tasks": {
            "listen": {
                "interaction_mode": "PLAY_AUDIO",
                "skills": [{"name": "self.mp3.play"}],
            },
            "chatty": {"interaction_mode": "CHAT", "skills": []},
        }
    }
    assert app_sd_layout_paths("story_time", app) == ["/story_time/listen/lessons"]
xiaozhi-esp32-server/main/xiaozhi-server/tests/test_assessment.py
New file
@@ -0,0 +1,544 @@
"""Phase 4: listen-window clock + deferred quantitative assessment."""
import asyncio
from types import SimpleNamespace
from core.app_runtime.assessment import (
    build_metrics,
    clamp_listen_ratio,
    listen_window_ms,
    resolve_expected_text,
    text_similarity_v0,
)
from core.app_runtime.session import TaskSession, begin_session
CLIPS = [
    "/english_learning/shadowing/lessons/unit01/01.mp3",
    "/english_learning/shadowing/lessons/unit01/02.mp3",
]
def test_resolve_expected_text_from_transcripts_not_path():
    task = {
        "transcripts": {"01.mp3": "Hello, how are you today?", "01": "Hello, how are you today?"},
        "sentences": ["/english_learning/shadowing/lessons/unit01/01.mp3"],
    }
    expected = resolve_expected_text(task, CLIPS, 0)
    assert expected == "Hello, how are you today?"
    assert not expected.endswith(".mp3")
    assert resolve_expected_text(task, CLIPS, 0) != CLIPS[0]
def test_resolve_expected_text_rejects_path_as_original():
    task = {
        "transcripts": {"01.mp3": "/english_learning/shadowing/lessons/unit01/01.mp3"},
        "sentences": [CLIPS[0]],
    }
    assert resolve_expected_text(task, CLIPS, 0) is None
def test_resolve_expected_text_from_cache_txt(tmp_path):
    (tmp_path / "01.txt").write_text("I like to read English books.", encoding="utf-8")
    expected = resolve_expected_text({}, CLIPS, 0, cache_dir=str(tmp_path))
    assert expected == "I like to read English books."
def test_resolve_expected_text_sentences_fallback():
    task = {"sentences": ["Hello, how are you today?", "I like to read English books."]}
    assert resolve_expected_text(task, CLIPS, 1) == "I like to read English books."
def test_text_similarity_metrics_have_no_passed_or_grade():
    metrics = build_metrics("Hello, how are you today?", "Hello, how are you today?")
    assert len(metrics) == 1
    assert metrics[0]["method"] == "text_similarity_v0"
    assert 0.0 <= metrics[0]["value"] <= 1.0
    assert "passed" not in metrics[0]
    assert "grade" not in metrics[0]
    ratio = text_similarity_v0("hello how are you today", "Hello, how are you today?")
    assert ratio > 0.9
def test_build_metrics_empty_when_missing_asr_or_expected():
    assert build_metrics("", "Hello") == []
    assert build_metrics("Hello", "") == []
    assert build_metrics("Hello", CLIPS[0]) == []
def test_listen_ratio_is_clamped():
    assert clamp_listen_ratio(0.5) == 1.2
    assert clamp_listen_ratio(3.0) == 1.5
    assert clamp_listen_ratio(1.3) == 1.3
    assert listen_window_ms(1000, 0.5) == max(800, int(1000 * 1.2))
    assert listen_window_ms(1000, 3.0) == int(1000 * 1.5)
    assert listen_window_ms(100, 1.2) == 800
    # 听窗随上一句实际播放时长变化,不是固定 2.5s;倍速已含在 wall-clock duration 里。
    assert listen_window_ms(2050, 1.2) == int(2050 * 1.2)
    assert listen_window_ms(4000, 1.2) == int(4000 * 1.2)
    assert listen_window_ms(2050, 1.2, speed=0.9, wall_clock=True) == int(2050 * 1.2)
    assert listen_window_ms(1800, 1.2, speed=0.9, wall_clock=False) == int(round(1800 / 0.9) * 1.2)
def _shadowing_conn(tmp_path, monkeypatch):
    monkeypatch.setattr("core.app_runtime.session.session_dir", lambda: str(tmp_path))
    monkeypatch.setattr("core.app_runtime.session._schedule_upsert", lambda _payload: None)
    monkeypatch.setattr("core.audio_archive.get_project_dir", lambda: str(tmp_path))
    conn = SimpleNamespace(
        session_id="ws-1",
        device_id="mac",
        interaction_mode="SHADOWING",
        client_is_speaking=False,
        shadowing_index=0,
        shadowing_prompt=CLIPS[0],
        shadowing_clips=list(CLIPS),
        shadowing_awaiting_user=True,
        shadowing_clip_gen=1,
        shadowing_assess_index=0,
        shadowing_listen_pcm=[],
        shadowing_audio_saved_index=-1,
        asr_audio=[],
        config={"audio_archive": {"enabled": True, "dir": "data/recordings"}},
        last_asr_audio_snapshot=[],
        sentence_id=None,
        task_session=None,
        active_app_id="english_learning",
        current_task_id="shadowing",
        loop=None,
        prompt="",
    )
    begin_session(conn, "english_learning", "shadowing", "voice", "SHADOWING")
    return conn
def test_asr_does_not_play_next_only_timer_advances(tmp_path, monkeypatch):
    from core.app_runtime.runtime import (
        _on_shadowing_listen_window_closed,
        apply_shadowing_user_text,
    )
    played = []
    async def fake_play(conn, name):
        played.append(name)
        return True
    monkeypatch.setattr("core.app_runtime.runtime._play_shadowing_clip", fake_play)
    conn = _shadowing_conn(tmp_path, monkeypatch)
    outcome = apply_shadowing_user_text(conn, "today weather is bad")
    assert outcome == "recorded"
    assert conn.shadowing_index == 0
    assert played == []
    asyncio.run(_on_shadowing_listen_window_closed(conn))
    assert conn.shadowing_index == 1
    assert played == []
    assert conn.task_session.find_turn(0)["asr_text"] == "today weather is bad"
def test_skip_listen_stop_asr_while_shadowing_clip_plays():
    from core.app_runtime.runtime import should_skip_listen_stop_asr
    playing = SimpleNamespace(interaction_mode="SHADOWING", shadowing_awaiting_user=False)
    listening = SimpleNamespace(interaction_mode="SHADOWING", shadowing_awaiting_user=True)
    chat = SimpleNamespace(interaction_mode="CHAT", shadowing_awaiting_user=False)
    repeater = SimpleNamespace(interaction_mode="PLAY_AUDIO", shadowing_awaiting_user=False)
    assert should_skip_listen_stop_asr(playing) is True
    assert should_skip_listen_stop_asr(listening) is False
    assert should_skip_listen_stop_asr(chat) is False
    assert should_skip_listen_stop_asr(repeater) is True
def test_timer_advances_after_assess_and_upsert_errors(tmp_path, monkeypatch):
    from core.app_runtime.runtime import _on_shadowing_listen_window_closed
    played = []
    async def fake_play(conn, name):
        played.append(name)
        return True
    def boom_assess(*_args, **_kwargs):
        raise RuntimeError("assess exploded")
    monkeypatch.setattr("core.app_runtime.runtime._play_shadowing_clip", fake_play)
    monkeypatch.setattr("core.app_runtime.runtime.apply_deferred_assessment", boom_assess)
    conn = _shadowing_conn(tmp_path, monkeypatch)
    def boom_save():
        raise RuntimeError("upsert exploded")
    conn.task_session.save = boom_save
    asyncio.run(_on_shadowing_listen_window_closed(conn))
    assert conn.shadowing_index == 1
    assert played == []
def test_timer_advances_without_asr(tmp_path, monkeypatch):
    from core.app_runtime.runtime import _on_shadowing_listen_window_closed
    played = []
    async def fake_play(conn, name):
        played.append(name)
        return True
    monkeypatch.setattr("core.app_runtime.runtime._play_shadowing_clip", fake_play)
    conn = _shadowing_conn(tmp_path, monkeypatch)
    asyncio.run(_on_shadowing_listen_window_closed(conn))
    assert conn.shadowing_index == 1
    turn = conn.task_session.find_turn(0)
    assert turn is not None
    assert turn["asr_text"] == ""
    names = [event["name"] for event in conn.task_session.events]
    assert "listen_window_closed" in names
    assert played == []
def test_upsert_turn_keeps_last_asr_same_index(tmp_path, monkeypatch):
    monkeypatch.setattr("core.app_runtime.session.session_dir", lambda: str(tmp_path))
    session = TaskSession(task_session_id="dup", app_id="english_learning", task_id="shadowing")
    session.upsert_turn(0, CLIPS[0], "first try")
    session.upsert_turn(0, CLIPS[0], "second try")
    assert len(session.turns) == 1
    assert session.turns[0]["asr_text"] == "second try"
def test_deferred_assessment_writes_metrics_not_passed(tmp_path, monkeypatch):
    from core.app_runtime.runtime import apply_deferred_assessment, apply_shadowing_user_text
    conn = _shadowing_conn(tmp_path, monkeypatch)
    apply_shadowing_user_text(conn, "Hello, how are you today?")
    apply_deferred_assessment(conn, 0)
    turn = conn.task_session.find_turn(0)
    assert turn["expected_text"] == "Hello, how are you today?"
    assert not str(turn["expected_text"]).endswith(".mp3")
    assert turn["metrics"][0]["method"] == "text_similarity_v0"
    assert 0.0 <= turn["metrics"][0]["value"] <= 1.0
    assert "passed" not in turn
    assert "grade" not in turn
    assert "passed" not in turn["metrics"][0]
def test_upsert_empty_metrics_do_not_wipe_existing(tmp_path, monkeypatch):
    monkeypatch.setattr("core.app_runtime.session.session_dir", lambda: str(tmp_path))
    session = TaskSession(task_session_id="keep-m", app_id="english_learning", task_id="shadowing")
    session.upsert_turn(
        0,
        CLIPS[0],
        "hello",
        expected_text="Hello, how are you today?",
        metrics=[{"method": "text_similarity_v0", "value": 0.9}],
        assessed_at=1,
    )
    session.upsert_turn(0, CLIPS[0], "hello")
    assert session.turns[0]["metrics"][0]["value"] == 0.9
    assert "passed" not in session.turns[0]
    assert "passed" not in session.turns[0]["metrics"][0]
def test_skill_event_starts_listen_window_before_save(tmp_path, monkeypatch):
    from core.app_runtime.runtime import handle_skill_event, listen_window_ms
    scheduled = []
    def fake_schedule(conn, duration_ms):
        scheduled.append(duration_ms)
    monkeypatch.setattr(
        "core.app_runtime.runtime._schedule_shadowing_listen_timeout", fake_schedule
    )
    conn = _shadowing_conn(tmp_path, monkeypatch)
    conn.shadowing_awaiting_user = False
    asyncio.run(
        handle_skill_event(
            conn, "[skill_event]audio.completed|english_learning|shadowing|2050"
        )
    )
    assert scheduled == [2050]
    assert listen_window_ms(2050, 1.2) == int(2050 * 1.2)
    assert conn.task_session.events[-1]["name"] == "audio.completed"
def test_repeater_completed_returns_to_chat_even_if_event_says_shadowing(monkeypatch):
    from core.app_runtime.runtime import handle_skill_event
    switched = []
    async def fake_start(conn, app_id, task_id, params, source="voice", **kwargs):
        switched.append(
            (app_id, task_id, kwargs.get("speak_switch"), kwargs.get("stop_skills"))
        )
        return None
    async def fake_continue(conn):
        return False
    monkeypatch.setattr("core.app_runtime.runtime.start_app_task", fake_start)
    monkeypatch.setattr("core.app_runtime.runtime._continue_repeater_if_needed", fake_continue)
    spoken = []
    monkeypatch.setattr("core.app_runtime.runtime._speak", lambda conn, text: spoken.append(text))
    conn = SimpleNamespace(
        interaction_mode="PLAY_AUDIO",
        active_app_id="english_learning",
        current_task_id="repeater",
        task_session=None,
        repeater_speed_queue=[],
        repeater_play_name="",
        prompt="",
        config={},
        device_id="mac",
    )
    asyncio.run(
        handle_skill_event(
            conn, "[skill_event]audio.completed|english_learning|shadowing|6130"
        )
    )
    assert switched == [("chat", "conversation", False, False)]
    assert spoken == ["复读结束。我们来聊天吧。"]
def test_late_asr_after_listen_window_still_scores_previous_sentence(tmp_path, monkeypatch):
    from core.app_runtime.runtime import apply_shadowing_user_text
    conn = _shadowing_conn(tmp_path, monkeypatch)
    conn.shadowing_awaiting_user = False
    conn.shadowing_index = 1
    conn.shadowing_assess_index = 0
    conn.shadowing_prompt = CLIPS[1]
    conn.client_is_speaking = False
    outcome = apply_shadowing_user_text(conn, "hello how are you today")
    assert outcome == "recorded"
    assert conn.shadowing_index == 1
    turn = conn.task_session.find_turn(0)
    assert turn["asr_text"] == "hello how are you today"
    assert conn.task_session.find_turn(1) is None
def test_evaluate_turn_saves_wav_and_metrics(tmp_path, monkeypatch):
    from core.app_runtime.assessment import pcm_snapshot_to_wav
    from core.app_runtime.runtime import apply_deferred_assessment, apply_shadowing_user_text
    captured = {}
    def fake_save(session_id, step_index, wav_bytes, conn=None):
        captured["wav"] = wav_bytes
        captured["id"] = f"{session_id}_{step_index}.wav"
        return captured["id"]
    monkeypatch.setattr("core.app_runtime.runtime.save_turn_wav", fake_save)
    conn = _shadowing_conn(tmp_path, monkeypatch)
    # 16-bit silence ~ 20ms
    conn.last_asr_audio_snapshot = [b"\x00\x00" * 160]
    apply_shadowing_user_text(conn, "Hello, how are you today?")
    apply_deferred_assessment(conn, 0, audio_snapshot=conn.last_asr_audio_snapshot)
    turn = conn.task_session.find_turn(0)
    assert turn["metrics"][0]["method"] == "text_similarity_v0"
    assert turn["audio_id"] == captured["id"]
    wav = pcm_snapshot_to_wav(conn.last_asr_audio_snapshot)
    assert captured["wav"][:4] == b"RIFF"
    assert wav[8:12] == b"WAVE"
    assert captured["wav"] == wav
def test_shadowing_playlist_end_announces_then_chat(tmp_path, monkeypatch):
    from core.app_runtime.runtime import handle_skill_event
    switched = []
    spoken = []
    played = []
    async def fake_play(conn, name):
        played.append(name)
        return True
    async def fake_start(conn, app_id, task_id, params, source="voice", **kwargs):
        switched.append((app_id, task_id, kwargs.get("speak_switch")))
        conn.interaction_mode = "CHAT"
        conn.active_app_id = app_id
        conn.current_task_id = task_id
        return None
    monkeypatch.setattr("core.app_runtime.runtime._play_shadowing_clip", fake_play)
    monkeypatch.setattr("core.app_runtime.runtime.start_app_task", fake_start)
    monkeypatch.setattr("core.app_runtime.runtime._speak", lambda conn, text: spoken.append(text))
    conn = _shadowing_conn(tmp_path, monkeypatch)
    conn.shadowing_index = 1
    conn.shadowing_prompt = CLIPS[1]
    conn.shadowing_awaiting_user = False
    asyncio.run(
        handle_skill_event(
            conn, "[skill_event]audio.playlist_completed|english_learning|shadowing"
        )
    )
    assert played == []
    assert switched == [("chat", "conversation", False)]
    assert spoken == ["跟读结束。我们来聊天吧。"]
def test_playlist_completed_returns_chat_after_reconnect(monkeypatch):
    """WS 掉线后设备可能在新的 CHAT 连接上补发 playlist_completed。"""
    from core.app_runtime.runtime import handle_skill_event
    switched = []
    spoken = []
    async def fake_start(conn, app_id, task_id, params, source="voice", **kwargs):
        switched.append((app_id, task_id, kwargs.get("speak_switch")))
        conn.interaction_mode = "CHAT"
        return None
    monkeypatch.setattr("core.app_runtime.runtime.start_app_task", fake_start)
    monkeypatch.setattr("core.app_runtime.runtime._speak", lambda conn, text: spoken.append(text))
    conn = SimpleNamespace(
        interaction_mode="CHAT",
        active_app_id="chat",
        current_task_id="conversation",
        task_session=None,
        repeater_speed_queue=[],
        repeater_playlist=[],
        shadowing_awaiting_user=False,
        prompt="",
        config={},
        device_id="mac",
        tts=None,
    )
    asyncio.run(
        handle_skill_event(
            conn, "[skill_event]audio.playlist_completed|english_learning|shadowing"
        )
    )
    assert switched == [("chat", "conversation", False)]
    assert spoken == ["跟读结束。我们来聊天吧。"]
def test_handle_shadowing_turn_does_not_enter_chat(tmp_path, monkeypatch):
    from core.app_runtime.runtime import handle_shadowing_turn
    conn = _shadowing_conn(tmp_path, monkeypatch)
    conn.last_asr_audio_snapshot = [b"\x00\x00" * 80]
    asyncio.run(handle_shadowing_turn(conn, "Hello, how are you today?"))
    assert conn.interaction_mode == "SHADOWING"
    assert conn.shadowing_index == 0
    turn = conn.task_session.find_turn(0)
    assert turn["asr_text"] == "Hello, how are you today?"
def test_listen_window_saves_wav_even_if_user_silent(tmp_path, monkeypatch):
    from pathlib import Path
    from core.app_runtime.runtime import _on_shadowing_listen_window_closed
    conn = _shadowing_conn(tmp_path, monkeypatch)
    conn.shadowing_listen_pcm = []
    conn.asr_audio = []
    asyncio.run(_on_shadowing_listen_window_closed(conn))
    turn = conn.task_session.find_turn(0)
    assert turn is not None
    assert turn.get("audio_id")
    assert "mac/shadowing/" in turn["audio_id"]
    wav_path = tmp_path / "data" / "recordings" / Path(turn["audio_id"])
    assert wav_path.is_file()
    assert wav_path.stat().st_size > 44
def test_fifteen_listen_windows_write_fifteen_wavs(tmp_path, monkeypatch):
    from pathlib import Path
    from core.app_runtime.runtime import save_shadowing_listen_audio
    conn = _shadowing_conn(tmp_path, monkeypatch)
    conn.shadowing_clips = CLIPS[:1] * 15
    saved = []
    for i in range(15):
        conn.shadowing_assess_index = i
        conn.shadowing_index = i
        conn.shadowing_audio_saved_index = -1
        conn.shadowing_listen_pcm = [b"\x00\x00" * (80 + i)]
        save_shadowing_listen_audio(conn, i)
        turn = conn.task_session.find_turn(i)
        assert turn.get("audio_id")
        saved.append(turn["audio_id"])
        assert (tmp_path / "data" / "recordings" / Path(turn["audio_id"])).is_file()
    assert len(saved) == 15
    assert len(set(saved)) == 15
def test_shadowing_save_does_not_merge_previous_asr_audio(tmp_path, monkeypatch):
    from pathlib import Path
    import wave
    from core.app_runtime.runtime import save_shadowing_listen_audio
    conn = _shadowing_conn(tmp_path, monkeypatch)
    leftover = b"\x11\x00" * 8000
    current = b"\x22\x00" * 160
    conn.asr_audio = [leftover]
    conn.shadowing_listen_pcm = [current]
    conn.shadowing_assess_index = 0
    conn.shadowing_audio_saved_index = -1
    save_shadowing_listen_audio(conn, 0)
    turn = conn.task_session.find_turn(0)
    wav_path = tmp_path / "data" / "recordings" / Path(turn["audio_id"])
    with wave.open(str(wav_path), "rb") as handle:
        pcm = handle.readframes(handle.getnframes())
    assert leftover not in pcm
    assert current in pcm
    assert len(pcm) == len(current)
def test_one_step_writes_only_one_wav(tmp_path, monkeypatch):
    from core.app_runtime.runtime import save_shadowing_listen_audio
    conn = _shadowing_conn(tmp_path, monkeypatch)
    conn.shadowing_listen_pcm = [b"\x00\x00" * 80]
    save_shadowing_listen_audio(conn, 0)
    first = conn.task_session.find_turn(0)["audio_id"]
    conn.shadowing_listen_pcm = [b"\x00\x00" * 800]
    conn.asr_audio = [b"\x00\x00" * 8000]
    save_shadowing_listen_audio(conn, 0)
    assert conn.task_session.find_turn(0)["audio_id"] == first
    recordings = list((tmp_path / "data" / "recordings").rglob("*.wav"))
    assert len(recordings) == 1
def test_listen_stop_drains_queued_pcm_instead_of_silent_placeholder(tmp_path, monkeypatch):
    import queue as queue_mod
    import wave
    from pathlib import Path
    from core.app_runtime.runtime import close_shadowing_listen_window
    conn = _shadowing_conn(tmp_path, monkeypatch)
    conn.shadowing_awaiting_user = True
    conn.shadowing_index = 0
    conn.shadowing_listen_pcm = []
    conn.asr_audio_queue = queue_mod.Queue()
    real = b"\x22\x00" * 1600
    conn.asr_audio_queue.put(real)
    conn.asr_audio_queue.put(b"\x33\x00" * 1600)
    asyncio.run(close_shadowing_listen_window(conn))
    turn = conn.task_session.find_turn(0)
    wav_path = tmp_path / "data" / "recordings" / Path(turn["audio_id"])
    with wave.open(str(wav_path), "rb") as handle:
        pcm = handle.readframes(handle.getnframes())
    assert real in pcm
    assert len(pcm) == 1600 * 2 * 2
    assert conn.asr_audio_queue.empty()
def test_listen_stop_advances_index_for_next_wav(tmp_path, monkeypatch):
    from pathlib import Path
    from core.app_runtime.runtime import close_shadowing_listen_window, save_shadowing_listen_audio
    conn = _shadowing_conn(tmp_path, monkeypatch)
    conn.shadowing_clips = CLIPS[:1] * 15
    conn.shadowing_awaiting_user = True
    conn.shadowing_index = 0
    conn.shadowing_listen_pcm = [b"\x00\x00" * 80]
    asyncio.run(close_shadowing_listen_window(conn))
    assert conn.shadowing_index == 1
    assert conn.shadowing_awaiting_user is False
    first = conn.task_session.find_turn(0)["audio_id"]
    conn.shadowing_listen_pcm = [b"\x11\x00" * 80]
    conn.shadowing_audio_saved_index = -1
    save_shadowing_listen_audio(conn, 1)
    second = conn.task_session.find_turn(1)["audio_id"]
    assert first != second
    recordings = list((tmp_path / "data" / "recordings").rglob("*.wav"))
    assert len(recordings) == 2
xiaozhi-esp32-server/main/xiaozhi-server/tests/test_audio_archive.py
New file
@@ -0,0 +1,59 @@
"""Local audio archive path: device / scene / date."""
from pathlib import Path
from types import SimpleNamespace
from core.audio_archive import archive_pcm_snapshot, archive_wav
from core.app_runtime.assessment import pcm_snapshot_to_wav, save_turn_wav
def test_archive_path_uses_device_mode_and_date(tmp_path, monkeypatch):
    monkeypatch.setattr("core.audio_archive.get_project_dir", lambda: str(tmp_path))
    conn = SimpleNamespace(
        device_id="AA:BB:CC:DD:EE:FF",
        interaction_mode="SHADOWING",
        config={"audio_archive": {"enabled": True, "dir": "data/recordings"}},
    )
    wav = pcm_snapshot_to_wav([b"\x00\x00" * 80])
    relative = archive_wav(conn, wav, kind="asr", extra="sess_0")
    assert relative is not None
    assert relative.startswith("AA-BB-CC-DD-EE-FF/shadowing/")
    assert "_asr_" in relative
    assert (tmp_path / "data" / "recordings" / Path(relative)).is_file()
def test_archive_disabled(tmp_path, monkeypatch):
    monkeypatch.setattr("core.audio_archive.get_project_dir", lambda: str(tmp_path))
    conn = SimpleNamespace(
        device_id="dev1",
        interaction_mode="CHAT",
        config={"audio_archive": {"enabled": False}},
    )
    wav = pcm_snapshot_to_wav([b"\x00\x00" * 80])
    assert archive_wav(conn, wav) is None
def test_save_turn_wav_uses_archive_layout(tmp_path, monkeypatch):
    monkeypatch.setattr("core.audio_archive.get_project_dir", lambda: str(tmp_path))
    conn = SimpleNamespace(
        device_id="mac1",
        interaction_mode="SHADOWING",
        config={"audio_archive": {"enabled": True, "dir": "data/recordings"}},
    )
    wav = pcm_snapshot_to_wav([b"\x00\x00" * 160])
    audio_id = save_turn_wav("sess", 3, wav, conn=conn)
    assert audio_id is not None
    assert "mac1/shadowing/" in audio_id
    assert audio_id.endswith(".wav")
def test_chat_pcm_archive_uses_chat_dir(tmp_path, monkeypatch):
    monkeypatch.setattr("core.audio_archive.get_project_dir", lambda: str(tmp_path))
    conn = SimpleNamespace(
        device_id="dev2",
        interaction_mode="CHAT",
        config={"audio_archive": {"enabled": True, "dir": "data/recordings"}},
    )
    relative = archive_pcm_snapshot(conn, [b"\x00\x00" * 80])
    assert relative is not None
    assert relative.startswith("dev2/chat/")
xiaozhi-esp32-server/main/xiaozhi-server/tests/test_playback.py
New file
@@ -0,0 +1,384 @@
"""Playback speed clamp and repeater speed gradients."""
from core.app_runtime.playback import (
    PLAY_SPEED_DEFAULT,
    clamp_play_speed,
    daily_base_speed,
    lesson_day_index,
    mark_lesson_played_today,
    parse_playlist,
    speeds_for_repeats,
)
from core.app_runtime.runtime import (
    _apply_playback_speed,
    _begin_repeater_playlist,
    _begin_shadowing_playlist,
    _continue_repeater_if_needed,
)
from types import SimpleNamespace
import asyncio
def test_clamp_play_speed_range():
    assert clamp_play_speed(None) == PLAY_SPEED_DEFAULT
    assert clamp_play_speed("nope") == PLAY_SPEED_DEFAULT
    assert clamp_play_speed(0.3) == 0.5
    assert clamp_play_speed(3.0) == 2.0
    assert clamp_play_speed(0.8) == 0.8
    assert clamp_play_speed(1.05) == 1.05
    assert clamp_play_speed(1.0) == 1.0
def test_daily_speeds_use_practice_day():
    task = {"playback": {"daily_speeds": [0.8, 0.9, 1.0, 1.05, 1.1, 1.15, 1.2]}}
    assert daily_base_speed(task, 0) == 0.8
    assert daily_base_speed(task, 3) == 1.05
    assert daily_base_speed(task, 99) == 1.2
def test_repeat_speeds_five_passes():
    task = {
        "playback": {
            "daily_speeds": [0.8, 0.9, 1.0],
            "repeat_speeds": [0.8, 0.9, 1.0, 1.1, 1.2],
        }
    }
    assert speeds_for_repeats(task, 1, day_index=0) == [0.8]
    assert speeds_for_repeats(task, 5, day_index=0) == [0.8, 0.9, 1.0, 1.1, 1.2]
def test_repeat_one_does_not_use_full_curve():
    task = {"playback": {"speed": 1.0, "repeat_speeds": [0.8, 0.9, 1.0, 1.1, 1.2]}}
    assert speeds_for_repeats(task, 1) == [1.0]
def test_lesson_day_index_counts_distinct_days(tmp_path, monkeypatch):
    monkeypatch.setattr("core.app_runtime.playback.get_project_dir", lambda: str(tmp_path))
    assert lesson_day_index("mac1", "english_learning", "repeater", "unit01.mp3") == 0
    mark_lesson_played_today("mac1", "english_learning", "repeater", "unit01.mp3")
    assert lesson_day_index("mac1", "english_learning", "repeater", "unit01.mp3") == 1
    mark_lesson_played_today("mac1", "english_learning", "repeater", "unit01.mp3")
    assert lesson_day_index("mac1", "english_learning", "repeater", "unit01.mp3") == 1
def test_apply_playback_speed_queues_gradient():
    task = {"playback": {"repeat_speeds": [0.8, 0.9, 1.0, 1.1, 1.2]}}
    conn = SimpleNamespace(
        device_id="mac",
        active_app_id="english_learning",
        current_task_id="repeater",
        repeater_speed_queue=[],
        repeater_play_name="",
        repeater_power_off_after=False,
    )
    play = _apply_playback_speed(
        conn, task, {"name": "/english_learning/repeater/lessons/unit01/full.mp3", "repeat": 5}
    )
    assert play["speed"] == 0.8
    assert play["repeat"] == 1
    assert play["power_off_after"] is False
    assert conn.repeater_speed_queue == [0.9, 1.0, 1.1, 1.2]
def test_explicit_speed_skips_gradient():
    task = {"playback": {"repeat_speeds": [0.8, 0.9, 1.0, 1.1, 1.2]}}
    conn = SimpleNamespace(
        device_id="mac",
        active_app_id="english_learning",
        current_task_id="repeater",
        repeater_speed_queue=[],
        repeater_play_name="",
        repeater_power_off_after=False,
    )
    play = _apply_playback_speed(conn, task, {"name": "a.mp3", "repeat": 5, "speed": 1.05})
    assert play["speed"] == 1.05
    assert play["repeat"] == 5
    assert conn.repeater_speed_queue == []
def test_continue_repeater_plays_next_speed(monkeypatch):
    played = []
    async def fake_play(conn, params):
        played.append(params["speed"])
        return None
    monkeypatch.setattr("core.app_runtime.runtime._execute_named_tool_play", fake_play)
    conn = SimpleNamespace(
        repeater_speed_queue=[0.9, 1.0],
        repeater_play_name="a.mp3",
        repeater_power_off_after=False,
        interaction_mode="PLAY_AUDIO",
    )
    assert asyncio.run(_continue_repeater_if_needed(conn)) is True
    assert played == [0.9]
    assert conn.repeater_speed_queue == [1.0]
    assert asyncio.run(_continue_repeater_if_needed(conn)) is True
    assert played == [0.9, 1.0]
    assert conn.repeater_speed_queue == []
    assert asyncio.run(_continue_repeater_if_needed(conn)) is False
def test_parse_playlist_from_items_and_urls():
    items = parse_playlist(
        {
            "items": [
                {
                    "url": "http://cdn.example/static/english_learning/repeater/lessons/a.mp3",
                    "repeat": 3,
                },
                {
                    "path": "/english_learning/repeater/lessons/b.mp3",
                    "count": 2,
                    "speed": 0.9,
                },
            ]
        }
    )
    assert items[0]["name"] == "/english_learning/repeater/lessons/a.mp3"
    assert items[0]["repeat"] == 3
    assert items[1]["name"] == "/english_learning/repeater/lessons/b.mp3"
    assert items[1]["repeat"] == 2
    assert items[1]["speed"] == 0.9
def test_begin_repeater_queues_remaining_items():
    conn = SimpleNamespace(
        device_id="mac",
        active_app_id="english_learning",
        current_task_id="repeater",
        repeater_speed_queue=[],
        repeater_play_name="",
        repeater_power_off_after=False,
        repeater_playlist=[],
    )
    task = {"playback": {"speed": 1.0}}
    first = _begin_repeater_playlist(
        conn,
        task,
        {
            "items": [
                {"name": "/english_learning/repeater/lessons/a.mp3", "repeat": 2, "speed": 1.0},
                {"name": "/english_learning/repeater/lessons/b.mp3", "repeat": 1, "speed": 0.9},
            ]
        },
    )
    assert first["name"].endswith("/a.mp3")
    assert first["repeat"] == 2
    assert first.get("finish_task") is False
    assert len(first["items"]) == 2
    assert first["items"][1]["name"].endswith("/b.mp3")
    assert first["items"][1]["repeat"] == 1
    assert conn.repeater_playlist == []
def test_continue_repeater_plays_next_playlist_file(monkeypatch):
    played = []
    async def fake_play(conn, params):
        played.append((params.get("name"), params.get("repeat"), params.get("speed"), params.get("finish_task")))
        return None
    monkeypatch.setattr("core.app_runtime.runtime._execute_named_tool_play", fake_play)
    conn = SimpleNamespace(
        device_id="mac",
        active_app_id="english_learning",
        current_task_id="repeater",
        repeater_speed_queue=[],
        repeater_play_name="/english_learning/repeater/lessons/a.mp3",
        repeater_power_off_after=False,
        repeater_playlist=[{"name": "/english_learning/repeater/lessons/b.mp3", "repeat": 2, "speed": 0.9}],
        interaction_mode="PLAY_AUDIO",
    )
    assert asyncio.run(_continue_repeater_if_needed(conn)) is True
    assert played == [("/english_learning/repeater/lessons/b.mp3", 2, 0.9, False)]
    assert conn.repeater_playlist == []
    assert asyncio.run(_continue_repeater_if_needed(conn)) is False
def test_begin_repeater_uses_temp_unit01_playlist():
    from core.app_runtime.playback import TEMP_REPEATER_PLAYLIST
    conn = SimpleNamespace(
        device_id="mac",
        active_app_id="english_learning",
        current_task_id="repeater",
        repeater_speed_queue=[],
        repeater_play_name="",
        repeater_power_off_after=False,
        repeater_playlist=[],
    )
    first = _begin_repeater_playlist(conn, {"playback": {"speed": 1.0}}, {})
    assert first["name"] == TEMP_REPEATER_PLAYLIST[0]["name"]
    assert first["repeat"] == 10
    assert first.get("finish_task") is False
    assert len(first["items"]) == 2
    assert first["items"][0]["repeat"] == 10
    assert first["items"][1]["name"] == TEMP_REPEATER_PLAYLIST[1]["name"]
    assert first["items"][1]["repeat"] == 5
    assert conn.repeater_playlist == []
def test_begin_shadowing_playlist_one_shot():
    from core.app_runtime.playback import TEMP_SHADOWING_PLAYLIST
    conn = SimpleNamespace(
        device_id="mac",
        active_app_id="english_learning",
        current_task_id="shadowing",
        config={},
        shadowing_clips=[],
        shadowing_index=0,
        shadowing_prompt="",
        shadowing_awaiting_user=True,
    )
    play = _begin_shadowing_playlist(conn, {})
    assert play["name"] == TEMP_SHADOWING_PLAYLIST[0]["name"]
    assert play["repeat"] == 5
    assert play.get("finish_task") is False
    assert play.get("listen_ratio") == 1.2
    assert len(play["items"]) == 2
    assert play["items"][0]["repeat"] == 5
    assert play["items"][1]["name"] == TEMP_SHADOWING_PLAYLIST[1]["name"]
    assert play["items"][1]["repeat"] == 10
    assert conn.shadowing_clips.count(TEMP_SHADOWING_PLAYLIST[0]["name"]) == 5
    assert conn.shadowing_clips.count(TEMP_SHADOWING_PLAYLIST[1]["name"]) == 10
    assert len(conn.shadowing_clips) == 15
    assert conn.shadowing_awaiting_user is False
def test_expand_shadowing_playlist_one_clip_per_listen_window():
    from core.app_runtime.playback import (
        TEMP_SHADOWING_PLAYLIST,
        default_shadowing_playlist,
        expand_playlist_plays,
    )
    clips = expand_playlist_plays(default_shadowing_playlist())
    assert clips.count(TEMP_SHADOWING_PLAYLIST[0]["name"]) == 5
    assert clips.count(TEMP_SHADOWING_PLAYLIST[1]["name"]) == 10
    assert len(clips) == 15
def test_audio_completion_timeout_scales_with_queue():
    from core.utils.audioRateController import audio_completion_timeout_sec
    assert audio_completion_timeout_sec(0) == 8.0
    assert audio_completion_timeout_sec(82) == 8.0 + 82 * 0.06
    assert audio_completion_timeout_sec(5000) == 180.0
def test_execute_named_tool_play_sends_active_task(monkeypatch):
    from core.app_runtime.runtime import _execute_named_tool_play
    captured = {}
    async def fake_tool(conn, tool_name, arguments):
        captured["tool"] = tool_name
        captured["arguments"] = arguments
        return None
    monkeypatch.setattr("core.app_runtime.runtime._execute_named_tool", fake_tool)
    conn = SimpleNamespace(
        active_app_id="english_learning",
        current_task_id="repeater",
    )
    asyncio.run(
        _execute_named_tool_play(
            conn,
            {
                "name": "/english_learning/shadowing/lessons/unit01/02.mp3",
                "repeat": 5,
                "speed": 1.0,
                "finish_task": True,
                "items": [
                    {
                        "name": "/english_learning/shadowing/lessons/unit01/01.mp3",
                        "repeat": 10,
                        "speed": 1.0,
                    },
                    {
                        "name": "/english_learning/shadowing/lessons/unit01/02.mp3",
                        "repeat": 5,
                        "speed": 1.0,
                    },
                ],
            },
        )
    )
    assert captured["arguments"]["task_id"] == "repeater"
    assert captured["arguments"]["app_id"] == "english_learning"
    assert captured["arguments"]["finish_task"] is False
    assert captured["arguments"]["force"] is True
    assert "/01.mp3" in captured["arguments"]["items"]
    assert "/02.mp3" in captured["arguments"]["items"]
def test_execute_named_tool_play_retries_duplicate_ignored(monkeypatch):
    from core.app_runtime.runtime import _execute_named_tool_play
    from plugins_func.register import Action, ActionResponse
    calls = []
    async def fake_tool(conn, tool_name, arguments):
        calls.append(dict(arguments))
        if len(calls) == 1:
            return ActionResponse(
                action=Action.RESPONSE,
                response='{"accepted":true,"duplicate_ignored":true}',
            )
        return ActionResponse(
            action=Action.RESPONSE,
            response='{"accepted":true,"repeat":1,"power_off_after":false}',
        )
    monkeypatch.setattr("core.app_runtime.runtime._execute_named_tool", fake_tool)
    conn = SimpleNamespace(active_app_id="english_learning", current_task_id="shadowing")
    notice = asyncio.run(
        _execute_named_tool_play(
            conn,
            {
                "name": "/english_learning/shadowing/lessons/unit01/01.mp3",
                "repeat": 1,
                "speed": 1.0,
                "finish_task": False,
            },
        )
    )
    assert notice is None
    assert len(calls) == 2
    assert calls[0]["task_id"] == "shadowing"
    assert calls[0]["finish_task"] is False
    assert calls[1]["speed"] == 1.02
    assert calls[1]["force"] is True
def test_play_shadowing_clip_keeps_task_open(monkeypatch):
    from core.app_runtime.runtime import _play_shadowing_clip
    captured = {}
    async def fake_play(conn, params):
        captured["params"] = params
        return None
    monkeypatch.setattr("core.app_runtime.runtime._execute_named_tool_play", fake_play)
    conn = SimpleNamespace(
        interaction_mode="SHADOWING",
        shadowing_clip_gen=0,
        active_app_id="english_learning",
        current_task_id="shadowing",
        config={},
    )
    ok = asyncio.run(_play_shadowing_clip(conn, "/english_learning/shadowing/lessons/unit01/01.mp3"))
    assert ok is True
    assert captured["params"]["finish_task"] is False
    assert captured["params"]["repeat"] == 1
def test_lesson_end_speech_includes_chat_greeting():
    from core.app_runtime.runtime import _lesson_end_speech
    conn = SimpleNamespace(config={})
    assert _lesson_end_speech(conn, "复读结束") == "复读结束。我们来聊天吧。"
    assert _lesson_end_speech(conn, "跟读结束") == "跟读结束。我们来聊天吧。"