编辑 | blame | 历史 | 原始文档

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 名,**不增加命令种类**。
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):

  1. 不新增 WS type、不新增 MQTT action 种类、不新增 MCP 工具种类(别名映射除外,本阶段也不改 MCP 名)。
  2. 不对错、出题、翻译算法、接龙词库进固件。
  3. 不把 apps.json 下发到 SD。
  4. 不改 hello/listen/tts/stt 字段含义,不改 Opus 帧。
  5. 无 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. 架构(本阶段)

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

{
  "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 导致两次招呼。

推荐:

自然结束:只发 [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 保持不变(不增加命令种类)

[app_task]app|task|source     态同步,execute_skills=false
[voice_entry]                 菜单
hello / listen start|stop / tts / mcp / START_APP_TASK / SET_ALARM

listenMessageHandler 解析顺序:

[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

今日:

pending → chat
was_repeater && !stopped → notify_server(chat, true)

改为:

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)

今日顺序:

exit? → speaking? drop → echo? drop → send_stt → index+1 → 无句则回 chat

目标:

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. 实施顺序

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

复读(验证 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

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 代码)

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