# 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、本地录音上传。