From: SnickerSun Date: Thu, 7 May 2026 08:18:50 +0000 (+0800) Subject: V2.5 程序架构初步改动完毕,当下采用状态机模式驱动,暂时空间不够,还需削减代码。正常长对话,单对话,led都工作,但是功能还未完全实现完。 X-Git-Url: http://git.snicker.com.cn/?a=commitdiff_plain;h=bad4c2e9df2d8eb288931b3a1b61fe4227b11c48;p=mirrors%2FASR1603_G5.git V2.5 程序架构初步改动完毕,当下采用状态机模式驱动,暂时空间不够,还需削减代码。正常长对话,单对话,led都工作,但是功能还未完全实现完。 --- diff --git "a/1.doc/RVC Service WebSocket V3 API \345\274\200\345\217\221\346\226\207\346\241\243.pdf" "b/1.doc/RVC Service WebSocket V3 API \345\274\200\345\217\221\346\226\207\346\241\243.pdf" new file mode 100644 index 00000000..1ef43f0b 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_\345\215\217\350\256\256\345\217\230\346\233\264\345\257\271\347\205\247.md" "b/1.doc/V2.5_to_V3_\345\215\217\350\256\256\345\217\230\346\233\264\345\257\271\347\205\247.md" new file mode 100644 index 00000000..98ee4331 --- /dev/null +++ "b/1.doc/V2.5_to_V3_\345\215\217\350\256\256\345\217\230\346\233\264\345\257\271\347\205\247.md" @@ -0,0 +1,347 @@ +# V2.5 → V3 协议变更对照(语音对话 WebSocket) + +本文基于以下两份文档对照整理: + +- `金三服务-语音对话V2.5.pdf` +- `RVC Service WebSocket V3 API 开发文档.pdf` + +目标读者:端侧/客户端对接工程师、服务端兼容层工程师。 +目标:从 V2.5 升级到 V3 时,把**所有协议不兼容点**、**字段重命名**、**事件变更**、**时序差异**一次列清。 + +--- + +## 1. 结论速览(必改项 Top 10) + +- **(1) WS 路径变更**:V3 统一为 `/rvc-service/api/v3/ws`。 +- **(2) ApiKey 鉴权参数名变更**:V2.5 用 `token`;V3 用 `api_key`,并新增可传 `user_id/device_id`。 +- **(3) JSON 封装变化(最大不兼容)**:V2.5 `event/payload` → V3 `type/data` + Envelope(timestamp/message_id/session_id/metadata)。 +- **(4) start 入参结构重构**:V2.5 `inbound_attributes/outbound_attributes` → V3 `audio_config.input/output` + `session_mode/input_mode/output_mode`。 +- **(5) 会话模式明确化**:V3 引入 `realtime` 与 `push_to_talk`,直接决定是否需要 `audio.create/audio.completed`。 +- **(6) 输入/输出模式枚举**:V3 支持 `input_mode`/`output_mode`(audio/text/both/none),替代 V2.5 若干分散开关(如 simple_response)。 +- **(7) 事件集与字段名变化**:如 `failed`→`error`,TTS `tts.completed`→`tts.done`,ASR `content`→`text` 等。 +- **(8) cancel 约束变化**:V2.5 文档限制“只能 asr.completed 后取消”;V3 可用于打断 LLM/TTS,打断时机受策略控制。 +- **(9) 新增音频帧加密协商**:V3 增加 `audio_encryption`(仅加密二进制音频帧)。 +- **(10) 新增 metadata 动态更新**:V3 增加 `metadata.update`(客户端)与 `metadata.updated`(服务端)。 + +> 风险提示:V2.5 文档出现的 `audio.played`、`audio.convert.*`、`command`/`submit.tool_output` 在 V3 文档中未覆盖,若你现网依赖这些能力,需要额外确认 V3 是否提供等价机制或需服务端兼容。 + +--- + +## 2. 接入信息变化 + +### 2.1 WebSocket URL + +- **V2.5**:文档列出多套网关/路径(示例:`/vc/agent`、`/vc/chat`)。 +- **V3**:统一为: + - `ws://{host}:{port}/rvc-service/api/v3/ws` + +### 2.2 鉴权方式(Query 参数) + +两版均支持 DMP 授权(`dt/ut`),但 ApiKey 的参数名变化。 + +| 目的 | V2.5 | V3 | +|---|---|---| +| DMP 设备/用户授权 | `?dt=...&ut=...` | `?dt=...&ut=...` | +| ApiKey 授权 | `?token=API_KEY` | `?api_key=...`(可选再带 `user_id/device_id`) | + +--- + +## 3. JSON 消息封装变化(Envelope) + +### 3.1 V2.5:event/payload + +V2.5 控制类消息基本形如: + +```json +{ + "event": "start", + "payload": { } +} +``` + +### 3.2 V3:type/data + Envelope + +V3 所有 JSON 文本消息统一 Envelope: + +```json +{ + "type": "started", + "data": { }, + "timestamp": "2025-01-01T12:00:00Z", + "message_id": "optional", + "session_id": "optional", + "metadata": { "k": "v" } +} +``` + +字段说明: + +- **type**:事件/消息类型(字符串) +- **data**:负载(结构随 type 而变) +- **timestamp/message_id/session_id/metadata**:可选 + +迁移影响: + +- **客户端发送**:全部从 `event` 改为 `type`,从 `payload` 改为 `data`(并允许无 data)。 +- **客户端接收**:必须以 Envelope 解析为主(否则会漏掉重要字段,如 session_id/metadata)。 + +--- + +## 4. start(会话初始化)协议差异 + +### 4.1 V2.5 start(关键字段) + +V2.5 示例中常见字段组合: + +- `simple_response`:控制 LLM 是否仅返回完整文本 +- `outbound_attributes.cfg`:TTS 输出音频配置(format/codec/sample_rate/bit_rate/channel/volume/speech_rate/chunk_interval_ms) +- `inbound_attributes.disable_vad/vad_silence/interrupt_options/cfg`:ASR 输入配置与 VAD/打断 + +### 4.2 V3 start(关键字段) + +V3 start 负载结构强调“模式”和“音频配置”: + +- `session_mode`: `realtime` / `push_to_talk` +- `input_mode`: `audio` / `text` / `none` +- `output_mode`: `audio` / `text` / `both` / `none` +- `audio_config.input/output`: `format`/`sample_rate`/`bit_rate`/`channel`(并支持 output `speech_rate` 等) +- `interrupt_options.strategy`: `keyword` / `always_allow` / `never_allow`(文档亦出现 thinking_time_only 的描述) +- 可选 `audio_encryption`:音频二进制帧加密协商 +- 可选 `metadata`:会话级自定义参数 + +### 4.3 V2.5 → V3 字段映射建议 + +| V2.5 字段路径 | V3 字段路径 | 备注 | +|---|---|---| +| `payload.device_id` | `data.device_id` | 字段名一致,位置不同 | +| `payload.outbound_attributes.cfg.*` | `data.audio_config.output.*` | 输出音频参数归一 | +| `payload.inbound_attributes.cfg.*` | `data.audio_config.input.*` | 输入音频参数归一 | +| `payload.inbound_attributes.disable_vad` | `data.session_mode` | 语义变化:V3 用会话模式显式表达 | +| `payload.simple_response` | `data.output_mode` + LLM 事件处理 | V3 不再用该开关表达“增量/完整”同一概念 | + +--- + +## 5. 会话模式与音频发送控制差异 + +### 5.1 V2.5(基于 VAD 开关的约定) + +V2.5 文档说明: + +- 开启 VAD 时:**不需要**发送 `audio.create` 与 `audio.completed`;收到 `started` 后直接发音频。 +- 关闭 VAD 时:需要用 `audio.create` 与 `audio.completed` 包裹一次讲话。 + +### 5.2 V3(session_mode 明确化) + +V3 明确规定: + +- **realtime**:持续监听,**无需** `audio.create/audio.completed` +- **push_to_talk**:按键对讲,每轮必须: + - `audio.create` → 发送二进制音频帧 → `audio.completed` + +迁移建议: + +- 若你原来“开启 VAD 并持续推流”的形态,V3 对应 **realtime**。 +- 若你原来“按键说话”的形态,V3 对应 **push_to_talk**。 + +--- + +## 6. 客户端 → 服务端:消息类型对照 + +| 目的 | V2.5(event) | V3(type) | 变化点 | +|---|---|---|---| +| 开始会话 | `start` | `start` | 外层从 event/payload → type/data;start.data 结构重构 | +| 开始说话(PTT) | `audio.create` | `audio.create` | V3 明确仅 push_to_talk 使用 | +| 结束说话(PTT) | `audio.completed` | `audio.completed` | 同上 | +| 文本输入 | `prompt` | `prompt` | payload.prompt → data.messages | +| 直接 TTS | `synthesis` | `synthesis` | 外层封装变化;data.text | +| 取消/打断 | `cancel` | `cancel` | V3 语义更通用(打断 LLM/TTS),不再强调固定时机 | +| 会话中更新参数 | (无) | `metadata.update` | V3 新增 | + +二进制音频帧(上行)两版都是直接发 BinaryFrame(非 JSON),但 V3 增加了可选加密层。 + +--- + +## 7. 服务端 → 客户端:事件类型对照 + +### 7.1 会话/流程类 + +| 含义 | V2.5(event) | V3(type) | 变化点 | +|---|---|---|---| +| 会话开始 | `started` | `started` | V3 的 started.data 会回传更完整的 config(含加密协商信息) | +| 会话完成 | `completed` | `completed` | 封装变化;V3 表达“一次完整链路结束” | +| 会话取消结果 | `canceled` | `canceled` | 封装变化 | +| 错误 | `failed` | `error` | **名称与负载结构均变化** | +| metadata 更新回执 | (无) | `metadata.updated` | V3 新增 | + +### 7.2 VAD/ASR + +| 含义 | V2.5(event) | V3(type) | 负载关键字段变化 | +|---|---|---|---| +| VAD 活跃 | `vad.active` | `vad.active` | 结构更轻,通常无或少量 data | +| VAD 静音 | `vad.silence` | `vad.silence` | 同上 | +| ASR 开始 | `asr.started` | `asr.started` | V2.5 常带 dialog_id;V3 以 data 结构定义 | +| ASR 片段 | `asr.result` | `asr.result` | **V2.5: payload.content**;**V3: data.text + is_final/confidence/...** | +| ASR 完成 | `asr.completed` | `asr.completed` | 封装变化 | + +### 7.3 LLM + +| 含义 | V2.5(event) | V3(type) | 变化点 | +|---|---|---|---| +| LLM 创建 | `llm.created` | (无) | V3 用 `llm.started` 表达开始 | +| LLM 增量 | `llm.delta` | `llm.delta` | V3 data 常见 `{delta:"..."}`(字段名不同于 V2.5 payload.content) | +| LLM 完整文本 | `llm.completed` | (无明确同名) | V3 更多依赖 delta 聚合或 done 信号 | +| LLM 结束 | `llm.done` | `llm.done` | 封装变化 | + +### 7.4 TTS 与音频下行(二进制) + +| 含义 | V2.5(event) | V3(type) | 变化点 | +|---|---|---|---| +| TTS 创建 | `tts.created` | (无) | V3 不再强调 created | +| TTS 开始 | `tts.started` | `tts.started` | 封装变化 | +| TTS 音频结束 | `tts.completed` | `tts.done` | **事件名变化** | +| 音频数据 | 二进制帧 | 二进制帧 | V3 可能加密;格式取决于 audio_config.output.format | + +--- + +## 8. 错误模型变化 + +### 8.1 V2.5:failed + +示例: + +```json +{ + "event": "failed", + "payload": { + "error": "AUDIO_FILE_READ_ERROR", + "message": "音频文件读取失败" + } +} +``` + +### 8.2 V3:error + +示例: + +```json +{ + "type": "error", + "data": { + "code": "BadRequest", + "message": "参数不合法", + "details": "..." + } +} +``` + +迁移影响: + +- 错误事件名从 `failed` → `error` +- 字段从 `error/message` → `code/message/details?` + +--- + +## 9. 音频帧与格式差异(PCM/Opus + 加密) + +### 9.1 PCM 约束(两版基本一致) + +- 编码:S16LE(小端) +- 采样率:16000 Hz(建议) +- 声道:1(单声道) +- 发送方式:WebSocket 二进制帧 + +### 9.2 Opus(两版均支持,但 V3更体系化) + +V3 对 Opus 帧长建议更明确(示例 60ms),并强调仅当 `audio_config.input.format=opus` 才使用。 + +### 9.3 V3 新增:音频帧加密(可选) + +V3 增加 `audio_encryption`: + +- 仅加密**二进制音频帧**(上/下行);JSON 控制消息不加密 +- 支持算法:`x25519-aes-256-gcm`、`x25519-chacha20-poly1305`、`p256-aes-256-gcm` +- 通过 `start.data.audio_encryption.client_public_key` 与 `started.data.audio_encryption.server_public_key/salt` 协商 +- 加密帧格式:`12-byte nonce + ciphertext_with_tag` + +迁移建议: + +- 若不需要加密,可先保持 `audio_encryption.enabled=false`,降低一次性改造风险。 +- 如要开启加密,需确保端侧具备:密钥协商(X25519/P-256)+ HKDF-SHA256 + AEAD 解密/加密能力。 + +--- + +## 10. V2.5 有而 V3 文档未覆盖的能力(迁移风险清单) + +以下能力在 V2.5 文档中出现,但 V3 文档未描述对应 type: + +- **`audio.played`**:V2.5 关键词打断流程要求音频播放完回传,否则不进入下一轮对话状态。 +- **`audio.convert` / `audio.convert.started` / `audio.convert.completed` / `audio.convert.failed`**:V2.5 支持服务端拉取音频 URL 转码并下发。 +- **`command` + `submit.tool_output`**:V2.5 有指令解析/工具调用回传示例。 + +建议行动: + +- 若客户端有使用这些事件,请向服务端确认: + - V3 是否继续支持这些能力(可能为另一个 API/版本) + - 是否需要开兼容开关或走额外的扩展 metadata/自定义 type + +--- + +## 11. 典型时序对照(文本版) + +### 11.1 V3 Realtime(持续监听) + +1) 连接 WS +2) 发送 `start`(`session_mode=realtime`,`input_mode=audio`) +3) 持续发送二进制音频帧 +4) 服务端事件大致顺序: + +- `vad.active` → `asr.started` → 多次 `asr.result` → `asr.completed` +- `llm.started` → 多次 `llm.delta` → `llm.done` +- `tts.started` → 多次(二进制音频帧)→ `tts.done` +- `completed` + +需要打断时发送 `cancel`(效果受 interrupt 策略影响)。 + +### 11.2 V3 Push-To-Talk(按键说话) + +1) 发送 `start`(`session_mode=push_to_talk`) +2) 每轮: + +- `audio.create` → 发送二进制音频帧 → `audio.completed` + +后续服务端事件链路与 realtime 类似。 + +--- + +## 12. 迁移改造 Checklist(端侧) + +- [ ] WS URL 切到 `/rvc-service/api/v3/ws` +- [ ] 鉴权 query:`token` → `api_key`(如使用 ApiKey) +- [ ] JSON 解析器支持 Envelope:`type/data` + 可选字段 +- [ ] start 请求结构按 V3 重构: + - [ ] `session_mode/input_mode/output_mode` + - [ ] `audio_config.input/output` + - [ ] `interrupt_options` 新结构(strategy + keywords) +- [ ] 会话模式驱动音频控制: + - [ ] realtime:不发 create/completed + - [ ] push_to_talk:必须发 create/completed +- [ ] 服务端事件处理改造: + - [ ] `failed` → `error` + - [ ] `tts.completed` → `tts.done` + - [ ] `llm.*` 事件集对齐(started/delta/done) + - [ ] `asr.result` 字段路径从 `content` → `text`(并处理 is_final) +- [ ] 二进制音频帧: + - [ ] PCM/Opus 解码逻辑按 `audio_config.*.format` 选择 + - [ ] 如启用加密:实现协商与帧解密/加密 +- [ ] 若依赖 V2.5 的 `audio.played/audio.convert/command`:与服务端确认 V3 等价能力并实现适配 + +--- + +## 13. 附:快速对照(最常踩坑) + +1) **别再发 `event/payload`**:V3 收不到或会直接报参数错误。 +2) **别再等 `asr.completed` 才 cancel**:V3 cancel 面向打断 LLM/TTS,但要看策略。 +3) **TTS 结束事件名不同**:V2.5 写 `tts.completed`;V3 是 `tts.done`。 +4) **ASR 字段名不同**:V2.5 `content`;V3 `text`,并有 `is_final`。 +5) **V2.5 的某些扩展事件 V3 文档没写**:先确认再迁移,避免上线缺功能。 + diff --git "a/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" "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" new file mode 100644 index 00000000..29dbc448 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 diff --git a/2.sdk/project/7PIN_1_5/new.bat b/2.sdk/project/7PIN_1_5/new.bat index 396c014a..de5acf0c 100644 --- a/2.sdk/project/7PIN_1_5/new.bat +++ b/2.sdk/project/7PIN_1_5/new.bat @@ -1,6 +1,16 @@ @echo off md images del images /q + +:: 确保 7PIN_1_5 的 customer_fs(/usr) 内包含音频 NVM(增益/语音增强表) +:: 这两份文件会影响录音/通话的场景增益与 VE 参数;缺失时可能回退到默认表导致录音幅度异常。 +if exist "sdk\services\microPython\ports\quectel\core\audio_gain.nvm" ( + copy /y "sdk\services\microPython\ports\quectel\core\audio_gain.nvm" "usr\audio_gain.nvm" >nul +) +if exist "sdk\services\microPython\ports\quectel\core\audio_ve.nvm" ( + copy /y "sdk\services\microPython\ports\quectel\core\audio_ve.nvm" "usr\audio_ve.nvm" >nul +) + @GOTO :STEP1 ..\..\Helios_SDK\tools\ASR_1606\win32\mklfs.exe -c ext -b 4096 -r 4096 -p 4096 -s 1048576 -i .\images\ext_fs.bin if errorlevel 1 ( diff --git a/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/Makefile b/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/Makefile index 54aab983..51994be6 100644 --- a/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/Makefile +++ b/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/Makefile @@ -927,11 +927,9 @@ frozen_mpy_list = $(BUILD)/_boot.mpy \ $(BUILD)/ntptime.mpy \ $(BUILD)/request.mpy \ $(BUILD)/log.mpy \ - $(BUILD)/aLiYun.mpy \ $(BUILD)/hmac.mpy \ $(BUILD)/hashlib.mpy \ $(BUILD)/base64.mpy \ - $(BUILD)/TenCentYun.mpy \ $(BUILD)/file_sha256.mpy \ $(BUILD)/checksum.mpy \ $(BUILD)/checkNet.mpy \ @@ -1001,11 +999,9 @@ frozen_mpy_list = $(BUILD)/_boot.mpy \ $(BUILD)/ntptime.mpy \ $(BUILD)/request.mpy \ $(BUILD)/log.mpy \ - $(BUILD)/aLiYun.mpy \ $(BUILD)/hmac.mpy \ $(BUILD)/hashlib.mpy \ $(BUILD)/base64.mpy \ - $(BUILD)/TenCentYun.mpy \ $(BUILD)/file_sha256.mpy \ $(BUILD)/checksum.mpy \ $(BUILD)/checkNet.mpy \ @@ -1041,7 +1037,6 @@ frozen_mpy_list = $(BUILD)/_boot_WDF.mpy \ $(BUILD)/dataCall.mpy \ $(BUILD)/log.mpy \ $(BUILD)/request.mpy \ - $(BUILD)/aLiYun.mpy \ $(BUILD)/hmac.mpy \ $(BUILD)/hashlib.mpy \ $(BUILD)/ntptime.mpy \ @@ -1069,7 +1064,6 @@ frozen_mpy_list = $(BUILD)/_boot.mpy \ $(BUILD)/dataCall.mpy \ $(BUILD)/log.mpy \ $(BUILD)/request.mpy \ - $(BUILD)/aLiYun.mpy \ $(BUILD)/hmac.mpy \ $(BUILD)/hashlib.mpy \ $(BUILD)/ntptime.mpy \ @@ -1128,11 +1122,9 @@ frozen_mpy_list = $(BUILD)/_boot.mpy \ $(BUILD)/ntptime.mpy \ $(BUILD)/request.mpy \ $(BUILD)/log.mpy \ - $(BUILD)/aLiYun.mpy \ $(BUILD)/hmac.mpy \ $(BUILD)/hashlib.mpy \ $(BUILD)/base64.mpy \ - $(BUILD)/TenCentYun.mpy \ $(BUILD)/file_sha256.mpy \ $(BUILD)/checksum.mpy \ $(BUILD)/checkNet.mpy \ diff --git a/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/ai_VC.py b/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/ai_VC.py index 34fc83e0..476d06a8 100644 --- a/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/ai_VC.py +++ b/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/ai_VC.py @@ -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/sdk/services/microPython/ports/quectel/core/versions.py b/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/versions.py index fc837748..a8b4b6df 100644 --- a/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/versions.py +++ b/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/versions.py @@ -1,2 +1,2 @@ Version="V1.0" -Build_Time="2026-04-29 11:21:48" +Build_Time="2026-05-07 14:29:21" 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 index 00000000..fe06d817 Binary files /dev/null and b/2.sdk/project/7PIN_1_5/usr/audio_gain.nvm differ diff --git "a/\344\272\247\346\265\213\345\267\245\345\205\267/FactoryTool/test_led.py" "b/\344\272\247\346\265\213\345\267\245\345\205\267/FactoryTool/test_led.py" new file mode 100644 index 00000000..044b9569 --- /dev/null +++ "b/\344\272\247\346\265\213\345\267\245\345\205\267/FactoryTool/test_led.py" @@ -0,0 +1,70 @@ +""" +板端 LED/GPIO 闪烁测试(独立脚本,不依赖 VC)。 + +用法: +1) 改下面 TEST_GPIO 为导出 GPIO 号(与 Pin(Pin.GPIOxx) / machine.Pin 里 GPIOxx 一致),然后: + import usr.test_led + usr.test_led.run() + +2) 或 REPL 里指定脚号: + import usr.test_led as t + t.run(44) + +3) 若固件启动脚本支持传入 argv(视平台而定): + t.run_main() + +参数:总时长 3s,每隔 500ms 翻转一次电平;结束后拉低关闭。 +""" + +from machine import Pin +import utime + +try: + import usys +except ImportError: + import sys as usys + +# 手动指定要测的导出 GPIO(与 datasheet / Pin.GPIOxx 对应,不是原理图内部的 GPIO70) +TEST_GPIO = 21 + +TOTAL_MS = 3000 +HALF_PERIOD_MS = 500 + + +def run(gpio_num=None, total_ms=TOTAL_MS, half_ms=HALF_PERIOD_MS): + if gpio_num is None: + gpio_num = TEST_GPIO + gpio_num = int(gpio_num) + total_ms = int(total_ms) + half_ms = int(half_ms) + + print( + "test_led: gpio={} total_ms={} half_ms={} (toggle every {}ms)".format( + gpio_num, total_ms, half_ms, half_ms + ) + ) + pin = Pin(gpio_num, Pin.OUT, Pin.PULL_PD, 0) + start = utime.ticks_ms() + lvl = 0 + while utime.ticks_diff(utime.ticks_ms(), start) < total_ms: + pin.write(lvl) + lvl = 1 - lvl + utime.sleep_ms(half_ms) + pin.write(0) + print("test_led: done, gpio {} driven low".format(gpio_num)) + + +def run_main(): + """从 argv 读 GPIO:例如若平台支持 argv[1] 为数字则使用。""" + argv = getattr(usys, "argv", None) + if argv and len(argv) > 1: + try: + run(int(argv[1])) + return + except Exception as e: + print("test_led: argv parse fail {}, use TEST_GPIO".format(e)) + run() + + +if __name__ == "__main__": + run_main() \ No newline at end of file