* 避免复杂操作,核心功能(唤醒、开关机)仅需1种按键动作,老人、儿童可快速上手
-* 误触防护:短按Power键无操作、双击AI键仅在待机/播报后有效,减少误操作带来的困扰
\ No newline at end of file
+* 误触防护:待机短按 Power 无多余操作;单/长对话模式切换仅 Power 键双击,AI 键无双击业务
\ No newline at end of file
- 按键:Power键(开关键)、AI键(语音交互键)
- 指示灯:红(R)、蓝(B)、绿(G)三色(遵循定义:绿=正常/电量/充满电;蓝=AI交互;红=低电/异常)
- 提示音:采用短促、清晰的单音/双音,区分不同操作,音量适中(避免刺耳,适配贴身佩戴),支持后续可调节(可选)
-- 操作逻辑:长按=特殊功能(开关机、强制重启、连续对话);短按/双击=常用功能(唤醒、打断、复读),杜绝误触
+- 操作逻辑:长按=特殊功能(开关机、强制重启、连续对话);短按=常用功能(唤醒、打断);**模式切换仅 Power 键双击**,杜绝误触
### LED 灯效与时间术语(与工程 `ai_VC.AI_led` 对齐)
| **慢交替/交替慢闪** | 约 **1 s 换一色** | `1000` |
-**工程映射(默认)**:聆听蓝 **慢闪**、低电红 **慢闪**、待机绿 **慢闪** → `1000`;思考蓝绿 **交替** → `500`;弱网蓝绿 **慢交替** → `1000`。正文若写「快闪 / 2 次/秒」等,表示周期短于「闪」,工程可按场景单独设 `period_ms`(例如 `**250`** 约 0.5 s 一亮灭,便于对齐「2 次/秒」计数),与上表不冲突。
+**工程映射(默认)**:聆听蓝 **慢闪**、低电红 **慢闪**、正常待机 **灭灯**(`LED_IDLE_MODE=off`)→ 聆听/低电 `1000`;思考蓝绿 **交替** → `500`;弱网蓝绿 **慢交替** → `1000`;异常红蓝快闪 → `250`(约 2 次/秒)。正文若写「快闪 / 2 次/秒」等,表示周期短于「闪」,工程可按场景单独设 `period_ms`,与上表不冲突。
## 备注
-休眠模式:无灯光,无提示音(最低电量消耗)
-
-聆听模式:蓝灯闪(1 分钟内无交互,自动进入待机模式)
-
-思考模式:蓝绿交替闪
-
-播放模式:绿灯闪
-
-待机模式:绿色慢闪(2 分钟内无交互,自动进入休眠模式)
+| REQ-ID | 说明 |
+| ------ | --- |
+| REQ-NOTE-03 | 休眠模式:无灯光,无提示音(最低电量消耗) |
+| REQ-NOTE-02 | 聆听模式:蓝灯闪(1 分钟内无交互,自动进入待机模式) |
+| REQ-NOTE-02 | 思考模式:蓝绿交替闪 |
+| REQ-NOTE-02 | 播放模式:绿灯闪 |
+| REQ-NOTE-01 | 正常待机(part3):指示灯灭;2 分钟无交互进入休眠(REQ-NOTE-03)。旧工程绿灯慢闪可通过 `LED_IDLE_MODE=green_blink` 恢复 |
进入聆听模式:单声“滴”(短促)
## 第一部分:基础操作(Power键)+ 灯光+提示音联动
-| 操作动作 | 设备当前状态 | 指示灯状态 | 提示音 | 执行结果 |
-| ------------- | ---------------- | ----------------------- | ------------------- | --------------------------------------------- |
-| Power键 长按3秒 | 关机状态 | 绿色灯常亮(持续3秒,同步长按时间),然后熄灭 | 现有设备开机提示音(后续视需求可修改) | 设备开机,进入正常待机状态 |
-| Power键 长按8秒 | 任何状态(死机/卡死/异常) | 无变化 | 无提示音 | 设备关机,切断所有电源 |
-| Power键 短按(单击) | 待机状态 | 无指示灯 | 无提示音 | 进入休眠模式 |
-| Power键 短按(单击) | AI交互中(聆听/处理/TTS) | 绿色慢闪 | 无提示音 | 停止对话,进入待机模式 |
-| Power键 短按(双击) | 待机状态(任意状态) | 无变化 | 播报:单对话模式/长对话模式 | 如果对话时停止对话,设置对话模式,进入待机模式, 非对话模式,设置对话模式直接进入待机模式 |
+| REQ-ID | 操作动作 | 设备当前状态 | 指示灯状态 | 提示音 | 执行结果 |
+| ------ | ------------- | ---------------- | ----------------------- | ------------------- | --------------------------------------------- |
+| REQ-PWR-01 | Power键 长按3秒 | 关机状态 | 绿色灯常亮(持续3秒,同步长按时间),然后熄灭 | 现有设备开机提示音(后续视需求可修改) | 设备开机,进入正常待机状态 |
+| REQ-PWR-02 | Power键 长按8秒 | 任何状态(死机/卡死/异常) | 无变化 | 无提示音 | 设备关机,切断所有电源 |
+| REQ-PWR-03 | Power键 短按(单击) | 待机状态 | 无指示灯 | 无提示音 | 进入休眠模式 |
+| REQ-PWR-04 | Power键 短按(单击) | AI交互中(聆听/处理/TTS) | 绿色慢闪 | 无提示音 | 停止对话,进入待机模式 |
+| REQ-PWR-05 | Power键 短按(双击) | 待机状态(任意状态) | 无变化 | 播报:单对话模式/长对话模式 | 如果对话时停止对话,设置对话模式,进入待机模式, 非对话模式,设置对话模式直接进入待机模式 |
## 第二部分:AI交互操作(AI键)+ 灯光+提示音联动
-| 操作动作 | 对话模式 | 设备当前状态 | 指示灯状态 | 提示音 | 执行结果 |
-| --------------------------------- | ----- | ------------------- | ------------------- | ------------------ | --------------------------------------- |
-| AI键 短按1次 | 长对话模式 | 待机状态 | 立即切换为蓝色慢闪(1次/秒) | 单声“滴”(短促,清脆) | 唤醒AI,进入语音聆听状态,开始收音(用户可说话) |
-| AI键 短按1次 | 长对话模式 | 语音聆听中(蓝色慢闪) | 切换为蓝色慢闪 | 单声“滴”(短促) | 重新进入语音聆听状态,开始收音(用户可说话) |
-| AI键 短按1次 | 长对话模式 | TTS播报中(绿色常亮灯) | 切换为蓝色慢闪 | 单声“滴”(短促) | 进入语音聆听状态,开始收音(用户可说话) |
-| AI键 短按1次 | 长对话模式 | 思考中(蓝绿交替闪) | 切换为蓝色慢闪 | 单声“滴”(短促) | 进入语音聆听状态,开始收音(用户可说话) |
-| AI键 短按1次 | 单对话模式 | TTS播报中(绿色常亮) | 灯灭→idle | 无(或极短停播) | **仅停播**,不开录;续聊须再 **长按**(见附录 E5.3) |
-| AI键 短按1次 | 单对话模式 | 待机/思考/聆听(非TTS播放) | 无变化(短按吞掉) | 无 | 无业务;开录须 **长按**(见附录 E5) |
-| AI键 双击(两次短按,间隔≤1秒)-> 可选(根据可实现性而定) | | 待机状态 & TTS播报结束后1分钟内 | 蓝色灯常亮(与正常TTS播报灯光一致) | “滴、滴”(2声短促,间隔0.5秒) | 复读上一次AI的TTS回复内容,复读结束后回到待机状态(蓝色灯灭) |
-| AI键 双击>>暂时无功能 | | | | | |
-| AI键 长按(按下) | 单对话模式 | 待机状态/思考状态/播报状态 | 蓝色快闪(频率加快,2次/秒) | 开录提示音(start.mp3) | 进入收音状态;TTS中重开见附录 E5.2 |
-| AI键 长按(松开) | 单对话模式 | 聆听中(蓝快闪) | 蓝绿交替闪 | “层”(接入拾音/思考提示) | 结束上行,进入思考;协议 `audio.completed`(附录 E5.6) |
+| REQ-ID | 操作动作 | 对话模式 | 设备当前状态 | 指示灯状态 | 提示音 | 执行结果 |
+| ------ | ------------------------- | ------------ | ---------------- | --------------- | ---------------- | --------------------------------------- |
+| REQ-AI-01 | AI键 短按1次 | 长对话模式 | 待机状态 | 立即切换为蓝色慢闪(1次/秒) | 单声“滴”(短促,清脆) | 唤醒AI,进入语音聆听状态,开始收音(用户可说话) |
+| REQ-AI-02 | AI键 短按1次 | 长对话模式 | 语音聆听中(蓝色慢闪) | 切换为蓝色慢闪 | 单声“滴”(短促) | 重新进入语音聆听状态,开始收音(用户可说话) |
+| REQ-AI-03 | AI键 短按1次 | 长对话模式 | TTS播报中(绿色常亮灯) | 切换为蓝色慢闪 | 单声“滴”(短促) | 进入语音聆听状态,开始收音(用户可说话) |
+| REQ-AI-04 | AI键 短按1次 | 长对话模式 | 思考中(蓝绿交替闪) | 切换为蓝色慢闪 | 单声“滴”(短促) | 进入语音聆听状态,开始收音(用户可说话) |
+| REQ-AI-05 | AI键 短按1次 | 单对话模式 | TTS播报中(绿色常亮) | 灯灭→idle | 无(或极短停播) | **仅停播**,不开录;续聊须再 **长按**(见 [DES-SCN-020.3](AI吊坠(双按键+三色指示灯)详设-场景与协议流程.md)) |
+| REQ-AI-06 | AI键 短按1次 | 单对话模式 | 待机/思考/聆听(非TTS播放) | 无变化(短按吞掉) | 无 | 无业务;开录须 **长按**(见 [DES-SCN-020](AI吊坠(双按键+三色指示灯)详设-场景与协议流程.md)) |
+| REQ-AI-07 | AI键 长按(按下) | 单对话模式 | 待机状态/思考状态/播报状态 | 蓝色快闪(频率加快,2次/秒) | 开录提示音(start.mp3) | 进入收音状态;TTS中重开见 [DES-SCN-020.2](AI吊坠(双按键+三色指示灯)详设-场景与协议流程.md) |
+| REQ-AI-08 | AI键 长按(松开) | 单对话模式 | 聆听中(蓝快闪) | 蓝绿交替闪 | “层”(接入拾音/思考提示) | 结束上行,进入思考;协议 `audio.completed`(见 [DES-SCN-020.6](AI吊坠(双按键+三色指示灯)详设-场景与协议流程.md)) |
## 第三部分:电量/充电状态 + 灯光+提示音联动
-| 设备状态 | 触发条件 | 指示灯状态 | 提示音 | 补充说明 |
-| ----------------- | --------------- | ----------------------- | ------------------- | ------------------------ |
-| 正常待机(电量≥30%) | 开机后无操作、交互结束后 | 指示灯灭(AI交互过程遵循如上第二部分的定义) | 无提示音 | |
-| 低电量提醒(10%≤电量<30%) | 电量降至阈值、每5分钟触发1次 | 红色慢闪(1次/1秒),每次闪3次 | “滴、滴”(2声短促,与灯光同步) | 提醒用户尽快充电,不频繁打扰 |
-| 电量耗尽(电量<10%) | 电量降至关机阈值 | 红色快闪3次(2次/秒)→ 熄灭 | “滴、滴、滴”(3声短促,与灯光同步) | 快闪结束后设备自动关机 |
-| 充电中(电量<100%) | 连接充电器,接触正常 | 红色常亮 | 单声“滴”(短促) | 充电期间,按键操作仍有效(开关机、AI交互) |
-| 充电满(电量=100%) | 充电至满电阈值 | 红色常亮→切换为绿色常亮(延迟1秒) | “滴——”(长音,持续1秒) | 绿色常亮保持,提醒用户可拔下充电器(晚上不打扰) |
+| REQ-ID | 设备状态 | 触发条件 | 指示灯状态 | 提示音 | 补充说明 |
+| ------ | ----------------- | --------------- | ----------------------- | ------------------- | ------------------------ |
+| REQ-BAT-01 | 正常待机(电量≥30%) | 开机后无操作、交互结束后 | 指示灯灭(AI交互过程遵循如上第二部分的定义) | 无提示音 | |
+| REQ-BAT-02 | 低电量提醒(10%≤电量<30%) | 电量降至阈值、每5分钟触发1次 | 红色慢闪(1次/1秒),每次闪3次 | “滴、滴”(2声短促,与灯光同步) | 提醒用户尽快充电,不频繁打扰 |
+| REQ-BAT-03 | 电量耗尽(电量<10%) | 电量降至关机阈值 | 红色快闪3次(2次/秒)→ 熄灭 | “滴、滴、滴”(3声短促,与灯光同步) | 快闪结束后设备自动关机 |
+| REQ-BAT-04 | 充电中(电量<100%) | 连接充电器,接触正常 | 红色常亮 | 单声“滴”(短促) | 充电期间,按键操作仍有效(开关机、AI交互) |
+| REQ-BAT-05 | 充电满(电量=100%) | 充电至满电阈值 | 红色常亮→切换为绿色常亮(延迟1秒) | “滴——”(长音,持续1秒) | 绿色常亮保持,提醒用户可拔下充电器(晚上不打扰) |
## 第四部分:AI交互异常状态 + 灯光+提示音联动
-| 异常场景 | 指示灯状态 | 提示音 | 处理方式 |
-| ---------------- | ------------------------ | ------------------- | -------------------- |
-| 语音识别失败(未听清/无法识别) | 红蓝快闪2次(2次/秒)→ 回到待机状态(灯灭) | “滴、滴”(2声短促,与灯光同步) | 用户可再次短按AI键,重新唤醒聆听 |
-| 网络异常(无法请求AI) | 蓝绿交替慢闪(1次/2秒),持续闪烁 | “网络异常信号弱”(每5秒1次,循环) | 检查网络连接,恢复网络后自动回到待机状态 |
-| AI处理超时 | 蓝色常亮→红蓝快闪2次→回到待机状态(灯灭) | “滴、滴”(2声短促) | 处理失败,用户可重新发起语音请求 |
+| REQ-ID | 异常场景 | 指示灯状态 | 提示音 | 处理方式 |
+| ------ | ---------------- | ------------------------ | ------------------- | -------------------- |
+| REQ-ERR-01 | 语音识别失败(未听清/无法识别) | 红蓝快闪2次(2次/秒)→ 回到待机状态(灯灭) | “滴、滴”(2声短促,与灯光同步) | 用户可再次短按AI键,重新唤醒聆听 |
+| REQ-ERR-02 | 网络异常(无法请求AI) | 蓝绿交替慢闪(1次/2秒),持续闪烁 | “网络异常信号弱”(每5秒1次,循环) | 检查网络连接,恢复网络后自动回到待机状态 |
+| REQ-ERR-03 | AI处理超时 | 蓝色常亮→红蓝快闪2次→回到待机状态(灯灭) | “滴、滴”(2声短促) | 处理失败,用户可重新发起语音请求 |
## 第五部分:补充规范(工程&用户体验)
- 所有操作均有“灯光+提示音”双重反馈,无屏状态下也能清晰判断设备状态
- 避免复杂操作,核心功能(唤醒、开关机)仅需1种按键动作,老人、儿童可快速上手
-- 误触防护:短按Power键无操作、双击AI键仅在待机/播报后有效,减少误操作带来的困扰
-
----
-
-## 附录 A:灯效编程对照表(`ai_VC.AI_led`)
-
-灯由 `EventMesh.publish("set_led_state", { ... })` 聚合驱动;**优先级自上而下**,先命中先显示,后面的输入仍写入内部状态但不生效,直到高优先级条件解除。
-
-
-| 优先级 | 输入字段 | 取值(示例) | 内部 `_mode` | 灯效(R/G/B) | `period_ms`(节拍) | 对应术语 | 说明 |
-| --- | ---------- | --------------------- | ------------- | --------- | ------------------------------------ | ------- | -------------------- |
-| P1 | `ai_state` | `playing` | `green_on` | 绿常亮 | — | — | TTS/本地播报中 |
-| P2 | `ai_state` | `thinking` | `bg_alt` | 蓝↔绿交替 | `500` | **交替** | 约 0.5 s 换一色 |
-| P3 | `ai_state` | `listening` | `blue_blink` | 蓝亮灭 | `1000` | **慢闪** | 约 2 s 一亮灭;整颗 GPIO 亮灭 |
-| B1 | `charging` | `full` | `green_on` | 绿常亮 | — | — | USB 在位且电压达充满阈值 |
-| B2 | `charging` | `charging` | `red_on` | 红常亮 | — | — | USB 在位且未充满 |
-| B3 | `battery` | `low` / `empty` | `red_blink` | 红亮灭 | `1000` | **慢闪** | 非充电时按 SoC/OCV 判定 |
-| B4 | `signal` | `weak` | `bg_alt` | 蓝↔绿交替 | `1000` | **慢交替** | CSQ 低于阈值 |
-| 默认 | (上述均未命中) | `ai_state` 一般为 `idle` | `green_blink` | 绿亮灭 | `LED_IDLE_GREEN_BLINK_MS`(默认 `1000`) | **慢闪** | 待机/空闲 |
-
-
-`**set_led_state` 消息字典键**
-
-
-| 键 | 类型 | 含义 |
-| ---------- | --- | --------------------------------------------- |
-| `ai_state` | str | `idle` / `listening` / `thinking` / `playing` |
-| `charging` | str | `none` / `charging` / `full` |
-| `battery` | str | `normal` / `low` / `empty` |
-| `signal` | str | `normal` / `weak` |
-
-
-**可配置项(`default_config.py` / `vc_config`)**
-
-
-| 配置名 | 作用 |
-| --------------------------------------- | --------------------------------------- |
-| `LED_GPIO` | (R, G, B) 引脚名 |
-| `LED_ACTIVE_LOW` / `LED_ACTIVE_LOW_RGB` | 点亮电平反相 |
-| `LED_IDLE_GREEN_BLINK_MS` | 待机绿灯**慢闪**半周期(ms),默认 `1000`,代码侧最小 `400` |
-
-
-**与正文规范的差异(便于联调对齐)**
-
-
-| 正文描述 | 当前工程实现要点 |
-| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
-| 待机灯灭 / Power 短按待机无灯 | 代码待机为 **绿灯慢闪**(见上表默认行);与正文第三 part「正常待机灯灭」不一致时以代码为准或后续再改表 |
-| 休眠无灯光 | 休眠路径在 `ui.py`(`pm.autosleep` 等);LED 是否全灭取决于休眠前是否再发 `set_led_state` |
-| Power 长按 8s 关机无灯 / 无提示音 | 正文第一 part 为「无变化、无提示音」;若需与关机动画/提示音一致,由电源键模块与产品再定稿 |
-| 备注「蓝灯闪 / 绿灯闪」与附录节拍 | 工程侧聆听为 **慢闪**(`1000`)、播放为 **绿常亮**;与口语「闪」字面不完全相同时,以文首 **术语表** 与附录 A 为准 |
-| 闪 / 慢闪 / 交替 / 慢交替 | 与文首 **「LED 灯效与时间术语」** 及上表一致;`listening` / 低电 / 待机为 **慢闪**(`1000`),`thinking` 为 **交替**(`500`),弱网为 **慢交替**(`1000`);若正文写「1 次/秒」亮灭见术语 **闪**(`500`) |
-
-
----
-
-## 附录 B:对话模式与状态机(`ai_VC.py`)
-
-> **源码对齐**:下列 FSM、`VC_TtsRxPhase`、`[FSM]`/`[KEY]` 日志字段等与 `2.sdk/Helios_SDK/services/microPython/ports/quectel/core/ai_VC.py` 一致。仓库内若另有 `project/*/sdk/.../ai_VC.py` 等副本,可能尚未合并同一套 `VCStateMachine`,**以当前固件实际编译进镜像的文件为准**。
-
-### B1 对话模式 `VC_mode`(存储键 `VC_mode`)
-
-
-| 值 | 常量名(代码) | 含义 | 协议侧要点 |
-| --- | ------------------- | -------- | ------------------------------------------------------- |
-| `0` | `VCMode.Realtime` | 长对话(连续) | VAD 开,一般 **无需** `audio.create` / `audio.completed` 包裹上行 |
-| `1` | `VCMode.PushToTalk` | 单对话(PTT) | VAD 关,**必须** `audio.create` → 帧 → `audio.completed` |
-
-
-### B2 会话层 `VCSessionState`(`AI_VC.fsm.session`)
-
-
-| 值 | 名称 | 含义 |
-| --- | -------------- | -------------------- |
-| `0` | `Disconnected` | 未连接 / 已断开 |
-| `1` | `Connecting` | 连接中 |
-| `2` | `Starting` | 已发 Start,等 `started` |
-| `3` | `Started` | 会话已建立,可上行/收事件 |
-
-
-### B3 轮次层 `VCTurnState`(`AI_VC.fsm.turn`)
-
-
-| 值 | 名称 | 含义(简述) | 当前 `VCStateMachine` 赋值情况 |
-| --- | ----------------- | ------------------------ | ------------------------------------------------------------------------- |
-| `0` | `Idle` | 本轮空闲 | 会写入(会话开始/断开等) |
-| `1` | `Listening` | 聆听 / 等用户说话(长对话侧常见) | 会写入(`started` / `canceled` / `audio.played` 后等) |
-| `2` | `SpeechStreaming` | 语音上行中(流式) | **枚举保留**;上行是否打开见 `fsm.speech_uplink_open`(`asr.started` 置 1) |
-| `3` | `SpeechEnded` | 本轮说话结束(等 ASR/LLM) | 会写入(`vad.silence` 等) |
-| `4` | `Thinking` | LLM 思考中 | 会写入(`llm.created`;`llm.completed` 兜底补位) |
-| `5` | `TtsReceiving` | 正在收 TTS 音频流 | 会写入(`tts.started` / `local.synthesis_sent`) |
-| `6` | `Playing` | 本地正在播 TTS | 会写入(`local.play_start`) |
-| `7` | `AwaitPlayedAck` | 已播完,等 `audio.played` 等门控 | **枚举保留**;当前以 `fsm.await_played` 布尔门控为主,`turn` 多在 `Playing`/`TtsReceiving` |
-
-
-### B3.1 TTS 接收相 `VC_TtsRxPhase`(`AI_VC.fsm.tts_rx_phase`)
-
-与 `tts.started` / `tts.completed` 对齐,用于区分服务端下发阶段(本地可能仍在播)。
-
-
-| 值 | 名称 | 含义 |
-| --- | ----------------- | --------------------------- |
-| `0` | `Idle` | 无 TTS 接收相活动 |
-| `1` | `Started` | 已收到 `tts.started` |
-| `2` | `ServerCompleted` | 已收到 `tts.completed`(本地未必播完) |
-
-
-### B4 旧版 `VC_State_`* 整型(已移除,仅作历史对照)
-
-源码注释:**「原 `VC_State_`* 整型已移除;会话/轮次见 `VCTurnState` / `VCStateMachine`(含 `tts_rx_phase`、`speech_uplink_open`)。」** 下列宏名**不再出现在运行逻辑中**,仅便于对照旧日志或旧文档。
-
-
-| 宏名(历史) | 值 | 含义 |
-| --------------------------------------- | ---- | ------- |
-| `VC_State_None` | 0 | 无 |
-| `VC_State_Listening` | 1 | 听 |
-| `VC_State_SpeechStart` | 2 | 说开始 |
-| `VC_State_SpeechIng` | 3 | 正在说 |
-| `VC_State_SpeechEnd` | 4 | 说结束 |
-| `VC_State_RequestToRespond` | 5 | 请求回复 |
-| `VC_State_Thinking` | 6 | 思考 |
-| `VC_State_Responding` | 7 | 响应/播放相关 |
-| `VC_State_TTSStart` … `VC_State_TTSEnd` | 8–11 | TTS 子阶段 |
-| `VC_State_TTSError` | 12 | TTS 异常 |
-| `VC_State_DialogError` | 13 | 对话异常 |
-| `VC_State_End` | 14 | 结束 |
-
-
-### B5 灯与 FSM 的对应关系(编程时)
-
-
-| `set_led_state` 的 `ai_state` | 典型触发场景(与 FSM/业务相关) |
-| ---------------------------- | -------------------------------------------- |
-| `listening` | 开麦聆听、长对话续聊等 |
-| `thinking` | `llm.created` / 思考阶段灯 |
-| `playing` | `tts.started` 后本地 PCM 播放、`audio_cb` 播放起止等 |
-| `idle` | 交互结束、休眠前、或硬件态仅刷新 `charging/battery/signal` 时 |
-
-
-> **调试**:`EventMesh.publish("get_led_debug_state")` 返回当前聚合 `{ ai, charging, battery, signal, mode, period_ms }`,与串口 `[KEY]` / `[FSM]` 日志对照。
-
-### B6 状态机层级与典型跳转(实现速查)
-
-- **两层模型**:`session`(WebSocket 会话生命周期)与 `turn`(单轮「听→说→想→播」)。`**[FSM]` 单行日志**(`VCStateMachine.log`)形如:`[FSM] <reason> session=Started turn=Listening await_played=0 tts_rx=0 speech_up=0`(其中 `tts_rx` 为 `VC_TtsRxPhase` 数值,`speech_up` 为 `speech_uplink_open`)。
-- `**[KEY]` 行内片段**(`_vc_fsm_log_snip`):`sess=<int> turn=<int> tts_rx=<int> await=<0|1> sp_up=<0|1>`,与上式含义一致。
-- **协议约束**(代码注释摘要):对齐《金三服务-语音对话 V2.5》——收到 `started` 后才允许发音频帧;VAD 开时长对话无需 `audio.create`/`completed`;VAD 关时 PTT 必须 `audio.create`→帧→`audio.completed`;`audio.played` 为进入下一轮的门控之一。
-- **可选严格协议行为**:模块级开关 `VC_PROTO_GUARDS`(默认 `0`)控制 started 前 PCM 缓冲/丢弃、PTT 下 `audio.create` 前禁发 PCM、`llm.completed` 后久无 `tts.started` 的会话兜底等;与产品灯效无直接耦合,联调协议时可关注。
-- **详细边条件**:以 `ai_VC.py` 中 `VCStateMachine` 各 `on_server_`* / `on_local_*` 及事件适配 `on_server_event` 为准;下图为**阅读级**总览(与当前赋值路径对齐:`Listening` 可直接到 `SpeechEnded`),非穷举所有分支。
-
-```mermaid
-stateDiagram-v2
- direction LR
- state "会话 session" as S {
- [*] --> Disconnected
- Disconnected --> Connecting : websocket_connect
- Connecting --> Starting : 发 Start
- Starting --> Started : rx started
- Started --> Disconnected : Close / 断链
- }
- state "轮次 turn(Started 内)" as T {
- [*] --> Idle
- Idle --> Listening : 开麦/续聊
- Listening --> SpeechEnded : vad.silence 等
- SpeechEnded --> Thinking : llm.created / completed 兜底
- Thinking --> TtsReceiving : tts.started
- TtsReceiving --> Playing : local.play_start
- Playing --> Listening : audio.played 后 on_local_audio_played
- Listening --> Idle : cancel / Close
- }
-```
-
-
-
-`**VC_mode` 与 FSM 关系(简述)**
-
-
-| `VC_mode` | 对 `turn` 的影响要点 |
-| --------- | --------------------------------------------------------------------------------------------- |
-| `0` 长对话 | 易在 `Listening` 与上行、`canceled` 自动续听等路径间切换;详见 PTT 与长对话分支 |
-| `1` 单对话 | 依赖 `audio.create` / `audio.completed`;长按 `ptt_begin`:本地停播 →(忙态)`cancel` → `audio.create` 重开上行 |
-
-
----
-
-## 附录 C:充电 / 电量 / 信号(硬件态)与 LED 同步(`AI_hw_status` + `AI_led`)
-
-硬件侧由 `**AI_hw_status._update()`** 周期性(默认 `HW_STATUS_PERIOD_MS`,如 10000 ms)及 `**hw_usb_state` 事件** 刷新,并 **一次下发**:
-
-`EventMesh.publish("set_led_state", {"charging": chg, "battery": bat, "signal": sig})`
-
-仅更新这三个字段;`**ai_state` 仍由上一条 VC/业务逻辑保留**,最终灯效由 `**AI_led._apply_by_table` 优先级**决定:**任意非 `idle` 的 `ai_state`(listening/thinking/playing)都会盖住充电/电量/弱网灯**。
-
-### C1 采样输入 → 内部枚举(工程判定)
-
-
-| USB 在位 (`GET_USB_STATE` / 事件) | 电池电压 `vbatt_mv`(默认算法) | `charging` | `battery`(仅 **USB 不在位** 时按 SoC 算) |
-| ----------------------------- | ------------------------------------------------------- | ---------- | ------------------------------------ |
-| 否 | SoC ≥ 5% 且非低电档 | `none` | `normal` |
-| 否 | SoC < 5% | `none` | `empty` |
-| 否 | 5% ≤ SoC < 20%(工程档,非正文 10%/30%) | `none` | `low` |
-| 是 | < 充满阈值(默认 **≥4290 mV** 视为满,可 `VBATT_CHARGE_FULL_MV` 覆盖) | `charging` | 不按 SoC 刷低电(充电中不走路径 B3) |
-| 是 | ≥ 充满阈值 | `full` | 不参与 SoC 低电判定,`battery` 仍为默认 `normal` |
-
-
-**说明**:USB 在位时 `**battery` 不按电压刷新**(代码仅在 `usb_status != 1` 时根据 SoC 置 `low`/`empty`),灯效以 `**charging` 优先**(见附录 C3)。
-
-**SoC 来源**:默认 **OCV 电压表插值**(`_ocv_map`);若 `USE_SIMPLE_VBATT_THRESH=True`,则按 `VBATT_CHARGE_FULL_MV` / `VBATT_LOW_MV` / `VBATT_EMPTY_MV` 三档近似。
-
-### C2 信号弱网
-
-
-| 条件 | `signal` |
-| ----------------------------------------------------------------------- | -------- |
-| `net.csqQueryPoll()` 得到的 RSSI 存在且 **0 ≤ RSSI < SIGNAL_WEAK_CSQ**(默认 10) | `weak` |
-| 否则 | `normal` |
-
-
-### C3 枚举 → `AI_led` 灯效(且 `**ai_state == idle`** 时生效)
-
-
-| `charging` | `battery` | `signal` | 内部 `_mode` | 灯效 | 节拍与术语 |
-| ---------- | --------------- | -------- | ----------- | --------------- | ----------------------------------- |
-| `full` | * | * | `green_on` | 绿常亮 | — |
-| `charging` | * | * | `red_on` | 红常亮 | — |
-| `none` | `low` / `empty` | * | `red_blink` | 红**慢闪** | `period_ms=1000`(约 2 s 一亮灭) |
-| `none` | `normal` | `weak` | `bg_alt` | 蓝绿**慢交替** | `period_ms=1000`(约 1 s 换一色) |
-| `none` | `normal` | `normal` | — | 附录 A 默认:**绿慢闪** | `LED_IDLE_GREEN_BLINK_MS`,默认 `1000` |
-
-
-### C4 正文「第三部分」与工程灯效对照(含 AI 覆盖)
-
-
-| 正文设备状态 | 正文指示灯 | 工程基础态(`ai_state=idle`) | **AI 交互中**(`listening`/`thinking`/`playing`) |
-| ----------- | ------ | ---------------------------------------------- | -------------------------------------------- |
-| 正常待机(≥30%) | 灯灭 | **绿灯慢闪**(见附录 A 差异说明) | 以 AI 灯为准(蓝慢闪 / 蓝绿交替 / 绿常亮) |
-| 低电量 10%~30% | 红慢闪等 | `battery=low` → `red_blink`(阈值实为 SoC<20%) | AI 优先:例如 **播放中仍为绿常亮**,低电红慢闪不显示 |
-| 电量耗尽 <10% | 红快闪→灭 | `battery=empty` → `red_blink`(SoC<5%);关机另由电源策略 | 同上,AI 可盖住 |
-| 充电中 <100% | 红常亮 | `charging=charging` → `red_on` | AI 优先:如 **聆听蓝慢闪** 盖住充电红 |
-| 充电满 | 红→绿常亮等 | `charging=full` → `green_on` | 与 **TTS `playing` 绿常亮** 同色,语义需靠场景区分 |
-
-
----
-
-## 附录 D:异常状态(正文第四部分)与 LED 同步(工程现状)
-
-当前 `**AI_led*`* 无单独「红蓝快闪」「网络异常专用慢闪」等 `_mode`;异常分支多为 **TTS / `websocket_error` / `Close` + `ai_state: idle`**,灯效回到 **硬件态表(附录 C3)** 或 **待机绿灯慢闪**。
-
-
-| 正文异常场景 | 正文指示灯 | 工程侧典型处理 | 灯效(概括) |
-| ------------- | -------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------- |
-| 语音识别失败(未听清) | 红蓝快闪 2 次 → 待机灭 | `asr.result` 空串时置 `AI_TTS_str="我没听清…"`,走后续 TTS/状态机;**未专设红蓝闪灯** | 播提示 TTS 时多为 `**playing`→绿常亮**;结束变 `**idle`** 后接 **附录 C3**(弱电则红慢闪,否则绿慢闪) |
-| 网络异常(无法请求 AI) | 蓝绿慢交替 + 语音循环 | `websocket_error` 等:`Close`、`set_led_state({"ai_state":"idle"})` | `**idle` + 硬件态**:弱网则 `**bg_alt`(慢交替)**;否则 **绿慢闪**;无「持续网络异常」专用灯型 |
-| AI 处理超时 | 蓝常亮 → 红蓝快闪 → 灭 | 依赖超时/兜底分支,**无独立灯型表项** | 过程中可能 `**thinking`(交替)** 或 `**idle`**;未实现规范中的红蓝快闪序列 |
-
-
-### D1 与附录 A 的编程关系(异常联调)
-
-1. 需要 **「异常专用灯」**:应在业务点增加 `**set_led_state` 新键**(如 `alert`)并在 `**AI_led._apply_by_table`** 中插入优先级;当前未做。
-2. 仅依赖 **现有键**:异常后务必 `**ai_state: idle`** 才会重新露出 **充电/电量/信号** 灯;否则用户会一直看到 **AI 阶段灯**。
-3. `**websocket_error`** 内已 `**set_led_state({"ai_state":"idle"})`**,可与 `**get_led_debug_state`** 联调确认 `charging/battery/signal` 是否已刷新。
-
----
-
-## 附录 E:AI 对话处理逻辑(单对话 vs 长对话)
-
-> **与正文关系**:第二部分表格描述用户可感知的「按键→灯→音→结果」;本附录描述 **协议与本地执行顺序**,供固件(`ai_VC.py`)联调与评审。实现以当前编译进镜像的 `ai_VC.py` 为准。
-> **模式存储**:`VC_mode`(`0`=长对话 / `1`=单对话),Power 键双击可切换并 TTS 播报模式名(见正文第一部分)。
-
-### E0 典型场景速查(产品语言)
-
-**单对话 · 场景 1:TTS 播报中,AI 键重新开始对话(长按)**
-
-
-| 步骤 | 要求 |
-| --- | ------------------------------------------------ |
-| 1 | **本地立即停播**,清空 `AI_audio_queue` 与 codec 缓冲,杜绝残余尾音 |
-| 2 | 发 **停止当前轮** 消息(`cancel`,服务端停止生成;**不换会话**) |
-| 3 | 发 **继续对话** 消息(`audio.create`) |
-| 4 | 打开录音 → 播 **开录提示音**(start.mp3)→ 开始上传 PCM |
-| 5 | 松手发 `audio.completed`,进入思考 → TTS 播放 |
-
-
-**长对话 · 对照**:TTS 播报中 **短按** AI 键续聊 → 本地清队列 + `cancel` → 收 `canceled` 后 **直接开麦**(无需 `audio.create`)。详见 **E4.2**、**E6**。
-
-**场景索引**
-
-
-| 模式 | 场景 | 章节 |
-| --- | ----------- | --------- |
-| 长对话 | TTS 中短按打断续聊 | E4.2 |
-| 长对话 | 聆听中短按重开麦 | E4.3 |
-| 长对话 | 思考中短按打断 | E4.4 |
-| 长对话 | 待机唤醒 | E4.5 |
-| 长对话 | VAD 自动多轮 | E4.7 |
-| 单对话 | TTS 中长按重开 | E5.2 |
-| 单对话 | TTS 中短按仅停播 | E5.3 |
-| 单对话 | 待机/思考长按开录 | E5.4 |
-| 单对话 | 标准一轮 PTT | E5.6 |
-| 单对话 | 播完后再问 | E5.7 |
-| 通用 | 完整状态矩阵 | E4.6、E5.8 |
-| 通用 | 服务端事件对照 | E10 |
-| 可选 | AI 键双击复读 | E9 |
-
-
-### E1 模式总览
-
-
-| 维度 | 长对话 `VC_mode=0`(Realtime) | 单对话 `VC_mode=1`(Push-To-Talk / PTT) |
-| --------- | -------------------------------------------- | ---------------------------------------------------------------------- |
-| 用户操作 | **短按** AI 键唤醒 / 打断 / 续聊 | **长按** 开录、**松开** 结束本轮上行 |
-| VAD | 开(服务端判停) | 关(`disable_vad=true`) |
-| 上行包裹 | 一般 **无需** `audio.create` / `audio.completed` | **必须** `audio.create` → PCM 帧 → `audio.completed` |
-| 打断 TTS 续聊 | 短按:发 `**cancel`** + 本地清队列 → 等 `canceled` 后开麦 | **长按**:本地停播清队列 →(TTS/思考态)发 `**cancel`**(不换 `sid`)→ `audio.create` 开新一轮 |
-| 典型灯效 | 聆听蓝慢闪 / 思考蓝绿交替 / 播放绿常亮 | 长按聆听蓝快闪;松手后思考蓝绿交替 |
-| 进入下一轮门控 | `audio.played`(播完上报)、`canceled` 后自动开麦等 | 松手 `audio.completed` 后等 ASR/LLM/TTS;再次长按走 `ptt_begin` |
-
-
-**设计目标**:用户在任何中间态(TTS 播报、思考、弱网恢复中等)按 AI 键,应 **尽快** 进入「下一轮可说话」或「继续当前会话内的新一轮上行」,且 **无残余 TTS 尾音、无播放队列叠录**。
-
-### E2 协议消息与「停止 / 继续」语义
-
-
-| 产品用语 | WebSocket `event` | 适用模式 | 作用 |
-| ------------- | ------------------------------ | ---------------------- | -------------------------------------------------------- |
-| 停止当前轮次 / 打断播报 | `cancel` | **长对话**(短按打断 TTS、思考中等) | 通知服务端撤销当前响应;端侧随后收 `canceled`,再开麦 |
-| 本地停止播放(非协议) | —(`VC_init` + `VC_Play_End`) | **单对话、长对话** | 立即停止 PCM/流播放,**清空 `AI_audio_queue`**,必要时 `aud.stopAll()` |
-| 继续对话 / 开新一轮上行 | 长对话:开麦上行 PCM(无 `audio.create`) | 长对话 | `request_uplink_start` → `VC_Speech_Start_now` |
-| 继续对话 / 开新一轮上行 | 单对话:`audio.create` | 单对话 | `AI_client_VC_send_speech` → 录音 → 帧上行 |
-| 本轮说话结束 | `audio.completed` | 单对话 | 松手触发 `AI_client_VC_end_speech` |
-| 本地 TTS 播完 | `audio.played` | 长对话为主 | 播完后上报,服务端进入下一轮 ASR(门控之一) |
-
-
-> **「停止对话」与 `cancel` 的分工**:`cancel` 仅撤销**当前轮**服务端响应(LLM/TTS 生成),**不断开 WebSocket、不换 `sid`**;与 Power 键 `Close`(换会话)区分。单对话在 TTS/思考态 **长按重开** 时也会发 `cancel`,避免旧轮下行与新一轮 `audio.create` 竞态;须 **先本地停播清队列**(用户侧立即静音),再发 `cancel`,再发 `audio.create`。
-
-### E3 本地停播与清缓存(凡打断 TTS 必做)
-
-无论哪种模式,只要用户打断 **TTS/本地播报**,须在同一按键处理链内完成(顺序不可颠倒):
-
-
-| 步骤 | 动作 | 说明 |
-| --- | ---------------------------------------------- | ---------------------------------------------- |
-| L1 | `VC_Play_End()` / `VC_init()` | 停播放流、`VC_Play_flag=0`;`AI_audio_queue.clear()` |
-| L2 | `aud.stopPlayStream()` + `aud.stopAll()` | 避免 DMA/codec 缓冲残留 |
-| L3 | `_drop_tts_audio = 1`(可选窗口) | 打断后短时 **丢弃** 服务端仍下发的 TTS 帧,防「断断续续继续播」 |
-| L4 | `fsm.await_played = false`(若曾等 `audio.played`) | 避免门控卡死,无法开麦 |
-| L5 | 灯 `ai_state: idle` 或 `listening` | 打断瞬间可先 `idle`;开录后切 `listening` |
-
-
-TTS 自然播完:队列读空后 `VC_Play_End`;若本轮需门控,再在适当时机发 `audio.played`。
-
-### E4 长对话(`VC_mode=0`)— AI 键处理
-
-#### E4.1 状态 → 动作
-
-
-| 设备状态(用户视角) | FSM 近似 | AI 键 **短按** | 协议 / 本地顺序 |
-| ----------- | ------------------------------- | ------------- | ----------------------------------------------------------------------------- |
-| 待机、未建会话 | `session≠Started` | 唤醒 | `AI_VC_start` → `Start` → 等 `started` → 开麦 → 蓝慢闪 + 开录提示 |
-| 聆听中 | `turn=Listening` | 重新聆听 | `request_uplink_start("key.press/continue")` → 清僵死录音态 → `VC_Speech_Start_now` |
-| 思考中 | `turn=Thinking` | 打断并开麦 | 见 **E4.4**(宜 `cancel`;管线忙时同 E4.2) |
-| **TTS 播报中** | `turn=Playing` / `TtsReceiving` | **打断并续聊** | 见 **E4.2** |
-| 弱网 / 异常 | `signal=weak` 等 | 同待机或 Close 策略 | 以异常附录 D 为准 |
-
-
-#### E4.2 场景:TTS 播报中短按 — 打断并进入下一轮聆听
-
-**目标**:尽快停播、通知服务端停止当前响应、开麦。
-
-```text
-用户短按 AI 键
- → (1) is_ruppt_tts = 1
- → (2) VC_init(立即停播 + 清空 AI_audio_queue)
- → (3) 发 cancel(RequestToSpeak)
- → (4) 等 server.canceled
- → (5) FSM: uplink_start(canceled) → VC_Speech_Start_now
- → (6) 蓝慢闪 + 开录提示音(start.mp3 等)+ 上行 PCM(VAD,无 audio.create)
-```
-
-**灯与音**:发 `cancel` 前后本地已无 TTS;开录时蓝慢闪 + 「滴」类开录提示(与正文第二部分一致)。
-**注意**:`Close_flag` 须在 `RequestToSpeak` 路径清零,否则 `canceled` 后 `_uplink_request_start` 会拒开麦。
-
-#### E4.3 场景:会话已建立、聆听中短按 — 重新开麦
-
-**用户意图**:上一轮 VAD 已判停或仍在聆听,希望 **再说一段**。
-
-```text
-用户短按 AI 键(session=Started,turn=Listening/SpeechEnded,非 TTS 忙)
- → request_uplink_start("key.press/continue")
- → _vc_clear_stale_record_state(若 record_flag 僵死)
- → VC_Speech_Start_now
- → 蓝慢闪 + 开录提示音 + PCM 上行(VAD,无 audio.create)
-```
-
-不发 `cancel`(当前轮无服务端 TTS/播放占用)。
-
-#### E4.4 场景:思考中短按 — 打断并开麦
-
-**用户意图**(正文第二部分):不等 LLM/TTS 完成,直接说话。
-
-```text
-用户短按 AI 键(turn=Thinking 或 TTS 管线忙)
- → 若 _vc_tts_pipeline_busy()(含 _tts_pending / tts_rx / Playing 等):
- 同 E4.2:VC_init → cancel → canceled → uplink_start
- → 否则(纯 Thinking、管线不忙):
- 产品应 cancel 打断;当前实现可能仅 request_uplink_start,
- 若 turn≠Listening/SpeechEnded 会被门控拒绝(串口 Deny START by key.press/continue)
- → 联调时建议统一走 RequestToSpeak
-```
-
-**灯与音**:打断成功后蓝慢闪 + 开录提示。
-
-#### E4.5 场景:待机 / 未建会话短按 — 唤醒
-
-```text
-用户短按 AI 键(未连接或未 started)
- → 落入 AI_key_press 兜底:AI_VC_start → Start → 等 started
- → started 后:request_uplink_start("server.started/idle")
- → 蓝慢闪 + 开录提示 + VAD 上行
-```
-
-**注意**:此路径含 `utime.sleep_ms(500)` 与历史 `Close` 兼容逻辑,与「已连接续聊」分支不同;联调以 `[KEY]`、`[VC][uplink_ctrl]` 为准。
-
-#### E4.6 长对话 — 完整状态矩阵(AI 键短按)
-
-
-| 设备状态(用户视角) | FSM 近似 | 短按动作 | 协议要点 | 灯效 |
-| ---------- | ------------------------ | ---------- | ------------------------------------ | ----- |
-| 待机、未建会话 | `session≠Started` | 唤醒开麦 | `Start` → `started` → 上行 PCM | 蓝慢闪 |
-| 聆听中 | `Listening` | 重开麦 | `uplink_start`,无 `cancel` | 蓝慢闪 |
-| 用户说完等 ASR | `SpeechEnded` | 可重开麦 | `uplink_start`(若门控允许) | 蓝慢闪 |
-| 思考中 | `Thinking` | 打断开麦 | **宜** `cancel`→`canceled`→开麦(见 E4.4) | 蓝慢闪 |
-| TTS 接收/播报 | `TtsReceiving`/`Playing` | 打断续聊 | E4.2:`cancel` + 清队列 | 蓝慢闪 |
-| TTS 自然播完 | → `audio.played` | 无需按键 | 自动 `uplink_start`(长对话) | 蓝慢闪 |
-| 弱网 | `signal=weak` | 同唤醒或 Close | 附录 D | 蓝绿慢交替 |
-
-
-#### E4.7 长对话 — 无按键自动轮次(VAD 闭环)
-
-```text
-开麦聆听(Listening)
- → 用户说话,PCM 上行
- → 服务端 vad.silence → uplink_stop,turn=SpeechEnded
- → llm.created → Thinking(蓝绿交替)
- → tts.started → TtsReceiving → local.play_start → Playing(绿常亮)
- → 本地播完 → audio.played(若 _need_audio_played_ack)
- → on_local_audio_played → Listening,可自动 uplink_start 进入下一轮
-```
-
-**门控**:`await_played` 为真时,禁止 `completed`/`canceled` 抢跑开麦;`Close_flag=1` 时拒绝 `canceled`/`audio.played` 触发的自动开麦。
-
-### E5 单对话(`VC_mode=1`)— AI 键处理
-
-#### E5.1 操作分工
-
-
-| 操作 | 作用 | 说明 |
-| ---------- | -------------------- | ------------------------------------------------------------------------------------------ |
-| **短按** | 仅在 **TTS/播放管线忙** 时有效 | 仅 **本地** `VC_Play_End` + 清队列;**不** 开录、**不** 发 `audio.create`;非播放态短按无业务(由 `ptt_begin` 承担开录) |
-| **长按(按下)** | 开始 / **重新开始** 一轮对话 | 走 `ptt_begin`(`AI_key_long_press`) |
-| **长按(松开)** | 结束本轮上行 | `AI_client_VC_end_speech`(`audio.completed`)→ 思考灯 + 「层」提示音 |
-
-
-#### E5.2 场景 1:TTS 播报中 **长按** — 重新开始一轮对话(核心示例)
-
-**用户意图**:不等 TTS 播完,尽快进入下一轮「按住说话」或继续当前会话内的新一轮上行。
-
-**产品要求(执行顺序不可颠倒)**:
-
-1. **先本地停播并清空播放缓存**(用户侧须立即静音,防止残余 TTS 尾音)。
-2. **再发停止当前轮次消息**(WebSocket `cancel`,仅当服务端仍在 TTS/思考等中间态)。
-3. **再发继续对话协议**(`audio.create`,不换 `sid`)。
-4. **打开录音** → **播开录提示音** → **上行 PCM 帧**(`audio.create` 与开麦顺序受 `VC_PROTO_GUARDS` 约束)。
-
-**固件链路(`ptt_begin`,与 `ai_VC.py` 对齐)**:
-
-```text
-用户长按 AI 键(ptt_begin)
- → (1) Close_flag = 0;_drop_tts_audio = 1
- → (2) VC_init() // 立即停播 + 清空 AI_audio_queue(VC_Play_End)
- → (3) 若仍在播:再次 VC_Play_End() // 兜底 codec/DMA 缓冲
- → (4) 若上一轮录音未结束:VC_Speech_End()
- → (5) 若 turn∈{Thinking,TtsReceiving,Playing} 或 TTS 管线忙:
- 发 cancel(停止当前轮服务端响应,不换 sid)
- → (6) 若 WebSocket 未连接 / 未 started:
- AI_VC_start 或 Start;_ptt_force_record_on_started,等 started 后自动 (7)~(11)
- 否则(会话已 started):
- → (7) 发 audio.create(AI_client_VC_send_speech) // 继续对话 / 新一轮上行
- → (8) VC_Speech_Start_now() // 开硬件录音
- → (9) 开录提示音(RECORD_START_TONE_FILE / start.mp3,由 VC_Speech_Start 路径播放)
- → (10) 蓝灯聆听态(长按按下:蓝快闪,见正文第二部分)
- → (11) 上行 PCM 帧(须在 audio.create 之后)
-```
-
-**为何本地停播优先于 `cancel`**:用户按下 AI 键后 **≤100ms** 内应听不到旧 TTS;若先发 `cancel` 再等本地清队列,DMA/队列中已缓冲的 PCM 仍会播出。本地 `VC_init` 后再 `cancel`,可同时满足「马上静音」与「服务端停止生成」。
-
-**与产品描述的对应**:
-
-
-| 产品描述 | 单对话实现 |
-| ------------- | --------------------------------------------------------- |
-| 「停止对话消息」 | TTS/思考态:`**cancel`**;并 **必先** 完成 E3 本地 L1~L3 |
-| TTS 立即停止或自然结束 | **以 L1~L2 立即停播为准**;无新数据后播放线程退出;`_drop_tts_audio` 丢弃迟到的下行帧 |
-| 「继续对话协议」 | `**audio.create`**(保持当前 `sid` / `dialog_id`) |
-| 打开录音 | `VC_Speech_Start_now` |
-| 开录提示音 | `RECORD_START_TONE_FILE` / start.mp3 |
-| 上传录音 | 录音线程 → WS 发 PCM 帧 → 松手发 `audio.completed` |
-
-
-**松开手指**:`audio.completed` → 服务端 ASR/LLM/TTS → 思考蓝绿交替 + 「层」音 → 播放绿常亮。
-
-#### E5.3 场景 2:TTS 播报中 **短按** — 仅打断播放
-
-```text
-短按 → VC_Play_End + 清队列 + await_played=false + 灯 idle
- → 不 audio.create、不开麦(_ptt_short_press_stop 标记,松手不触发 end_speech)
-```
-
-用于「只想闭嘴、暂不继续说」;要续聊须 **再次长按**。
-
-#### E5.4 场景 3:待机 / 思考中 **长按** — 开始或重开一轮
-
-- **待机、未连接**:`AI_VC_start` → 等 `started` → 自动 `audio.create` + 开录(`_ptt_force_record_on_started`)。
-- **思考中、无 TTS 播放**:同 E5.2 的 (1)~(11);`turn=Thinking` 时发 `cancel`,再 `audio.create`。
-
-#### E5.5 场景 4:聆听中 **长按** — 重录当前轮
-
-用户按下时尚未松手、或希望 **覆盖本轮录音**:
-
-```text
-长按(ptt_begin)
- → VC_init / 停上一轮录音(VC_Speech_End 若 flag≠0)
- → cancel(若仍在 Thinking/TTS 管线)
- → audio.create + VC_Speech_Start_now + 开录提示
- → 松手 → audio.completed
-```
-
-**说明**:单对话一轮以「按下→松开」为边界;聆听中再次长按视为 **新开一轮** `audio.create`,非长对话式续麦。
-
-#### E5.6 场景 5:标准一轮 PTT(按下 → 松开 → 应答)
-
-**用户操作**:待机或上轮结束后 **长按说话,松手结束**。
-
-```text
-长按按下(ptt_begin)
- → audio.create → VC_Speech_Start_now → 蓝快闪 + start.mp3
- → 上行 PCM 帧…
-长按松开(_ai_key_release_handle_ptt,record_flag≠0)
- → AI_client_VC_end_speech(audio.completed)
- → uplink_stop,turn 经 SpeechEnded → Thinking
- → 灯:蓝绿交替 + 「层」提示音
-服务端 asr.result → llm → tts.started
- → 绿常亮播放 TTS
- → 播完:本地 VC_Play_End;单对话一般 **不等** audio.played 门控下一轮
- → 用户可再次长按开始新一轮(E5.2 / E5.4)
-```
-
-**与长对话差异**:松手后 **不会** 自动开麦;必须再次 **长按** 才能说下一句话。
-
-#### E5.7 场景 6:TTS 自然播完 → 待机后再问
-
-```text
-TTS 队列读空 → VC_Play_End → ai_state 回 idle(或绿灯慢闪待机)
- → 用户长按 ptt_begin
- → 若 session 仍 started:直接 audio.create + 开录
- → 若会话已 Close/超时:AI_VC_start → started → 再开录
-```
-
-无 `cancel`(除非上轮服务端仍在 Thinking/TTS 下行)。
-
-#### E5.8 单对话 — 完整状态矩阵(AI 键)
-
-
-| 设备状态(用户视角) | 短按 | 长按按下 | 长按松开 | 协议要点 |
-| ---------- | ------------- | ----------------------- | ----------------- | -------------------------------- |
-| 待机、未连接 | 无 | 建链+开录 | — | `Start`→`started`→`audio.create` |
-| 聆听中(蓝快闪) | 吞掉 | 重开一轮(E5.5) | `audio.completed` | 松手结束上行 |
-| 思考中 | 吞掉 | 打断+`audio.create`(E5.4) | 若已开录则 completed | `cancel`(忙态) |
-| TTS 播报中 | **仅停播**(E5.3) | **重开一轮**(E5.2) | 停播后松手无 completed | 长按:cancel+create |
-| TTS 播完待机 | 吞掉 | 新一轮(E5.7) | completed | `audio.create` |
-| 弱网 / 异常 | 无 | 同待机尝试 | — | 附录 D |
-
-
-#### E5.9 单对话时序简图(长按打断 TTS)
-
-```mermaid
-sequenceDiagram
- participant U as 用户
- participant D as 设备端
- participant S as 服务端
-
- Note over D,S: turn=Playing,TTS 下行中
- U->>D: 长按 AI 键
- D->>D: VC_init / VC_Play_End,清空播放队列
- D->>D: _drop_tts_audio=1
- D->>S: cancel(TTS/思考态)
- D->>S: audio.create
- D->>D: VC_Speech_Start_now,开录提示音
- D->>S: PCM 音频帧…
- U->>D: 松开
- D->>S: audio.completed
- S-->>D: asr / llm / tts 事件
-```
-
-
-
-### E6 长对话打断 TTS 时序(对照)
-
-```mermaid
-sequenceDiagram
- participant U as 用户
- participant D as 设备端
- participant S as 服务端
-
- U->>D: 短按 AI 键
- D->>D: VC_init(立即停播,清播放队列)
- D->>S: cancel
- S-->>D: canceled
- D->>D: uplink_start → VC_Speech_Start_now,开录提示音
- D->>S: PCM 上行(VAD,无 audio.create)
-```
-
-
-
-### E7 与电源键、模式切换的边界
-
-
-| 操作 | 行为 |
-| ------------- | ----------------------------------------------------------- |
-| Power 短按(交互中) | `Close`:断会话 / 清 `sid`,进待机;**不是** 续聊 |
-| Power 双击 | 切换 `VC_mode` 并 TTS 播报「单对话/长对话」;若正在对话先 `Close` |
-| 单对话 ↔ 长对话切换后 | 须重新 `Start` / 按键建会话;避免沿用上一种模式的 `cancel` / `audio.create` 假设 |
-
-
-### E8 联调检查清单
-
-
-| # | 检查项 | 长对话 | 单对话 |
-| --- | -------------------------- | -------------------------------------------------- | --------------------------------------------- |
-| 1 | TTS 中按键后 **500ms 内** 无残余播报 | 短按+cancel 路径 | 长按 `ptt_begin` 路径 |
-| 2 | `AI_audio_queue` 深度为 0 | `VC_init` 后打印 `[VC] init audio_queue` | 同左 |
-| 3 | 开录前已 `started` | 是 | 是;否则 `audio.create` 被 SKIP(`VC_PROTO_GUARDS`) |
-| 4 | 开录提示音不叠按键音 | 短按提示音已关 | `ptt_begin` 内不再叠短嘟 |
-| 5 | 打断后能快速第二轮 | `canceled`→开麦 | 松手→播完→再长按;或 TTS 中直接长按 |
-| 6 | 串口日志 | `[KEY]`、`[PTT]`、`[FSM]`、`[VC][proto] audio.create` | 同左 |
-
-
-### E9 AI 键双击(复读,可选)
-
-正文第二部分:**待机且上轮 TTS 结束 1 分钟内**,双击 AI 键复读上一段 TTS。
-
-
-| 项目 | 说明 |
-| ---- | ----------------------------------------------------------------------------------------------- |
-| 产品行为 | 蓝常亮播复读内容 → 结束回待机(灯灭) |
-| 工程现状 | `ai_VC` 内 **AI 键双击业务未实现**;`pwk_key_double_press` 为 **Power 键双击切模式**(`VC_mode` 0↔1 + TTS 提示),勿混淆 |
-| 若需落地 | 缓存末轮 TTS PCM/文本;双击时本地 `play_tts` 或重放队列;期间 `ai_state=playing`;不与 `ptt_begin`/`cancel` 并发 |
-
-
-### E10 服务端事件与端侧动作对照(联调)
-
-
-| 服务端 `event` | 长对话典型端侧动作 | 单对话典型端侧动作 |
-| ------------------------------- | ------------------------ | ------------------------------------------------------- |
-| `started` | `uplink_start` 或等用户按键 | PTT:`audio.create`+开麦(若 `_ptt_force_record_on_started`) |
-| `vad.silence` | `uplink_stop`,停上行 | 一般不走(VAD 关) |
-| `asr.started` / `asr.completed` | 更新 FSM,可能提示音 | 松手后 ASR |
-| `llm.created` | `turn=Thinking`,蓝绿交替 | 同左 |
-| `tts.started` | 停上行、`play_start`、绿常亮 | 同左 |
-| `tts.completed` | 标记服务端下发结束;本地可能仍播 | 同左 |
-| `canceled` | `uplink_start` 自动开麦(长对话) | **不**自动开麦(`VC_mode≠0` 门控) |
-| `completed` | 无 TTS 时可能 `uplink_start` | 不自动开麦 |
-| `config.update` | `uplink_stop` | `uplink_stop` |
-
-
-本地 `**audio.played`**:长对话播完上报后门控下一轮;单对话以用户 **长按** 开始下一轮为主。
-
-### E11 实现差异与待对齐项(评审用)
-
-
-| # | 场景 | 产品/正文 | 当前 `ai_VC.py` 要点 |
-| --- | --------- | --------- | ------------------------------------------------------- |
-| 1 | 长对话·思考中短按 | 打断并开麦 | 仅 TTS 管线忙时走 `cancel`;纯 `Thinking` 可能 `Deny START`(E4.4) |
-| 2 | 长对话·待机短按 | 直接唤醒 | 未命中分支时走 `AI_VC_start` 兜底(含 500ms sleep) |
-| 3 | 单对话·短按 | 非 TTS 无操作 | `_ai_key_press_ptt_mode` 非播放态 **return True 吞掉** |
-| 4 | AI 键双击复读 | 可选 | **未实现**;Power 双击为切模式(E9) |
-| 5 | 待机灯 | 正文灯灭 | 工程绿灯慢闪(附录 A) |
-
-
-若产品要求单对话 **短按即开录**,须改 `_ai_key_press_ptt_mode` 并同步正文第二部分表格。
-
-### E12 联调日志关键字(补充 E8)
-
-
-| 日志 | 含义 |
-| ------------------------------------------------ | ----------------------------- |
-| `[KEY] AI_key_press VC_mode=` | 按键模式与 FSM 快照 |
-| `[PTT] cancel before new uplink` | 单对话长按前已发 cancel |
-| `[PTT] wait server.started` | 等 `started` 后再 `audio.create` |
-| `[VC][proto] audio.create send` / `SKIP` | PTT 开轮是否成功 |
-| `[VC][uplink_ctrl] Request START` / `Deny START` | 长对话开麦门控 |
-| `[FSM] server.canceled` | 打断成功,长对话可续麦 |
-| `[VC] init audio_queue` | 清播放队列成功 |
-
+- 误触防护:待机短按 Power 无多余操作;单/长对话模式切换 **仅 Power 键双击**(见 REQ-PWR-05),AI 键无双击业务
---
+## 工程详设(对照 / 联调 / 决策)
+| 文档 | 用途 |
+| --- | --- |
+| **[详设-对照与决策.md](./AI吊坠(双按键+三色指示灯)详设-对照与决策.md)** | 架构、需求-设计-实现矩阵、GAP 双轨决策(**评审入口**) |
+| **[详设-场景与协议流程.md](./AI吊坠(双按键+三色指示灯)详设-场景与协议流程.md)** | 按键/协议步骤、时序图(联调深读) |
-
-
-## **要点摘要**
-
-**长对话**:短按打断 TTS → `VC_init` → `cancel` → `canceled` → 自动开麦;VAD 判停后可多轮自动循环(E4.7)。
-
-**单对话**:长按开录 / 松手 `audio.completed`;TTS 中 **短按只停播**,续聊须 **再长按**;标准流程见 E5.6。
-
-**实现差异(E11)**:长对话在纯 `Thinking` 且 TTS 管线不忙时,短按可能被 `uplink` 门控拒绝,产品要求打断时应统一走 `cancel`;AI 键双击复读尚未实现。
-
-如需下一步把 E11 中的差异落实到 `ai_VC.py` 代码修改,可以说明优先项。
-
+上表 `REQ-*` 编号与详设主文档第 3、4 章一一对应。
\ No newline at end of file
--- /dev/null
+# AI吊坠(双按键+三色指示灯)— 工程附录(已迁移)
+
+> **本文档已重构**,请使用新的详设体系进行代码审查与需求对照:
+
+| 文档 | 用途 |
+| --- | --- |
+| **[详设-对照与决策.md](./AI吊坠(双按键+三色指示灯)详设-对照与决策.md)** | 架构、三角对照矩阵、GAP 双轨决策(**评审入口**) |
+| **[详设-场景与协议流程.md](./AI吊坠(双按键+三色指示灯)详设-场景与协议流程.md)** | 按键/协议步骤、时序图(联调深读) |
+| **[交互规范_new.md](./AI吊坠(双按键+三色指示灯)交互规范_new.md)** | 产品需求(`REQ-*` 编号) |
+
+**实现真源**:`2.sdk/Helios_SDK/services/microPython/ports/quectel/core/ai_VC.py`
--- /dev/null
+# AI吊坠(双按键+三色指示灯)详设 — 场景与协议流程
+
+> **上级文档**:[详设-对照与决策](./AI吊坠(双按键+三色指示灯)详设-对照与决策.md)(三角对照、GAP 决策入口)
+> **需求文档**:[交互规范_new](./AI吊坠(双按键+三色指示灯)交互规范_new.md)
+> **实现真源**:`2.sdk/Helios_SDK/services/microPython/ports/quectel/core/ai_VC.py`
+
+**阅读说明**:本章为步骤级场景展开(按键顺序、协议事件、时序图)。若与主详设 **第 3/4 章** 或 GAP「决策」列冲突,**以 GAP 已决项为准**。
+
+---
+## 场景详设总览
+
+> **与正文关系**:需求文档第二部分描述用户可感知的「按键→灯→音→结果」;本章描述 **协议与本地执行顺序**,供固件(`ai_VC.py`)联调与评审。实现以当前编译进镜像的 `ai_VC.py` 为准。
+> **模式存储**:`VC_mode`(`0`=长对话 / `1`=单对话),Power 键双击可切换并 TTS 播报模式名(见正文第一部分)。
+
+### DES-SCN-000 典型场景速查(产品语言)
+
+**单对话 · 场景 1:TTS 播报中,AI 键重新开始对话(长按)**
+
+
+| 步骤 | 要求 |
+| --- | ------------------------------------------------ |
+| 1 | **本地立即停播**,清空 `AI_audio_queue` 与 codec 缓冲,杜绝残余尾音 |
+| 2 | 发 **停止当前轮** 消息(`cancel`,服务端停止生成;**不换会话**) |
+| 3 | 发 **继续对话** 消息(`audio.create`) |
+| 4 | 打开录音 → 播 **开录提示音**(start.mp3)→ 开始上传 PCM |
+| 5 | 松手发 `audio.completed`,进入思考 → TTS 播放 |
+
+
+**长对话 · 对照**:TTS 播报中 **短按** AI 键续聊 → 本地清队列 + `cancel` → 收 `canceled` 后 **直接开麦**(无需 `audio.create`)。详见 **DES-SCN-010.2**、**DES-SCN-011**。
+
+**场景索引**
+
+
+| 模式 | 场景 | 章节 |
+| --- | ----------- | --------- |
+| 长对话 | TTS 中短按打断续聊 | DES-SCN-010.2 |
+| 长对话 | 聆听中短按重开麦 | DES-SCN-010.3 |
+| 长对话 | 思考中短按打断 | DES-SCN-010.4 |
+| 长对话 | 待机唤醒 | DES-SCN-010.5 |
+| 长对话 | VAD 自动多轮 | DES-SCN-010.7 |
+| 单对话 | TTS 中长按重开 | DES-SCN-020.2 |
+| 单对话 | TTS 中短按仅停播 | DES-SCN-020.3 |
+| 单对话 | 待机/思考长按开录 | DES-SCN-020.4 |
+| 单对话 | 标准一轮 PTT | DES-SCN-020.6 |
+| 单对话 | 播完后再问 | DES-SCN-020.7 |
+| 通用 | 完整状态矩阵 | DES-SCN-010.6、DES-SCN-020.8 |
+| 通用 | 服务端事件对照 | DES-SCN-050 |
+
+
+### DES-SCN-001 模式总览
+
+
+| 维度 | 长对话 `VC_mode=0`(Realtime) | 单对话 `VC_mode=1`(Push-To-Talk / PTT) |
+| --------- | -------------------------------------------- | ---------------------------------------------------------------------- |
+| 用户操作 | **短按** AI 键唤醒 / 打断 / 续聊 | **长按** 开录、**松开** 结束本轮上行 |
+| VAD | 开(服务端判停) | 关(`disable_vad=true`) |
+| 上行包裹 | 一般 **无需** `audio.create` / `audio.completed` | **必须** `audio.create` → PCM 帧 → `audio.completed` |
+| 打断 TTS 续聊 | 短按:发 `**cancel`** + 本地清队列 → 等 `canceled` 后开麦 | **长按**:本地停播清队列 →(TTS/思考态)发 `**cancel`**(不换 `sid`)→ `audio.create` 开新一轮 |
+| 典型灯效 | 聆听蓝慢闪 / 思考蓝绿交替 / 播放绿常亮 | 长按聆听蓝快闪;松手后思考蓝绿交替 |
+| 进入下一轮门控 | `audio.played`(播完上报)、`canceled` 后自动开麦等 | 松手 `audio.completed` 后等 ASR/LLM/TTS;再次长按走 `ptt_begin` |
+
+
+**设计目标**:用户在任何中间态(TTS 播报、思考、弱网恢复中等)按 AI 键,应 **尽快** 进入「下一轮可说话」或「继续当前会话内的新一轮上行」,且 **无残余 TTS 尾音、无播放队列叠录**。
+
+### DES-SCN-002 协议消息与「停止 / 继续」语义
+
+
+| 产品用语 | WebSocket `event` | 适用模式 | 作用 |
+| ------------- | ------------------------------ | ---------------------- | -------------------------------------------------------- |
+| 停止当前轮次 / 打断播报 | `cancel` | **长对话**(短按打断 TTS、思考中等) | 通知服务端撤销当前响应;端侧随后收 `canceled`,再开麦 |
+| 本地停止播放(非协议) | —(`VC_init` + `VC_Play_End`) | **单对话、长对话** | 立即停止 PCM/流播放,**清空 `AI_audio_queue`**,必要时 `aud.stopAll()` |
+| 继续对话 / 开新一轮上行 | 长对话:开麦上行 PCM(无 `audio.create`) | 长对话 | `request_uplink_start` → `VC_Speech_Start_now` |
+| 继续对话 / 开新一轮上行 | 单对话:`audio.create` | 单对话 | `AI_client_VC_send_speech` → 录音 → 帧上行 |
+| 本轮说话结束 | `audio.completed` | 单对话 | 松手触发 `AI_client_VC_end_speech` |
+| 本地 TTS 播完 | `audio.played` | 长对话为主 | 播完后上报,服务端进入下一轮 ASR(门控之一) |
+
+
+> **「停止对话」与 `cancel` 的分工**:`cancel` 仅撤销**当前轮**服务端响应(LLM/TTS 生成),**不断开 WebSocket、不换 `sid`**;与 Power 键 `Close`(换会话)区分。单对话在 TTS/思考态 **长按重开** 时也会发 `cancel`,避免旧轮下行与新一轮 `audio.create` 竞态;须 **先本地停播清队列**(用户侧立即静音),再发 `cancel`,再发 `audio.create`。
+
+### DES-SCN-003 本地停播与清缓存(凡打断 TTS 必做)
+
+无论哪种模式,只要用户打断 **TTS/本地播报**,须在同一按键处理链内完成(顺序不可颠倒):
+
+
+| 步骤 | 动作 | 说明 |
+| --- | ---------------------------------------------- | ---------------------------------------------- |
+| L1 | `VC_Play_End()` / `VC_init()` | 停播放流、`VC_Play_flag=0`;`AI_audio_queue.clear()` |
+| L2 | `aud.stopPlayStream()` + `aud.stopAll()` | 避免 DMA/codec 缓冲残留 |
+| L3 | `_drop_tts_audio = 1`(可选窗口) | 打断后短时 **丢弃** 服务端仍下发的 TTS 帧,防「断断续续继续播」 |
+| L4 | `fsm.await_played = false`(若曾等 `audio.played`) | 避免门控卡死,无法开麦 |
+| L5 | 灯 `ai_state: idle` 或 `listening` | 打断瞬间可先 `idle`;开录后切 `listening` |
+
+
+TTS 自然播完:队列读空后 `VC_Play_End`;若本轮需门控,再在适当时机发 `audio.played`。
+
+### DES-SCN-010 长对话(`VC_mode=0`)— AI 键处理
+
+#### DES-SCN-010.1 状态 → 动作
+
+
+| 设备状态(用户视角) | FSM 近似 | AI 键 **短按** | 协议 / 本地顺序 |
+| ----------- | ------------------------------- | ------------- | ----------------------------------------------------------------------------- |
+| 待机、未建会话 | `session≠Started` | 唤醒 | `AI_VC_start` → `Start` → 等 `started` → 开麦 → 蓝慢闪 + 开录提示 |
+| 聆听中 | `turn=Listening` | 重新聆听 | `request_uplink_start("key.press/continue")` → 清僵死录音态 → `VC_Speech_Start_now` |
+| 思考中 | `turn=Thinking` | 打断并开麦 | 见 **DES-SCN-010.4**(宜 `cancel`;管线忙时同 DES-SCN-010.2) |
+| **TTS 播报中** | `turn=Playing` / `TtsReceiving` | **打断并续聊** | 见 **DES-SCN-010.2** |
+| 弱网 / 异常 | `signal=weak` 等 | 同待机或 Close 策略 | 以异常主详设 GAP-006 为准 |
+
+
+#### DES-SCN-010.2 场景:TTS 播报中短按 — 打断并进入下一轮聆听
+
+**目标**:尽快停播、通知服务端停止当前响应、开麦。
+
+```text
+用户短按 AI 键
+ → (1) is_ruppt_tts = 1
+ → (2) VC_init(立即停播 + 清空 AI_audio_queue)
+ → (3) 发 cancel(RequestToSpeak)
+ → (4) 等 server.canceled
+ → (5) FSM: uplink_start(canceled) → VC_Speech_Start_now
+ → (6) 蓝慢闪 + 开录提示音(start.mp3 等)+ 上行 PCM(VAD,无 audio.create)
+```
+
+**灯与音**:发 `cancel` 前后本地已无 TTS;开录时蓝慢闪 + 「滴」类开录提示(与正文第二部分一致)。
+**注意**:`Close_flag` 须在 `RequestToSpeak` 路径清零,否则 `canceled` 后 `_uplink_request_start` 会拒开麦。
+
+#### DES-SCN-010.3 场景:会话已建立、聆听中短按 — 重新开麦
+
+**用户意图**:上一轮 VAD 已判停或仍在聆听,希望 **再说一段**。
+
+```text
+用户短按 AI 键(session=Started,turn=Listening/SpeechEnded,非 TTS 忙)
+ → request_uplink_start("key.press/continue")
+ → _vc_clear_stale_record_state(若 record_flag 僵死)
+ → VC_Speech_Start_now
+ → 蓝慢闪 + 开录提示音 + PCM 上行(VAD,无 audio.create)
+```
+
+不发 `cancel`(当前轮无服务端 TTS/播放占用)。
+
+#### DES-SCN-010.4 场景:思考中短按 — 打断并开麦
+
+**用户意图**(正文第二部分):不等 LLM/TTS 完成,直接说话。
+
+```text
+用户短按 AI 键(turn=Thinking 或 TTS 管线忙)
+ → 若 _vc_tts_pipeline_busy()(含 _tts_pending / tts_rx / Playing 等):
+ 同 DES-SCN-010.2:VC_init → cancel → canceled → uplink_start
+ → 否则(纯 Thinking、管线不忙):
+ 产品应 cancel 打断;当前实现可能仅 request_uplink_start,
+ 若 turn≠Listening/SpeechEnded 会被门控拒绝(串口 Deny START by key.press/continue)
+ → 联调时建议统一走 RequestToSpeak
+```
+
+**灯与音**:打断成功后蓝慢闪 + 开录提示。
+
+#### DES-SCN-010.5 场景:待机 / 未建会话短按 — 唤醒
+
+```text
+用户短按 AI 键(未连接或未 started)
+ → 落入 AI_key_press 兜底:AI_VC_start → Start → 等 started
+ → started 后:request_uplink_start("server.started/idle")
+ → 蓝慢闪 + 开录提示 + VAD 上行
+```
+
+**注意**:此路径含 `utime.sleep_ms(500)` 与历史 `Close` 兼容逻辑,与「已连接续聊」分支不同;联调以 `[KEY]`、`[VC][uplink_ctrl]` 为准。
+
+#### DES-SCN-010.6 长对话 — 完整状态矩阵(AI 键短按)
+
+
+| 设备状态(用户视角) | FSM 近似 | 短按动作 | 协议要点 | 灯效 |
+| ---------- | ------------------------ | ---------- | ------------------------------------ | ----- |
+| 待机、未建会话 | `session≠Started` | 唤醒开麦 | `Start` → `started` → 上行 PCM | 蓝慢闪 |
+| 聆听中 | `Listening` | 重开麦 | `uplink_start`,无 `cancel` | 蓝慢闪 |
+| 用户说完等 ASR | `SpeechEnded` | 可重开麦 | `uplink_start`(若门控允许) | 蓝慢闪 |
+| 思考中 | `Thinking` | 打断开麦 | **宜** `cancel`→`canceled`→开麦(见 DES-SCN-010.4) | 蓝慢闪 |
+| TTS 接收/播报 | `TtsReceiving`/`Playing` | 打断续聊 | DES-SCN-010.2:`cancel` + 清队列 | 蓝慢闪 |
+| TTS 自然播完 | → `audio.played` | 无需按键 | 自动 `uplink_start`(长对话) | 蓝慢闪 |
+| 弱网 | `signal=weak` | 同唤醒或 Close | 主详设 GAP-006 | 蓝绿慢交替 |
+
+
+#### DES-SCN-010.7 长对话 — 无按键自动轮次(VAD 闭环)
+
+```text
+开麦聆听(Listening)
+ → 用户说话,PCM 上行
+ → 服务端 vad.silence → uplink_stop,turn=SpeechEnded
+ → llm.created → Thinking(蓝绿交替)
+ → tts.started → TtsReceiving → local.play_start → Playing(绿常亮)
+ → 本地播完 → audio.played(若 _need_audio_played_ack)
+ → on_local_audio_played → Listening,可自动 uplink_start 进入下一轮
+```
+
+**门控**:`await_played` 为真时,禁止 `completed`/`canceled` 抢跑开麦;`Close_flag=1` 时拒绝 `canceled`/`audio.played` 触发的自动开麦。
+
+### DES-SCN-020 单对话(`VC_mode=1`)— AI 键处理
+
+#### DES-SCN-020.1 操作分工
+
+
+| 操作 | 作用 | 说明 |
+| ---------- | -------------------- | ------------------------------------------------------------------------------------------ |
+| **短按** | 仅在 **TTS/播放管线忙** 时有效 | 仅 **本地** `VC_Play_End` + 清队列;**不** 开录、**不** 发 `audio.create`;非播放态短按无业务(由 `ptt_begin` 承担开录) |
+| **长按(按下)** | 开始 / **重新开始** 一轮对话 | 走 `ptt_begin`(`AI_key_long_press`) |
+| **长按(松开)** | 结束本轮上行 | `AI_client_VC_end_speech`(`audio.completed`)→ 思考灯 + 「层」提示音 |
+
+
+#### DES-SCN-020.2 场景 1:TTS 播报中 **长按** — 重新开始一轮对话(核心示例)
+
+**用户意图**:不等 TTS 播完,尽快进入下一轮「按住说话」或继续当前会话内的新一轮上行。
+
+**产品要求(执行顺序不可颠倒)**:
+
+1. **先本地停播并清空播放缓存**(用户侧须立即静音,防止残余 TTS 尾音)。
+2. **再发停止当前轮次消息**(WebSocket `cancel`,仅当服务端仍在 TTS/思考等中间态)。
+3. **再发继续对话协议**(`audio.create`,不换 `sid`)。
+4. **打开录音** → **播开录提示音** → **上行 PCM 帧**(`audio.create` 与开麦顺序受 `VC_PROTO_GUARDS` 约束)。
+
+**固件链路(`ptt_begin`,与 `ai_VC.py` 对齐)**:
+
+```text
+用户长按 AI 键(ptt_begin)
+ → (1) Close_flag = 0;_drop_tts_audio = 1
+ → (2) VC_init() // 立即停播 + 清空 AI_audio_queue(VC_Play_End)
+ → (3) 若仍在播:再次 VC_Play_End() // 兜底 codec/DMA 缓冲
+ → (4) 若上一轮录音未结束:VC_Speech_End()
+ → (5) 若 turn∈{Thinking,TtsReceiving,Playing} 或 TTS 管线忙:
+ 发 cancel(停止当前轮服务端响应,不换 sid)
+ → (6) 若 WebSocket 未连接 / 未 started:
+ AI_VC_start 或 Start;_ptt_force_record_on_started,等 started 后自动 (7)~(11)
+ 否则(会话已 started):
+ → (7) 发 audio.create(AI_client_VC_send_speech) // 继续对话 / 新一轮上行
+ → (8) VC_Speech_Start_now() // 开硬件录音
+ → (9) 开录提示音(RECORD_START_TONE_FILE / start.mp3,由 VC_Speech_Start 路径播放)
+ → (10) 蓝灯聆听态(长按按下:蓝快闪,见正文第二部分)
+ → (11) 上行 PCM 帧(须在 audio.create 之后)
+```
+
+**为何本地停播优先于 `cancel`**:用户按下 AI 键后 **≤100ms** 内应听不到旧 TTS;若先发 `cancel` 再等本地清队列,DMA/队列中已缓冲的 PCM 仍会播出。本地 `VC_init` 后再 `cancel`,可同时满足「马上静音」与「服务端停止生成」。
+
+**与产品描述的对应**:
+
+
+| 产品描述 | 单对话实现 |
+| ------------- | --------------------------------------------------------- |
+| 「停止对话消息」 | TTS/思考态:`**cancel`**;并 **必先** 完成 DES-SCN-003 本地 L1~L3 |
+| TTS 立即停止或自然结束 | **以 L1~L2 立即停播为准**;无新数据后播放线程退出;`_drop_tts_audio` 丢弃迟到的下行帧 |
+| 「继续对话协议」 | `**audio.create`**(保持当前 `sid` / `dialog_id`) |
+| 打开录音 | `VC_Speech_Start_now` |
+| 开录提示音 | `RECORD_START_TONE_FILE` / start.mp3 |
+| 上传录音 | 录音线程 → WS 发 PCM 帧 → 松手发 `audio.completed` |
+
+
+**松开手指**:`audio.completed` → 服务端 ASR/LLM/TTS → 思考蓝绿交替 + 「层」音 → 播放绿常亮。
+
+#### DES-SCN-020.3 场景 2:TTS 播报中 **短按** — 仅打断播放
+
+```text
+短按 → VC_Play_End + 清队列 + await_played=false + 灯 idle
+ → 不 audio.create、不开麦(_ptt_short_press_stop 标记,松手不触发 end_speech)
+```
+
+用于「只想闭嘴、暂不继续说」;要续聊须 **再次长按**。
+
+#### DES-SCN-020.4 场景 3:待机 / 思考中 **长按** — 开始或重开一轮
+
+- **待机、未连接**:`AI_VC_start` → 等 `started` → 自动 `audio.create` + 开录(`_ptt_force_record_on_started`)。
+- **思考中、无 TTS 播放**:同 DES-SCN-020.2 的 (1)~(11);`turn=Thinking` 时发 `cancel`,再 `audio.create`。
+
+#### DES-SCN-020.5 场景 4:聆听中 **长按** — 重录当前轮
+
+用户按下时尚未松手、或希望 **覆盖本轮录音**:
+
+```text
+长按(ptt_begin)
+ → VC_init / 停上一轮录音(VC_Speech_End 若 flag≠0)
+ → cancel(若仍在 Thinking/TTS 管线)
+ → audio.create + VC_Speech_Start_now + 开录提示
+ → 松手 → audio.completed
+```
+
+**说明**:单对话一轮以「按下→松开」为边界;聆听中再次长按视为 **新开一轮** `audio.create`,非长对话式续麦。
+
+#### DES-SCN-020.6 场景 5:标准一轮 PTT(按下 → 松开 → 应答)
+
+**用户操作**:待机或上轮结束后 **长按说话,松手结束**。
+
+```text
+长按按下(ptt_begin)
+ → audio.create → VC_Speech_Start_now → 蓝快闪 + start.mp3
+ → 上行 PCM 帧…
+长按松开(_ai_key_release_handle_ptt,record_flag≠0)
+ → AI_client_VC_end_speech(audio.completed)
+ → uplink_stop,turn 经 SpeechEnded → Thinking
+ → 灯:蓝绿交替 + 「层」提示音
+服务端 asr.result → llm → tts.started
+ → 绿常亮播放 TTS
+ → 播完:本地 VC_Play_End;单对话一般 **不等** audio.played 门控下一轮
+ → 用户可再次长按开始新一轮(DES-SCN-020.2 / DES-SCN-020.4)
+```
+
+**与长对话差异**:松手后 **不会** 自动开麦;必须再次 **长按** 才能说下一句话。
+
+#### DES-SCN-020.7 场景 6:TTS 自然播完 → 待机后再问
+
+```text
+TTS 队列读空 → VC_Play_End → ai_state 回 idle → 正常待机灭灯(`LED_IDLE_MODE=off`)
+ → 用户长按 ptt_begin
+ → 若 session 仍 started:直接 audio.create + 开录
+ → 若会话已 Close/超时:AI_VC_start → started → 再开录
+```
+
+无 `cancel`(除非上轮服务端仍在 Thinking/TTS 下行)。
+
+#### DES-SCN-020.8 单对话 — 完整状态矩阵(AI 键)
+
+
+| 设备状态(用户视角) | 短按 | 长按按下 | 长按松开 | 协议要点 |
+| ---------- | ------------- | ----------------------- | ----------------- | -------------------------------- |
+| 待机、未连接 | 无 | 建链+开录 | — | `Start`→`started`→`audio.create` |
+| 聆听中(蓝快闪) | 吞掉 | 重开一轮(DES-SCN-020.5) | `audio.completed` | 松手结束上行 |
+| 思考中 | 吞掉 | 打断+`audio.create`(DES-SCN-020.4) | 若已开录则 completed | `cancel`(忙态) |
+| TTS 播报中 | **仅停播**(DES-SCN-020.3) | **重开一轮**(DES-SCN-020.2) | 停播后松手无 completed | 长按:cancel+create |
+| TTS 播完待机 | 吞掉 | 新一轮(DES-SCN-020.7) | completed | `audio.create` |
+| 弱网 / 异常 | 无 | 同待机尝试 | — | 主详设 GAP-006 |
+
+
+#### DES-SCN-020.9 单对话时序简图(长按打断 TTS)
+
+```mermaid
+sequenceDiagram
+ participant U as 用户
+ participant D as 设备端
+ participant S as 服务端
+
+ Note over D,S: turn=Playing,TTS 下行中
+ U->>D: 长按 AI 键
+ D->>D: VC_init / VC_Play_End,清空播放队列
+ D->>D: _drop_tts_audio=1
+ D->>S: cancel(TTS/思考态)
+ D->>S: audio.create
+ D->>D: VC_Speech_Start_now,开录提示音
+ D->>S: PCM 音频帧…
+ U->>D: 松开
+ D->>S: audio.completed
+ S-->>D: asr / llm / tts 事件
+```
+
+
+
+### DES-SCN-011 长对话打断 TTS 时序(对照)
+
+```mermaid
+sequenceDiagram
+ participant U as 用户
+ participant D as 设备端
+ participant S as 服务端
+
+ U->>D: 短按 AI 键
+ D->>D: VC_init(立即停播,清播放队列)
+ D->>S: cancel
+ S-->>D: canceled
+ D->>D: uplink_start → VC_Speech_Start_now,开录提示音
+ D->>S: PCM 上行(VAD,无 audio.create)
+```
+
+
+
+### DES-SCN-030 与电源键、模式切换的边界
+
+
+| 操作 | 行为 |
+| ------------- | ----------------------------------------------------------- |
+| Power 短按(交互中) | `Close`:断会话 / 清 `sid`,进待机;**不是** 续聊 |
+| Power 双击 | 切换 `VC_mode` 并 TTS 播报「单对话/长对话」;若正在对话先 `Close` |
+| 单对话 ↔ 长对话切换后 | 须重新 `Start` / 按键建会话;避免沿用上一种模式的 `cancel` / `audio.create` 假设 |
+
+> **说明**:产品需求已取消 AI 键双击;模式切换见上表 **Power 双击**(对应 REQ-PWR-05)。
+
+### DES-SCN-050 服务端事件与端侧动作对照(联调)
+
+
+| 服务端 `event` | 长对话典型端侧动作 | 单对话典型端侧动作 |
+| ------------------------------- | ------------------------ | ------------------------------------------------------- |
+| `started` | `uplink_start` 或等用户按键 | PTT:`audio.create`+开麦(若 `_ptt_force_record_on_started`) |
+| `vad.silence` | `uplink_stop`,停上行 | 一般不走(VAD 关) |
+| `asr.started` / `asr.completed` | 更新 FSM,可能提示音 | 松手后 ASR |
+| `llm.created` | `turn=Thinking`,蓝绿交替 | 同左 |
+| `tts.started` | 停上行、`play_start`、绿常亮 | 同左 |
+| `tts.completed` | 标记服务端下发结束;本地可能仍播 | 同左 |
+| `canceled` | `uplink_start` 自动开麦(长对话) | **不**自动开麦(`VC_mode≠0` 门控) |
+| `completed` | 无 TTS 时可能 `uplink_start` | 不自动开麦 |
+| `config.update` | `uplink_stop` | `uplink_stop` |
+
+
+本地 `**audio.played`**:长对话播完上报后门控下一轮;单对话以用户 **长按** 开始下一轮为主。
+
+
--- /dev/null
+# AI吊坠(双按键+三色指示灯)详设 — 对照与决策
+
+> **需求文档**:[交互规范_new](./AI吊坠(双按键+三色指示灯)交互规范_new.md)(产品行为准绳)
+> **场景详设**:[详设-场景与协议流程](./AI吊坠(双按键+三色指示灯)详设-场景与协议流程.md)(按键/协议步骤展开)
+> **实现真源**:`2.sdk/Helios_SDK/services/microPython/ports/quectel/core/ai_VC.py`(`AI_led`、`AI_hw_status`、`AI_dev`)
+
+---
+
+## 0. 如何使用本文档
+
+### 0.1 三角对照
+
+
+| 角 | 文档 | 回答的问题 |
+| ------ | ------------------------------ | -------- |
+| **需求** | `交互规范_new.md` + `REQ-`* | 用户要什么? |
+| **设计** | 本文第 2 章 `DES-`*、场景 `DES-SCN-`* | 固件应怎么做? |
+| **实现** | `ai_VC.py` 等 | 代码现在怎么做? |
+
+
+评审流程:**第 3 章矩阵** 标 `状态` → 不一致项查 **第 4 章 GAP** → 在 **决策** 列填 `采纳甲` / `采纳乙` / `搁置` / `改需求` → 再改代码或改需求,避免三方漂移。
+
+### 0.2 准绳优先级
+
+1. 你已勾选的 GAP **决策**(最高)
+2. 需求文档(产品对外承诺)
+3. 本文设计规格(工程应然)
+4. 当前代码(现状,直至按决策修改)
+
+### 0.3 要点摘要(评审速览)
+
+- **长对话**:短按打断 TTS / **思考中** → `RequestToSpeak` → `VC_init` → `cancel` → `canceled` → `uplink_start`(见 `DES-SCN-010.2`~`010.4`、`DES-SCN-011`)。
+- **单对话**:长按开录 / 松手 `audio.completed`;TTS 中短按仅停播,续聊须再长按(见 `DES-SCN-020.6`)。
+- **待机灯**:正常待机 **灭灯**(`LED_IDLE_MODE=off`,GAP-005 方案甲)。
+- **电量阈值**:低电 `soc<30%`、空电 `soc<10%`(GAP-007 方案甲)。
+- **异常灯**:`set_led_state.alert=rb_flash_2` 红蓝快闪 2 次后回表(GAP-006 已落实 ASR 空结果、thinking 超时)。
+
+---
+
+## 1. 系统架构(一页)
+
+```mermaid
+flowchart TB
+ gpio[GPIO_btn_device]
+ dev[AI_dev_去抖串行]
+ vc[AI_VC_按键与VCStateMachine]
+ ws[WebSocket_V2_5]
+ aud[录音播放队列]
+ led[AI_led]
+ hw[AI_hw_status]
+ gpio -->|EventMesh| dev
+ dev -->|AI_key_press等| vc
+ vc --> ws
+ vc --> aud
+ vc -->|set_led_state| led
+ hw -->|set_led_state| led
+```
+
+
+
+### 1.1 模块职责
+
+
+| 模块 | 文件 | 职责 |
+| ---------------- | ---------- | ------------------------------------------ |
+| `AI_dev` | `ai_VC.py` | 按键 ExtInt、去抖、`publish_AI_btnInfo` 串行执行 |
+| `AI_VC` | `ai_VC.py` | WebSocket 会话、按键路由、音频上下行 |
+| `VCStateMachine` | `ai_VC.py` | `session` / `turn` / `tts_rx_phase` 唯一轮次真源 |
+| `AI_hw_status` | `ai_VC.py` | 10s 轮询 + `hw_usb_state` → 充电/电量/信号 |
+| `AI_led` | `ai_VC.py` | `set_led_state` 聚合 → GPIO 灯效 |
+
+
+### 1.2 EventMesh 主题(常用)
+
+
+| 主题 | 发布方 | 订阅方 | 说明 |
+| ------------------------------------------------------- | ---------------------- | -------------- | ------------------- |
+| `AI_key_press` / `AI_key_long_press` / `AI_key_release` | `AI_dev` | `AI_VC` | AI 键 |
+| `pwk_key_double_press` | 电源键模块 | `AI_VC` | Power 双击切 `VC_mode` |
+| `set_led_state` | `AI_VC`、`AI_hw_status` | `AI_led` | LED 唯一入口 |
+| `hw_usb_state` | 底层 | `AI_hw_status` | USB 插拔即时刷新 |
+| `get_led_debug_state` | 调试 | `AI_led` | 返回聚合灯态 |
+
+
+### 1.3 设计原则(文档化)
+
+- 轮次/会话判断只读 `fsm.*`,不引入并行整型状态。
+- 打断 TTS:**先** `VC_init`(本地停播清队列)**再** `cancel` / `audio.create`(见 `DES-SCN-003`)。
+- LED 只经 `set_led_state`;AI 阶段灯优先于充电/电量(见 `DES-L3-01`)。
+
+---
+
+## 2. 设计规格(按层)
+
+### 2.1 L1 输入与按键(`DES-L1`)
+
+
+| ID | 规格 | 实现锚点 |
+| --------- | ----------------------------------------- | ------------------------------------------------ |
+| DES-L1-01 | 长对话:AI **短按** 唤醒/打断/续聊 | `AI_key_press` → `_ai_key_press_long_dialogue_`* |
+| DES-L1-02 | 单对话:AI **长按** 开录、**松开** `audio.completed` | `ptt_begin`、`_ai_key_release_handle_ptt` |
+| DES-L1-03 | 单对话:TTS 中 **短按** 仅停播,不开录 | `_ai_key_press_ptt_mode` |
+| DES-L1-04 | Power **双击** 切换 `VC_mode` 并 TTS 播报 | `pwk_key_double_press` |
+| DES-L1-05 | Power 交互中 **短按** `Close` 断会话进待机 | `pwk_key_press` 等 |
+
+
+### 2.2 L2 会话与协议(`DES-L2`)
+
+
+| ID | 规格 | 实现锚点 |
+| --------- | ------------------------------------------------------- | ------------------------------------------ |
+| DES-L2-01 | `VC_mode=0`:VAD 开,一般无 `audio.create`/`completed` | `VCMode.Realtime` |
+| DES-L2-02 | `VC_mode=1`:VAD 关,必须 `audio.create`→帧→`audio.completed` | `VCMode.PushToTalk` |
+| DES-L2-03 | 会话层:`Disconnected`→`Connecting`→`Starting`→`Started` | `VCSessionState` |
+| DES-L2-04 | 轮次层:`Idle`→`Listening`→…→`Playing`→续轮 | `VCTurnState`、`VCStateMachine` |
+| DES-L2-05 | 长对话打断 TTS:`cancel`→`canceled`→`uplink_start` | `RequestToSpeak`、`on_server_canceled` |
+| DES-L2-06 | 长对话播完门控:`audio.played` 后可自动开麦 | `fsm.await_played`、`on_local_audio_played` |
+
+
+步骤级展开见 [场景详设](./AI吊坠(双按键+三色指示灯)详设-场景与协议流程.md)。
+
+### 2.3 L3 呈现:灯 / 音 / 硬件态(`DES-L3`)
+
+#### DES-L3-01 LED 优先级(`AI_led._apply_by_table`)
+
+
+| 优先级 | 输入 | 取值 | 灯效 | `period_ms` |
+| --- | ---------- | ------------- | ----- | ------------------------- |
+| P1 | `ai_state` | `playing` | 绿常亮 | — |
+| P2 | `ai_state` | `thinking` | 蓝绿交替 | 500 |
+| P3 | `ai_state` | `listening` | 蓝慢闪 | 1000 |
+| B1 | `charging` | `full` | 绿常亮 | — |
+| B2 | `charging` | `charging` | 红常亮 | — |
+| B3 | `battery` | `low`/`empty` | 红慢闪 | 1000 |
+| B4 | `signal` | `weak` | 蓝绿慢交替 | 1000 |
+| 默认 | — | `idle` | **灭灯** | `LED_IDLE_MODE=off`(可选 `green_blink`) |
+| P0 | `alert` | `rb_flash_2` | 红蓝快闪 2 次 | `250`(约 2 次/秒)后回表 |
+
+
+`set_led_state` 键:`ai_state` / `charging` / `battery` / `signal` / `alert`。
+
+#### DES-L3-02 硬件态采样(`AI_hw_status`)
+
+
+| USB | SoC(非充电) | `charging` | `battery` |
+| --- | -------- | ----------------- | ---------- |
+| 否 | ≥30% | `none` | `normal` |
+| 否 | 10%~30% | `none` | `low` |
+| 否 | <10% | `none` | `empty` |
+| 是 | — | `charging`/`full` | 不按 SoC 刷低电 |
+
+
+弱网:`0 ≤ CSQ < SIGNAL_WEAK_CSQ`(默认 10)→ `signal=weak`。
+
+#### DES-L3-03 灯与 FSM 对应
+
+
+| `ai_state` | 典型触发 |
+| ----------- | ------------------- |
+| `listening` | 开麦、续聊 |
+| `thinking` | `llm.created` |
+| `playing` | `tts.started` / 本地播 |
+| `idle` | 交互结束、异常恢复 |
+
+
+---
+
+## 3. 需求 — 设计 — 实现对照矩阵
+
+
+| REQ-ID | 需求摘要 | 设计规格 | 实现锚点 | 状态 | GAP-ID |
+| ----------- | ------------------------- | ----------------------- | ---------------------------- | ----- | ------- |
+| REQ-PWR-01 | 关机时长按 Power 3s 开机,绿灯常亮 3s | (电源模块) | 电源键 / `ui` | 需求未定义 | — |
+| REQ-PWR-02 | 死机长按 8s 强制关机,无灯无音 | (电源模块) | 电源键 | 需求未定义 | — |
+| REQ-PWR-03 | 待机短按 Power 进休眠,无灯 | DES-L1-05 | `pm.autosleep` 等 | 部分一致 | — |
+| REQ-PWR-04 | AI 交互中短按 Power 停对话进待机,绿慢闪 | DES-L1-05 | `Close` | 部分一致 | — |
+| REQ-PWR-05 | Power 双击切换单/长对话并播报 | DES-L1-04 | `pwk_key_double_press` | 一致 | — |
+| REQ-AI-01 | 长对话待机短按:蓝慢闪+滴,开麦 | DES-L1-01、DES-SCN-010.5 | `_ai_key_press_long_dialogue_wake`→`AI_VC_start` | 一致 | GAP-002 |
+| REQ-AI-02 | 长对话聆听中短按:重开麦 | DES-SCN-010.3 | `request_uplink_start` | 一致 | — |
+| REQ-AI-03 | 长对话 TTS 中短按:打断并聆听 | DES-SCN-010.2 | `_ai_key_press_long_dialogue_interrupt` | 一致 | — |
+| REQ-AI-04 | 长对话思考中短按:打断并聆听 | DES-SCN-010.4 | `_ai_key_press_long_dialogue_interrupt` | 一致 | GAP-001 |
+| REQ-AI-05 | 单对话 TTS 中短按:仅停播 | DES-L1-03、DES-SCN-020.3 | `_ai_key_press_ptt_mode` | 一致 | — |
+| REQ-AI-06 | 单对话非 TTS 短按:无操作 | DES-L1-03 | `return True` 吞掉 | 一致 | GAP-003 |
+| REQ-AI-07 | 单对话长按:蓝快闪+开录音 | DES-L1-02、DES-SCN-020.2 | `ptt_begin` | 一致 | — |
+| REQ-AI-08 | 单对话松手:思考灯+层音+completed | DES-L1-02 | `AI_client_VC_end_speech` | 一致 | — |
+| REQ-BAT-01 | 正常待机灯灭 | DES-L3-01 默认 | `_set_off`(`LED_IDLE_MODE=off`) | 一致 | GAP-005 |
+| REQ-BAT-02 | 低电 10%~30% 红慢闪+双滴 | DES-L3-02 | `battery=low`(SoC<30%) | 一致 | GAP-007 |
+| REQ-BAT-03 | 电量<10% 红快闪 3 次后关机 | DES-L3-02 | `battery=empty`(SoC<10%) | 一致 | GAP-007 |
+| REQ-BAT-04 | 充电中红常亮+滴 | DES-L3-02 | `charging=charging` | 一致 | GAP-008 |
+| REQ-BAT-05 | 充满红→绿常亮+长滴 | DES-L3-02 | `charging=full` | 一致 | — |
+| REQ-ERR-01 | ASR 失败:红蓝快闪 2 次→待机灭 | DES-L3 P0 `alert` | `_vc_led_alert_rb_flash`(asr 空) | 一致 | GAP-006 |
+| REQ-ERR-02 | 网络异常:蓝绿慢交替+语音循环 | DES-L3-02 B4 | `websocket_error`、`bg_alt` | 部分一致 | GAP-006 |
+| REQ-ERR-03 | AI 超时:蓝常亮→红蓝闪→灭 | DES-L3 P0 `alert` | `thinking_timeout`→`alert` | 部分一致 | GAP-006 |
+| REQ-NOTE-01 | 备注:会话空闲后进入休眠前灯态 | DES-L3 默认灭灯;可选绿闪 | `LED_IDLE_MODE` | 已对齐 part3 | GAP-005 |
+| REQ-NOTE-02 | 备注:聆听蓝闪、思考交替、播放绿闪 | DES-L3-01 P2/P3/P1 | `set_led_state` | 部分一致 | — |
+| REQ-NOTE-03 | 休眠无灯光 | (休眠策略) | `ui.py` / `pm` | 部分一致 | — |
+| REQ-SUP-01 | 充电中 AI 交互优先 AI 灯 | DES-L3-01 | `_apply_by_table` | 一致 | GAP-008 |
+| REQ-SUP-02 | 按键后灯音延迟≤100ms | DES-SCN-003 | `VC_init` 优先 | 一致 | — |
+
+
+---
+
+## 4. 差异登记簿(GAP)
+
+> **决策**列请产品/固件填写:`采纳甲` | `采纳乙` | `搁置` | `改需求`
+
+
+| GAP-ID | 需求 | 设计目标 | 实现现状 | 风险 | 方案甲:架构精简·安全·实用 | 方案乙:改动最小 | 决策 | 影响面 |
+| ------- | --------------------- | ------------------ | ---------------------------------------------- | --------- | ----------------------------------------------- | -------------------------------------- | -------------------------------- | -------------------------------------- |
+| GAP-001 | REQ-AI-04 | 思考中短按统一打断并开麦 | 仅 TTS 忙走 `cancel`;纯 `Thinking` 可能 `Deny START` | 用户按了没反应 | `turn==Thinking` 走 `RequestToSpeak`→`cancel`→开麦 | 详设标注限制;或单行门控热修 | **采纳甲**(已改代码) | `AI_key_press`、`_ai_key_press_long_dialogue_interrupt` |
+| GAP-002 | REQ-AI-01 | 待机短按直达唤醒 | 兜底 `Close`+`AI_VC_start`+`sleep(500)` | 唤醒慢/偶发断链感 | 去掉兜底 `Close`;未连接单一路径 `AI_VC_start` | 保留现状,联调看 `[KEY]` | **采纳甲**(已改代码) | `AI_key_press`、`_ai_key_press_long_dialogue_wake` |
+| GAP-003 | REQ-AI-06 | 单对话非 TTS 短按无操作 | 非播放态 `return True` 吞掉 | 低(防误触) | 保持为设计,REQ 已对齐 | 若需短按开录:改 `_ai_key_press_ptt_mode`+改需求表 | **采纳甲** | `_ai_key_press_ptt_mode` |
+| GAP-005 | REQ-BAT-01 vs NOTE-01 | part3 灯灭 vs 备注绿灯慢闪 | idle 默认 `green_blink` | 产品表述冲突 | `AI_led` idle 灭灯 | **改需求** part3 为绿灯慢闪 | **采纳甲**(`LED_IDLE_MODE=off`) | `AI_led._apply_by_table` |
+| GAP-006 | REQ-ERR-01~03 | 红蓝快闪等专用灯序 | 无 `alert` 模式;TTS/弱网替代 | 异常辨识度弱 | 新增 `alert` 键+优先级+业务触发 | 需求降级为语音+弱网灯;接受现状 | **采纳甲**(ASR 空、thinking 超时已接 alert) | `AI_led`、`ai_VC` 异常分支 |
+| GAP-007 | REQ-BAT-02~03 | 10%/30% 阈值 | SoC 5%/20% 工程档 | 提醒时机偏差 | `AI_hw_status` 阈值可配置并对齐需求 | 需求脚注「以 OCV/工程档为准」 | **采纳甲**(30%/10%) | `default_config`、`AI_hw_status` |
+| GAP-008 | REQ-BAT-04、SUP-01 | 充电红常亮 vs AI 覆盖 | AI 非 idle 盖住充电灯 | 充电中不易见红常亮 | 保持 AI 优先(交互反馈优先) | 充电中插入充电指示(需产品确认) | **采纳甲** | `AI_led` 优先级 |
+
+
+### 4.1 整体姿态对比(选默认路线)
+
+
+| 维度 | 姿态 A:架构精简·安全·实用 | 姿态 B:改动最小 |
+| ------ | -------------------- | ------------------ |
+| 目标 | 需求↔实现长期一致 | 冻结现状,文档吸收差异 |
+| LED/异常 | 倾向 GAP-005/006 **甲** | 倾向 **乙**(改需求或接受现状) |
+| 按键/FSM | 倾向 GAP-001/002 **甲** | 倾向 **乙**(标注已知限制) |
+| 代码工作量 | 中~高 | 低(0~2 处热修) |
+
+
+**建议**:先填齐上表 **决策** 列,再批量改需求或 `ai_VC.py`。
+
+### 4.2 GAP-001:为何 `RequestToSpeak` 必须先 `cancel` 再开麦
+
+长对话在 `turn==Thinking` 或 TTS 播放中,若直接 `uplink_start`,`_uplink_request_start` 会因 `turn` 非 `Listening/SpeechEnded` 而 **Deny START**;若先开麦再 `cancel`,本地/服务端旧轮下行可能与新一轮上行竞态。
+
+**方案甲时序(与 DES-SCN-010.2/010.4 一致)**:
+
+1. 短按 → `AI_client_VC_RequestToSpeak`:`VC_init` 停播清队列 + WebSocket 发 `cancel`。
+2. 服务端 `server.canceled` → `on_server_canceled` 将 `turn` 置为 `Listening`。
+3. `on_server_event("canceled")` 追加 `uplink_start` → 开麦、蓝慢闪。
+
+因此「cancel 在后」是指 **开麦动作在 canceled 事件之后**,而非不发 cancel。
+
+---
+
+## 5. 联调索引
+
+### 5.1 检查清单(原 E8)
+
+
+| # | 检查项 | 长对话 | 单对话 |
+| --- | ---------------------- | ----------------------------------- | ---------------------- |
+| 1 | TTS 中按键后 500ms 内无残余播报 | 短按+cancel | 长按 `ptt_begin` |
+| 2 | `AI_audio_queue` 深度为 0 | `VC_init` 后 `[VC] init audio_queue` | 同左 |
+| 3 | 开录前已 `started` | 是 | 否则 `audio.create` SKIP |
+| 4 | 开录提示音不叠按键音 | 短按提示音已关 | `ptt_begin` 内不叠短嘟 |
+| 5 | 打断后能快速第二轮 | `canceled`→开麦 | 松手→播完→再长按 |
+| 6 | 串口日志 | `[KEY]`、`[FSM]`、`[VC][proto]` | 同左 |
+
+
+### 5.2 日志关键字(原 E12)
+
+
+| 日志 | 含义 |
+| ------------------------------------------------ | ------------------------- |
+| `[KEY] AI_key_press VC_mode=` | 按键模式与 FSM 快照 |
+| `[PTT] cancel before new uplink` | PTT 长按前已发 cancel |
+| `[PTT] wait server.started` | 等 started 后再 audio.create |
+| `[VC][proto] audio.create send` / `SKIP` | PTT 开轮是否成功 |
+| `[VC][uplink_ctrl] Request START` / `Deny START` | 长对话开麦门控 |
+| `[FSM] server.canceled` | 打断成功,长对话可续麦 |
+| `[VC] init audio_queue` | 清播放队列成功 |
+
+
+### 5.3 调试 API
+
+`EventMesh.publish("get_led_debug_state")` → `{ ai, charging, battery, signal, mode, period_ms }`
+
+---
+
+## 6. 引用与场景索引
+
+
+| 文档 | 路径 |
+| -------- | ------------------------------------------------------------------- |
+| 需求规范 | [交互规范_new.md](./AI吊坠(双按键+三色指示灯)交互规范_new.md) |
+| 场景详设 | [详设-场景与协议流程.md](./AI吊坠(双按键+三色指示灯)详设-场景与协议流程.md) |
+| 源码 | `2.sdk/Helios_SDK/services/microPython/ports/quectel/core/ai_VC.py` |
+| 旧附录(已迁移) | [交互规范_附录-工程与代码审查.md](./AI吊坠(双按键+三色指示灯)交互规范_附录-工程与代码审查.md) |
+
+
+### 6.1 场景详设索引(`DES-SCN-`*)
+
+
+| ID | 内容 |
+| ----------- | --------------------------- |
+| DES-SCN-000 | 场景速查与索引 |
+| DES-SCN-001 | 模式总览 |
+| DES-SCN-002 | 协议消息语义 |
+| DES-SCN-003 | 打断 TTS 本地清缓存顺序 |
+| DES-SCN-010 | 长对话 AI 键(含 010.2~010.7 子场景) |
+| DES-SCN-011 | 长对话打断 TTS 时序图 |
+| DES-SCN-020 | 单对话 PTT(含 020.2~020.8) |
+| DES-SCN-030 | 与 Power 键、模式切换边界 |
+| DES-SCN-050 | 服务端事件对照 |
+
+
self._st_charging = "none" # none/charging/full
self._st_battery = "normal" # normal/low/empty
self._st_signal = "normal" # normal/weak
+ self._st_alert = None # None / rb_flash_2(REQ-ERR-01/03)
self._timer = osTimer()
- self._mode = "off" # off/red_on/green_on/green_blink/blue_blink/red_blink/bg_alt
+ self._mode = "off" # off/red_on/green_on/green_blink/blue_blink/red_blink/bg_alt/alert_rb
+ self._alert_steps_left = 0
+ self._alert_rb_phase = 0
+ self._alert_period_ms = 250
self._blink_on = 0
self._bg_phase = 0
self._period_ms = 1000
self._set_rgb(0, 0, 1)
self._timer.start(self._period_ms, 1, self._timer_cb)
+ def _set_alert_rb_flash(self, cycles=2, period_ms=250):
+ """REQ-ERR-01/03:红蓝快闪(约 2 次/秒),结束后回到 _apply_by_table。"""
+ try:
+ cycles = int(cycles or 2)
+ except Exception:
+ cycles = 2
+ if cycles < 1:
+ cycles = 1
+ try:
+ period_ms = int(period_ms or 250)
+ except Exception:
+ period_ms = 250
+ if period_ms < 100:
+ period_ms = 100
+ self._mode = "alert_rb"
+ self._st_alert = "rb_flash_2"
+ self._alert_period_ms = period_ms
+ self._alert_steps_left = cycles * 2
+ self._alert_rb_phase = 0
+ self._timer_stop()
+ self._set_rgb(1, 0, 0)
+ self._timer.start(self._alert_period_ms, 0, self._timer_cb)
+
def _timer_cb(self, *args, **kwargs):
EventMesh.publish("publish_AI_btnInfo", {"func": self._timer_cb_})
self._set_rgb(0, 0, 1)
self._bg_phase = 0
return
+ if self._mode == "alert_rb":
+ self._alert_steps_left -= 1
+ if self._alert_steps_left <= 0:
+ self._st_alert = None
+ try:
+ self._apply_by_table()
+ except Exception:
+ self._set_off()
+ return
+ if self._alert_rb_phase == 0:
+ self._set_rgb(0, 0, 1)
+ self._alert_rb_phase = 1
+ else:
+ self._set_rgb(1, 0, 0)
+ self._alert_rb_phase = 0
+ try:
+ self._timer.start(self._alert_period_ms, 0, self._timer_cb)
+ except Exception:
+ pass
+ return
def set_led_state(self, event=None, msg=None):
- # msg: dict {ai_state, charging, battery, signal}
+ # msg: dict {ai_state, charging, battery, signal, alert}
try:
if isinstance(msg, dict):
+ if "alert" in msg:
+ aval = msg.get("alert")
+ if aval in ("rb_flash_2", "rb_flash", "rb_flash2"):
+ self._set_alert_rb_flash(2, 250)
+ return
+ if aval in (None, "", "none", "clear"):
+ self._st_alert = None
if "ai_state" in msg:
self._st_ai = msg.get("ai_state") or "idle"
if "charging" in msg:
self._st_signal = msg.get("signal") or "normal"
except Exception:
pass
+ if self._mode == "alert_rb":
+ return
+ if self._led_state_debug_enabled() and isinstance(msg, dict):
+ try:
+ print("[LED] set_led_state in={}".format(msg))
+ except Exception:
+ pass
self._apply_by_table(msg)
+ def _led_state_debug_enabled(self):
+ try:
+ if "vc_config" in globals() and vc_config:
+ return vc_config.get_bool("LED_STATE_DEBUG", True)
+ except Exception:
+ pass
+ return True
+
+ def _led_log(self, tag, extra=""):
+ if not self._led_state_debug_enabled():
+ return
+ try:
+ print("[LED] {} ai={} chg={} bat={} sig={} -> mode={} period_ms={} {}".format(
+ tag, self._st_ai, self._st_charging, self._st_battery, self._st_signal,
+ self._mode, self._period_ms, extra))
+ except Exception:
+ pass
+
def _apply_by_table(self, _msg=None):
+ if self._mode == "alert_rb":
+ return
+ _prev_mode = self._mode
# P1~P3:AI 阶段(播放 > 思考 > 聆听)
try:
if self._st_ai == "playing":
self._set_bg_alt(500)
return
if self._st_ai == "listening":
- # 「慢闪」:整颗亮灭整周期约 2×period_ms → period_ms=1000 约 2 s 一亮灭
- self._set_blue_blink(1000)
+ # 长对话蓝慢闪(≈1s);单对话 PTT 蓝快闪(≈2次/s,REQ-AI-07)
+ period_ms = 1000
+ try:
+ if EventMesh.publish("get_storage_config", "VC_mode") == 1:
+ period_ms = 250
+ except Exception:
+ pass
+ self._set_blue_blink(period_ms)
return
except Exception:
pass
return
except Exception:
pass
- # 空闲待机:绿灯慢闪(亮 LED_IDLE_GREEN_BLINK_MS / 灭 LED_IDLE_GREEN_BLINK_OFF_MS;未配 OFF 则与亮时间对称)
+ # 空闲待机:默认灭灯(REQ-BAT-01 / GAP-005 方案甲);LED_IDLE_MODE=green_blink 可恢复旧工程绿灯慢闪
try:
- idle_on = 1000
+ idle_mode = "off"
if "vc_config" in globals() and vc_config:
- idle_on = int(vc_config.get_int("LED_IDLE_GREEN_BLINK_MS", 1000))
- idle_off = idle_on
- if "vc_config" in globals() and vc_config:
- _ioff = vc_config.get_attr("LED_IDLE_GREEN_BLINK_OFF_MS", None)
- if _ioff is not None:
- try:
- idle_off = int(_ioff)
- except Exception:
- idle_off = idle_on
- if idle_on < 400:
- idle_on = 400
- if idle_off < 400:
- idle_off = 400
- self._set_green_blink(idle_on, idle_off)
+ _im = vc_config.get_attr("LED_IDLE_MODE", None)
+ if _im is not None:
+ idle_mode = str(_im).strip().lower()
+ if idle_mode in ("green_blink", "blink", "green"):
+ idle_on = 1000
+ if "vc_config" in globals() and vc_config:
+ idle_on = int(vc_config.get_int("LED_IDLE_GREEN_BLINK_MS", 1000))
+ idle_off = idle_on
+ if "vc_config" in globals() and vc_config:
+ _ioff = vc_config.get_attr("LED_IDLE_GREEN_BLINK_OFF_MS", None)
+ if _ioff is not None:
+ try:
+ idle_off = int(_ioff)
+ except Exception:
+ idle_off = idle_on
+ if idle_on < 400:
+ idle_on = 400
+ if idle_off < 400:
+ idle_off = 400
+ self._set_green_blink(idle_on, idle_off)
+ else:
+ self._set_off()
except Exception:
- self._set_green_blink(1000, 2000)
+ self._set_off()
+ if self._mode != _prev_mode:
+ self._led_log("apply_table", "in={}".format(_msg))
def get_led_debug_state(self, event=None, msg=None):
# 供按键/状态机打印当前 LED 聚合状态与最终模式
self.charge_full_mv = int(vc_config.get_int("VBATT_CHARGE_FULL_MV", 4290))
self.low_mv = int(vc_config.get_int("VBATT_LOW_MV", 3699))
self.empty_mv = int(vc_config.get_int("VBATT_EMPTY_MV", 3629))
- self.low_soc = int(vc_config.get_int("BATTERY_LOW_SOC_PERCENT", 20))
- self.empty_soc = int(vc_config.get_int("BATTERY_EMPTY_SOC_PERCENT", 5))
+ self.low_soc = int(vc_config.get_int("BATTERY_LOW_SOC_PERCENT", 30))
+ self.empty_soc = int(vc_config.get_int("BATTERY_EMPTY_SOC_PERCENT", 10))
_ocv_cfg = _parse_battery_ocv_map(vc_config.get_attr("BATTERY_OCV_MAP", None))
if _ocv_cfg:
self.ocv_map = _ocv_cfg
return int(soc)
def audio_montage_play(self,event,msg):
self.wakeup_BT()
- # 抢占规则:流播放(TTS)可打断本地;本地提示音不可打断流播放
+ # 仅服务端 TTS PCM 在播时跳过;FSM 处于 TtsReceiving 但尚未出声时不挡本地 montage
try:
- if EventMesh.publish("get_vc_stream_active") == 1:
- print("[audio-arb] skip local montage_play during stream")
+ if EventMesh.publish("get_vc_tts_playback_active") == 1:
+ print("[audio-arb] skip local montage_play during tts_pcm")
return
except Exception:
pass
class AI_VC(Abstract):
+ def get_vc_tts_playback_active(self, *args, **kwargs):
+ try:
+ return 1 if self._vc_tts_playback_active() else 0
+ except Exception:
+ return 0
+
def get_vc_stream_active(self, *args, **kwargs):
# 供本地提示音侧查询:TTS 流播放期间本地不可抢占。
try:
- if getattr(self, "VC_Play_flag", 0) == 1:
+ if self._vc_tts_playback_active():
return 1
except Exception:
pass
pass
return False
+ def _vc_tts_playback_active(self):
+ """仅服务端 TTS/PCM 流在播(VC_Play_Start);aud.play 本地短音不算。"""
+ try:
+ if getattr(self, "VC_Play_flag", 0) == 1:
+ return True
+ except Exception:
+ pass
+ try:
+ if getattr(self, "play_pcm", None) is not None:
+ return True
+ except Exception:
+ pass
+ return False
+
def _vc_vad_uplink_stop_eligible(self):
# 对应旧 VC_state >= SpeechStart
try:
except Exception:
pass
try:
- EventMesh.publish("set_led_state", {"ai_state": "idle"})
+ self._vc_publish_led("idle", "ptt_short_press/stop_tts")
except Exception:
pass
return True
pass
return True
- def _ai_key_press_long_dialogue_tts_busy(self):
- """长对话:播放态短按打断续聊(RequestToSpeak)。"""
- if not self._vc_tts_pipeline_busy():
+ def _ai_key_press_long_dialogue_interrupt(self):
+ """长对话:TTS/播放忙或 turn==Thinking 时短按 → RequestToSpeak(先 cancel,canceled 后再开麦)。"""
+ if self.VC_mode != 0:
return False
- print("【打断播放】设备正在说话,仅中断播放,不发起新对话")
+ try:
+ if getattr(self, "AI_client_VC_isconnect", 0) != 1 or getattr(self, "_vc_session_started", 0) != 1:
+ return False
+ except Exception:
+ return False
+ need_cancel = False
+ if self._vc_tts_pipeline_busy():
+ need_cancel = True
+ else:
+ try:
+ if getattr(self.fsm, "turn", VCTurnState.Idle) == VCTurnState.Thinking:
+ need_cancel = True
+ except Exception:
+ pass
+ if not need_cancel:
+ return False
+ print("[KEY] long interrupt -> RequestToSpeak (cancel then uplink)")
self.is_ruppt_tts = 1
EventMesh.publish("publish_AI_btnInfo", {"func": self.AI_client_VC_RequestToSpeak})
return True
+ def _ai_key_press_long_dialogue_wake(self):
+ """长对话未连接或未 started:直接 AI_VC_start,勿 Close+sleep。"""
+ if self.VC_mode != 0:
+ return False
+ if getattr(self, "voice_flag", 0) == 1 or getattr(self, "meet_flag", 0) == 1 or getattr(self, "trans_flag", 0) == 1:
+ return False
+ try:
+ if getattr(self, "AI_client_VC_isconnect", 0) == 1 and getattr(self, "_vc_session_started", 0) == 1:
+ return False
+ except Exception:
+ pass
+ print("[KEY] long wake AI_VC_start (no Close)")
+ try:
+ self.Close_flag = 0
+ except Exception:
+ pass
+ self.get_config_timer_count = 0
+ self.key_state = 1
+ self.AI_TTS_str = ""
+ self.AI_ASR_str = ""
+ self.AI_ASR_str_tmp = ""
+ self.AI_VC_start()
+ self.AI_key_long_press_flag = 0
+ return True
+
+ def _vc_led_alert_rb_flash(self):
+ try:
+ EventMesh.publish("set_led_state", {"alert": "rb_flash_2"})
+ except Exception:
+ pass
+
def _vc_clear_stale_record_state(self, reason=""):
"""record_state_flag 非 0 但可能无真实录音时先停录复位,避免 uplink 门控拒开麦。"""
try:
return False
try:
if getattr(self, "AI_client_VC_isconnect", 0) == 1 and getattr(self, "_vc_session_started", 0) == 1:
+ # 模式切换提示音会置 Close_flag=1;用户再短按须恢复可开麦
+ try:
+ self.Close_flag = 0
+ except Exception:
+ pass
self._vc_clear_stale_record_state("key.press/continue")
self.request_uplink_start("key.press/continue")
return True
"""单对话 PTT 松手:仅在上行录音中时 end_speech;禁止误触发 AI_VC_start(否则新 sid / cancel 竞态)。"""
if self.AI_key_long_press_flag == 1:
if getattr(self, "VC_record_state_flag", 0) != 0:
- print("AI_key_release:asr_str:{}".format(self.AI_ASR_str))
+ try:
+ gap = 200
+ if "vc_config" in globals() and vc_config:
+ gap = int(vc_config.get_int("PTT_RELEASE_END_SPEECH_DELAY_MS", 200))
+ if gap > 0:
+ utime.sleep_ms(gap)
+ except Exception:
+ pass
+ print("[PTT] release end_speech asr_str={!r} rec={}".format(
+ self.AI_ASR_str, getattr(self, "VC_record_state_flag", -1)))
self.AI_client_VC_end_speech()
else:
self.AI_key_long_press_flag = 0
# 语义:
# - 长按:无论当前处于 TTS/Thinking/任何中间态,立即打断并进入新一轮录音上行
# - 松开:停止录音并等待服务端返回(由 AI_client_VC_end_speech 触发)
+ def _ptt_end_led_suppress(self):
+ try:
+ self._vc_play_end_suppress_idle_led = 0
+ except Exception:
+ pass
+
+ def _vc_enqueue_btn(self, func, data=None):
+ """统一经 publish_AI_btnInfo 入队,在 btnInfo_queue_thread 串行执行。"""
+ try:
+ if data is not None:
+ EventMesh.publish("publish_AI_btnInfo", {"func": func, "data": data})
+ else:
+ EventMesh.publish("publish_AI_btnInfo", {"func": func})
+ except Exception:
+ pass
+
+ def _vc_ptt_cancel_wait_ms(self):
+ try:
+ if "vc_config" in globals() and vc_config:
+ return int(vc_config.get_int("PTT_CANCEL_WAIT_MS", 2500))
+ except Exception:
+ pass
+ return 2500
+
+ def _vc_ptt_need_interrupt_wait(self):
+ """单对话:服务端仍在思考/收 TTS/播报时,开录前须先 cancel 并等 canceled。"""
+ try:
+ if getattr(self, "VC_mode", -1) != 1:
+ return False
+ turn = getattr(self.fsm, "turn", VCTurnState.Idle)
+ if self._vc_tts_pipeline_busy():
+ return True
+ if turn in (
+ VCTurnState.Thinking, VCTurnState.TtsReceiving,
+ VCTurnState.Playing, VCTurnState.AwaitPlayedAck,
+ ):
+ return True
+ except Exception:
+ pass
+ return False
+
+ def _vc_begin_ptt_cancel_wait(self, reason="ptt_interrupt"):
+ try:
+ self._ptt_pending_arm = 1
+ self._vc_serial_canceling = 1
+ try:
+ self._vc_cancel_deadline_ms = utime.ticks_add(
+ utime.ticks_ms(), self._vc_ptt_cancel_wait_ms())
+ except Exception:
+ self._vc_cancel_deadline_ms = 0
+ json_data = {"event": "cancel", "payload": {}}
+ self.AI_client_VC_send(ujson.dumps(json_data))
+ try:
+ self.fsm.reset_rx_phases()
+ except Exception:
+ pass
+ self._drop_tts_audio = 1
+ print("[VC][serial] cancel_wait reason={} turn={}".format(
+ reason, getattr(getattr(self, "fsm", None), "turn", "?")))
+ except Exception:
+ pass
+
+ def _vc_on_ptt_cancel_settled(self, via="canceled"):
+ """btn 线程:cancel 已收尾,执行挂起的 PTT 开录。"""
+ try:
+ if not getattr(self, "_vc_serial_canceling", 0) and not getattr(self, "_ptt_pending_arm", 0):
+ return
+ self._vc_serial_canceling = 0
+ self._vc_cancel_deadline_ms = 0
+ if getattr(self, "_ptt_pending_arm", 0):
+ self._ptt_pending_arm = 0
+ print("[VC][serial] ptt_arm after {}".format(via))
+ self._ptt_arm_uplink()
+ except Exception:
+ pass
+
+ def _vc_tick_serial_cancel_timeout(self):
+ if not getattr(self, "_vc_serial_canceling", 0):
+ return
+ try:
+ dl = getattr(self, "_vc_cancel_deadline_ms", 0)
+ if dl and utime.ticks_diff(utime.ticks_ms(), dl) >= 0:
+ print("[VC][serial] cancel_wait timeout -> arm")
+ self._vc_on_ptt_cancel_settled(via="timeout")
+ except Exception:
+ pass
+
+ def _vc_fsm_dispatch_cb(self, data):
+ """btn 线程:处理服务端 FSM 事件(单对话串行)。"""
+ try:
+ ev, jsload = data
+ except Exception:
+ return
+ try:
+ actions = self.fsm.on_server_event(ev, jsload)
+ self._fsm_apply_actions(actions)
+ except Exception as e:
+ usys.print_exception(e)
+ if ev == "canceled":
+ self._vc_on_ptt_cancel_settled(via="canceled")
+
+ def _vc_dispatch_server_event(self, ev, jsload_data=None):
+ if getattr(self, "VC_mode", -1) == 1:
+ self._vc_enqueue_btn(self._vc_fsm_dispatch_cb, (ev, jsload_data))
+ else:
+ try:
+ self._fsm_apply_actions(self.fsm.on_server_event(ev, jsload_data))
+ except Exception:
+ pass
+
+ def _ptt_arm_uplink(self, t0=0):
+ """btn 线程:cancel 已结束或无需 cancel 时,发 audio.create 并开麦。"""
+ try:
+ self._ptt_audio_created = 0
+ except Exception:
+ pass
+ try:
+ self.AI_ASR_flag = 0
+ except Exception:
+ pass
+ try:
+ self.AI_client_VC_send_speech()
+ except Exception:
+ pass
+ try:
+ self.VC_Speech_Start_now()
+ except Exception:
+ pass
+ try:
+ self._vc_publish_led("listening", "ptt_begin/recording")
+ except Exception:
+ pass
+ self._ptt_end_led_suppress()
+ try:
+ if t0:
+ print("[PTT] record started (ms={})".format(utime.ticks_ms() - t0))
+ except Exception:
+ pass
+
def ptt_begin(self, reason="key.long_press"):
try:
if self.VC_mode != 1:
except Exception:
return
- # PTT 新一轮开始:清理“提示音播完退出”等标志,避免 Close_flag 残留影响当前回合
+ # PTT 新一轮开始:清理模式切换/提示音退出标志
try:
self.Close_flag = 0
+ self._ptt_await_server = 0
except Exception:
pass
# 打断上一段 TTS 后可能仍有残留音频帧下发:短时间丢弃,避免“断断续续继续播”
except Exception:
t0 = 0
+ # 先亮聆听灯,再停播;VC_init/VC_Play_End 全程抑制 idle,避免开录前灭灯
+ try:
+ self._vc_play_end_suppress_idle_led = 1
+ except Exception:
+ pass
+ try:
+ self._vc_publish_led("listening", "ptt_begin/pre")
+ except Exception:
+ pass
+
# 本地先停播并清空队列,避免“边播边录/回声触发/状态机乱跳”
try:
self.VC_init()
self.VC_Play_End()
except Exception:
pass
+ try:
+ self._vc_publish_led("listening", "ptt_begin/armed")
+ except Exception:
+ pass
# 若上一轮录音尚未结束,先强制停掉,避免 record_thread 复用出错
try:
except Exception:
pass
- # 服务端仍在出 TTS/思考:发 cancel 打断本轮回复(不换 sid),避免与新一轮 audio.create 竞态
+ # 已在等 canceled:只刷新「待开录」,勿重复 audio.create
try:
- turn = getattr(self.fsm, "turn", VCTurnState.Idle)
- if self._vc_tts_pipeline_busy() or turn in (
- VCTurnState.Thinking, VCTurnState.TtsReceiving, VCTurnState.Playing
- ):
- json_data = {"event": "cancel", "payload": {}}
- self.AI_client_VC_send(ujson.dumps(json_data))
- try:
- self.fsm.reset_rx_phases()
- except Exception:
- pass
- try:
- self._drop_tts_audio = 1
- except Exception:
- pass
- print("[PTT] cancel before new uplink turn={}".format(turn))
+ if getattr(self, "_vc_serial_canceling", 0):
+ self._ptt_pending_arm = 1
+ print("[PTT] cancel in progress, refresh pending arm")
+ self._ptt_end_led_suppress()
+ return
except Exception:
pass
if getattr(self, "AI_client_VC_isconnect", 0) != 1:
self._ptt_force_record_on_started = 1
self.AI_VC_start()
+ self._ptt_end_led_suppress()
return
except Exception:
self._ptt_force_record_on_started = 1
self.AI_VC_start()
except Exception:
pass
+ self._ptt_end_led_suppress()
return
# 会话还没 initialized:先发 Start,请求服务端进入 started
self.AI_client_VC_start_requset()
except Exception:
pass
+ self._ptt_end_led_suppress()
+ return
+
+ # 服务端仍在思考/TTS:先 cancel,在 btn 队列收到 canceled 后再开录(方案甲串行)
+ if self._vc_ptt_need_interrupt_wait():
+ self._vc_begin_ptt_cancel_wait("ptt_begin")
+ self._ptt_end_led_suppress()
return
- # 不打 cancel:保持当前会话 sid/dialog,仅本地停播后用 audio.create 开新一轮上行
- # (cancel 会导致服务端撤销本轮,与「打断 TTS 不换 sid」及电源键 Close 才换会话矛盾)
- # 开录提示音仅由 VC_Speech_Start/RECORD_START_TONE_FILE 播放,此处不再叠短嘟声以免冲突
+ self._ptt_arm_uplink(t0)
+
+ def _vc_led_state_debug_enabled(self):
try:
- self._ptt_audio_created = 0
+ if "vc_config" in globals() and vc_config:
+ return vc_config.get_bool("LED_STATE_DEBUG", True)
except Exception:
pass
+ return True
+
+ def _vc_led_should_standby_idle(self):
+ """未录音且非 TTS 播中:待机/提示音/模式切换/单对话轮间应灭灯(DES-L3 idle),勿被 FSM.turn=Listening 误导。"""
try:
- self.AI_ASR_flag = 0
+ if getattr(self, "VC_record_state_flag", 0) != 0:
+ return False
+ if self._vc_tts_playback_active():
+ return False
+ if getattr(self, "_ptt_await_server", 0) == 1:
+ return False
+ t = getattr(self.fsm, "turn", VCTurnState.Idle)
+ if t in (
+ VCTurnState.Thinking,
+ VCTurnState.TtsReceiving,
+ VCTurnState.Playing,
+ VCTurnState.AwaitPlayedAck,
+ ):
+ return False
+ if getattr(self, "Close_flag", 0) == 1:
+ return True
+ if getattr(self, "VC_mode", -1) == 1:
+ return True
+ k = getattr(self, "_last_tts_kind", "") or ""
+ if k in ("prompt", "boot_prompt", "mode_switch_long", "mode_switch_ptt"):
+ return True
except Exception:
pass
+ return False
+
+ def _vc_publish_led(self, ai_state, reason=""):
try:
- self.AI_client_VC_send_speech()
+ if self._vc_led_state_debug_enabled():
+ print("[LED] publish ai={} reason={} VC_mode={} rec={} turn={}".format(
+ ai_state, reason, getattr(self, "VC_mode", -1),
+ getattr(self, "VC_record_state_flag", -1),
+ getattr(getattr(self, "fsm", None), "turn", "?")))
except Exception:
pass
try:
- self.VC_Speech_Start_now()
+ EventMesh.publish("set_led_state", {"ai_state": ai_state})
except Exception:
pass
+
+ def _vc_led_sync_from_fsm(self):
+ """按 FSM/真实 TTS 播放恢复 AI 灯;本地 aud.play 短音不参与。"""
try:
- if t0:
- print("[PTT] record started (ms={})".format(utime.ticks_ms() - t0))
+ if self._vc_led_should_standby_idle():
+ self._vc_publish_led("idle", "fsm_sync/standby_idle")
+ return
+ except Exception:
+ pass
+ try:
+ if getattr(self, "VC_mode", -1) == 1 and getattr(self, "_ptt_await_server", 0) == 1:
+ self._vc_publish_led("thinking", "fsm_sync/ptt_await")
+ return
+ except Exception:
+ pass
+ try:
+ if self._vc_tts_playback_active():
+ self._vc_publish_led("playing", "fsm_sync/tts")
+ return
except Exception:
pass
+ try:
+ t = getattr(self.fsm, "turn", VCTurnState.Idle)
+ if t in (VCTurnState.Thinking, VCTurnState.TtsReceiving):
+ self._vc_publish_led("thinking", "fsm_sync/thinking")
+ elif t in (VCTurnState.Playing, VCTurnState.AwaitPlayedAck):
+ # 服务端已标记播放态但本地 PCM 尚未起播:仍显示思考,勿误绿灯
+ self._vc_publish_led("thinking", "fsm_sync/await_tts")
+ elif t in (VCTurnState.Listening, VCTurnState.SpeechEnded, VCTurnState.SpeechStreaming):
+ self._vc_publish_led("listening", "fsm_sync/listening")
+ else:
+ self._vc_publish_led("idle", "fsm_sync/idle")
+ except Exception:
+ try:
+ self._vc_publish_led("idle", "fsm_sync/fallback")
+ except Exception:
+ pass
def _uplink_request_start(self, reason="", *args, **kwargs):
# 统一入口:避免多处同时 start 造成冲突(尤其是 TTS/播放阶段误开麦导致回声触发与状态紊乱)
try:
if self.Close_flag == 1:
try:
- if reason in ("canceled", "local.play_end", "audio.played"):
+ if reason in ("canceled", "local.play_end", "audio.played", "key.press/continue"):
print("[VC][uplink_ctrl] Deny START by {} cause=Close_flag".format(reason))
except Exception:
pass
elif a[0] == "uplink_start":
self.request_uplink_start(a[1] if len(a) > 1 else "")
elif a[0] == "play_start":
+ if getattr(self, "VC_mode", -1) == 1:
+ if getattr(self, "_vc_serial_canceling", 0) or getattr(self, "_ptt_pending_arm", 0):
+ if self._vc_led_state_debug_enabled():
+ print("[VC][serial] skip play_start (cancel_wait)")
+ continue
try:
self.fsm.on_local_play_start()
except Exception:
# VC 会话是否已进入 started(服务端 session initialized)
# 重要:在收到 started 前发送音频帧会触发服务端 "session not initialized" → failed → 端侧重连死循环
self._vc_session_started = 0
+ # 开机/提示音:本轮 started 不自动开麦(避免问候 TTS 前误进 listening)
+ self._skip_started_uplink_once = 0
+ self._last_tts_kind = ""
+ self._pending_tts_kind = ""
# Push-To-Talk(VAD关闭/单对话)是否已发送 audio.create
# 重要:协议要求 disable_vad=true 时必须 audio.create→音频帧→audio.completed
self._ptt_audio_created = 0
+ # PTT:松手等服务器期间保持 thinking 勿回蓝闪聆听
+ self._ptt_await_server = 0
+ # 方案甲 btnInfo 串行:打断时先 cancel,收到 canceled(或超时)后再 audio.create
+ self._vc_serial_canceling = 0
+ self._ptt_pending_arm = 0
+ self._vc_cancel_deadline_ms = 0
# 端侧主动发 synthesis 后到收到 tts.started 之间的窗口标志,用于阻止误开下一轮录音上行
self._tts_pending = 0
self._tts_pending_ms = 0
EventMesh.subscribe("get_AI_audio_queue", self.get_AI_audio_queue)
EventMesh.subscribe("get_VC_satrt_flag", self.get_VC_satrt_flag)
EventMesh.subscribe("get_vc_stream_active", self.get_vc_stream_active)
+ EventMesh.subscribe("get_vc_tts_playback_active", self.get_vc_tts_playback_active)
EventMesh.subscribe("VC_error", self.VC_error)
EventMesh.subscribe("audio_test", self.audio_test)
EventMesh.subscribe("audio_test_close", self.audio_test_close)
pass
self.AI_TTS_str = self.power_on_tone.replace("bot_name", self.bot_name)
print("开机提示:{}".format(self.AI_TTS_str))
+ try:
+ self._pending_tts_kind = "boot_prompt"
+ self._last_tts_kind = "boot_prompt"
+ self._skip_started_uplink_once = 1
+ except Exception:
+ pass
EventMesh.publish("publish_AI_btnInfo",{"func":self.AI_VC_start})
except Exception as e:
self.audio_play_state =1
#EventMesh.publish("set_audio_state", 1)
print('audio-play start.')
- # 本地播放真正开始:统一进入 playing(绿灯态由 AI_led 表决定)
- try:
- EventMesh.publish("set_led_state", {"ai_state": "playing"})
- except Exception:
- pass
+ # 本地 aud.play(开录/思考短音等)不改动 AI 灯,仅 TTS 流由 VC_Play_Start 控绿灯
+ if self._vc_led_state_debug_enabled():
+ print("[LED] audio_cb start skip (local_play)")
+ return
elif event == 7:
self.audio_play_state = 0
#EventMesh.publish("set_audio_state", 0)
print('audio-play finish.!!!!!!')
- # 本地播放真正结束:统一退出 playing
try:
- EventMesh.publish("set_led_state", {"ai_state": "idle"})
+ if getattr(self, "VC_mode", -1) == 1 and getattr(self, "_ptt_await_server", 0) == 1:
+ self._vc_publish_led("thinking", "audio_cb/finish/ptt_await")
+ return
+ except Exception:
+ pass
+ try:
+ if getattr(self, "VC_record_state_flag", 0) != 0:
+ self._vc_publish_led("listening", "audio_cb/finish/recording")
+ return
except Exception:
pass
+ try:
+ if self._vc_tts_playback_active():
+ if self._vc_led_state_debug_enabled():
+ print("[LED] audio_cb finish skip (tts_playback)")
+ return
+ except Exception:
+ pass
+ self._vc_led_sync_from_fsm()
def set_tts_volume(self,event,msg):
EventMesh.publish("set_storage_config",["VC_TTS_volume",msg])#音量配置
self.VC_TTS_volume = msg
return
except Exception:
pass
- if self._ai_key_press_long_dialogue_tts_busy():
+ if self._ai_key_press_long_dialogue_interrupt():
return
try:
if self._ai_key_press_long_dialogue_when_session_ready():
return
except Exception:
pass
+ try:
+ if self._ai_key_press_long_dialogue_wake():
+ EventMesh.publish(constants.EVENT.SYSTEM_UP)
+ return
+ except Exception:
+ pass
EventMesh.publish(constants.EVENT.SYSTEM_UP)#唤醒休眠
if EventMesh.publish("get_sos_flag")== 1:
return
EventMesh.publish("stop_voice_record") #关闭语音录音
- print("AI_key_press")
- self.get_config_timer_count = 0
- self.key_state = 1
- # EventMesh.publish("audio_montage_play",["chenggong.mp3"])
- # self.chenggong_tone() #播放按键音
- utime.sleep_ms(500)
- self.Close()
-
- # if dev_type == "7PIN":
- # self.chenggong_tone()
- #self.aud.aud_tone_play(10,200)#播放按键音
- self.AI_TTS_str = ""#"你好,有什么吩咐"
- self.AI_ASR_str = ""
- self.AI_ASR_str_tmp = ""
- # if self.VC_state != VC_State_None:
- # EventMesh.publish("publish_AI_btnInfo",{"func":self.Close})
- # EventMesh.publish("publish_AI_btnInfo",{"func":utime.sleep_ms,'data':(10)})
- self._uplink_request_stop("BT_AI_key_press/compat")
- self._uplink_request_start("BT_AI_key_press/compat")
- self.AI_VC_start()
-
-
- # self.Close()
- # self.AI_VC_start()
+ print("AI_key_press: unhandled mode branch")
self.AI_key_long_press_flag = 0
def BT_AI_key_press(self, event=None, msg = None):#按键按下
EventMesh.publish(constants.EVENT.SYSTEM_UP)#唤醒休眠
def pwk_key_double_press(self,event,msg):#pwk双击
print("come 双击")
# 切换模式:必须打断当前对话/录音/播放,回到 idle,等待按键触发新一轮
+ try:
+ self.Close_flag = 1
+ except Exception:
+ pass
try:
EventMesh.publish("publish_AI_btnInfo", {"func": self.Close})
except Exception:
self.VC_record_thread_id))
except Exception:
pass
- # 录音开始:聆听灯(蓝慢闪)
+ # 录音开始:聆听灯(PTT 蓝快闪 / 长对话蓝慢闪)
try:
- EventMesh.publish("set_led_state", {"ai_state": "listening"})
- except Exception:
- pass
- # 单对话长按开麦:可选开录提示音(RECORD_START_TONE_FILE)
- try:
- if self.VC_mode == 1 and "vc_config" in globals() and vc_config:
- rtf = vc_config.get_attr("RECORD_START_TONE_FILE", None)
- if rtf:
- try:
- if EventMesh.publish("get_vc_stream_active") != 1:
- self.aud.play(1, 1, rtf)
- except Exception:
- pass
+ self._vc_publish_led("listening", "VC_Speech_Start")
except Exception:
pass
self.AI_key_long_press_flag = 0
except Exception:
pass
- # å½\95é\9f³å\81\9cæ¢ï¼\9aå\85³é\97è\81\86å\90¬ç\81¯ï¼\88æ\81¢å¤\8d baseï¼\89
+ # å½\95é\9f³å\81\9cæ¢ï¼\9aå\8d\95对è¯\9dè¿\9bå\85¥æ\80\9dè\80\83æ\80\81ï¼\88ç\89æ\9c\8då\8a¡å\99¨ï¼\89ï¼\8cå\8b¿ç»§ç»è\93\9dé\97ªè\81\86å\90¬
try:
- EventMesh.publish("set_led_state", {"ai_state": "idle"})
+ if getattr(self, "VC_mode", -1) == 1:
+ self._ptt_await_server = 1
+ self._vc_publish_led("thinking", "VC_Speech_End/ptt")
+ else:
+ self._ptt_await_server = 0
+ self._vc_publish_led("idle", "VC_Speech_End")
except Exception:
pass
print("[VC] Speech_End record_state={}".format(self.VC_record_state_flag))
try:
ev = jsload_data.get("event", "")
if ev:
- self._fsm_apply_actions(self.fsm.on_server_event(ev, jsload_data))
+ self._vc_dispatch_server_event(ev, jsload_data)
except Exception:
pass
self._turn_has_tts_started = 0
self._llm_completed_ms = 0
if self.VC_mode == 0 and getattr(self, "Close_flag", 0) == 0:
- # 会话已 started:若此时上行未启动,则在这里补一次开麦,避免“Start 前开麦被 Close 停掉”后
- # 出现首轮无音频上行导致“第一次对话没有”的情况。
- if self.record_data_save_flag == 0:
- if self.VC_record_state_flag == 0:
- self.request_uplink_start("server.started")
- else:
- self._vc_clear_stale_record_state("server.started")
- self.request_uplink_start("server.started/retry")
+ _skip_uplink = 0
+ try:
+ if getattr(self, "_skip_started_uplink_once", 0) == 1:
+ self._skip_started_uplink_once = 0
+ _skip_uplink = 1
+ print("[VC][uplink_ctrl] Deny START by server.started cause=boot_prompt")
+ except Exception:
+ pass
+ if not _skip_uplink:
+ # 会话已 started:若此时上行未启动,则在这里补一次开麦,避免“Start 前开麦被 Close 停掉”后
+ # 出现首轮无音频上行导致“第一次对话没有”的情况。
+ if self.record_data_save_flag == 0:
+ if self.VC_record_state_flag == 0:
+ self.request_uplink_start("server.started")
+ else:
+ self._vc_clear_stale_record_state("server.started")
+ self.request_uplink_start("server.started/retry")
print("VC:Started!!!!!!!!\r\n")
print("TTS音频为:{},语音下发地址为:{}".format(self.AI_TTS_str,self.AI_new_voice_url))
print("[VC][ASR] {}".format(self.AI_ASR_str))
if self.AI_ASR_str == "":#没识别到
self.AI_TTS_str = "我没听清,请再说一遍"
+ self._vc_led_alert_rb_flash()
elif jsload_data["event"] == "asr.completed" :#asr 终止的标识
self._turn_has_asr_completed = 1
# 本地 think 提示:每轮 ASR 最多一次,且不得与流式 TTS/PCM 播放竞态(避免长对话偶发连播「滴」)
else:
stream_on = False
try:
- stream_on = EventMesh.publish("get_vc_stream_active") == 1
+ stream_on = EventMesh.publish("get_vc_tts_playback_active") == 1
except Exception:
stream_on = False
pcm_busy = getattr(self, "play_pcm", None) is not None
if self.VC_record_state_flag != 0 and self._vc_vad_uplink_stop_eligible():
self.request_uplink_stop("vad.silence")
elif jsload_data["event"] == "llm.created" :#思考(LLM)
+ try:
+ self._ptt_await_server = 0
+ except Exception:
+ pass
try:
EventMesh.publish("set_led_state", {"ai_state": "thinking"})
except Exception:
if self.VC_record_state_flag == 1:#llm返回数据还没停止,就先停止
self.request_uplink_stop("llm.delta")
elif jsload_data["event"] == "tts.started" :#tts开始
+ try:
+ self._ptt_await_server = 0
+ except Exception:
+ pass
self._server_vad_silence = 1
self._turn_has_tts_started = 1
self._tts_pending = 0
self._last_tts_kind = k
else:
cur = getattr(self, "_last_tts_kind", "") or ""
- if cur in ("mode_switch_long", "mode_switch_ptt"):
+ if cur in (
+ "mode_switch_long",
+ "mode_switch_ptt",
+ "boot_prompt",
+ ):
pass
elif not cur:
self._last_tts_kind = "prompt"
self.AI_VC_send_data_queue.clear()
if self.AI_ASR_send_data_queue.empty()!=True:
self.AI_ASR_send_data_queue.clear()
- EventMesh.publish("set_led_state", {"ai_state": "thinking"})
+ try:
+ if is_close_VC:
+ EventMesh.publish("set_led_state", {"ai_state": "idle"})
+ else:
+ EventMesh.publish("set_led_state", {"ai_state": "thinking"})
+ except Exception:
+ pass
self.AI_ASR_send_buf_len = 0
self._uplink_request_stop("play_tts_cb")
self.websocket_deconnect_VC()
pass
self.VC_Play_flag = 1
try:
- EventMesh.publish("set_led_state", {"ai_state": "playing"})
+ self._vc_publish_led("playing", "VC_Play_Start/tts")
except Exception:
pass
except Exception as e:
if self.AI_audio_queue.empty()!=True:
self.AI_audio_queue.clear()
- # 播放结束后:退出“播报中”绿灯态(恢复 base)。
+ # TTS 播完:按真实 FSM/录音态恢复灯;本地短音不走此路径
try:
- EventMesh.publish("set_led_state", {"ai_state": "idle"})
+ if getattr(self, "_vc_play_end_suppress_idle_led", 0):
+ if self._vc_led_state_debug_enabled():
+ print("[LED] VC_Play_End skip led (suppress)")
+ elif getattr(self, "VC_record_state_flag", 0) != 0:
+ self._vc_publish_led("listening", "VC_Play_End/recording")
+ else:
+ self._vc_led_sync_from_fsm()
except Exception:
pass
# 关键日志:定位“tts 结束绿灯仍不灭”究竟是 base 态(如 charging/full)还是 playing 未退出
age_ms, restart_threshold_ms))
except Exception:
pass
+ self._vc_led_alert_rb_flash()
# thinking_timeout 策略:重启会话(而不是抢开麦),避免干扰服务端 LLM/TTS 流程
EventMesh.publish("publish_AI_btnInfo", {"func": self.AI_VC_start})
except Exception:
pass
- def _1S_timer_cb_(self):
-
+ def _1S_timer_cb_(self):
+ try:
+ self._vc_tick_serial_cancel_timeout()
+ except Exception:
+ pass
+
EventMesh.publish("system_sleep_rest")#防止黑屏
# --- Level 1:仅长对话(VC_mode==0),无业务下行 idle_sec 后结束会话(需再次按键进入)
# close_timerout:保活清除策略见 _vc_rx_resets_long_idle_timer(排除文本 ping/pong、heartbeat 等)
self._tts_pending_ms = 0
self._vc_session_started = 0
self._ptt_audio_created = 0
+ try:
+ self._skip_started_uplink_once = 0
+ except Exception:
+ pass
# Power 短按停止对话后,再次按 AI 需要新建 dialog_id:清理本轮 dialog/session 标识
try:
self._cur_dialog_id = ""
# 硬件差异项(由每个设备的 default_config 固定)\r
# --------------------\r
LED_DEBUG = True\r
+LED_STATE_DEBUG = True\r
KEY_LED_DEBUG = True\r
HW_STATUS_DEBUG = True\r
\r
VBATT_CHARGE_FULL_MV = 4290 # USB 在位且电压≥此值视为充满(get_battery / AI_hw_status / AI_dev 共用)\r
VBATT_LOW_MV = 3699 # 低电电压门限;simple 模式与 AI_dev 低电提示用\r
VBATT_EMPTY_MV = 3629 # 空电电压门限;AI_dev 低电关机等\r
-BATTERY_LOW_SOC_PERCENT = 20 # OCV 模式:soc < 此值 → battery=low\r
-BATTERY_EMPTY_SOC_PERCENT = 5 # OCV 模式:soc < 此值 → battery=empty\r
+BATTERY_LOW_SOC_PERCENT = 30 # OCV 模式:soc < 此值 → battery=low(REQ-BAT-02:10%~30%)\r
+BATTERY_EMPTY_SOC_PERCENT = 10 # OCV 模式:soc < 此值 → battery=empty(REQ-BAT-03:<10%)\r
HW_STATUS_PERIOD_MS = 10000\r
SIGNAL_WEAK_CSQ = 10\r
\r
# 是否在“开始上传语音”时播放成功提示音(原逻辑:非 7PIN/7PIN_1_5/7PIN_coze 才播)\r
UPLOAD_START_TONE_ENABLED = False\r
\r
+# 正常待机 idle 灯效:off=灭灯(REQ-BAT-01);green_blink=旧工程绿灯慢闪\r
+LED_IDLE_MODE = "off"\r
+\r
# LLM thinking 阶段 LED 策略:flicker_blue_green / open_red_green\r
THINKING_LED_MODE = "flicker_blue_green"\r
\r
# AI 键按下时:延迟播放按键音(ms),规避蓝牙刚连上不出声等(与 7PIN_1_5 一致)\r
KEY_PRESS_TONE_DELAY_MS = 300\r
\r
-# 单对话长按开麦:短提示音路径(与短按 KEY_PRESS_TONE 分离,见 ai_VC)\r
-RECORD_START_TONE_FILE = "U:/static/start.mp3"\r
+# 单对话长按开麦:开录提示音(None=不播,流畅优先;可设 U:/static/start.mp3)\r
+RECORD_START_TONE_FILE = None\r
+RECORD_START_TONE_GAP_MS = 180\r
+# 单对话松手后延迟(ms)再发 audio.completed,利于尾包/ASR 收敛\r
+PTT_RELEASE_END_SPEECH_DELAY_MS = 200\r
+# 单对话打断:发 cancel 后等待 canceled 的最长时间(ms),超时仍开录\r
+PTT_CANCEL_WAIT_MS = 2500\r
\r
# PTT 提示语(单对话模式,未长按时的提示;是什么语言就写什么)\r
PTT_HINT = "请您按住圆形的按钮开始讲话,讲完松开即可 "\r
# 硬件差异项(由每个设备的 default_config 固定)\r
# --------------------\r
LED_DEBUG = True\r
+LED_STATE_DEBUG = True\r
KEY_LED_DEBUG = True\r
HW_STATUS_DEBUG = True\r
\r
VBATT_CHARGE_FULL_MV = 4290 # USB 在位且电压≥此值视为充满(get_battery / AI_hw_status / AI_dev 共用)\r
VBATT_LOW_MV = 3699 # 低电电压门限;simple 模式与 AI_dev 低电提示用\r
VBATT_EMPTY_MV = 3629 # 空电电压门限;AI_dev 低电关机等\r
-BATTERY_LOW_SOC_PERCENT = 20 # OCV 模式:soc < 此值 → battery=low\r
-BATTERY_EMPTY_SOC_PERCENT = 5 # OCV 模式:soc < 此值 → battery=empty\r
+BATTERY_LOW_SOC_PERCENT = 30 # OCV 模式:soc < 此值 → battery=low(REQ-BAT-02:10%~30%)\r
+BATTERY_EMPTY_SOC_PERCENT = 10 # OCV 模式:soc < 此值 → battery=empty(REQ-BAT-03:<10%)\r
HW_STATUS_PERIOD_MS = 10000\r
SIGNAL_WEAK_CSQ = 10\r
\r
# 是否在“开始上传语音”时播放成功提示音(原逻辑:非 7PIN/7PIN_1_5/7PIN_coze 才播)\r
UPLOAD_START_TONE_ENABLED = False\r
\r
+# 正常待机 idle 灯效:off=灭灯(REQ-BAT-01);green_blink=旧工程绿灯慢闪\r
+LED_IDLE_MODE = "off"\r
+\r
# LLM thinking 阶段 LED 策略:flicker_blue_green / open_red_green\r
THINKING_LED_MODE = "flicker_blue_green"\r
\r
# AI 键按下时:延迟播放按键音(ms),规避蓝牙刚连上不出声等(与 7PIN_1_5 一致)\r
KEY_PRESS_TONE_DELAY_MS = 300\r
\r
-# 单对话长按开麦:短提示音路径(与短按 KEY_PRESS_TONE 分离,见 ai_VC)\r
-RECORD_START_TONE_FILE = "U:/static/start.mp3"\r
+# 单对话长按开麦:开录提示音(None=不播,流畅优先;可设 U:/static/start.mp3)\r
+RECORD_START_TONE_FILE = None\r
+RECORD_START_TONE_GAP_MS = 180\r
+# 单对话松手后延迟(ms)再发 audio.completed,利于尾包/ASR 收敛\r
+PTT_RELEASE_END_SPEECH_DELAY_MS = 200\r
+# 单对话打断:发 cancel 后等待 canceled 的最长时间(ms),超时仍开录\r
+PTT_CANCEL_WAIT_MS = 2500\r
\r
# PTT 提示语(单对话模式,未长按时的提示;是什么语言就写什么)\r
PTT_HINT = "请您按住圆形的按钮开始讲话,讲完松开即可 "\r
Version="V1.2" \r
-Build_Time="2026-05-16 10:23:12" \r
+Build_Time="2026-05-17 20:20:17" \r