From: snickersun Date: Wed, 27 May 2026 08:30:13 +0000 (+0800) Subject: 升级协议到v3 X-Git-Url: http://git.snicker.com.cn/?a=commitdiff_plain;h=e3f201b6cc94f4dde3779b091962d06065041cee;p=mirrors%2FASR1603_G5.git 升级协议到v3 录音音量降低 开机震动提前 --- diff --git "a/1.doc/K8_diaozhui/design/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/K8_diaozhui/design/V2.5_to_V3_\345\215\217\350\256\256\345\217\230\346\233\264\345\257\271\347\205\247.md" index 98ee4331..8376a295 100644 --- "a/1.doc/K8_diaozhui/design/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/K8_diaozhui/design/V2.5_to_V3_\345\215\217\350\256\256\345\217\230\346\233\264\345\257\271\347\205\247.md" @@ -114,10 +114,17 @@ V3 start 负载结构强调“模式”和“音频配置”: | 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 不再用该开关表达“增量/完整”同一概念 | +| `payload.outbound_attributes.cfg.*` | `data.audio_config.output.*` | 仅 `format/sample_rate/bit_rate/channel/speech_rate` | +| `payload.outbound_attributes.cfg.voice` | `data.metadata.voice`(或 agent 绑定) | **V3 audio_config 无 voice** | +| `payload.outbound_attributes.cfg.chunk_interval_ms` | `data.metadata.tts_cfg_extra` | **V3 audio_config 无此字段** | +| `payload.outbound_attributes.cfg.emotion` | (V3 不传) | V3 无 emotion;端侧 V2.5 仍可在 outbound 使用 | +| `payload.outbound_attributes.cfg.volume/chunk_interval_ms` | (V3 不传) | 同上 | +| `payload.inbound_attributes.cfg.*` | `data.audio_config.input.*` | 仅 `format/sample_rate/bit_rate/channel` | +| `payload.inbound_attributes.cfg.keywords` 等 | `data.metadata.asr_cfg_extra` | V3 input 无热词等字段 | +| `payload.inbound_attributes.disable_vad=false` | `session_mode: "realtime"` | 长对话/VAD 自动断句;无需 audio.create | +| `payload.inbound_attributes.disable_vad=true` | `session_mode: "push_to_talk"` | 按键说话;须 audio.create → 帧 → audio.completed | +| `payload.simple_response: true` | `output_mode: "audio"` | 不订阅 LLM 文本流;与 `both`/`text` 区分 | +| `payload.dialog_attributes` / `bot_option` | `agent_id` + `metadata` | V3 无 `dialog_attributes` 顶层;须服务端约定映射 | --- @@ -343,5 +350,173 @@ V3 增加 `audio_encryption`: 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 文档没写**:先确认再迁移,避免上线缺功能。 +5) **V2.5 的某些扩展事件 V3 文档没写**:先确认再迁移,避免上线缺功能。 +6) **本工程 Start 出站形态与 V2.5 文档示例不完全一致**:代码用 `header.event="Start"` + `payload`,recv 侧按顶层 `event` 解析;切 V3 时须统一走适配层,勿只改 start 不改 cancel/synthesis。 + +--- + +## 14. 本工程(7PIN_1_5 / k8)现状与代码锚点 + +当前固件实现的是 **金三 V2.5 语义**(`event`/`payload` 或 `header.event` + `payload`),与 `vc_fsm` / 按键 UX 已深度绑定。 + +| 职责 | 文件 | 关键符号 | +|------|------|----------| +| WS 建链 URL | `vc_session_io.py` | `websocket_connect_VC()`:`VC_WS_PATH` + `?is_auth=&dt={token}` | +| 会话 start | `vc_session_io.py` | `AI_client_VC_start_requset()`:`header.event="Start"` + `inbound_attributes` / `outbound_attributes` / `dialog_attributes` | +| 下行分发 | `vc_session_io.py` | `AI_client_VC_recv()`:`jsload_data["event"]`、`payload` | +| PTT 音频控制 | `vc_session_media.py` | `AI_client_VC_send_speech()` / `end_speech()`:`audio.create` / `audio.completed` | +| TTS 合成 / 播完上报 | `vc_session_media.py` | `AI_client_VC_synthesis()`、`AI_client_VC_audio_played()` | +| 状态机 | `vc_fsm.py` | `VC_mode` 0=长对话(VAD on) ↔ realtime;1=PTT ↔ push_to_talk | +| 产品配置 | `products/configs/k8/default_config.py` | `VC_WS_PATH = "vc/agent"` | +| 环境主机 | `default_srv.py` / `srv_configs/*` | `AMP_SERVER`(`ws://{AMP_SERVER}/...`) | + +**与 V2.5 文档的差异(实现层,非阻塞迁移):** + +- 出站 **Start** 使用 `header.event: "Start"`(大写),文档示例为顶层 `"event": "start"`。 +- 出站 **audio.create / audio.completed** 放在 `header.event` 下;**cancel / synthesis** 为顶层 `event` + `payload`(`_vc_out_json_event_meta` 已兼容两种形态)。 +- **长对话**(`VC_mode==0`)在 start 里设 `disable_vad=False`;**PTT** 默认 `disable_vad=True`,与 V3 的 `session_mode` 一一对应。 +- **`audio.played`** 在 `vc_common.py` 注释中明确为 V2.5 服务端门控;`vc_fsm` 的 `await_played` 依赖本地播完 + 上报。 + +--- + +## 15. 最简单可配置迁移方案(推荐) + +原则:**配置切换协议版本 + 单一适配层归一化事件名/字段**;`vc_fsm`、按键、`vc_session_media` 上行门控 **尽量不改**,避免拆东墙补西墙。 + +### 15.1 仅增配置项(`default_config.py`,k8 量产可覆盖) + +```python +# 协议版本:v2.5 | v3 +VC_PROTOCOL_VERSION = "v2.5" + +# WebSocket 路径段(无首尾斜杠) +# v2.5: vc/agent | vc/chat +# v3: rvc-service/api/v3/ws +VC_WS_PATH = "vc/agent" + +# 鉴权 Query 风格(与 get_dev_token / DMP 链路配合) +# dmp_dt → ...?dt={token}(现状) +# api_key → ...?api_key={key}&device_id={imei}(V3 ApiKey 模式,需服务端发 key) +VC_WS_AUTH_MODE = "dmp_dt" + +# V2.5 专有:播完上报 audio.played;V3 文档无此事件,应置 0,改由 tts.done + 本地播完驱动 +VC_AUDIO_PLAYED_ACK = 1 + +# V3 start:agent_id(对应现网 bot);workflow 等放进 metadata(须与 RVC 服务端约定) +VC_AGENT_ID = None # None 时用 VC_BOT_ID + +# 可选:首版不启用 V3 音频帧加密 +VC_AUDIO_ENCRYPTION = 0 +``` + +`default_srv.py` 仍只负责 `AMP_SERVER`;**路径与协议版本放在 `default_config`**,与现有 `VC_WS_PATH` 分工一致。 + +### 15.2 新增薄适配层(建议 `vc_proto.py`,约 120 行) + +只暴露 4 个函数,所有 JSON 进出 WS 经此层: + +| 函数 | 作用 | +|------|------| +| `vc_proto_build_ws_url(amp_server, path, auth_mode, token, device_id)` | 统一拼 `websocket_connect_VC` 的 URL | +| `vc_proto_pack_client(event, payload)` | 出站:`v2.5` → `event/payload` 或 `header`;`v3` → `{type, data}` | +| `vc_proto_unpack_server(raw_dict)` | 入站:解析后 **归一化为 V2.5 内部事件名 + payload 字典** | +| `vc_proto_build_start(vc_session)` | 按版本生成 start/Start 整包 | + +**入站别名表(V3 → 内部仍用 V2.5 名,FSM/recv 零改动):** + +| V3 `type` | 内部 `event`(现有代码) | +|-----------|-------------------------| +| `error` | `failed` | +| `tts.done` | `tts.completed` | +| `llm.started` | `llm.created` | +| 其余同名 | 不变(`started`、`asr.*`、`vad.*`、`cancel` 等) | + +**payload 字段归一(在 `unpack` 内完成):** + +- `asr.result`:`data.text` → `payload.content`(保留现有 `asr.result` 已尝试 `text` 的兼容逻辑)。 +- `llm.delta`:`data.delta` → 若业务读 `payload.content` 则同步写入。 +- `failed`/`error`:`data.code/message/details` → `payload.error` + `payload.message`(供 `websocket_error` 使用)。 + +### 15.3 改动面最小化(仅 6 个 choke point) + +| # | 位置 | 改动 | +|---|------|------| +| 1 | `websocket_connect_VC` | URL 改为 `vc_proto_build_ws_url(...)` | +| 2 | `AI_client_VC_start_requset` | 仍构建 V2.5 JSON;`AI_client_VC_send` 内 `vc_proto_pack_wire` 转 V3 start | +| 3 | `AI_client_VC_send` | 统一 `vc_proto_pack_wire`(所有 JSON 出站,含 cancel/audio.*/synthesis) | +| 4 | `AI_client_VC_recv` 解析后 | `ev, payload, env = vc_proto_unpack_server(jsload_data)`,后续仍用 `jsload_data["event"]` 可写回归一结果 | +| 5 | `AI_client_VC_audio_played` | `if VC_AUDIO_PLAYED_ACK: ...`;V3 直接走现有 `_vc_fire_local_play_end` 链 | +| 6 | `vc_common._vc_out_json_event_meta` | 同时识别 `type`(V3 出站日志) | + +**明确不先改:** `vc_fsm.py` 事件名、`vc_key_router`、录音上行门控(`VC_PROTO_GUARDS`)、二进制 PCM 发送逻辑。 + +### 15.4 K8 产品:start 字段映射(V2.5 → V3) + +| 现网 `AI_client_VC_start_requset` | V3 `start.data` | +|----------------------------------|-----------------| +| `VC_mode==0` → `disable_vad=False` | `session_mode: "realtime"` | +| `VC_mode==1` → `disable_vad=True` | `session_mode: "push_to_talk"` | +| (固定音频对话) | `input_mode: "audio"`, `output_mode: "audio"` | +| `simple_response: True` | `output_mode: "audio"`(不订阅 LLM 文本流;无需 `both`) | +| `outbound_attributes.cfg.format/sr/...` | `audio_config.output.*`(仅 format/sample_rate/bit_rate/channel/speech_rate) | +| `outbound_attributes.cfg.voice` | `metadata.voice` | 仅音色进 metadata | +| `outbound_attributes.cfg.emotion/volume/chunk_interval_ms` | (不传) | V3 无这些字段 | +| `inbound_attributes.cfg.*` + `vad_silence` | `audio_config.input.*`;静音阈值若 V3 无独立字段 → `metadata.vad_silence` **须服务端确认** | +| `interrupt_options.thinking_time_only` | `interrupt_options.strategy: "thinking_time_only"`(V3 文档支持) | +| `dialog_attributes.cfg.bot_option` | `agent_id` + `metadata`(`bot_id`/`workflow_id`/`custom_variables`) | +| `payload.device_id` | `data.device_id` | +| DMP `devToken` in `custom_variables` | `metadata` 或继续 DMP Query `dt` | + +### 15.5 分阶段上线建议 + +| 阶段 | 配置 | 验证点 | +|------|------|--------| +| P0 联调 | `VC_PROTOCOL_VERSION=v3`,`VC_AUDIO_ENCRYPTION=0`,`VC_AUDIO_PLAYED_ACK=0` | 建链、start、started、长对话连续 3 轮、PTT 短按/长按 | +| P1 行为对齐 | 同上 + 打断/cancel 策略 | thinking 期打断、PTT 过短取消、Power Close | +| P2 可选 | `VC_AUDIO_ENCRYPTION=1` | 仅二进制帧加解密,JSON 仍明文 | +| 保持 V2.5 | 默认不改配置 | 回归现有金三网关 | + +**上线前必须向 RVC 服务端确认:** + +1. K8 使用的 **Dify bot_id / workflow_id** 在 V3 下是 `agent_id` 还是 `metadata.inputs`。 +2. **`audio.played` 无等价事件** 时,关键词打断 / 连续对话是否仅依赖 `tts.done` + 本地播完。 +3. **`command` / `audio.convert.*`** 是否仍走旧 `/vc/agent` 或另有 HTTP/扩展 type。 + +--- + +## 16. 迁移改造 Checklist(端侧,含本仓库) + +在 §12 通用项基础上,按本工程勾选: + +- [x] `default_config.py` 增加 `VC_PROTOCOL_VERSION` / `VC_WS_AUTH_MODE` / `VC_AUDIO_PLAYED_ACK`(`configs/k8/default_config.py`) +- [x] 新增 `vc_proto.py`(pack/unpack/url;start 经 `pack_wire` 转换) +- [x] `vc_session_io.websocket_connect_VC` 使用 `vc_proto_build_ws_url` +- [x] `AI_client_VC_start_requset`:仍构建 V2.5 结构,经 `AI_client_VC_send` → `vc_proto_pack_wire` 发 V3 +- [x] 所有 JSON 出站经 `AI_client_VC_send` → `vc_proto_pack_wire`(含 cancel/audio.*/synthesis) +- [x] `AI_client_VC_recv` 入站 `vc_proto_normalize_server` +- [x] `VC_AUDIO_PLAYED_ACK=0` 时 `play_end` 自动开麦(长对话):V3 无 `dialog_id` 时用 `_turn_has_asr_completed` / `_vc_expect_dialog_tts` 标 `kind=dialog`(`vc_session_io._vc_classify_llm_tts_kind`) +- [ ] PTT:`session_mode=push_to_talk` 下 `audio.create`/`audio.completed` 行为不变 — **需实机 V3 联调** +- [ ] 留言 / `audio.convert` / `command`:确认 V3 不支持则 **保持 v2.5 路径或单独开关** +- [ ] 联调环境:`AMP_SERVER` + `VC_WS_PATH=rvc-service/api/v3/ws`(`default_srv` + k8 `default_config`) + +--- + +## 17. 配置示例(k8 切 V3 联调) + +```python +# products/configs/k8/default_config.py(或 usr 覆盖) +VC_PROTOCOL_VERSION = "v3" +VC_WS_PATH = "rvc-service/api/v3/ws" +VC_WS_AUTH_MODE = "dmp_dt" # 仍用设备 token 时 +VC_AUDIO_PLAYED_ACK = 0 +VC_AGENT_ID = VC_BOT_ID # 或服务端分配的 agent_id +VC_AUDIO_ENCRYPTION = 0 +``` + +```python +# products/srv_configs/beike_dev/default_srv.py(示例,按实际 RVC 主机修改) +AMP_SERVER = "115.190.94.208:8087" # 文档示例 ws://host:8087/rvc-service/api/v3/ws +``` + +编译:`new_clean.bat k8 1`(与 `.cursor/rules/project.mdc` 一致)。 diff --git a/2.sdk/Helios_SDK/services/microPython/ports/quectel/core/uwebsocket.py b/2.sdk/Helios_SDK/services/microPython/ports/quectel/core/uwebsocket.py index 258d5f1d..cbf3d9c1 100644 --- a/2.sdk/Helios_SDK/services/microPython/ports/quectel/core/uwebsocket.py +++ b/2.sdk/Helios_SDK/services/microPython/ports/quectel/core/uwebsocket.py @@ -11,6 +11,16 @@ from ucollections import namedtuple import websocket_mask LOGGER = log.getLogger(__name__) + +def _vc_ws_native_ping_tx_enabled(): + """1=客户端主动发 RFC6455 OP_PING;0=不发。入站 OP_PING 始终回 OP_PONG(与配置无关)。""" + try: + import vc_config + return int(vc_config.get_int("VC_WS_NATIVE_PING_ENABLE", 0)) != 0 + except Exception: + return False + + # Opcodes OP_CONT = 0x0 OP_TEXT = 0x1 @@ -215,10 +225,9 @@ class Websocket(object): # Ignore this frame, keep waiting for a data frame continue elif opcode == OP_PING: - # We need to send a pong frame - if self.debug: LOGGER.info("Sending PONG") + if self.debug: + LOGGER.info("Sending PONG") self.write_frame(OP_PONG, data) - # And then wait to receive continue elif opcode == OP_CONT: # This is a continuation of a previous frame @@ -242,7 +251,8 @@ class Websocket(object): self.write_frame(opcode, buf) def ping(self): - self.write_frame(OP_PING,b'ping') + if _vc_ws_native_ping_tx_enabled(): + self.write_frame(OP_PING, b'ping') def close(self, code=CLOSE_OK, reason=''): """Close the websocket.""" 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 1c8fa202..16eb7064 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 @@ -969,6 +969,7 @@ frozen_mpy_list = $(BUILD)/_boot.mpy \ $(BUILD)/version_util.mpy \ $(BUILD)/motor.mpy \ $(BUILD)/vc_common.mpy \ + $(BUILD)/vc_proto.mpy \ $(BUILD)/vc_utils.mpy \ $(BUILD)/vc_led.mpy \ $(BUILD)/vc_hw.mpy \ diff --git a/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/default_config.py b/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/default_config.py index d7723d39..5bacccb2 100644 --- a/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/default_config.py +++ b/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/default_config.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- -# k8 产品 — 设备侧默认配置(编译期 freeze 进固件) +# k8v3 产品 — 设备侧默认配置(RVC WebSocket V3;编译期 freeze 进固件) # # 加载优先级(vc_config.py): # 1) usr/default_config.py(现场覆盖) -# 2) 本文件(由 new_clean.bat 按 product_k8.ini 的 DEV_TYPE 拷贝到 core/default_config.py) +# 2) 本文件(由 new_clean.bat 按 product_k8v3.ini 的 DEV_TYPE 拷贝到 core/default_config.py) # # 约定:仅放「机型/产品差异」常量,勿放运行态变量。 @@ -106,6 +106,11 @@ BATTERY_OCV_MAP = { 3634: 20, 3577: 15, 3522: 10, 3468: 5, 3400: 0, } +# 录音麦克增益:record.gain_set(codec_gain, dsp_gain) +# codec_gain 0~4(编解码模拟增益 默认4);dsp_gain -36~12(数字增益,原工程常用 10) +RECORD_CODEC_GAIN = 2 +RECORD_DSP_GAIN = 6 + # ============================================================================= # 5. 可选外设与协议 # ============================================================================= @@ -114,6 +119,7 @@ GNSS_ENABLED = False # 无 GNSS 硬件时 False,避免 import quecgn UART_SUPPORTED = False BT_OTA_ENABLED = False UPLOAD_START_TONE_ENABLED = False # 开始上传语音时是否播成功提示音 +AI_ALARM_ENABLED = False # K8 吊坠无闹钟/日程硬件 # ============================================================================= # 6. 按键逻辑(AI_dev / btn_device) @@ -127,11 +133,28 @@ SOS_ENABLED = False # AI 长按 3s SOS、板载 SOS 入口 # 7. VC 会话 / WebSocket / 空闲与休眠 # ============================================================================= -# 语音 WS 路径段(无首尾斜杠);完整 URL 见 default_srv.AMP_SERVER + 文首拼接说明 -VC_WS_PATH = "vc/agent" -VC_WS_PING_INTERVAL_SEC = 55 # 文本 ping 间隔,过大影响保活,过小费电 +# 语音 WS 路径段(无首尾斜杠);主机见 srv_configs/beike_dev_v3/default_srv.py AMP_SERVER +VC_WS_PATH = "rvc-service/api/v3/ws" +VC_WS_NATIVE_PING_ENABLE = 0 # 0=不发标准 PING(入站 PING 仍回 PONG);1=客户端也发标准 PING +VC_WS_PING_INTERVAL_SEC = 0 # V3 无自定义文本心跳;仅 V2.5 使用(>0 时发 "ping") + +# VC 协议:v3(RVC);环境 SRV_TYPE=beike_dev_v3 +VC_PROTOCOL_VERSION = "v3" +VC_WS_AUTH_MODE = "dmp_dt" # dmp_dt | api_key +VC_AUDIO_PLAYED_ACK = 0 # V2.5 播完上报 audio.played;V3 联调建议 0 +VC_AGENT_ID = None # V3 start.agent_id;None 时用 VC_BOT_ID +VC_AUDIO_ENCRYPTION = 0 # V3 二进制帧加密(首版保持 0) +# VC_API_KEY = "" # VC_WS_AUTH_MODE=api_key 时使用 +VC_PROTO_TX_LOG = 1 # 1=打印出站摘要 + 原始 JSON(过长分 json_partN 输出) + +# V3 start.welcome_options:0=关闭服务端自动欢迎 TTS(由设备 synthesis/notify 播报) +VC_WELCOME_SERVER_ENABLED = 0 +# VC_WELCOME_SERVER_MESSAGE = "" # VC_WELCOME_SERVER_ENABLED=1 时可选文案 + +# 协议 failed / websocket_error 是否 synthesis 播报错误原文(联调=1,量产=0) +VC_ERROR_TTS_ENABLED = 1 -# 长对话:无有效业务下行累计达此秒数结束会话(代码最小 5) +# 长对话 L1:轮次间隙无 TTS 下行累计达此秒 Close(听/想/播/录、保活帧不计;最小 5) VC_IDLE_AUTO_CLOSE_SEC = 20 VC_IDLE_AUTO_CLOSE_SEC_PTT = 0 # 单对话预留,当前与 idle 统一处理 @@ -139,7 +162,8 @@ VC_IDLE_AUTO_CLOSE_SEC_PTT = 0 # 单对话预留,当前与 idle 统一处理 VC_IDLE_DEEP_SLEEP_SEC = 60 VC_IDLE_DEEP_SLEEP_ENABLE = 1 -VAD_SILENCE_DEFAULT = 300 # VAD 静音阈值(ms) +# 服务端断句静音(ms);句中短暂停顿建议 ≥1200;可被 storage vad_silence 覆盖 +VAD_SILENCE_DEFAULT = 300 # ============================================================================= # 8. 语音对话 / TTS / 智能体 @@ -148,8 +172,27 @@ VAD_SILENCE_DEFAULT = 300 # VAD 静音阈值(ms) POWER_ON_TONE_ENABLED = True POWER_ON_TONE_DEFAULT = "你好,我来了" -VC_TTS_VOICE_TYPE_DEFAULT = None # dict 或 None -VC_TTS_VOICE_KEY_DEFAULT = None # 如 "男" / "ç«¥" +# 默认音色(无 storage / usr 覆盖时生效) +# - voice:start → audio_config.output.voice,须与 RVC V3 服务端已配置的 speaker 一致 +# - 联调若报 resource ID mismatched with speaker,改 voice 或让服务端绑定该音色 +VC_TTS_VOICE_KEY_DEFAULT = "男" +VC_TTS_VOICE_TYPE_DEFAULT = { + "男": { + "bot_name": "柒先生", + "voice": "zh_male_wennuanahu_moon_bigtts", + "lang": "zh-cn", + }, + "女": { + "bot_name": "柒妹妹", + "voice": "zh_female_wanwanxiaohe_moon_bigtts", + "lang": "zh-cn", + }, + "ç«¥": { + "bot_name": "柒乐乐", + "voice": "ICL_zh_female_huoponvhai_tob", + "lang": "zh-cn", + }, +} DIALOG_BOT_OPTION = None # {"bot_id", "workflow_id", "use_workflow"} VC_BOT_ID = "7462617881076351002" @@ -173,13 +216,20 @@ POWEROFF_CMD_CLOSE_ONLY = False # 「关机」指令仅 Close,不真断电 KWS_PTT_HINT = "请您按住圆形的按钮开始讲话,讲完松开即可 " PTT_HINT = "请您按住圆形的按钮开始讲话,讲完松开即可 " +ASR_EMPTY_HINT_TTS = "我没有听清,请再说一遍" # asr.completed 无有效识别时 Notify 播报 + # ============================================================================= -# 10. 开录音 / PTT 时序(k8 量产:仅 start.mp3 开录提醒,无按键确认音) +# 10. 开录音 / PTT 时序(对齐「录音上传与播放」设计稿) # ============================================================================= +# 无按键确认音:不按 key_prompt;仅按键/PTT 开录时播 start.mp3(自动续聊不播) +# PTT idle 短按:notify 话术 PTT_HINT;长按开录:start.mp3 -RECORD_START_TONE_FILE = "U:/static/start.mp3" +RECORD_START_TONE_FILE = "U:/static/start.mp3" # 按键/PTT 开录提示音(非 server.started 自动开麦) RECORD_START_TONE_GAP_MS = 180 +PTT_MIN_RECORD_MS = 400 +PTT_RECORD_TOO_SHORT_TTS = "录音时间太短,请按住圆形按钮说话" # notify + PTT_RELEASE_END_SPEECH_DELAY_MS = 200 # 松手后发 audio.completed 前延迟 PTT_CANCEL_WAIT_MS = 2500 # 打断后等 canceled 最长时间 PTT_POST_MUTE_MS = 30 # 本地硬静音后 codec 收尾 diff --git a/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/default_srv.py b/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/default_srv.py index d282d900..e0e1e16e 100644 --- a/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/default_srv.py +++ b/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/default_srv.py @@ -5,7 +5,7 @@ # ============================================================================= # # 编译: -# product_k8.ini 中 SRV_TYPE=beike_dev +# product_k8v3.ini 中 SRV_TYPE=beike_dev_v3 # → new_clean.bat 拷贝本文件 → core/default_srv.py → 打进固件 # # 运行时优先级(srv_config.py): @@ -26,16 +26,11 @@ # 【语音对话 WebSocket — AI_VC.websocket_connect_VC】 # 主机:constants.AMP_SERVER(本文件 AMP_SERVER) # → ai_VC 赋给 self.server_ip(可被存储 server_ip / 云端下发覆盖) -# 路径:default_config.VC_WS_PATH(vc_config,如 k8 为 "vc/agent") -# 查询:is_auth、设备 token(get_dev_token) +# 路径:default_config.VC_WS_PATH(k8v3 为 "rvc-service/api/v3/ws") +# 查询:DMP dt(get_dev_token)或 api_key(见 VC_WS_AUTH_MODE) # -# 拼接结果: -# req_str = "{VC_WS_PATH}?is_auth={is_auth}" -# 有 token: ws://{AMP_SERVER}/{req_str}&dt={token} -# 无 token: ws://{AMP_SERVER}/{req_str} -# -# 示例(beike_dev + k8): -# ws://118.145.158.207:22080/vc/agent?is_auth=...&dt=... +# 示例(beike_dev_v3 + k8v3,RVC V3 API 文档): +# ws://115.190.94.208:8087/rvc-service/api/v3/ws?is_auth=...&dt=... # # 【IoT 设备云 WebSocket — protocol.py】 # 完整 URL 已在本文件写死为 IOT_WS_URL,仅追加 token: @@ -62,16 +57,16 @@ # ============================================================================= # ---- 环境标识(仅注释/运维用;constants.SERVER_ENV 会读取)---- -SERVER_ENV = "beike_dev" +SERVER_ENV = "beike_dev_v3" # ---- 三套「根地址」(constants.py 启动时加载,改环境必改)---- # DMP 业务 HTTP 根路径(无尾斜杠)。用于 DMP_BASE + "/dev/..." 、"/ota/result?..." DMP_BASE = "http://dmp-dev.raisound.com/dmp-api" -# 语音对话 AMP 主机 host:port(无 ws://)。用于 ws://{AMP_SERVER}/{VC_WS_PATH}... -# 须与 default_config.VC_WS_PATH 联调;勿漏改 VC_WS_PATH(在 configs/k8/default_config.py) -AMP_SERVER = "118.145.158.207:22080" +# RVC V3 语音对话主机 host:port(无 ws://)。完整 WS: +# ws://{AMP_SERVER}/{VC_WS_PATH} 例见 configs/k8v3/default_config.py +AMP_SERVER = "115.190.94.208:8087" # IoT 长连接完整 WebSocket URL(含 ws://)。protocol 仅追加 ?bz-dt=token IOT_WS_URL = "ws://dmp-dev.raisound.com/iot-api/ws-hub/dev/v1" diff --git a/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/main_init.py b/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/main_init.py index e43dcaa6..57122db2 100644 --- a/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/main_init.py +++ b/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/main_init.py @@ -85,8 +85,19 @@ def main_init(): res = list(res) EventMesh.debug_log('总usr空间:{} MB,剩余空间:{} MB'.format((((res[0] * res[2]) / 1024 / 1024)),((res[0] * res[3]) / 1024 / 1024))) EventMesh.debug_log("init1:{}".format(utime.localtime())) - - + try: + try: + import vc_config # noqa: F401 — 尽早加载产品 GPIO/时长 + except Exception: + pass + try: + from usr.motor import motor_boot_vibrate_kick_early + except Exception: + from motor import motor_boot_vibrate_kick_early + motor_boot_vibrate_kick_early() + except Exception as e: + usys.print_exception(e) + EventMesh.debug_log("init2:{}".format(utime.localtime())) EventMesh.debug_log("init3:{}".format(utime.localtime())) diff --git a/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/motor.py b/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/motor.py index c155c5cf..275b53fc 100644 --- a/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/motor.py +++ b/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/motor.py @@ -20,6 +20,8 @@ except Exception: motor_pin = None _motor_active_low = 0 +_boot_vibrate_kicked = 0 +_boot_vibrate_timer = None def _cfg_motor_gpio_name(): @@ -96,6 +98,34 @@ def close_motor(): motor_pin.write(1 if _motor_active_low else 0) +def motor_boot_vibrate_kick_early(): + """开机尽早震动(main_init 早期调用);与 motorEventManager 注册幂等。""" + global _boot_vibrate_kicked, _boot_vibrate_timer + ms = 0 if _boot_vibrate_kicked else _cfg_boot_vibrate_ms() + if ms <= 0: + return + try: + _ensure_motor_pin() + open_motor() + _boot_vibrate_kicked = 1 + + def _boot_vibe_stop(*args): + try: + close_motor() + except Exception: + pass + + try: + _boot_vibrate_timer.stop() + except Exception: + pass + _boot_vibrate_timer = osTimer() + _boot_vibrate_timer.start(ms, 0, _boot_vibe_stop) + print("[motor] boot vibrate early {}ms".format(ms)) + except Exception as e: + print("[motor] boot vibrate early fail:{}".format(e)) + + class motorEventManager(Abstract): def __init__(self): self.motor_timer = osTimer() @@ -133,12 +163,7 @@ class motorEventManager(Abstract): EventMesh.publish("publish_message_event_motorInfo",{"func":self.motor_fun_cb,"data":0}) def post_processor_after_instantiation(self): - ms = _cfg_boot_vibrate_ms() - if ms <= 0: - return - ticks = _motor_vibrate_ticks(ms) - self.motor_count_tmp = 0 - self.set_motor_time(None, [1, 1, ticks, 0, 0]) + motor_boot_vibrate_kick_early() # msg 0马达使能 1 马达振动时间100ms单位 2 震动次数 3 间隔时间振动 4 是否需要长振 5 长振时间 6间隔时间长振 7长振次数1一次 要为200ms的倍数 def set_motor_time(self,event,msg): diff --git a/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/products/configs/k8/default_config.py b/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/products/configs/k8/default_config.py index bb24b9af..06facaea 100644 --- a/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/products/configs/k8/default_config.py +++ b/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/products/configs/k8/default_config.py @@ -132,6 +132,18 @@ SOS_ENABLED = False # AI 长按 3s SOS、板载 SOS 入口 VC_WS_PATH = "vc/agent" VC_WS_PING_INTERVAL_SEC = 55 # 文本 ping 间隔,过大影响保活,过小费电 +# VC 协议:v2.5(金三,默认)| v3(RVC);切 v3 时改 VC_WS_PATH 与 AMP_SERVER 联调 +VC_PROTOCOL_VERSION = "v2.5" +VC_WS_AUTH_MODE = "dmp_dt" # dmp_dt | api_key +VC_AUDIO_PLAYED_ACK = 1 # V2.5 播完上报 audio.played;V3 联调建议 0 +VC_AGENT_ID = None # V3 start.agent_id;None 时用 VC_BOT_ID +VC_AUDIO_ENCRYPTION = 0 # V3 二进制帧加密(首版保持 0) +# VC_API_KEY = "" # VC_WS_AUTH_MODE=api_key 时使用 +VC_PROTO_TX_LOG = 0 # 1=打印 start/synthesis 等出站 TTS 参数摘要 + +# 协议 failed 是否 synthesis 播报错误原文(联调=1,量产=0) +VC_ERROR_TTS_ENABLED = 0 + # 长对话:无有效业务下行累计达此秒数结束会话(代码最小 5) VC_IDLE_AUTO_CLOSE_SEC = 20 VC_IDLE_AUTO_CLOSE_SEC_PTT = 0 # 单对话预留,当前与 idle 统一处理 @@ -141,7 +153,7 @@ VC_IDLE_DEEP_SLEEP_SEC = 60 VC_IDLE_DEEP_SLEEP_ENABLE = 1 # 服务端断句静音(ms);句中短暂停顿建议 ≥1200;可被 storage vad_silence 覆盖 -VAD_SILENCE_DEFAULT = 1500 +VAD_SILENCE_DEFAULT = 300 # ============================================================================= # 8. 语音对话 / TTS / 智能体 @@ -150,8 +162,26 @@ VAD_SILENCE_DEFAULT = 1500 POWER_ON_TONE_ENABLED = True POWER_ON_TONE_DEFAULT = "你好,我来了" -VC_TTS_VOICE_TYPE_DEFAULT = None # dict 或 None -VC_TTS_VOICE_KEY_DEFAULT = None # 如 "男" / "ç«¥" +# 默认音色(无 storage / usr 覆盖时生效) +# - voice:start → outbound_attributes.cfg.voice(金三 V2.5) +VC_TTS_VOICE_KEY_DEFAULT = "男" +VC_TTS_VOICE_TYPE_DEFAULT = { + "男": { + "bot_name": "柒先生", + "voice": "zh_male_wennuanahu_moon_bigtts", + "lang": "zh-cn", + }, + "女": { + "bot_name": "柒妹妹", + "voice": "zh_female_wanwanxiaohe_moon_bigtts", + "lang": "zh-cn", + }, + "ç«¥": { + "bot_name": "柒乐乐", + "voice": "ICL_zh_female_huoponvhai_tob", + "lang": "zh-cn", + }, +} DIALOG_BOT_OPTION = None # {"bot_id", "workflow_id", "use_workflow"} VC_BOT_ID = "7462617881076351002" diff --git a/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/products/configs/k8v3/default_config.py b/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/products/configs/k8v3/default_config.py new file mode 100644 index 00000000..5bacccb2 --- /dev/null +++ b/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/products/configs/k8v3/default_config.py @@ -0,0 +1,244 @@ +# -*- coding: utf-8 -*- +# k8v3 产品 — 设备侧默认配置(RVC WebSocket V3;编译期 freeze 进固件) +# +# 加载优先级(vc_config.py): +# 1) usr/default_config.py(现场覆盖) +# 2) 本文件(由 new_clean.bat 按 product_k8v3.ini 的 DEV_TYPE 拷贝到 core/default_config.py) +# +# 约定:仅放「机型/产品差异」常量,勿放运行态变量。 + +# ============================================================================= +# 1. 产品与区域 +# ============================================================================= + +# 硬件平台类型(与 products/configs// 目录名一致) +DEV_TYPE = "7PIN" + +# 文案/逻辑用语言(如 zh / en) +LANG = "zh" + +# 设备鉴权(device_res_author:HMAC 签名与 AES 加解密)产品名“小量博士” 、“浪漫挂坠” +PRODUCT_PID = "cqxJZi5CNgzjS3mX21Zw" +PRODUCT_KEY = "uKLrChwrxA1LklgBc3VwawYjUkbT3BgX" # 32 字节,运行时转为 bytes + +# ============================================================================= +# 2. GPIO 引脚 +# ============================================================================= + +# --- 2.1 三色 LED(Pin 属性名字符串,vc_config 转为 Pin.GPIOxx)--- +LED_GPIO = ("GPIO36", "GPIO44", "GPIO43") # (R, G, B) + +# 点亮电平:False=高电平亮;True=低电平亮;仅某路反相用 LED_ACTIVE_LOW_RGB +LED_ACTIVE_LOW = False +LED_ACTIVE_LOW_RGB = None # 例 (True, False, False) 仅红灯反相 + +# --- 2.2 按键 ExtInt --- +EXTINT_AI_GPIO = "GPIO22" # AI 键 +PWK_KEY_GPIO = "GPIO26" # 电源键(短按/双击切模式等) + +# btn_device_ec800m:扩展按键(本机型未用) +BTN_WECHAT_GPIO = None +BTN_SOS_GPIO = None + +# --- 2.3 马达(motor.py / motorEventManager)--- +MOTOR_PIN_GPIO = "GPIO20" # None 表示无马达 +MOTOR_TICK_MS = 100 +MOTOR_ACTIVE_LOW = False +MOTOR_BOOT_VIBRATE_MS = 200 # 开机震动时长(ms) + +# --- 2.4 蓝牙电源 / 音频 IIS(本机型关闭)--- +BT_POWER_MOTOR_ENABLED = False +PIN_BT_ON_GPIO = None +PIN_MOT_GPIO = None +AUDIO_QDAI_CFG = None # 非 None 时调用 audio.set_qdai_cfg(...) + +# --- 2.5 蓝牙↔4G 唤醒(本机型关闭)--- +BT_WAKEUP_4G_ENABLED = False +EXTINT_WAKEUP_CTA1_GPIO = None +PIN_WAKEUP_BT_GPIO = None + +# ============================================================================= +# 3. LED 灯效策略(AI_led / ai_VC,对齐交互规范) +# ============================================================================= + +# 正常待机 idle:off=灭灯(REQ-BAT-01);green_blink=旧工程绿灯慢闪 +LED_IDLE_MODE = "off" + +# green_blink 模式下的亮/灭时长(ms);LED_IDLE_MODE=off 时不生效 +LED_IDLE_GREEN_BLINK_MS = 1000 +LED_IDLE_GREEN_BLINK_OFF_MS = 2000 + +# LLM 思考阶段:flicker_blue_green | open_red_green +THINKING_LED_MODE = "flicker_blue_green" + +# 收到 vad.active 是否强制回蓝灯(长对话) +VAD_ACTIVE_BLUE_LED = True + +# 收音 listening:长对话蓝慢闪(1000ms)、单对话蓝快闪(250ms) 由 VC_mode 在 AI_led 内切换,无需本文件单独配置 + +# ============================================================================= +# 4. 电池与硬件状态(AI_hw_status / AI_dev) +# ============================================================================= + +HW_STATUS_PERIOD_MS = 10000 +# 电池电压:全机约每 10s 采样 1 次 getVbatt,10 点滑动平均(见 vc_hw._BatteryCfg) +VBATT_AVG_LEN = 10 +VBATT_PUSH_MIN_MS = 9000 # 同周期内 AI_hw / protocol 去重,避免重复读电压 +SIGNAL_WEAK_CSQ = 10 # 0≤CSQ<此值 → signal=weak(REQ-ERR-02) + +# SoC 门限:仅低电提示/LED 空电闪(REQ-BAT-02/03);关机见 VBATT_EMPTY_MV +BATTERY_LOW_SOC_PERCENT = 25 # <25% 低电语音 +BATTERY_EMPTY_SOC_PERCENT = 5 # <5% LED 空电态;不单独触发关机 +BATTERY_LOW_WARN_INTERVAL_MS = 300000 # 低电语音间隔 5min +BATTERY_EMPTY_FLASH_COUNT = 3 +BATTERY_EMPTY_FLASH_PERIOD_MS = 500 # 空电红快闪约 2 次/秒 + +# 电压门限(mV):充满 / 低电 / 空电(与 get_battery、simple 模式共用) +USE_SIMPLE_VBATT_THRESH = False # True 时忽略 BATTERY_OCV_MAP,仅用三档电压 +VBATT_CHARGE_FULL_MV = 4320 # 502631 ZCV 100% OCV≈4.322V;CV 4.35V +VBATT_LOW_MV = 3630 # 约对应 ZCV 20%(4G 带载下不宜再抬高) +VBATT_EMPTY_MV = 3400 # 未充电:滑动平均电压 < 此值 → 低电关机(唯一关机条件) + +# 502631-510 4.35V ZCV 放电 OCV 表(mV→SoC);0% 锚定 3400 而非实验室 3262 +BATTERY_OCV_MAP = { + 4322: 100, 4239: 95, 4177: 90, 4117: 85, 4059: 80, 4006: 75, 3942: 70, 3898: 65, + 3853: 60, 3814: 55, 3781: 50, 3755: 45, 3738: 40, 3724: 35, 3706: 30, 3678: 25, + 3634: 20, 3577: 15, 3522: 10, 3468: 5, 3400: 0, +} + +# 录音麦克增益:record.gain_set(codec_gain, dsp_gain) +# codec_gain 0~4(编解码模拟增益 默认4);dsp_gain -36~12(数字增益,原工程常用 10) +RECORD_CODEC_GAIN = 2 +RECORD_DSP_GAIN = 6 + +# ============================================================================= +# 5. 可选外设与协议 +# ============================================================================= + +GNSS_ENABLED = False # 无 GNSS 硬件时 False,避免 import quecgnss +UART_SUPPORTED = False +BT_OTA_ENABLED = False +UPLOAD_START_TONE_ENABLED = False # 开始上传语音时是否播成功提示音 +AI_ALARM_ENABLED = False # K8 吊坠无闹钟/日程硬件 + +# ============================================================================= +# 6. 按键逻辑(AI_dev / btn_device) +# ============================================================================= + +KEY_RELEASE_DEBOUNCE_COUNT = 5 +AI_LONG_PRESS_POWER_OFF_ENABLED = False # AI 键长按 8s 关机 +SOS_ENABLED = False # AI 长按 3s SOS、板载 SOS 入口 + +# ============================================================================= +# 7. VC 会话 / WebSocket / 空闲与休眠 +# ============================================================================= + +# 语音 WS 路径段(无首尾斜杠);主机见 srv_configs/beike_dev_v3/default_srv.py AMP_SERVER +VC_WS_PATH = "rvc-service/api/v3/ws" +VC_WS_NATIVE_PING_ENABLE = 0 # 0=不发标准 PING(入站 PING 仍回 PONG);1=客户端也发标准 PING +VC_WS_PING_INTERVAL_SEC = 0 # V3 无自定义文本心跳;仅 V2.5 使用(>0 时发 "ping") + +# VC 协议:v3(RVC);环境 SRV_TYPE=beike_dev_v3 +VC_PROTOCOL_VERSION = "v3" +VC_WS_AUTH_MODE = "dmp_dt" # dmp_dt | api_key +VC_AUDIO_PLAYED_ACK = 0 # V2.5 播完上报 audio.played;V3 联调建议 0 +VC_AGENT_ID = None # V3 start.agent_id;None 时用 VC_BOT_ID +VC_AUDIO_ENCRYPTION = 0 # V3 二进制帧加密(首版保持 0) +# VC_API_KEY = "" # VC_WS_AUTH_MODE=api_key 时使用 +VC_PROTO_TX_LOG = 1 # 1=打印出站摘要 + 原始 JSON(过长分 json_partN 输出) + +# V3 start.welcome_options:0=关闭服务端自动欢迎 TTS(由设备 synthesis/notify 播报) +VC_WELCOME_SERVER_ENABLED = 0 +# VC_WELCOME_SERVER_MESSAGE = "" # VC_WELCOME_SERVER_ENABLED=1 时可选文案 + +# 协议 failed / websocket_error 是否 synthesis 播报错误原文(联调=1,量产=0) +VC_ERROR_TTS_ENABLED = 1 + +# 长对话 L1:轮次间隙无 TTS 下行累计达此秒 Close(听/想/播/录、保活帧不计;最小 5) +VC_IDLE_AUTO_CLOSE_SEC = 20 +VC_IDLE_AUTO_CLOSE_SEC_PTT = 0 # 单对话预留,当前与 idle 统一处理 + +# 业务 idle 达此秒数触发深睡断开(最小 10;ENABLE=0 关闭) +VC_IDLE_DEEP_SLEEP_SEC = 60 +VC_IDLE_DEEP_SLEEP_ENABLE = 1 + +# 服务端断句静音(ms);句中短暂停顿建议 ≥1200;可被 storage vad_silence 覆盖 +VAD_SILENCE_DEFAULT = 300 + +# ============================================================================= +# 8. 语音对话 / TTS / 智能体 +# ============================================================================= + +POWER_ON_TONE_ENABLED = True +POWER_ON_TONE_DEFAULT = "你好,我来了" + +# 默认音色(无 storage / usr 覆盖时生效) +# - voice:start → audio_config.output.voice,须与 RVC V3 服务端已配置的 speaker 一致 +# - 联调若报 resource ID mismatched with speaker,改 voice 或让服务端绑定该音色 +VC_TTS_VOICE_KEY_DEFAULT = "男" +VC_TTS_VOICE_TYPE_DEFAULT = { + "男": { + "bot_name": "柒先生", + "voice": "zh_male_wennuanahu_moon_bigtts", + "lang": "zh-cn", + }, + "女": { + "bot_name": "柒妹妹", + "voice": "zh_female_wanwanxiaohe_moon_bigtts", + "lang": "zh-cn", + }, + "ç«¥": { + "bot_name": "柒乐乐", + "voice": "ICL_zh_female_huoponvhai_tob", + "lang": "zh-cn", + }, +} + +DIALOG_BOT_OPTION = None # {"bot_id", "workflow_id", "use_workflow"} +VC_BOT_ID = "7462617881076351002" +VC_WORKFLOW_ID = "7462621757327196187" +DIALOG_PROVIDER_DEFAULT = None +KEYWORDS_FROM_VOICE_TYPE = False + +BT_VOICE_FEATURES_ENABLED = False + +# ============================================================================= +# 9. 本地指令与固定话术 +# ============================================================================= + +# protocol.ai_cmd_handle:云端 NLP 扩展(会议纪要 / 实时翻译 / 历史留言) +NLP_EXTENDED_CMDS = False + +CMD_TOGGLE_MODE_TO_PTT_TTS = "已切换到单对话模式" +CMD_TOGGLE_MODE_TO_LONG_TTS = "已切换到长对话模式" +POWEROFF_CMD_CLOSE_ONLY = False # 「关机」指令仅 Close,不真断电 + +KWS_PTT_HINT = "请您按住圆形的按钮开始讲话,讲完松开即可 " +PTT_HINT = "请您按住圆形的按钮开始讲话,讲完松开即可 " + +ASR_EMPTY_HINT_TTS = "我没有听清,请再说一遍" # asr.completed 无有效识别时 Notify 播报 + +# ============================================================================= +# 10. 开录音 / PTT 时序(对齐「录音上传与播放」设计稿) +# ============================================================================= +# 无按键确认音:不按 key_prompt;仅按键/PTT 开录时播 start.mp3(自动续聊不播) +# PTT idle 短按:notify 话术 PTT_HINT;长按开录:start.mp3 + +RECORD_START_TONE_FILE = "U:/static/start.mp3" # 按键/PTT 开录提示音(非 server.started 自动开麦) +RECORD_START_TONE_GAP_MS = 180 + +PTT_MIN_RECORD_MS = 400 +PTT_RECORD_TOO_SHORT_TTS = "录音时间太短,请按住圆形按钮说话" # notify + +PTT_RELEASE_END_SPEECH_DELAY_MS = 200 # 松手后发 audio.completed 前延迟 +PTT_CANCEL_WAIT_MS = 2500 # 打断后等 canceled 最长时间 +PTT_POST_MUTE_MS = 30 # 本地硬静音后 codec 收尾 + +# ============================================================================= +# 11. 调试开关(量产可全 False) +# ============================================================================= + +LED_DEBUG = True +LED_STATE_DEBUG = True +KEY_LED_DEBUG = True +HW_STATUS_DEBUG = True \ No newline at end of file diff --git a/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/products/product_k8v3.ini b/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/products/product_k8v3.ini new file mode 100644 index 00000000..16124930 --- /dev/null +++ b/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/products/product_k8v3.ini @@ -0,0 +1,3 @@ +DEV_TYPE=k8v3 +SRV_TYPE=beike_dev_v3 +VERSION=V1.3 diff --git a/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/products/srv_configs/beike_dev_v3/default_srv.py b/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/products/srv_configs/beike_dev_v3/default_srv.py new file mode 100644 index 00000000..e0e1e16e --- /dev/null +++ b/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/products/srv_configs/beike_dev_v3/default_srv.py @@ -0,0 +1,93 @@ +# 设备侧 Server 配置(编译期由 product_*.ini 的 SRV_TYPE 拷贝为 core/default_srv.py å¹¶ freeze)。 +# +# ============================================================================= +# 加载与修改方式(改环境时按此检查,避免漏改) +# ============================================================================= +# +# 编译: +# product_k8v3.ini 中 SRV_TYPE=beike_dev_v3 +# → new_clean.bat 拷贝本文件 → core/default_srv.py → 打进固件 +# +# 运行时优先级(srv_config.py): +# 1) usr/default_srv.py(现场覆盖,无需重编) +# 2) freeze 内置 core/default_srv.py +# +# 启动时注入 constants(constants.py 仅读取下列 3 项到模块常量): +# DMP_BASE, AMP_SERVER, IOT_WS_URL → constants.DMP_BASE / AMP_SERVER / IOT_WS_URL +# +# 与 default_config.py 的分工(勿混在同一文件改): +# - 本文件:环境 / 域名 / 主机(随 SRV_TYPE 变,如 beike_dev ↔ beike) +# - default_config:产品能力(随 DEV_TYPE/k8 变),含 VC_WS_PATH、VC_BOT_ID 等 +# +# ============================================================================= +# URL 拼接总览(改 VC 联调时两项都要对) +# ============================================================================= +# +# 【语音对话 WebSocket — AI_VC.websocket_connect_VC】 +# 主机:constants.AMP_SERVER(本文件 AMP_SERVER) +# → ai_VC 赋给 self.server_ip(可被存储 server_ip / 云端下发覆盖) +# 路径:default_config.VC_WS_PATH(k8v3 为 "rvc-service/api/v3/ws") +# 查询:DMP dt(get_dev_token)或 api_key(见 VC_WS_AUTH_MODE) +# +# 示例(beike_dev_v3 + k8v3,RVC V3 API 文档): +# ws://115.190.94.208:8087/rvc-service/api/v3/ws?is_auth=...&dt=... +# +# 【IoT 设备云 WebSocket — protocol.py】 +# 完整 URL 已在本文件写死为 IOT_WS_URL,仅追加 token: +# {IOT_WS_URL}?bz-dt={token} +# 示例:ws://dmp-dev.raisound.com/iot-api/ws-hub/dev/v1?bz-dt=... +# +# 【DMP HTTP — ai_VC 内认证/校时/ICCID/OTA 结果等】 +# 前缀:constants.DMP_BASE(本文件 DMP_BASE,无尾部斜杠) +# 路径:ai_VC 内目前为字面量 "/dev/..."(与下方 DMP_*_PATH 取值应对齐) +# 示例:{DMP_BASE}/dev/time 、{DMP_BASE}/dev/auth/active +# +# 【OTA 检查任务 — vc_ota.min_sys_ota / bt_ota】 +# constants.OTA_TASK_URL_FMT.format(imei, sourceVersion) +# OTA 结果上报:{DMP_BASE} + OTA_RESULT_PATH_FMT(vc_ota.ota_result) +# +# 【AMP 健康检查 — ai_VC 错误上报】 +# 使用 self.server_ip(同 AMP_SERVER)+ 硬编码路径: +# http://{server_ip}/health?task_id={...} +# 与 AMP_HEALTH_URL_FMT 形式一致(第一参数为 host:port,无 http 前缀) +# +# 注意:下方 DMP_*_PATH / OTA_*_FMT / AMP_HEALTH_URL_FMT 为**集中文档与目标值**; +# constants 启动时尚未自动读取这些路径常量;修改后若 ai_VC 仍写死字符串,需同步改代码或接 srv_config.get_attr。 +# +# ============================================================================= + +# ---- 环境标识(仅注释/运维用;constants.SERVER_ENV 会读取)---- +SERVER_ENV = "beike_dev_v3" + +# ---- 三套「根地址」(constants.py 启动时加载,改环境必改)---- + +# DMP 业务 HTTP 根路径(无尾斜杠)。用于 DMP_BASE + "/dev/..." 、"/ota/result?..." +DMP_BASE = "http://dmp-dev.raisound.com/dmp-api" + +# RVC V3 语音对话主机 host:port(无 ws://)。完整 WS: +# ws://{AMP_SERVER}/{VC_WS_PATH} 例见 configs/k8v3/default_config.py +AMP_SERVER = "115.190.94.208:8087" + +# IoT 长连接完整 WebSocket URL(含 ws://)。protocol 仅追加 ?bz-dt=token +IOT_WS_URL = "ws://dmp-dev.raisound.com/iot-api/ws-hub/dev/v1" + +# ---- DMP 子路径(相对 DMP_BASE;与 ai_VC 内硬编码路径保持一致)---- + +DMP_TIME_PATH = "/dev/time" # 校时 GET → dev_NTP +DMP_ACTIVE_PATH = "/dev/auth/active" # 激活 POST +DMP_SIGN_IN_PATH = "/dev/auth/sign_in" # 登录 +DMP_TOKEN_PATH = "/dev/auth/token" # 取 token → get_dev_token 链路 +DMP_ICCID_PATH = "/dev/iccid" # ICCID 上报(带 query) +DMP_DEV_CONFIG_PATH_FMT = "/dev/config/{}" # 设备配置,format(deviceId) + +# ---- OTA ---- + +# 完整 URL 模板;占位符 {} 依次为 deviceId(IMEI)、sourceVersion +OTA_TASK_URL_FMT = "http://dmp-dev.raisound.com/dmp-api/ota/task?deviceId={}&sourceVersion={}" + +# 相对 DMP_BASE 的路径模板(上报 OTA 结果) +OTA_RESULT_PATH_FMT = "/ota/result?deviceId={}¤tVersion={}" + +# ---- AMP 健康检查(host 用 AMP_SERVER / server_ip,非 DMP_BASE)---- + +AMP_HEALTH_URL_FMT = "http://{}/health?task_id={}" # .format(server_ip, task_id) diff --git a/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_common.py b/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_common.py index 728d240d..c04daf4c 100644 --- a/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_common.py +++ b/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_common.py @@ -89,15 +89,19 @@ VC_HEARTBEAT_LOG = 0 # 若误关会导致问候结束后首轮用户说话无 vad/asr。 VC_PROTO_GUARDS = 0 # 1=打印 VC WebSocket 每条收发的完整文本(流量大、易刷屏);0 仅一行 event 摘要(llm.delta 省略) -VC_WS_IO_LOG = 0 -# 长对话 20s 无 TTS idle:仅 tts.* / started / TTS 音频字节重置 close_timerout;asr/vad/llm 不重置。 -# 帧级 WebSocket PING/PONG 在 uwebsocket.recv() 内已处理,不会进入 AI_client_VC_recv。 -# 金三 V2.5 文档示例:WebSocket 文本帧 "ping" 保活;对端可能回 pong / heartbeat 类事件。 +VC_WS_IO_LOG = 1 +# 1=打印出站 JSON 摘要(start 音色/TTS 参数、synthesis 文本等);联调 TTS 建议开启 +VC_PROTO_TX_LOG = 1 +# 长对话 L1(VC_IDLE_AUTO_CLOSE_SEC):仅 tts.* / started / TTS 音频下行重置 close_timerout。 +# 保活不计入 L1:V2.5 文本 ping/pong、JSON heartbeat;RFC6455 OP_PING/OP_PONG(uwebsocket 内处理)。 +# V2.5:默认不发标准 PING,发文本 "ping"(VC_WS_PING_INTERVAL_SEC>0);须响应入站标准 PING→PONG。 +# V3:无自定义心跳;默认不发标准 PING;须响应入站标准 PING→PONG。 +# VC_WS_NATIVE_PING_ENABLE=1 时客户端才主动发标准 PING(两协议均默认 0)。 _VC_KEEPALIVE_RX_EVENTS = frozenset(("pong", "ping", "heartbeat", "keepalive")) def _vc_out_json_event_meta(s): - """解析 WS 出站 JSON:event 可能在 header 或顶层(Start/audio.create vs synthesis)。""" + """解析 WS 出站 JSON:event 可能在 header、顶层 event 或 V3 type。""" try: jd = ujson.loads(s) h = jd.get("header") @@ -105,8 +109,11 @@ def _vc_out_json_event_meta(s): ev = h.get("event") or "" if ev: return ev, h.get("trace_id") or "" - ev = jd.get("event") or "" - return ev, "" + ev = jd.get("event") or jd.get("type") or "" + tid = jd.get("trace_id") or "" + if not tid and isinstance(h, dict): + tid = h.get("trace_id") or "" + return ev, tid except Exception: return "", "" diff --git a/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_proto.py b/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_proto.py new file mode 100644 index 00000000..cf9e6662 --- /dev/null +++ b/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_proto.py @@ -0,0 +1,536 @@ +# -*- coding: utf-8 -*- +"""VC WebSocket 协议适配:V2.5(金三)与 V3(RVC)可配置切换。""" +import ujson + +PROTO_V25 = "v2.5" +PROTO_V3 = "v3" + +# V3 下行 type → 内部仍使用的 V2.5 事件名(FSM/recv 无需改) +_SERVER_TYPE_TO_EVENT = { + "error": "failed", + "tts.done": "tts.completed", + "llm.started": "llm.created", +} + + +def _cfg(name, default=None): + try: + import vc_config + return vc_config.get_attr(name, default) + except Exception: + return default + + +def get_protocol_version(): + v = _cfg("VC_PROTOCOL_VERSION", PROTO_V25) + if v is None: + return PROTO_V25 + try: + v = str(v).lower().strip() + except Exception: + return PROTO_V25 + if v in ("v3", "3", "rvc", "rvc_v3"): + return PROTO_V3 + return PROTO_V25 + + +def is_v3(): + return get_protocol_version() == PROTO_V3 + + +def vc_ws_custom_ping_enabled(): + """V2.5:允许设备发 WebSocket 文本帧 \"ping\";V3:无自定义心跳。""" + if is_v3(): + return False + try: + return int(_cfg("VC_WS_PING_INTERVAL_SEC", 55)) > 0 + except Exception: + return True + + +def vc_proto_audio_played_enabled(): + default = 0 if is_v3() else 1 + try: + return bool(_cfg("VC_AUDIO_PLAYED_ACK", default)) + except Exception: + return bool(default) + + +def vc_proto_build_ws_url(amp_server, path, is_auth, token=None, device_id=None): + """拼 VC WebSocket URL(无 ws:// 前缀)。""" + path = path or "vc/chat" + auth_mode = _cfg("VC_WS_AUTH_MODE", "dmp_dt") + try: + auth_mode = str(auth_mode or "dmp_dt").lower().strip() + except Exception: + auth_mode = "dmp_dt" + + if is_v3(): + if auth_mode == "api_key": + key = token or _cfg("VC_API_KEY", "") or "" + did = device_id or "" + if key and did: + return "ws://{}/{}?api_key={}&device_id={}".format( + amp_server, path, key, did) + if key: + return "ws://{}/{}?api_key={}".format(amp_server, path, key) + return "ws://{}/{}".format(amp_server, path) + # DMP:V3 文档 dt/ut;保留 is_auth 便于联调旧网关 + if token: + return "ws://{}/{}?is_auth={}&dt={}".format( + amp_server, path, is_auth, token) + return "ws://{}/{}?is_auth={}".format(amp_server, path, is_auth) + + # V2.5(现状) + req = "{}?is_auth={}".format(path, is_auth) + if token: + return "ws://{}/{}&dt={}".format(amp_server, req, token) + return "ws://{}/{}".format(amp_server, req) + + +def _extract_client_event_payload(obj): + """从出站 JSON 对象解析 event 与 payload(兼容 header 与顶层 event)。""" + if not isinstance(obj, dict): + return None, {} + h = obj.get("header") + if isinstance(h, dict): + ev = h.get("event") or "" + if ev: + return ev, obj.get("payload") or {} + ev = obj.get("event") or obj.get("type") or "" + if ev: + pl = obj.get("payload") + if pl is None: + pl = obj.get("data") + if pl is None: + pl = {} + return ev, pl + return None, {} + + +# V3 StartData.audio_config 文档字段(不含 V2.5 专有项) +_V3_AUDIO_CFG_INPUT_KEYS = ("format", "sample_rate", "bit_rate", "channel") +_V3_AUDIO_CFG_OUTPUT_KEYS = ("format", "sample_rate", "bit_rate", "channel", "speech_rate") +# V2.5 outbound 专有:V3 不识别,不写入 start.data(仅 voice 放 metadata 供服务端选用) +_V3_DROP_TTS_EXTRA_KEYS = frozenset(("emotion", "volume", "chunk_interval_ms", "codec")) + + +def _pick_v3_audio_cfg(src, is_output=False): + """仅保留 V3 支持的 audio_config 字段;其余进 metadata 扩展。""" + allowed = _V3_AUDIO_CFG_OUTPUT_KEYS if is_output else _V3_AUDIO_CFG_INPUT_KEYS + cfg = {} + extras = {} + if not isinstance(src, dict): + return cfg, extras + for k, v in src.items(): + if k in allowed: + cfg[k] = v + else: + extras[k] = v + return cfg, extras + + +def _build_v3_welcome_options(dlg_cfg): + """V3 start.welcome_options:与 V2.5 dialog_attributes.cfg.greeting 及配置对齐。 + + 设备侧 notify/local/boot_prompt 等走 synthesis,默认关闭服务端自动欢迎 TTS。 + """ + greeting = {} + if isinstance(dlg_cfg, dict): + g = dlg_cfg.get("greeting") + if isinstance(g, dict): + greeting = g + enabled = False + message = "" + try: + if greeting.get("enabled"): + enabled = True + message = greeting.get("content") or "" + except Exception: + pass + cfg_val = _cfg("VC_WELCOME_SERVER_ENABLED", None) + if cfg_val is not None: + try: + enabled = bool(int(cfg_val)) + except Exception: + enabled = bool(cfg_val) + if enabled: + message = _cfg("VC_WELCOME_SERVER_MESSAGE", message) or message + en = bool(enabled) + msg = message if en else "" + # 同时带 enabled/message(与 audio_encryption.enabled 命名一致)及 started 回包字段名 + return { + "enabled": en, + "message": msg, + "welcome_enabled": en, + "welcome_message": msg, + } + + +def _convert_start_payload_v25_to_v3(payload): + """V2.5 start payload → V3 start.data。""" + if not isinstance(payload, dict): + payload = {} + inb = payload.get("inbound_attributes") or {} + outb = payload.get("outbound_attributes") or {} + dlg = payload.get("dialog_attributes") or {} + dlg_cfg = dlg.get("cfg") if isinstance(dlg.get("cfg"), dict) else {} + bot = dlg_cfg.get("bot_option") if isinstance(dlg_cfg.get("bot_option"), dict) else {} + + disable_vad = inb.get("disable_vad", True) + session_mode = "push_to_talk" if disable_vad else "realtime" + + in_cfg, in_extra = _pick_v3_audio_cfg(inb.get("cfg") or {}, False) + out_cfg, out_extra = _pick_v3_audio_cfg(outb.get("cfg") or {}, True) + + data = { + "device_id": payload.get("device_id") or "", + "session_mode": session_mode, + "input_mode": "audio", + "output_mode": "audio", + "audio_config": { + "input": in_cfg, + "output": out_cfg, + }, + } + + agent_id = _cfg("VC_AGENT_ID", None) + if agent_id is None or agent_id == "": + agent_id = bot.get("bot_id") or _cfg("VC_BOT_ID", "") + if agent_id: + data["agent_id"] = agent_id + + io = dict(inb.get("interrupt_options") or {}) + if io.get("thinking_time_only"): + io["strategy"] = "thinking_time_only" + elif io.get("keyword_interrupt"): + io["strategy"] = "keyword" + if io.get("interrupt_keywords"): + io["interrupt_keywords"] = io["interrupt_keywords"] + if io: + data["interrupt_options"] = io + + data["welcome_options"] = _build_v3_welcome_options(dlg_cfg) + + meta = {} + if bot: + meta["bot_option"] = bot + cv = dlg_cfg.get("custom_variables") + if isinstance(cv, dict) and cv: + meta["custom_variables"] = cv + vs = inb.get("vad_silence") + if vs is not None: + meta["vad_silence"] = vs + prov = dlg.get("provider") + if prov: + meta["dialog_provider"] = prov + # V2.5 专有字段勿进 audio_config;V3 仅保留文档认可的 metadata + if in_extra: + meta["asr_cfg_extra"] = in_extra + if out_extra: + voice = out_extra.get("voice") + if voice: + meta["voice"] = voice + # emotion/volume/chunk_interval_ms 等 V3 无对应项,不随 start 上报 + tts_other = {} + for k, v in out_extra.items(): + if k in _V3_DROP_TTS_EXTRA_KEYS or k == "voice": + continue + tts_other[k] = v + if tts_other: + meta["tts_cfg_extra"] = tts_other + if outb.get("provider"): + meta["tts_provider"] = outb.get("provider") + if inb.get("provider"): + meta["asr_provider"] = inb.get("provider") + if meta: + data["metadata"] = meta + + enc = _cfg("VC_AUDIO_ENCRYPTION", 0) + try: + if int(enc): + data["audio_encryption"] = {"enabled": True} + except Exception: + pass + + return data + + +def vc_proto_pack_obj(obj): + """V3:将 V2.5 形态 dict 转为 Envelope dict;V2.5:原样返回。""" + if not is_v3(): + return obj + if not isinstance(obj, dict): + return obj + + ev, payload = _extract_client_event_payload(obj) + if not ev: + return obj + + type_name = ev + if type_name == "Start": + type_name = "start" + + if type_name == "start": + data = _convert_start_payload_v25_to_v3(payload) + elif type_name in ("cancel", "audio.create", "audio.completed"): + data = payload if isinstance(payload, dict) else {} + else: + data = payload if isinstance(payload, dict) else {} + + return {"type": type_name, "data": data} + + +def vc_proto_pack_wire(s): + """出站 JSON 字符串:V3 时改写为 type/data。""" + if not is_v3(): + return s + if not isinstance(s, str) or not s or s == "ping": + return s + try: + obj = ujson.loads(s) + except Exception: + return s + try: + packed = vc_proto_pack_obj(obj) + return ujson.dumps(packed) + except Exception: + return s + + +def _normalize_payload(event, data): + if not isinstance(data, dict): + data = {} + p = data + if event == "asr.result": + if "text" in p and "content" not in p: + p["content"] = p["text"] + elif event == "failed": + if "message" not in p: + if isinstance(p.get("error"), str): + p["message"] = p.get("error") + elif isinstance(p.get("msg"), str): + p["message"] = p.get("msg") + if "code" in p and "error" not in p: + p["error"] = p["code"] + elif event == "llm.delta": + if "delta" in p and "content" not in p: + p["content"] = p["delta"] + return p + + +def _proto_tx_log_enabled(): + try: + import vc_config + if vc_config: + v = vc_config.get_attr("VC_PROTO_TX_LOG", None) + if v is not None: + return bool(v) + except Exception: + pass + try: + from vc_common import VC_PROTO_TX_LOG + return bool(VC_PROTO_TX_LOG) + except Exception: + return True + + +def _text_preview(s, n=48): + try: + t = (s or "").strip().replace("\n", " ") + except Exception: + return "" + if len(t) <= n: + return t + return t[:n] + "..." + + +def _log_wire_json(tag, wire_str): + """打印 WebSocket 出站原始 JSON(过长时分段,避免串口截断)。""" + if not _proto_tx_log_enabled(): + return + try: + s = wire_str if isinstance(wire_str, str) else "" + n = len(s) + chunk = 480 + print("[VC][proto] {} json_len={}".format(tag, n)) + if n <= chunk: + print("[VC][proto] {} json={}".format(tag, s)) + return + pos = 0 + part = 0 + while pos < n: + print("[VC][proto] {} json_part{}={}".format(tag, part, s[pos:pos + chunk])) + pos += chunk + part += 1 + except Exception: + pass + + +def vc_proto_log_started_server(payload): + """入站 started:打印服务端 welcome 配置(联调 welcome_options 是否生效)。""" + if not is_v3() or not _proto_tx_log_enabled(): + return + try: + p = payload if isinstance(payload, dict) else {} + we = p.get("welcome_enabled") + wm = p.get("welcome_message") or "" + agent = p.get("agent") or {} + if isinstance(agent, dict) and we is None: + we = agent.get("welcome_enabled") + if not wm: + wm = agent.get("welcome_message") or "" + print("[VC][proto] rx_started ver=v3 welcome_enabled={} welcome_msg={!r}".format( + we, _text_preview(str(wm), 48))) + except Exception: + pass + + +def vc_proto_log_start_from_v25(json_data, vc_mode=-1): + """start/Start 发出前:打印 TTS/会话关键参数(不依赖大包 JSON 解析)。""" + if not _proto_tx_log_enabled(): + return + try: + p = json_data.get("payload") or {} + inb = p.get("inbound_attributes") or {} + outb = p.get("outbound_attributes") or {} + ocfg = outb.get("cfg") or {} + icfg = inb.get("cfg") or {} + dlg = p.get("dialog_attributes") or {} + bot = (dlg.get("cfg") or {}).get("bot_option") or {} + disable_vad = inb.get("disable_vad", True) + sess = "push_to_talk" if disable_vad else "realtime" + tid = "" + try: + tid = (json_data.get("header") or {}).get("trace_id") or "" + except Exception: + pass + print("[VC][proto] start_tx ver={} trace={} vc_mode={} sess={} vad_silence={} " + "voice={} out_prov={} in_prov={} fmt={} sr={} speech_rate={} bot={} wf={}".format( + get_protocol_version(), + tid or "-", + vc_mode, + sess, + inb.get("vad_silence", "-"), + ocfg.get("voice", "-"), + outb.get("provider", "-"), + inb.get("provider", "-"), + ocfg.get("format", "-"), + ocfg.get("sample_rate", "-"), + ocfg.get("speech_rate", "-"), + bot.get("bot_id", "-"), + bot.get("workflow_id", "-"), + )) + try: + # 内部 V2.5 草稿,不经 pack_wire,不会上 WebSocket + _log_wire_json("start_draft_v25", ujson.dumps(json_data)) + except Exception: + pass + if is_v3(): + try: + d = _convert_start_payload_v25_to_v3(p) + ac_out = (d.get("audio_config") or {}).get("output") or {} + meta = d.get("metadata") or {} + wo = d.get("welcome_options") or {} + print("[VC][proto] start_preview_v3 agent={} session_mode={} voice_meta={} " + "welcome_enabled={} audio_out_keys={}".format( + d.get("agent_id", "-"), + d.get("session_mode", "-"), + meta.get("voice", "-"), + wo.get("welcome_enabled", "-"), + list(ac_out.keys()), + )) + # V3 pack 预览,未上链;链路上仅 start_wire_SENT 一条 + _log_wire_json("start_preview_v3", ujson.dumps({"type": "start", "data": d})) + print("[VC][proto] start_log_note 链路上仅发 start_wire_SENT(type:start)") + except Exception: + pass + except Exception: + pass + + +def vc_proto_log_synthesis(text, pending_kind=""): + """synthesis 发出前:打印待合成文本摘要。""" + if not _proto_tx_log_enabled(): + return + try: + t = text or "" + print("[VC][proto] synthesis_tx ver={} text_len={} kind={} text={!r}".format( + get_protocol_version(), + len(t), + pending_kind or "-", + _text_preview(t, 64), + )) + try: + if is_v3(): + wire = ujson.dumps({"type": "synthesis", "data": {"text": t}}) + else: + wire = ujson.dumps({"event": "synthesis", "payload": {"text": t}}) + _log_wire_json("tx_synthesis", wire) + except Exception: + pass + except Exception: + pass + + +def vc_proto_log_tx_wire(wire_str, ws_conn=0): + """发送线程:对已 pack 的 JSON 打一行摘要(synthesis 等小报文)。""" + if not _proto_tx_log_enabled(): + return + if not isinstance(wire_str, str) or not wire_str or wire_str == "ping": + return + ev = "" + try: + jd = ujson.loads(wire_str) + ev, pl = _extract_client_event_payload(jd) + except Exception: + print("[VC][proto] tx parse_fail len={} ws={}".format(len(wire_str), ws_conn)) + _log_wire_json("tx_wire_raw", wire_str) + return + if not ev: + print("[VC][proto] tx unknown_json len={} ws={}".format(len(wire_str), ws_conn)) + _log_wire_json("tx_wire_raw", wire_str) + return + el = str(ev).lower() + tag = "tx_" + el.replace(".", "_") + _log_wire_json(tag, wire_str) + if el == "synthesis": + txt = "" + try: + txt = pl.get("text") or "" + except Exception: + pass + print("[VC][proto] tx synthesis ver={} len={} ws={} text={!r}".format( + get_protocol_version(), len(wire_str), ws_conn, _text_preview(txt, 64))) + return + if el in ("start",): + print("[VC][proto] start_wire_SENT ver={} wire_len={} ws={}".format( + get_protocol_version(), len(wire_str), ws_conn)) + return + if el in ("audio.create", "audio.completed", "cancel", "audio.played", "audio.convert"): + print("[VC][proto] tx event={} ver={} len={} ws={}".format( + ev, get_protocol_version(), len(wire_str), ws_conn)) + return + print("[VC][proto] tx event={} ver={} len={} ws={}".format( + ev, get_protocol_version(), len(wire_str), ws_conn)) + + +def vc_proto_normalize_server(js): + """入站:V3 Envelope 归一为顶层 event + payload(就地修改并返回)。""" + if not isinstance(js, dict): + return js + if not is_v3(): + return js + + if js.get("type"): + ev = js.get("type") or "" + ev = _SERVER_TYPE_TO_EVENT.get(ev, ev) + data = js.get("data") + if data is None: + data = {} + js["event"] = ev + js["payload"] = _normalize_payload(ev, data) + sid = js.get("session_id") or js.get("sid") + if sid and not js.get("sid"): + js["sid"] = sid + return js diff --git a/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_session.py b/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_session.py index 96c67592..469b383a 100644 --- a/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_session.py +++ b/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_session.py @@ -208,6 +208,22 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract): pass return 8000 + def _vc_ignore_late_tts_completed_event(self): + """打断后已停播/开麦:迟到的 tts.completed 不参与 await_played / play_end。""" + try: + return ( + self._vc_is_long_dialogue_mode() + and not self._vc_tts_playback_active() + and ( + getattr(self, "VC_record_state_flag", 0) + or getattr(self, "_vc_pending_interrupt_uplink", 0) + or getattr(self, "is_ruppt_tts", 0) + or getattr(self, "_drop_tts_audio", 0) + ) + ) + except Exception: + return False + def _long_dialogue_should_drop_inbound_tts(self): """长对话打断窗口:丢弃迟到 TTS 帧 / 禁止起播。""" if not self._vc_is_long_dialogue_mode(): @@ -483,7 +499,7 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract): except Exception: pass def _vc_rx_resets_long_idle_timer(self, recv_data): - """是否清零 close_timerout:仅 TTS 反馈(tts.* / started / 音频字节),asr/vad/llm 不计。""" + """是否清零 close_timerout:仅 TTS 反馈;保活(文本 ping/pong、heartbeat)不计。""" try: if recv_data is None: return False @@ -515,7 +531,7 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract): except Exception: return False def _vc_long_idle_timer_armed(self): - """长对话 L1:会话已 started 且已连接时才开始计 20s 无 TTS。""" + """长对话 L1:轮次间隙无 TTS 下行才累计;听/想/播/录与 JSON 保活均不计。""" try: if getattr(self, "VC_mode", -1) != 0: return False @@ -525,6 +541,17 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract): return False if getattr(self, "_vc_session_started", 0) != 1: return False + if getattr(self, "VC_record_state_flag", 0) != 0: + return False + t = getattr(self.fsm, "turn", VCTurnState.Idle) + if t in ( + VCTurnState.Listening, + VCTurnState.Thinking, + VCTurnState.TtsReceiving, + VCTurnState.Playing, + VCTurnState.AwaitPlayedAck, + ): + return False return True except Exception: return False @@ -2038,6 +2065,7 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract): self._vc_boot_standby = 0 self._last_tts_kind = "" self._pending_tts_kind = "" + self._vc_expect_dialog_tts = 0 # Push-To-Talk(VAD关闭/单对话)是否已发送 audio.create # 重要:协议要求 disable_vad=true 时必须 audio.create→音频帧→audio.completed self._ptt_audio_created = 0 @@ -2073,6 +2101,9 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract): self._uplink_active = 0 self._uplink_bytes = 0 self._uplink_start_ms = 0 + self._uplink_tx_fail_cnt = 0 + self._uplink_tx_fail_bytes = 0 + self._uplink_tx_fail_log_ms = 0 # 状态机骨架(后续逐步把旧逻辑迁移进来) self.fsm = VCStateMachine(self) @@ -2325,8 +2356,26 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract): self.Audio_buf_size = 20*1024 self.record = audio.Record() #self.record.end_callback(self.record_callback) - self.record.gain_set(4,10) - #self.record.gain_set(4,12) + try: + _cg, _dg = 4, 10 + if "vc_config" in globals() and vc_config: + _cg = int(vc_config.get_int("RECORD_CODEC_GAIN", 4)) + _dg = int(vc_config.get_int("RECORD_DSP_GAIN", 10)) + if _cg < 0: + _cg = 0 + elif _cg > 4: + _cg = 4 + if _dg < -36: + _dg = -36 + elif _dg > 12: + _dg = 12 + self.record.gain_set(_cg, _dg) + except Exception as e: + usys.print_exception(e) + try: + self.record.gain_set(4, 10) + except Exception: + pass self.aud = audio.Audio(0) self.aud.setCallback(self.audio_cb) # 确保 record/aud 已创建后再打印硬件/音频诊断信息(需要 gain_get/type(record)) @@ -3123,8 +3172,8 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract): pass if _bg_active: EventMesh.publish("system_sleep_rest") - # --- Level 1:仅长对话,idle_sec 内无 TTS 下行 → Close 进 idle(需再按 AI 进入) - # close_timerout:仅 tts.* / started / TTS 音频重置;见 _vc_rx_resets_long_idle_timer + # --- Level 1:长对话轮次间隙 idle_sec 内无 TTS 下行 → Close(JSON ping/pong 不重置、不暂停累计) + # close_timerout:仅 tts.* / started / TTS 音频重置;听/想/播/录期 _vc_long_idle_timer_armed=False idle_sec = 20 try: if "vc_config" in globals() and vc_config: @@ -3216,16 +3265,17 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract): if self.VC_satrt_kws_flag == 1 and self.VC_satrt_flag == 0: pass - # VC WebSocket 文本 ping - try: - _ping_iv = int(vc_config.get_int("VC_WS_PING_INTERVAL_SEC", 4)) - except Exception: - _ping_iv = 4 - if _ping_iv > 0 and getattr(self, "AI_client_VC_isconnect", 0) == 1: - self.VC_pingping_timerout += 1 - if self.VC_pingping_timerout >= _ping_iv: - self.VC_pingping_timerout = 0 - self.AI_client_VC_ping() + # V2.5 自定义文本 ping;V3 无自定义心跳(入站标准 PING 由 uwebsocket 回 PONG) + if vc_ws_custom_ping_enabled() and getattr(self, "AI_client_VC_isconnect", 0) == 1: + try: + _ping_iv = int(vc_config.get_int("VC_WS_PING_INTERVAL_SEC", 55)) + except Exception: + _ping_iv = 55 + if _ping_iv > 0: + self.VC_pingping_timerout += 1 + if self.VC_pingping_timerout >= _ping_iv: + self.VC_pingping_timerout = 0 + self.AI_client_VC_ping() # self.asr_pingping_timerout += 1 # if self.asr_pingping_timerout>3: # self.asr_pingping_timerout=0 diff --git a/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_session_imports.py b/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_session_imports.py index 7a970606..ee7a4eda 100644 --- a/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_session_imports.py +++ b/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_session_imports.py @@ -59,6 +59,18 @@ from vc_common import ( _vc_out_json_event_meta, PRESS_AUDIO_MODE, ) +from vc_proto import ( + vc_proto_build_ws_url, + vc_proto_pack_wire, + vc_proto_normalize_server, + vc_proto_audio_played_enabled, + vc_proto_log_start_from_v25, + vc_proto_log_started_server, + vc_proto_log_synthesis, + vc_proto_log_tx_wire, + is_v3, + vc_ws_custom_ping_enabled, +) from vc_fsm import ( VCSessionState, VCTurnState, diff --git a/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_session_io.py b/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_session_io.py index 8585aa21..7bc733de 100644 --- a/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_session_io.py +++ b/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_session_io.py @@ -21,6 +21,64 @@ class VCSessionIoMixin: pass return False + def _vc_error_tts_enabled(self): + """VC_ERROR_TTS_ENABLED=1:协议 failed 原文 synthesis 播报(联调用)。""" + try: + if "vc_config" in globals() and vc_config: + return vc_config.get_bool("VC_ERROR_TTS_ENABLED", False) + except Exception: + pass + return False + + def _vc_error_tts_speakable(self, msg): + """协议 failed 是否走 synthesis;默认过滤技术错误,VC_ERROR_TTS_ENABLED=1 时全播。""" + try: + if msg is None: + return None + s = str(msg).strip() + if not s: + return None + if self._vc_error_tts_enabled(): + return s + low = s.lower() + for pat in ( + "failed to parse", + "parse message", + "json decode", + "invalid json", + "internal server", + "concurrency_limit", + ): + if pat in low: + return None + except Exception: + return None + return s + + def _vc_classify_llm_tts_kind(self, payload_dialog_id=""): + """设计稿 llm tts:V3 下行常无 dialog_id,用轮次状态补判为 dialog。""" + try: + if payload_dialog_id: + return "dialog" + k = getattr(self, "_pending_tts_kind", "") or "" + if k: + return k + cur = getattr(self, "_last_tts_kind", "") or "" + if cur in ("mode_switch_long", "mode_switch_ptt", "boot_prompt"): + return cur + if getattr(self, "_vc_expect_dialog_tts", 0) == 1: + return "dialog" + if ( + getattr(self, "VC_mode", -1) == 0 + and getattr(self, "_turn_has_asr_completed", 0) == 1 + ): + return "dialog" + if not cur: + return "prompt" + return cur + except Exception: + return "prompt" + def _vc_orchestrator_server_event_cb(self, data): """btn 编排线程:服务端协议事件唯一入口(长对话/PTT 统一)。""" try: @@ -64,6 +122,9 @@ class VCSessionIoMixin: self._vc_stop_downlink_for_interrupt("canceled") except Exception: pass + if ev == "tts.completed" and self._vc_ignore_late_tts_completed_event(): + print("[ORCH] ignore late tts.completed (interrupt/recording)") + return try: actions = self.fsm.on_server_event(ev, jsload) if ev == "canceled" and self._vc_is_long_dialogue_mode(): @@ -155,17 +216,18 @@ class VCSessionIoMixin: token = EventMesh.publish("get_dev_token") print("token:{}".format(token)) - req_str = "vc/chat?is_auth={}".format(self.is_auth) + base = "vc/chat" try: if "vc_config" in globals() and vc_config: base = vc_config.get_attr("VC_WS_PATH", "vc/chat") or "vc/chat" - req_str = "{}?is_auth={}".format(base, self.is_auth) except Exception: pass - if(token): - url = 'ws://{}/{}&dt={}'.format(self.server_ip,req_str,token) - else: - url = 'ws://{}/{}'.format(self.server_ip,req_str) + try: + did = modem.getDevImei() + except Exception: + did = "" + url = vc_proto_build_ws_url( + self.server_ip, base, self.is_auth, token=token, device_id=did) #url = 'ws://115.190.94.208:22080/vc/coze?dt={}'.format(token) if VC_WS_IO_LOG: @@ -274,10 +336,17 @@ class VCSessionIoMixin: utime.sleep_ms(3000) EventMesh.publish("publish_AI_btnInfo",{"func":self.AI_VC_start}) else: - print("websocket_error:tts:{}".format(TTS_str)) - self.AI_TTS_str = TTS_str - EventMesh.publish("publish_AI_btnInfo",{"func":self.AI_VC_start}) - self.Close_flag = 1 + speak = self._vc_error_tts_speakable(TTS_str) + print("websocket_error:tts:{} speakable={}".format(TTS_str, speak)) + if speak: + self.AI_TTS_str = speak + EventMesh.publish("publish_AI_btnInfo", {"func": self.AI_VC_start}) + self.Close_flag = 1 + else: + self.websocket_error_count += 1 + if self.websocket_error_count < 3: + utime.sleep_ms(3000) + EventMesh.publish("publish_AI_btnInfo", {"func": self.AI_VC_start}) print("websocket_error2{}".format(self.websocket_error_count)) @@ -351,23 +420,29 @@ class VCSessionIoMixin: usys.print_exception(e) print("websocket_deconnect_VC {}!!!!!!!".format(self.AI_client_VC_isconnect)) def AI_client_VC_ping(self): - pass + """V2.5:WebSocket 文本帧 \"ping\";V3 不调用。不发 RFC6455 OP_PING(默认)。""" + if not vc_ws_custom_ping_enabled(): + return if self.AI_client_VC_isconnect == 1: - #self.AI_client_VC.write_frame(0x09,b'ping ping') self.AI_client_VC_send("ping") if VC_HEARTBEAT_LOG: print("VC: ping ping") def AI_client_VC_send(self,data_str): - # if isinstance(data_str, str):#是字符串 - # print("AI_client_VC_send:{}".format(data_str)) - # pass - # else: - # print("AI_client_VC_send:len{}".format(len(data_str))) + # V3:统一在发送队列前将 event/payload 转为 type/data + try: + if isinstance(data_str, str): + data_str = vc_proto_pack_wire(data_str) + except Exception: + pass # 统计“语音上行”真实开始/结束:这里只统计二进制音频(非 ping/字符串) + is_pcm = not isinstance(data_str, str) try: - if not isinstance(data_str, str): + if is_pcm: l = len(data_str) if l > 0: + if self.AI_client_VC_isconnect != 1 or self.AI_client_VC is None: + self._uplink_note_tx_fail(l, "enqueue_ws_down") + return self._uplink_mark_start(l) self._uplink_bytes += l except Exception: @@ -460,15 +535,9 @@ class VCSessionIoMixin: pass else: try: - ev, tid = _vc_out_json_event_meta(s) - if ev: - print("[VC][proto] tx event={} trace_id={} len={} ws_conn={}".format( - ev, tid or "-", len(s), self.AI_client_VC_isconnect)) - else: - print("[VC][proto] tx unknown_json len={} ws_conn={}".format( - len(s), self.AI_client_VC_isconnect)) + vc_proto_log_tx_wire(s, self.AI_client_VC_isconnect) except Exception: - print("[VC][proto] tx parse_fail len={}".format(len(s))) + print("[VC][proto] tx log_fail len={}".format(len(s))) else: pass # if VC_AUDIO_FRAME_LOG: @@ -483,8 +552,15 @@ class VCSessionIoMixin: print("VC VC: send_data:ERROR len={} event={} trace_id={}".format( len(data[1]), _ev or "?", _tid or "-")) else: + if not isinstance(data[1], str): + self._uplink_note_tx_fail(len(data[1]), "ws_down") print("VC VC: send_data:ERROR:{}".format(len(data[1]))) except Exception: + if not isinstance(data[1], str): + try: + self._uplink_note_tx_fail(len(data[1]), "ws_down") + except Exception: + pass print("VC VC: send_data:ERROR:{}".format(len(data[1]))) #print("VC: send_data end") @@ -493,10 +569,11 @@ class VCSessionIoMixin: print("DeviceInfo_queue_thread:ERROR:{}".format(e)) usys.print_exception(e) if data[0] == AI_client_VC_num: - #self.websocket_error() - pass - - + try: + if data[1] is not None and not isinstance(data[1], str): + self._uplink_note_tx_fail(len(data[1]), "send_exc") + except Exception: + pass try: if self.AI_client_VC_isconnect != 0: print("VC发送数据错误,退出") @@ -568,7 +645,14 @@ class VCSessionIoMixin: try: self.websocket_error_count = 0 if isinstance(recv_data, str):#返回的是字符串 + _tl = recv_data.strip().lower() + if _tl in ("ping", "pong"): + continue jsload_data = ujson.loads(recv_data) + try: + jsload_data = vc_proto_normalize_server(jsload_data) + except Exception: + pass if VC_WS_IO_LOG: print("[VC] recv raw:{}".format(recv_data)) else: @@ -608,6 +692,7 @@ class VCSessionIoMixin: self._turn_has_vad_silence = 0 self._turn_has_tts_started = 0 self._llm_completed_ms = 0 + self._vc_expect_dialog_tts = 0 _skip_long_up = 0 if getattr(self, "_skip_started_uplink_once", 0) == 1: try: @@ -617,6 +702,10 @@ class VCSessionIoMixin: except Exception: pass print("VC:Started!!!!!!!!\r\n") + try: + vc_proto_log_started_server(jsload_data.get("payload") or {}) + except Exception: + pass print("TTS音频为:{},语音下发地址为:{}".format( self.AI_TTS_str, self.AI_new_voice_url)) try: @@ -813,7 +902,14 @@ class VCSessionIoMixin: except Exception: pass self._llm_completed_ms = 0 - # EventMesh.publish("publish_AI_btnInfo",{"func":self.VC_Play_Start}) + try: + if ( + getattr(self, "VC_mode", 0) == 0 + and getattr(self, "_turn_has_asr_completed", 0) == 1 + ): + self._vc_expect_dialog_tts = 1 + except Exception: + pass pass elif jsload_data["event"] == "llm.completed" :#llm完成(可能无 tts) try: @@ -850,28 +946,14 @@ class VCSessionIoMixin: # - 无 dialog_id:认为是“提示/提醒 TTS”(模式切换可通过 _pending_tts_kind 标记) try: did = jsload_data.get("payload", {}).get("dialog_id", "") or "" + if not did: + did = getattr(self, "_cur_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", - "boot_prompt", - ): - pass - elif not cur: - self._last_tts_kind = "prompt" + self._last_tts_kind = self._vc_classify_llm_tts_kind(did) + if self._last_tts_kind == "dialog": + self._vc_expect_dialog_tts = 0 except Exception: pass try: @@ -1014,13 +1096,22 @@ class VCSessionIoMixin: 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":#接口返回错误 - if jsload_data["payload"]["error"] == "concurrency_limit_exceeded": + pl = jsload_data.get("payload") or {} + err = pl.get("error") or "" + msg = pl.get("message") or pl.get("msg") or err or "" + try: + print("[VC] rx failed sid={} err={} msg={}".format( + jsload_data.get("sid", "") or "-", err, msg)) + except Exception: + pass + if err == "concurrency_limit_exceeded": EventMesh.publish("publish_AI_btnInfo",{"func":self.concurrency_limit_exceeded_tone}) - - #utime.sleep_ms(4000) - else: - EventMesh.publish("publish_AI_btnInfo",{"func":self.websocket_error,"data":jsload_data["payload"]["message"]}) + speak = self._vc_error_tts_speakable(msg) + EventMesh.publish( + "publish_AI_btnInfo", + {"func": self.websocket_error, "data": speak}, + ) pass elif jsload_data["event"] == "audio.convert.completed":#音频转成完成没有出错 接口 @@ -1774,17 +1865,11 @@ class VCSessionIoMixin: self.fsm.on_session_start_request() except Exception: pass - self.AI_client_VC_send(ujson.dumps(json_data)) try: - _tid = json_data.get("header", {}).get("trace_id", "") - print("[VC][proto] Start enqueued trace_id={} VC_mode={} disable_vad={} vad_silence={}ms ws_conn={}".format( - _tid or "-", - self.VC_mode, - json_data.get("payload", {}).get("inbound_attributes", {}).get("disable_vad", "?"), - getattr(self, "vad_silence", "?"), - self.AI_client_VC_isconnect)) + vc_proto_log_start_from_v25(json_data, getattr(self, "VC_mode", -1)) except Exception: pass + self.AI_client_VC_send(ujson.dumps(json_data)) def AI_client_VC_RequestToSpeak(self): """兼容旧入口:长对话短按打断(勿 VC_init,避免灯态/fsm 进 thinking)。""" print("AI_client_VC_RequestToSpeak!!!!!!!!!!!!!!!!!!!!!!!!!!!!!") diff --git a/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_session_media.py b/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_session_media.py index adba4f79..ef5a26b0 100644 --- a/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_session_media.py +++ b/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_session_media.py @@ -744,22 +744,54 @@ class VCSessionMediaMixin: self._uplink_active = 1 self._uplink_bytes = 0 self._uplink_start_ms = utime.ticks_ms() + self._uplink_tx_fail_cnt = 0 + self._uplink_tx_fail_bytes = 0 + self._uplink_tx_fail_log_ms = 0 print("[VC][uplink] START len={} {} VC_record_state_flag={}".format( first_len, self._vc_fsm_log_snip(), self.VC_record_state_flag)) except Exception: pass + + def _uplink_note_tx_fail(self, nbytes, reason=""): + """上行 PCM 未成功发出(WS 未连、send 异常、协议门控丢弃等)。""" + try: + n = int(nbytes or 0) + self._uplink_tx_fail_cnt = getattr(self, "_uplink_tx_fail_cnt", 0) + 1 + self._uplink_tx_fail_bytes = getattr(self, "_uplink_tx_fail_bytes", 0) + n + now = utime.ticks_ms() + last = getattr(self, "_uplink_tx_fail_log_ms", 0) + if not last or utime.ticks_diff(now, last) >= 800: + self._uplink_tx_fail_log_ms = now + print( + "[VC][uplink] TX_FAIL reason={} len={} fail_cnt={} fail_bytes={} {}".format( + reason or "?", + n, + self._uplink_tx_fail_cnt, + self._uplink_tx_fail_bytes, + self._vc_fsm_log_snip(), + ) + ) + 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_record_state_flag={}".format( - reason, self._uplink_bytes, dur, self._vc_fsm_log_snip(), self.VC_record_state_flag)) + fc = getattr(self, "_uplink_tx_fail_cnt", 0) + fb = getattr(self, "_uplink_tx_fail_bytes", 0) + print("[VC][uplink] END reason={} bytes={} dur_ms={} tx_fail_cnt={} tx_fail_bytes={} {} VC_record_state_flag={}".format( + reason, self._uplink_bytes, dur, fc, fb, + self._vc_fsm_log_snip(), self.VC_record_state_flag)) except Exception: pass self._uplink_active = 0 self._uplink_bytes = 0 self._uplink_start_ms = 0 + self._uplink_tx_fail_cnt = 0 + self._uplink_tx_fail_bytes = 0 + self._uplink_tx_fail_log_ms = 0 # 最近一次 TTS 的语义来源(dialog/prompt/mode_switch_long/mode_switch_ptt/...) self._last_tts_kind = "" @@ -1191,6 +1223,10 @@ class VCSessionMediaMixin: # 协议门控:会话未 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() + try: + self._uplink_note_tx_fail(len(_), "not_started_drop") + except Exception: + pass 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): @@ -1211,6 +1247,10 @@ class VCSessionMediaMixin: 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() + try: + self._uplink_note_tx_fail(len(d), "prestart_buf_overflow") + except Exception: + pass AI_record_tmp_queue_len = max(0, AI_record_tmp_queue_len - len(d)) except Exception: pass @@ -1619,6 +1659,16 @@ class VCSessionMediaMixin: return 0 return self._PCM_STREAM_RING_BYTES - pcm_free + def _vc_pcm_pending_for_play_end(self): + """play_pcm 已关闭时勿用 stream_get_buf_len(0) 误判整环待播。""" + pcm = getattr(self, "play_pcm", None) + if not pcm: + return 0 + try: + return max(0, self._vc_pcm_stream_pending_bytes(pcm.stream_get_buf_len())) + except Exception: + return 0 + def _vc_finish_play_end_close_if_needed(self): """旧版 LocalRespondingEnded:Close_flag==1 时提示音/模式切换播完应 Close 会话。""" try: @@ -1695,13 +1745,7 @@ class VCSessionMediaMixin: is_pcm_empty_z = (self.play_pcm is None) or ( self.play_pcm is not None and self.play_pcm.stream_is_empty() == 1 ) - pcm_pending_z = 0 - if self.play_pcm is not None: - try: - pcm_pending_z = self._vc_pcm_stream_pending_bytes( - self.play_pcm.stream_get_buf_len()) - except Exception: - pcm_pending_z = 0 + pcm_pending_z = self._vc_pcm_pending_for_play_end() if (not is_pcm_empty_z) or pcm_pending_z > 0: if since_done < 5000: return False @@ -1717,14 +1761,7 @@ class VCSessionMediaMixin: slack = max(slack, in_len // 25) except Exception: pass - try: - if self.play_pcm is not None: - pcm_free_pre = self.play_pcm.stream_get_buf_len() - else: - pcm_free_pre = 0 - except Exception: - pcm_free_pre = 0 - pcm_pending_pre = self._vc_pcm_stream_pending_bytes(pcm_free_pre) + pcm_pending_pre = self._vc_pcm_pending_for_play_end() if pcm_pending_pre < 0: pcm_pending_pre = max(0, in_len - out_len) played_ok = (out_len >= in_len) or ( @@ -1746,13 +1783,13 @@ class VCSessionMediaMixin: is_pcm_empty = (self.play_pcm is None) or ( self.play_pcm is not None and self.play_pcm.stream_is_empty() == 1 ) - pcm_free = 0 + pcm_free = -1 if self.play_pcm is not None: try: pcm_free = self.play_pcm.stream_get_buf_len() except Exception: pcm_free = -1 - pcm_pending = self._vc_pcm_stream_pending_bytes(pcm_free) + pcm_pending = self._vc_pcm_pending_for_play_end() if pcm_pending < 0: pcm_pending = max(0, in_len - out_len) @@ -1859,11 +1896,20 @@ class VCSessionMediaMixin: return except Exception: pass + _syn_text = self.AI_TTS_str or "" + try: + _syn_kind = getattr(self, "_pending_tts_kind", "") or getattr(self, "_last_tts_kind", "") or "" + except Exception: + _syn_kind = "" + try: + vc_proto_log_synthesis(_syn_text, _syn_kind) + except Exception: + pass json_data = \ { "event": "synthesis", "payload": { - "text": self.AI_TTS_str + "text": _syn_text } } # 语义标记只用于“是否自动开麦”的策略。 @@ -1884,8 +1930,10 @@ class VCSessionMediaMixin: # 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)) def AI_client_VC_audio_played(self): - # V2.5:播完上报;与 VC_PROTO_GUARDS 无关(见文件头说明) + # V2.5:播完上报;V3 默认关闭(VC_AUDIO_PLAYED_ACK=0) try: + if not vc_proto_audio_played_enabled(): + return if self.AI_client_VC_isconnect != 1 or self.AI_client_VC == None: return json_data = {"event": "audio.played", "payload": {}} diff --git a/2.sdk/project/7PIN_1_5/sdk/system/platform/ASR_1606/boards/EG810MCN_GA_VOLTE/images/firmware_base_release_pack.zip b/2.sdk/project/7PIN_1_5/sdk/system/platform/ASR_1606/boards/EG810MCN_GA_VOLTE/images/firmware_base_release_pack.zip index a066ee2b..f79de5c9 100644 Binary files a/2.sdk/project/7PIN_1_5/sdk/system/platform/ASR_1606/boards/EG810MCN_GA_VOLTE/images/firmware_base_release_pack.zip and b/2.sdk/project/7PIN_1_5/sdk/system/platform/ASR_1606/boards/EG810MCN_GA_VOLTE/images/firmware_base_release_pack.zip differ