From: SnickerSun Date: Sun, 17 May 2026 15:21:14 +0000 (+0800) Subject: 提交修改又的交互规范需求,设计等相关文档 X-Git-Url: http://git.snicker.com.cn/?a=commitdiff_plain;h=af3d421d5f016bc6441a6110ceb2b374611760c3;p=mirrors%2FASR1603_G5.git 提交修改又的交互规范需求,设计等相关文档 长对话ok,单对话显示还是有异常。需要优化实现逻辑。 --- diff --git "a/1.doc/AI\345\220\212\345\235\240\357\274\210\345\217\214\346\214\211\351\224\256+\344\270\211\350\211\262\346\214\207\347\244\272\347\201\257\357\274\211\344\272\244\344\272\222\350\247\204\350\214\203.md" "b/1.doc/AI\345\220\212\345\235\240\357\274\210\345\217\214\346\214\211\351\224\256+\344\270\211\350\211\262\346\214\207\347\244\272\347\201\257\357\274\211\344\272\244\344\272\222\350\247\204\350\214\203.md" index 883afa79..0435ba53 100644 --- "a/1.doc/AI\345\220\212\345\235\240\357\274\210\345\217\214\346\214\211\351\224\256+\344\270\211\350\211\262\346\214\207\347\244\272\347\201\257\357\274\211\344\272\244\344\272\222\350\247\204\350\214\203.md" +++ "b/1.doc/AI\345\220\212\345\235\240\357\274\210\345\217\214\346\214\211\351\224\256+\344\270\211\350\211\262\346\214\207\347\244\272\347\201\257\357\274\211\344\272\244\344\272\222\350\247\204\350\214\203.md" @@ -109,4 +109,4 @@ * 避免复杂操作,核心功能(唤醒、开关机)仅需1种按键动作,老人、儿童可快速上手 -* 误触防护:短按Power键无操作、双击AI键仅在待机/播报后有效,减少误操作带来的困扰 \ No newline at end of file +* 误触防护:待机短按 Power 无多余操作;单/长对话模式切换仅 Power 键双击,AI 键无双击业务 \ No newline at end of file diff --git "a/1.doc/AI\345\220\212\345\235\240\357\274\210\345\217\214\346\214\211\351\224\256+\344\270\211\350\211\262\346\214\207\347\244\272\347\201\257\357\274\211\344\272\244\344\272\222\350\247\204\350\214\203_new.md" "b/1.doc/AI\345\220\212\345\235\240\357\274\210\345\217\214\346\214\211\351\224\256+\344\270\211\350\211\262\346\214\207\347\244\272\347\201\257\357\274\211\344\272\244\344\272\222\350\247\204\350\214\203_new.md" index 9a1b7afb..a646477e 100644 --- "a/1.doc/AI\345\220\212\345\235\240\357\274\210\345\217\214\346\214\211\351\224\256+\344\270\211\350\211\262\346\214\207\347\244\272\347\201\257\357\274\211\344\272\244\344\272\222\350\247\204\350\214\203_new.md" +++ "b/1.doc/AI\345\220\212\345\235\240\357\274\210\345\217\214\346\214\211\351\224\256+\344\270\211\350\211\262\346\214\207\347\244\272\347\201\257\357\274\211\344\272\244\344\272\222\350\247\204\350\214\203_new.md" @@ -7,7 +7,7 @@ - 按键:Power键(开关键)、AI键(语音交互键) - 指示灯:红(R)、蓝(B)、绿(G)三色(遵循定义:绿=正常/电量/充满电;蓝=AI交互;红=低电/异常) - 提示音:采用短促、清晰的单音/双音,区分不同操作,音量适中(避免刺耳,适配贴身佩戴),支持后续可调节(可选) -- 操作逻辑:长按=特殊功能(开关机、强制重启、连续对话);短按/双击=常用功能(唤醒、打断、复读),杜绝误触 +- 操作逻辑:长按=特殊功能(开关机、强制重启、连续对话);短按=常用功能(唤醒、打断);**模式切换仅 Power 键双击**,杜绝误触 ### LED 灯效与时间术语(与工程 `ai_VC.AI_led` 对齐) @@ -31,19 +31,17 @@ | **慢交替/交替慢闪** | 约 **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` 恢复 | 进入聆听模式:单声“滴”(短促) @@ -60,52 +58,50 @@ ## 第一部分:基础操作(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声短促) | 处理失败,用户可重新发起语音请求 | ## 第五部分:补充规范(工程&用户体验) @@ -133,723 +129,15 @@ - 所有操作均有“灯光+提示音”双重反馈,无屏状态下也能清晰判断设备状态 - 避免复杂操作,核心功能(唤醒、开关机)仅需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] 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= turn= tts_rx= 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 diff --git "a/1.doc/AI\345\220\212\345\235\240\357\274\210\345\217\214\346\214\211\351\224\256+\344\270\211\350\211\262\346\214\207\347\244\272\347\201\257\357\274\211\344\272\244\344\272\222\350\247\204\350\214\203_\351\231\204\345\275\225-\345\267\245\347\250\213\344\270\216\344\273\243\347\240\201\345\256\241\346\237\245.md" "b/1.doc/AI\345\220\212\345\235\240\357\274\210\345\217\214\346\214\211\351\224\256+\344\270\211\350\211\262\346\214\207\347\244\272\347\201\257\357\274\211\344\272\244\344\272\222\350\247\204\350\214\203_\351\231\204\345\275\225-\345\267\245\347\250\213\344\270\216\344\273\243\347\240\201\345\256\241\346\237\245.md" new file mode 100644 index 00000000..7f9aa7ab --- /dev/null +++ "b/1.doc/AI\345\220\212\345\235\240\357\274\210\345\217\214\346\214\211\351\224\256+\344\270\211\350\211\262\346\214\207\347\244\272\347\201\257\357\274\211\344\272\244\344\272\222\350\247\204\350\214\203_\351\231\204\345\275\225-\345\267\245\347\250\213\344\270\216\344\273\243\347\240\201\345\256\241\346\237\245.md" @@ -0,0 +1,11 @@ +# 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` diff --git "a/1.doc/AI\345\220\212\345\235\240\357\274\210\345\217\214\346\214\211\351\224\256+\344\270\211\350\211\262\346\214\207\347\244\272\347\201\257\357\274\211\350\257\246\350\256\276-\345\234\272\346\231\257\344\270\216\345\215\217\350\256\256\346\265\201\347\250\213.md" "b/1.doc/AI\345\220\212\345\235\240\357\274\210\345\217\214\346\214\211\351\224\256+\344\270\211\350\211\262\346\214\207\347\244\272\347\201\257\357\274\211\350\257\246\350\256\276-\345\234\272\346\231\257\344\270\216\345\215\217\350\256\256\346\265\201\347\250\213.md" new file mode 100644 index 00000000..e48241dc --- /dev/null +++ "b/1.doc/AI\345\220\212\345\235\240\357\274\210\345\217\214\346\214\211\351\224\256+\344\270\211\350\211\262\346\214\207\347\244\272\347\201\257\357\274\211\350\257\246\350\256\276-\345\234\272\346\231\257\344\270\216\345\215\217\350\256\256\346\265\201\347\250\213.md" @@ -0,0 +1,398 @@ +# 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`**:长对话播完上报后门控下一轮;单对话以用户 **长按** 开始下一轮为主。 + + diff --git "a/1.doc/AI\345\220\212\345\235\240\357\274\210\345\217\214\346\214\211\351\224\256+\344\270\211\350\211\262\346\214\207\347\244\272\347\201\257\357\274\211\350\257\246\350\256\276-\345\257\271\347\205\247\344\270\216\345\206\263\347\255\226.md" "b/1.doc/AI\345\220\212\345\235\240\357\274\210\345\217\214\346\214\211\351\224\256+\344\270\211\350\211\262\346\214\207\347\244\272\347\201\257\357\274\211\350\257\246\350\256\276-\345\257\271\347\205\247\344\270\216\345\206\263\347\255\226.md" new file mode 100644 index 00000000..1b08cf34 --- /dev/null +++ "b/1.doc/AI\345\220\212\345\235\240\357\274\210\345\217\214\346\214\211\351\224\256+\344\270\211\350\211\262\346\214\207\347\244\272\347\201\257\357\274\211\350\257\246\350\256\276-\345\257\271\347\205\247\344\270\216\345\206\263\347\255\226.md" @@ -0,0 +1,307 @@ +# 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 | 服务端事件对照 | + + diff --git a/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/ai_VC.py b/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/ai_VC.py index 8204a6af..6b5bfc88 100644 --- a/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/ai_VC.py +++ b/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/ai_VC.py @@ -127,9 +127,13 @@ class AI_led(Abstract): 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 @@ -248,6 +252,29 @@ class AI_led(Abstract): 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_}) @@ -277,11 +304,38 @@ class AI_led(Abstract): 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: @@ -292,9 +346,37 @@ class AI_led(Abstract): 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": @@ -305,8 +387,14 @@ class AI_led(Abstract): 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 @@ -341,26 +429,36 @@ class AI_led(Abstract): 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 聚合状态与最终模式 @@ -465,8 +563,8 @@ class _BatteryCfg(object): 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 @@ -2257,10 +2355,10 @@ class AI_dev(Abstract): 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 @@ -3364,10 +3462,16 @@ class VCStateMachine: 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 @@ -3424,6 +3528,20 @@ class AI_VC(Abstract): 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: @@ -3573,7 +3691,7 @@ class AI_VC(Abstract): 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 @@ -3581,15 +3699,62 @@ class AI_VC(Abstract): 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: @@ -3607,6 +3772,11 @@ class AI_VC(Abstract): 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 @@ -3618,7 +3788,16 @@ class AI_VC(Abstract): """单对话 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 @@ -3651,6 +3830,145 @@ class AI_VC(Abstract): # 语义: # - 长按:无论当前处于 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: @@ -3658,9 +3976,10 @@ class AI_VC(Abstract): except Exception: return - # PTT 新一轮开始:清理“提示音播完退出”等标志,避免 Close_flag 残留影响当前回合 + # PTT 新一轮开始:清理模式切换/提示音退出标志 try: self.Close_flag = 0 + self._ptt_await_server = 0 except Exception: pass # 打断上一段 TTS 后可能仍有残留音频帧下发:短时间丢弃,避免“断断续续继续播” @@ -3675,6 +3994,16 @@ class AI_VC(Abstract): 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() @@ -3687,6 +4016,10 @@ class AI_VC(Abstract): self.VC_Play_End() except Exception: pass + try: + self._vc_publish_led("listening", "ptt_begin/armed") + except Exception: + pass # 若上一轮录音尚未结束,先强制停掉,避免 record_thread 复用出错 try: @@ -3695,23 +4028,13 @@ class AI_VC(Abstract): 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 @@ -3720,6 +4043,7 @@ class AI_VC(Abstract): 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 @@ -3727,6 +4051,7 @@ class AI_VC(Abstract): self.AI_VC_start() except Exception: pass + self._ptt_end_led_suppress() return # 会话还没 initialized:先发 Start,请求服务端进入 started @@ -3741,39 +4066,110 @@ class AI_VC(Abstract): 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 @@ -3927,6 +4323,11 @@ class AI_VC(Abstract): 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: @@ -4028,9 +4429,19 @@ class AI_VC(Abstract): # 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 @@ -4345,6 +4756,7 @@ class AI_VC(Abstract): 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) @@ -4438,6 +4850,12 @@ class AI_VC(Abstract): 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: @@ -4628,20 +5046,34 @@ class AI_VC(Abstract): 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 @@ -4745,13 +5177,19 @@ class AI_VC(Abstract): 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: @@ -4772,30 +5210,7 @@ class AI_VC(Abstract): 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)#唤醒休眠 @@ -4903,6 +5318,10 @@ class AI_VC(Abstract): 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: @@ -5023,21 +5442,9 @@ class AI_VC(Abstract): 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 @@ -5098,9 +5505,14 @@ class AI_VC(Abstract): self.AI_key_long_press_flag = 0 except Exception: pass - # 录音停止:关闭聆听灯(恢复 base) + # 录音停止:单对话进入思考态(等服务器),勿继续蓝闪聆听 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)) @@ -5786,7 +6198,7 @@ class AI_VC(Abstract): 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 @@ -5808,14 +6220,23 @@ class AI_VC(Abstract): 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)) @@ -5935,6 +6356,7 @@ class AI_VC(Abstract): 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 播放竞态(避免长对话偶发连播「滴」) @@ -5944,7 +6366,7 @@ class AI_VC(Abstract): 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 @@ -6005,6 +6427,10 @@ class AI_VC(Abstract): 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: @@ -6032,6 +6458,10 @@ class AI_VC(Abstract): 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 @@ -6057,7 +6487,11 @@ class AI_VC(Abstract): 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" @@ -6440,7 +6874,13 @@ class AI_VC(Abstract): 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() @@ -7109,7 +7549,7 @@ class AI_VC(Abstract): 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: @@ -7149,9 +7589,15 @@ class AI_VC(Abstract): 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 未退出 @@ -7237,13 +7683,18 @@ class AI_VC(Abstract): 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 等) @@ -8399,6 +8850,10 @@ class AI_VC(Abstract): 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 = "" diff --git a/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/default_config.py b/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/default_config.py index 1978dd37..243f326f 100644 --- a/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/default_config.py +++ b/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/default_config.py @@ -40,6 +40,7 @@ LANG = "zh" # 硬件差异项(由每个设备的 default_config 固定) # -------------------- LED_DEBUG = True +LED_STATE_DEBUG = True KEY_LED_DEBUG = True HW_STATUS_DEBUG = True @@ -51,8 +52,8 @@ USE_SIMPLE_VBATT_THRESH = False VBATT_CHARGE_FULL_MV = 4290 # USB 在位且电压≥此值视为充满(get_battery / AI_hw_status / AI_dev 共用) VBATT_LOW_MV = 3699 # 低电电压门限;simple 模式与 AI_dev 低电提示用 VBATT_EMPTY_MV = 3629 # 空电电压门限;AI_dev 低电关机等 -BATTERY_LOW_SOC_PERCENT = 20 # OCV 模式:soc < 此值 → battery=low -BATTERY_EMPTY_SOC_PERCENT = 5 # OCV 模式:soc < 此值 → battery=empty +BATTERY_LOW_SOC_PERCENT = 30 # OCV 模式:soc < 此值 → battery=low(REQ-BAT-02:10%~30%) +BATTERY_EMPTY_SOC_PERCENT = 10 # OCV 模式:soc < 此值 → battery=empty(REQ-BAT-03:<10%) HW_STATUS_PERIOD_MS = 10000 SIGNAL_WEAK_CSQ = 10 @@ -141,6 +142,9 @@ BT_OTA_ENABLED = False # 是否在“开始上传语音”时播放成功提示音(原逻辑:非 7PIN/7PIN_1_5/7PIN_coze 才播) UPLOAD_START_TONE_ENABLED = False +# 正常待机 idle 灯效:off=灭灯(REQ-BAT-01);green_blink=旧工程绿灯慢闪 +LED_IDLE_MODE = "off" + # LLM thinking 阶段 LED 策略:flicker_blue_green / open_red_green THINKING_LED_MODE = "flicker_blue_green" @@ -186,8 +190,13 @@ KEY_PRESS_TONE_FILE = None # AI 键按下时:延迟播放按键音(ms),规避蓝牙刚连上不出声等(与 7PIN_1_5 一致) KEY_PRESS_TONE_DELAY_MS = 300 -# 单对话长按开麦:短提示音路径(与短按 KEY_PRESS_TONE 分离,见 ai_VC) -RECORD_START_TONE_FILE = "U:/static/start.mp3" +# 单对话长按开麦:开录提示音(None=不播,流畅优先;可设 U:/static/start.mp3) +RECORD_START_TONE_FILE = None +RECORD_START_TONE_GAP_MS = 180 +# 单对话松手后延迟(ms)再发 audio.completed,利于尾包/ASR 收敛 +PTT_RELEASE_END_SPEECH_DELAY_MS = 200 +# 单对话打断:发 cancel 后等待 canceled 的最长时间(ms),超时仍开录 +PTT_CANCEL_WAIT_MS = 2500 # PTT 提示语(单对话模式,未长按时的提示;是什么语言就写什么) PTT_HINT = "请您按住圆形的按钮开始讲话,讲完松开即可 " diff --git a/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/products/configs/7PIN/default_config.py b/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/products/configs/7PIN/default_config.py index 1978dd37..243f326f 100644 --- a/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/products/configs/7PIN/default_config.py +++ b/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/products/configs/7PIN/default_config.py @@ -40,6 +40,7 @@ LANG = "zh" # 硬件差异项(由每个设备的 default_config 固定) # -------------------- LED_DEBUG = True +LED_STATE_DEBUG = True KEY_LED_DEBUG = True HW_STATUS_DEBUG = True @@ -51,8 +52,8 @@ USE_SIMPLE_VBATT_THRESH = False VBATT_CHARGE_FULL_MV = 4290 # USB 在位且电压≥此值视为充满(get_battery / AI_hw_status / AI_dev 共用) VBATT_LOW_MV = 3699 # 低电电压门限;simple 模式与 AI_dev 低电提示用 VBATT_EMPTY_MV = 3629 # 空电电压门限;AI_dev 低电关机等 -BATTERY_LOW_SOC_PERCENT = 20 # OCV 模式:soc < 此值 → battery=low -BATTERY_EMPTY_SOC_PERCENT = 5 # OCV 模式:soc < 此值 → battery=empty +BATTERY_LOW_SOC_PERCENT = 30 # OCV 模式:soc < 此值 → battery=low(REQ-BAT-02:10%~30%) +BATTERY_EMPTY_SOC_PERCENT = 10 # OCV 模式:soc < 此值 → battery=empty(REQ-BAT-03:<10%) HW_STATUS_PERIOD_MS = 10000 SIGNAL_WEAK_CSQ = 10 @@ -141,6 +142,9 @@ BT_OTA_ENABLED = False # 是否在“开始上传语音”时播放成功提示音(原逻辑:非 7PIN/7PIN_1_5/7PIN_coze 才播) UPLOAD_START_TONE_ENABLED = False +# 正常待机 idle 灯效:off=灭灯(REQ-BAT-01);green_blink=旧工程绿灯慢闪 +LED_IDLE_MODE = "off" + # LLM thinking 阶段 LED 策略:flicker_blue_green / open_red_green THINKING_LED_MODE = "flicker_blue_green" @@ -186,8 +190,13 @@ KEY_PRESS_TONE_FILE = None # AI 键按下时:延迟播放按键音(ms),规避蓝牙刚连上不出声等(与 7PIN_1_5 一致) KEY_PRESS_TONE_DELAY_MS = 300 -# 单对话长按开麦:短提示音路径(与短按 KEY_PRESS_TONE 分离,见 ai_VC) -RECORD_START_TONE_FILE = "U:/static/start.mp3" +# 单对话长按开麦:开录提示音(None=不播,流畅优先;可设 U:/static/start.mp3) +RECORD_START_TONE_FILE = None +RECORD_START_TONE_GAP_MS = 180 +# 单对话松手后延迟(ms)再发 audio.completed,利于尾包/ASR 收敛 +PTT_RELEASE_END_SPEECH_DELAY_MS = 200 +# 单对话打断:发 cancel 后等待 canceled 的最长时间(ms),超时仍开录 +PTT_CANCEL_WAIT_MS = 2500 # PTT 提示语(单对话模式,未长按时的提示;是什么语言就写什么) PTT_HINT = "请您按住圆形的按钮开始讲话,讲完松开即可 " diff --git a/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/versions.py b/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/versions.py index 252883e6..a4cbca2e 100644 --- a/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/versions.py +++ b/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/versions.py @@ -1,2 +1,2 @@ Version="V1.2" -Build_Time="2026-05-16 10:23:12" +Build_Time="2026-05-17 20:20:17"