V2.5 程序架构初步改动完毕,当下采用状态机模式驱动,暂时空间不够,还需削减代码。正常长对话,单对话,led都工作,但是功能还未完全实现完。
authorSnickerSun <snickersun@163.com>
Thu, 7 May 2026 08:18:50 +0000 (16:18 +0800)
committerSnickerSun <snickersun@163.com>
Thu, 7 May 2026 08:18:50 +0000 (16:18 +0800)
1.doc/RVC Service WebSocket V3 API 开发文档.pdf [new file with mode: 0644]
1.doc/V2.5_to_V3_协议变更对照.md [new file with mode: 0644]
1.doc/金三服务-语音对话V2.5.pdf [new file with mode: 0644]
2.sdk/project/7PIN_1_5/new.bat
2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/Makefile
2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/ai_VC.py
2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/versions.py
2.sdk/project/7PIN_1_5/usr/audio_gain.nvm [new file with mode: 0644]
产测工具/FactoryTool/test_led.py [new file with mode: 0644]

diff --git a/1.doc/RVC Service WebSocket V3 API 开发文档.pdf b/1.doc/RVC Service WebSocket V3 API 开发文档.pdf
new file mode 100644 (file)
index 0000000..1ef43f0
Binary files /dev/null and "b/1.doc/RVC Service WebSocket V3 API \345\274\200\345\217\221\346\226\207\346\241\243.pdf" differ
diff --git a/1.doc/V2.5_to_V3_协议变更对照.md b/1.doc/V2.5_to_V3_协议变更对照.md
new file mode 100644 (file)
index 0000000..98ee433
--- /dev/null
@@ -0,0 +1,347 @@
+# V2.5 → V3 协议变更对照(语音对话 WebSocket)\r
+\r
+本文基于以下两份文档对照整理:\r
+\r
+- `金三服务-语音对话V2.5.pdf`\r
+- `RVC Service WebSocket V3 API 开发文档.pdf`\r
+\r
+目标读者:端侧/客户端对接工程师、服务端兼容层工程师。  \r
+目标:从 V2.5 升级到 V3 时,把**所有协议不兼容点**、**字段重命名**、**事件变更**、**时序差异**一次列清。\r
+\r
+---\r
+\r
+## 1. 结论速览(必改项 Top 10)\r
+\r
+- **(1) WS 路径变更**:V3 统一为 `/rvc-service/api/v3/ws`。\r
+- **(2) ApiKey 鉴权参数名变更**:V2.5 用 `token`;V3 用 `api_key`,并新增可传 `user_id/device_id`。\r
+- **(3) JSON 封装变化(最大不兼容)**:V2.5 `event/payload` → V3 `type/data` + Envelope(timestamp/message_id/session_id/metadata)。\r
+- **(4) start 入参结构重构**:V2.5 `inbound_attributes/outbound_attributes` → V3 `audio_config.input/output` + `session_mode/input_mode/output_mode`。\r
+- **(5) 会话模式明确化**:V3 引入 `realtime` 与 `push_to_talk`,直接决定是否需要 `audio.create/audio.completed`。\r
+- **(6) 输入/输出模式枚举**:V3 支持 `input_mode`/`output_mode`(audio/text/both/none),替代 V2.5 若干分散开关(如 simple_response)。\r
+- **(7) 事件集与字段名变化**:如 `failed`→`error`,TTS `tts.completed`→`tts.done`,ASR `content`→`text` 等。\r
+- **(8) cancel 约束变化**:V2.5 文档限制“只能 asr.completed 后取消”;V3 可用于打断 LLM/TTS,打断时机受策略控制。\r
+- **(9) 新增音频帧加密协商**:V3 增加 `audio_encryption`(仅加密二进制音频帧)。\r
+- **(10) 新增 metadata 动态更新**:V3 增加 `metadata.update`(客户端)与 `metadata.updated`(服务端)。\r
+\r
+> 风险提示:V2.5 文档出现的 `audio.played`、`audio.convert.*`、`command`/`submit.tool_output` 在 V3 文档中未覆盖,若你现网依赖这些能力,需要额外确认 V3 是否提供等价机制或需服务端兼容。\r
+\r
+---\r
+\r
+## 2. 接入信息变化\r
+\r
+### 2.1 WebSocket URL\r
+\r
+- **V2.5**:文档列出多套网关/路径(示例:`/vc/agent`、`/vc/chat`)。\r
+- **V3**:统一为:\r
+  - `ws://{host}:{port}/rvc-service/api/v3/ws`\r
+\r
+### 2.2 鉴权方式(Query 参数)\r
+\r
+两版均支持 DMP 授权(`dt/ut`),但 ApiKey 的参数名变化。\r
+\r
+| 目的 | V2.5 | V3 |\r
+|---|---|---|\r
+| DMP 设备/用户授权 | `?dt=...&ut=...` | `?dt=...&ut=...` |\r
+| ApiKey 授权 | `?token=API_KEY` | `?api_key=...`(可选再带 `user_id/device_id`) |\r
+\r
+---\r
+\r
+## 3. JSON 消息封装变化(Envelope)\r
+\r
+### 3.1 V2.5:event/payload\r
+\r
+V2.5 控制类消息基本形如:\r
+\r
+```json\r
+{\r
+  "event": "start",\r
+  "payload": { }\r
+}\r
+```\r
+\r
+### 3.2 V3:type/data + Envelope\r
+\r
+V3 所有 JSON 文本消息统一 Envelope:\r
+\r
+```json\r
+{\r
+  "type": "started",\r
+  "data": { },\r
+  "timestamp": "2025-01-01T12:00:00Z",\r
+  "message_id": "optional",\r
+  "session_id": "optional",\r
+  "metadata": { "k": "v" }\r
+}\r
+```\r
+\r
+字段说明:\r
+\r
+- **type**:事件/消息类型(字符串)\r
+- **data**:负载(结构随 type 而变)\r
+- **timestamp/message_id/session_id/metadata**:可选\r
+\r
+迁移影响:\r
+\r
+- **客户端发送**:全部从 `event` 改为 `type`,从 `payload` 改为 `data`(并允许无 data)。\r
+- **客户端接收**:必须以 Envelope 解析为主(否则会漏掉重要字段,如 session_id/metadata)。\r
+\r
+---\r
+\r
+## 4. start(会话初始化)协议差异\r
+\r
+### 4.1 V2.5 start(关键字段)\r
+\r
+V2.5 示例中常见字段组合:\r
+\r
+- `simple_response`:控制 LLM 是否仅返回完整文本\r
+- `outbound_attributes.cfg`:TTS 输出音频配置(format/codec/sample_rate/bit_rate/channel/volume/speech_rate/chunk_interval_ms)\r
+- `inbound_attributes.disable_vad/vad_silence/interrupt_options/cfg`:ASR 输入配置与 VAD/打断\r
+\r
+### 4.2 V3 start(关键字段)\r
+\r
+V3 start 负载结构强调“模式”和“音频配置”:\r
+\r
+- `session_mode`: `realtime` / `push_to_talk`\r
+- `input_mode`: `audio` / `text` / `none`\r
+- `output_mode`: `audio` / `text` / `both` / `none`\r
+- `audio_config.input/output`: `format`/`sample_rate`/`bit_rate`/`channel`(并支持 output `speech_rate` 等)\r
+- `interrupt_options.strategy`: `keyword` / `always_allow` / `never_allow`(文档亦出现 thinking_time_only 的描述)\r
+- 可选 `audio_encryption`:音频二进制帧加密协商\r
+- 可选 `metadata`:会话级自定义参数\r
+\r
+### 4.3 V2.5 → V3 字段映射建议\r
+\r
+| V2.5 字段路径 | V3 字段路径 | 备注 |\r
+|---|---|---|\r
+| `payload.device_id` | `data.device_id` | 字段名一致,位置不同 |\r
+| `payload.outbound_attributes.cfg.*` | `data.audio_config.output.*` | 输出音频参数归一 |\r
+| `payload.inbound_attributes.cfg.*` | `data.audio_config.input.*` | 输入音频参数归一 |\r
+| `payload.inbound_attributes.disable_vad` | `data.session_mode` | 语义变化:V3 用会话模式显式表达 |\r
+| `payload.simple_response` | `data.output_mode` + LLM 事件处理 | V3 不再用该开关表达“增量/完整”同一概念 |\r
+\r
+---\r
+\r
+## 5. 会话模式与音频发送控制差异\r
+\r
+### 5.1 V2.5(基于 VAD 开关的约定)\r
+\r
+V2.5 文档说明:\r
+\r
+- 开启 VAD 时:**不需要**发送 `audio.create` 与 `audio.completed`;收到 `started` 后直接发音频。\r
+- 关闭 VAD 时:需要用 `audio.create` 与 `audio.completed` 包裹一次讲话。\r
+\r
+### 5.2 V3(session_mode 明确化)\r
+\r
+V3 明确规定:\r
+\r
+- **realtime**:持续监听,**无需** `audio.create/audio.completed`\r
+- **push_to_talk**:按键对讲,每轮必须:\r
+  - `audio.create` → 发送二进制音频帧 → `audio.completed`\r
+\r
+迁移建议:\r
+\r
+- 若你原来“开启 VAD 并持续推流”的形态,V3 对应 **realtime**。\r
+- 若你原来“按键说话”的形态,V3 对应 **push_to_talk**。\r
+\r
+---\r
+\r
+## 6. 客户端 → 服务端:消息类型对照\r
+\r
+| 目的 | V2.5(event) | V3(type) | 变化点 |\r
+|---|---|---|---|\r
+| 开始会话 | `start` | `start` | 外层从 event/payload → type/data;start.data 结构重构 |\r
+| 开始说话(PTT) | `audio.create` | `audio.create` | V3 明确仅 push_to_talk 使用 |\r
+| 结束说话(PTT) | `audio.completed` | `audio.completed` | 同上 |\r
+| 文本输入 | `prompt` | `prompt` | payload.prompt → data.messages |\r
+| 直接 TTS | `synthesis` | `synthesis` | 外层封装变化;data.text |\r
+| 取消/打断 | `cancel` | `cancel` | V3 语义更通用(打断 LLM/TTS),不再强调固定时机 |\r
+| 会话中更新参数 | (无) | `metadata.update` | V3 新增 |\r
+\r
+二进制音频帧(上行)两版都是直接发 BinaryFrame(非 JSON),但 V3 增加了可选加密层。\r
+\r
+---\r
+\r
+## 7. 服务端 → 客户端:事件类型对照\r
+\r
+### 7.1 会话/流程类\r
+\r
+| 含义 | V2.5(event) | V3(type) | 变化点 |\r
+|---|---|---|---|\r
+| 会话开始 | `started` | `started` | V3 的 started.data 会回传更完整的 config(含加密协商信息) |\r
+| 会话完成 | `completed` | `completed` | 封装变化;V3 表达“一次完整链路结束” |\r
+| 会话取消结果 | `canceled` | `canceled` | 封装变化 |\r
+| 错误 | `failed` | `error` | **名称与负载结构均变化** |\r
+| metadata 更新回执 | (无) | `metadata.updated` | V3 新增 |\r
+\r
+### 7.2 VAD/ASR\r
+\r
+| 含义 | V2.5(event) | V3(type) | 负载关键字段变化 |\r
+|---|---|---|---|\r
+| VAD 活跃 | `vad.active` | `vad.active` | 结构更轻,通常无或少量 data |\r
+| VAD 静音 | `vad.silence` | `vad.silence` | 同上 |\r
+| ASR 开始 | `asr.started` | `asr.started` | V2.5 常带 dialog_id;V3 以 data 结构定义 |\r
+| ASR 片段 | `asr.result` | `asr.result` | **V2.5: payload.content**;**V3: data.text + is_final/confidence/...** |\r
+| ASR 完成 | `asr.completed` | `asr.completed` | 封装变化 |\r
+\r
+### 7.3 LLM\r
+\r
+| 含义 | V2.5(event) | V3(type) | 变化点 |\r
+|---|---|---|---|\r
+| LLM 创建 | `llm.created` | (无) | V3 用 `llm.started` 表达开始 |\r
+| LLM 增量 | `llm.delta` | `llm.delta` | V3 data 常见 `{delta:"..."}`(字段名不同于 V2.5 payload.content) |\r
+| LLM 完整文本 | `llm.completed` | (无明确同名) | V3 更多依赖 delta 聚合或 done 信号 |\r
+| LLM 结束 | `llm.done` | `llm.done` | 封装变化 |\r
+\r
+### 7.4 TTS 与音频下行(二进制)\r
+\r
+| 含义 | V2.5(event) | V3(type) | 变化点 |\r
+|---|---|---|---|\r
+| TTS 创建 | `tts.created` | (无) | V3 不再强调 created |\r
+| TTS 开始 | `tts.started` | `tts.started` | 封装变化 |\r
+| TTS 音频结束 | `tts.completed` | `tts.done` | **事件名变化** |\r
+| 音频数据 | 二进制帧 | 二进制帧 | V3 可能加密;格式取决于 audio_config.output.format |\r
+\r
+---\r
+\r
+## 8. 错误模型变化\r
+\r
+### 8.1 V2.5:failed\r
+\r
+示例:\r
+\r
+```json\r
+{\r
+  "event": "failed",\r
+  "payload": {\r
+    "error": "AUDIO_FILE_READ_ERROR",\r
+    "message": "音频文件读取失败"\r
+  }\r
+}\r
+```\r
+\r
+### 8.2 V3:error\r
+\r
+示例:\r
+\r
+```json\r
+{\r
+  "type": "error",\r
+  "data": {\r
+    "code": "BadRequest",\r
+    "message": "参数不合法",\r
+    "details": "..."\r
+  }\r
+}\r
+```\r
+\r
+迁移影响:\r
+\r
+- 错误事件名从 `failed` → `error`\r
+- 字段从 `error/message` → `code/message/details?`\r
+\r
+---\r
+\r
+## 9. 音频帧与格式差异(PCM/Opus + 加密)\r
+\r
+### 9.1 PCM 约束(两版基本一致)\r
+\r
+- 编码:S16LE(小端)\r
+- 采样率:16000 Hz(建议)\r
+- 声道:1(单声道)\r
+- 发送方式:WebSocket 二进制帧\r
+\r
+### 9.2 Opus(两版均支持,但 V3更体系化)\r
+\r
+V3 对 Opus 帧长建议更明确(示例 60ms),并强调仅当 `audio_config.input.format=opus` 才使用。\r
+\r
+### 9.3 V3 新增:音频帧加密(可选)\r
+\r
+V3 增加 `audio_encryption`:\r
+\r
+- 仅加密**二进制音频帧**(上/下行);JSON 控制消息不加密\r
+- 支持算法:`x25519-aes-256-gcm`、`x25519-chacha20-poly1305`、`p256-aes-256-gcm`\r
+- 通过 `start.data.audio_encryption.client_public_key` 与 `started.data.audio_encryption.server_public_key/salt` 协商\r
+- 加密帧格式:`12-byte nonce + ciphertext_with_tag`\r
+\r
+迁移建议:\r
+\r
+- 若不需要加密,可先保持 `audio_encryption.enabled=false`,降低一次性改造风险。\r
+- 如要开启加密,需确保端侧具备:密钥协商(X25519/P-256)+ HKDF-SHA256 + AEAD 解密/加密能力。\r
+\r
+---\r
+\r
+## 10. V2.5 有而 V3 文档未覆盖的能力(迁移风险清单)\r
+\r
+以下能力在 V2.5 文档中出现,但 V3 文档未描述对应 type:\r
+\r
+- **`audio.played`**:V2.5 关键词打断流程要求音频播放完回传,否则不进入下一轮对话状态。\r
+- **`audio.convert` / `audio.convert.started` / `audio.convert.completed` / `audio.convert.failed`**:V2.5 支持服务端拉取音频 URL 转码并下发。\r
+- **`command` + `submit.tool_output`**:V2.5 有指令解析/工具调用回传示例。\r
+\r
+建议行动:\r
+\r
+- 若客户端有使用这些事件,请向服务端确认:\r
+  - V3 是否继续支持这些能力(可能为另一个 API/版本)\r
+  - 是否需要开兼容开关或走额外的扩展 metadata/自定义 type\r
+\r
+---\r
+\r
+## 11. 典型时序对照(文本版)\r
+\r
+### 11.1 V3 Realtime(持续监听)\r
+\r
+1) 连接 WS  \r
+2) 发送 `start`(`session_mode=realtime`,`input_mode=audio`)  \r
+3) 持续发送二进制音频帧  \r
+4) 服务端事件大致顺序:\r
+\r
+- `vad.active` → `asr.started` → 多次 `asr.result` → `asr.completed`\r
+- `llm.started` → 多次 `llm.delta` → `llm.done`\r
+- `tts.started` → 多次(二进制音频帧)→ `tts.done`\r
+- `completed`\r
+\r
+需要打断时发送 `cancel`(效果受 interrupt 策略影响)。\r
+\r
+### 11.2 V3 Push-To-Talk(按键说话)\r
+\r
+1) 发送 `start`(`session_mode=push_to_talk`)  \r
+2) 每轮:\r
+\r
+- `audio.create` → 发送二进制音频帧 → `audio.completed`\r
+\r
+后续服务端事件链路与 realtime 类似。\r
+\r
+---\r
+\r
+## 12. 迁移改造 Checklist(端侧)\r
+\r
+- [ ] WS URL 切到 `/rvc-service/api/v3/ws`\r
+- [ ] 鉴权 query:`token` → `api_key`(如使用 ApiKey)\r
+- [ ] JSON 解析器支持 Envelope:`type/data` + 可选字段\r
+- [ ] start 请求结构按 V3 重构:\r
+  - [ ] `session_mode/input_mode/output_mode`\r
+  - [ ] `audio_config.input/output`\r
+  - [ ] `interrupt_options` 新结构(strategy + keywords)\r
+- [ ] 会话模式驱动音频控制:\r
+  - [ ] realtime:不发 create/completed\r
+  - [ ] push_to_talk:必须发 create/completed\r
+- [ ] 服务端事件处理改造:\r
+  - [ ] `failed` → `error`\r
+  - [ ] `tts.completed` → `tts.done`\r
+  - [ ] `llm.*` 事件集对齐(started/delta/done)\r
+  - [ ] `asr.result` 字段路径从 `content` → `text`(并处理 is_final)\r
+- [ ] 二进制音频帧:\r
+  - [ ] PCM/Opus 解码逻辑按 `audio_config.*.format` 选择\r
+  - [ ] 如启用加密:实现协商与帧解密/加密\r
+- [ ] 若依赖 V2.5 的 `audio.played/audio.convert/command`:与服务端确认 V3 等价能力并实现适配\r
+\r
+---\r
+\r
+## 13. 附:快速对照(最常踩坑)\r
+\r
+1) **别再发 `event/payload`**:V3 收不到或会直接报参数错误。  \r
+2) **别再等 `asr.completed` 才 cancel**:V3 cancel 面向打断 LLM/TTS,但要看策略。  \r
+3) **TTS 结束事件名不同**:V2.5 写 `tts.completed`;V3 是 `tts.done`。  \r
+4) **ASR 字段名不同**:V2.5 `content`;V3 `text`,并有 `is_final`。  \r
+5) **V2.5 的某些扩展事件 V3 文档没写**:先确认再迁移,避免上线缺功能。\r
+\r
diff --git a/1.doc/金三服务-语音对话V2.5.pdf b/1.doc/金三服务-语音对话V2.5.pdf
new file mode 100644 (file)
index 0000000..29dbc44
Binary files /dev/null and "b/1.doc/\351\207\221\344\270\211\346\234\215\345\212\241-\350\257\255\351\237\263\345\257\271\350\257\235V2.5.pdf" differ
index 396c014..de5acf0 100644 (file)
@@ -1,6 +1,16 @@
 @echo off\r
 md images\r
 del images /q\r
+\r
+:: 确保 7PIN_1_5 的 customer_fs(/usr) 内包含音频 NVM(增益/语音增强表)\r
+:: 这两份文件会影响录音/通话的场景增益与 VE 参数;缺失时可能回退到默认表导致录音幅度异常。\r
+if exist "sdk\services\microPython\ports\quectel\core\audio_gain.nvm" (\r
+    copy /y "sdk\services\microPython\ports\quectel\core\audio_gain.nvm" "usr\audio_gain.nvm" >nul\r
+)\r
+if exist "sdk\services\microPython\ports\quectel\core\audio_ve.nvm" (\r
+    copy /y "sdk\services\microPython\ports\quectel\core\audio_ve.nvm" "usr\audio_ve.nvm" >nul\r
+)\r
+\r
 @GOTO :STEP1\r
 ..\..\Helios_SDK\tools\ASR_1606\win32\mklfs.exe -c ext -b 4096 -r 4096 -p 4096 -s 1048576 -i .\images\ext_fs.bin\r
 if errorlevel 1 (\r
index 54aab98..51994be 100644 (file)
@@ -927,11 +927,9 @@ frozen_mpy_list =  $(BUILD)/_boot.mpy \
                                        $(BUILD)/ntptime.mpy \\r
                                        $(BUILD)/request.mpy \\r
                                        $(BUILD)/log.mpy \\r
-                                       $(BUILD)/aLiYun.mpy \\r
                                        $(BUILD)/hmac.mpy \\r
                                        $(BUILD)/hashlib.mpy \\r
                                        $(BUILD)/base64.mpy \\r
-                                       $(BUILD)/TenCentYun.mpy \\r
                                        $(BUILD)/file_sha256.mpy \\r
                                        $(BUILD)/checksum.mpy \\r
                                        $(BUILD)/checkNet.mpy \\r
@@ -1001,11 +999,9 @@ frozen_mpy_list =         $(BUILD)/_boot.mpy \
                                        $(BUILD)/ntptime.mpy \\r
                                        $(BUILD)/request.mpy \\r
                                        $(BUILD)/log.mpy \\r
-                                       $(BUILD)/aLiYun.mpy \\r
                                        $(BUILD)/hmac.mpy \\r
                                        $(BUILD)/hashlib.mpy \\r
                                        $(BUILD)/base64.mpy \\r
-                                       $(BUILD)/TenCentYun.mpy \\r
                                        $(BUILD)/file_sha256.mpy \\r
                                        $(BUILD)/checksum.mpy \\r
                                        $(BUILD)/checkNet.mpy \\r
@@ -1041,7 +1037,6 @@ frozen_mpy_list =         $(BUILD)/_boot_WDF.mpy \
                                        $(BUILD)/dataCall.mpy \\r
                                        $(BUILD)/log.mpy \\r
                                        $(BUILD)/request.mpy \\r
-                                       $(BUILD)/aLiYun.mpy \\r
                                        $(BUILD)/hmac.mpy \\r
                                        $(BUILD)/hashlib.mpy \\r
                                        $(BUILD)/ntptime.mpy \\r
@@ -1069,7 +1064,6 @@ frozen_mpy_list =         $(BUILD)/_boot.mpy \
                                        $(BUILD)/dataCall.mpy \\r
                                        $(BUILD)/log.mpy \\r
                                        $(BUILD)/request.mpy \\r
-                                       $(BUILD)/aLiYun.mpy \\r
                                        $(BUILD)/hmac.mpy \\r
                                        $(BUILD)/hashlib.mpy \\r
                                        $(BUILD)/ntptime.mpy \\r
@@ -1128,11 +1122,9 @@ frozen_mpy_list =        $(BUILD)/_boot.mpy \
                                        $(BUILD)/ntptime.mpy \\r
                                        $(BUILD)/request.mpy \\r
                                        $(BUILD)/log.mpy \\r
-                                       $(BUILD)/aLiYun.mpy \\r
                                        $(BUILD)/hmac.mpy \\r
                                        $(BUILD)/hashlib.mpy \\r
                                        $(BUILD)/base64.mpy \\r
-                                       $(BUILD)/TenCentYun.mpy \\r
                                        $(BUILD)/file_sha256.mpy \\r
                                        $(BUILD)/checksum.mpy \\r
                                        $(BUILD)/checkNet.mpy \\r
index 34fc83e..476d06a 100644 (file)
@@ -54,30 +54,50 @@ dev_type = "7PIN"#柒牌
 #dev_type = 'jswm'#金三完美
 #dev_type = 'jswm_BOX'#金三完美柒牌盒子
 #dev_type = 'csh'#慈善会
+
+# 1=打印 VC 周期性保活专用行「VC: ping ping」;默认 0(WS send 明细里已跳过 ping,无需再开此项)
+VC_HEARTBEAT_LOG = 0
+# 1=打印音频帧级日志:Record_len / send_data len / 下发音频长度;默认 0(只看 uplink START/END)
+# VC_AUDIO_FRAME_LOG = 0
+# 1=启用我们新增的协议层“严格门控/缓冲/兜底”行为;0=临时排除这些改动(用于定位协议影响)
+# 受控项(与对端 V2.5+ 能力对齐;关=回退旧端行为):
+# - 上行:收到 started 前缓冲/丢弃 PCM;PTT 模式下 audio.create 前禁发 PCM
+# - 兜底:llm.completed 后若久无 tts.started,端侧 force_tts 主动发 synthesis
+# 注意:audio.played 不受此开关控制——金三 V2.5 服务端依赖端侧播完后上报才能进入下一轮 ASR;
+#       若误关会导致问候结束后首轮用户说话无 vad/asr。
+VC_PROTO_GUARDS = 0
+# 1=打印 VC WebSocket 每条收发的完整文本(流量大、易刷屏);0 仅一行 event 摘要(llm.delta 省略)
+VC_WS_IO_LOG = 1
+
+# 用于现场确认设备实际运行的 ai_VC 版本(只打印一次,不影响业务)
+VC_BUILD_TAG = "fsm_tts_actions_2026-05-06"
+
+
 class AI_led(Abstract):
     def __init__(self):
         if dev_type == "G10" or dev_type == 'jswm':
-            self.led_red = Pin(Pin.GPIO36, Pin.OUT, Pin.PULL_PD, 0)
-            self.led_green = Pin(Pin.GPIO44, Pin.OUT, Pin.PULL_PD, 1)
-            self.led_blue = Pin(Pin.GPIO21, Pin.OUT, Pin.PULL_PD, 1)
+            r_gpio, g_gpio, b_gpio = Pin.GPIO36, Pin.GPIO44, Pin.GPIO21
         elif dev_type == "7PIN_BK_EU":
-            self.led_red = Pin(Pin.GPIO36, Pin.OUT, Pin.PULL_PD, 0)
-            #self.led_green = Pin(Pin.GPIO21, Pin.OUT, Pin.PULL_PD, 1)
-            self.led_green = Pin(Pin.GPIO8, Pin.OUT, Pin.PULL_PD, 1)
-            self.led_blue = Pin(Pin.GPIO19, Pin.OUT, Pin.PULL_PD, 1)
+            r_gpio, g_gpio, b_gpio = Pin.GPIO36, Pin.GPIO8, Pin.GPIO19
         elif dev_type == "7PIN_1_5":
-            self.led_red = Pin(Pin.GPIO28, Pin.OUT, Pin.PULL_PD, 0)
-            #self.led_green = Pin(Pin.GPIO21, Pin.OUT, Pin.PULL_PD, 1)
-            self.led_green = Pin(Pin.GPIO44, Pin.OUT, Pin.PULL_PD, 1)
-            self.led_blue = Pin(Pin.GPIO30, Pin.OUT, Pin.PULL_PD, 1)
+            r_gpio, g_gpio, b_gpio = Pin.GPIO28, Pin.GPIO44, Pin.GPIO30
+        elif dev_type == "7PIN":
+            r_gpio, g_gpio, b_gpio = Pin.GPIO36, Pin.GPIO44, Pin.GPIO43
         else:
-            self.led_red = Pin(Pin.GPIO36, Pin.OUT, Pin.PULL_PD, 0)
-            self.led_green = Pin(Pin.GPIO21, Pin.OUT, Pin.PULL_PD, 1)
-            self.led_blue = Pin(Pin.GPIO19, Pin.OUT, Pin.PULL_PD, 1)
-
-        #=1 红灯闪,=2白灯闪,=3绿灯闪,=4全部灯闪
+            r_gpio, g_gpio, b_gpio = Pin.GPIO36, Pin.GPIO21, Pin.GPIO19
+        self._led_gpio_r = r_gpio
+        self._led_gpio_g = g_gpio
+        self._led_gpio_b = b_gpio
+        self.led_red = Pin(r_gpio, Pin.OUT, Pin.PULL_PD, 0)
+        self.led_green = Pin(g_gpio, Pin.OUT, Pin.PULL_PD, 1)
+        self.led_blue = Pin(b_gpio, Pin.OUT, Pin.PULL_PD, 1)
+        print("AI_led GPIO dev_type={} red={} green={} blue={}".format(
+            dev_type, r_gpio, g_gpio, b_gpio))
+
+        #=1 红灯闪,=2蓝灯闪,=3绿灯闪,=4全部灯闪,=5 思考:蓝绿交替
         self.led_flicker_flag = 0
         self.led_flicker_on_off_flag = 0
+        self.led_think_phase = 0
         self.led_flicker_timer = osTimer()
         
         EventMesh.subscribe("open_led_red",self.open_led_red)
@@ -90,30 +110,105 @@ class AI_led(Abstract):
         EventMesh.subscribe("flicker_led_blue",self.flicker_led_blue)
         EventMesh.subscribe("flicker_led_green",self.flicker_led_green)
         EventMesh.subscribe("flicker_led_all",self.flicker_led_all)
+        EventMesh.subscribe("flicker_led_blue_green",self.flicker_led_blue_green)
         EventMesh.subscribe("close_led",self.close_led)
         EventMesh.publish("open_led_red")
 
+        # 开机演示:每秒切换一次 LED,红->绿->蓝 为一轮,共 2 轮后停止
+        self._boot_led_demo_timer = osTimer()
+        self._boot_led_demo_step = 0
+        self._boot_led_demo_left = 3 * 2  # 3 色 * 2 轮
+        self._boot_led_demo_timer.start(1000, 1, self._boot_led_demo_timer_cb)
+
+    def _boot_led_demo_timer_cb(self, *args, **kwargs):
+        EventMesh.publish("publish_AI_btnInfo", {"func": self._boot_led_demo_timer_cb_})
+
+    def _boot_led_demo_timer_cb_(self):
+        try:
+            if self._boot_led_demo_left <= 0:
+                self._boot_led_demo_timer.stop()
+                return
+            # 不与闪烁抢占:演示期强制关掉闪烁
+            self.led_flicker_flag = 0
+            self._led_stop_flicker_timer()
+
+            if (self._boot_led_demo_step % 3) == 0:
+                print("boot_led_demo: RED (gpio={})".format(
+                    self._led_gpio_r))
+                self.open_led_red(None, None)
+            elif (self._boot_led_demo_step % 3) == 1:
+                print("boot_led_demo: GREEN (gpio={})".format(
+                    self._led_gpio_g))
+                self.open_led_green(None, None)
+            else:
+                print("boot_led_demo: BLUE (gpio={})".format(
+                    self._led_gpio_b))
+                self.open_led_blue(None, None)
+
+            self._boot_led_demo_step += 1
+            self._boot_led_demo_left -= 1
+            if self._boot_led_demo_left <= 0:
+                self._boot_led_demo_timer.stop()
+        except Exception as e:
+            try:
+                self._boot_led_demo_timer.stop()
+            except Exception:
+                pass
+            print("_boot_led_demo_timer_cb_ fail: {}".format(e))
+
+    def _led_stop_flicker_timer(self):
+        try:
+            self.led_flicker_timer.stop()
+        except Exception:
+            pass
+
     def flicker_led_red(self,event,msg):
+        self._led_stop_flicker_timer()
         self.led_flicker_flag = 1
         self.led_flicker_timer.start(1500, 1, self.led_flicker_timer_cb)
 
     def flicker_led_blue(self,event,msg):
+        self._led_stop_flicker_timer()
         self.led_flicker_flag = 2
         self.led_flicker_timer.start(1500, 1, self.led_flicker_timer_cb)
 
     def flicker_led_green(self,event,msg):
+        self._led_stop_flicker_timer()
         self.led_flicker_flag = 3
         self.led_flicker_timer.start(1500, 1, self.led_flicker_timer_cb)
 
     def flicker_led_all(self,event,msg):
+        self._led_stop_flicker_timer()
         self.led_flicker_flag = 4
         self.led_flicker_timer.start(1500, 1, self.led_flicker_timer_cb)
 
+    def flicker_led_blue_green(self,event,msg):
+        # 思考态:蓝/绿交替亮(各占半个周期),红灯关闭
+        self._led_stop_flicker_timer()
+        self.led_flicker_flag = 5
+        self.led_think_phase = 0
+        self.led_red.write(0)
+        self.led_blue.write(1)
+        self.led_green.write(0)
+        self.led_think_phase = 1
+        self.led_flicker_timer.start(400, 1, self.led_flicker_timer_cb)
+
     def led_flicker_timer_cb(self, *args, **kwargs):
         EventMesh.publish("publish_AI_btnInfo",{"func":self.led_flicker_timer_cb_})  
     def led_flicker_timer_cb_(self):
         if self.led_flicker_flag == 0:
-            self.led_flicker_timer.stop()
+            self._led_stop_flicker_timer()
+            return
+        if self.led_flicker_flag == 5:
+            self.led_red.write(0)
+            if self.led_think_phase == 0:
+                self.led_blue.write(1)
+                self.led_green.write(0)
+                self.led_think_phase = 1
+            else:
+                self.led_blue.write(0)
+                self.led_green.write(1)
+                self.led_think_phase = 0
             return
         if self.led_flicker_on_off_flag == 0:
             self.led_red.write(0)
@@ -142,6 +237,7 @@ class AI_led(Abstract):
     def open_led_red(self,event,msg):
         #print("open_led_red")
         self.led_flicker_flag = 0
+        self._led_stop_flicker_timer()
         #self.led_red.write(0)
         self.led_blue.write(0)
         self.led_green.write(0)
@@ -150,6 +246,7 @@ class AI_led(Abstract):
     def open_led_blue(self,event,msg):
         #print("open_led_blue")
         self.led_flicker_flag = 0
+        self._led_stop_flicker_timer()
         self.led_red.write(0)
         #self.led_blue.write(0)
         self.led_green.write(0)
@@ -158,6 +255,7 @@ class AI_led(Abstract):
     def open_led_green(self,event,msg):
         #print("open_led_green")
         self.led_flicker_flag = 0
+        self._led_stop_flicker_timer()
         self.led_red.write(0)
         self.led_blue.write(0)
         #self.led_green.write(0)
@@ -166,6 +264,7 @@ class AI_led(Abstract):
     def open_led_red_blue(self,event,msg):
         #print("open_led_blue")
         self.led_flicker_flag = 0
+        self._led_stop_flicker_timer()
         self.led_red.write(1)
         #self.led_blue.write(0)
         self.led_green.write(0)
@@ -174,6 +273,7 @@ class AI_led(Abstract):
     def open_led_red_green(self,event,msg):
         #print("open_led_blue")
         self.led_flicker_flag = 0
+        self._led_stop_flicker_timer()
         self.led_red.write(1)
         #self.led_blue.write(0)
         self.led_green.write(1)
@@ -182,6 +282,7 @@ class AI_led(Abstract):
     def open_led_all(self,event,msg):
         #print("open_led_all")
         self.led_flicker_flag = 0
+        self._led_stop_flicker_timer()
         #self.led_red.write(0)
         self.led_blue.write(1)
         self.led_green.write(1)
@@ -190,6 +291,11 @@ class AI_led(Abstract):
     def close_led(self,event,msg):
         #print("close_led")
         self.led_flicker_flag = 0
+        self._led_stop_flicker_timer()
+        try:
+            self._boot_led_demo_timer.stop()
+        except Exception:
+            pass
         self.led_red.write(0)
         self.led_blue.write(0)
         self.led_green.write(0)
@@ -366,14 +472,12 @@ class AI_UART():
             self.read_uart_data = ""
             self.uart_timer.stop()
     def split_combined_json(self,combined_str):
-        """
-        将连续的JSON字符串分割为多个独立的JSON字符串列表
-        
-        参数:
-            combined_str: 多个JSON拼接的字符串
-        返回:
-            分割后的
-        """
+        # 将连续的JSON字符串分割为多个独立的JSON字符串列表
+        #
+        # 参数:
+        #     combined_str: 多个JSON拼接的字符串
+        # 返回:
+        #     分割后的
         json_list = []
         start_index = 0
         brace_count = 0  # 用于匹配{}括号
@@ -839,9 +943,7 @@ class AI_UART():
             "event": "get_versions",
         }
         json_str  =ujson.dumps(json_data)
-        print("send_get_versions{}".format(json_str))
-        print("send_get_versions{}".format(json_str))
-        print("send_get_versions{}".format(json_str))
+        print("send_get_versions {}".format(json_str))
         self.send_data(json_str,1)
         #self.AI_uart.write(json_str)
         count =1000
@@ -1236,21 +1338,15 @@ class AI_dev(Abstract):
         if dev_type != "7PIN_1_5": #不是1.5代不能发串口数据
             return
         print("sys_sleep_cb!!!!!!!!!!!!!!!")
-        print("sys_sleep_cb!!!!!!!!!!!!!!!")
-        print("sys_sleep_cb!!!!!!!!!!!!!!!")
         EventMesh.publish("sleep_BT")#休眠蓝牙
         self.PIN_wakeup_bt.write(0)
     def sys_up_cb(self,event=None,msg=None):#唤醒前回调
         if dev_type != "7PIN_1_5": #不是1.5代不能发串口数据
             return
         print("sys_up_cb!!!!!!!!!!!!!!!")
-        print("sys_up_cb!!!!!!!!!!!!!!!")
-        print("sys_up_cb!!!!!!!!!!!!!!!")
         self.wakeup_BT()
         self.PIN_wakeup_bt.write(1)
     def rest_TB(self,event=None,msg=None):
-        print("rest_TB!!!!!!!!!!!!!!!")
-        print("rest_TB!!!!!!!!!!!!!!!")
         print("rest_TB!!!!!!!!!!!!!!!")
         self.PIN_BT_ON.write(0)
         utime.sleep_ms(100)
@@ -1259,7 +1355,6 @@ class AI_dev(Abstract):
         if dev_type != "7PIN_1_5": #不是1.5代不能发串口数据
             return
         print("wakeup_BT!!!!!!!!!!!!!!!")
-        print("wakeup_BT!!!!!!!!!!!!!!!")
         wakeup_cta1_io = self.extint_wakeup_cta1.read_level()
         if wakeup_cta1_io == 1:
             return 1
@@ -1699,7 +1794,13 @@ class AI_dev(Abstract):
             return 10
     def audio_montage_play(self,event,msg):
         self.wakeup_BT()
-        
+        # 抢占规则:流播放(TTS)可打断本地;本地提示音不可打断流播放
+        try:
+            if EventMesh.publish("get_vc_stream_active") == 1:
+                print("[audio-arb] skip local montage_play during stream")
+                return
+        except Exception:
+            pass
         self.aud.stopPlayStream()
         self.aud.stopAll()
         str_data='mp3files='
@@ -2571,9 +2672,557 @@ AI_client_VC_num = 3
 AI_client_asr_num = 4
 
 
+class VCSessionState:
+    Disconnected = 0
+    Connecting = 1
+    Starting = 2
+    Started = 3
+
+
+class VCTurnState:
+    Idle = 0
+    Listening = 1
+    SpeechStreaming = 2
+    SpeechEnded = 3
+    Thinking = 4
+    TtsReceiving = 5
+    Playing = 6
+    AwaitPlayedAck = 7
+
+
+class VCMode:
+    Realtime = 0       # VAD on(长对话,协议:无需 audio.create/completed)
+    PushToTalk = 1     # VAD off(单对话,协议:必须 audio.create/completed 包裹音频帧)
+
+
+class VCStateMachine:
+    # 轻量状态机骨架(先落地骨架与动作接口,后续逐步把旧 if/flag 逻辑收敛进来)。
+    #
+    # 约束来自《金三服务-语音对话V2.5》:
+    # - 收到 started 后才允许发音频帧(否则 session not initialized)
+    # - VAD 开启:不需要 audio.create/completed
+    # - VAD 关闭:必须 audio.create → frames → audio.completed
+    # - 播放结束 audio.played 是进入下一轮的门控信号之一(尤其关键词打断场景)
+
+    def __init__(self, vc):
+        self.vc = vc
+        self.session = VCSessionState.Disconnected
+        self.turn = VCTurnState.Idle
+        self.last_llm_ms = 0
+        self.last_tts_pending_ms = 0
+        self.last_force_tts_ms = 0
+        self.await_played = False
+        self.last_tts_completed_ms = 0
+
+    def _turn_name(self, t):
+        try:
+            return {
+                VCTurnState.Idle: "Idle",
+                VCTurnState.Listening: "Listening",
+                VCTurnState.SpeechStreaming: "SpeechStreaming",
+                VCTurnState.SpeechEnded: "SpeechEnded",
+                VCTurnState.Thinking: "Thinking",
+                VCTurnState.TtsReceiving: "TtsReceiving",
+                VCTurnState.Playing: "Playing",
+                VCTurnState.AwaitPlayedAck: "AwaitPlayedAck",
+            }.get(t, str(t))
+        except Exception:
+            return str(t)
+
+    def _session_name(self, s):
+        try:
+            return {
+                VCSessionState.Disconnected: "Disconnected",
+                VCSessionState.Connecting: "Connecting",
+                VCSessionState.Starting: "Starting",
+                VCSessionState.Started: "Started",
+            }.get(s, str(s))
+        except Exception:
+            return str(s)
+
+    def log(self, reason=""):
+        # 只用于关键节点的单行状态日志,便于串口快速定位状态漂移
+        try:
+            print("[FSM] {} session={} turn={} await_played={}".format(
+                reason,
+                self._session_name(self.session),
+                self._turn_name(self.turn),
+                1 if self.await_played else 0,
+            ))
+        except Exception:
+            pass
+
+    def mode(self):
+        try:
+            return VCMode.Realtime if self.vc.VC_mode == 0 else VCMode.PushToTalk
+        except Exception:
+            return VCMode.Realtime
+
+    def on_session_start_request(self):
+        self.session = VCSessionState.Starting
+        self.turn = VCTurnState.Idle
+        # 新会话开始时必须清空上一轮“待播完”状态,否则会出现:
+        # - 切换模式/重连后 await_played 仍为 True → play_end 逻辑抢跑/不触发 → 无法自动开麦
+        self.await_played = False
+        self.last_tts_completed_ms = 0
+        self.last_llm_ms = 0
+
+    def on_server_started(self):
+        self.session = VCSessionState.Started
+        # 新会话 started:确保进入 Listening,且清理上轮播放门控
+        self.await_played = False
+        self.last_tts_completed_ms = 0
+        if self.turn in (VCTurnState.Idle, VCTurnState.AwaitPlayedAck):
+            self.turn = VCTurnState.Listening
+        self.log("server.started")
+
+    def on_server_vad_silence(self):
+        if self.session == VCSessionState.Started:
+            self.turn = VCTurnState.SpeechEnded
+        self.log("server.vad.silence")
+
+    def on_server_llm_created(self):
+        if self.session == VCSessionState.Started:
+            self.turn = VCTurnState.Thinking
+            try:
+                self.last_llm_ms = utime.ticks_ms()
+            except Exception:
+                self.last_llm_ms = 1
+        self.log("server.llm.created")
+
+    def on_server_tts_started(self):
+        if self.session == VCSessionState.Started:
+            self.turn = VCTurnState.TtsReceiving
+        self.log("server.tts.started")
+
+    def on_server_tts_completed(self):
+        if self.session == VCSessionState.Started:
+            # tts.completed 只表示服务端音频下发结束,本地可能仍在播放;保持 Playing,由 await_played 门控播完上报
+            self.await_played = True
+            try:
+                self.last_tts_completed_ms = utime.ticks_ms()
+            except Exception:
+                self.last_tts_completed_ms = 1
+        self.log("server.tts.completed")
+
+    def on_server_canceled(self):
+        if self.session == VCSessionState.Started:
+            self.turn = VCTurnState.Listening
+        self.log("server.canceled")
+
+    def on_server_completed(self):
+        if self.session == VCSessionState.Started:
+            # 本轮结束:默认回到 Listening;是否自动开麦由 action 门控决定
+            # 若仍在等待本地播完(await_played),completed 不应把 turn 拉回 Listening,
+            # 否则会触发 completed→uplink_start 抢跑,打断播放链路。
+            if not self.await_played and self.turn != VCTurnState.AwaitPlayedAck:
+                self.turn = VCTurnState.Listening
+        self.log("server.completed")
+
+    def on_server_event(self, event, jsload_data=None):
+        # 事件→动作(action list)适配层。
+        # 现阶段只产出“可幂等的控制动作”(uplink start/stop),避免和旧逻辑重复触发播放等副作用。
+        # 后续会逐步把更多行为(play_start/audio.played/ptt create/completed)迁移到这里。
+        actions = []
+        try:
+            # LLM 阶段必须驱动 FSM.turn,否则 turn 会停在 SpeechEnded,导致:
+            # - _fsm_timeout_table_tick 里「Thinking 超时 / synthesis 超时」永不命中
+            # - 与注释和 force_tts 前置条件不一致
+            if event == "llm.created":
+                self.on_server_llm_created()
+                return actions
+            if event == "llm.completed":
+                # 若对端偶发跳过 llm.created,用 completed 把 turn 补到 Thinking,避免兜底全失效
+                try:
+                    if self.session == VCSessionState.Started:
+                        if self.turn in (VCTurnState.SpeechEnded, VCTurnState.Listening):
+                            self.turn = VCTurnState.Thinking
+                except Exception:
+                    pass
+                return actions
+            if event == "started":
+                self.on_server_started()
+                return actions
+            if event == "vad.silence":
+                self.on_server_vad_silence()
+                actions.append(("uplink_stop", "vad.silence"))
+                return actions
+            if event == "tts.started":
+                self.on_server_tts_started()
+                actions.append(("uplink_stop", "tts.started"))
+                actions.append(("play_start", "tts.started"))
+                return actions
+            if event == "tts.completed":
+                self.on_server_tts_completed()
+                # 播放可能已在 started 时开启,这里不再强制 play_start,避免反复 open PCM
+                return actions
+            if event == "canceled":
+                self.on_server_canceled()
+                # canceled:若处于长对话,立刻回到聆听并尝试开麦(由 _uplink_request_start 的门控保证安全)
+                actions.append(("uplink_start", "canceled"))
+                return actions
+            if event == "completed":
+                self.on_server_completed()
+                # completed:若不在等待 audio.played(说明本轮无 TTS),长对话可直接进入下一轮聆听
+                if (not getattr(self, "await_played", False)) and self.turn != VCTurnState.AwaitPlayedAck:
+                    actions.append(("uplink_start", "completed"))
+                return actions
+        except Exception:
+            pass
+        return actions
+
+    def on_local_audio_played(self):
+        # 本地播报完成后:长对话回到 Listening,等待下一轮输入
+        if self.session == VCSessionState.Started:
+            self.turn = VCTurnState.Listening
+            self.await_played = False
+            self.last_tts_completed_ms = 0
+        self.log("local.audio_played")
+
+    def on_local_play_start(self):
+        if self.session == VCSessionState.Started:
+            self.turn = VCTurnState.Playing
+        self.log("local.play_start")
+
+    def on_local_synthesis_sent(self):
+        # synthesis 发出后到 tts.started 之间:视为 TTS 接收阶段,禁止开麦
+        if self.session == VCSessionState.Started:
+            self.turn = VCTurnState.TtsReceiving
+        self.log("local.synthesis_sent")
+
+    def on_local_play_end(self):
+        actions = []
+        try:
+            if self.session != VCSessionState.Started:
+                return actions
+            if self.await_played:
+                actions.append(("send_audio_played", "local.play_end"))
+                # 当前策略:长对话模式不做“播完自动开麦”,避免静音时持续占用麦克/上行、误触发和隐私争议。
+                # 因此这里不追加 uplink_start。
+                try:
+                    k = getattr(self.vc, "_last_tts_kind", "") or ""
+                except Exception:
+                    k = ""
+            self.on_local_audio_played()
+        except Exception:
+            pass
+        self.log("local.play_end")
+        return actions
+
+
 class AI_VC(Abstract):
+    def get_vc_stream_active(self, *args, **kwargs):
+        # 供本地提示音侧查询:TTS 流播放期间本地不可抢占。
+        try:
+            if getattr(self, "VC_Play_flag", 0) == 1:
+                return 1
+        except Exception:
+            pass
+        try:
+            if getattr(self.fsm, "await_played", False):
+                return 1
+        except Exception:
+            pass
+        try:
+            t = getattr(self.fsm, "turn", VCTurnState.Idle)
+            if t in (VCTurnState.TtsReceiving, VCTurnState.Playing):
+                return 1
+        except Exception:
+            pass
+        try:
+            if getattr(self, "_tts_pending", 0) == 1:
+                return 1
+        except Exception:
+            pass
+        return 0
+
+    # ===== Push-To-Talk(单对话模式)=====
+    # 语义:
+    # - 长按:无论当前处于 TTS/Thinking/任何中间态,立即打断并进入新一轮录音上行
+    # - 松开:停止录音并等待服务端返回(由 AI_client_VC_end_speech 触发)
+    def ptt_begin(self, reason="key.long_press"):
+        try:
+            if self.VC_mode != 1:
+                return
+        except Exception:
+            return
+
+        t0 = 0
+        try:
+            t0 = utime.ticks_ms()
+        except Exception:
+            t0 = 0
+
+        # 本地先停播并清空队列,避免“边播边录/回声触发/状态机乱跳”
+        try:
+            self.VC_init()
+        except Exception:
+            pass
+
+        # 若上一轮录音尚未结束,先强制停掉,避免 record_thread 复用出错
+        try:
+            if getattr(self, "VC_record_state_flag", 0) != 0:
+                self.VC_Speech_End()
+        except Exception:
+            pass
+
+        # 会话未连接:先建链并发 Start,等待 started 后再自动开麦
+        try:
+            if getattr(self, "AI_client_VC_isconnect", 0) != 1:
+                self._ptt_force_record_on_started = 1
+                self.AI_VC_start()
+                return
+        except Exception:
+            self._ptt_force_record_on_started = 1
+            try:
+                self.AI_VC_start()
+            except Exception:
+                pass
+            return
+
+        # 会话还没 initialized:先发 Start,请求服务端进入 started
+        if getattr(self, "_vc_session_started", 0) != 1:
+            self._ptt_force_record_on_started = 1
+            try:
+                try:
+                    if t0:
+                        print("[PTT] wait server.started (ms={})".format(utime.ticks_ms() - t0))
+                except Exception:
+                    pass
+                self.AI_client_VC_start_requset()
+            except Exception:
+                pass
+            return
+
+        # 打断服务端当前 turn(如正在 tts/思考),然后开启新一轮 PTT
+        try:
+            cancel = {"event": "cancel", "payload": {}}
+            self.AI_client_VC_send(ujson.dumps(cancel))
+        except Exception:
+            pass
+
+        # 新一轮:先发 audio.create,再开麦(record_thread 以 _ptt_audio_created 门控)
+        try:
+            self._ptt_audio_created = 0
+        except Exception:
+            pass
+        try:
+            self.AI_client_VC_send_speech()
+        except Exception:
+            pass
+        try:
+            self.VC_Speech_Start_now()
+        except Exception:
+            pass
+        try:
+            if t0:
+                print("[PTT] record started (ms={})".format(utime.ticks_ms() - t0))
+        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"):
+                        print("[VC][uplink_ctrl] Deny START by {} cause=Close_flag".format(reason))
+                except Exception:
+                    pass
+                return
+        except Exception:
+            return
+
+        # 连续对话才允许“自动开新一轮上行”
+        try:
+            if self.VC_mode != 0:
+                try:
+                    if reason in ("canceled", "local.play_end", "audio.played"):
+                        print("[VC][uplink_ctrl] Deny START by {} cause=VC_mode({})".format(reason, self.VC_mode))
+                except Exception:
+                    pass
+                return
+        except Exception:
+            return
+
+        # ===== 业务门控(收敛到 FSM turn/session)=====
+        try:
+            if getattr(self.fsm, "session", VCSessionState.Disconnected) != VCSessionState.Started:
+                try:
+                    if reason in ("canceled", "local.play_end", "audio.played"):
+                        print("[VC][uplink_ctrl] Deny START by {} cause=fsm.session".format(reason))
+                except Exception:
+                    pass
+                return
+            turn = getattr(self.fsm, "turn", VCTurnState.Idle)
+            # 仅 Listening/SpeechEnded 允许开麦进入下一轮;其它阶段一律拒绝(避免抢跑打乱链路)
+            if turn not in (VCTurnState.Listening, VCTurnState.SpeechEnded):
+                try:
+                    if reason in ("canceled", "local.play_end", "audio.played"):
+                        print("[VC][uplink_ctrl] Deny START by {} cause=fsm.turn({})".format(reason, turn))
+                except Exception:
+                    pass
+                return
+        except Exception:
+            return
+
+        # 若当前不在录音上行,才允许开新一轮
+        if getattr(self, "VC_record_state_flag", 0) != 0:
+            try:
+                if reason in ("canceled", "local.play_end", "audio.played"):
+                    print("[VC][uplink_ctrl] Deny START by {} cause=VC_record_state_flag({})".format(
+                        reason, getattr(self, "VC_record_state_flag", 0)))
+            except Exception:
+                pass
+            return
+
+        # 新一轮开始录音:允许上行(上一轮可能已置 silence 闸门)
+        if getattr(self, "record_data_save_flag", 0) == 0:
+            self._server_vad_silence = 0
+
+        print("[VC][uplink_ctrl] Request START by {}".format(reason))
+        self.VC_Speech_Start_now()
+
+    def _uplink_request_stop(self, reason="", *args, **kwargs):
+        # 统一入口:避免多处同时 stop 造成冲突
+        if getattr(self, "record_data_save_flag", 0) == 0:
+            self._server_vad_silence = 1
+
+        if getattr(self, "VC_record_state_flag", 0) == 0:
+            return
+
+        print("[VC][uplink_ctrl] Request STOP by {}".format(reason))
+        self.VC_Speech_End()
+
+    def _uplink_mark_start(self, first_len=0):
+        try:
+            if self._uplink_active == 1:
+                return
+            self._uplink_active = 1
+            self._uplink_bytes = 0
+            self._uplink_start_ms = utime.ticks_ms()
+            print("[VC][uplink] START len={} VC_state={} VC_record_state_flag={}".format(
+                first_len, self.VC_state, self.VC_record_state_flag))
+        except Exception:
+            pass
+
+    def _uplink_mark_end(self, reason=""):
+        try:
+            if self._uplink_active != 1:
+                return
+            dur = utime.ticks_diff(utime.ticks_ms(), self._uplink_start_ms) if self._uplink_start_ms else -1
+            print("[VC][uplink] END reason={} bytes={} dur_ms={} VC_state={} VC_record_state_flag={}".format(
+                reason, self._uplink_bytes, dur, self.VC_state, self.VC_record_state_flag))
+        except Exception:
+            pass
+        self._uplink_active = 0
+        self._uplink_bytes = 0
+        self._uplink_start_ms = 0
+
+        # 最近一次 TTS 的语义来源(dialog/prompt/mode_switch_long/mode_switch_ptt/...)
+        self._last_tts_kind = ""
+        # 下一个“无 dialog_id 的提示音 TTS”在 tts.started 时采用该 kind
+        self._pending_tts_kind = ""
+
+    # ===== 状态机动作接口(骨架)=====
+    # 说明:统一把“副作用动作”收敛到这些方法,便于后续把 recv/timer 的 if/flag 逻辑迁移到 VCStateMachine。
+    def request_send_start(self):
+        EventMesh.publish("publish_AI_btnInfo", {"func": self.AI_client_VC_start_requset})
+
+    def request_send_audio_create(self):
+        EventMesh.publish("publish_AI_btnInfo", {"func": self.AI_client_VC_send_speech})
+
+    def request_send_audio_completed(self):
+        EventMesh.publish("publish_AI_btnInfo", {"func": self.AI_client_VC_end_speech})
+
+    def request_send_audio_played(self):
+        EventMesh.publish("publish_AI_btnInfo", {"func": self.AI_client_VC_audio_played})
+
+    def request_send_synthesis(self, text):
+        try:
+            self.AI_TTS_str = text
+        except Exception:
+            pass
+        EventMesh.publish("publish_AI_btnInfo", {"func": self.AI_client_VC_synthesis})
+
+    def request_uplink_start(self, reason=""):
+        EventMesh.publish("publish_AI_btnInfo", {"func": self._uplink_request_start, "data": reason})
+
+    def request_uplink_stop(self, reason=""):
+        EventMesh.publish("publish_AI_btnInfo", {"func": self._uplink_request_stop, "data": reason})
+
+    def _fsm_apply_actions(self, actions):
+        try:
+            for a in actions:
+                if not a:
+                    continue
+                if a[0] == "uplink_stop":
+                    self.request_uplink_stop(a[1] if len(a) > 1 else "")
+                elif a[0] == "uplink_start":
+                    self.request_uplink_start(a[1] if len(a) > 1 else "")
+                elif a[0] == "play_start":
+                    try:
+                        self.fsm.on_local_play_start()
+                    except Exception:
+                        pass
+                    EventMesh.publish("publish_AI_btnInfo", {"func": self.VC_Play_Start})
+                elif a[0] == "send_audio_played":
+                    self.request_send_audio_played()
+        except Exception:
+            pass
+
     # AI
-    
+
+    def _print_hw_diag(self):
+        # 仅诊断使用:打印当前分支能看到的麦克/音频相关配置和运行时状态。
+        # JSWM2 老分支没有 device_profile/_PROFILE,这里宽容处理
+        try:
+            profile = _PROFILE if isinstance(_PROFILE, dict) else {}
+        except Exception:
+            profile = {}
+        dev_type_profile = profile.get("dev_type", None)
+        flags = profile.get("flags", {})
+        hardware = profile.get("hardware", {})
+        product_cfg = profile.get("product_cfg", {})
+        vc_cfg = {}
+        try:
+            vc_cfg = product_cfg.get("vc", {})
+        except Exception:
+            vc_cfg = {}
+
+        print("[hw-diag] ===== AI_VC hardware diag =====")
+        print("[hw-diag] profile.dev_type        =", dev_type_profile)
+        print("[hw-diag] profile.flags           =", flags)
+        print("[hw-diag] profile.hardware        =", hardware)
+        print("[hw-diag] product_cfg.vc          =", vc_cfg)
+
+        try:
+            print("[hw-diag] VC_record_samplerate   =", self.VC_record_samplerate)
+            print("[hw-diag] VC_play_samplerate     =", self.VC_play_samplerate)
+        except Exception:
+            pass
+        try:
+            print("[hw-diag] vad_silence_runtime    =", self.vad_silence)
+        except Exception:
+            pass
+        try:
+            print("[hw-diag] record_data_save_flag  =", self.record_data_save_flag)
+            print("[hw-diag] record_max_bytes       =", self.record_max_bytes)
+        except Exception:
+            pass
+
+        try:
+            g = self.record.gain_get()
+        except Exception as e:
+            g = "gain_get_error: {}".format(e)
+        print("[hw-diag] record.gain_get         =", g)
+        try:
+            print("[hw-diag] type(record)            =", type(self.record))
+            print("[hw-diag] type(aud)               =", type(self.aud))
+        except Exception:
+            pass
+        print("[hw-diag] ===== end of hw-diag =====")
+
     def __init__(self):
         # import voiceCall
         # voiceCall.callStart("10086")
@@ -2603,6 +3252,39 @@ class AI_VC(Abstract):
             self.VC_mode = 0
         self.is_up_error= 0 #是否上传错误id
         self.Close_flag =0 #=1时,连续对话播放完音频就退出
+        # 服务端 VAD 结束标志:收到 vad.silence 后置 1,用于硬闸门禁止继续上行音频
+        # (避免仍持续上行导致服务端推迟/抑制后续 TTS,从而一直停留在思考态蓝绿闪)
+        self._server_vad_silence = 0
+        # 单轮状态标志:用于判定 completed 是否应自动进入下一轮
+        self._turn_has_asr_completed = 0
+        self._turn_has_vad_silence = 0
+        self._turn_has_tts_started = 0
+        # VC 会话是否已进入 started(服务端 session initialized)
+        # 重要:在收到 started 前发送音频帧会触发服务端 "session not initialized" → failed → 端侧重连死循环
+        self._vc_session_started = 0
+        # Push-To-Talk(VAD关闭/单对话)是否已发送 audio.create
+        # 重要:协议要求 disable_vad=true 时必须 audio.create→音频帧→audio.completed
+        self._ptt_audio_created = 0
+        # 端侧主动发 synthesis 后到收到 tts.started 之间的窗口标志,用于阻止误开下一轮录音上行
+        self._tts_pending = 0
+        self._tts_pending_ms = 0
+        # 各类兜底的节流时间戳(避免“兜底过激/连续触发”)
+        self._fb_last_thinking_restart_ms = 0
+        self._fb_last_force_tts_ms = 0
+        self._fb_last_tts_pending_timeout_ms = 0
+        # thinking 兜底:部分情况下服务端只返回 llm.completed 而不触发 tts.started/completed,
+        # 会导致一直停留在 thinking。此时超时自动回到聆听。
+        self._llm_completed_ms = 0
+        # llm→tts 兜底:若服务端只回 llm.completed 没有自动触发 tts,则由端侧主动发 synthesis 触发 TTS
+        self._llm_text_pending_tts = ""
+        self._llm_force_tts_ms = 0
+        # 语音上行统计(真实发送到 VC 发送队列的开始/结束)
+        self._uplink_active = 0
+        self._uplink_bytes = 0
+        self._uplink_start_ms = 0
+
+        # 状态机骨架(后续逐步把旧逻辑迁移进来)
+        self.fsm = VCStateMachine(self)
 
         self.play_new_voice_flag =0 #播放文字留言flag
 
@@ -3006,6 +3688,11 @@ class AI_VC(Abstract):
         #self.record.gain_set(4,12)
         self.aud = audio.Audio(0)
         self.aud.setCallback(self.audio_cb)
+        # 确保 record/aud 已创建后再打印硬件/音频诊断信息(需要 gain_get/type(record))
+        try:
+            self._print_hw_diag()
+        except Exception as e:
+            usys.print_exception(e)
         #self.aud.setVolume(self.VC_TTS_volume //10)
         self.audio_play_state = 1 #音频播放完状态
         self.close_timerout = 0
@@ -3021,6 +3708,7 @@ class AI_VC(Abstract):
         self.alarm_play_flag = 0 #闹钟播放标志
         self.tts_in_data_len = 0
         self.tts_out_data_len = 0
+        self._need_audio_played_ack = 0#tts.completed 后需在本地播报结束后上报 audio.played;勿用 tts_in/out:tts.completed 会再调 VC_Play_Start 把二者清零
         self.VC_satrt_flag = 0
         self.VC_stop_play_audio_flag = 0
         self.VC_satrt_kws_flag = 0
@@ -3041,6 +3729,7 @@ class AI_VC(Abstract):
         EventMesh.subscribe("pwk_key_release", self.pwk_key_release)
         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("VC_error", self.VC_error)
         EventMesh.subscribe("audio_test", self.audio_test)
         EventMesh.subscribe("audio_test_close", self.audio_test_close)
@@ -3185,10 +3874,10 @@ class AI_VC(Abstract):
         self.pwk_notice_flag = 0
         EventMesh.publish("set_app_new_voice_flag",0)
     def audio_test(self,event = None, msg = None):
-        self.VC_Speech_Start()   
+        self._uplink_request_start("audio_test")
         self.VC_Play_Start()      
     def audio_test_close(self,event = None, msg = None):
-        self.VC_Speech_End()   
+        self._uplink_request_stop("audio_test_close")
         self.VC_Play_End()    
         if self.record_data_save_flag == 1:
             if self.record_fd :
@@ -3203,8 +3892,8 @@ class AI_VC(Abstract):
     def get_VC_satrt_flag(self, event, msg):
         return self.VC_satrt_flag
     #检查有没有意外打开ai部分功能
-    def chack_vc_start(self):
-        print("chack_vc_start:{} {} {} {}".format(self.VC_satrt_flag,self.VC_record_state_flag,self.AI_client_VC_isconnect,self.VC_Play_flag))
+    def check_vc_start(self):
+        print("check_vc_start:{} {} {} {}".format(self.VC_satrt_flag,self.VC_record_state_flag,self.AI_client_VC_isconnect,self.VC_Play_flag))
         if self.VC_satrt_flag == 0:
             if self.VC_record_state_flag != 0 or self.AI_client_VC_isconnect == 1 or  self.VC_Play_flag == 1:
                 self.Close()
@@ -3220,7 +3909,7 @@ class AI_VC(Abstract):
                     self.http_get_config()#获取/更新配置参数
                 except Exception as e:
                     usys.print_exception(e)
-        self.chack_vc_start()#30秒检查一下有没有关闭
+        self.check_vc_start()#30秒检查一下有没有关闭
     #提交错误id           
     def ud_error(self,error_data):
         response = None
@@ -3421,7 +4110,12 @@ class AI_VC(Abstract):
         if dev_type == "7PIN": #2代产品要延时播放按键音 为了连接蓝牙后不播放按键音的问题
             # print("1111111111播放按键音!!!!!!!!!!")
             # self.aud.aud_tone_play(10,400)#播放按键音
-            self.aud.play(1, 1, 'U:/static/start.mp3')  #播放按键音
+            # 抢占规则:本地提示音不可打断流播放
+            try:
+                if EventMesh.publish("get_vc_stream_active") != 1:
+                    self.aud.play(1, 1, 'U:/static/start.mp3')  #播放按键音
+            except Exception:
+                pass
         # 播放状态判定
         if  self.VC_state >= VC_State_TTSStart and self.VC_state <= VC_State_TTSEnd:
             print("【打断播放】设备正在说话,仅中断播放,不发起新对话")    
@@ -3429,6 +4123,15 @@ class AI_VC(Abstract):
             EventMesh.publish("publish_AI_btnInfo",{"func":self.AI_client_VC_RequestToSpeak})#打断
             return
 
+        # 长对话:单按键语义为“打断当前对话,然后继续开麦上行”
+        # - 若服务端返回 canceled,则会在 canceled 分支自动触发开麦(见 recv 里的 request_uplink_start("canceled"))
+        try:
+            if self.VC_mode == 0:
+                EventMesh.publish("publish_AI_btnInfo", {"func": self.AI_client_VC_RequestToSpeak})
+                return
+        except Exception:
+            pass
+
         EventMesh.publish(constants.EVENT.SYSTEM_UP)#唤醒休眠
         if EventMesh.publish("get_sos_flag")== 1:
             return
@@ -3469,7 +4172,6 @@ class AI_VC(Abstract):
                     pass
                     # self.aud.aud_tone_play(10,200)#播放按键音
         utime.sleep_ms(500)
-
         self.Close()
         if PRESS_AUDIO_MODE == 0:
             if dev_type == "7PIN" or dev_type == "7PIN_1_5":
@@ -3493,10 +4195,8 @@ class AI_VC(Abstract):
         # 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)})
-        #if self.VC_record_state_flag >1:#如果在录音,就先停止录音
-        self.VC_Speech_End()
-
-        self.VC_Speech_Start()    
+        self._uplink_request_stop("BT_AI_key_press/compat")
+        self._uplink_request_start("BT_AI_key_press/compat")
         self.AI_VC_start() 
         
         
@@ -3523,6 +4223,14 @@ class AI_VC(Abstract):
             if dev_type == "7PIN" or dev_type == "7PIN_1_5":
                 pass
                 # self.aud.aud_tone_play(10,400)#播放按键音
+        # 长对话:蓝牙按键同单按——打断并继续开麦上行
+        try:
+            if self.VC_mode == 0:
+                EventMesh.publish("publish_AI_btnInfo", {"func": self.AI_client_VC_RequestToSpeak})
+                return
+        except Exception:
+            pass
+
         self.Close()
         if PRESS_AUDIO_MODE == 0:
             if dev_type == "7PIN" or dev_type == "7PIN_1_5":
@@ -3544,10 +4252,8 @@ class AI_VC(Abstract):
         # 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)})
-        #if self.VC_record_state_flag >1:#如果在录音,就先停止录音
-        self.VC_Speech_End()
-
-        self.VC_Speech_Start()    
+        self._uplink_request_stop("BT_AI_key_press/compat2")
+        self._uplink_request_start("BT_AI_key_press/compat2")
         
         self.AI_VC_start() 
         
@@ -3563,7 +4269,7 @@ class AI_VC(Abstract):
             self.AI_ASR_str_tmp = ""
             self.AI_key_long_press_flag = 0
             EventMesh.publish("publish_AI_btnInfo",{"func":self.Close})
-            EventMesh.publish("publish_AI_btnInfo",{"func":self.VC_Speech_End})
+            self.request_uplink_stop("stop_AI_record")
             count = 0
             while True:
                 if count >= 20 or self.VC_satrt_flag == 0:
@@ -3622,22 +4328,37 @@ class AI_VC(Abstract):
         if self.VC_mode == 0:
             self.VC_mode = 1
             tts_str = "已切换到单对话模式" 
+            # 下一次“无 dialog_id 的提示音 TTS”归类为模式切换提示(单对话)
+            try:
+                self._pending_tts_kind = "mode_switch_ptt"
+                self._last_tts_kind = "mode_switch_ptt"
+            except Exception:
+                pass
         else:
             self.VC_mode = 0 
             tts_str = "已切换到长对话模式" 
+            # 下一次“无 dialog_id 的提示音 TTS”归类为模式切换提示(长对话)
+            try:
+                self._pending_tts_kind = "mode_switch_long"
+                self._last_tts_kind = "mode_switch_long"
+            except Exception:
+                pass
         EventMesh.publish("play_tts",[tts_str,1])
         EventMesh.publish("set_storage_config",["VC_mode",self.VC_mode])
 
     def AI_key_long_press(self, event, msg):#按键长按
         print("AI_key_long_press")
-        if dev_type == 'jswm' or dev_type == 'jswm_BOX'  :
+        # 单对话(PTT):任何中间态都允许打断并开启新一轮录音上行
+        if self.trans_flag == 1:#AI翻译模式
             self.Close()
+            EventMesh.publish("open_led_green")
+            EventMesh.publish("start_voice_record",4)#开始翻译录音
+        self.AI_key_long_press_flag = 1
+        if self.VC_mode == 1:
+            EventMesh.publish("publish_AI_btnInfo", {"func": self.ptt_begin})
         else:
-            if self.trans_flag == 1:#AI翻译模式
-                self.Close()
-                EventMesh.publish("open_led_green")
-                EventMesh.publish("start_voice_record",4)#开始翻译录音
-            self.AI_key_long_press_flag = 1
+            # 长对话:长按与单按一致——打断当前并继续对话(开麦上行)
+            EventMesh.publish("publish_AI_btnInfo", {"func": self.AI_key_press})
 
     def pwk_key_release(self, event, msg):#电源键短按,关闭
         print("pwk_key_release")
@@ -3666,23 +4387,27 @@ class AI_VC(Abstract):
     def set_VC_record_state_flag(self,data):
         
         self.VC_record_state_flag = data
-        print("set_VC_record_state_flag:{}".format(self.VC_record_state_flag))
+        print("[VC] record_state_flag={}".format(self.VC_record_state_flag))
     #开始录音,不缓存,直接发
     def VC_Speech_Start_now(self):
+        # 新一轮开始录音:允许上行(上一轮可能已置 silence 闸门)
+        self._server_vad_silence = 0
         self.VC_Speech_Start()
         if self.VC_record_state_flag  == 1:
             self.VC_record_state_flag =2
-        print("VC_Speech_Start_now:{}".format(self.VC_record_state_flag))
+        print("[VC] Speech_Start_now record_state={}".format(self.VC_record_state_flag))
     #开始录音,先缓存到buf,等服务器响应再发
     def VC_Speech_Start(self):
         try:
             
             #self.VC_state =  VC_State_SpeechStart
-            print("VC_Speech_Start VC_record_state_flag:{} self.AI_ASR_flag:{}!!!!!!!!".format(self.VC_record_state_flag,self.AI_ASR_flag))  
+            print("[VC] Speech_Start record_state={} AI_ASR_flag={}".format(self.VC_record_state_flag,self.AI_ASR_flag))
             if self.AI_ASR_flag  != 0:
                 return
             
             if self.VC_record_state_flag  == 0:
+                # 新一轮开始录音:允许上行(上一轮可能已置 silence 闸门)
+                self._server_vad_silence = 0
                 
                 self.VC_record_thread_close()#关闭录音线程
                 _thread.stack_size(self.Audio_buf_size+8*1024)
@@ -3724,6 +4449,7 @@ class AI_VC(Abstract):
     #停止录音
     def VC_Speech_End(self):
         try:
+            self._uplink_mark_end("VC_Speech_End")
             self.AI_ASR_flag = 0
             self.AI_ASR_str_tmp = ""
             self.AI_ASR_str = ""
@@ -3738,7 +4464,8 @@ class AI_VC(Abstract):
                 Record_read_buf = bytearray(self.Audio_buf_size)
                 for i in range(10):
                     Record_len = self.record.stream_read(Record_read_buf, self.Audio_buf_size)
-                    print("Record_len:!!!!!!{}".format(Record_len))
+                    # if VC_AUDIO_FRAME_LOG:
+                    #     print("Record_len:!!!!!!{}".format(Record_len))
                     if Record_len <= 0:
                         break
             elif self.VC_record_TYPE == VC_record_TYPE_PCM:
@@ -3747,7 +4474,8 @@ class AI_VC(Abstract):
                 Record_read_buf = bytearray(self.Audio_buf_size)
                 for i in range(10):
                     Record_len = self.record.stream_read(Record_read_buf, self.Audio_buf_size)
-                    print("Record_len:!!!!!!{}".format(Record_len))
+                    # if VC_AUDIO_FRAME_LOG:
+                    #     print("Record_len:!!!!!!{}".format(Record_len))
                     if Record_len <= 0:
                         break
                 # if self.record_pcm != None:
@@ -3764,7 +4492,7 @@ class AI_VC(Abstract):
             self.AI_record_tmp_queue.clear()
         
         self.VC_record_state_flag = 0
-        print("VC_Speech_End VC_record_state_flag:{}!!!!!!!!".format(self.VC_record_state_flag))   
+        print("[VC] Speech_End record_state={}".format(self.VC_record_state_flag))
     #关闭录音线程   
     def VC_record_thread_close(self): 
         if self.VC_record_thread_id != 0:
@@ -3790,7 +4518,7 @@ class AI_VC(Abstract):
 
     def VC_record_thread(self):
         ##return
-        print("VC_record_thread start!!!!!!!!!!!!!!!!!!!!\r\n")
+        print("[VC] record_thread start")
         count = 0
         RequestToSpeak_flag = 0
         # try:
@@ -3831,14 +4559,16 @@ class AI_VC(Abstract):
                 #print("VC_record_thread:{}".format(Record_len))
                 if Record_len > 0:
                     read_buf = bytes(Record_read_buf[:Record_len])
-                    print("Record_len:{}".format(Record_len))
+                    # if VC_AUDIO_FRAME_LOG:
+                    #     print("Record_len:{}".format(Record_len))
                     if self.record_data_save_flag == 1:
                         if self.record_fd == None:
                             try:
                                 uos.remove('/usr/record_data.pcm')
                             except Exception as e:
-                                usys.print_exception(e)
-                            self.record_fd = open('/usr/record_data.pcm','w')
+                                pass
+                            # 二进制写入,避免文本模式导致数据异常
+                            self.record_fd = open('/usr/record_data.pcm', 'wb')
                             self.record_fd_len = 0
                         if self.record_fd != None:
                             try:
@@ -3886,8 +4616,6 @@ class AI_VC(Abstract):
                                 break
                             if vad_state == 1:
                                 print("检测到VAD=1!!!!!!!!!!!!!")
-                                print("检测到VAD=1!!!!!!!!!!!!!")
-                                print("检测到VAD=1!!!!!!!!!!!!!")
    
                                 # EventMesh.publish("publish_AI_btnInfo",{"func":self.AI_client_VC_RequestToSpeak})#打断
                                 # RequestToSpeak_flag = 1
@@ -3919,8 +4647,37 @@ class AI_VC(Abstract):
                                     break
                                 else:
                                     pass
-                                    self.AI_client_VC_send(self.AI_record_tmp_queue.get())  #发送之前存的数据
-                            self.AI_client_VC_send(read_buf)   
+                                    # 协议门控:会话未 started 前禁止上行音频(否则 session not initialized)
+                                    if VC_PROTO_GUARDS and self._vc_session_started != 1 and self.record_data_save_flag == 0:
+                                        _ = self.AI_record_tmp_queue.get()
+                                        AI_record_tmp_queue_len = max(0, AI_record_tmp_queue_len - len(_))
+                                        continue
+                                    if not (self._server_vad_silence == 1 and self.record_data_save_flag == 0):
+                                        self.AI_client_VC_send(self.AI_record_tmp_queue.get())  #发送之前存的数据
+                                    else:
+                                        self._uplink_mark_end("silence_gate(drop_queue)")
+                                        _ = self.AI_record_tmp_queue.get()
+                            # 当前帧发送前协议门控(可用 VC_PROTO_GUARDS 临时关闭):
+                            # - session started 前禁止发帧
+                            # - Push-To-Talk 在 audio.create 前禁止发帧
+                            if VC_PROTO_GUARDS and (
+                                (self._vc_session_started != 1 and self.record_data_save_flag == 0) or
+                                (self.VC_mode == 1 and self._ptt_audio_created != 1 and self.record_data_save_flag == 0)
+                            ):
+                                # 未 ready:转入缓存,等待 started 再发(并做容量控制)
+                                try:
+                                    self.AI_record_tmp_queue.put(read_buf)
+                                    AI_record_tmp_queue_len += len(read_buf)
+                                    while AI_record_tmp_queue_len > 128*1024 and self.AI_record_tmp_queue.empty() != True:
+                                        d = self.AI_record_tmp_queue.get()
+                                        AI_record_tmp_queue_len = max(0, AI_record_tmp_queue_len - len(d))
+                                except Exception:
+                                    pass
+                            else:
+                                if not (self._server_vad_silence == 1 and self.record_data_save_flag == 0):
+                                    self.AI_client_VC_send(read_buf)
+                                else:
+                                    self._uplink_mark_end("silence_gate(drop_buf)")
                     
                     
 
@@ -4004,22 +4761,19 @@ class AI_VC(Abstract):
                 url = 'ws://{}/{}'.format(self.server_ip,req_str)
             #url = 'ws://115.190.94.208:22080/vc/coze?dt={}'.format(token)
             
-            print("VC VC:url{}".format(url))
+            if VC_WS_IO_LOG:
+                print("[VC] ws url {}".format(url))
             self.AI_client_VC = uwebsocket.Client.connect(url, debug=True)
             #self.AI_WS_connect_Queue.put(self.AI_client_VC)
 
        
-            print("websocket_connect_VC:OK!!!!!!!!!!!!!!!!!!!!!")
+            print("[VC] websocket_connect ok")
         except Exception as e: #网络连接异常
             usys.print_exception(e)
             print("websocket_connect_VC error!!!\r\n")
             str_error = str(e)
             if str_error.find("[Errno 22] EINVAL")!= -1: 
-                print("websocket_connect_VC:str_error:{}".format(str_error))
-                print("[Errno 22] EINVAL!!!\r\n")
-                print("[Errno 22] EINVAL!!!\r\n")
-                print("[Errno 22] EINVAL!!!\r\n")
-                print("[Errno 22] EINVAL!!!\r\n")
+                print("[VC] websocket EINVAL: {}".format(str_error))
                 self.websocket_error()
                 EventMesh.publish("set_storage_config",["powerRestart",1])#调试临时关闭
                 utime.sleep_ms(1000)
@@ -4064,7 +4818,8 @@ class AI_VC(Abstract):
         self.AI_ASR_str = ""
         self.AI_ASR_str_tmp = ""
         self.AI_ASR_flag  = 0
-        self.VC_Speech_End()
+        # 可能由收包线程/其它线程触发:统一切回主线程执行 stop,避免“异常→重试→再异常”的循环
+        EventMesh.publish("publish_AI_btnInfo",{"func":self._uplink_request_stop, "data":"websocket_error"})
         EventMesh.publish("close_led")
         if TTS_str == None:
             self.websocket_error_count+=1
@@ -4096,6 +4851,38 @@ class AI_VC(Abstract):
     def websocket_deconnect_VC(self):
         #print("websocket_deconnect_VC {}!!!!!!!".format(self.AI_client_VC_isconnect))
         print("websocket_deconnect_VC!!!!!!!")
+        self._vc_session_started = 0
+        # 断链/模式切换时必须重置“上行录音状态”,否则会被 _uplink_request_start 的幂等门控挡住,
+        # 表现为:切回长对话后无法自动开麦,按键 cancel/canceled 也无法恢复。
+        try:
+            self.VC_record_state_flag = 0
+        except Exception:
+            pass
+        try:
+            self._server_vad_silence = 0
+        except Exception:
+            pass
+        try:
+            self._ptt_audio_created = 0
+        except Exception:
+            pass
+        try:
+            # 若录音线程/录音设备仍在占用,先停掉
+            self.VC_Speech_End()
+        except Exception:
+            try:
+                self.VC_record_thread_close()
+            except Exception:
+                pass
+        # 断链必须同步重置 FSM,否则 await_played/turn 残留会导致动态切换后“无法自动开麦/按键无效”
+        try:
+            self.fsm.session = VCSessionState.Disconnected
+            self.fsm.turn = VCTurnState.Idle
+            self.fsm.await_played = False
+            self.fsm.last_tts_completed_ms = 0
+            self.fsm.last_llm_ms = 0
+        except Exception:
+            pass
         self.AI_client_VC_recv_thread_close()#关闭读线程
         self.AI_client_VC_recv_id = 0
         if self.AI_client_VC_isconnect == 1 or self.AI_client_VC!=None:
@@ -4134,7 +4921,8 @@ class AI_VC(Abstract):
         if self.AI_client_VC_isconnect == 1:
             #self.AI_client_VC.write_frame(0x09,b'ping ping')
             self.AI_client_VC_send("ping")
-            print("VC: ping ping")
+            if VC_HEARTBEAT_LOG:
+                print("VC: ping ping")
 
     #websocket发送数据    
     def AI_client_VC_send(self,data_str):
@@ -4143,26 +4931,46 @@ class AI_VC(Abstract):
         #     pass
         # else:
         #     print("AI_client_VC_send:len{}".format(len(data_str)))
+        # 统计“语音上行”真实开始/结束:这里只统计二进制音频(非 ping/字符串)
+        try:
+            if not isinstance(data_str, str):
+                l = len(data_str)
+                if l > 0:
+                    self._uplink_mark_start(l)
+                    self._uplink_bytes += l
+        except Exception:
+            pass
         self.AI_VC_send_data_queue.put([AI_client_VC_num,data_str]) 
      #关闭数据发送线程
     def AI_VC_send_data_queue_thread_close(self):
         if self.AI_VC_send_data_queue_thread_id != 0:
-            count =500
+            # 先清空待发队列,避免关闭过程持续堆积/刷屏
+            if self.AI_VC_send_data_queue.empty() != True:
+                self.AI_VC_send_data_queue.clear()
+            # 通知发送线程退出;若发送线程正阻塞在底层 send,则只能等超时或强制 stop_thread
+            self.AI_VC_send_data_queue_thread_lock = 2
+            count = 500
             while True:
                 count -= 1
                 if self.AI_VC_send_data_queue_thread_lock == 0 or count <=0:
                     break
                 else:
-                    self.AI_VC_send_data_queue_thread_lock = 2
-                    print("AI_VC_send_data_queue_thread going:{}...".format(count))
-                    utime.sleep_ms(1)
-                    if self.AI_VC_send_data_queue.empty()!=True:
-                        self.AI_VC_send_data_queue.clear()
+                    # 该打印只是“等待线程停下来”的调试心跳,默认关闭以免影响系统实时性
+                    if VC_HEARTBEAT_LOG and (count % 50 == 0 or count > 490):
+                        print("AI_VC_send_data_queue_thread going:{}...".format(count))
+                    utime.sleep_ms(5)
             self.AI_VC_send_data_queue_thread_lock = 0
-            print("AI_VC_send_data_queue_thread_close")
+            if VC_HEARTBEAT_LOG:
+                print("AI_VC_send_data_queue_thread_close")
             if self.AI_VC_send_data_queue_thread_id != 0:
                 try:
-                    _thread.stop_thread(self.AI_VC_send_data_queue_thread_id)
+                    # 线程可能已自然退出/被复用,stop_thread 会抛 ValueError
+                    try:
+                        running = _thread.threadIsRunning(self.AI_VC_send_data_queue_thread_id)
+                    except Exception:
+                        running = True
+                    if running:
+                        _thread.stop_thread(self.AI_VC_send_data_queue_thread_id)
                 except Exception as e:
                     usys.print_exception(e)
         self.AI_VC_send_data_queue_thread_id = 0
@@ -4201,9 +5009,35 @@ class AI_VC(Abstract):
                             
                             if isinstance(data[1], str):#是字符串
                                 self.VC_pingping_timerout = 0
-                                print("VC VC: send_data:{}".format(data[1]))
+                                s = data[1]
+                                if s is None:
+                                    s = ""
+                                # 保活 ping 不落 WS IO 日志(避免周期性刷屏)
+                                if VC_WS_IO_LOG and s != "ping":
+                                    try:
+                                        if len(s) > 240:
+                                            preview = s[:160] + " ... " + s[-60:]
+                                        else:
+                                            preview = s
+                                        print("[VC] WS send_txt len={} txt={}".format(len(s), preview))
+                                    except Exception:
+                                        try:
+                                            print("[VC] WS send_txt {}".format(data[1]))
+                                        except Exception:
+                                            pass
+                                elif not VC_WS_IO_LOG:
+                                    try:
+                                        if s == "ping":
+                                            pass
+                                        else:
+                                            jd = ujson.loads(s)
+                                            print("[VC] tx event={}".format(jd.get("event", "?")))
+                                    except Exception:
+                                        print("[VC] tx len={} (non-json)".format(len(s)))
                             else:
-                                print("VC: send_data len:{}".format(len(data[1])))
+                                pass
+                                # if VC_AUDIO_FRAME_LOG:
+                                #     print("VC: send_data len:{}".format(len(data[1])))
                             # print("self.AI_client_VC = {} , self.AI_client_VC_isconnect = {}".format(self.AI_client_VC,self.AI_client_VC_isconnect))    
                             if self.AI_client_VC != None and self.AI_client_VC_isconnect == 1:
                                 self.AI_client_VC.send(data[1])
@@ -4292,15 +5126,53 @@ class AI_VC(Abstract):
                 try:
                     self.websocket_error_count = 0
                     if isinstance(recv_data, str):#返回的是字符串
-                        print("VC VC:recv_data:{}".format(recv_data))
-                        jsload_data =  ujson.loads(recv_data)
+                        jsload_data = ujson.loads(recv_data)
+                        if VC_WS_IO_LOG:
+                            print("[VC] recv raw:{}".format(recv_data))
+                        else:
+                            try:
+                                _rev = jsload_data.get("event", "")
+                                if _rev != "llm.delta":
+                                    print("[VC] rx event={} sid={}".format(
+                                        _rev, jsload_data.get("sid", "") or "-"))
+                            except Exception:
+                                pass
                         #print("AI_client_VC_recv jsload_data = {}".format(jsload_data))
+                        try:
+                            self._cur_sid = jsload_data.get("sid", "") or ""
+                        except Exception:
+                            self._cur_sid = ""
+                        try:
+                            ev = jsload_data.get("event", "")
+                            if ev:
+                                self._fsm_apply_actions(self.fsm.on_server_event(ev, jsload_data))
+                        except Exception:
+                            pass
                         
                         if jsload_data["event"] == "started" :#会话开始
                             #payload = jsload_data["payload"]
                             # self.VC_dialog_id = payload["dialog_id"] 
                             # self.VC_task_id = jsload_data["task_id"] 
                             EventMesh.publish("open_led_blue")
+                            # 会话开始:允许上行;连续对话模式下直接进入“可发送音频”状态
+                            self._vc_session_started = 1
+                            # 新会话开始必须允许连续对话自动开麦(防止上一轮 Close_flag 残留导致 Deny START)
+                            self.Close_flag = 0
+                            self._ptt_audio_created = 0
+                            self._server_vad_silence = 0
+                            self._turn_has_asr_completed = 0
+                            self._turn_has_vad_silence = 0
+                            self._turn_has_tts_started = 0
+                            self._llm_completed_ms = 0
+                            self._llm_text_pending_tts = ""
+                            self._llm_force_tts_ms = 0
+                            if self.VC_mode == 0:
+                                # 会话已 started:若此时上行未启动,则在这里补一次开麦,避免“Start 前开麦被 Close 停掉”后
+                                # 出现首轮无音频上行导致“第一次对话没有”的情况。
+                                if self.record_data_save_flag == 0 and self.VC_record_state_flag == 0:
+                                    self.request_uplink_start("server.started")
+                                else:
+                                    self.VC_record_state_flag = 2
                             
                             print("VC:Started!!!!!!!!\r\n")
                             print("TTS音频为:{},语音下发地址为:{}".format(self.AI_TTS_str,self.AI_new_voice_url))
@@ -4360,7 +5232,15 @@ class AI_VC(Abstract):
                                 if self.VC_mode == 0    :
                                     self.VC_record_state_flag  =2
                                 else:
-                                    EventMesh.publish("publish_AI_btnInfo",{"func":self.AI_client_VC_send_speech})
+                                    # Push-To-Talk:会话 started 后再发 audio.create(协议要求)
+                                    self.request_send_audio_create()
+                                    # 若长按发生在“未 started”窗口,收到 started 后立即开麦进入新一轮 PTT
+                                    try:
+                                        if getattr(self, "_ptt_force_record_on_started", 0) == 1:
+                                            self._ptt_force_record_on_started = 0
+                                            self.VC_Speech_Start_now()
+                                    except Exception:
+                                        pass
                                 
                             
                             # if self.AI_new_voice_url != "":
@@ -4369,10 +5249,17 @@ class AI_VC(Abstract):
                             self.VC_state =  VC_State_None  
                         elif jsload_data["event"] == "config.update" :#配置更新中,需要停止录音
                             self.VC_state = VC_State_End
-                            EventMesh.publish("publish_AI_btnInfo",{"func":self.VC_Speech_End})
+                            self.request_uplink_stop("config.update")
 
                         elif jsload_data["event"] == "asr.started" :#asr开始 
                             self.VC_state =  VC_State_SpeechStart
+                            self._server_vad_silence = 0
+                            self._turn_has_asr_completed = 0
+                            self._turn_has_vad_silence = 0
+                            self._turn_has_tts_started = 0
+                            self._llm_completed_ms = 0
+                            self._llm_text_pending_tts = ""
+                            self._llm_force_tts_ms = 0
                             if self.VC_mode == 1    :
                                 self.VC_record_state_flag = 2
                             self.LocalRespondingEnded_timerout = 0
@@ -4383,7 +5270,13 @@ class AI_VC(Abstract):
                                 self.AI_TTS_str = "我没听清,请再说一遍"
                             EventMesh.publish("open_led_blue")
                         elif jsload_data["event"] == "asr.completed" :#asr 终止的标识 
-                            self.aud.play(1, 1, 'U:/static/think.mp3')  #播放按键音
+                            self._turn_has_asr_completed = 1
+                            # 抢占规则:本地提示音不可打断流播放
+                            try:
+                                if EventMesh.publish("get_vc_stream_active") != 1:
+                                    self.aud.play(1, 1, 'U:/static/think.mp3')  #播放按键音
+                            except Exception:
+                                pass
                             print("self.commands_mode:{}".format(self.commands_mode))
                             if self.commands_mode ==2:
                                 ret= self.check_text(self.AI_ASR_str)
@@ -4397,7 +5290,7 @@ class AI_VC(Abstract):
                                     EventMesh.publish("open_led_all")
                                     try:
                                         
-                                        EventMesh.publish("publish_AI_btnInfo",{"func":self.VC_Speech_End})
+                                        self.request_uplink_stop("asr.completed/config_cmd")
                                         EventMesh.publish("publish_AI_btnInfo",{"func":self.websocket_deconnect_VC}) 
                                         EventMesh.publish("publish_AI_btnInfo",{"func":self.AI_VC_start}) 
                                     except Exception as e:
@@ -4415,36 +5308,130 @@ class AI_VC(Abstract):
 
                             # if dev_type != "7PIN" :
                             #EventMesh.publish("publish_AI_btnInfo",{"func":self.send_tone}) 
+                        elif jsload_data["event"] == "vad.active" :#VAD 检测到说话→聆听
+                            if dev_type in ("7PIN_1_5", "7PIN", "7PIN_coze"):
+                                self._server_vad_silence = 0
+                                EventMesh.publish("open_led_blue")
                         elif jsload_data["event"] == "vad.silence" :#VAD 终止的标识 
-                            pass
-                            #EventMesh.publish("publish_AI_btnInfo",{"func":self.VC_Speech_End})
-                        elif jsload_data["event"] == "llm.created" :#会话开始
-                            EventMesh.publish("open_led_red_green")#
+                            # 说明服务端已判定“本轮说话结束”;此时继续上行只会干扰后续(例如推迟 TTS)。
+                            # 仅在“正在录音上行”的语音阶段才停止,避免误触发。
+                            self._server_vad_silence = 1
+                            self._turn_has_vad_silence = 1
+                            self._uplink_mark_end("vad.silence")
+                            if self.record_data_save_flag == 0:
+                                if self.VC_record_state_flag != 0 and self.VC_state >= VC_State_SpeechStart:
+                                    self.request_uplink_stop("vad.silence")
+                        elif jsload_data["event"] == "llm.created" :#思考(LLM)
+                            if dev_type in ("7PIN_1_5", "7PIN", "7PIN_coze"):
+                                EventMesh.publish("flicker_led_blue_green")
+                            else:
+                                EventMesh.publish("open_led_red_green")
                             self.VC_state = VC_State_Thinking
+                            self._llm_completed_ms = 0
+                            self._llm_text_pending_tts = ""
+                            self._llm_force_tts_ms = 0
                             # EventMesh.publish("publish_AI_btnInfo",{"func":self.VC_Play_Start})
                             pass
+                        elif jsload_data["event"] == "llm.completed" :#llm完成(可能无 tts)
+                            try:
+                                self._llm_completed_ms = utime.ticks_ms()
+                            except Exception:
+                                # 勿用 1:ticks_diff(now,1)≈开机至今,会瞬间满足 thinking 超时误以为该重启
+                                self._llm_completed_ms = 0
+                            # 若服务端没有自动触发 tts.started,则准备由端侧主动发 synthesis
+                            try:
+                                txt = jsload_data.get("payload", {}).get("content", "")
+                            except Exception:
+                                txt = ""
+                            if isinstance(txt, str) and txt.strip() and VC_PROTO_GUARDS:
+                                self._llm_text_pending_tts = txt.strip()
+                                self._llm_force_tts_ms = self._llm_completed_ms
+                            try:
+                                self._cur_dialog_id = jsload_data.get("payload", {}).get("dialog_id", "") or ""
+                            except Exception:
+                                self._cur_dialog_id = ""
+                            try:
+                                self.fsm.log("server.llm.completed")
+                            except Exception:
+                                pass
+                            pass
                         elif jsload_data["event"] == "llm.delta" :#llm返回数据
                             if self.is_VAD == 0 and self.record_data_save_flag == 0:
                                 if self.VC_record_state_flag == 1:#llm返回数据还没停止,就先停止
-                                    EventMesh.publish("publish_AI_btnInfo",{"func":self.VC_Speech_End})
+                                    self.request_uplink_stop("llm.delta")
                         elif jsload_data["event"] == "tts.started" :#tts开始
                             self.VC_state = VC_State_TTSStart
-                            if self.is_VAD == 0 and self.record_data_save_flag == 0:
-                                EventMesh.publish("publish_AI_btnInfo",{"func":self.VC_Speech_End})
-                            EventMesh.publish("publish_AI_btnInfo",{"func":self.VC_Play_Start})
-                            
-                            #self.VC_Play_Start()
+                            self._server_vad_silence = 1
+                            self._turn_has_tts_started = 1
+                            self._tts_pending = 0
+                            self._tts_pending_ms = 0
+                            self._llm_text_pending_tts = ""
+                            self._llm_force_tts_ms = 0
+                            self._uplink_mark_end("tts.started")
+                            # stop uplink + play_start 由 FSM action list 统一触发,避免多处重复调用
+                            # 标记本轮 TTS 来源:
+                            # - 有 payload.dialog_id:认为是“对话回复 TTS”
+                            # - 无 dialog_id:认为是“提示/提醒 TTS”(模式切换可通过 _pending_tts_kind 标记)
+                            try:
+                                did = jsload_data.get("payload", {}).get("dialog_id", "") or ""
+                            except Exception:
+                                did = ""
+                            # 注意:无 dialog_id 的 tts.started 很可能是提示音/提醒音;
+                            # 但“模式切换提示音”的 kind 可能已在 synthesis 时写入 _last_tts_kind,
+                            # 这里不应无条件覆盖为 prompt,否则播完无法触发下一轮开麦。
+                            try:
+                                if did:
+                                    self._last_tts_kind = "dialog"
+                                else:
+                                    k = getattr(self, "_pending_tts_kind", "") or ""
+                                    if k:
+                                        self._last_tts_kind = k
+                                    else:
+                                        cur = getattr(self, "_last_tts_kind", "") or ""
+                                        if cur in ("mode_switch_long", "mode_switch_ptt"):
+                                            pass
+                                        elif not cur:
+                                            self._last_tts_kind = "prompt"
+                            except Exception:
+                                pass
+                            try:
+                                self._pending_tts_kind = ""
+                            except Exception:
+                                pass
                             EventMesh.publish("open_led_green")#绿色
                         elif jsload_data["event"] == "tts.completed" :#tts结束
                             EventMesh.publish("open_led_green")#绿色
-                            EventMesh.publish("publish_AI_btnInfo",{"func":self.VC_Play_Start})
-                            self.LocalRespondingEnded_timerout = 8
                             self.VC_state = VC_State_TTSEnd
+                            try:
+                                # server event 已在统一入口 on_server_event 触发 FSM,这里避免重复驱动
+                                pass
+                            except Exception:
+                                pass
+                            try:
+                                try:
+                                    sid = getattr(self, "_cur_sid", "")
+                                    did = getattr(self, "_cur_dialog_id", "")
+                                except Exception:
+                                    sid, did = "", ""
+                                print("[TTS] completed in_len={} out_len={} q_empty={} play_pcm={} sid={} did={}".format(
+                                    getattr(self, "tts_in_data_len", 0),
+                                    getattr(self, "tts_out_data_len", 0),
+                                    1 if self.AI_audio_queue.empty() else 0,
+                                    1 if self.play_pcm != None else 0,
+                                    sid,
+                                    did,
+                                ))
+                            except Exception:
+                                pass
+                            # 播完上报完全由 FSM await_played + play_end 驱动,这里不再使用 LocalRespondingEnded_timerout/_need_audio_played_ack
                             pass
 
                         elif jsload_data["event"] == "canceled" :#打断
                             self.is_ruppt_tts = 0#设备上发中断tts,收到服务器返回的cancel后置为0
-                            EventMesh.publish("publish_AI_btnInfo",{"func":self.VC_Speech_Start_now})#开始录音
+                            self._need_audio_played_ack = 0
+                            self._tts_pending = 0
+                            self._tts_pending_ms = 0
+                            # 开麦由 FSM action list 统一触发(uplink_start:canceled),避免重复调用
                             if self.AI_audio_queue_thread_id ==0:
                                 _thread.stack_size(24*1024)
                                 self.AI_audio_queue_thread_id = _thread.start_new_thread(self.AI_audio_queue_thread, ())
@@ -4453,11 +5440,25 @@ class AI_VC(Abstract):
                             else:
                                 print("AI_VC_start :AI_audio_queue_thread_id != 0 ")
                             self.VC_state = VC_State_None
-                        elif jsload_data["event"] == "completed" :#会话结束 "event":"audio.convert.completed"
-                            #EventMesh.publish("open_led_green")#绿色
-                            #if self.VC_state >= VC_State_TTSStart:
-                            self.LocalRespondingEnded_timerout = 8
-                            self.VC_state = VC_State_End
+                        elif jsload_data["event"] == "completed" :#会话结束(可能无 TTS,如 ASR 空)
+                            # 重要:服务端常在 tts.completed 后紧跟 completed。
+                            # 若本轮存在 TTS/待发 audio.played,则不能在此清零 LocalRespondingEnded_timerout/_need_audio_played_ack,
+                            # 否则会掐断「播完→audio.played→下一轮录音」链路,表现为最多 1~2 轮就停。
+                            if self._need_audio_played_ack == 1 or self.VC_state >= VC_State_TTSStart or self.VC_Play_flag == 1:
+                                pass
+                            else:
+                                # 无 TTS 的 completed:清状态并按需进入下一轮(见下方条件)
+                                self.LocalRespondingEnded_timerout = 0
+                                self._need_audio_played_ack = 0
+                                self.VC_state = VC_State_None
+                            # 连续对话:若本轮没有 TTS(或服务端直接 completed),需要立刻回到聆听状态
+                            # 否则会出现“有时 1 轮/2 轮后就停住”的现象(服务端 completed 但端侧未重新开录音上行)。
+                            if self.VC_mode == 0 and self.Close_flag == 0:
+                                # 仅当“本轮已结束一次语句(vad.silence 或 asr.completed)且从未进入 tts.started”
+                                # 才自动进入下一轮;避免把真正的“会话结束/异常结束”误当成下一轮入口。
+                                if (self._turn_has_tts_started == 0) and (self._turn_has_vad_silence == 1 or self._turn_has_asr_completed == 1):
+                                    self._server_vad_silence = 0
+                                    # 开麦由 FSM action list 统一触发(uplink_start:completed),这里不再重复调用
                             pass
 
                         elif jsload_data["event"] == "command" :#命令匹配
@@ -4486,7 +5487,7 @@ class AI_VC(Abstract):
                                 if self.AI_TTS_str  =="":
                                     self.AI_TTS_str = "抱歉,没找到对应操作,可以换个说法试试" 
                             #EventMesh.publish("publish_AI_btnInfo",{"func":utime.sleep_ms,'data':(10)})
-                            EventMesh.publish("publish_AI_btnInfo",{"func":self.VC_Speech_End})
+                            self.request_uplink_stop("command")
                             EventMesh.publish("publish_AI_btnInfo",{"func":self.websocket_deconnect_VC}) 
                             EventMesh.publish("publish_AI_btnInfo",{"func":self.AI_VC_start}) 
                         elif jsload_data["event"] == "failed":#接口返回错误
@@ -4559,7 +5560,8 @@ class AI_VC(Abstract):
                             print("新的AI对话不允许以前的播放数据:")
                             break
 
-                        print("下发音频数据1:{}".format(len(recv_data)))
+                        # if VC_AUDIO_FRAME_LOG:
+                        #     print("下发音频数据1:{}".format(len(recv_data)))
 
                         if self.play_data_save_flag == 1:
                             if self.play_fd ==None:
@@ -4600,7 +5602,10 @@ class AI_VC(Abstract):
                     error_str = str(e)
                     #print("VC VC:e:{}".format(error_str))
                     if error_str.find("[Errno 110] ETIMEDOUT")!= -1 or error_str.find("[Errno 115] ")!= -1:
-                        print("VC VC:recv 正常报错:{}".format(error_str))
+                        if VC_HEARTBEAT_LOG:
+                            print("VC VC:recv 正常报错:{}".format(error_str))
+                        # recv 超时仅表示本周期无帧,链路仍可能在等 LLM/TTS;清零避免误触发「接口无响应」
+                        self.close_timerout = 0
                         pass
                     else:
                         usys.print_exception(e)
@@ -4612,7 +5617,7 @@ class AI_VC(Abstract):
         self.AI_client_VC_isconnect = 0
         self.AI_client_VC_recv_id = 0 
         self.AI_client_VC_recv_lock = 0
-        print("AI_client_VC_recv END!!!!!!!!!!!!!!!!!!!!!!!!!!!")
+        print("[VC] recv_thread exit")
     
     def voice_play_10S(self,event,msg):#按键开启留言播放等待10S定时器判断
         self.voice_timer.start(1000,1,self.voice_judge)
@@ -4723,6 +5728,22 @@ class AI_VC(Abstract):
         
         #print("下发音频数据1:{}".format(len(data[0])))
         ret=self.AI_audio_queue.put(data)
+        try:
+            if self.tts_in_data_len == 0 and data and len(data[0]) > 0:
+                try:
+                    sid = getattr(self, "_cur_sid", "")
+                    did = getattr(self, "_cur_dialog_id", "")
+                except Exception:
+                    sid, did = "", ""
+                print("[TTS] first_chunk len={} play_pcm={} VC_Play_flag={} sid={} did={}".format(
+                    len(data[0]),
+                    1 if self.play_pcm != None else 0,
+                    getattr(self, "VC_Play_flag", 0),
+                    sid,
+                    did,
+                ))
+        except Exception:
+            pass
         self.tts_in_data_len += len(data[0])
         #print("下发音频数据2:{},{}".format(len(data[0]),self.tts_in_data_len))
     #msg[0]是tts播放字符串,msg[1]是是否关闭当前tts
@@ -4743,7 +5764,7 @@ class AI_VC(Abstract):
                 self.AI_ASR_send_data_queue.clear()
             EventMesh.publish("open_led_all")
             self.AI_ASR_send_buf_len = 0
-            self.VC_Speech_End()
+            self._uplink_request_stop("play_tts_cb")
             self.websocket_deconnect_VC()
             self.AI_VC_start()
             
@@ -5374,12 +6395,42 @@ class AI_VC(Abstract):
         self.tts_out_data_len = 0
         if self.VC_Play_flag == 0:
             try:
+                # 本地提示音/文件播放可能占用同一音频通道,导致 TTS 流播放“写入成功但无声”;
+                # 在开启 TTS PCM 前先停掉本地播放,避免资源冲突。
+                try:
+                    self.aud.stopPlayStream()
+                    self.aud.stopAll()
+                except Exception:
+                    pass
+                # 资源互斥:播放前确保录音上行已停止(无 AEC 场景)
+                try:
+                    if self.record_data_save_flag == 0 and self.VC_record_state_flag != 0:
+                        self._uplink_request_stop("VC_Play_Start")
+                        utime.sleep_ms(30)
+                except Exception:
+                    pass
                 if self.VC_play_TYPE == VC_play_TYPE_PCM:
-                    if self.play_pcm == None:        
-                        self.play_pcm = audio.Audio.PCM(0,1,self.VC_play_samplerate,1,1,2)
-                        #self.play_pcm.setVolume(11)
-                        ret = self.play_pcm.stream_write_start()
-                        print("self.record_pcm.stream_write_start:ret:{}".format(ret))
+                    if self.play_pcm == None:
+                        # 刚停采集时延后打开播音 PCM,且失败时重试,降低「pcm open write only fail」
+                        last_err = None
+                        for i in range(3):
+                            try:
+                                utime.sleep_ms(80 + i * 60)
+                                self.play_pcm = audio.Audio.PCM(0, 1, self.VC_play_samplerate, 1, 1, 2)
+                                ret = self.play_pcm.stream_write_start()
+                                print("self.record_pcm.stream_write_start:ret:{}".format(ret))
+                                last_err = None
+                                break
+                            except Exception as e:
+                                last_err = e
+                                try:
+                                    if self.play_pcm != None:
+                                        self.play_pcm.close()
+                                except Exception:
+                                    pass
+                                self.play_pcm = None
+                        if last_err:
+                            raise last_err
                 elif self.VC_play_TYPE == VC_play_TYPE_MP3:
                     pass
                 self.VC_Play_flag = 1
@@ -5419,11 +6470,120 @@ class AI_VC(Abstract):
             self.aud.stopAll()
         if self.AI_audio_queue.empty()!=True:
             self.AI_audio_queue.clear()
+
+        # 播放结束后:退出“播报中”绿灯态。默认回到蓝灯(可说/待机态)。
+        try:
+            EventMesh.publish("open_led_blue")
+        except Exception:
+            pass
         
 
     def _1S_timer_cb(self,*argc):
         EventMesh.publish("publish_AI_btnInfo",{"func":self._1S_timer_cb_}) 
 
+    def _fsm_timeout_table_tick(self, now_ms):
+        # 兜底统一入口:按“状态机状态 + 超时阈值 + 节流”驱动,避免兜底抢跑/多入口打架。
+        # 只在对应 turn/session 状态触发动作。
+        try:
+            turn = getattr(self.fsm, "turn", VCTurnState.Idle)
+        except Exception:
+            turn = VCTurnState.Idle
+
+        # 1) synthesis→tts.started 超时:只在 Thinking 期允许清 pending 并重启会话
+        #    根因:服务端未返回 tts.started 时,继续“回到聆听/开麦”会把本轮状态机拉扯得更乱;
+        #    这里直接重启会话,让协议重新进入 started→下一轮流程。
+        try:
+            if turn == VCTurnState.Thinking and self._tts_pending == 1 and self._tts_pending_ms:
+                try:
+                    if self._fb_last_tts_pending_timeout_ms:
+                        tts_gap_ok = utime.ticks_diff(now_ms, self._fb_last_tts_pending_timeout_ms) > 15000
+                    else:
+                        tts_gap_ok = True
+                except Exception:
+                    tts_gap_ok = True
+                if utime.ticks_diff(now_ms, self._tts_pending_ms) > 8000 and tts_gap_ok:
+                    self._fb_last_tts_pending_timeout_ms = now_ms
+                    print("[VC][tts_pending] timeout, clear pending and restart session")
+                    self._tts_pending = 0
+                    self._tts_pending_ms = 0
+                    if self.VC_mode == 0 and self.Close_flag == 0:
+                        self.VC_state = VC_State_None
+                        EventMesh.publish("open_led_blue")
+                        EventMesh.publish("publish_AI_btnInfo", {"func": self.AI_VC_start})
+        except Exception:
+            pass
+
+        # 2) thinking 超时:只在「llm.completed 已收到但 tts.started 长时间未到」时才重启会话
+        #    注意:不能以 llm.created 的时间戳作为超时基准,否则会在 LLM 正常推理期间误判超时(notts 场景)。
+        try:
+            if self.VC_mode == 0 and self.Close_flag == 0:
+                llm_completed_ms = getattr(self, "_llm_completed_ms", 0)
+                # 只有在 llm.completed 之后才允许超时重启
+                if turn == VCTurnState.Thinking and llm_completed_ms and self._turn_has_tts_started == 0:
+                    age_ms = utime.ticks_diff(now_ms, llm_completed_ms)
+                    # 有 force_tts pending 时不可用 6s 短超时:会与下方 force_tts(约 5s)抢跑 AI_VC_start,
+                    # 打断 synthesis/会话;改为长超时兜底,仍以 throttle 为保护。
+                    pending_for_force_tts = VC_PROTO_GUARDS and (
+                        self._llm_text_pending_tts or self._llm_force_tts_ms)
+                    restart_threshold_ms = 40000 if pending_for_force_tts else 20000
+                    # _fb_last_thinking_restart_ms==0 时 ticks_diff(now,0) 非预期(间隔被误判极大),
+                    # 会在 llm.completed 后首批 tick 就触发 AI_VC_start → notts(无 tts.started);首次仅信任 age 门限
+                    try:
+                        if self._fb_last_thinking_restart_ms:
+                            gap_ok = utime.ticks_diff(now_ms, self._fb_last_thinking_restart_ms) > 15000
+                        else:
+                            gap_ok = True
+                    except Exception:
+                        gap_ok = True
+                    if (age_ms > restart_threshold_ms and gap_ok and
+                        self._tts_pending == 0 and self._need_audio_played_ack == 0 and self.VC_Play_flag == 0):
+                        self._fb_last_thinking_restart_ms = now_ms
+                        self._llm_completed_ms = 0
+                        self._server_vad_silence = 0
+                        self.VC_state = VC_State_None
+                        try:
+                            print("[VC][thinking_timeout] AI_VC_start age_ms={} thr={}".format(
+                                age_ms, restart_threshold_ms))
+                        except Exception:
+                            pass
+                        EventMesh.publish("open_led_blue")
+                        # thinking_timeout 策略:重启会话(而不是抢开麦),避免干扰服务端 LLM/TTS 流程
+                        EventMesh.publish("publish_AI_btnInfo", {"func": self.AI_VC_start})
+        except Exception:
+            pass
+
+        # 3) llm→tts 兜底:仅 Thinking 期且足够等待/节流时触发;若仍在录音则先 stop,下一次 tick 再判断(受 VC_PROTO_GUARDS)
+        try:
+            if VC_PROTO_GUARDS and self._llm_text_pending_tts and self._llm_force_tts_ms and self._turn_has_tts_started == 0:
+                # 注意:服务端可能出现 llm.completed 后直接 completed(无 tts 事件)的情况,
+                # 此时 FSM turn 可能已回到 Listening;兜底不能硬绑 Thinking,否则会“只见 llm 不见 tts”。
+                if (turn not in (VCTurnState.TtsReceiving, VCTurnState.Playing, VCTurnState.AwaitPlayedAck) and
+                    utime.ticks_diff(now_ms, self._llm_force_tts_ms) > 5000 and
+                    utime.ticks_diff(now_ms, self._fb_last_force_tts_ms) > 15000 and
+                    self._tts_pending == 0 and self._need_audio_played_ack == 0 and self.VC_Play_flag == 0):
+                    self._fb_last_force_tts_ms = now_ms
+                    if self.record_data_save_flag == 0 and self.VC_record_state_flag != 0:
+                        self.request_uplink_stop("force_tts_pre_stop")
+                        return
+                    self._llm_force_tts_ms = 0
+                    self.AI_TTS_str = self._llm_text_pending_tts
+                    self._llm_text_pending_tts = ""
+                    self._server_vad_silence = 1
+                    if self.record_data_save_flag == 0:
+                        self.request_uplink_stop("force_tts")
+                    try:
+                        sid = getattr(self, "_cur_sid", "")
+                        did = getattr(self, "_cur_dialog_id", "")
+                    except Exception:
+                        sid, did = "", ""
+                    try:
+                        print("[VC][force_tts] send synthesis sid={} did={} turn={}".format(sid, did, turn))
+                    except Exception:
+                        pass
+                    EventMesh.publish("publish_AI_btnInfo",{"func":self.AI_client_VC_synthesis})
+        except Exception:
+            pass
+
     def _1S_timer_cb_(self):    
     
         EventMesh.publish("system_sleep_rest")#防止黑屏
@@ -5438,33 +6598,51 @@ class AI_VC(Abstract):
                 EventMesh.publish("publish_AI_btnInfo",{"func":self.Close}) 
                 #EventMesh.publish("sleep_BT")#休眠蓝牙
         
+        # 统一兜底表(按状态机状态触发)
+        try:
+            self._fsm_timeout_table_tick(utime.ticks_ms())
+        except Exception:
+            pass
+
+        # (不做额外兜底)动态切换依赖 FSM 状态正确清理后,由 server.tts.completed→local.play_end 驱动自动开麦
+
         # print("get_audio_state !!!!!{} {} {}\r\n".format(self.play_end,EventMesh.publish("get_audio_state"),self.play_flag))
-        if self.LocalRespondingEnded_timerout>0  :#
-            self.LocalRespondingEnded_timerout -= 1
-            print("LocalRespondingEnded_timerout:{}".format(self.LocalRespondingEnded_timerout))
-            if self.LocalRespondingEnded_timerout <= 0 or self.play_pcm == None or(self.play_pcm != None and self.play_pcm.stream_is_empty() == 1) :#超时或者音频播放完,
-                self.LocalRespondingEnded_timerout = 0
-                if self.VC_mode == 1 :#长按单次问答模式
-                    EventMesh.publish("publish_AI_btnInfo",{"func":self.Close}) 
-                    #EventMesh.publish("sleep_BT")#休眠蓝牙
-                else:
-                    EventMesh.publish("open_led_blue")
-                    if self.Close_flag == 1:
-                        self.Close_flag = 0
-                        EventMesh.publish("publish_AI_btnInfo",{"func":self.Close}) 
-                        #EventMesh.publish("sleep_BT")#休眠蓝牙
-                    else:
-                        #utime.sleep_ms(1000)
-                        
-                        EventMesh.publish("publish_AI_btnInfo",{"func":self.VC_Play_End})
-                        #EventMesh.publish("publish_AI_btnInfo",{"func":self.chenggong_tone})
-                        EventMesh.publish("publish_AI_btnInfo",{"func":self.VC_Speech_Start_now})#开始录音
+        # ===== 播放结束检测:用 FSM 驱动 play_end 动作链 =====
+        try:
+            if self.VC_mode != 1:
+                if getattr(self.fsm, "await_played", False):
+                    # 不能只用 stream_is_empty 判定“播完”,否则可能在队列仍有待播数据/统计尚未对齐时提前关播,出现“没声/一闪而过”。
+                    is_pcm_empty = (self.play_pcm == None) or (self.play_pcm != None and self.play_pcm.stream_is_empty() == 1)
+                    try:
+                        q_empty = self.AI_audio_queue.empty()
+                    except Exception:
+                        q_empty = True
+                    try:
+                        in_len = getattr(self, "tts_in_data_len", 0)
+                        out_len = getattr(self, "tts_out_data_len", 0)
+                    except Exception:
+                        in_len, out_len = 0, 0
+                    drained = (in_len > 0 and out_len >= in_len and q_empty)
+                    # 8s 超时:兜底认为播放结束(避免播放状态卡死)
+                    too_long = False
+                    try:
+                        t0 = getattr(self.fsm, "last_tts_completed_ms", 0)
+                        if t0:
+                            too_long = utime.ticks_diff(utime.ticks_ms(), t0) > 8000
+                    except Exception:
+                        pass
+                    if (is_pcm_empty and drained) or too_long:
+                        EventMesh.publish("open_led_blue")
+                        EventMesh.publish("publish_AI_btnInfo", {"func": self.VC_Play_End})
+                        self._fsm_apply_actions(self.fsm.on_local_play_end())
                         self.VC_state = VC_State_None
-                    #通知服务器播放结束
-                    #self.AI_client_VC_LocalRespondingEnded()
+        except Exception:
+            pass
         if self.online_kws == 1:
             if self.VC_satrt_kws_flag == 1 and self.VC_satrt_flag == 0:#要打开录音上传数据实现kws
                 pass
+
+        # thinking/llm→tts 等兜底已在 _fsm_timeout_table_tick 内统一处理
         self.VC_pingping_timerout+=1
         if self.VC_pingping_timerout>3:
             self.VC_pingping_timerout=0
@@ -5473,15 +6651,11 @@ class AI_VC(Abstract):
         # if self.asr_pingping_timerout>3:
         #     self.asr_pingping_timerout=0
         #     self.AI_client_asr_ping()    
-        if self.VC_Thinking_timerout > 0 and self.VC_Thinking_timerout < 100:#VC Thinking超时后重新开始
-            print("VC_Thinking_timerout:{}!!!!!!!!!!!!!!!!!!!!!".format(self.VC_Thinking_timerout))
-            self.VC_Thinking_timerout -=1
-            if self.VC_Thinking_timerout == 0:
-                self.VC_Thinking_timerout = 100
-                print("Thinking重试,重试!!!!!!!!!!!!!!!!!!!!!")
-                self.VC_state = VC_State_None
-                EventMesh.publish("publish_AI_btnInfo",{"func":self.AI_VC_start}) 
-                pass
+        # 历史遗留:VC_Thinking_timerout 的重启通道会在 llm.created 后抢跑触发 AI_VC_start,
+        # 直接打断本轮 LLM→TTS(notts 日志:llm.created 后立刻 AI_VC_start,导致无 tts.started)。
+        # 现在统一由 _fsm_timeout_table_tick(Thinking 超时) 负责重启会话,因此禁用该旧通道。
+        if self.VC_Thinking_timerout > 0 and self.VC_Thinking_timerout < 100:
+            self.VC_Thinking_timerout = 100
     # def AI_kws_start(self):    
     #     print("AI_kws_start")
     #     EventMesh.publish("wakeup_BT")
@@ -5493,6 +6667,10 @@ class AI_VC(Abstract):
     #VC开始
     def AI_VC_start(self):
         print("AI_VC_start1")
+        try:
+            print("[VC] build:{}".format(VC_BUILD_TAG))
+        except Exception:
+            pass
         
         EventMesh.publish(constants.EVENT.SYSTEM_UP)#唤醒休眠
         self.VC_satrt_flag = 1
@@ -5527,7 +6705,7 @@ class AI_VC(Abstract):
         self.VC_state = VC_State_None
         if self.AI_TTS_str == "" and self.AI_new_voice_url == "":#没有tts要播放的时候才开始录音 并音频路径为空
             #开始asr录音
-            self.VC_Speech_Start()#开启线程获取录音数据
+            self._uplink_request_start("AI_VC_start")#开启线程获取录音数据(统一入口)
            
                 
         #     pass
@@ -5756,6 +6934,12 @@ class AI_VC(Abstract):
         if self.VC_mode == 0 :#and dev_type != "7PIN_BK_coze":#长对话模式打开vad
             json_data["payload"]["inbound_attributes"]["disable_vad"] = False
         self.VC_state = VC_State_None
+        # 新会话开始:在收到服务端 started 前,禁止发送音频帧(避免 session not initialized)
+        self._vc_session_started = 0
+        try:
+            self.fsm.on_session_start_request()
+        except Exception:
+            pass
         self.AI_client_VC_send(ujson.dumps(json_data))
  
     #开始上传语音
@@ -5769,7 +6953,10 @@ class AI_VC(Abstract):
         }
         
         if self.VC_mode == 1:#单对话模式
-            self.AI_client_VC_send(ujson.dumps(json_data))
+            # 协议门控(可用 VC_PROTO_GUARDS 临时关闭)
+            if (not VC_PROTO_GUARDS) or (self._vc_session_started == 1):
+                self._ptt_audio_created = 1
+                self.AI_client_VC_send(ujson.dumps(json_data))
         
 
             
@@ -5787,7 +6974,8 @@ class AI_VC(Abstract):
             },
         }
         self.AI_client_VC_send(ujson.dumps(json_data))
-        EventMesh.publish("publish_AI_btnInfo",{"func":self.VC_Speech_End})
+        self._ptt_audio_created = 0
+        EventMesh.publish("publish_AI_btnInfo",{"func":self._uplink_request_stop, "data":"audio.completed"})
 
   
     #客户端打断对话
@@ -5798,16 +6986,21 @@ class AI_VC(Abstract):
             "payload": {}
         }
         print("AI_client_VC_RequestToSpeak!!!!!!!!!!!!!!!!!!!!!!!!!!!!!")
-        EventMesh.publish("publish_AI_btnInfo",{"func":self.VC_init}) 
-        EventMesh.publish("publish_AI_btnInfo",{"func":self.AI_client_VC_send,"data":ujson.dumps(json_data)}) 
+        # 只做“打断+清播放队列”,不要在这里关音频队列线程(会导致后续 TTS/提示音不稳定,且触发竞态)
+        EventMesh.publish("publish_AI_btnInfo",{"func":self.VC_init})
+        EventMesh.publish("publish_AI_btnInfo",{"func":self.AI_client_VC_send,"data":ujson.dumps(json_data)})
         utime.sleep_ms(1)
-        # self.websocket_deconnect_VC()
-        self.AI_audio_queue_thread_close()
         self.VC_state = VC_State_None
         # #self.Close()
         # self.AI_VC_start() 
     #TTS播放
     def AI_client_VC_synthesis(self):
+        # 幂等保护:在上一条 synthesis 还未等到 tts.started 前,不重复发(防止多兜底/多入口重复触发)
+        try:
+            if self._tts_pending == 1:
+                return
+        except Exception:
+            pass
         json_data = \
         {
             "event": "synthesis",
@@ -5815,11 +7008,34 @@ class AI_VC(Abstract):
                 "text": self.AI_TTS_str
             }
         }
+        # 语义标记只用于“是否自动开麦”的策略。
+        # 当前策略:长对话不自动开麦(需按键开始),因此这里不对提示音做自动开麦标记。
         self.AI_TTS_str = ""
+        # 标记为“等待 tts.started”,避免在 synthesis→tts.started 窗口误开下一轮录音上行
+        self._tts_pending = 1
+        try:
+            self._tts_pending_ms = utime.ticks_ms()
+        except Exception:
+            self._tts_pending_ms = 0
+        try:
+            self.fsm.on_local_synthesis_sent()
+        except Exception:
+            pass
         print("AI_client_VC_synthesis!!!!!!!!!!!!!!!!!!!!!!!!!!!!!")
         # if self.VC_state == VC_State_RequestToRespond:
         #     print("AI_client_VC_RequestToRespond 重复 tts_str:{}\nasr_str:{}".format(self.AI_TTS_str,self.AI_ASR_str))
         self.AI_client_VC_send(ujson.dumps(json_data))
+    # 金三 V2.5:AudioPlayed — 端侧 TTS 播报结束后上报,服务端进入下一轮对话(轻度重同步,不断开 WebSocket)
+    def AI_client_VC_audio_played(self):
+        # V2.5:播完上报;与 VC_PROTO_GUARDS 无关(见文件头说明)
+        try:
+            if self.AI_client_VC_isconnect != 1 or self.AI_client_VC == None:
+                return
+            json_data = {"event": "audio.played", "payload": {}}
+            print("AI_client_VC_audio_played")
+            self.AI_client_VC_send(ujson.dumps(json_data))
+        except Exception as e:
+            usys.print_exception(e)
     def kws_even(self,event = None,msg= None):
         EventMesh.publish("publish_AI_btnInfo",{"func":self.kws_even_cb}) 
     def kws_even_cb(self):
@@ -5854,8 +7070,8 @@ class AI_VC(Abstract):
             self.AI_ASR_str = ""
             self.AI_ASR_str_tmp = ""
             self.VC_state = VC_State_None
-            self.VC_Speech_End()
-            self.VC_Speech_Start()
+            self._uplink_request_stop("kws_even_cb")
+            self._uplink_request_start("kws_even_cb")
             self.AI_VC_start() 
 
     def set_vad_silence(self,event = None,msg= None):
@@ -5871,8 +7087,7 @@ class AI_VC(Abstract):
         self.VC_TTS_voice_key = "APP_voice"
         EventMesh.publish("set_storage_config",["VC_TTS_voice_type",self.VC_TTS_voice_type])
         EventMesh.publish("set_storage_config",["VC_TTS_voice_key",self.VC_TTS_voice_key])
-        print("VC_TTS_voice_type:{}".format(self.VC_TTS_voice_type))
-        print("VC_TTS_voice_key:{}".format(self.VC_TTS_voice_key))
+        print("[VC] TTS_voice key={} map={}".format(self.VC_TTS_voice_key, self.VC_TTS_voice_type))
     #修改音量大小 msg为1则为加大,msg为0则为减小,返回1成功,返回0失败
     def change_volume(self,event =None, msg =None):
         if msg == 0 :
@@ -6035,7 +7250,7 @@ class AI_VC(Abstract):
                 self.AI_ASR_send_data_queue.clear()
             EventMesh.publish("open_led_all")
             self.AI_ASR_send_buf_len = 0
-            self.VC_Speech_End()
+            self._uplink_request_stop("play_single_voice_cb")
             self.websocket_deconnect_VC()
             self.AI_VC_start()
             self.play_new_voice_flag = 1
@@ -6081,7 +7296,7 @@ class AI_VC(Abstract):
                 self.AI_ASR_send_data_queue.clear()
             EventMesh.publish("open_led_all")
             self.AI_ASR_send_buf_len = 0
-            self.VC_Speech_End()
+            self._uplink_request_stop("play_new_voice_cb")
             self.websocket_deconnect_VC()
             self.AI_VC_start()
             self.play_new_voice_flag = 1
@@ -6116,7 +7331,7 @@ class AI_VC(Abstract):
                 self.AI_ASR_send_data_queue.clear()
             EventMesh.publish("open_led_all")
             self.AI_ASR_send_buf_len = 0
-            self.VC_Speech_End()
+            self._uplink_request_stop("play_his_voice_cb")
             self.websocket_deconnect_VC()
             self.AI_VC_start()
             self.play_new_voice_flag = 1
@@ -6154,7 +7369,7 @@ class AI_VC(Abstract):
                 self.AI_ASR_send_data_queue.clear()
             EventMesh.publish("open_led_all")
             self.AI_ASR_send_buf_len = 0
-            self.VC_Speech_End()
+            self._uplink_request_stop("play_wait_voice_cb")
             self.websocket_deconnect_VC()
             self.AI_VC_start()
             self.play_new_voice_flag = 1
@@ -6196,7 +7411,7 @@ class AI_VC(Abstract):
                 self.AI_ASR_send_data_queue.clear()
             EventMesh.publish("open_led_all")
             self.AI_ASR_send_buf_len = 0
-            self.VC_Speech_End()
+            self._uplink_request_stop("play_music_cb")
             self.websocket_deconnect_VC()
             self.AI_VC_start()
             self.play_new_voice_flag = 1
@@ -6220,7 +7435,7 @@ class AI_VC(Abstract):
                 self.AI_ASR_send_data_queue.clear()
             EventMesh.publish("open_led_all")
             self.AI_ASR_send_buf_len = 0
-            self.VC_Speech_End()
+            self._uplink_request_stop("play_trans_tts_cb")
             self.websocket_deconnect_VC()
             self.AI_VC_start()
             EventMesh.publish("publish_AI_btnInfo",{"func":self.AI_client_new_voice})#播放留言
@@ -6296,7 +7511,7 @@ class AI_VC(Abstract):
                     break
                 #
                 count+=1
-                if count%500 == 1:
+                if count%500 == 1 and VC_HEARTBEAT_LOG:
                     print("AI_audio_queue_thread on")
                 utime.sleep_ms(1)
                 #print("AI_audio_queue_thread")
@@ -6331,6 +7546,17 @@ class AI_VC(Abstract):
                                         #print("play_pcm.stream_write !!!")
                                         ret = self.play_pcm.stream_write(write_buf,len(write_buf))
                                         #ret= self.aud.playStream(1, data[0])#播放音频流
+                                        if self.tts_out_data_len == 0:
+                                            try:
+                                                sid = getattr(self, "_cur_sid", "")
+                                                did = getattr(self, "_cur_dialog_id", "")
+                                            except Exception:
+                                                sid, did = "", ""
+                                            print("[TTS] first_write ret={} len={} sid={} did={}".format(
+                                                ret, len(write_buf), sid, did
+                                            ))
+                                        if ret <= 0:
+                                            print("[TTS] stream_write ret={} len={} (no audio?)".format(ret, len(write_buf)))
                                         self.tts_out_data_len += ret
                                         #print("play_pcm.stream_write2:{}".format(self.tts_out_data_len))
                             elif self.VC_play_TYPE == VC_play_TYPE_MP3:
@@ -6394,6 +7620,11 @@ class AI_VC(Abstract):
         self.AI_ASR_check_timeout = 0
         self.AI_ASR_error_timeout = 0
         self.LocalRespondingEnded_timerout = 0
+        self._need_audio_played_ack = 0
+        self._tts_pending = 0
+        self._tts_pending_ms = 0
+        self._vc_session_started = 0
+        self._ptt_audio_created = 0
         self.is_ruppt_tts = 0#打断标志位清零
         print("ai_VC Close1!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!")
         EventMesh.publish("close_led")
@@ -6410,7 +7641,8 @@ class AI_VC(Abstract):
         except Exception as e: 
             usys.print_exception(e)
         
-        self.VC_Speech_End()
+        # Close 可能被不同线程触发:统一切回主线程执行 stop
+        EventMesh.publish("publish_AI_btnInfo",{"func":self._uplink_request_stop, "data":"Close"})
         
         print("ai_VC Close1.1!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!")
         
@@ -6488,19 +7720,11 @@ class AI_VC(Abstract):
         if self.AI_audio_queue.empty()!=True:
             self.AI_audio_queue.clear()
     def VC_init(self):
-        
-        print("VC_init:AI_VC_send_data_queue:size:{}".format(self.AI_audio_queue.size()))
-        
-        
-        print("VC_init2")
-        #self.VC_Speech_End()
-        print("VC_init3")
+        _q0 = self.AI_audio_queue.size()
         self.VC_Play_End()
-        print("VC_init4")
-        
         if self.AI_audio_queue.empty()!=True:
             self.AI_audio_queue.clear()
-        print("VC_init:AI_VC_send_data_queue:end size:{}".format(self.AI_audio_queue.size()))
+        print("[VC] init audio_queue {} -> {}".format(_q0, self.AI_audio_queue.size()))
         # num = EventMesh.publish("get_volum_size")
         # if num == None:
         #     num = 8
diff --git a/2.sdk/project/7PIN_1_5/usr/audio_gain.nvm b/2.sdk/project/7PIN_1_5/usr/audio_gain.nvm
new file mode 100644 (file)
index 0000000..fe06d81
Binary files /dev/null and b/2.sdk/project/7PIN_1_5/usr/audio_gain.nvm differ
diff --git a/产测工具/FactoryTool/test_led.py b/产测工具/FactoryTool/test_led.py
new file mode 100644 (file)
index 0000000..044b956
--- /dev/null
@@ -0,0 +1,70 @@
+"""\r
+板端 LED/GPIO 闪烁测试(独立脚本,不依赖 VC)。\r
+\r
+用法:\r
+1) 改下面 TEST_GPIO 为导出 GPIO 号(与 Pin(Pin.GPIOxx) / machine.Pin 里 GPIOxx 一致),然后:\r
+       import usr.test_led\r
+       usr.test_led.run()\r
+\r
+2) 或 REPL 里指定脚号:\r
+       import usr.test_led as t\r
+       t.run(44)\r
+\r
+3) 若固件启动脚本支持传入 argv(视平台而定):\r
+       t.run_main()\r
+\r
+参数:总时长 3s,每隔 500ms 翻转一次电平;结束后拉低关闭。\r
+"""\r
+\r
+from machine import Pin\r
+import utime\r
+\r
+try:\r
+    import usys\r
+except ImportError:\r
+    import sys as usys\r
+\r
+# 手动指定要测的导出 GPIO(与 datasheet / Pin.GPIOxx 对应,不是原理图内部的 GPIO70)\r
+TEST_GPIO = 21\r
+\r
+TOTAL_MS = 3000\r
+HALF_PERIOD_MS = 500\r
+\r
+\r
+def run(gpio_num=None, total_ms=TOTAL_MS, half_ms=HALF_PERIOD_MS):\r
+    if gpio_num is None:\r
+        gpio_num = TEST_GPIO\r
+    gpio_num = int(gpio_num)\r
+    total_ms = int(total_ms)\r
+    half_ms = int(half_ms)\r
+\r
+    print(\r
+        "test_led: gpio={} total_ms={} half_ms={} (toggle every {}ms)".format(\r
+            gpio_num, total_ms, half_ms, half_ms\r
+        )\r
+    )\r
+    pin = Pin(gpio_num, Pin.OUT, Pin.PULL_PD, 0)\r
+    start = utime.ticks_ms()\r
+    lvl = 0\r
+    while utime.ticks_diff(utime.ticks_ms(), start) < total_ms:\r
+        pin.write(lvl)\r
+        lvl = 1 - lvl\r
+        utime.sleep_ms(half_ms)\r
+    pin.write(0)\r
+    print("test_led: done, gpio {} driven low".format(gpio_num))\r
+\r
+\r
+def run_main():\r
+    """从 argv 读 GPIO:例如若平台支持 argv[1] 为数字则使用。"""\r
+    argv = getattr(usys, "argv", None)\r
+    if argv and len(argv) > 1:\r
+        try:\r
+            run(int(argv[1]))\r
+            return\r
+        except Exception as e:\r
+            print("test_led: argv parse fail {}, use TEST_GPIO".format(e))\r
+    run()\r
+\r
+\r
+if __name__ == "__main__":\r
+    run_main()
\ No newline at end of file