修复两次VAD提示音的BUG,修复未close直接断联websocket问题,修复长对话模式 短按按键重新录音 TTS仍播报的问题,修复单对话模式 长按按键 无法打断TT...
authorTimekeeperPL <1624952103@qq.com>
Wed, 27 May 2026 07:16:23 +0000 (15:16 +0800)
committerTimekeeperPL <1624952103@qq.com>
Wed, 27 May 2026 07:16:23 +0000 (15:16 +0800)
2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/products/configs/k8/default_config.py
2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_interrupt.py
2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_key_router.py
2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_notify.py
2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_present.py
2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_session.py
2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_session_io.py
2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_session_media.py
2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_storage.py

index bb24b9a..089dc8f 100644 (file)
@@ -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
index 88db152..78a6734 100644 (file)
-# -*- coding: utf-8 -*-\r
-"""长对话/PTT 打断上下文:绑定 turn_gen,收敛 is_ruppt_tts 等 legacy 标志。"""\r
-try:\r
-    import utime\r
-except Exception:\r
-    utime = None\r
-\r
-\r
-class InterruptContext(object):\r
-    KIND_NONE = ""\r
-    KIND_LONG_SHORT = "long_short"\r
-    KIND_PTT_SHORT = "ptt_short"\r
-    KIND_PTT_LONG = "ptt_long"\r
-\r
-    def __init__(self):\r
-        self.active = 0\r
-        self.kind = self.KIND_NONE\r
-        self.gen = 0\r
-        self.pending_uplink = 0\r
-        self.drop_tts = 0\r
-        self.ruppt_tts = 0\r
-        self.settled_ms = 0\r
-        self.cancel_deadline_ms = 0\r
-\r
-    def clear(self, vc=None):\r
-        self.active = 0\r
-        self.kind = self.KIND_NONE\r
-        self.gen = 0\r
-        self.pending_uplink = 0\r
-        self.drop_tts = 0\r
-        self.ruppt_tts = 0\r
-        self.settled_ms = 0\r
-        self.cancel_deadline_ms = 0\r
-        if vc is not None:\r
-            self._sync_legacy(vc)\r
-\r
-    def begin(self, vc, kind, pending_uplink=0, cancel_wait_ms=800):\r
-        try:\r
-            gen = int(getattr(vc, "_vc_turn_gen", 0))\r
-        except Exception:\r
-            gen = 0\r
-        self.active = 1\r
-        self.kind = kind or self.KIND_NONE\r
-        self.gen = gen\r
-        self.pending_uplink = 1 if pending_uplink else 0\r
-        self.drop_tts = 1\r
-        self.ruppt_tts = 1\r
-        self.settled_ms = 0\r
-        if utime and cancel_wait_ms:\r
-            try:\r
-                self.cancel_deadline_ms = utime.ticks_add(utime.ticks_ms(), int(cancel_wait_ms))\r
-            except Exception:\r
-                self.cancel_deadline_ms = 0\r
-        else:\r
-            self.cancel_deadline_ms = 0\r
-        self._sync_legacy(vc)\r
-        return gen\r
-\r
-    def settle(self, vc):\r
-        """打断开麦完成:清 is_ruppt/drop 以允许新 TTS;保留 settled_ms 供迟到 canceled 判 stale。"""\r
-        if utime:\r
-            try:\r
-                self.settled_ms = utime.ticks_ms()\r
-            except Exception:\r
-                self.settled_ms = 0\r
-        self.pending_uplink = 0\r
-        self.active = 0\r
-        self.drop_tts = 0\r
-        self.ruppt_tts = 0\r
-        self._sync_legacy(vc)\r
-\r
-    def end_drop_window(self, vc):\r
-        """新一轮 TTS 开始:结束丢弃窗口。"""\r
-        self.drop_tts = 0\r
-        self.ruppt_tts = 0\r
-        self.settled_ms = 0\r
-        self.pending_uplink = 0\r
-        self.active = 0\r
-        self._sync_legacy(vc)\r
-\r
-    def is_stale_gen(self, vc):\r
-        try:\r
-            return int(getattr(vc, "_vc_turn_gen", 0)) != int(self.gen)\r
-        except Exception:\r
-            return False\r
-\r
-    def _sync_legacy(self, vc):\r
-        """与 vc_session_io / media 中既有 getattr 兼容。"""\r
-        try:\r
-            vc._vc_pending_interrupt_uplink = 1 if self.pending_uplink else 0\r
-            vc.is_ruppt_tts = 1 if self.ruppt_tts else 0\r
-            vc._drop_tts_audio = 1 if self.drop_tts else 0\r
-            vc._long_dialogue_interrupt_settled_ms = int(self.settled_ms or 0)\r
-            vc._vc_cancel_deadline_ms = int(self.cancel_deadline_ms or 0)\r
-        except Exception:\r
-            pass\r
-\r
-    def load_legacy(self, vc):\r
-        try:\r
-            self.pending_uplink = 1 if getattr(vc, "_vc_pending_interrupt_uplink", 0) else 0\r
-            self.ruppt_tts = 1 if getattr(vc, "is_ruppt_tts", 0) else 0\r
-            self.drop_tts = 1 if getattr(vc, "_drop_tts_audio", 0) else 0\r
-            self.settled_ms = int(getattr(vc, "_long_dialogue_interrupt_settled_ms", 0) or 0)\r
-            self.cancel_deadline_ms = int(getattr(vc, "_vc_cancel_deadline_ms", 0) or 0)\r
-            self.active = 1 if (self.pending_uplink or self.ruppt_tts or self.drop_tts) else 0\r
-        except Exception:\r
-            pass\r
+# -*- 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
index 98e0863..f85b03f 100644 (file)
@@ -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)
index b957e55..2ef0843 100644 (file)
-# -*- coding: utf-8 -*-\r
-"""通知通道:server.started 媒体排队、play_tts 与对话会话分流。"""\r
-try:\r
-    from vc_fsm import VCTurnState\r
-except Exception:\r
-    VCTurnState = None\r
-try:\r
-    import vc_config\r
-except Exception:\r
-    vc_config = None\r
-\r
-# play_tts 通道\r
-CHANNEL_TAKEOVER = "takeover"   # 断连重连 WS synthesis(IoT 查找设备/模式切换/无对话)\r
-CHANNEL_DEFER = "defer"         # 对话进行中排队,空闲后播放(is_close_VC=0 的 IoT 查询等)\r
-\r
-# TTS 业务种类(_pending_tts_kind / play_tts 第三参数)\r
-TTS_KIND_FIND_DEVICE = "find_device"\r
-TTS_KIND_MODE_SWITCH_LONG = "mode_switch_long"\r
-TTS_KIND_MODE_SWITCH_PTT = "mode_switch_ptt"\r
-TTS_KIND_BOOT_PROMPT = "boot_prompt"\r
-TTS_KIND_PROMPT = "prompt"\r
-\r
-# 须立即占用会话、不可 defer\r
-_TAKEOVER_KINDS = (\r
-    TTS_KIND_FIND_DEVICE,\r
-    TTS_KIND_MODE_SWITCH_LONG,\r
-    TTS_KIND_MODE_SWITCH_PTT,\r
-    TTS_KIND_BOOT_PROMPT,\r
-    TTS_KIND_PROMPT,\r
-)\r
-\r
-# server.started plan op\r
-OP_LONG_UPLINK = "long_uplink"\r
-OP_ALARM_VOICE = "alarm_voice"\r
-OP_VOICE_URL = "voice_url"\r
-OP_SYNTHESIS = "synthesis"\r
-OP_SYNTHESIS_HIS_TIMER = "synthesis_his_timer"\r
-OP_SYNTHESIS_WAIT_TIMER = "synthesis_wait_timer"\r
-OP_SYNTHESIS_WAIT_SINGLE = "synthesis_wait_single"\r
-OP_SYNTHESIS_VOICE_TIMER = "synthesis_voice_timer"\r
-OP_SYNTHESIS_MUSIC_TIMER = "synthesis_music_timer"\r
-OP_IDLE_UPLINK = "idle_uplink"\r
-OP_PTT_ROUND = "ptt_round"\r
-OP_UPLOAD_TONE = "upload_tone"\r
-\r
-\r
-def dialog_session_active(vc):\r
-    """对话占用中:通知类 play_tts 应 defer。"""\r
-    try:\r
-        if getattr(vc, "Close_flag", 0) == 1:\r
-            return False\r
-        if getattr(vc, "VC_satrt_flag", 0) != 1:\r
-            return False\r
-        if getattr(vc, "AI_client_VC_isconnect", 0) != 1:\r
-            return False\r
-        if getattr(vc, "VC_Play_flag", 0) == 1:\r
-            return True\r
-        if getattr(vc, "VC_record_state_flag", 0) != 0:\r
-            return True\r
-        # 7PIN_1_5 无闹钟业务;alarm_play_flag 仅 legacy(如 play_single_voice)\r
-        if getattr(vc, "music_play_flag", 0) == 1:\r
-            return True\r
-        if VCTurnState is not None and getattr(vc, "fsm", None) is not None:\r
-            t = getattr(vc.fsm, "turn", VCTurnState.Idle)\r
-            if t not in (VCTurnState.Idle, VCTurnState.Listening):\r
-                return True\r
-    except Exception:\r
-        pass\r
-    return False\r
-\r
-\r
-class NotifyScheduler(object):\r
-    @staticmethod\r
-    def classify_play_tts(vc, is_close_vc):\r
-        try:\r
-            if is_close_vc:\r
-                return CHANNEL_TAKEOVER\r
-        except Exception:\r
-            pass\r
-        try:\r
-            k = getattr(vc, "_pending_tts_kind", "") or getattr(vc, "_last_tts_kind", "") or ""\r
-            if k in _TAKEOVER_KINDS:\r
-                return CHANNEL_TAKEOVER\r
-        except Exception:\r
-            pass\r
-        if dialog_session_active(vc):\r
-            return CHANNEL_DEFER\r
-        return CHANNEL_TAKEOVER\r
-\r
-    @staticmethod\r
-    def plan_on_started(vc, skip_long_uplink=0):\r
-        """替代 vc_session_io started 内 if-elif 媒体分支。"""\r
-        plan = []\r
-        try:\r
-            import EventMesh\r
-        except Exception:\r
-            EventMesh = None\r
-\r
-        # 长对话:started 补开麦(原 597-623,与媒体队列正交)\r
-        try:\r
-            if (\r
-                not skip_long_uplink\r
-                and getattr(vc, "VC_mode", 0) == 0\r
-                and getattr(vc, "Close_flag", 0) == 0\r
-                and not getattr(vc, "_vc_boot_standby", 0)\r
-            ):\r
-                plan.append({"op": OP_LONG_UPLINK})\r
-        except Exception:\r
-            pass\r
-\r
-        tts = getattr(vc, "AI_TTS_str", "") or ""\r
-        url = getattr(vc, "AI_new_voice_url", "") or ""\r
-        alarm = getattr(vc, "alarm_play_flag", 0) == 1\r
-        music = getattr(vc, "music_play_flag", 0) == 1\r
-\r
-        # 闹钟铃声(其它机型);7PIN_1_5 无闹钟,alarm_play_flag 通常为 0\r
-        if url and alarm:\r
-            plan.append({"op": OP_ALARM_VOICE})\r
-        elif not tts and url and not music:\r
-            plan.append({"op": OP_VOICE_URL})\r
-        elif tts and not url:\r
-            plan.append({"op": OP_SYNTHESIS})\r
-        elif tts and url and not music:\r
-            try:\r
-                his = EventMesh.publish("get_history_voice_flag") == 1 if EventMesh else False\r
-            except Exception:\r
-                his = False\r
-            if his:\r
-                try:\r
-                    wait = EventMesh.publish("get_wait_voice_flag") == 0 if EventMesh else True\r
-                except Exception:\r
-                    wait = True\r
-                if wait:\r
-                    plan.append({"op": OP_SYNTHESIS_HIS_TIMER})\r
-                else:\r
-                    try:\r
-                        notice = getattr(vc, "pwk_notice_flag", 0) == 0\r
-                    except Exception:\r
-                        notice = True\r
-                    if notice:\r
-                        plan.append({"op": OP_SYNTHESIS_WAIT_TIMER})\r
-                    else:\r
-                        plan.append({"op": OP_SYNTHESIS_WAIT_SINGLE})\r
-            else:\r
-                plan.append({"op": OP_SYNTHESIS_VOICE_TIMER})\r
-        elif tts and url and music:\r
-            plan.append({"op": OP_SYNTHESIS_MUSIC_TIMER})\r
-        else:\r
-            try:\r
-                if "vc_config" in globals() and vc_config and vc_config.get_bool(\r
-                    "UPLOAD_START_TONE_ENABLED", False\r
-                ):\r
-                    plan.append({"op": OP_UPLOAD_TONE})\r
-            except Exception:\r
-                pass\r
-            try:\r
-                if getattr(vc, "VC_mode", 0) == 0:\r
-                    plan.append({"op": OP_IDLE_UPLINK})\r
-                else:\r
-                    plan.append({"op": OP_PTT_ROUND})\r
-            except Exception:\r
-                plan.append({"op": OP_IDLE_UPLINK})\r
-        return plan\r
+# -*- 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
index 7e26832..d987e3d 100644 (file)
@@ -71,18 +71,19 @@ def present_to_led_msg(vc, present=None):
     """返回传给 set_led_state 的 dict;不发布。"""\r
     if present is None:\r
         present = build_present_state(vc)\r
-    if present.get("override_ai"):\r
-        msg = {"ai_state": present["override_ai"]}\r
-        if present.get("override_prefer_off") or present.get("prefer_off"):\r
-            msg["prefer_off"] = 1\r
-        return msg\r
     if present.get("force_idle"):\r
         msg = {"ai_state": "idle"}\r
         if present.get("prefer_off"):\r
             msg["prefer_off"] = 1\r
         return msg\r
+    # 录音/开麦锁:优先于陈旧 thinking override(单对话长按须保持蓝快闪)\r
     if present.get("hold_listening"):\r
         return {"ai_state": "listening"}\r
+    if present.get("override_ai"):\r
+        msg = {"ai_state": present["override_ai"]}\r
+        if present.get("override_prefer_off") or present.get("prefer_off"):\r
+            msg["prefer_off"] = 1\r
+        return msg\r
     if present.get("ptt_await_server") and int(present.get("mode", 0)) == 1:\r
         return {"ai_state": "thinking"}\r
     if present.get("stream_active"):\r
index 96c6759..0d1a5b2 100644 (file)
@@ -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):
-        """é\95¿å¯¹è¯\9d recording+ç\9f­æ\8c\89ï¼\9aå\81\9cå½\95å\90\8eç«\8bå\8d³é\87\8då¼\80ï¼\88æ\97§ sidï¼\8c对é½\90设计稿)。"""
+        """é\95¿å¯¹è¯\9d recording+ç\9f­æ\8c\89ï¼\9aåº\9fä¸\8aä¸\80è½®ä¸\8bè¡\8c + å\81\9cå½\95ç«\8bå\8d³é\87\8då¼\80ï¼\88æ\97§ 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):
-        """模å¼\8få\88\87æ\8d¢ PREPï¼\9a软å\81\9cæ\92­/æ\96­ WS/å\81\9c线ç¨\8bï¼\8cä¸\8dè°\83ç\94¨å®\8cæ\95´ Closeï¼\88é\81¿å\85\8d play_pcm.close æ\8e\90æ\8f\90示é\9f³ï¼\89。"""
+        """模å¼\8få\88\87æ\8d¢ PREPï¼\9aè½»é\87\8f Closeï¼\88ä¸\8dæ\89\93 VC_Play_Endï¼\89ï¼\8cå\86\8dæ\92­æ¨¡å¼\8fæ\8f\90示é\9f³。"""
         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:
index 8585aa2..12737c1 100644 (file)
@@ -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)
index adba4f7..98f6430 100644 (file)
@@ -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
                     )
                 ):
-                    # é\95¿å¯¹è¯\9dï¼\9aæ\9c\89æ\9c\89æ\95\88 ASR ç\9a\84 completed æ\89\8dè\87ªå\8a¨ä¸\8bä¸\80è½®ï¼\88空 ASR ä¸\8dæ\8a¢å¼\80麦ï¼\89
+                    # é\95¿å¯¹è¯\9dï¼\9aæ\9c¬è½®æ\9c\89 asr.result ç\9a\84 completed æ\89\8dè\87ªå\8a¨ä¸\8bä¸\80è½®
                     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
index b26d1a2..7d1549f 100644 (file)
@@ -1,57 +1,59 @@
-# -*- coding: utf-8 -*-\r
-"""持久化配置读写。"""\r
-from common import Abstract\r
-import EventMesh\r
-import ujson\r
-import ql_fs\r
-\r
-class getStorageDataManager(Abstract):#获取内存数据\r
-        def __init__(self):\r
-            self.config_data = {}\r
-            check = ql_fs.path_exists("/usr/static/storage_file/config.json")\r
-            if check == True:\r
-                with open("/usr/static/storage_file/config.json","r") as fd:\r
-                    self.config_data = ujson.load(fd)\r
-            else :\r
-                self.config_data = {'old_time': 0, 'flip_sw': 0}\r
-                pass\r
-\r
-        def post_processor_after_instantiation(self):\r
-            EventMesh.subscribe("set_storage_data", self.set_storage_data)\r
-            EventMesh.subscribe("set_storage_config", self.set_storage_config)\r
-            EventMesh.subscribe("get_storage_data", self.get_storage_data)\r
-            EventMesh.subscribe("get_storage_config", self.get_storage_config)\r
-\r
-        def set_storage_data(self, event, msg): \r
-            # EventMesh.debug_log("set_storage_data:{}".format(msg))\r
-            if msg != None:\r
-                self.config_data = msg\r
-                with open("/usr/static/storage_file/config.json",'w+',encoding="utf-8") as fd:\r
-                    ujson.dump(self.config_data,fd)\r
-        \r
-\r
-        def set_storage_config(self,event,msg): \r
-            # EventMesh.debug_log("set_storage_key_data1:{}".format(msg))\r
-            if len(msg) >= 2:\r
-                self.config_data[msg[0]]=msg[1]\r
-                # EventMesh.debug_log("set_storage_key_data2:{}".format(self.config_data[msg[0]]))\r
-                EventMesh.publish("publish_message_event_ui",{"func":self.set_storage_save,"data":0})\r
-        def set_storage_save(self, data): \r
-            with open("/usr/static/storage_file/config.json",'w+',encoding="utf-8") as fd:\r
-                ujson.dump(self.config_data,fd)\r
-        def get_storage_data(self, event, msg): \r
-            # EventMesh.debug_log("get_storage_data:{}".format(self.config_data))\r
-            return self.config_data \r
-        def get_storage_config(self,event,msg):\r
-            # EventMesh.debug_log("get_storage_key_data1:{}".format(msg))\r
-            b = msg in self.config_data.keys()\r
-            if b == True:\r
-                # EventMesh.debug_log("get_storage_key_data2:{}".format(self.config_data[msg]))\r
-                return self.config_data[msg]\r
-            else :\r
-                return None\r
-            # try:\r
-            #     return self.config_data[msg]\r
-            # except:\r
-            #     EventMesh.debug_log("get_storage_key_data:none key:{}",msg)\r
-            #     return None\r
+# -*- 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