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 为准 |
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。
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 路径) |
无 App Runtime 相关改动。 无新表、无新 REST。跟读音频仍走现有 POST /agent/chat-history/report。
| 模块 | 位置 | 职责 |
|---|---|---|
| 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。
| 类型 | 名称 | 说明 |
|---|---|---|
| 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 完成事件,不是新命令种类。
无新表。 Session 在本地 JSON。Chat WAV 仍在 ai_agent_chat_audio。WS 断线会 flush JSON(disconnected),不落 manager-api。
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 枚举。跟读句子表 不在 固件。
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 表。
复读
口令 / 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
| 能力 | 状态 |
|---|---|
统一入口 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。
| 项 | 现状 |
|---|---|
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)。 语音表仍靠人工烧录勾选。
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 的资源形态。
phase-2-plan.md)speak 仍可用于无音频时的提示,不再当范读主路径。duration_ms。 Server 用它做听窗上限(1.2 ×,最少 800ms)。VAD 说完可提前结束。这是 D7 v1 的增强,不是设备 wait Skill(D11 仍未做)。/{app}/{task}/lessons。运输变了,语义是已有 mkdir + Spec §9 资源树,不是新传感器。START_APP_TASK 补进 MQTT ctl 白名单。 实施中发现它原先不在 BoardMqtt_ActionIsCtl,ctl 主题会拒收。属于 Phase 1 漏网,本阶段修好。self.mp3.play 的 Task(复读)才在播放结束时把 pending 写回 chat。符合 D16:步骤循环在 Server。这些修改应视为 Phase 2 的真实架构(Current Architecture V3)。后续不得假装「跟读仍是 TTS Demo」或「SD 根目录随便堆文件」。
wait 一等 Device Skill(D11):用 Server 听窗代替audio_id 写进 Session turnuser.speech.stopped 事件(继续用 Server VAD)type=task / mode.set(按 Spec 本就不该在 Phase 2)duration_ms。tools/list 处理函数里同步 call_mcp_tool 会和 WS 收包死锁 → 建目录必须 create_task 后台跑。| 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 不是新能力种类。
V1.1 把 Phase 2 写成 type=task + Java 表 + 评分。Spec 已把它拆开。本阶段 没有 按 V1.1 做表和评分,这是正确的。
V1.1 Client Phase 3 才做「App 资源目录」——实际在 Phase 2 因订阅不烧录提前做了布局。Phase 6 应缩小为「清单/DOWNLOAD/resource_id」,不要重做 mkdir。
| 抽象 | 当前是否存在 | 实现位置 | 完成程度 | 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) |
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 不靠此表运行
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
/{app_id}/{task_id}/lessons/{lesson_id}/file.mp3
app_id / task_id:[A-Za-z0-9_-],长度 < 32name:优先 /{app}/{task}/lessons/,再 NVS last_rep,再全卡第一首小程序/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 唯一可查询归档
无。pytest 与 Session schema 已足够建表。
进入 Phase 3 的前提是:不要在建表同时做评分 / 新 Device 能力。
| 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 不改这些接口 |
| 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 字符串 |
功能 Done(带条件):
[app_task]chat」变成逻辑事件工程未 Done:
phase-2-voice-test.mdaudio_id 未挂到 turn因此: 可以宣称 Spec §20 前三条(完成事件、非 Chat 句边界、跟读归档 v1)**在 Runtime 层已钉住**。
不可以宣称 Device Capability 已 100% 实现(缺 wait / pause Task / 统一 resource_id)。
不可以开启 Assessment、倒背/口算/接龙、type=task。
下一阶段只做一件事:把已经稳定的 TaskSession 持久化成可查询的 TaskInstance(见 phase-3-plan.md)。