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++。
| 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。
[app_task]chat 猜测。task_session_id
turns[]: index, prompt_text, asr_text, echo_rejected, recorded_at
events[]: audio.completed | audio.stopped | ...
Spec §20 冻结缺口前三条:完成事件、非 Chat 句边界、跟读归档。不补这三项,不能宣称 Device Capability 已冻结,Phase 3 建表也没有内容。
能力:
audio.completed(运输见 §4),回 CHAT 只招呼一次。audio.stopped,**不**主动招呼。WakeWordInvoke(Phase 1 纪律)。chat();讲笑话不会当闲聊回答。data/task_sessions/*.json 轮次与实际一致;**最后一轮 asr 必须写入后再回 chat**。report_asr_enable 且 chat_history_conf != 0 时,有效跟读入队 report;回声拒绝与 PLAY_AUDIO 忽略上行 不得 当作用户话语上报。冻结纪律(Spec §22):
type、不新增 MQTT action 种类、不新增 MCP 工具种类(别名映射除外,本阶段也不改 MCP 名)。apps.json 下发到 SD。hello/listen/tts/stt 字段含义,不改 Opus 帧。task_instance 表、mode.set、type=task / type=commandif correctwait Skill、TASK_PAUSE、indicate 新逻辑态、按钮长按退出 Taskrecord_clip 本地文件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),禁止做成设备内句子列表。
路径: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 | cancelledend_reason: finished | user_exit | preempted | disconnected | play_completed | play_stopped
事件 name 用 Spec 逻辑名:audio.completed / audio.stopped(不要再用含糊的 play.xxx 当档案字段;运输字符串见 §4)。
断线:ConnectionHandler 清理路径 flush,cancelled + disconnected,已有 turns 不得丢。
禁止新增 Xiaozhi type。本阶段运输 = listen state=detect 文本前缀(Phase 1 已用)。
改运输以后(type=event)不算引进新能力;本阶段不要做那一步。
[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) 分支。self.mp3.play。[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 可当回聊处理。**新固件不要两种都发。**
[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。
仓库: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。
仓库: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)。
今日顺序:
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 标准音。
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 单测 + 手工表。
Chat:
跟读(冻结验证件):
复读(D1):
repeat N 次行为与 Phase 1 相同(D15 未破坏)翻译:
不要本阶段验收倒背/口算/接龙。
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。
if (app == english) 业务树;事件里的 id 来自 pending 查找表。DeviceState。repeat 语义、不得下发 apps.json、不得写 Redis/MySQL Session。manage_report)。| 风险 | 缓解 |
|---|---|
| 回声当跟读成功 | 保持 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 |
复读(验证 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 招呼
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
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、本地录音上传。