From: TimekeeperPL <1624952103@qq.com> Date: Wed, 27 May 2026 07:16:23 +0000 (+0800) Subject: 修复两次VAD提示音的BUG,修复未close直接断联websocket问题,修复长对话模式 短按按键重新录音 TTS仍播报的问题,修复单对话模式 长按按键 无法打断TT... X-Git-Url: http://git.snicker.com.cn/?a=commitdiff_plain;h=f2c62bbcb8e05050bd7db2d2a82d0fa20d90cb21;p=mirrors%2FASR1603_G5.git 修复两次VAD提示音的BUG,修复未close直接断联websocket问题,修复长对话模式 短按按键重新录音 TTS仍播报的问题,修复单对话模式 长按按键 无法打断TTS播报并重新录音的问题 --- 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..089dc8f5 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 @@ -143,6 +143,21 @@ VC_IDLE_DEEP_SLEEP_ENABLE = 1 # 服务端断句静音(ms);句中短暂停顿建议 ≥1200;可被 storage vad_silence 覆盖 VAD_SILENCE_DEFAULT = 1500 +# 长对话打断等:等 canceled 超时后再开麦(ms) +LONG_DIALOGUE_CANCEL_WAIT_MS = 800 +LONG_DIALOGUE_DROP_TTS_MS = 8000 + +# asr.completed 滴声(think.mp3):同轮须先有 asr.result;防抖避免残留 completed 连播 +ASR_COMPLETED_THINK_DEBOUNCE_MS = 2500 +# llm 完成但云端未下 tts.started:用 llm.completed 正文发 synthesis 兜底播报 +LLM_WITHOUT_TTS_SYNTHESIS_FALLBACK = True +# completed 后云端迟到 tts.started 等待窗(此期间勿发 cancel) +LLM_LATE_TTS_WAIT_MS = 3500 +# 打断 cancel 开麦后:短 uplink/空 ASR 仅重开麦;vad.silence 宽限(ms) +INTERRUPT_ASR_RECOVER_MS = 8000 +INTERRUPT_VAD_SILENCE_GRACE_MS = 3000 +INTERRUPT_ASR_MIN_UPLINK_MS = 2000 + # ============================================================================= # 8. 语音对话 / TTS / 智能体 # ============================================================================= @@ -185,6 +200,9 @@ ASR_EMPTY_HINT_TTS = "我没有听清,请再说一遍" # asr.completed 无 RECORD_START_TONE_FILE = "U:/static/start.mp3" # 按键/PTT 开录提示音(非 server.started 自动开麦) RECORD_START_TONE_GAP_MS = 180 +RECORD_START_TONE_UPLINK_DELAY_MS = 700 # 滴声结束后再启录音/上行(须≥MP3 时长,避免滞后 ASR) +ASR_EMPTY_MIN_UPLINK_MS = 1500 # 上行短于此的空 ASR:按键开麦仅重开麦,不播「没听清」 +RECORD_RESTART_STALE_DROP_MS = 1500 # key.restart 后至 uplink_open 前丢弃迟到 cancel/ASR/TTS PTT_MIN_RECORD_MS = 400 PTT_RECORD_TOO_SHORT_TTS = "录音时间太短,请按住圆形按钮说话" # notify diff --git a/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_interrupt.py b/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_interrupt.py index 88db1527..78a67344 100644 --- a/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_interrupt.py +++ b/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_interrupt.py @@ -1,107 +1,116 @@ -# -*- coding: utf-8 -*- -"""长对话/PTT 打断上下文:绑定 turn_gen,收敛 is_ruppt_tts 等 legacy 标志。""" -try: - import utime -except Exception: - utime = None - - -class InterruptContext(object): - KIND_NONE = "" - KIND_LONG_SHORT = "long_short" - KIND_PTT_SHORT = "ptt_short" - KIND_PTT_LONG = "ptt_long" - - def __init__(self): - self.active = 0 - self.kind = self.KIND_NONE - self.gen = 0 - self.pending_uplink = 0 - self.drop_tts = 0 - self.ruppt_tts = 0 - self.settled_ms = 0 - self.cancel_deadline_ms = 0 - - def clear(self, vc=None): - self.active = 0 - self.kind = self.KIND_NONE - self.gen = 0 - self.pending_uplink = 0 - self.drop_tts = 0 - self.ruppt_tts = 0 - self.settled_ms = 0 - self.cancel_deadline_ms = 0 - if vc is not None: - self._sync_legacy(vc) - - def begin(self, vc, kind, pending_uplink=0, cancel_wait_ms=800): - try: - gen = int(getattr(vc, "_vc_turn_gen", 0)) - except Exception: - gen = 0 - self.active = 1 - self.kind = kind or self.KIND_NONE - self.gen = gen - self.pending_uplink = 1 if pending_uplink else 0 - self.drop_tts = 1 - self.ruppt_tts = 1 - self.settled_ms = 0 - if utime and cancel_wait_ms: - try: - self.cancel_deadline_ms = utime.ticks_add(utime.ticks_ms(), int(cancel_wait_ms)) - except Exception: - self.cancel_deadline_ms = 0 - else: - self.cancel_deadline_ms = 0 - self._sync_legacy(vc) - return gen - - def settle(self, vc): - """打断开麦完成:清 is_ruppt/drop 以允许新 TTS;保留 settled_ms 供迟到 canceled 判 stale。""" - if utime: - try: - self.settled_ms = utime.ticks_ms() - except Exception: - self.settled_ms = 0 - self.pending_uplink = 0 - self.active = 0 - self.drop_tts = 0 - self.ruppt_tts = 0 - self._sync_legacy(vc) - - def end_drop_window(self, vc): - """新一轮 TTS 开始:结束丢弃窗口。""" - self.drop_tts = 0 - self.ruppt_tts = 0 - self.settled_ms = 0 - self.pending_uplink = 0 - self.active = 0 - self._sync_legacy(vc) - - def is_stale_gen(self, vc): - try: - return int(getattr(vc, "_vc_turn_gen", 0)) != int(self.gen) - except Exception: - return False - - def _sync_legacy(self, vc): - """与 vc_session_io / media 中既有 getattr 兼容。""" - try: - vc._vc_pending_interrupt_uplink = 1 if self.pending_uplink else 0 - vc.is_ruppt_tts = 1 if self.ruppt_tts else 0 - vc._drop_tts_audio = 1 if self.drop_tts else 0 - vc._long_dialogue_interrupt_settled_ms = int(self.settled_ms or 0) - vc._vc_cancel_deadline_ms = int(self.cancel_deadline_ms or 0) - except Exception: - pass - - def load_legacy(self, vc): - try: - self.pending_uplink = 1 if getattr(vc, "_vc_pending_interrupt_uplink", 0) else 0 - self.ruppt_tts = 1 if getattr(vc, "is_ruppt_tts", 0) else 0 - self.drop_tts = 1 if getattr(vc, "_drop_tts_audio", 0) else 0 - self.settled_ms = int(getattr(vc, "_long_dialogue_interrupt_settled_ms", 0) or 0) - self.cancel_deadline_ms = int(getattr(vc, "_vc_cancel_deadline_ms", 0) or 0) - self.active = 1 if (self.pending_uplink or self.ruppt_tts or self.drop_tts) else 0 - except Exception: - pass +# -*- coding: utf-8 -*- +"""长对话/PTT 打断上下文:绑定 turn_gen,收敛 is_ruppt_tts 等 legacy 标志。""" +try: + import utime +except Exception: + utime = None + + +class InterruptContext(object): + KIND_NONE = "" + KIND_LONG_SHORT = "long_short" + KIND_PTT_SHORT = "ptt_short" + KIND_PTT_LONG = "ptt_long" + + def __init__(self): + self.active = 0 + self.kind = self.KIND_NONE + self.gen = 0 + self.pending_uplink = 0 + self.drop_tts = 0 + self.ruppt_tts = 0 + self.settled_ms = 0 + self.cancel_deadline_ms = 0 + + def clear(self, vc=None): + self.active = 0 + self.kind = self.KIND_NONE + self.gen = 0 + self.pending_uplink = 0 + self.drop_tts = 0 + self.ruppt_tts = 0 + self.settled_ms = 0 + self.cancel_deadline_ms = 0 + if vc is not None: + self._sync_legacy(vc) + + def begin(self, vc, kind, pending_uplink=0, cancel_wait_ms=800): + try: + gen = int(getattr(vc, "_vc_turn_gen", 0)) + except Exception: + gen = 0 + self.active = 1 + self.kind = kind or self.KIND_NONE + self.gen = gen + self.pending_uplink = 1 if pending_uplink else 0 + self.drop_tts = 1 + self.ruppt_tts = 1 + self.settled_ms = 0 + if utime and cancel_wait_ms: + try: + self.cancel_deadline_ms = utime.ticks_add(utime.ticks_ms(), int(cancel_wait_ms)) + except Exception: + self.cancel_deadline_ms = 0 + else: + self.cancel_deadline_ms = 0 + self._sync_legacy(vc) + return gen + + def settle(self, vc): + """打断开麦完成:清 pending;录音进行中继续丢弃上一轮迟到 TTS。""" + if utime: + try: + self.settled_ms = utime.ticks_ms() + except Exception: + self.settled_ms = 0 + self.pending_uplink = 0 + self.active = 0 + keep_drop = False + try: + keep_drop = getattr(vc, "VC_record_state_flag", 0) != 0 + except Exception: + keep_drop = False + if keep_drop: + self.drop_tts = 1 + self.ruppt_tts = 1 + else: + self.drop_tts = 0 + self.ruppt_tts = 0 + self._sync_legacy(vc) + + def end_drop_window(self, vc): + """新一轮 TTS 开始:结束丢弃窗口。""" + self.drop_tts = 0 + self.ruppt_tts = 0 + self.settled_ms = 0 + self.pending_uplink = 0 + self.active = 0 + self._sync_legacy(vc) + + def is_stale_gen(self, vc): + try: + return int(getattr(vc, "_vc_turn_gen", 0)) != int(self.gen) + except Exception: + return False + + def _sync_legacy(self, vc): + """与 vc_session_io / media 中既有 getattr 兼容。""" + try: + vc._vc_pending_interrupt_uplink = 1 if self.pending_uplink else 0 + vc.is_ruppt_tts = 1 if self.ruppt_tts else 0 + vc._drop_tts_audio = 1 if self.drop_tts else 0 + vc._long_dialogue_interrupt_settled_ms = int(self.settled_ms or 0) + vc._vc_cancel_deadline_ms = int(self.cancel_deadline_ms or 0) + except Exception: + pass + + def load_legacy(self, vc): + try: + self.pending_uplink = 1 if getattr(vc, "_vc_pending_interrupt_uplink", 0) else 0 + self.ruppt_tts = 1 if getattr(vc, "is_ruppt_tts", 0) else 0 + self.drop_tts = 1 if getattr(vc, "_drop_tts_audio", 0) else 0 + self.settled_ms = int(getattr(vc, "_long_dialogue_interrupt_settled_ms", 0) or 0) + self.cancel_deadline_ms = int(getattr(vc, "_vc_cancel_deadline_ms", 0) or 0) + self.active = 1 if (self.pending_uplink or self.ruppt_tts or self.drop_tts) else 0 + except Exception: + pass diff --git a/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_key_router.py b/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_key_router.py index 98e08638..f85b03fa 100644 --- a/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_key_router.py +++ b/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_key_router.py @@ -43,16 +43,9 @@ class KeyRouter(object): print("[KEY][router] ptt_release ux={}".format(ux_name(ux))) except Exception: pass - if ux == UX_TTS: + if ux in (UX_TTS, UX_THINKING): try: - if vc._ptt_is_active_tts_playback(): - vc._ptt_stop_tts_on_short_press() - return True - except Exception: - pass - if ux == UX_THINKING: - try: - vc._ptt_stop_dialog_to_idle() + vc._ptt_short_press_interrupt_restart() return True except Exception: pass @@ -67,25 +60,26 @@ class KeyRouter(object): @staticmethod def handle_power_short(vc): - """Power 短按:cancel → Close → 清 sid(设计稿幻灯片 6)。""" + """Power 短按:停录 + cancel + Close(force) 清 sid(设计稿幻灯片 6)。""" ux = derive_ux_state(vc) try: print("[KEY][router] power_short ux={}".format(ux_name(ux))) except Exception: pass try: - vc._vc_send_protocol_cancel("power_short/ux={}".format(ux_name(ux))) - except Exception: - pass - try: - vc._vc_close_reason = "power_short" - except Exception: - pass - try: - if EventMesh: - EventMesh.publish("publish_AI_btnInfo", {"func": vc.Close}) - except Exception: - pass + if hasattr(vc, "_vc_power_short_shutdown"): + vc._vc_power_short_shutdown() + else: + vc._vc_send_protocol_cancel("power_short/ux={}".format(ux_name(ux))) + vc._vc_close_reason = "power_short" + if EventMesh: + EventMesh.publish("publish_AI_btnInfo", {"func": vc.Close}) + except Exception as e: + try: + import usys + usys.print_exception(e) + except Exception: + pass try: if EventMesh and constants: EventMesh.publish(constants.EVENT.SYSTEM_UP) diff --git a/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_notify.py b/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_notify.py index b957e555..2ef0843d 100644 --- a/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_notify.py +++ b/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_notify.py @@ -1,163 +1,180 @@ -# -*- coding: utf-8 -*- -"""通知通道:server.started 媒体排队、play_tts 与对话会话分流。""" -try: - from vc_fsm import VCTurnState -except Exception: - VCTurnState = None -try: - import vc_config -except Exception: - vc_config = None - -# play_tts 通道 -CHANNEL_TAKEOVER = "takeover" # 断连重连 WS synthesis(IoT 查找设备/模式切换/无对话) -CHANNEL_DEFER = "defer" # 对话进行中排队,空闲后播放(is_close_VC=0 的 IoT 查询等) - -# TTS 业务种类(_pending_tts_kind / play_tts 第三参数) -TTS_KIND_FIND_DEVICE = "find_device" -TTS_KIND_MODE_SWITCH_LONG = "mode_switch_long" -TTS_KIND_MODE_SWITCH_PTT = "mode_switch_ptt" -TTS_KIND_BOOT_PROMPT = "boot_prompt" -TTS_KIND_PROMPT = "prompt" - -# 须立即占用会话、不可 defer -_TAKEOVER_KINDS = ( - TTS_KIND_FIND_DEVICE, - TTS_KIND_MODE_SWITCH_LONG, - TTS_KIND_MODE_SWITCH_PTT, - TTS_KIND_BOOT_PROMPT, - TTS_KIND_PROMPT, -) - -# server.started plan op -OP_LONG_UPLINK = "long_uplink" -OP_ALARM_VOICE = "alarm_voice" -OP_VOICE_URL = "voice_url" -OP_SYNTHESIS = "synthesis" -OP_SYNTHESIS_HIS_TIMER = "synthesis_his_timer" -OP_SYNTHESIS_WAIT_TIMER = "synthesis_wait_timer" -OP_SYNTHESIS_WAIT_SINGLE = "synthesis_wait_single" -OP_SYNTHESIS_VOICE_TIMER = "synthesis_voice_timer" -OP_SYNTHESIS_MUSIC_TIMER = "synthesis_music_timer" -OP_IDLE_UPLINK = "idle_uplink" -OP_PTT_ROUND = "ptt_round" -OP_UPLOAD_TONE = "upload_tone" - - -def dialog_session_active(vc): - """对话占用中:通知类 play_tts 应 defer。""" - try: - if getattr(vc, "Close_flag", 0) == 1: - return False - if getattr(vc, "VC_satrt_flag", 0) != 1: - return False - if getattr(vc, "AI_client_VC_isconnect", 0) != 1: - return False - if getattr(vc, "VC_Play_flag", 0) == 1: - return True - if getattr(vc, "VC_record_state_flag", 0) != 0: - return True - # 7PIN_1_5 无闹钟业务;alarm_play_flag 仅 legacy(如 play_single_voice) - if getattr(vc, "music_play_flag", 0) == 1: - return True - if VCTurnState is not None and getattr(vc, "fsm", None) is not None: - t = getattr(vc.fsm, "turn", VCTurnState.Idle) - if t not in (VCTurnState.Idle, VCTurnState.Listening): - return True - except Exception: - pass - return False - - -class NotifyScheduler(object): - @staticmethod - def classify_play_tts(vc, is_close_vc): - try: - if is_close_vc: - return CHANNEL_TAKEOVER - except Exception: - pass - try: - k = getattr(vc, "_pending_tts_kind", "") or getattr(vc, "_last_tts_kind", "") or "" - if k in _TAKEOVER_KINDS: - return CHANNEL_TAKEOVER - except Exception: - pass - if dialog_session_active(vc): - return CHANNEL_DEFER - return CHANNEL_TAKEOVER - - @staticmethod - def plan_on_started(vc, skip_long_uplink=0): - """替代 vc_session_io started 内 if-elif 媒体分支。""" - plan = [] - try: - import EventMesh - except Exception: - EventMesh = None - - # 长对话:started 补开麦(原 597-623,与媒体队列正交) - try: - if ( - not skip_long_uplink - and getattr(vc, "VC_mode", 0) == 0 - and getattr(vc, "Close_flag", 0) == 0 - and not getattr(vc, "_vc_boot_standby", 0) - ): - plan.append({"op": OP_LONG_UPLINK}) - except Exception: - pass - - tts = getattr(vc, "AI_TTS_str", "") or "" - url = getattr(vc, "AI_new_voice_url", "") or "" - alarm = getattr(vc, "alarm_play_flag", 0) == 1 - music = getattr(vc, "music_play_flag", 0) == 1 - - # 闹钟铃声(其它机型);7PIN_1_5 无闹钟,alarm_play_flag 通常为 0 - if url and alarm: - plan.append({"op": OP_ALARM_VOICE}) - elif not tts and url and not music: - plan.append({"op": OP_VOICE_URL}) - elif tts and not url: - plan.append({"op": OP_SYNTHESIS}) - elif tts and url and not music: - try: - his = EventMesh.publish("get_history_voice_flag") == 1 if EventMesh else False - except Exception: - his = False - if his: - try: - wait = EventMesh.publish("get_wait_voice_flag") == 0 if EventMesh else True - except Exception: - wait = True - if wait: - plan.append({"op": OP_SYNTHESIS_HIS_TIMER}) - else: - try: - notice = getattr(vc, "pwk_notice_flag", 0) == 0 - except Exception: - notice = True - if notice: - plan.append({"op": OP_SYNTHESIS_WAIT_TIMER}) - else: - plan.append({"op": OP_SYNTHESIS_WAIT_SINGLE}) - else: - plan.append({"op": OP_SYNTHESIS_VOICE_TIMER}) - elif tts and url and music: - plan.append({"op": OP_SYNTHESIS_MUSIC_TIMER}) - else: - try: - if "vc_config" in globals() and vc_config and vc_config.get_bool( - "UPLOAD_START_TONE_ENABLED", False - ): - plan.append({"op": OP_UPLOAD_TONE}) - except Exception: - pass - try: - if getattr(vc, "VC_mode", 0) == 0: - plan.append({"op": OP_IDLE_UPLINK}) - else: - plan.append({"op": OP_PTT_ROUND}) - except Exception: - plan.append({"op": OP_IDLE_UPLINK}) - return plan +# -*- coding: utf-8 -*- +"""通知通道:server.started 媒体排队、play_tts 与对话会话分流。""" +try: + from vc_fsm import VCTurnState +except Exception: + VCTurnState = None +try: + import vc_config +except Exception: + vc_config = None + +# play_tts 通道 +CHANNEL_TAKEOVER = "takeover" # 断连重连 WS synthesis(IoT 查找设备/模式切换/无对话) +CHANNEL_DEFER = "defer" # 对话进行中排队,空闲后播放(is_close_VC=0 的 IoT 查询等) + +# TTS 业务种类(_pending_tts_kind / play_tts 第三参数) +TTS_KIND_FIND_DEVICE = "find_device" +TTS_KIND_MODE_SWITCH_LONG = "mode_switch_long" +TTS_KIND_MODE_SWITCH_PTT = "mode_switch_ptt" +TTS_KIND_BOOT_PROMPT = "boot_prompt" +TTS_KIND_PROMPT = "prompt" + +# 须立即占用会话、不可 defer +_TAKEOVER_KINDS = ( + TTS_KIND_FIND_DEVICE, + TTS_KIND_MODE_SWITCH_LONG, + TTS_KIND_MODE_SWITCH_PTT, + TTS_KIND_BOOT_PROMPT, + TTS_KIND_PROMPT, +) + +# server.started plan op +OP_LONG_UPLINK = "long_uplink" +OP_ALARM_VOICE = "alarm_voice" +OP_VOICE_URL = "voice_url" +OP_SYNTHESIS = "synthesis" +OP_SYNTHESIS_HIS_TIMER = "synthesis_his_timer" +OP_SYNTHESIS_WAIT_TIMER = "synthesis_wait_timer" +OP_SYNTHESIS_WAIT_SINGLE = "synthesis_wait_single" +OP_SYNTHESIS_VOICE_TIMER = "synthesis_voice_timer" +OP_SYNTHESIS_MUSIC_TIMER = "synthesis_music_timer" +OP_IDLE_UPLINK = "idle_uplink" +OP_PTT_ROUND = "ptt_round" +OP_UPLOAD_TONE = "upload_tone" + + +def dialog_session_active(vc): + """对话占用中:通知类 play_tts 应 defer。""" + try: + if getattr(vc, "Close_flag", 0) == 1: + return False + if getattr(vc, "VC_satrt_flag", 0) != 1: + return False + if getattr(vc, "AI_client_VC_isconnect", 0) != 1: + return False + if getattr(vc, "VC_Play_flag", 0) == 1: + return True + if getattr(vc, "VC_record_state_flag", 0) != 0: + return True + # 7PIN_1_5 无闹钟业务;alarm_play_flag 仅 legacy(如 play_single_voice) + if getattr(vc, "music_play_flag", 0) == 1: + return True + if VCTurnState is not None and getattr(vc, "fsm", None) is not None: + t = getattr(vc.fsm, "turn", VCTurnState.Idle) + if t not in (VCTurnState.Idle, VCTurnState.Listening): + return True + except Exception: + pass + return False + + +class NotifyScheduler(object): + @staticmethod + def classify_play_tts(vc, is_close_vc): + try: + if is_close_vc: + return CHANNEL_TAKEOVER + except Exception: + pass + try: + k = getattr(vc, "_pending_tts_kind", "") or getattr(vc, "_last_tts_kind", "") or "" + if k in _TAKEOVER_KINDS: + return CHANNEL_TAKEOVER + except Exception: + pass + if dialog_session_active(vc): + return CHANNEL_DEFER + return CHANNEL_TAKEOVER + + @staticmethod + def plan_on_started(vc, skip_long_uplink=0): + """替代 vc_session_io started 内 if-elif 媒体分支。""" + plan = [] + try: + import EventMesh + except Exception: + EventMesh = None + + tts = getattr(vc, "AI_TTS_str", "") or "" + # 模式切换提示音播放期间才抑制 started 自动开麦;Close 后 kind 残留不得挡 PTT 长按 + ms_hint_only = False + try: + k0 = getattr(vc, "_pending_tts_kind", "") or getattr(vc, "_last_tts_kind", "") or "" + if k0 in (TTS_KIND_MODE_SWITCH_LONG, TTS_KIND_MODE_SWITCH_PTT): + if getattr(vc, "Close_flag", 0) == 1: + ms_hint_only = True + else: + ph = int(getattr(vc, "_vc_ms_phase", 0) or 0) + if ph in (1, 2, 3): + ms_hint_only = True + except Exception: + ms_hint_only = False + + # 长对话:started 补开麦(模式切换提示音播完前勿自动开录) + try: + if ( + not skip_long_uplink + and not ms_hint_only + and getattr(vc, "VC_mode", 0) == 0 + and getattr(vc, "Close_flag", 0) == 0 + and not getattr(vc, "_vc_boot_standby", 0) + ): + plan.append({"op": OP_LONG_UPLINK}) + except Exception: + pass + url = getattr(vc, "AI_new_voice_url", "") or "" + alarm = getattr(vc, "alarm_play_flag", 0) == 1 + music = getattr(vc, "music_play_flag", 0) == 1 + + # 闹钟铃声(其它机型);7PIN_1_5 无闹钟,alarm_play_flag 通常为 0 + if url and alarm: + plan.append({"op": OP_ALARM_VOICE}) + elif not tts and url and not music: + plan.append({"op": OP_VOICE_URL}) + elif tts and not url: + plan.append({"op": OP_SYNTHESIS}) + elif tts and url and not music: + try: + his = EventMesh.publish("get_history_voice_flag") == 1 if EventMesh else False + except Exception: + his = False + if his: + try: + wait = EventMesh.publish("get_wait_voice_flag") == 0 if EventMesh else True + except Exception: + wait = True + if wait: + plan.append({"op": OP_SYNTHESIS_HIS_TIMER}) + else: + try: + notice = getattr(vc, "pwk_notice_flag", 0) == 0 + except Exception: + notice = True + if notice: + plan.append({"op": OP_SYNTHESIS_WAIT_TIMER}) + else: + plan.append({"op": OP_SYNTHESIS_WAIT_SINGLE}) + else: + plan.append({"op": OP_SYNTHESIS_VOICE_TIMER}) + elif tts and url and music: + plan.append({"op": OP_SYNTHESIS_MUSIC_TIMER}) + else: + try: + if "vc_config" in globals() and vc_config and vc_config.get_bool( + "UPLOAD_START_TONE_ENABLED", False + ): + plan.append({"op": OP_UPLOAD_TONE}) + except Exception: + pass + try: + if ms_hint_only: + pass + elif getattr(vc, "VC_mode", 0) == 0: + plan.append({"op": OP_IDLE_UPLINK}) + else: + plan.append({"op": OP_PTT_ROUND}) + except Exception: + if not ms_hint_only: + plan.append({"op": OP_IDLE_UPLINK}) + return plan diff --git a/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_present.py b/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_present.py index 7e268320..d987e3de 100644 --- a/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_present.py +++ b/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_present.py @@ -71,18 +71,19 @@ def present_to_led_msg(vc, present=None): """返回传给 set_led_state 的 dict;不发布。""" if present is None: present = build_present_state(vc) - if present.get("override_ai"): - msg = {"ai_state": present["override_ai"]} - if present.get("override_prefer_off") or present.get("prefer_off"): - msg["prefer_off"] = 1 - return msg if present.get("force_idle"): msg = {"ai_state": "idle"} if present.get("prefer_off"): msg["prefer_off"] = 1 return msg + # 录音/开麦锁:优先于陈旧 thinking override(单对话长按须保持蓝快闪) if present.get("hold_listening"): return {"ai_state": "listening"} + if present.get("override_ai"): + msg = {"ai_state": present["override_ai"]} + if present.get("override_prefer_off") or present.get("prefer_off"): + msg["prefer_off"] = 1 + return msg if present.get("ptt_await_server") and int(present.get("mode", 0)) == 1: return {"ai_state": "thinking"} if present.get("stream_active"): 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..0d1a5b2c 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 @@ -16,6 +16,414 @@ from vc_notify import ( ) from vc_mode_strategy import strategy_for_mode +# 长对话轮次守卫(内嵌,勿拆独立 .py,避免烧录漏文件导致 ai_VC 导入失败、无 token) +_POST_INTERRUPT_DROP_EVENTS = ( + "vad.silence", "vad.active", "asr.result", "asr.completed", + "llm.created", "llm.completed", "llm.done", "llm.delta", "completed", +) +# 录音中 key.restart:仅 cancel→滴声→开麦 窗口内丢弃(勿用 post_interrupt,否则会误杀新一句 ASR) +_RECORD_RESTART_STALE_DROP_EVENTS = ( + "vad.silence", "vad.active", "asr.started", "asr.result", "asr.completed", + "llm.created", "llm.completed", "llm.done", "llm.delta", + "tts.started", "tts.completed", "completed", "canceled", +) + +# 单对话 PTT 轮次:0=idle 1=arming(等 started) +PTT_ROUND_IDLE = 0 +PTT_ROUND_ARMING = 1 + + +def _vc_guard_cfg_int(key, default): + try: + if vc_config: + return int(vc_config.get_int(key, default)) + except Exception: + pass + return default + + +def _vc_guard_cfg_bool(key, default): + try: + if vc_config: + return vc_config.get_bool(key, default) + except Exception: + pass + return default + + +class VCTurnGuard(object): + def __init__(self, vc): + self.vc = vc + + def late_tts_wait_ms(self): + return _vc_guard_cfg_int("LLM_LATE_TTS_WAIT_MS", 3500) + + def interrupt_recover_ms(self): + return _vc_guard_cfg_int("INTERRUPT_ASR_RECOVER_MS", 8000) + + def vad_silence_grace_ms(self): + return _vc_guard_cfg_int("INTERRUPT_VAD_SILENCE_GRACE_MS", 3000) + + def min_uplink_ms(self): + return _vc_guard_cfg_int("INTERRUPT_ASR_MIN_UPLINK_MS", 2000) + + def _post_interrupt_active(self): + v = self.vc + return ( + getattr(v, "_vc_post_interrupt_until_asr", 0) == 1 + or getattr(v, "_vc_suppress_stale_server_turn", 0) == 1 + or getattr(v, "_vc_stale_tts_until_asr_started", 0) == 1 + ) + + def enter_post_interrupt(self): + v = self.vc + v._vc_post_interrupt_until_asr = 1 + v._vc_suppress_stale_server_turn = 1 + v._vc_stale_tts_until_asr_started = 1 + + def clear_post_interrupt(self): + v = self.vc + v._vc_post_interrupt_until_asr = 0 + v._vc_suppress_stale_server_turn = 0 + v._vc_stale_tts_until_asr_started = 0 + + def _record_restart_stale_drop_ms(self): + return _vc_guard_cfg_int("RECORD_RESTART_STALE_DROP_MS", 1500) + + def arm_record_restart_stale_drop(self): + v = self.vc + try: + v._vc_record_restart_drop_until_ms = utime.ticks_add( + utime.ticks_ms(), self._record_restart_stale_drop_ms()) + print("[VC][guard] record_restart stale_drop_ms={}".format( + self._record_restart_stale_drop_ms())) + except Exception: + v._vc_record_restart_drop_until_ms = 0 + + def clear_record_restart_stale_drop(self, tag=""): + v = self.vc + if not getattr(v, "_vc_record_restart_drop_until_ms", 0): + return + v._vc_record_restart_drop_until_ms = 0 + if tag: + try: + print("[VC][guard] record_restart stale_drop clear ({})".format(tag)) + except Exception: + pass + + def record_restart_stale_drop_active(self): + until = int(getattr(self.vc, "_vc_record_restart_drop_until_ms", 0) or 0) + if not until: + return False + try: + if utime.ticks_diff(until, utime.ticks_ms()) > 0: + return True + except Exception: + pass + self.clear_record_restart_stale_drop("expired") + return False + + def should_drop_record_restart_stale(self, ev): + if not self.record_restart_stale_drop_active(): + return False + return ev in _RECORD_RESTART_STALE_DROP_EVENTS + + def should_drop_server_event(self, ev): + if self.should_drop_record_restart_stale(ev): + return True + if self._post_interrupt_active() and ev in _POST_INTERRUPT_DROP_EVENTS: + return True + if ev == "vad.silence" and self.should_ignore_vad_silence(): + return True + return False + + def should_stale_tts(self): + if not self._is_long(): + return False + v = self.vc + if self._post_interrupt_active(): + return True + if getattr(v, "_vc_pending_interrupt_uplink", 0) == 1: + return True + return False + + def should_drop_inbound_tts_pcm(self): + v = self.vc + if not self._is_long(): + return False + if self._post_interrupt_active(): + return True + if getattr(v, "_drop_tts_audio", 0) == 1: + return True + if getattr(v, "_vc_pending_interrupt_uplink", 0) == 1: + return True + if getattr(v, "is_ruppt_tts", 0) == 1: + return True + settled = getattr(v, "_long_dialogue_interrupt_settled_ms", 0) + if settled: + try: + win = _vc_guard_cfg_int("LONG_DIALOGUE_DROP_TTS_MS", 8000) + if utime.ticks_diff(utime.ticks_ms(), settled) < win: + return True + except Exception: + pass + return False + + def arm_interrupt_recover(self, tag=""): + v = self.vc + now = utime.ticks_ms() + v._vc_interrupt_recover_until_ms = utime.ticks_add( + now, self.interrupt_recover_ms()) + v._vc_ignore_vad_silence_until_ms = utime.ticks_add( + now, self.vad_silence_grace_ms()) + v._vc_interrupt_asr_min_uplink_ms = self.min_uplink_ms() + def in_interrupt_recover(self): + until = int(getattr(self.vc, "_vc_interrupt_recover_until_ms", 0) or 0) + return bool(until and utime.ticks_diff(until, utime.ticks_ms()) > 0) + + def should_ignore_vad_silence(self): + until = int(getattr(self.vc, "_vc_ignore_vad_silence_until_ms", 0) or 0) + return bool(until and utime.ticks_diff(until, utime.ticks_ms()) > 0) + + def should_play_record_start_tone(self, reason): + r = str(reason or "") + if r.startswith("asr.empty"): + return False + # 用户打断 TTS 后 cancel 开麦:播 start.mp3(与 key.press 一致);迟到 canceled 不播 + if r == "canceled" or r.startswith("canceled/"): + if getattr(self.vc, "_vc_interrupt_record_start_tone", 0) == 1: + return True + if getattr(self.vc, "_vc_pending_interrupt_uplink", 0) == 1: + return True + return False + if self.vc._vc_uplink_reason_is_user_key(reason): + return True + if r.startswith("server.started") and getattr(self.vc, "_long_dialogue_key_arm", 0) == 1: + return True + return False + + def asr_empty_use_soft_recover(self): + if not self.in_interrupt_recover(): + return False + v = self.vc + min_ms = int(getattr(v, "_vc_interrupt_asr_min_uplink_ms", 0) or self.min_uplink_ms()) + dur = int(getattr(v, "_vc_last_uplink_dur_ms", -1)) + if dur >= 0 and dur < min_ms: + return True + if getattr(v, "_turn_has_asr_result", 0) != 1: + return True + return False + + def clear_interrupt_recover(self): + v = self.vc + v._vc_interrupt_recover_until_ms = 0 + v._vc_ignore_vad_silence_until_ms = 0 + + def arm_await_late_tts(self, reason="completed"): + self.vc._vc_await_late_tts_until_ms = utime.ticks_add( + utime.ticks_ms(), self.late_tts_wait_ms()) + try: + print("[VC][guard] late_tts arm reason={} wait_ms={}".format( + reason, self.late_tts_wait_ms())) + except Exception: + pass + + def awaiting_late_tts(self, now_ms=None): + until = int(getattr(self.vc, "_vc_await_late_tts_until_ms", 0) or 0) + if not until: + return False + if now_ms is None: + now_ms = utime.ticks_ms() + return utime.ticks_diff(until, now_ms) > 0 + + def clear_late_tts(self, tag=""): + v = self.vc + v._vc_await_late_tts_until_ms = 0 + v._vc_llm_without_tts_round = 0 + if tag: + try: + print("[VC][guard] late_tts clear {}".format(tag)) + except Exception: + pass + + def on_llm_without_tts_completed(self): + self.vc._vc_llm_without_tts_round = 1 + if not self.try_llm_synthesis_fallback("completed/llm_without_tts"): + self.arm_await_late_tts("llm_without_tts") + + def try_llm_synthesis_fallback(self, reason="completed"): + v = self.vc + if not self._is_long(): + return False + if getattr(v, "_turn_has_tts_started", 0) == 1: + return False + if getattr(v, "_tts_pending", 0) == 1: + return False + if not _vc_guard_cfg_bool("LLM_WITHOUT_TTS_SYNTHESIS_FALLBACK", True): + return False + now = utime.ticks_ms() + last = getattr(v, "_vc_llm_synth_fallback_ms", 0) or 0 + if last and utime.ticks_diff(now, last) < 3000: + return False + text = (getattr(v, "_llm_reply_text", "") or "").strip() + if not text: + self.arm_await_late_tts(reason) + return False + v._vc_llm_synth_fallback_ms = now + v._vc_llm_without_tts_round = 0 + try: + print("[VC][guard] llm_fallback synthesis len={} reason={}".format( + len(text), reason)) + except Exception: + pass + v.AI_TTS_str = text + v._pending_tts_kind = "dialog" + v._last_tts_kind = "dialog" + v.request_send_synthesis(text) + return True + + def late_tts_wait_tick(self, now_ms): + until = int(getattr(self.vc, "_vc_await_late_tts_until_ms", 0) or 0) + if not until: + return + if utime.ticks_diff(now_ms, until) < 0: + return + self.vc._vc_await_late_tts_until_ms = 0 + if getattr(self.vc, "_turn_has_tts_started", 0) == 1: + return + if getattr(self.vc, "_tts_pending", 0) == 1: + return + if getattr(self.vc, "_turn_has_asr_result", 0) == 1: + return + try: + if hasattr(self.vc, "_vc_effective_asr_text") and self.vc._vc_effective_asr_text(): + return + except Exception: + pass + hint = "我没有听清,请再说一遍" + try: + if vc_config: + hint = str(vc_config.get_attr("ASR_EMPTY_HINT_TTS", hint) or hint).strip() + except Exception: + pass + if not hint: + return + try: + print("[VC][guard] late_tts timeout -> synthesis hint") + except Exception: + pass + v = self.vc + v._pending_tts_kind = "asr_empty_hint" + v._last_tts_kind = "asr_empty_hint" + v.request_send_synthesis(hint) + + def reset_turn_flags(self, reason=""): + v = self.vc + v._turn_has_asr_completed = 0 + v._turn_has_vad_silence = 0 + v._turn_has_tts_started = 0 + v._turn_has_asr_result = 0 + v._llm_completed_ms = 0 + v._llm_reply_text = "" + v._vc_defer_vad_uplink_stop = 0 + v._server_vad_silence = 0 + v._vc_vad_had_active_after_silence = 0 + v._vc_llm_without_tts_round = 0 + v._vc_await_late_tts_until_ms = 0 + self.clear_interrupt_recover() + v._asr_completed_tone_done = 0 + try: + print("[VC][guard] reset_turn_flags reason={}".format(reason)) + except Exception: + pass + + def on_long_interrupt(self): + self.reset_turn_flags("long_interrupt") + self.enter_post_interrupt() + self.arm_interrupt_recover("long_interrupt") + try: + self.vc._vc_interrupt_record_start_tone = 1 + self.vc._vc_interrupt_cancel_uplink_armed = 0 + self.vc._vc_defer_record_for_tone = 0 + except Exception: + pass + + def on_record_restart(self): + """录音中重录:turn å·² bump;短窗口丢 cancel 前迟到下行;开麦后正常收 ASR。""" + v = self.vc + self.reset_turn_flags("key.restart") + self.arm_record_restart_stale_drop() + self.arm_interrupt_recover("key.restart") + self.purge_stale_tts("key.restart") + try: + v.AI_ASR_str = "" + v.AI_ASR_str_tmp = "" + v._last_asr_result_text = "" + except Exception: + pass + + def should_preserve_tts_drop_window(self): + if self._post_interrupt_active(): + return True + v = self.vc + if getattr(v, "_vc_pending_interrupt_uplink", 0) == 1: + return True + if getattr(v, "is_ruppt_tts", 0) == 1: + return True + if getattr(v, "_drop_tts_audio", 0) == 1: + return True + return False + + def on_asr_started(self): + self.clear_post_interrupt() + self.clear_interrupt_recover() + + def on_asr_result(self, text): + if (text or "").strip(): + self.clear_interrupt_recover() + + def purge_stale_tts(self, tag=""): + v = self.vc + try: + v._drop_tts_audio = 1 + q = getattr(v, "AI_audio_queue", None) + if q is not None and q.empty() != True: + q.clear() + if getattr(v, "VC_Play_flag", 0) == 1 or getattr(v, "play_pcm", None) is not None: + try: + v.VC_Play_End() + except Exception: + pass + if getattr(v, "play_pcm", None) is not None: + try: + v.play_pcm.stream_stop() + v.play_pcm.close() + except Exception: + pass + v.play_pcm = None + v.fsm.await_played = False + v._need_audio_played_ack = 0 + try: + v.fsm.mark_tts_rx_idle() + if getattr(v.fsm, "session", None) == VCSessionState.Started: + v.fsm.turn = VCTurnState.Listening + except Exception: + pass + v._turn_has_tts_started = 0 + try: + print("[VC][guard] purge_stale_tts ({})".format(tag)) + except Exception: + pass + except Exception: + pass + + def _is_long(self): + try: + return getattr(self.vc, "VC_mode", -1) == 0 + except Exception: + return False + + class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract): def _vc_fsm_log_snip(self): try: @@ -45,6 +453,97 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract): except Exception: return False + def _ptt_in_interrupt_wait(self): + if not self._vc_is_ptt_mode(): + return False + try: + return ( + getattr(self, "_ptt_interrupt_active", 0) == 1 + or getattr(self, "_vc_serial_canceling", 0) == 1 + or getattr(self, "_ptt_pending_arm", 0) == 1 + ) + except Exception: + return False + + def _ptt_release_interrupt_settle(self): + """TTS 打断长按后松手且未开录:停播、单对话待机,不 Close WS。""" + try: + self.AI_key_long_press_flag = 0 + self._ptt_force_record_on_started = 0 + self._ptt_round_phase = PTT_ROUND_IDLE + self._ptt_skip_arm_on_cancel = 1 + self._ptt_pending_arm = 0 + self._need_audio_played_ack = 0 + try: + self.fsm.await_played = False + except Exception: + pass + self._ptt_hard_mute_local_playback() + self._ptt_led_lock_listening = 0 + self._vc_play_end_suppress_idle_led = 0 + self._ptt_idle_latch = 1 + self._vc_publish_led("idle", "ptt_intr_rel", prefer_off=1) + except Exception: + pass + + def _ptt_round_should_arm_on_started(self): + if not self._vc_is_ptt_mode(): + return False + try: + if getattr(self, "AI_key_long_press_flag", 0) != 1: + return False + if getattr(self, "_ptt_force_record_on_started", 0) != 1: + return False + ph = int(getattr(self, "_ptt_round_phase", PTT_ROUND_IDLE)) + if ph == PTT_ROUND_ARMING: + return True + if ph == PTT_ROUND_IDLE: + self._ptt_round_phase = PTT_ROUND_ARMING + return True + return False + except Exception: + return False + + def _ptt_round_abort(self, reason="", close_session=False, publish_idle=True): + if not self._vc_is_ptt_mode(): + return + try: + self._ptt_round_phase = PTT_ROUND_IDLE + self.AI_key_long_press_flag = 0 + self._ptt_force_record_on_started = 0 + self._ptt_defer_speech_until_asr = 0 + self._ptt_audio_created = 0 + self._ptt_record_open_ms = 0 + self._ptt_pending_arm = 0 + self._ptt_short_press_stop = 0 + self._ptt_abort_interrupt_arm() + self._ptt_idle_latch = 0 + self._ptt_await_server = 0 + except Exception: + pass + if publish_idle: + try: + self._vc_publish_led("idle", "ptt_abort") + except Exception: + pass + if close_session and getattr(self, "VC_record_state_flag", 0) == 0: + try: + if getattr(self, "VC_satrt_flag", 0) == 1 or getattr( + self, "AI_client_VC_isconnect", 0 + ) == 1: + self._vc_enqueue_btn(self._ptt_abort_close_btn, priority=1) + except Exception: + pass + + def _ptt_abort_close_btn(self, event=None, msg=None): + if getattr(self, "VC_record_state_flag", 0) != 0: + return + try: + self._vc_close_reason = "ptt_abort" + self.Close() + except Exception: + pass + def _vc_reset_dialogue_mode_state(self): """Close/模式切换时清零双模式按键与灯锁,避免残留互相影响。""" try: @@ -60,6 +559,8 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract): self._ptt_force_record_on_started = 0 self._ptt_await_server = 0 self._ptt_audio_created = 0 + self._ptt_round_phase = PTT_ROUND_IDLE + self._ptt_skip_arm_on_cancel = 0 except Exception: pass try: @@ -74,6 +575,11 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract): self._led_override = None except Exception: pass + try: + self._last_tts_kind = "" + self._pending_tts_kind = "" + except Exception: + pass try: self._drop_tts_audio = 0 self.is_ruppt_tts = 0 @@ -126,6 +632,9 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract): if not self._vc_is_long_dialogue_mode(): return True try: + r = str(reason or "") + if r.startswith("asr.empty/recover"): + return True if getattr(self, "_vc_pending_interrupt_uplink", 0) == 1: return True # TTS 播完进入下一轮:play_end 时 PCM 尚未 close,勿用 pipeline_busy 拒开麦 @@ -160,17 +669,69 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract): return False def _vc_flush_deferred_vad_uplink_stop(self, reason="asr.completed"): - """长对话:asr.completed 再停麦,避免句中 vad.silence 把一句话切成两段。""" + """长对话:vad.silence å·² silence_gate æ­¢ PCM;asr.completed 再关麦/停录线程。""" try: - if not getattr(self, "_vc_defer_vad_uplink_stop", 0): + had_defer = getattr(self, "_vc_defer_vad_uplink_stop", 0) == 1 + if had_defer: + self._vc_defer_vad_uplink_stop = 0 + if getattr(self, "_server_vad_silence", 0) != 1: + self._server_vad_silence = 1 + if getattr(self, "VC_record_state_flag", 0) == 0: return - self._vc_defer_vad_uplink_stop = 0 - self._server_vad_silence = 1 - if getattr(self, "VC_record_state_flag", 0) != 0 and self._vc_vad_uplink_stop_eligible(): - self.request_uplink_stop(reason) + if not self._vc_vad_uplink_stop_eligible(): + return + if had_defer or self._vc_is_long_dialogue_mode(): + # 编排线程内同步停麦;勿 request_uplink_stop 入队(与 llm.created/canceled 竞态) + self._uplink_request_stop(reason) + except Exception: + pass + + def _vc_capture_llm_reply_text(self, jsload_data, append=False): + try: + payload = jsload_data.get("payload", {}) if jsload_data else {} + c = (payload.get("content") or payload.get("text") or "").strip() + if not c: + return + if append: + self._llm_reply_text = (getattr(self, "_llm_reply_text", "") or "") + c + else: + self._llm_reply_text = c except Exception: pass + def _vc_try_llm_reply_synthesis_fallback(self, reason="completed"): + return self._turn_guard.try_llm_synthesis_fallback(reason) + + def _vc_arm_await_late_tts(self, reason="completed"): + self._turn_guard.arm_await_late_tts(reason) + + def _vc_awaiting_late_tts(self, now_ms=None): + return self._turn_guard.awaiting_late_tts(now_ms) + + def _vc_clear_await_late_tts(self, tag=""): + self._turn_guard.clear_late_tts(tag) + + def _vc_arm_interrupt_asr_recover(self, tag=""): + self._turn_guard.arm_interrupt_recover(tag) + + def _vc_in_interrupt_asr_recover(self): + return self._turn_guard.in_interrupt_recover() + + def _vc_should_ignore_vad_silence(self): + return self._turn_guard.should_ignore_vad_silence() + + def _vc_asr_empty_soft_recover(self): + return self._turn_guard.asr_empty_use_soft_recover() + + def _vc_should_drop_record_restart_stale(self, ev): + g = getattr(self, "_turn_guard", None) + if g is not None: + return g.should_drop_record_restart_stale(ev) + return False + + def _vc_late_tts_wait_tick(self, now_ms): + self._turn_guard.late_tts_wait_tick(now_ms) + def _long_dialogue_media_busy(self): """长对话:TTS/思考/播报中(按键须走打断,勿误走 continue)。""" try: @@ -208,29 +769,30 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract): pass return 8000 + def _long_dialogue_is_stale_server_tts(self): + return self._turn_guard.should_stale_tts() + + def _vc_purge_stale_tts_round(self, tag=""): + self._turn_guard.purge_stale_tts(tag) + def _long_dialogue_should_drop_inbound_tts(self): - """长对话打断窗口:丢弃迟到 TTS 帧 / 禁止起播。""" - if not self._vc_is_long_dialogue_mode(): - return False - try: - if getattr(self, "_drop_tts_audio", 0) == 1: - return True - if getattr(self, "_vc_pending_interrupt_uplink", 0) == 1: - return True - if getattr(self, "is_ruppt_tts", 0) == 1: - return True - settled = getattr(self, "_long_dialogue_interrupt_settled_ms", 0) - if settled and utime.ticks_diff(utime.ticks_ms(), settled) < self._long_dialogue_drop_tts_window_ms(): - return True - except Exception: - pass - return False + return self._turn_guard.should_drop_inbound_tts_pcm() def _long_dialogue_on_server_tts_started(self): """新一轮服务端 TTS:结束打断丢弃窗口,允许 VC_Play_Start 与下行写入。""" if not self._vc_is_long_dialogue_mode(): return try: + self._vc_clear_await_late_tts("") + except Exception: + pass + try: + if hasattr(self, "_long_dialogue_is_stale_server_tts") and self._long_dialogue_is_stale_server_tts(): + try: + print("[VC][long] tts.started stale (recording/interrupt) -> ignore") + except Exception: + pass + return if getattr(self, "_vc_pending_interrupt_uplink", 0) == 1: return if getattr(self, "_intr", None) is not None: @@ -242,6 +804,43 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract): except Exception: pass + def _ptt_is_stale_server_cancel(self): + """PTT 真开录后迟到的 canceled(上一轮 cancel/提示音 TTS 回声),勿停录。""" + if not self._vc_is_ptt_mode(): + return False + try: + if getattr(self, "_vc_serial_canceling", 0) == 1: + return False + if getattr(self, "_ptt_pending_arm", 0) == 1: + return False + if getattr(self, "_ptt_interrupt_active", 0) == 1: + return False + if getattr(self, "VC_record_state_flag", 0) == 0: + return False + return True + except Exception: + return False + + def _ptt_handle_stale_cancel(self): + """仅清迟到 TTS/播放下行,保持 PTT 录音与蓝快闪。""" + if not self._vc_is_ptt_mode(): + return + try: + self._drop_tts_audio = 1 + if getattr(self, "AI_audio_queue", None) is not None: + if self.AI_audio_queue.empty() != True: + self.AI_audio_queue.clear() + self._ptt_hard_mute_local_playback() + if getattr(self, "VC_Play_flag", 0) == 1 or getattr(self, "play_pcm", None) is not None: + self.VC_Play_End() + self.fsm.await_played = False + self._need_audio_played_ack = 0 + self.fsm.on_server_canceled() + self._ptt_publish_listening_fast("canceled/stale") + print("[PTT] stale canceled -> keep recording") + except Exception: + pass + def _long_dialogue_is_stale_server_cancel(self): """打断已开麦后迟到的 canceled:勿停录/勿灭灯(多次短按常见)。""" if not self._vc_is_long_dialogue_mode(): @@ -249,6 +848,20 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract): try: if getattr(self, "_vc_pending_interrupt_uplink", 0) == 1: return False + # å·² asr.completed、等 LLM/TTS 期间的迟到 cancel 视为残留 + if ( + getattr(self, "_turn_has_asr_completed", 0) == 1 + and getattr(self, "_turn_has_tts_started", 0) == 0 + ): + t = getattr(self.fsm, "turn", VCTurnState.Idle) + if t in ( + VCTurnState.SpeechEnded, + VCTurnState.Thinking, + VCTurnState.TtsReceiving, + ): + return True + if getattr(self, "_llm_completed_ms", 0): + return True if getattr(self, "VC_record_state_flag", 0) == 0: return False settled = getattr(self, "_long_dialogue_interrupt_settled_ms", 0) @@ -287,6 +900,7 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract): """PTT 长按:等 started / asr.started 后开录,无开录提示音(REQ-AI-07)。""" try: self._ptt_force_record_on_started = 1 + self._ptt_round_phase = PTT_ROUND_ARMING except Exception: pass @@ -386,7 +1000,12 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract): and str(reason).find("canceled") >= 0 and self._long_dialogue_is_stale_server_cancel() ) - if stale_cancel: + ptt_stale_cancel = ( + self._vc_is_ptt_mode() + and str(reason).find("canceled") >= 0 + and self._ptt_is_stale_server_cancel() + ) + if stale_cancel or ptt_stale_cancel: pass elif ( not pending_intr @@ -586,7 +1205,7 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract): pass try: if getattr(self, "AI_client_VC_isconnect", 0) == 1: - self.websocket_deconnect_VC() + self.Close_reconnect() except Exception: pass try: @@ -753,41 +1372,85 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract): pass elif op == OP_IDLE_UPLINK: self.request_uplink_start("server.started/idle") - elif op == OP_PTT_ROUND: - _ptt_rec = getattr(self, "_ptt_force_record_on_started", 0) == 1 - if _ptt_rec: - try: - self._ptt_force_record_on_started = 0 - except Exception: - pass - try: - self.AI_client_VC_send_speech() - except Exception: - self.request_send_audio_create() - try: - if _ptt_rec: - self._vc_user_record_entry("ptt_begin/started") - except Exception: - pass + + def _vc_on_server_started_plan(self, plan): + if not plan: + return + rest = [] + ptt_arm = 0 + for item in plan: + try: + op = item.get("op") if isinstance(item, dict) else item + except Exception: + continue + if op == OP_PTT_ROUND: + ptt_arm = 1 + else: + rest.append(item) + if rest: + self._vc_apply_notify_started_plan(rest) + if ptt_arm and self._vc_is_ptt_mode(): + self._vc_enqueue_btn(self._ptt_started_arm_cb, priority=1) + + def _ptt_started_arm_cb(self, data=None): + if not self._ptt_round_should_arm_on_started(): + return + self._ptt_force_record_on_started = 0 + try: + self.AI_client_VC_send_speech() + except Exception: + self.request_send_audio_create() + try: + self._vc_user_record_entry("ptt_begin/started") + except Exception: + pass def _vc_send_protocol_cancel(self, reason=""): """协议 cancel(Power / 打断)。""" try: if getattr(self, "AI_client_VC_isconnect", 0) == 1 and getattr(self, "_vc_session_started", 0) == 1: self.AI_client_VC_send(ujson.dumps({"event": "cancel", "payload": {}})) - print("[VC] cancel reason={}".format(reason)) except Exception: pass - def _ptt_abort_short_record(self, tts_str): - """PTT 录音过短:cancel + notify TTS + idle。""" + def _vc_asr_empty_end_and_cancel(self): try: - self.AI_key_long_press_flag = 0 - self._ptt_defer_speech_until_asr = 0 + if getattr(self, "VC_record_state_flag", 0): + self.VC_Speech_End() + if getattr(self, "AI_client_VC_isconnect", 0) == 1 and getattr( + self, "_vc_session_started", 0 + ) == 1: + self._vc_send_protocol_cancel("asr.empty") + except Exception: + pass + + def _vc_asr_empty_play_hint(self, hint): + try: + self._pending_tts_kind = self._last_tts_kind = "asr_empty_hint" + self.request_send_synthesis(hint) + except Exception: + pass + + def _ptt_reset_empty_flags(self, soft): + try: + if soft: + self._ptt_abort_interrupt_arm() + self._ptt_round_phase = PTT_ROUND_IDLE + self._turn_guard.clear_interrupt_recover() + self._ptt_await_server = 0 self._ptt_audio_created = 0 + self._ptt_defer_speech_until_asr = 0 self._ptt_record_open_ms = 0 + self.AI_key_long_press_flag = 0 + self._ptt_idle_latch = 1 if soft else 0 + if not soft: + self._vc_reopen_uplink_after_empty_hint = 0 except Exception: pass + + def _ptt_abort_short_record(self, tts_str): + """PTT 录音过短:cancel + notify TTS + idle。""" + self._ptt_round_abort("short_record", close_session=False, publish_idle=False) try: self._vc_send_protocol_cancel("ptt_short_record") except Exception: @@ -821,15 +1484,10 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract): hint = str(cfg).strip() except Exception: pass - try: - print("[VC][ASR] completed empty -> clear + notify ({})".format(reason)) - except Exception: - pass try: self.AI_ASR_str = "" self.AI_ASR_str_tmp = "" self._last_asr_result_text = "" - self._asr_completed_tone_done = 0 except Exception: pass if self._vc_is_long_dialogue_mode(): @@ -837,8 +1495,46 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract): return self._vc_handle_asr_empty_ptt(hint, reason) + def _vc_should_soft_recover_empty_asr(self): + """过短/打断续聊空 ASR:仅重开麦,勿 cancel+synthesis 播「没听清」。""" + if self._vc_asr_empty_soft_recover(): + return True + try: + if self._vc_is_ptt_mode() and getattr(self, "_turn_has_asr_result", 0) != 1: + return True + except Exception: + pass + min_ms = 1500 + try: + if "vc_config" in globals() and vc_config: + min_ms = int(vc_config.get_int("ASR_EMPTY_MIN_UPLINK_MS", min_ms)) + except Exception: + pass + dur = int(getattr(self, "_vc_last_uplink_dur_ms", -1)) + if dur >= 0 and dur < min_ms: + r = str(getattr(self, "_vc_user_record_open_reason", "") or "") + if hasattr(self, "_vc_uplink_reason_is_user_key") and self._vc_uplink_reason_is_user_key(r): + return True + if r.startswith("key."): + return True + return False + def _vc_handle_asr_empty_long(self, hint, reason="asr.completed"): """长对话:空 ASR 提示播完后复用 sid 重新开麦(蓝慢闪)。""" + if self._vc_should_soft_recover_empty_asr(): + try: + self._server_vad_silence = 0 + self._vc_defer_vad_uplink_stop = 0 + self._vc_vad_had_active_after_silence = 0 + self._vc_reopen_uplink_after_empty_hint = 0 + if getattr(self, "VC_record_state_flag", 0): + self.VC_Speech_End() + self._vc_clear_stale_record_state("asr.empty/recover") + self._long_dialogue_publish_listening("asr.empty/recover") + self.request_uplink_start("asr.empty/recover") + except Exception: + pass + return try: self._vc_reopen_uplink_after_empty_hint = 0 if ( @@ -855,56 +1551,22 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract): self._vc_vad_had_active_after_silence = 0 except Exception: pass - try: - if getattr(self, "VC_record_state_flag", 0) != 0: - self.VC_Speech_End() - except Exception: - pass - try: - if getattr(self, "AI_client_VC_isconnect", 0) == 1 and getattr( - self, "_vc_session_started", 0 - ) == 1: - self._vc_send_protocol_cancel("asr.empty") - except Exception: - pass - try: - self._pending_tts_kind = "asr_empty_hint" - self._last_tts_kind = "asr_empty_hint" - self.request_send_synthesis(hint) - print("[VC][ASR] long empty hint -> synthesis, reopen_after=1") - except Exception as e: - usys.print_exception(e) + self._vc_asr_empty_end_and_cancel() + self._vc_asr_empty_play_hint(hint) def _vc_handle_asr_empty_ptt(self, hint, reason="asr.completed"): - """单对话:idle + Notify(不断线重连的轻量路径仍走 play_tts)。""" - try: - self._ptt_await_server = 0 - self._ptt_audio_created = 0 - self._ptt_defer_speech_until_asr = 0 - self._ptt_record_open_ms = 0 - self.AI_key_long_press_flag = 0 - self._ptt_idle_latch = 1 - self._vc_reopen_uplink_after_empty_hint = 0 - except Exception: - pass - try: - if getattr(self, "VC_record_state_flag", 0) != 0: - self.VC_Speech_End() - except Exception: - pass - try: - if getattr(self, "AI_client_VC_isconnect", 0) == 1 and getattr( - self, "_vc_session_started", 0 - ) == 1: - self._vc_send_protocol_cancel("asr.empty") - except Exception: - pass - try: - EventMesh.publish("play_tts", [hint, 0, "prompt"]) - except Exception: - pass + soft = self._vc_should_soft_recover_empty_asr() + self._ptt_reset_empty_flags(soft) + self._vc_asr_empty_end_and_cancel() + if soft: + try: + self._vc_publish_led("idle", "asr.empty", prefer_off=1) + except Exception: + pass + return + self._vc_asr_empty_play_hint(hint) try: - self._vc_publish_led("idle", "asr.empty") + self._vc_publish_led("thinking", "asr.empty") except Exception: pass @@ -938,6 +1600,43 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract): usys.print_exception(e) return False + def _vc_power_short_shutdown(self): + """电源短按:停录 + cancel + 强制 Close(PWR 须能退出,不受模式切换 phase 挡)。""" + try: + from vc_overlay import overlay_set, OVERLAY_IDLE + overlay_set(self, OVERLAY_IDLE) + except Exception: + try: + self._vc_ms_phase = 0 + self._vc_ms_close_pending = 0 + except Exception: + pass + try: + if getattr(self, "VC_record_state_flag", 0) != 0: + self.VC_Speech_End() + except Exception: + pass + try: + ux = None + try: + from vc_ux import derive_ux_state, ux_name + ux = ux_name(derive_ux_state(self)) + except Exception: + ux = "?" + self._vc_send_protocol_cancel("power_short/ux={}".format(ux)) + except Exception: + pass + try: + self._vc_close_reason = "power_short" + except Exception: + pass + try: + print("[KEY] power_short -> Close(force) rec={}".format( + getattr(self, "VC_record_state_flag", -1))) + except Exception: + pass + self.Close(force=True) + def _handle_pwk_short_release(self, event=None, msg=None, merged_ai_tail=False): """电源键短按:读留言/会议;否则按 UX 表 cancel+Close(清 sid)。""" if self._vc_ms_keys_blocked("pwk_release"): @@ -1069,37 +1768,65 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract): pass return False - def _long_dialogue_interrupt_on_short_press(self): - """长对话 TTS/思考中短按:停播 + cancel;canceled(或 cancel 超时)后开麦。""" + def _intr_discard_pending_uplink(self): + """取消「等 canceled 再开麦」;保留 TTS 丢弃窗口(与 on_record_restart 配合)。""" + intr = getattr(self, "_intr", None) + keep_drop = False + try: + g = getattr(self, "_turn_guard", None) + keep_drop = g.should_preserve_tts_drop_window() if g else False + except Exception: + keep_drop = bool(getattr(self, "_drop_tts_audio", 0)) + if intr is not None: + intr.pending_uplink = 0 + intr.active = 0 + intr.cancel_deadline_ms = 0 + if keep_drop: + intr.drop_tts = 1 + intr.ruppt_tts = 1 + intr._sync_legacy(self) + return + try: + self._vc_pending_interrupt_uplink = 0 + self._vc_cancel_deadline_ms = 0 + except Exception: + pass + + def _long_dialogue_invalidate_server_turn(self, tag, wait_canceled_uplink=False): + """作废云端当前轮:turn_bump + 守卫 + 停播 + cancel。wait=True 时等 canceled 再开麦。""" if not self._vc_is_long_dialogue_mode(): return try: - self._vc_turn_bump("long_interrupt") + self._vc_turn_bump(tag) self.Close_flag = 0 self.fsm.await_played = False - wait_ms = 800 - if "vc_config" in globals() and vc_config: - wait_ms = int(vc_config.get_int("LONG_DIALOGUE_CANCEL_WAIT_MS", wait_ms)) - if getattr(self, "_intr", None) is not None: - self._intr.begin( - self, InterruptContext.KIND_LONG_SHORT, - pending_uplink=1, cancel_wait_ms=wait_ms, - ) + if wait_canceled_uplink: + self._turn_guard.on_long_interrupt() + wait_ms = 800 + if "vc_config" in globals() and vc_config: + wait_ms = int(vc_config.get_int("LONG_DIALOGUE_CANCEL_WAIT_MS", wait_ms)) + if getattr(self, "_intr", None) is not None: + self._intr.begin( + self, InterruptContext.KIND_LONG_SHORT, + pending_uplink=1, cancel_wait_ms=wait_ms, + ) + else: + self.is_ruppt_tts = 1 + self._drop_tts_audio = 1 + self._vc_pending_interrupt_uplink = 1 + self._vc_cancel_deadline_ms = utime.ticks_add(utime.ticks_ms(), wait_ms) else: - self.is_ruppt_tts = 1 - self._drop_tts_audio = 1 - self._vc_pending_interrupt_uplink = 1 - self._vc_cancel_deadline_ms = utime.ticks_add(utime.ticks_ms(), wait_ms) + self._turn_guard.on_record_restart() + self._intr_discard_pending_uplink() + self._vc_interrupt_cancel_uplink_armed = 1 except Exception: pass try: - self._vc_stop_downlink_for_interrupt("long_press/interrupt") + self._vc_stop_downlink_for_interrupt(tag) except Exception: pass try: - if getattr(self, "AI_client_VC_isconnect", 0) == 1: - self.AI_client_VC_send(ujson.dumps({"event": "cancel", "payload": {}})) - print("[KEY] long_dialogue interrupt -> cancel") + self._vc_send_protocol_cancel(tag) except Exception: pass try: @@ -1109,37 +1836,73 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract): except Exception: pass - def _ptt_stop_tts_on_short_press(self): - """单对话短按:停本地+下行 TTS、灭灯;发 cancel,不换 sid、不开录。""" + def _long_dialogue_interrupt_on_short_press(self): + """长对话 TTS/思考中短按:停播 + cancel;canceled(或超时)后再开麦。""" + self._long_dialogue_invalidate_server_turn( + "long_interrupt", wait_canceled_uplink=True) + try: + print("[KEY] long_dialogue interrupt -> cancel") + except Exception: + pass + + def _ptt_short_press_interrupt_restart(self): + """单对话 TTS/思考中短按松手:打断下行并进入新一轮录音(同长按打断 cancel→开麦)。""" if not self._vc_is_ptt_mode(): return - self._ptt_short_press_stop = 1 - self._ptt_idle_latch = 1 try: - if self._vc_ptt_need_interrupt_wait() or self._vc_tts_pipeline_busy(): - self._vc_turn_bump("ptt_short_stop") - self.is_ruppt_tts = 1 - self._drop_tts_audio = 1 - self._ptt_interrupt_active = 0 - self._ptt_led_lock_listening = 0 + self._ptt_short_press_stop = 0 + self._ptt_idle_latch = 0 + except Exception: + pass + try: self.fsm.await_played = False except Exception: pass try: - self._vc_stop_downlink_for_interrupt("ptt_short_press") + if getattr(self, "VC_record_state_flag", 0) != 0: + self.VC_Speech_End() except Exception: pass try: - if getattr(self, "AI_client_VC_isconnect", 0) == 1 and getattr(self, "_vc_session_started", 0) == 1: - json_data = {"event": "cancel", "payload": {}} - self.AI_client_VC_send(ujson.dumps(json_data)) - print("[PTT] short_press stop_tts -> cancel") + self._ptt_enter_interrupt_arm(bump_gen=True) + except Exception: + pass + try: + self._vc_stop_downlink_for_interrupt("ptt_short_interrupt") + except Exception: + pass + try: + if getattr(self, "AI_client_VC_isconnect", 0) != 1: + self._vc_ptt_mark_record_after_started() + self.AI_VC_start() + print("[PTT] short_interrupt -> AI_VC_start (no ws)") + return + except Exception: + pass + try: + if getattr(self, "_vc_session_started", 0) != 1: + self._vc_ptt_mark_record_after_started() + self.AI_client_VC_start_requset() + print("[PTT] short_interrupt -> wait started") + return except Exception: pass try: - self._vc_publish_led("idle", "ptt_short_press/stop_tts") + if self._vc_ptt_need_interrupt_wait(): + self._vc_begin_ptt_cancel_wait("ptt_short_interrupt") + print("[PTT] short_interrupt -> cancel_wait arm") + return except Exception: pass + try: + self._ptt_arm_uplink() + print("[PTT] short_interrupt -> arm uplink") + except Exception as e: + usys.print_exception(e) + + def _ptt_stop_tts_on_short_press(self): + """兼容旧名:单对话短按打断 TTS 并重新开录。""" + self._ptt_short_press_interrupt_restart() def _ai_key_press_ptt_mode(self): """单对话 PTT:按下仅吞掉;短按停播在松手且未长按时;长按→ptt_begin。""" @@ -1233,12 +1996,13 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract): except Exception: pass def _vc_restart_record_same_dialog(self): - """长对话 recording+短按:停录后立即重开(旧 sid,对齐设计稿)。""" + """长对话 recording+短按:废上一轮下行 + 停录立即重开(旧 sid)。""" if not self._vc_is_long_dialogue_mode(): return False try: - self.Close_flag = 0 self._skip_started_uplink_once = 0 + self._long_dialogue_invalidate_server_turn( + "key.restart", wait_canceled_uplink=False) self._vc_reset_long_idle_timer("key.restart") self._vc_long_dialogue_key_arm_session() self._long_dialogue_publish_listening("key.restart") @@ -1246,6 +2010,10 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract): self.request_uplink_stop("key.restart/stop") self._vc_clear_stale_record_state("key.restart/post") self.request_uplink_start("key.restart/start") + try: + print("[KEY] key.restart invalidate+cancel -> uplink restart") + except Exception: + pass return True except Exception as e: usys.print_exception(e) @@ -1291,8 +2059,19 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract): except Exception: pass try: + ux = None + try: + from vc_ux import derive_ux_state, UX_TTS, UX_THINKING + ux = derive_ux_state(self) + if ux in (UX_TTS, UX_THINKING) and hasattr( + self, "_ptt_short_press_interrupt_restart" + ): + self._ptt_short_press_interrupt_restart() + return + except Exception: + pass if self._ptt_is_active_tts_playback(): - self._ptt_stop_tts_on_short_press() + self._ptt_short_press_interrupt_restart() return except Exception: pass @@ -1312,35 +2091,41 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract): utime.sleep_ms(gap) except Exception: pass - print("[PTT] release end_speech asr_str={!r} rec={}".format( - self.AI_ASR_str, getattr(self, "VC_record_state_flag", -1))) self.AI_client_VC_end_speech() try: self._ptt_record_open_ms = 0 except Exception: pass + elif self._ptt_in_interrupt_wait(): + self._ptt_release_interrupt_settle() + elif getattr(self, "VC_record_state_flag", 0) == 0: + try: + self._ptt_skip_arm_on_cancel = 1 + self._ptt_pending_arm = 0 + except Exception: + pass + self._ptt_round_abort("", close_session=False, publish_idle=True) + try: + self._ptt_idle_latch = 1 + self._vc_publish_led("idle", "ptt_rel_pre", prefer_off=1) + except Exception: + pass elif getattr(self, "_ptt_audio_created", 0) == 1 or getattr( self, "_ptt_defer_speech_until_asr", 0 ) == 1: - self.AI_key_long_press_flag = 0 try: - self._ptt_defer_speech_until_asr = 0 - self._ptt_audio_created = 0 - self._ptt_abort_interrupt_arm() if getattr(self, "AI_client_VC_isconnect", 0) == 1: self.AI_client_VC_send( ujson.dumps({"event": "cancel", "payload": {}})) - print("[PTT] release cancel pending audio.create (no record)") except Exception: pass + self._ptt_round_abort("", close_session=False) else: + self._ptt_round_abort("", close_session=False) + try: self.AI_key_long_press_flag = 0 - try: - if getattr(self, "_ptt_short_press_stop", 0) == 1: - self._ptt_short_press_stop = 0 - except Exception: - pass - print("[PTT] release_no_uplink_skip") + except Exception: + pass return try: if getattr(self, "_ptt_short_press_stop", 0) == 1: @@ -1397,6 +2182,7 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract): try: if bump_gen: self._vc_turn_bump("ptt_interrupt") + self._vc_arm_interrupt_asr_recover("ptt_interrupt") self._ptt_interrupt_active = 1 self._ptt_led_lock_listening = 1 self._vc_play_end_suppress_idle_led = 1 @@ -1505,12 +2291,14 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract): except Exception: return False + def _vc_reset_turn_rx_flags(self, reason=""): + self._turn_guard.reset_turn_flags(reason) + def _vc_turn_bump(self, reason=""): """打断/新轮次:递增 turn_gen å¹¶ purge 队列中过期 VC 项。""" try: self._vc_turn_gen = int(getattr(self, "_vc_turn_gen", 0)) + 1 self._ptt_arm_epoch = self._vc_turn_gen - print("[ORCH] turn_gen={} reason={}".format(self._vc_turn_gen, reason)) EventMesh.publish("vc_orchestrator_purge", self._vc_turn_gen) except Exception: pass @@ -1576,8 +2364,6 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract): except Exception: pass self._drop_tts_audio = 1 - print("[VC][serial] cancel_wait reason={} turn={}".format( - reason, getattr(getattr(self, "fsm", None), "turn", "?"))) self._ptt_publish_listening_fast("ptt_cancel_wait") except Exception: pass @@ -1588,9 +2374,15 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract): return self._vc_serial_canceling = 0 self._vc_cancel_deadline_ms = 0 + if getattr(self, "_ptt_skip_arm_on_cancel", 0): + self._ptt_skip_arm_on_cancel = 0 + self._ptt_pending_arm = 0 + self._ptt_interrupt_active = 0 + self._drop_tts_audio = 0 + self.is_ruppt_tts = 0 + return if getattr(self, "_ptt_pending_arm", 0): self._ptt_pending_arm = 0 - print("[VC][serial] ptt_arm after {}".format(via)) self._ptt_arm_uplink() except Exception: pass @@ -1605,6 +2397,11 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract): if dl and utime.ticks_diff(utime.ticks_ms(), dl) >= 0: self._vc_cancel_deadline_ms = 0 if getattr(self, "VC_record_state_flag", 0) == 0: + if ( + hasattr(self, "_vc_interrupt_cancel_uplink_busy") + and self._vc_interrupt_cancel_uplink_busy() + ): + return print("[KEY] long_dialogue cancel_wait timeout -> uplink") try: self._vc_clear_stale_record_state("canceled/timeout") @@ -1625,6 +2422,13 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract): pass def _ptt_arm_uplink(self, t0=0): """btn 线程:cancel 已结束或无需 cancel 时,发 audio.create 并开麦。""" + try: + if getattr(self, "_ptt_skip_arm_on_cancel", 0) == 1: + return + if getattr(self, "AI_key_long_press_flag", 0) != 1: + return + except Exception: + pass try: self._ptt_await_server = 0 self.is_ruppt_tts = 0 @@ -1647,11 +2451,6 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract): except Exception: pass self._ptt_end_led_suppress() - try: - if t0: - print("[PTT] record started (ms={})".format(utime.ticks_ms() - t0)) - except Exception: - pass def ptt_begin(self, reason="key.long_press"): if not self._vc_is_ptt_mode(): return @@ -1659,6 +2458,8 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract): # PTT 新一轮开始:清理模式切换/提示音退出标志 try: self.Close_flag = 0 + self._last_tts_kind = "" + self._pending_tts_kind = "" except Exception: pass @@ -1735,7 +2536,8 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract): # 若上一轮录音尚未结束,先强制停掉,避免 record_thread 复用出错 try: if getattr(self, "VC_record_state_flag", 0) != 0: - self.VC_Speech_End() + silent = getattr(self, "AI_key_long_press_flag", 0) == 1 + self.VC_Speech_End(ptt_silent=silent) except Exception: pass @@ -2029,7 +2831,24 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract): self._turn_has_asr_completed = 0 self._turn_has_vad_silence = 0 self._turn_has_tts_started = 0 + self._turn_has_asr_result = 0 # 本轮是否收到过非空 asr.result(防残留 completed 双滴) + self._vc_last_think_tone_ms = 0 + self._vc_llm_without_tts_round = 0 # llm 无 TTS 轮次,禁止 completed 误开麦 + self._llm_reply_text = "" # llm.completed/delta 正文(simple_response 时用于 synthesis 兜底) + self._vc_llm_synth_fallback_ms = 0 + self._vc_await_late_tts_until_ms = 0 + self._vc_post_interrupt_until_asr = 0 # 与 guard 同步:打断后至 asr.started + self._vc_suppress_stale_server_turn = 0 # 兼容旧读法,由 VCTurnGuard 维护 + self._vc_stale_tts_until_asr_started = 0 + self._vc_interrupt_recover_until_ms = 0 + self._vc_ignore_vad_silence_until_ms = 0 + self._vc_interrupt_asr_min_uplink_ms = 2000 + self._vc_last_uplink_dur_ms = -1 self._vc_reopen_uplink_after_empty_hint = 0 + self._vc_interrupt_record_start_tone = 0 + self._vc_defer_record_for_tone = 0 + self._vc_interrupt_cancel_uplink_armed = 0 + self._turn_guard = VCTurnGuard(self) # VC 会话是否已进入 started(服务端 session initialized) # 重要:在收到 started 前发送音频帧会触发服务端 "session not initialized" → failed → 端侧重连死循环 self._vc_session_started = 0 @@ -2041,6 +2860,8 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract): # Push-To-Talk(VAD关闭/单对话)是否已发送 audio.create # 重要:协议要求 disable_vad=true 时必须 audio.create→音频帧→audio.completed self._ptt_audio_created = 0 + self._ptt_round_phase = PTT_ROUND_IDLE + self._ptt_skip_arm_on_cancel = 0 # PTT:松手等服务器期间保持 thinking 勿回蓝闪聆听 self._ptt_await_server = 0 # 方案甲 btnInfo 串行:打断时先 cancel,收到 canceled(或超时)后再 audio.create @@ -2501,6 +3322,20 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract): except Exception as e: usys.print_exception(e) + + def _vc_asr_started_should_reset_think_tone(self): + """未开麦的 asr.started(llm 无 TTS 后的残留)勿重置滴声标志。""" + try: + if getattr(self, "VC_record_state_flag", 0) != 0: + return True + if getattr(self, "_turn_has_asr_result", 0) == 1: + return True + if getattr(self, "_llm_completed_ms", 0) and getattr(self, "_turn_has_tts_started", 0) == 0: + return False + except Exception: + pass + return False + def set_pwk_notice_flag(self,event,msg):#设置未读状态 self.pwk_notice_flag = msg def get_wait_voice_flag(self,event,msg): @@ -2533,6 +3368,23 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract): return self.VC_satrt_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)) + try: + if ( + self._vc_is_ptt_mode() + and int(getattr(self, "_ptt_round_phase", PTT_ROUND_IDLE)) + == PTT_ROUND_IDLE + and getattr(self, "VC_satrt_flag", 0) == 1 + and getattr(self, "VC_record_state_flag", 0) == 0 + and getattr(self, "AI_key_long_press_flag", 0) == 0 + and not getattr(self, "_ptt_await_server", 0) + and not self._vc_tts_playback_active() + and getattr(self, "AI_client_VC_isconnect", 0) == 1 + ): + self._vc_close_reason = "ptt_orphan" + self.Close() + return + except Exception: + pass 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() @@ -2854,7 +3706,7 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract): except Exception: pass def _vc_mode_switch_prep(self, gen): - """模式切换 PREP:软停播/断 WS/停线程,不调用完整 Close(避免 play_pcm.close 掐提示音)。""" + """模式切换 PREP:轻量 Close(不打 VC_Play_End),再播模式提示音。""" try: print("[VC][ms] prep gen={} {}".format(gen, self._vc_fsm_log_snip())) except Exception: @@ -2873,13 +3725,7 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract): pass try: if getattr(self, "VC_satrt_flag", 0) == 1 or getattr(self, "AI_client_VC_isconnect", 0) == 1: - self.websocket_deconnect_VC() - except Exception: - pass - try: - if getattr(self, "VC_satrt_flag", 0) == 1: - self.AI_audio_queue_thread_close() - self.AI_VC_send_data_queue_thread_close() + self.Close_reconnect() except Exception: pass try: @@ -2945,8 +3791,10 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract): tts_str = getattr(self, "_vc_ms_pending_tts_str", "") or "" if not tts_str: tts_str = "已切换到单对话模式" if getattr(self, "VC_mode", 0) == 1 else "已切换到长对话模式" - print("[VC][ms] play_tts gen={} mode={}".format(gen, getattr(self, "VC_mode", -1))) - EventMesh.publish("play_tts", [tts_str, 1]) + kind = "mode_switch_ptt" if getattr(self, "VC_mode", 0) == 1 else "mode_switch_long" + print("[VC][ms] play_tts gen={} mode={} kind={}".format( + gen, getattr(self, "VC_mode", -1), kind)) + EventMesh.publish("play_tts", [tts_str, 1, kind]) def pwk_key_double_press(self,event,msg):#pwk双击 print("come 双击") @@ -3201,6 +4049,10 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract): EventMesh.publish("publish_AI_btnInfo", {"func": self._vc_idle_deep_sleep_impl}) if _bg_active: + try: + self._vc_late_tts_wait_tick(utime.ticks_ms()) + except Exception: + pass # 统一兜底表(按状态机状态触发) try: self._fsm_timeout_table_tick(utime.ticks_ms()) @@ -3400,29 +4252,40 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract): EventMesh.publish("audio_montage_play",["xinhaoruo1.mp3"]) def Close_VC(self,event,msg): EventMesh.publish("publish_AI_btnInfo",{"func":self.Close}) - def Close(self): + def Close_reconnect(self): + """断链后重连/接管:轻量 Close,不打断本地 TTS、保留 dialog、不经 idle_shutdown。""" + self.Close(light=True, force=True, skip_idle_shutdown=True, preserve_dialog=True) + def Close(self, light=False, force=False, skip_idle_shutdown=False, preserve_dialog=False): try: self._vc_persist_vc_mode() except Exception: pass try: ms_phase = int(getattr(self, "_vc_ms_phase", 0)) - if ms_phase in (1, 2, 3) and getattr(self, "_vc_close_reason", "") != "mode_switch_play_end": + cr = str(getattr(self, "_vc_close_reason", "") or "") + if ( + not force + and ms_phase in (1, 2, 3) + and cr != "mode_switch_play_end" + and not cr.startswith("power_short") + ): print("[VC][ms] Close blocked phase={} reason={}".format( - ms_phase, getattr(self, "_vc_close_reason", ""))) + ms_phase, cr)) return except Exception: pass try: - self._vc_close_reason = "" + if not light: + self._vc_close_reason = "" except Exception: pass try: - self._vc_idle_close_in_progress = 1 + if not light: + self._vc_idle_close_in_progress = 1 except Exception: pass try: - if getattr(self, "VC_mode", 0) == 0: + if not light and getattr(self, "VC_mode", 0) == 0: self._vc_prepare_for_idle_close() except Exception: pass @@ -3433,9 +4296,11 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract): getattr(self, "VC_satrt_flag", -1), getattr(self, "VC_record_state_flag", -1))) except Exception: pass - self.VC_stop_play_audio_flag = 0 + if not light: + self.VC_stop_play_audio_flag = 0 try: - self.AI_key_long_press_flag = 0 + if not light: + self.AI_key_long_press_flag = 0 except Exception: pass self.VC_Thinking_timerout = 0 @@ -3468,30 +4333,35 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract): except Exception: pass try: - self._vc_boot_standby = 0 + if not light: + self._vc_boot_standby = 0 except Exception: pass try: - self._vc_reset_dialogue_mode_state() + if not light: + self._vc_reset_dialogue_mode_state() except Exception: pass # Power 短按停止对话后,再次按 AI 需要新建 dialog_id:清理本轮 dialog/session 标识 - try: - self._cur_dialog_id = "" - except Exception: - pass - try: - self._cur_sid = "" - except Exception: - pass - self.is_ruppt_tts = 0#打断标志位清零 + if not preserve_dialog: + try: + self._cur_dialog_id = "" + except Exception: + pass + try: + self._cur_sid = "" + except Exception: + pass + if not light: + self.is_ruppt_tts = 0#打断标志位清零 print("ai_VC Close1!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!") try: if getattr(self, "VC_mode", 0) != 1: print("[VC][long_idle] step=set_led_state_idle (first)") except Exception: pass - EventMesh.publish("set_led_state", {"ai_state": "idle", "prefer_off": 1}) + if not light: + EventMesh.publish("set_led_state", {"ai_state": "idle", "prefer_off": 1}) self.tts_in_data_len = 0 self.tts_out_data_len = 0 #必须先销毁线程,不然线程会容易报错 @@ -3526,13 +4396,15 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract): except Exception: pass try: + self._vc_close_ws_internal = 1 self.websocket_deconnect_VC() except Exception as e: usys.print_exception(e) - - - - + finally: + try: + self._vc_close_ws_internal = 0 + except Exception: + pass print("ai_VC Close1.3!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!") @@ -3566,43 +4438,43 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract): print("[VC][long_idle] step=set_led_state_idle (second) -> VC_init VC_satrt_flag=0") except Exception: pass - EventMesh.publish("set_led_state", {"ai_state": "idle"}) - self.VC_init() + if not light: + EventMesh.publish("set_led_state", {"ai_state": "idle"}) + self.VC_init() self.VC_satrt_flag = 0 try: - self._vc_shutdown_idle_services("Close") + if not skip_idle_shutdown: + self._vc_shutdown_idle_services("Close") except Exception: pass # Level2:会话结束后仍可继续 1s 节拍累计 idle 直至深睡(依赖 VC_IDLE_DEEP_SLEEP_SEC) + if not light: + try: + _dsec = 60 + _den = 1 + if "vc_config" in globals() and vc_config: + _dsec = int(vc_config.get_int("VC_IDLE_DEEP_SLEEP_SEC", 60)) + _den = int(vc_config.get_int("VC_IDLE_DEEP_SLEEP_ENABLE", 1)) + if _den and _dsec > 0: + self._1S_timer.start(1000, 1, self._1S_timer_cb) + except Exception: + pass try: - _dsec = 60 - _den = 1 - if "vc_config" in globals() and vc_config: - _dsec = int(vc_config.get_int("VC_IDLE_DEEP_SLEEP_SEC", 60)) - _den = int(vc_config.get_int("VC_IDLE_DEEP_SLEEP_ENABLE", 1)) - if _den and _dsec > 0: - self._1S_timer.start(1000, 1, self._1S_timer_cb) - except Exception: - pass - try: - self._vc_idle_close_in_progress = 0 - self._vc_idle_l1_close = 0 + if not light: + self._vc_idle_close_in_progress = 0 + self._vc_idle_l1_close = 0 except Exception: pass try: - self._vc_ms_phase = 0 - self._vc_ms_play_gen = 0 - self._vc_ms_close_pending = 0 + if not light: + self._vc_ms_phase = 0 + self._vc_ms_play_gen = 0 + self._vc_ms_close_pending = 0 except Exception: pass def VC_init2(self): - self.VC_init() - - print("ai_VC VC_init2!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!") - try: - self.websocket_deconnect_VC() - except Exception as e: - usys.print_exception(e) + print("ai_VC VC_init2 -> Close_reconnect") + self.Close_reconnect() print("ai_VC Close3!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\r\n") self._1S_timer.stop() if self.AI_VC_send_data_queue.empty()!=True: 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..12737c1f 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 @@ -7,7 +7,8 @@ import _thread class VCSessionIoMixin: _VC_STALE_DROP_EVENTS = ( - "tts.started", "tts.completed", "llm.created", "llm.completed", "completed", + "tts.started", "tts.completed", "llm.created", "llm.completed", "llm.done", + "completed", "asr.result", "asr.completed", "vad.silence", "vad.active", ) def _vc_server_event_drop_stale(self, ev, gen): @@ -38,6 +39,30 @@ class VCSessionIoMixin: except Exception: pass return + if self._vc_suppress_post_interrupt_server_turn(ev): + try: + print("[ORCH] drop post-interrupt ev={}".format(ev)) + except Exception: + pass + return + try: + if ev == "asr.completed": + self._turn_has_asr_completed = 1 + elif ev == "vad.silence": + self._turn_has_vad_silence = 1 + except Exception: + pass + if ev in ("tts.started", "tts.completed") and self._vc_is_long_dialogue_mode(): + try: + if ( + hasattr(self, "_long_dialogue_is_stale_server_tts") + and self._long_dialogue_is_stale_server_tts() + ): + if hasattr(self, "_vc_purge_stale_tts_round"): + self._vc_purge_stale_tts_round("stale/{}".format(ev)) + return + except Exception: + pass if ev == "tts.started": try: self._vc_tts_rx_counters_reset() @@ -50,6 +75,48 @@ class VCSessionIoMixin: except Exception: pass if ev == "canceled": + try: + if ( + self._vc_is_ptt_mode() + and hasattr(self, "_ptt_is_stale_server_cancel") + and self._ptt_is_stale_server_cancel() + ): + self._ptt_handle_stale_cancel() + return + except Exception: + pass + try: + if ( + self._vc_is_long_dialogue_mode() + and getattr(self, "_vc_pending_interrupt_uplink", 0) != 1 + and ( + getattr(self, "VC_Play_flag", 0) == 1 + or getattr(self.fsm, "turn", None) + in (VCTurnState.Playing, VCTurnState.TtsReceiving) + ) + ): + self._drop_tts_audio = 1 + if getattr(self, "AI_audio_queue", None) is not None: + if self.AI_audio_queue.empty() != True: + self.AI_audio_queue.clear() + if getattr(self, "VC_Play_flag", 0) == 1 or getattr( + self, "play_pcm", None + ) is not None: + self.VC_Play_End() + try: + self.fsm.await_played = False + self._need_audio_played_ack = 0 + except Exception: + pass + try: + self._long_dialogue_publish_listening( + "canceled/during_play") + except Exception: + pass + print("[VC] canceled during play -> stop TTS") + return + except Exception: + pass try: if ( self._vc_is_long_dialogue_mode() @@ -64,6 +131,12 @@ class VCSessionIoMixin: self._vc_stop_downlink_for_interrupt("canceled") except Exception: pass + if ev == "asr.completed": + try: + if hasattr(self, "_vc_flush_deferred_vad_uplink_stop"): + self._vc_flush_deferred_vad_uplink_stop("asr.completed/vad") + except Exception: + pass try: actions = self.fsm.on_server_event(ev, jsload) if ev == "canceled" and self._vc_is_long_dialogue_mode(): @@ -73,9 +146,44 @@ class VCSessionIoMixin: a for a in (actions or []) if a and a[0] != "uplink_start" ] + elif ( + hasattr(self, "_vc_interrupt_cancel_uplink_busy") + and self._vc_interrupt_cancel_uplink_busy() + ): + actions = [ + a for a in (actions or []) + if a and a[0] != "uplink_start" + ] + try: + print("[VC] orch skip uplink_start (canceled dup)") + except Exception: + pass + except Exception: + pass + if ev == "vad.silence": + try: + _skip_vad_stop = False + if getattr(self, "_vc_defer_vad_uplink_stop", 0): + _skip_vad_stop = True + elif ( + getattr(self, "_turn_has_asr_completed", 0) == 1 + and getattr(self, "_turn_has_tts_started", 0) == 0 + ): + _skip_vad_stop = True + if _skip_vad_stop: + actions = [ + a for a in (actions or []) + if a and a[0] != "uplink_stop" + ] + print("[VC][VAD] orch skip uplink_stop (defer/post-asr)") except Exception: pass self._fsm_apply_actions(actions) + if ev == "asr.completed": + try: + self._vc_try_play_think_tone() + except Exception: + pass if ev == "canceled": try: if ( @@ -94,8 +202,44 @@ class VCSessionIoMixin: if ev == "canceled" and self._vc_is_ptt_mode(): self._vc_on_ptt_cancel_settled(via="canceled") + def _vc_suppress_post_interrupt_server_turn(self, ev): + g = getattr(self, "_turn_guard", None) + if g is not None: + return g.should_drop_server_event(ev) + try: + if getattr(self, "_vc_suppress_stale_server_turn", 0) != 1: + return False + return ev in ( + "vad.silence", "vad.active", "asr.result", "asr.completed", + "llm.created", "llm.completed", "llm.done", "llm.delta", "completed", + ) + except Exception: + return False + def _vc_dispatch_server_event(self, ev, jsload_data=None): """recv 线程:仅入编排队列,不在此修改 FSM。""" + try: + if hasattr(self, "_vc_should_drop_record_restart_stale") and self._vc_should_drop_record_restart_stale(ev): + print("[VC] drop record_restart stale ev={}".format(ev)) + return + except Exception: + pass + if self._vc_suppress_post_interrupt_server_turn(ev): + try: + print("[VC] drop post-interrupt ev={}".format(ev)) + except Exception: + pass + return + try: + if ( + ev == "vad.silence" + and hasattr(self, "_vc_should_ignore_vad_silence") + and self._vc_should_ignore_vad_silence() + ): + print("[VC] drop vad.silence (interrupt recover grace)") + return + except Exception: + pass gen = self._vc_turn_current() self._vc_enqueue_btn( self._vc_orchestrator_server_event_cb, @@ -227,7 +371,7 @@ class VCSessionIoMixin: pass self.AI_client_VC_isconnect = 0 try: - self.websocket_deconnect_VC() + self.Close_reconnect() except Exception: pass def websocket_error(self, TTS_str=None, play_weak_signal_tone=False): @@ -283,6 +427,16 @@ class VCSessionIoMixin: print("websocket_error4") def websocket_deconnect_VC(self): + if not getattr(self, "_vc_close_ws_internal", 0): + try: + print("[VC] websocket_deconnect_VC -> Close_reconnect (unified)") + except Exception: + pass + try: + self.Close_reconnect() + except Exception as e: + usys.print_exception(e) + return #print("websocket_deconnect_VC {}!!!!!!!".format(self.AI_client_VC_isconnect)) print("websocket_deconnect_VC!!!!!!!") self._vc_session_started = 0 @@ -607,6 +761,7 @@ class VCSessionIoMixin: self._turn_has_asr_completed = 0 self._turn_has_vad_silence = 0 self._turn_has_tts_started = 0 + self._turn_has_asr_result = 0 self._llm_completed_ms = 0 _skip_long_up = 0 if getattr(self, "_skip_started_uplink_once", 0) == 1: @@ -622,7 +777,7 @@ class VCSessionIoMixin: try: _plan = NotifyScheduler.plan_on_started( self, skip_long_uplink=_skip_long_up) - self._vc_apply_notify_started_plan(_plan) + self._vc_on_server_started_plan(_plan) except Exception as e: usys.print_exception(e) @@ -634,6 +789,20 @@ class VCSessionIoMixin: self.request_uplink_stop("config.update") elif jsload_data["event"] == "asr.started" :#asr开始 + if ( + hasattr(self, "_vc_should_drop_record_restart_stale") + and self._vc_should_drop_record_restart_stale("asr.started") + ): + continue + try: + g = getattr(self, "_turn_guard", None) + if g is not None: + g.on_asr_started() + else: + self._vc_suppress_stale_server_turn = 0 + self._vc_stale_tts_until_asr_started = 0 + except Exception: + pass try: self.fsm.on_server_asr_started() except Exception: @@ -646,11 +815,21 @@ class VCSessionIoMixin: self._turn_has_asr_completed = 0 self._turn_has_vad_silence = 0 self._turn_has_tts_started = 0 + self._turn_has_asr_result = 0 + try: + self._llm_reply_text = "" + self._vc_llm_without_tts_round = 0 + except Exception: + pass self._llm_completed_ms = 0 self._vc_defer_vad_uplink_stop = 0 self._vc_vad_had_active_after_silence = 0 try: - self._asr_completed_tone_done = 0 + if hasattr(self, "_vc_asr_started_should_reset_think_tone"): + if self._vc_asr_started_should_reset_think_tone(): + self._asr_completed_tone_done = 0 + else: + self._asr_completed_tone_done = 0 except Exception: pass # 单对话:_drop_tts_audio 在 VC_Speech_Start 真开录后再清 0,避免 canceled 后仍播旧 TTS @@ -662,6 +841,11 @@ class VCSessionIoMixin: # 单对话:勿在 asr.started ç½® flag=2(未真开麦会僵死);由 VC_Speech_Start 维护 self.LocalRespondingEnded_timerout = 0 elif jsload_data["event"] == "asr.result" :#asr结果 + if ( + hasattr(self, "_vc_should_drop_record_restart_stale") + and self._vc_should_drop_record_restart_stale("asr.result") + ): + continue try: self.AI_ASR_str = jsload_data["payload"]["content"] except Exception: @@ -673,18 +857,27 @@ class VCSessionIoMixin: try: if (self.AI_ASR_str or "").strip(): self._last_asr_result_text = self.AI_ASR_str + self._turn_has_asr_result = 1 + g = getattr(self, "_turn_guard", None) + if g is not None: + g.on_asr_result(self.AI_ASR_str) + else: + self._vc_interrupt_recover_until_ms = 0 + self._vc_ignore_vad_silence_until_ms = 0 except Exception: pass if self.AI_ASR_str == "":#没识别到 self.AI_TTS_str = "我没听清,请再说一遍" self._vc_led_alert_rb_flash() elif jsload_data["event"] == "asr.completed" :#asr 终止的标识 + if ( + hasattr(self, "_vc_should_drop_record_restart_stale") + and self._vc_should_drop_record_restart_stale("asr.completed") + ): + continue + if self._vc_suppress_post_interrupt_server_turn("asr.completed"): + continue self._turn_has_asr_completed = 1 - try: - if hasattr(self, "_vc_flush_deferred_vad_uplink_stop"): - self._vc_flush_deferred_vad_uplink_stop("asr.completed/vad") - except Exception: - pass try: if self._vc_effective_asr_text() and hasattr(self, "_vc_reset_long_idle_timer"): self._vc_reset_long_idle_timer("asr.completed") @@ -695,44 +888,12 @@ class VCSessionIoMixin: self.AI_ASR_str = getattr(self, "_last_asr_result_text", "") or "" except Exception: pass - # 本地 think 提示:仅在有有效 ASR 文本时播,避免空轮次连播「滴」 + # think 滴声:编排线程 _vc_try_play_think_tone(asr.completed 唯一播放入口) try: - if getattr(self, "_asr_completed_tone_done", 0) == 1: - pass - elif not self._vc_effective_asr_text(): - try: - self._vc_handle_asr_empty("asr.completed") - except Exception as e: - usys.print_exception(e) - else: - stream_on = False - try: - stream_on = EventMesh.publish("get_vc_tts_playback_active") == 1 - except Exception: - stream_on = False - pcm_busy = getattr(self, "play_pcm", None) is not None - playing = getattr(self, "VC_Play_flag", 0) == 1 - try: - thinking = getattr(self.fsm, "turn", VCTurnState.Idle) == VCTurnState.Thinking - except Exception: - thinking = False - vad_cont = getattr(self, "_vc_vad_had_active_after_silence", 0) == 1 - if ( - (not stream_on) - and (not pcm_busy) - and (not playing) - and (not thinking) - and (not vad_cont) - ): - self._vc_local_play('U:/static/think.mp3', 1, 1, 650) - self._asr_completed_tone_done = 1 - elif vad_cont: - try: - print("[VC][ASR] skip think tone (vad pause merged)") - except Exception: - pass - except Exception: - pass + if not self._vc_effective_asr_text(): + self._vc_handle_asr_empty("asr.completed") + except Exception as e: + usys.print_exception(e) print("self.commands_mode:{}".format(self.commands_mode)) if self.commands_mode ==2: ret= self.check_text(self._vc_effective_asr_text()) @@ -747,7 +908,7 @@ class VCSessionIoMixin: try: 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.Close_reconnect}) EventMesh.publish("publish_AI_btnInfo",{"func":self.AI_VC_start}) except Exception as e: usys.print_exception(e) @@ -767,8 +928,15 @@ class VCSessionIoMixin: elif jsload_data["event"] == "vad.active" :#VAD 检测到说话→聆听 try: self._vc_vad_had_active_after_silence = 1 - self._vc_defer_vad_uplink_stop = 0 - self._server_vad_silence = 0 + if getattr(self, "_vc_defer_vad_uplink_stop", 0): + self._vc_defer_vad_uplink_stop = 0 + self._server_vad_silence = 0 + try: + print("[VC][VAD] active cancel defer, resume uplink") + except Exception: + pass + else: + self._server_vad_silence = 0 except Exception: pass try: @@ -777,13 +945,31 @@ class VCSessionIoMixin: except Exception: pass elif jsload_data["event"] == "vad.silence" :#VAD 终止的标识 + if self._vc_suppress_post_interrupt_server_turn("vad.silence"): + continue + try: + if ( + hasattr(self, "_vc_should_ignore_vad_silence") + and self._vc_should_ignore_vad_silence() + ): + print("[VC][VAD] ignore silence (interrupt recover grace)") + continue + except Exception: + pass # 长对话:句中停顿仅标记,等 asr.completed 再停麦(配合服务端 vad_silence 加长) self._turn_has_vad_silence = 1 try: if hasattr(self, "_vc_long_dialogue_defer_vad_stop") and self._vc_long_dialogue_defer_vad_stop(): + # 仅 defer 停麦(VC_Speech_End);须立刻 silence_gate æ­¢ PCM, + # 否则云端已 vad.silence 端侧仍上行 → LLM 无 tts / 残留 asr 轮次 self._vc_defer_vad_uplink_stop = 1 + self._server_vad_silence = 1 try: - print("[VC][VAD] silence defer uplink_stop (long_dialogue)") + self._uplink_mark_end("vad.silence/defer") + except Exception: + pass + try: + print("[VC][VAD] silence defer stop_mic_only (silence_gate on)") except Exception: pass else: @@ -796,6 +982,10 @@ class VCSessionIoMixin: self._server_vad_silence = 1 self._uplink_mark_end("vad.silence") elif jsload_data["event"] == "llm.created" :#思考(LLM) + try: + self._llm_reply_text = "" + except Exception: + pass try: if hasattr(self, "_vc_reset_long_idle_timer"): self._vc_reset_long_idle_timer("llm.created") @@ -815,7 +1005,18 @@ class VCSessionIoMixin: self._llm_completed_ms = 0 # EventMesh.publish("publish_AI_btnInfo",{"func":self.VC_Play_Start}) pass + elif jsload_data["event"] == "llm.done": + try: + if hasattr(self, "_vc_capture_llm_reply_text"): + self._vc_capture_llm_reply_text(jsload_data, append=False) + except Exception: + pass elif jsload_data["event"] == "llm.completed" :#llm完成(可能无 tts) + try: + if hasattr(self, "_vc_capture_llm_reply_text"): + self._vc_capture_llm_reply_text(jsload_data, append=False) + except Exception: + pass try: self._llm_completed_ms = utime.ticks_ms() except Exception: @@ -831,16 +1032,36 @@ class VCSessionIoMixin: pass pass elif jsload_data["event"] == "llm.delta" :#llm返回数据 + try: + if hasattr(self, "_vc_capture_llm_reply_text"): + self._vc_capture_llm_reply_text(jsload_data, append=True) + except Exception: + pass if self.is_VAD == 0 and self.record_data_save_flag == 0: if self.VC_record_state_flag == 1:#llm返回数据还没停止,就先停止 self.request_uplink_stop("llm.delta") elif jsload_data["event"] == "tts.started" :#tts开始 + if ( + self._vc_is_long_dialogue_mode() + and hasattr(self, "_long_dialogue_is_stale_server_tts") + and self._long_dialogue_is_stale_server_tts() + ): + try: + if hasattr(self, "_vc_purge_stale_tts_round"): + self._vc_purge_stale_tts_round("recv/tts.started") + except Exception: + pass + continue try: self._ptt_await_server = 0 except Exception: pass self._server_vad_silence = 1 self._turn_has_tts_started = 1 + try: + self._vc_llm_without_tts_round = 0 + except Exception: + pass self._tts_pending = 0 self._tts_pending_ms = 0 self._uplink_mark_end("tts.started") @@ -884,6 +1105,17 @@ class VCSessionIoMixin: except Exception: pass elif jsload_data["event"] == "tts.completed" :#tts结束 + if ( + self._vc_is_long_dialogue_mode() + and hasattr(self, "_long_dialogue_is_stale_server_tts") + and self._long_dialogue_is_stale_server_tts() + ): + try: + if hasattr(self, "_vc_purge_stale_tts_round"): + self._vc_purge_stale_tts_round("recv/tts.completed") + except Exception: + pass + continue # tts.completed 仅表示下发结束,本地可能仍在播放;绿灯关闭在本地播放结束时处理 try: # server event 已在统一入口 on_server_event 触发 FSM,这里避免重复驱动 @@ -1011,7 +1243,7 @@ class VCSessionIoMixin: self.AI_TTS_str = "抱歉,没找到对应操作,可以换个说法试试" #EventMesh.publish("publish_AI_btnInfo",{"func":utime.sleep_ms,'data':(10)}) self.request_uplink_stop("command") - EventMesh.publish("publish_AI_btnInfo",{"func":self.websocket_deconnect_VC}) + EventMesh.publish("publish_AI_btnInfo",{"func":self.Close_reconnect}) EventMesh.publish("publish_AI_btnInfo",{"func":self.AI_VC_start}) elif jsload_data["event"] == "failed":#接口返回错误 if jsload_data["payload"]["error"] == "concurrency_limit_exceeded": @@ -1135,7 +1367,8 @@ class VCSessionIoMixin: usys.print_exception(e) EventMesh.publish("publish_AI_btnInfo",{"func":self.put_record_data,"data":[recv_data,""]}) else: - print("被打断不播放") + if VC_WS_IO_LOG: + print("被打断不播放") utime.sleep_ms(1) except Exception as e: usys.print_exception(e) 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..98f6430e 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 @@ -186,7 +186,9 @@ class VCSessionMediaMixin: def _vc_uplink_reason_is_user_key(self, reason): """按键/PTT 开录(非 server.started、play_end、asr.empty 等自动开麦)。""" r = str(reason or "") - if r.startswith("key.press") or r.startswith("BT_AI_key_press"): + if r.startswith("key.press") or r.startswith("key.restart"): + return True + if r.startswith("BT_AI_key_press"): return True if r.startswith("ptt_begin") or r == "asr.started/defer": return True @@ -198,14 +200,15 @@ class VCSessionMediaMixin: return False def _vc_should_play_record_start_tone(self, reason): - """按键开录播 start.mp3;idle 按 AI 建链后 server.started 首开麦也算按键开录。""" + g = getattr(self, "_turn_guard", None) + if g is not None: + return g.should_play_record_start_tone(reason) + r = str(reason or "") + if r == "canceled" or r.startswith("canceled/") or r.startswith("asr.empty"): + return False if self._vc_uplink_reason_is_user_key(reason): return True - r = str(reason or "") - if ( - r.startswith("server.started") - and getattr(self, "_long_dialogue_key_arm", 0) == 1 - ): + if r.startswith("server.started") and getattr(self, "_long_dialogue_key_arm", 0) == 1: return True return False @@ -235,18 +238,39 @@ class VCSessionMediaMixin: usys.print_exception(e) def _vc_user_record_uplink_open(self, reason=""): + try: + r = str(reason or "") + if r.startswith("key.restart"): + g = getattr(self, "_turn_guard", None) + if g is not None: + g.clear_record_restart_stale_drop("uplink_open") + except Exception: + pass + try: + self._vc_interrupt_record_start_tone = 0 + except Exception: + pass + try: + if getattr(self, "_vc_defer_record_for_tone", 0) == 1: + self._vc_defer_record_for_tone = 0 + self._vc_clear_record_tmp_queue("tone_done") + self._vc_interrupt_cancel_uplink_armed = 0 + except Exception: + pass try: self._vc_defer_listening_led = 0 self._vc_skip_local_play_stop_record = 0 except Exception: pass try: + self._server_vad_silence = 0 + except Exception: + pass + try: + if getattr(self, "VC_record_state_flag", 0) == 0: + self.VC_Speech_Start() if getattr(self, "VC_record_state_flag", 0) == 1: self.VC_Speech_Start_now() - elif getattr(self, "VC_record_state_flag", 0) == 0: - self.VC_Speech_Start() - if getattr(self, "VC_record_state_flag", 0) == 1: - self.VC_Speech_Start_now() except Exception as e: usys.print_exception(e) try: @@ -279,29 +303,58 @@ class VCSessionMediaMixin: else: self._vc_user_record_entry_long(reason) + def _vc_interrupt_cancel_uplink_busy(self): + """打断 cancel 开麦是否已在进行(防 timeout 与 server.canceled 双触发双滴声)。""" + try: + if getattr(self, "_vc_defer_record_for_tone", 0) == 1: + return True + if getattr(self, "_vc_interrupt_cancel_uplink_armed", 0) == 1: + return True + except Exception: + pass + return False + + def _vc_clear_record_tmp_queue(self, tag=""): + """丢弃开录前缓存的 PCM(避免滴声/空档滞后送达云端 ASR)。""" + try: + q = getattr(self, "AI_record_tmp_queue", None) + if q is not None and q.empty() != True: + q.clear() + try: + print("[VC][user_record] clear_pcm_queue ({})".format(tag or "-")) + except Exception: + pass + except Exception: + pass + + def _vc_record_start_tone_uplink_delay_ms(self): + """滴声后再启录音/上行:须覆盖 start.mp3 实际时长(勿仅用 180ms GAP)。""" + delay_ms = 700 + gap_ms = 180 + try: + if "vc_config" in globals() and vc_config: + delay_ms = int(vc_config.get_int( + "RECORD_START_TONE_UPLINK_DELAY_MS", delay_ms)) + gap_ms = int(vc_config.get_int("RECORD_START_TONE_GAP_MS", gap_ms)) + except Exception: + pass + if delay_ms < gap_ms + 400: + delay_ms = gap_ms + 400 + if delay_ms < 500: + delay_ms = 500 + return delay_ms + def _vc_user_record_entry_long(self, reason=""): """ 长对话开麦: - - 按键/打断续聊:先 start.mp3,滴声结束后再上行+蓝慢闪 + - 按键/打断续聊:先 start.mp3,滴声结束后再启录音+上行(勿提前 VC_Speech_Start) - 自动续聊(server.started/play_end 等):不播 start,直接上行 """ try: self._vc_user_record_open_reason = str(reason or "") self._vc_defer_listening_led = 1 self._vc_skip_local_play_stop_record = 1 - except Exception: - pass - try: - if getattr(self, "VC_record_state_flag", 0) == 0: - self.VC_Speech_Start() - elif getattr(self, "VC_record_state_flag", 0) > 1: - pass - except Exception as e: - usys.print_exception(e) - hold_ms = 180 - try: - if "vc_config" in globals() and vc_config: - hold_ms = int(vc_config.get_int("RECORD_START_TONE_GAP_MS", hold_ms)) + self._vc_defer_record_for_tone = 0 except Exception: pass played = False @@ -311,21 +364,34 @@ class VCSessionMediaMixin: except Exception: played = False if not played: + try: + if getattr(self, "VC_record_state_flag", 0) == 0: + self.VC_Speech_Start() + elif getattr(self, "VC_record_state_flag", 0) > 1: + pass + except Exception as e: + usys.print_exception(e) try: self._vc_user_record_uplink_open(reason) except Exception: pass return try: - total_ms = int(hold_ms) + 40 - if total_ms < 200: - total_ms = 200 + self._vc_defer_record_for_tone = 1 + self._vc_interrupt_cancel_uplink_armed = 1 + self._server_vad_silence = 1 + except Exception: + pass + try: + total_ms = self._vc_record_start_tone_uplink_delay_ms() self._vc_user_record_tone_timer.stop() self._vc_user_record_tone_timer.start(total_ms, 0, self._vc_user_record_tone_timer_cb) - print("[VC][user_record] entry_long key_tone reason={} tone_ms={}".format(reason, total_ms)) + print("[VC][user_record] entry_long key_tone reason={} uplink_after_ms={}".format( + reason, total_ms)) except Exception as e: usys.print_exception(e) try: + self._vc_defer_record_for_tone = 0 self._vc_user_record_uplink_open(reason) except Exception: pass @@ -515,6 +581,62 @@ class VCSessionMediaMixin: return (getattr(self, "_last_asr_result_text", "") or "").strip() except Exception: return "" + + def _vc_try_play_think_tone(self): + """asr.completed 本地 think(滴)每轮仅一次;编排线程唯一入口,避免 recv/空 ASR 重置导致连播。""" + try: + if getattr(self, "_asr_completed_tone_done", 0) == 1: + return + if getattr(self, "_turn_has_asr_result", 0) != 1: + try: + print("[VC][ASR] skip think tone (no asr.result this turn)") + except Exception: + pass + return + debounce_ms = 2500 + try: + if "vc_config" in globals() and vc_config: + debounce_ms = int(vc_config.get_int("ASR_COMPLETED_THINK_DEBOUNCE_MS", debounce_ms)) + except Exception: + pass + try: + last_ms = int(getattr(self, "_vc_last_think_tone_ms", 0) or 0) + if last_ms and utime.ticks_diff(utime.ticks_ms(), last_ms) < debounce_ms: + print("[VC][ASR] skip think tone (debounce {}ms)".format(debounce_ms)) + return + except Exception: + pass + if self._vc_local_play_busy(): + try: + print("[VC][ASR] skip think tone (local_play busy)") + except Exception: + pass + return + if not self._vc_effective_asr_text(): + return + stream_on = False + try: + stream_on = EventMesh.publish("get_vc_tts_playback_active") == 1 + except Exception: + stream_on = False + pcm_busy = getattr(self, "play_pcm", None) is not None + playing = getattr(self, "VC_Play_flag", 0) == 1 + try: + thinking = getattr(self.fsm, "turn", VCTurnState.Idle) == VCTurnState.Thinking + except Exception: + thinking = False + vad_cont = getattr(self, "_vc_vad_had_active_after_silence", 0) == 1 + if stream_on or pcm_busy or playing or thinking or vad_cont: + return + self._asr_completed_tone_done = 1 + try: + self._vc_last_think_tone_ms = utime.ticks_ms() + except Exception: + self._vc_last_think_tone_ms = 0 + self._vc_local_play("U:/static/think.mp3", 1, 1, 650) + except Exception as e: + usys.print_exception(e) + def _vc_long_dialogue_auto_uplink_ok(self, reason): """长对话自动/打断开麦:ASR/LLM 门控 + 双轨防抖(自动 1200ms / 用户打断 450ms)。""" try: @@ -545,21 +667,36 @@ class VCSessionMediaMixin: self._vc_is_long_dialogue_mode() and reason == "completed" and self._vc_effective_asr_text() + and getattr(self, "_turn_has_asr_result", 0) == 1 and ( getattr(self, "_turn_has_vad_silence", 0) == 1 or getattr(self, "_turn_has_asr_completed", 0) == 1 ) ): - # 长对话:有有效 ASR 的 completed 才自动下一轮(空 ASR 不抢开麦) + # 长对话:本轮有 asr.result 的 completed 才自动下一轮 pass else: print("[VC][uplink_ctrl] Deny START by {} cause=empty_asr".format(reason)) return False + if reason == "completed": + if getattr(self, "_vc_llm_without_tts_round", 0) == 1: + print("[VC][uplink_ctrl] Deny START by completed cause=llm_without_tts_pending") + return False + if getattr(self, "_turn_has_asr_result", 0) != 1 and self._vc_effective_asr_text(): + print("[VC][uplink_ctrl] Deny START by completed cause=stale_asr_text") + return False if reason == "completed" and getattr(self, "_turn_has_tts_started", 0) == 0: if getattr(self, "_llm_completed_ms", 0) or getattr( self.fsm, "turn", None ) == VCTurnState.Thinking: print("[VC][uplink_ctrl] Deny START by completed cause=llm_without_tts") + try: + if hasattr(self, "_turn_guard"): + self._turn_guard.on_llm_without_tts_completed() + else: + self._vc_arm_await_late_tts(reason) + except Exception: + pass try: if self._vc_is_long_dialogue_mode() and getattr(self, "_vc_session_started", 0) == 1: self._long_dialogue_publish_listening("completed/llm_without_tts") @@ -687,6 +824,17 @@ class VCSessionMediaMixin: if getattr(self, "record_data_save_flag", 0) == 0: self._server_vad_silence = 0 + try: + if ( + reason == "canceled" + and getattr(self, "_vc_pending_interrupt_uplink", 0) == 1 + and hasattr(self, "_vc_interrupt_cancel_uplink_busy") + and self._vc_interrupt_cancel_uplink_busy() + ): + print("[VC][uplink_ctrl] skip duplicate canceled uplink (tone/open in progress)") + return + except Exception: + pass print("[VC][uplink_ctrl] Request START by {}".format(reason)) try: if ( @@ -713,6 +861,22 @@ class VCSessionMediaMixin: try: if reason == "canceled" and getattr(self, "_vc_pending_interrupt_uplink", 0) == 1: print("[VC][uplink_ctrl] interrupt -> start (canceled)") + try: + self._vc_interrupt_record_start_tone = 1 + self._vc_interrupt_cancel_uplink_armed = 1 + except Exception: + pass + except Exception: + pass + try: + r = str(reason or "") + if self._vc_is_long_dialogue_mode() and ( + r == "canceled" + or r.startswith("canceled/") + or r.startswith("asr.empty/recover") + ): + if not self._vc_in_interrupt_asr_recover(): + self._vc_arm_interrupt_asr_recover(r) except Exception: pass self._vc_uplink_start_recording(reason) @@ -744,8 +908,6 @@ class VCSessionMediaMixin: self._uplink_active = 1 self._uplink_bytes = 0 self._uplink_start_ms = utime.ticks_ms() - 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_mark_end(self, reason=""): @@ -753,8 +915,10 @@ class VCSessionMediaMixin: 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)) + try: + self._vc_last_uplink_dur_ms = int(dur) if dur is not None and dur >= 0 else -1 + except Exception: + self._vc_last_uplink_dur_ms = -1 except Exception: pass self._uplink_active = 0 @@ -811,11 +975,14 @@ class VCSessionMediaMixin: elif event == 7: self.audio_play_state = 0 #EventMesh.publish("set_audio_state", 0) - print('audio-play finish.!!!!!!') try: - if getattr(self, "VC_mode", -1) == 1 and getattr(self, "_ptt_await_server", 0) == 1: - self._vc_publish_led("thinking", "audio_cb/finish/ptt_await") - return + if getattr(self, "VC_mode", -1) == 1: + t = getattr(self.fsm, "turn", None) + if getattr(self, "_ptt_await_server", 0) or t in ( + VCTurnState.Thinking, VCTurnState.TtsReceiving, + ): + self._vc_publish_led("thinking", "audio_cb/ptt") + return except Exception: pass try: @@ -857,7 +1024,9 @@ class VCSessionMediaMixin: def VC_Speech_Start_now(self): # 新一轮开始录音:允许上行(上一轮可能已置 silence 闸门) self._server_vad_silence = 0 - self.VC_Speech_Start() + # intr_recover 已在 cancel 开麦 uplink_request_start 时 arm,勿重复 + if getattr(self, "VC_record_state_flag", 0) == 0: + self.VC_Speech_Start() if self.VC_record_state_flag == 1: self.VC_record_state_flag =2 try: @@ -966,7 +1135,7 @@ class VCSessionMediaMixin: self.VC_record_state_flag = 1 self.VC_Speech_End()#关闭录音 usys.print_exception(e) - def VC_Speech_End(self): + def VC_Speech_End(self, ptt_silent=False): try: self._uplink_mark_end("VC_Speech_End") self.AI_ASR_flag = 0 @@ -1011,9 +1180,36 @@ class VCSessionMediaMixin: self.AI_record_tmp_queue.clear() self.VC_record_state_flag = 0 + try: + if getattr(self.fsm, "speech_uplink_open", 0) == 1: + self.fsm.speech_uplink_open = 0 + except Exception: + pass + try: + if getattr(self, "VC_mode", 0) == 0: + keep_drop = False + try: + g = getattr(self, "_turn_guard", None) + keep_drop = g.should_preserve_tts_drop_window() if g else False + except Exception: + keep_drop = bool( + getattr(self, "_drop_tts_audio", 0) + or getattr(self, "is_ruppt_tts", 0) + ) + if not keep_drop: + self.is_ruppt_tts = 0 + self._drop_tts_audio = 0 + if getattr(self, "_intr", None) is not None: + try: + self._intr.drop_tts = 0 + self._intr.ruppt_tts = 0 + except Exception: + pass + except Exception: + pass # PTT 一轮录音结束:清长按语义,避免后续短按松手误走 end_speech 而不播提示音 try: - if getattr(self, "VC_mode", -1) == 1: + if getattr(self, "VC_mode", -1) == 1 and not ptt_silent: self.AI_key_long_press_flag = 0 except Exception: pass @@ -1022,8 +1218,12 @@ class VCSessionMediaMixin: if getattr(self, "VC_mode", -1) == 1: self._ptt_led_lock_listening = 0 self._ptt_interrupt_active = 0 - self._ptt_await_server = 1 - self._vc_publish_led("thinking", "VC_Speech_End/ptt") + if ptt_silent: + # ptt_begin 清理残留录音:用户仍按住,勿进思考态/勿清长按 + self._ptt_await_server = 0 + else: + self._ptt_await_server = 1 + self._vc_publish_led("thinking", "VC_Speech_End/ptt") else: self._ptt_await_server = 0 if ( @@ -1388,15 +1588,34 @@ class VCSessionMediaMixin: return except Exception: pass - if tts_str != "": - self.AI_TTS_str = tts_str + k = "" + try: + if len(data) > 2 and data[2]: + k = str(data[2]) + self._pending_tts_kind = k + self._last_tts_kind = k + except Exception: + pass + if not k: + k = str(getattr(self, "_pending_tts_kind", "") or "") + ms_hint = (tts_str or "").strip() + if k in ("mode_switch_long", "mode_switch_ptt"): + if not ms_hint: + ms_hint = str(getattr(self, "_vc_ms_pending_tts_str", "") or "").strip() + if not ms_hint: + ms_hint = ( + "已切换到单对话模式" if int(getattr(self, "VC_mode", 0)) == 1 + else "已切换到长对话模式" + ) + if ms_hint: + self.AI_TTS_str = ms_hint if is_close_VC != None: self.Close_flag = is_close_VC try: - k = getattr(self, "_pending_tts_kind", "") or "" if k in ("mode_switch_long", "mode_switch_ptt"): self._vc_ms_phase = 2 self._vc_ms_play_gen = int(getattr(self, "_vc_ms_active_gen", 0)) + self._skip_started_uplink_once = 1 except Exception: pass if self.AI_VC_send_data_queue.empty()!=True: @@ -1404,15 +1623,30 @@ class VCSessionMediaMixin: if self.AI_ASR_send_data_queue.empty()!=True: self.AI_ASR_send_data_queue.clear() try: - if is_close_VC: - self._vc_publish_led("idle", "play_tts_cb") + ptt_mode = int(getattr(self, "VC_mode", 0)) == 1 + notify_kind = k in ( + "prompt", "boot_prompt", "mode_switch_long", "mode_switch_ptt", + "find_device", "asr_empty_hint", + ) + # 单对话 notify:设计稿仅 ASR complete 后亮思考灯;提示音/模式切换保持 idle + if is_close_VC or ptt_mode or notify_kind: + prefer_off = 1 if (ptt_mode or k in ("prompt", "boot_prompt")) else 0 + self._vc_publish_led( + "idle", "play_tts_cb/notify", prefer_off=prefer_off) else: self._vc_publish_led("thinking", "play_tts_cb") except Exception: pass self.AI_ASR_send_buf_len = 0 self._uplink_request_stop("play_tts_cb") - self.websocket_deconnect_VC() + self.Close_reconnect() + try: + if k in ("mode_switch_long", "mode_switch_ptt") and ms_hint: + self.AI_TTS_str = ms_hint + self._pending_tts_kind = k + self._last_tts_kind = k + except Exception: + pass self.AI_VC_start() # EventMesh.publish("publish_AI_btnInfo",{"func":self.VC_Speech_End}) @@ -1443,6 +1677,12 @@ class VCSessionMediaMixin: if self._vc_led_state_debug_enabled(): print("[VC] VC_Play_Start skip (ptt_interrupt)") return + if hasattr(self, "_long_dialogue_is_stale_server_tts") and self._long_dialogue_is_stale_server_tts(): + try: + print("[VC] VC_Play_Start skip (long_recording/stale_tts)") + except Exception: + pass + return if hasattr(self, "_long_dialogue_should_drop_inbound_tts") and self._long_dialogue_should_drop_inbound_tts(): if self._vc_led_state_debug_enabled(): print("[VC] VC_Play_Start skip (long_interrupt)") @@ -1792,6 +2032,27 @@ class VCSessionMediaMixin: pass if not ready: + try: + if ( + getattr(self.fsm, "await_played", False) + and getattr(self, "play_pcm", None) is None + and getattr(self, "VC_Play_flag", 0) == 0 + and ( + self._long_dialogue_is_stale_server_tts() + or getattr(self, "VC_record_state_flag", 0) != 0 + ) + ): + self.fsm.await_played = False + try: + self.fsm.mark_tts_rx_idle() + if getattr(self.fsm, "session", None) == VCSessionState.Started: + self.fsm.turn = VCTurnState.Listening + except Exception: + pass + print("[TTS] play_end_skip (no play_pcm, recording/stale)") + return False + except Exception: + pass try: if not getattr(self, "_tts_play_check_log_ms", 0) or utime.ticks_diff( now, self._tts_play_check_log_ms @@ -2023,7 +2284,7 @@ class VCSessionMediaMixin: EventMesh.publish("set_led_state", {"ai_state": "thinking"}) self.AI_ASR_send_buf_len = 0 self._uplink_request_stop("play_single_voice_cb") - self.websocket_deconnect_VC() + self.Close_reconnect() self.AI_VC_start() self.play_new_voice_flag = 1 # EventMesh.publish("publish_AI_btnInfo",{"func":self.VC_Speech_End}) @@ -2068,7 +2329,7 @@ class VCSessionMediaMixin: EventMesh.publish("set_led_state", {"ai_state": "thinking"}) self.AI_ASR_send_buf_len = 0 self._uplink_request_stop("play_new_voice_cb") - self.websocket_deconnect_VC() + self.Close_reconnect() self.AI_VC_start() self.play_new_voice_flag = 1 # EventMesh.publish("publish_AI_btnInfo",{"func":self.VC_Speech_End}) @@ -2102,7 +2363,7 @@ class VCSessionMediaMixin: EventMesh.publish("set_led_state", {"ai_state": "thinking"}) self.AI_ASR_send_buf_len = 0 self._uplink_request_stop("play_his_voice_cb") - self.websocket_deconnect_VC() + self.Close_reconnect() self.AI_VC_start() self.play_new_voice_flag = 1 # EventMesh.publish("publish_AI_btnInfo",{"func":self.VC_Speech_End}) @@ -2139,7 +2400,7 @@ class VCSessionMediaMixin: EventMesh.publish("set_led_state", {"ai_state": "thinking"}) self.AI_ASR_send_buf_len = 0 self._uplink_request_stop("play_wait_voice_cb") - self.websocket_deconnect_VC() + self.Close_reconnect() self.AI_VC_start() self.play_new_voice_flag = 1 # EventMesh.publish("publish_AI_btnInfo",{"func":self.VC_Speech_End}) @@ -2180,7 +2441,7 @@ class VCSessionMediaMixin: EventMesh.publish("set_led_state", {"ai_state": "thinking"}) self.AI_ASR_send_buf_len = 0 self._uplink_request_stop("play_music_cb") - self.websocket_deconnect_VC() + self.Close_reconnect() self.AI_VC_start() self.play_new_voice_flag = 1 # EventMesh.publish("publish_AI_btnInfo",{"func":self.VC_Speech_End}) @@ -2202,7 +2463,7 @@ class VCSessionMediaMixin: EventMesh.publish("set_led_state", {"ai_state": "thinking"}) self.AI_ASR_send_buf_len = 0 self._uplink_request_stop("play_trans_tts_cb") - self.websocket_deconnect_VC() + self.Close_reconnect() self.AI_VC_start() EventMesh.publish("publish_AI_btnInfo",{"func":self.AI_client_new_voice})#播放留言 self.play_new_voice_flag = 1 diff --git a/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_storage.py b/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_storage.py index b26d1a2f..7d1549f7 100644 --- a/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_storage.py +++ b/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_storage.py @@ -1,57 +1,59 @@ -# -*- coding: utf-8 -*- -"""持久化配置读写。""" -from common import Abstract -import EventMesh -import ujson -import ql_fs - -class getStorageDataManager(Abstract):#获取内存数据 - def __init__(self): - self.config_data = {} - check = ql_fs.path_exists("/usr/static/storage_file/config.json") - if check == True: - with open("/usr/static/storage_file/config.json","r") as fd: - self.config_data = ujson.load(fd) - else : - self.config_data = {'old_time': 0, 'flip_sw': 0} - pass - - def post_processor_after_instantiation(self): - EventMesh.subscribe("set_storage_data", self.set_storage_data) - EventMesh.subscribe("set_storage_config", self.set_storage_config) - EventMesh.subscribe("get_storage_data", self.get_storage_data) - EventMesh.subscribe("get_storage_config", self.get_storage_config) - - def set_storage_data(self, event, msg): - # EventMesh.debug_log("set_storage_data:{}".format(msg)) - if msg != None: - self.config_data = msg - with open("/usr/static/storage_file/config.json",'w+',encoding="utf-8") as fd: - ujson.dump(self.config_data,fd) - - - def set_storage_config(self,event,msg): - # EventMesh.debug_log("set_storage_key_data1:{}".format(msg)) - if len(msg) >= 2: - self.config_data[msg[0]]=msg[1] - # EventMesh.debug_log("set_storage_key_data2:{}".format(self.config_data[msg[0]])) - EventMesh.publish("publish_message_event_ui",{"func":self.set_storage_save,"data":0}) - def set_storage_save(self, data): - with open("/usr/static/storage_file/config.json",'w+',encoding="utf-8") as fd: - ujson.dump(self.config_data,fd) - def get_storage_data(self, event, msg): - # EventMesh.debug_log("get_storage_data:{}".format(self.config_data)) - return self.config_data - def get_storage_config(self,event,msg): - # EventMesh.debug_log("get_storage_key_data1:{}".format(msg)) - b = msg in self.config_data.keys() - if b == True: - # EventMesh.debug_log("get_storage_key_data2:{}".format(self.config_data[msg])) - return self.config_data[msg] - else : - return None - # try: - # return self.config_data[msg] - # except: - # EventMesh.debug_log("get_storage_key_data:none key:{}",msg) - # return None +# -*- coding: utf-8 -*- +"""持久化配置读写。""" +from common import Abstract +import EventMesh +import ujson +import ql_fs + +class getStorageDataManager(Abstract):#获取内存数据 + def __init__(self): + self.config_data = {} + check = ql_fs.path_exists("/usr/static/storage_file/config.json") + if check == True: + with open("/usr/static/storage_file/config.json","r") as fd: + self.config_data = ujson.load(fd) + else : + self.config_data = {'old_time': 0, 'flip_sw': 0} + pass + + def post_processor_after_instantiation(self): + EventMesh.subscribe("set_storage_data", self.set_storage_data) + EventMesh.subscribe("set_storage_config", self.set_storage_config) + EventMesh.subscribe("get_storage_data", self.get_storage_data) + EventMesh.subscribe("get_storage_config", self.get_storage_config) + + def set_storage_data(self, event, msg): + # EventMesh.debug_log("set_storage_data:{}".format(msg)) + if msg != None: + self.config_data = msg + with open("/usr/static/storage_file/config.json",'w+',encoding="utf-8") as fd: + ujson.dump(self.config_data,fd) + + + def set_storage_config(self,event,msg): + # EventMesh.debug_log("set_storage_key_data1:{}".format(msg)) + if len(msg) >= 2: + self.config_data[msg[0]]=msg[1] + if msg[0] in ("VC_mode", "powerRestart"): + self.set_storage_save(0) + else: + EventMesh.publish("publish_message_event_ui",{"func":self.set_storage_save,"data":0}) + def set_storage_save(self, data): + with open("/usr/static/storage_file/config.json",'w+',encoding="utf-8") as fd: + ujson.dump(self.config_data,fd) + def get_storage_data(self, event, msg): + # EventMesh.debug_log("get_storage_data:{}".format(self.config_data)) + return self.config_data + def get_storage_config(self,event,msg): + # EventMesh.debug_log("get_storage_key_data1:{}".format(msg)) + b = msg in self.config_data.keys() + if b == True: + # EventMesh.debug_log("get_storage_key_data2:{}".format(self.config_data[msg])) + return self.config_data[msg] + else : + return None + # try: + # return self.config_data[msg] + # except: + # EventMesh.debug_log("get_storage_key_data:none key:{}",msg) + # return None