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

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)

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 架构变化

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 实际通信流程

复读
  口令 / 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>/。代码采用:

/{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

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

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 事实)

/{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

小程序/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)。