增加拼接字段 增加长对话25秒超时机制 JSWMV3
authorTimekeeperPL <1624952103@qq.com>
Sat, 30 May 2026 08:28:47 +0000 (16:28 +0800)
committerTimekeeperPL <1624952103@qq.com>
Sat, 30 May 2026 08:28:47 +0000 (16:28 +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_fsm.py
2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_proto.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

index 39827e7..eed875f 100644 (file)
@@ -158,6 +158,9 @@ VAD_SILENCE_DEFAULT = 1000
 # 长对话:单轮最长录音(ms)、asr.completed 后续说 merge 窗(ms);0=不限制
 LONG_DIALOGUE_MAX_TURN_MS = 25000
 LONG_DIALOGUE_ASR_CONTINUE_MS = 2000
+# 与 start.interrupt_options 一致
+INTERRUPT_CONCATENATE_RESULTS = 1
+INTERRUPT_STRATEGY_ALWAYS_ALLOW = 1
 # llm 已起后连续 canceled 达此次数则冻结续说、停麦等回复(避免一直录无 TTS)
 LONG_DIALOGUE_LLM_CANCEL_MAX = 2
 
index 7432d43..0beec43 100644 (file)
@@ -232,7 +232,7 @@ class VCStateMachine:
                 return actions
             if event == "canceled":
                 self.on_server_canceled()
-                # canceled:仅长对话允许“立刻回到聆听并尝试开麦”(由 _uplink_request_start 门控保证安全幂等)
+                # canceled 开麦由 _uplink_request_start 门控(拼接/打断/merge)
                 try:
                     if self.mode() == VCMode.Realtime and getattr(self.vc, "Close_flag", 0) == 0:
                         actions.append(("uplink_start", "canceled"))
@@ -241,10 +241,15 @@ class VCStateMachine:
                 return actions
             if event == "completed":
                 self.on_server_completed()
-                # completed:若不在等待 audio.played(说明本轮无 TTS),仅长对话可直接进入下一轮聆听
+                # completed:无 TTS 轮次才自动开麦;等对话 TTS 时由 local.play_end 开麦
                 try:
                     if self.mode() == VCMode.Realtime and getattr(self.vc, "Close_flag", 0) == 0:
-                        if (not getattr(self, "await_played", False)) and self.turn != VCTurnState.AwaitPlayedAck:
+                        expect_tts = getattr(self.vc, "_vc_expect_dialog_tts", 0) == 1
+                        if (
+                            (not expect_tts)
+                            and (not getattr(self, "await_played", False))
+                            and self.turn != VCTurnState.AwaitPlayedAck
+                        ):
                             actions.append(("uplink_start", "completed"))
                 except Exception:
                     pass
index cf9e666..d5e150d 100644 (file)
@@ -201,12 +201,13 @@ def _convert_start_payload_v25_to_v3(payload):
         data["agent_id"] = agent_id
 
     io = dict(inb.get("interrupt_options") or {})
-    if io.get("thinking_time_only"):
-        io["strategy"] = "thinking_time_only"
-    elif io.get("keyword_interrupt"):
-        io["strategy"] = "keyword"
-        if io.get("interrupt_keywords"):
-            io["interrupt_keywords"] = io["interrupt_keywords"]
+    if io.get("strategy") not in ("always_allow", "thinking_time_only", "keyword", "never_allow"):
+        if io.get("always_allow"):
+            io["strategy"] = "always_allow"
+        elif io.get("thinking_time_only"):
+            io["strategy"] = "thinking_time_only"
+        elif io.get("keyword_interrupt"):
+            io["strategy"] = "keyword"
     if io:
         data["interrupt_options"] = io
 
@@ -455,12 +456,7 @@ def vc_proto_log_synthesis(text, pending_kind=""):
         return
     try:
         t = text or ""
-        print("[VC][proto] synthesis_tx ver={} text_len={} kind={} text={!r}".format(
-            get_protocol_version(),
-            len(t),
-            pending_kind or "-",
-            _text_preview(t, 64),
-        ))
+        print("[VC][p] syn tx l={} k={}".format(len(t), pending_kind or "-"))
         try:
             if is_v3():
                 wire = ujson.dumps({"type": "synthesis", "data": {"text": t}})
@@ -500,19 +496,15 @@ def vc_proto_log_tx_wire(wire_str, ws_conn=0):
             txt = pl.get("text") or ""
         except Exception:
             pass
-        print("[VC][proto] tx synthesis ver={} len={} ws={} text={!r}".format(
-            get_protocol_version(), len(wire_str), ws_conn, _text_preview(txt, 64)))
+        print("[VC][p] tx syn l={}".format(len(wire_str)))
         return
     if el in ("start",):
-        print("[VC][proto] start_wire_SENT ver={} wire_len={} ws={}".format(
-            get_protocol_version(), len(wire_str), ws_conn))
+        print("[VC][p] tx start l={}".format(len(wire_str)))
         return
     if el in ("audio.create", "audio.completed", "cancel", "audio.played", "audio.convert"):
-        print("[VC][proto] tx event={} ver={} len={} ws={}".format(
-            ev, get_protocol_version(), len(wire_str), ws_conn))
+        print("[VC][p] tx {} l={}".format(ev, len(wire_str)))
         return
-    print("[VC][proto] tx event={} ver={} len={} ws={}".format(
-        ev, get_protocol_version(), len(wire_str), ws_conn))
+    print("[VC][p] tx {} l={}".format(ev, len(wire_str)))
 
 
 def vc_proto_normalize_server(js):
index 43b032d..76208d0 100644 (file)
@@ -1,5 +1,4 @@
 # -*- coding: utf-8 -*-
-"""语音对话编排:按键/PTT、AI_VC_start/Close、定时器与 FSM 编排。"""
 from vc_session_imports import *  # noqa: F401,F403
 import _thread
 from vc_session_io import VCSessionIoMixin
@@ -20,7 +19,7 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract):
     def _vc_fsm_log_snip(self):
         try:
             f = self.fsm
-            return "sess={} turn={} tts_rx={} await={} sp_up={}".format(
+            return "s={} t={} rx={} ap={} up={}".format(
                 int(getattr(f, "session", -1)),
                 int(getattr(f, "turn", -1)),
                 int(getattr(f, "tts_rx_phase", -1)),
@@ -128,6 +127,12 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract):
         try:
             if getattr(self, "_vc_pending_interrupt_uplink", 0) == 1:
                 return True
+            if (
+                reason in ("canceled/continue_speech", "canceled")
+                and getattr(self, "_vc_intr_always", 1)
+                and getattr(self, "_vc_intr_concat", 1)
+            ):
+                return True
             # TTS 播完进入下一轮:play_end 时 PCM 尚未 close,勿用 pipeline_busy 拒开麦
             if reason in ("local.play_end", "audio.played"):
                 if getattr(self, "_vc_session_started", 0) == 1:
@@ -142,7 +147,6 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract):
         return True
 
     def _vc_reset_long_idle_timer(self, tag=""):
-        """长对话 L1:用户开麦/有效轮次活动则清零无 TTS 超时计数。"""
         try:
             if getattr(self, "VC_mode", -1) != 0:
                 return
@@ -150,15 +154,6 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract):
         except Exception:
             pass
 
-    def _vc_long_dialogue_defer_vad_stop(self):
-        try:
-            return (
-                self._vc_is_long_dialogue_mode()
-                and getattr(self, "record_data_save_flag", 0) == 0
-            )
-        except Exception:
-            return False
-
     def _vc_asr_continue_merge_ms(self):
         try:
             if "vc_config" in globals() and vc_config:
@@ -167,14 +162,6 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract):
             pass
         return 2000
 
-    def _vc_ld_cancel_storm_max(self):
-        try:
-            if "vc_config" in globals() and vc_config:
-                return int(vc_config.get_int("LONG_DIALOGUE_LLM_CANCEL_MAX", 2))
-        except Exception:
-            pass
-        return 2
-
     def _vc_ld_reset_merge_freeze(self):
         try:
             self._vc_ld_merge_frozen = 0
@@ -184,17 +171,30 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract):
         except Exception:
             pass
 
-    def _vc_ld_on_tts_started(self):
+    def _vc_ld_begin_new_speech_turn(self, reason=""):
+        if not self._vc_is_long_dialogue_mode():
+            return
         try:
-            self._vc_ld_merge_frozen = 0
-            self._vc_ld_cancel_in_wall = 0
-            self._turn_has_llm_created = 0
-            self._vc_ld_awaiting_reply = 0
+            self._turn_has_asr_completed = 0
+            self._turn_has_vad_silence = 0
+            self._turn_has_asr_result = 0
+            self._vc_asr_continue_merge = 0
+            self._vc_pending_asr_completed_stop = 0
+            self._vc_asr_completed_ms = 0
+            self._vc_last_asr_result_ms = 0
+            self._vc_defer_vad_uplink_stop = 0
+            self._vc_vad_had_active_after_silence = 0
+            self._server_vad_silence = 0
+            self._vc_turn_cap_fired = 0
+            self._vc_turn_cap_await_asr_deadline_ms = 0
+            self._vc_turn_wall_start_ms = 0
+            self._vc_turn_wall_had_asr_completed = 0
+            self._vc_ld_reset_merge_freeze()
+            print("[VC][turn] new r={}".format(reason or "?"))
         except Exception:
             pass
 
     def _vc_ld_bump_cancel_in_wall(self):
-        """单轮墙钟内累计 canceled(不随 llm.created 清零);达上限强制提交。"""
         if not self._vc_is_long_dialogue_mode():
             return False
         if getattr(self, "_vc_pending_interrupt_uplink", 0) == 1:
@@ -204,13 +204,12 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract):
         try:
             n = int(getattr(self, "_vc_ld_cancel_in_wall", 0)) + 1
             self._vc_ld_cancel_in_wall = n
-            mx = self._vc_ld_cancel_storm_max()
-            if mx > 0 and n >= mx:
+            if n >= 2:
                 self._vc_ld_merge_frozen = 1
                 self._vc_asr_continue_merge = 0
                 self._vc_pending_asr_completed_stop = 0
                 try:
-                    print("[VC][cancel_storm] n={} max={} -> force commit".format(n, mx))
+                    print("[VC][cst] n={}/{}".format(n, mx))
                 except Exception:
                     pass
                 return True
@@ -218,17 +217,89 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract):
             pass
         return False
 
+    def _vc_turn_cap_await_asr_tick(self):
+        try:
+            if not self._vc_is_long_dialogue_mode():
+                return
+            dl = getattr(self, "_vc_turn_cap_await_asr_deadline_ms", 0)
+            if not dl:
+                return
+            if (
+                getattr(self, "_turn_has_asr_completed", 0) == 1
+                and getattr(self, "_vc_turn_cap_fired", 0) != 1
+            ):
+                self._vc_turn_cap_await_asr_deadline_ms = 0
+                return
+            if utime.ticks_diff(utime.ticks_ms(), dl) < 0:
+                return
+            self._vc_turn_cap_await_asr_deadline_ms = 0
+            try:
+                print("[VC][cap] asr wait to")
+            except Exception:
+                pass
+            if getattr(self, "VC_record_state_flag", 0) != 0:
+                try:
+                    self._vc_uplink_send_silence_tail_ms(1000)
+                except Exception:
+                    pass
+                self._server_vad_silence = 1
+                try:
+                    self._uplink_mark_end("max_turn_ms/asr_wait_to")
+                except Exception:
+                    pass
+                self.request_uplink_stop("max_turn_ms/asr_wait_to")
+        except Exception:
+            pass
+
     def _vc_ld_force_commit_turn(self, reason="force_commit"):
-        """停止续说开麦,关上行/停录,等云端用已有 ASR 走完 LLM→TTS。"""
         if not self._vc_is_long_dialogue_mode():
             return
         try:
             self._vc_asr_continue_merge = 0
             self._vc_pending_asr_completed_stop = 0
             self._vc_defer_vad_uplink_stop = 0
+            self._vc_ld_merge_frozen = 1
+            try:
+                self._vc_uplink_flush_record_queue()
+            except Exception:
+                pass
+            rec = int(getattr(self, "VC_record_state_flag", 0))
+            is_cap = str(reason or "") == "max_turn_ms"
+            wall_comp = getattr(self, "_vc_turn_wall_had_asr_completed", 0) == 1
+            if is_cap:
+                self._vc_ld_awaiting_reply = 1
+                if wall_comp:
+                    self._turn_has_asr_completed = 0
+                    try:
+                        print("[VC][cap] rechad")
+                    except Exception:
+                        pass
+                else:
+                    try:
+                        print("[VC][cap] stream_only")
+                    except Exception:
+                        pass
+            wait_asr = is_cap and rec >= 1 and wall_comp
+            if wait_asr:
+                try:
+                    cap_wait = 3500
+                    self._vc_turn_cap_await_asr_deadline_ms = utime.ticks_add(
+                        utime.ticks_ms(), cap_wait)
+                    print("[VC][cap] wait_asr ms={}".format(cap_wait))
+                except Exception:
+                    self._vc_turn_cap_await_asr_deadline_ms = 0
+                    wait_asr = False
+            if is_cap and rec >= 1 and not wait_asr:
+                try:
+                    self._vc_uplink_send_silence_tail_ms(1000)
+                except Exception:
+                    pass
             self._server_vad_silence = 1
-            self._vc_ld_awaiting_reply = 1
-            if getattr(self, "VC_record_state_flag", 0) != 0:
+            try:
+                self._vc_reset_long_idle_timer(reason)
+            except Exception:
+                pass
+            if rec >= 1 and not wait_asr:
                 try:
                     self._uplink_mark_end(reason)
                 except Exception:
@@ -239,11 +310,14 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract):
                     self._vc_publish_led("thinking", reason)
                 except Exception:
                     pass
+            try:
+                print("[VC][cap] vad_stop r={}".format(reason or "?"))
+            except Exception:
+                pass
         except Exception:
             pass
 
     def _vc_asr_continue_merge_active(self):
-        """asr.completed 后短窗内仍有 asr.result → 同轮续说 merge。"""
         if not self._vc_is_long_dialogue_mode():
             return False
         try:
@@ -262,37 +336,33 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract):
         except Exception:
             return False
 
-    def _vc_should_hold_mic_for_asr_continue(self):
-        # llm 已起:云端 barge-in 由 canceled+ASR 承接,端侧不再 hold 开麦
-        if getattr(self, "_turn_has_llm_created", 0) == 1:
-            return False
-        if getattr(self, "_vc_vad_had_active_after_silence", 0) == 1:
-            return True
-        return self._vc_asr_continue_merge_active()
-
     def _vc_note_asr_result_activity(self):
         try:
-            if getattr(self, "_vc_ld_merge_frozen", 0) == 1:
+            if (
+                getattr(self, "_vc_ld_merge_frozen", 0) == 1
+                or getattr(self, "_vc_turn_cap_fired", 0) == 1
+            ):
                 return
             self._vc_last_asr_result_ms = utime.ticks_ms()
-            # llm 已起后迟到的 asr.result 只更新文本,禁止 continue merge 再开上行
             if getattr(self, "_turn_has_llm_created", 0) == 1:
                 return
             if getattr(self, "_turn_has_asr_completed", 0) == 1:
+                if not getattr(self, "_vc_intr_concat", 1):
+                    return
                 self._vc_asr_continue_merge = 1
                 self._vc_pending_asr_completed_stop = 0
                 if getattr(self, "record_data_save_flag", 0) == 0:
+                    rec = int(getattr(self, "VC_record_state_flag", 0))
+                    if rec == 0:
+                        print("[VC][ASR] rx post stop")
+                        return
                     self._server_vad_silence = 0
                     self._vc_defer_vad_uplink_stop = 0
-                    try:
-                        print("[VC][ASR] continue after completed -> resume uplink gate")
-                    except Exception:
-                        pass
+                    print("[VC][ASR] cont rs={}".format(rec))
         except Exception:
             pass
 
     def _vc_flush_deferred_vad_uplink_stop(self, reason="asr.completed"):
-        """长对话 asr.completed:vad.silence 句末立停;无 vad 则留 merge 窗再停麦。"""
         try:
             if not self._vc_is_long_dialogue_mode():
                 return
@@ -300,7 +370,7 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract):
                 return
             if getattr(self, "_vc_vad_had_active_after_silence", 0) == 1:
                 try:
-                    print("[VC][VAD] asr.completed defer stop (vad pause merged)")
+                    print("[VC][VAD] completed defer vad merge")
                 except Exception:
                     pass
                 return
@@ -313,7 +383,7 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract):
                 self._vc_defer_vad_uplink_stop = 0
             elif not self._vc_effective_asr_text():
                 return
-            if had_defer:
+            if had_defer or not getattr(self, "_vc_intr_concat", 1):
                 self._server_vad_silence = 1
                 try:
                     self._uplink_mark_end(reason)
@@ -322,7 +392,6 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract):
                 if getattr(self, "VC_record_state_flag", 0) != 0 and self._vc_vad_uplink_stop_eligible():
                     self.request_uplink_stop(reason)
                 return
-            # 无 vad.silence:先 silence_gate,merge 窗内若有新 asr.result 则恢复;超时再关麦
             self._vc_pending_asr_completed_stop = 1
             self._server_vad_silence = 1
             try:
@@ -333,7 +402,6 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract):
             pass
 
     def _vc_asr_completed_stop_tick(self):
-        """merge 窗结束仍无续说 → 执行延后关麦。"""
         try:
             if not self._vc_is_long_dialogue_mode():
                 return
@@ -349,7 +417,7 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract):
             self._vc_pending_asr_completed_stop = 0
             if getattr(self, "VC_record_state_flag", 0) != 0 and self._vc_vad_uplink_stop_eligible():
                 try:
-                    print("[VC][ASR] merge window end -> stop uplink")
+                    print("[VC][ASR] merge end")
                 except Exception:
                     pass
                 self.request_uplink_stop("asr.completed/merge_timeout")
@@ -357,8 +425,7 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract):
             pass
 
     def _long_dialogue_cancel_preserve_speech(self):
-        """长对话 canceled:非用户打断时,录音中或 asr 续说窗内均保持/恢复上行。"""
-        if not self._vc_is_long_dialogue_mode():
+        if not self._vc_is_long_dialogue_mode() or not getattr(self, "_vc_intr_concat", 1):
             return False
         try:
             if getattr(self, "_vc_ld_merge_frozen", 0) == 1:
@@ -367,53 +434,38 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract):
                 return False
             if getattr(self, "is_ruppt_tts", 0) == 1:
                 return False
-            # llm 已起:达 cancel 上限后不再保录(由 cancel_storm 提交)
-            if getattr(self, "_turn_has_llm_created", 0) == 1:
-                if int(getattr(self, "_vc_ld_cancel_in_wall", 0)) >= self._vc_ld_cancel_storm_max():
-                    return False
+            if int(getattr(self, "_vc_ld_cancel_in_wall", 0)) >= 2:
+                return False
             if getattr(self, "VC_record_state_flag", 0) != 0:
                 return True
-            if self._vc_asr_continue_merge_active():
-                return True
-            if getattr(self, "_vc_asr_continue_merge", 0) == 1:
+            if self._vc_asr_continue_merge_active() or getattr(self, "_vc_asr_continue_merge", 0) == 1:
                 return True
+            if getattr(self, "_vc_intr_always", 1) and self._vc_effective_asr_text():
+                if (
+                    getattr(self, "_turn_has_asr_completed", 0) == 1
+                    or getattr(self, "_turn_has_llm_created", 0) == 1
+                    or getattr(self, "_turn_has_tts_started", 0) == 1
+                ):
+                    return True
         except Exception:
             pass
         return False
 
-    def _vc_ld_first_post_llm_cancel_barge(self):
-        """llm 已起后第 1 次 canceled(补半句):即使 merge 未置位也允许保录开麦。"""
-        if not self._vc_is_long_dialogue_mode():
-            return False
-        if getattr(self, "_vc_ld_merge_frozen", 0) == 1:
-            return False
-        if getattr(self, "_turn_has_llm_created", 0) != 1:
-            return False
-        if int(getattr(self, "_vc_ld_cancel_in_wall", 0)) != 1:
-            return False
-        return bool(self._vc_effective_asr_text())
-
-    def _vc_ld_should_continue_speech_on_cancel(self):
-        if getattr(self, "_vc_ld_merge_frozen", 0) == 1:
-            return False
-        if self._vc_asr_continue_merge_active():
-            return True
-        if getattr(self, "_vc_asr_continue_merge", 0) == 1:
-            return True
-        return self._vc_ld_first_post_llm_cancel_barge()
-
-    def _long_dialogue_overlap_cancel_keep_recording(self):
-        return self._long_dialogue_cancel_preserve_speech()
-
     def _long_dialogue_handle_continue_speech_cancel(self):
-        """canceled 但用户仍在说:清 TTS,保持/恢复录音与上行。"""
         try:
             self._drop_tts_audio = 1
+            try:
+                self._vc_hold_tts_consumer_for_interrupt()
+            except Exception:
+                pass
             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:
+            preserve_tone = self._vc_should_preserve_local_tone_on_mute()
+            self._ptt_hard_mute_local_playback(skip_aud_stop=preserve_tone)
+            if preserve_tone:
+                self.VC_Play_flag = 0
+            elif 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
@@ -425,85 +477,87 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract):
                 self._server_vad_silence = 0
                 self._vc_pending_asr_completed_stop = 0
             try:
-                print("[KEY] long_dialogue canceled -> keep/restore speech")
+                print("[KEY] ld canceled keep speech")
             except Exception:
                 pass
         except Exception:
             pass
 
-    def _vc_long_dialogue_max_turn_ms(self):
-        """长对话单轮录音墙钟上限(ms);0=不限制。merge 续说期间累计,不因 silence_gate 清零。"""
-        try:
-            if "vc_config" in globals() and vc_config:
-                return int(vc_config.get_int("LONG_DIALOGUE_MAX_TURN_MS", 25000))
-        except Exception:
-            pass
-        return 25000
-
-    def _vc_turn_wall_clear(self):
+    def _vc_turn_wall_clear(self, tag="", reset_merge=0):
         try:
+            if tag and not self._vc_is_long_dialogue_mode():
+                return
+            had = tag and (
+                getattr(self, "_vc_turn_wall_start_ms", 0)
+                or getattr(self, "_vc_turn_cap_fired", 0)
+            )
             self._vc_turn_wall_start_ms = 0
+            self._vc_turn_wall_had_asr_completed = 0
             self._vc_turn_cap_fired = 0
-            if hasattr(self, "_vc_ld_reset_merge_freeze"):
+            self._vc_turn_cap_await_asr_deadline_ms = 0
+            if had and tag:
+                print("[VC][cap] clr r={}".format(tag))
+            if reset_merge:
+                if getattr(self, "_vc_ld_awaiting_reply", 0) == 1:
+                    self._vc_ld_merge_frozen = 0
+                    return
                 self._vc_ld_reset_merge_freeze()
         except Exception:
             pass
 
-    def _vc_turn_wall_arm_if_needed(self):
-        """真开录时启动单轮墙钟;句中 vad.active 续说不重置。"""
-        try:
-            if not self._vc_is_long_dialogue_mode():
-                return
-            if getattr(self, "_vc_turn_wall_start_ms", 0):
-                return
-            self._vc_turn_wall_start_ms = utime.ticks_ms()
-            self._vc_turn_cap_fired = 0
-        except Exception:
-            pass
-
     def _vc_long_dialogue_turn_cap_tick(self):
-        """录音线程周期检查:超 LONG_DIALOGUE_MAX_TURN_MS 强制停录提交(方案2+上限)。"""
         try:
             if not self._vc_is_long_dialogue_mode():
                 return
+            self._vc_turn_cap_await_asr_tick()
             if getattr(self, "record_data_save_flag", 0) != 0:
                 return
-            if getattr(self, "VC_record_state_flag", 0) < 2:
-                return
             if getattr(self, "_vc_turn_cap_fired", 0) == 1:
                 return
-            max_ms = self._vc_long_dialogue_max_turn_ms()
+            if getattr(self, "_turn_has_llm_created", 0) == 1:
+                return
+            rec = int(getattr(self, "VC_record_state_flag", 0))
+            if rec < 1:
+                return
+            max_ms = 25000
+            try:
+                if "vc_config" in globals() and vc_config:
+                    max_ms = int(vc_config.get_int("LONG_DIALOGUE_MAX_TURN_MS", 25000))
+            except Exception:
+                pass
             if max_ms <= 0:
                 return
             start = getattr(self, "_vc_turn_wall_start_ms", 0)
+            if not start and rec >= 1:
+                self._vc_turn_wall_start_ms = utime.ticks_ms()
+                self._vc_turn_wall_had_asr_completed = 0
+                start = self._vc_turn_wall_start_ms
             if not start:
                 return
-            if utime.ticks_diff(utime.ticks_ms(), start) < max_ms:
+            elapsed = utime.ticks_diff(utime.ticks_ms(), start)
+            if elapsed < max_ms:
+                return
+            # 真开上行后才提交;仅硬件缓冲(state==1)则继续等到 uplink 或超时
+            if rec < 2 and getattr(self, "_uplink_active", 0) != 1:
                 return
             self._vc_turn_cap_fired = 1
-            self._vc_pending_asr_completed_stop = 0
-            self._vc_asr_continue_merge = 0
-            self._vc_ld_merge_frozen = 1
+            self._vc_vad_had_active_after_silence = 0
+            up_b_seg = int(getattr(self, "_uplink_bytes", 0) or 0)
+            no_comp = getattr(self, "_turn_has_asr_completed", 0) != 1
             try:
-                print("[VC][turn_cap] force stop elapsed_ms>={} max_ms={}".format(
-                    utime.ticks_diff(utime.ticks_ms(), start), max_ms))
+                print("[VC][cap] stop el>={}".format(elapsed))
             except Exception:
                 pass
-            self._vc_defer_vad_uplink_stop = 0
-            self._vc_vad_had_active_after_silence = 0
-            self._server_vad_silence = 1
+            self._vc_ld_force_commit_turn("max_turn_ms")
             try:
-                self._uplink_mark_end("max_turn_ms")
+                print("[VC][cap] el={} no={} b={}".format(
+                    elapsed, 1 if no_comp else 0, up_b_seg))
             except Exception:
                 pass
-            self.request_uplink_stop("max_turn_ms")
-            if hasattr(self, "_vc_ld_force_commit_turn"):
-                self._vc_ld_force_commit_turn("max_turn_ms")
         except Exception:
             pass
 
     def _long_dialogue_media_busy(self):
-        """长对话:TTS/思考/播报中(按键须走打断,勿误走 continue)。"""
         try:
             if self._vc_tts_playback_active() or self._vc_tts_pipeline_busy():
                 return True
@@ -555,8 +609,21 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract):
         except Exception:
             return False
 
+    def _long_dialogue_is_stale_tts_started(self):
+        """打断等 canceled 期间的迟到 tts.started(不含 settled 尾包窗)。"""
+        if not self._vc_is_long_dialogue_mode():
+            return False
+        try:
+            if getattr(self, "_vc_pending_interrupt_uplink", 0) == 1:
+                return True
+            if getattr(self, "is_ruppt_tts", 0) == 1 and getattr(self, "_drop_tts_audio", 0) == 1:
+                return True
+        except Exception:
+            pass
+        return False
+
     def _long_dialogue_should_drop_inbound_tts(self):
-        """长对话打断窗口:丢弃迟到 TTS 帧 / 禁止起播。"""
+        """长对话打断窗口:丢弃迟到 TTS PCM(含 settle 后尾包窗)。"""
         if not self._vc_is_long_dialogue_mode():
             return False
         try:
@@ -574,9 +641,12 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract):
         return False
 
     def _long_dialogue_on_server_tts_started(self):
-        """新一轮服务端 TTS:结束打断丢弃窗口,允许 VC_Play_Start 与下行写入。"""
         if not self._vc_is_long_dialogue_mode():
             return
+        try:
+            self._vc_turn_wall_clear("tts.started")
+        except Exception:
+            pass
         try:
             if getattr(self, "_vc_pending_interrupt_uplink", 0) == 1:
                 return
@@ -609,11 +679,18 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract):
         """仅清 TTS/播放下行,保持录音与蓝慢闪。"""
         try:
             self._drop_tts_audio = 1
+            try:
+                self._vc_hold_tts_consumer_for_interrupt()
+            except Exception:
+                pass
             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:
+            preserve_tone = self._vc_should_preserve_local_tone_on_mute()
+            self._ptt_hard_mute_local_playback(skip_aud_stop=preserve_tone)
+            if preserve_tone:
+                self.VC_Play_flag = 0
+            elif 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
@@ -684,8 +761,35 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract):
         except Exception:
             pass
         return True
+
+    def _vc_hold_tts_consumer_for_interrupt(self):
+        try:
+            if getattr(self, "AI_audio_queue_thread_id", 0) == 0:
+                return
+            self.AI_audio_queue_thread_lock = 2
+            for _ in range(80):
+                if getattr(self, "AI_audio_queue_thread_id", 0) == 0:
+                    break
+                utime.sleep_ms(1)
+        except Exception:
+            pass
+
+    def _vc_should_preserve_local_tone_on_mute(self):
+        try:
+            if getattr(self, "VC_record_state_flag", 0) == 0:
+                return False
+            if not self._vc_local_play_busy():
+                return False
+            return True
+        except Exception:
+            return False
+
     def _vc_stop_downlink_for_interrupt(self, reason="interrupt"):
         """详设 DES-SCN-003:打断/取消时先本地停播清队列,再允许开麦。"""
+        try:
+            self._vc_hold_tts_consumer_for_interrupt()
+        except Exception:
+            pass
         try:
             if getattr(self, "AI_audio_queue", None) is not None:
                 if self.AI_audio_queue.empty() != True:
@@ -697,20 +801,28 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract):
                 self._drop_tts_audio = 1
         except Exception:
             pass
+        preserve_tone = False
         try:
-            self._ptt_hard_mute_local_playback()
+            preserve_tone = self._vc_should_preserve_local_tone_on_mute()
         except Exception:
             pass
         try:
-            if getattr(self, "VC_Play_flag", 0) == 1 or getattr(self, "play_pcm", None) is not None:
-                self.VC_Play_End()
+            self._ptt_hard_mute_local_playback(skip_aud_stop=preserve_tone)
         except Exception:
             pass
         try:
-            self._vc_local_play_active = 0
-            self._vc_local_play_until_ms = 0
+            if preserve_tone:
+                self.VC_Play_flag = 0
+            elif getattr(self, "VC_Play_flag", 0) == 1 or getattr(self, "play_pcm", None) is not None:
+                self.VC_Play_End()
         except Exception:
             pass
+        if not preserve_tone:
+            try:
+                self._vc_local_play_active = 0
+                self._vc_local_play_until_ms = 0
+            except Exception:
+                pass
         try:
             self.fsm.await_played = False
             self._need_audio_played_ack = 0
@@ -1419,6 +1531,8 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract):
         try:
             if getattr(self, "_vc_ld_awaiting_reply", 0) == 1:
                 return False
+            if getattr(self, "_vc_turn_cap_fired", 0) == 1:
+                return False
             if getattr(self, "_vc_defer_listening_led", 0) == 1:
                 return False
             if getattr(self, "VC_mode", -1) != 0:
@@ -1437,6 +1551,10 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract):
         """长对话 TTS/思考中短按:停播 + cancel;canceled(或 cancel 超时)后开麦。"""
         if not self._vc_is_long_dialogue_mode():
             return
+        try:
+            self._vc_turn_wall_clear("key.interrupt")
+        except Exception:
+            pass
         try:
             self._vc_turn_bump("long_interrupt")
             self.Close_flag = 0
@@ -1597,10 +1715,41 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract):
         except Exception:
             pass
     def _vc_restart_record_same_dialog(self):
-        """长对话 recording+短按:停录后立即重开(旧 sid,对齐设计稿)。"""
         if not self._vc_is_long_dialogue_mode():
             return False
         try:
+            now = utime.ticks_ms()
+            last = getattr(self, "_vc_ld_restart_last_ms", 0)
+            if last and utime.ticks_diff(now, last) < 1500:
+                try:
+                    print("[KEY] restart debounce")
+                except Exception:
+                    pass
+                return True
+            self._vc_ld_restart_last_ms = now
+            rec = int(getattr(self, "VC_record_state_flag", 0))
+            if getattr(self, "AI_client_VC_isconnect", 0) == 1:
+                try:
+                    self.AI_client_VC_send(
+                        ujson.dumps({"event": "cancel", "payload": {}}))
+                    print("[KEY] restart cancel")
+                except Exception:
+                    pass
+            if rec >= 1:
+                try:
+                    self._vc_uplink_flush_record_queue()
+                except Exception:
+                    pass
+                try:
+                    self._vc_uplink_send_silence_tail_ms(1000)
+                except Exception:
+                    pass
+            self._vc_ld_begin_new_speech_turn("key.restart")
+            self.AI_ASR_str = ""
+            self.AI_ASR_str_tmp = ""
+            self.fsm.reset_rx_phases()
+            if getattr(self.fsm, "session", None) == VCSessionState.Started:
+                self.fsm.turn = VCTurnState.Listening
             self.Close_flag = 0
             self._skip_started_uplink_once = 0
             self._vc_reset_long_idle_timer("key.restart")
@@ -1814,7 +1963,7 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract):
                 self._intr.ruppt_tts = 0
         except Exception:
             pass
-    def _ptt_hard_mute_local_playback(self):
+    def _ptt_hard_mute_local_playback(self, skip_aud_stop=False):
         try:
             self.VC_Play_flag = 0
             if getattr(self, "VC_play_TYPE", None) == VC_play_TYPE_PCM:
@@ -1825,11 +1974,12 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract):
                     except Exception:
                         pass
                     self.play_pcm = None
-            try:
-                self.aud.stopPlayStream()
-                self.aud.stopAll()
-            except Exception:
-                pass
+            if not skip_aud_stop:
+                try:
+                    self.aud.stopPlayStream()
+                    self.aud.stopAll()
+                except Exception:
+                    pass
             if getattr(self, "AI_audio_queue", None) is not None:
                 try:
                     if self.AI_audio_queue.empty() != True:
@@ -2222,7 +2372,7 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract):
         """ imperative 灯态意图;由 Presenter 与 latch 合并后单次下发。"""
         try:
             if self._vc_led_state_debug_enabled():
-                print("[LED] publish intent ai={} reason={} VC_mode={} turn={}".format(
+                print("[LED] ai={} r={}".format(
                     ai_state, reason, getattr(self, "VC_mode", -1),
                     getattr(getattr(self, "fsm", None), "turn", "?")))
         except Exception:
@@ -2257,7 +2407,15 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract):
                 if not a:
                     continue
                 if a[0] == "uplink_stop":
-                    self._uplink_request_stop(a[1] if len(a) > 1 else "")
+                    _stop_reason = a[1] if len(a) > 1 else ""
+                    if (
+                        _stop_reason == "tts.started"
+                        and self._vc_is_long_dialogue_mode()
+                        and hasattr(self, "_long_dialogue_should_drop_inbound_tts")
+                        and self._long_dialogue_should_drop_inbound_tts()
+                    ):
+                        continue
+                    self._uplink_request_stop(_stop_reason)
                 elif a[0] == "uplink_start":
                     self._uplink_request_start(a[1] if len(a) > 1 else "")
                 elif a[0] == "play_start":
@@ -2266,6 +2424,12 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract):
                             if self._vc_led_state_debug_enabled():
                                 print("[VC][serial] skip play_start (ptt_interrupt)")
                             continue
+                    if (
+                        self._vc_is_long_dialogue_mode()
+                        and hasattr(self, "_long_dialogue_should_drop_inbound_tts")
+                        and self._long_dialogue_should_drop_inbound_tts()
+                    ):
+                        continue
                     try:
                         self.fsm.on_local_play_start()
                     except Exception:
@@ -2396,13 +2560,13 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract):
         self._vc_ms_close_pending = 0
         self._vc_ms_key_block_until_ms = 0
         self._vc_close_reason = ""
-        # 服务端 VAD 结束标志:收到 vad.silence 后置 1,用于硬闸门禁止继续上行音频
-        # (避免仍持续上行导致服务端推迟/抑制后续 TTS,从而一直停留在思考态蓝绿闪)
         self._server_vad_silence = 0
         self._vc_defer_vad_uplink_stop = 0
         self._vc_vad_had_active_after_silence = 0
         self._vc_turn_wall_start_ms = 0
+        self._vc_turn_wall_had_asr_completed = 0
         self._vc_turn_cap_fired = 0
+        self._vc_turn_cap_await_asr_deadline_ms = 0
         self._vc_asr_completed_ms = 0
         self._vc_last_asr_result_ms = 0
         self._vc_asr_continue_merge = 0
@@ -2764,6 +2928,14 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract):
         self._vc_local_play_until_ms = 0
         self._vc_pending_interrupt_uplink = 0  # 长对话短按打断:等 canceled 再开麦
         self._long_dialogue_interrupt_settled_ms = 0  # 打断开麦完成时刻(过滤迟到 canceled)
+        self._vc_intr_concat = 1
+        self._vc_intr_always = 1
+        try:
+            if "vc_config" in globals() and vc_config:
+                self._vc_intr_concat = vc_config.get_int("INTERRUPT_CONCATENATE_RESULTS", 1)
+                self._vc_intr_always = vc_config.get_int("INTERRUPT_STRATEGY_ALWAYS_ALLOW", 1)
+        except Exception:
+            pass
         self.websocket_error_count = 0
         self.AI_new_voice_url = "" #未读语音地址/音乐播放地址/闹钟播放标志
         self.music_play_flag = 0 #音乐播放标志
@@ -3524,6 +3696,11 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract):
             self._vc_tick_cancel_deadline()
         except Exception:
             pass
+        try:
+            if self._vc_is_long_dialogue_mode():
+                self._vc_long_dialogue_turn_cap_tick()
+        except Exception:
+            pass
 
         _bg_active = False
         try:
@@ -3554,17 +3731,15 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract):
             except Exception:
                 pass
             try:
-                print("[VC][idle_L1] 长对话无TTS反馈超时: elapsed={}s thr={}s VC_mode={} VC_satrt={} ws_conn={} {}".format(
-                    self.close_timerout, idle_sec, self.VC_mode,
-                    getattr(self, "VC_satrt_flag", -1), getattr(self, "AI_client_VC_isconnect", -1),
-                    self._vc_fsm_log_snip()))
+                print("[VC][L1] no_tts {}s/{} {}".format(
+                    self.close_timerout, idle_sec, self._vc_fsm_log_snip()))
             except Exception:
                 pass
             try:
                 print("[VC][idle_L1] 处理=Close(无TTS反馈进idle,需再次按键进入)")
             except Exception:
                 pass
-            print("[VC][long_idle] branch=no_tts_timeout -> Close")
+            print("[VC][L1] Close")
             try:
                 self._vc_idle_l1_close = 1
             except Exception:
@@ -3597,9 +3772,7 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract):
             self._idle_deep_sec += 1
         if active_deep and self._idle_deep_sec > deep_sec:
             try:
-                print("[VC][idle_L2] 业务idle累计超时: elapsed={}s thr={}s VC_mode={} VC_satrt={} ws_conn={}".format(
-                    self._idle_deep_sec, deep_sec, self.VC_mode,
-                    getattr(self, "VC_satrt_flag", -1), getattr(self, "AI_client_VC_isconnect", -1)))
+                print("[VC][L2] deep {}s/{}".format(self._idle_deep_sec, deep_sec))
             except Exception:
                 pass
             try:
@@ -3838,9 +4011,8 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract):
             pass
         try:
             if getattr(self, "VC_mode", 0) != 1:
-                print("[VC][long_idle] Close() enter VC_mode={} {} VC_satrt_flag={} VC_record_state_flag={}".format(
-                    getattr(self, "VC_mode", -1), self._vc_fsm_log_snip(),
-                    getattr(self, "VC_satrt_flag", -1), getattr(self, "VC_record_state_flag", -1)))
+                print("[VC][li] Close m={} {}".format(
+                    getattr(self, "VC_mode", -1), self._vc_fsm_log_snip()))
         except Exception:
             pass
         self.VC_stop_play_audio_flag = 0
@@ -3898,7 +4070,7 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract):
         print("ai_VC Close1!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!")
         try:
             if getattr(self, "VC_mode", 0) != 1:
-                print("[VC][long_idle] step=set_led_state_idle (first)")
+                print("[VC][li] led idle1")
         except Exception:
             pass
         EventMesh.publish("set_led_state", {"ai_state": "idle", "prefer_off": 1})
@@ -3918,7 +4090,7 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract):
         # Close 可能被不同线程触发:统一切回主线程执行 stop
         try:
             if getattr(self, "VC_mode", 0) != 1:
-                print("[VC][long_idle] step=schedule_uplink_stop reason=Close (async publish_AI_btnInfo)")
+                print("[VC][li] uplink stop")
         except Exception:
             pass
         try:
@@ -3932,7 +4104,7 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract):
         
         try:
             if getattr(self, "VC_mode", 0) != 1:
-                print("[VC][long_idle] step=websocket_deconnect_VC")
+                print("[VC][li] ws dc")
         except Exception:
             pass
         try:
@@ -3973,7 +4145,7 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract):
         #self.AI_WS_connect_Queue.put(self.AI_client_VC)
         try:
             if getattr(self, "VC_mode", 0) != 1:
-                print("[VC][long_idle] step=set_led_state_idle (second) -> VC_init VC_satrt_flag=0")
+                print("[VC][li] led idle2")
         except Exception:
             pass
         EventMesh.publish("set_led_state", {"ai_state": "idle"})
index 31b96db..fbfe711 100644 (file)
@@ -1,5 +1,4 @@
 # -*- coding: utf-8 -*-
-"""AI_VC mixin:VC WebSocket 建链、收发线程、协议 JSON 分发。"""
 from vc_session_imports import *  # noqa: F401,F403
 from vc_notify import NotifyScheduler
 # import * 不导入以下划线开头的符号;_thread 必须显式引入(否则 start_new_thread NameError)
@@ -30,13 +29,19 @@ class VCSessionIoMixin:
             pass
         return False
 
+    def _vc_is_silent_protocol_error(self, msg):
+        try:
+            low = str(msg or "").lower()
+            return ("idle too long" in low) or ("transcribe" in low) or ("asr error" in low)
+        except Exception:
+            return False
+
     def _vc_error_tts_speakable(self, msg):
-        """协议 failed 是否走 synthesis;默认过滤技术错误,VC_ERROR_TTS_ENABLED=1 时全播。"""
         try:
             if msg is None:
                 return None
             s = str(msg).strip()
-            if not s:
+            if not s or self._vc_is_silent_protocol_error(s):
                 return None
             if self._vc_error_tts_enabled():
                 return s
@@ -97,14 +102,26 @@ class VCSessionIoMixin:
                 pass
             return
         if ev == "tts.started":
+            try:
+                if (
+                    self._vc_is_long_dialogue_mode()
+                    and hasattr(self, "_long_dialogue_is_stale_tts_started")
+                    and self._long_dialogue_is_stale_tts_started()
+                ):
+                    try:
+                        self._vc_stop_downlink_for_interrupt("stale_tts.started")
+                    except Exception:
+                        pass
+                    return
+            except Exception:
+                pass
             try:
                 self._vc_tts_rx_counters_reset()
             except Exception:
                 pass
             if self._vc_is_long_dialogue_mode():
                 try:
-                    if hasattr(self, "_vc_ld_on_tts_started"):
-                        self._vc_ld_on_tts_started()
+                    self._vc_ld_reset_merge_freeze()
                     if hasattr(self, "_long_dialogue_on_server_tts_started"):
                         self._long_dialogue_on_server_tts_started()
                 except Exception:
@@ -116,22 +133,15 @@ class VCSessionIoMixin:
                     storm = False
                     if hasattr(self, "_vc_ld_bump_cancel_in_wall"):
                         storm = self._vc_ld_bump_cancel_in_wall()
-                    if storm and hasattr(self, "_vc_ld_force_commit_turn"):
+                    if storm:
                         self._vc_ld_force_commit_turn("cancel_storm")
                     elif (
                         hasattr(self, "_long_dialogue_cancel_preserve_speech")
                         and self._long_dialogue_cancel_preserve_speech()
-                    ) or (
-                        hasattr(self, "_vc_ld_first_post_llm_cancel_barge")
-                        and self._vc_ld_first_post_llm_cancel_barge()
                     ):
                         keep_speech = True
                 if keep_speech:
-                    if (
-                        hasattr(self, "_vc_ld_should_continue_speech_on_cancel")
-                        and self._vc_ld_should_continue_speech_on_cancel()
-                        and hasattr(self, "_long_dialogue_handle_continue_speech_cancel")
-                    ):
+                    if hasattr(self, "_long_dialogue_handle_continue_speech_cancel"):
                         self._long_dialogue_handle_continue_speech_cancel()
                     elif hasattr(self, "_long_dialogue_handle_stale_cancel"):
                         self._long_dialogue_handle_stale_cancel()
@@ -180,7 +190,6 @@ class VCSessionIoMixin:
             self._vc_on_ptt_cancel_settled(via="canceled")
 
     def _vc_dispatch_server_event(self, ev, jsload_data=None):
-        """recv 线程:仅入编排队列,不在此修改 FSM。"""
         gen = self._vc_turn_current()
         self._vc_enqueue_btn(
             self._vc_orchestrator_server_event_cb,
@@ -320,8 +329,8 @@ class VCSessionIoMixin:
         """play_weak_signal_tone=True 时播 xinhaoruo1(弱网/无卡类);连接/协议失败请传 False,避免误报「信号弱」。"""
         try:
             if getattr(self, "VC_mode", 0) != 1:
-                print("[VC][long_idle] websocket_error enter VC_mode={} TTS_str={} weak_hint={} -> Close then uplink_stop(async)".format(
-                    getattr(self, "VC_mode", -1), TTS_str, play_weak_signal_tone))
+                print("[VC][li] ws_err m={} w={}".format(
+                    getattr(self, "VC_mode", -1), play_weak_signal_tone))
         except Exception:
             pass
         print("websocket_error1!!!!")
@@ -339,7 +348,7 @@ class VCSessionIoMixin:
         # 可能由收包线程/其它线程触发:统一切回主线程执行 stop,避免“异常→重试→再异常”的循环
         try:
             if getattr(self, "VC_mode", 0) != 1:
-                print("[VC][long_idle] websocket_error schedule uplink_stop reason=websocket_error + set_led_idle")
+                print("[VC][li] ws_err stop")
         except Exception:
             pass
         EventMesh.publish("publish_AI_btnInfo",{"func":self._uplink_request_stop, "data":"websocket_error"})
@@ -682,28 +691,32 @@ class VCSessionIoMixin:
                             jsload_data = vc_proto_normalize_server(jsload_data)
                         except Exception:
                             pass
+                        try:
+                            _rev = jsload_data.get("event", "")
+                        except Exception:
+                            _rev = ""
+                        _cap = int(getattr(self, "_vc_turn_cap_fired", 0))
+                        _await = int(getattr(self, "_vc_ld_awaiting_reply", 0))
                         if VC_WS_IO_LOG:
                             print("[VC] recv raw:{}".format(recv_data))
-                        else:
+                        elif _rev != "llm.delta":
+                            try:
+                                print("[VC] rx event={} sid={}".format(
+                                    _rev, jsload_data.get("sid", "") or "-"))
+                            except Exception:
+                                pass
+                        if _cap or _await:
                             try:
-                                _rev = jsload_data.get("event", "")
-                                if _rev != "llm.delta":
-                                    print("[VC] rx event={} sid={}".format(
-                                        _rev, jsload_data.get("sid", "") or "-"))
+                                print("[VC][rx] ev={} n={} cap={} aw={}".format(
+                                    _rev or "?", len(recv_data), _cap, _await))
                             except Exception:
                                 pass
-                        #print("AI_client_VC_recv jsload_data = {}".format(jsload_data))
                         try:
                             self._cur_sid = jsload_data.get("sid", "") or ""
                         except Exception:
                             self._cur_sid = ""
-                        try:
-                            ev = jsload_data.get("event", "")
-                            if ev:
-                                self._vc_dispatch_server_event(ev, jsload_data)
-                        except Exception:
-                            pass
-                        
+                        if _rev:
+                            self._vc_dispatch_server_event(_rev, jsload_data)
                         if jsload_data["event"] == "started" :#会话开始
                             #payload = jsload_data["payload"]
                             # self.VC_dialog_id = payload["dialog_id"] 
@@ -728,7 +741,7 @@ class VCSessionIoMixin:
                                 try:
                                     self._skip_started_uplink_once = 0
                                     _skip_long_up = 1
-                                    print("[VC][uplink_ctrl] Deny START by server.started cause=boot_prompt")
+                                    print("[VC][up] Deny started boot_prompt")
                                 except Exception:
                                     pass
                             print("VC:Started!!!!!!!!\r\n")
@@ -799,8 +812,7 @@ class VCSessionIoMixin:
                                     self._turn_has_asr_result = 1
                                     # 有效 ASR 后勿再执行空 ASR 提示音播完开麦
                                     self._vc_reopen_uplink_after_empty_hint = 0
-                                    if hasattr(self, "_vc_note_asr_result_activity"):
-                                        self._vc_note_asr_result_activity()
+                                    self._vc_note_asr_result_activity()
                             except Exception:
                                 pass
                             if self.AI_ASR_str == "":#没识别到
@@ -809,13 +821,18 @@ class VCSessionIoMixin:
                         elif jsload_data["event"] == "asr.completed" :#asr 终止的标识 
                             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")
+                                if (
+                                    self._vc_is_long_dialogue_mode()
+                                    and getattr(self, "_vc_turn_wall_start_ms", 0)
+                                ):
+                                    self._vc_turn_wall_had_asr_completed = 1
                             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")
+                                self._vc_turn_cap_await_asr_deadline_ms = 0
+                                if getattr(self, "_vc_turn_cap_fired", 0) == 1:
+                                    self._vc_ld_merge_frozen = 0
+                                    self._vc_ld_awaiting_reply = 1
                             except Exception:
                                 pass
                             try:
@@ -823,6 +840,11 @@ class VCSessionIoMixin:
                                     self.AI_ASR_str = getattr(self, "_last_asr_result_text", "") or ""
                             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")
+                            except Exception:
+                                pass
                             # 本地 think 提示:仅在有有效 ASR 文本时播,避免空轮次连播「滴」
                             try:
                                 if getattr(self, "_asr_completed_tone_done", 0) == 1:
@@ -856,11 +878,15 @@ class VCSessionIoMixin:
                                         self._asr_completed_tone_done = 1
                                     elif vad_cont:
                                         try:
-                                            print("[VC][ASR] skip think tone (vad pause merged)")
+                                            print("[VC][ASR] skip think vad merge")
                                         except Exception:
                                             pass
                             except Exception:
                                 pass
+                            try:
+                                self._vc_flush_deferred_vad_uplink_stop("asr.completed")
+                            except Exception:
+                                pass
                             print("self.commands_mode:{}".format(self.commands_mode))
                             if self.commands_mode ==2:
                                 ret= self.check_text(self._vc_effective_asr_text())
@@ -905,11 +931,12 @@ class VCSessionIoMixin:
                             except Exception:
                                 pass
                         elif jsload_data["event"] == "vad.silence" :#VAD 终止的标识 
-                            # 长对话:句中停顿仅标记,等 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 上行
+                                if (
+                                    self._vc_is_long_dialogue_mode()
+                                    and self.record_data_save_flag == 0
+                                ):
                                     self._vc_defer_vad_uplink_stop = 1
                                     self._server_vad_silence = 1
                                     try:
@@ -932,6 +959,15 @@ class VCSessionIoMixin:
                         elif jsload_data["event"] == "llm.created" :#思考(LLM)
                             try:
                                 self._turn_has_llm_created = 1
+                                self._vc_ld_awaiting_reply = 0
+                                self._vc_turn_cap_fired = 0
+                            except Exception:
+                                pass
+                            try:
+                                if self._vc_is_long_dialogue_mode():
+                                    self._vc_asr_continue_merge = 0
+                                    self._vc_pending_asr_completed_stop = 0
+                                    self._vc_vad_had_active_after_silence = 0
                             except Exception:
                                 pass
                             try:
@@ -1164,6 +1200,23 @@ class VCSessionIoMixin:
                                 pass
                             if err == "concurrency_limit_exceeded":
                                 EventMesh.publish("publish_AI_btnInfo",{"func":self.concurrency_limit_exceeded_tone}) 
+                            elif (
+                                getattr(self, "VC_mode", -1) == 0
+                                and ("idletimeout" in str(msg).lower()
+                                     or "idle timeout" in str(msg).lower())
+                                and (
+                                    getattr(self, "_vc_ld_awaiting_reply", 0) == 1
+                                    or getattr(self, "_vc_turn_cap_fired", 0) == 1
+                                    or self._vc_effective_asr_text()
+                                )
+                            ):
+                                self._vc_ld_awaiting_reply = 0
+                                self._vc_turn_cap_fired = 0
+                                print("[VC][ASR] idle to skip Close")
+                                try:
+                                    self._vc_publish_led("idle", "asr/idle_timeout")
+                                except Exception:
+                                    pass
                             else:
                                 speak = self._vc_error_tts_speakable(msg)
                                 EventMesh.publish(
@@ -1287,6 +1340,11 @@ class VCSessionIoMixin:
                             print("被打断不播放")
                         utime.sleep_ms(1)
                 except Exception as e:
+                    if isinstance(recv_data, str):
+                        print("[VC][rx] json_err n={} cap={} aw={}".format(
+                            len(recv_data),
+                            getattr(self, "_vc_turn_cap_fired", 0),
+                            getattr(self, "_vc_ld_awaiting_reply", 0)))
                     usys.print_exception(e)
                 
             except Exception as e:
@@ -1820,10 +1878,8 @@ class VCSessionIoMixin:
                     "disable_vad":True,
                     "vad_silence":self.vad_silence,
                     "interrupt_options":{
-                        "concatenate_results":True,
-                        "thinking_time_only": True,
-                        #"keyword_interrupt": false, // 启用打断词功能 (打断词只有在思考以及之后才能打断)
-                        #"interrupt_keywords": ["停止", "打断","打断一下", "等一下", "别说了", "打住"] // 配置打断词列表
+                        "strategy": "always_allow",
+                        "concatenate_results": True,
                     },
                     "cfg": {
                         "format": "pcm",
index 3915bb8..aa79d54 100644 (file)
@@ -143,50 +143,15 @@ class VCSessionMediaMixin:
             usys.print_exception(e)
             return False
 
-    def _vc_play_key_press_tone(self, via=""):
-        """已废弃:K8 无按键确认音;仅 RECORD_START_TONE(start.mp3)。配置缺省则不播。"""
-        try:
-            path = None
-            hold_ms = 300
-            if "vc_config" in globals() and vc_config:
-                cfg = vc_config.get_attr("KEY_PRESS_TONE_FILE", None)
-                if cfg is not None and str(cfg).strip():
-                    path = str(cfg).strip()
-                hold_ms = int(vc_config.get_int("KEY_PRESS_TONE_DELAY_MS", hold_ms))
-            if not path:
-                return False
-            print("[VC] key_press_tone via={} path={}".format(via or "-", path))
-            self._vc_local_play(path, hold_ms=hold_ms)
-            return True
-        except Exception as e:
-            usys.print_exception(e)
-            return False
-
-    def _vc_arm_key_press_tone(self):
-        try:
-            self._vc_pending_key_press_tone = 1
-        except Exception:
-            pass
-
-    def _vc_try_play_key_press_tone_when_ready(self, via=""):
-        try:
-            if not getattr(self, "_vc_pending_key_press_tone", 0):
-                return False
-            if getattr(self, "Close_flag", 0) == 1:
-                return False
-            if not self._vc_play_key_press_tone(via=via):
-                return False
-            self._vc_pending_key_press_tone = 0
-            return True
-        except Exception:
-            return False
-
     _vc_play_ai_key_record_start_tone = _vc_play_record_start_tone
 
     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")
+            or r.startswith("BT_AI_key_press")
+        ):
             return True
         if r.startswith("ptt_begin") or r == "asr.started/defer":
             return True
@@ -240,6 +205,19 @@ class VCSessionMediaMixin:
             self._vc_skip_local_play_stop_record = 0
         except Exception:
             pass
+        try:
+            if getattr(self, "VC_mode", 0) == 0:
+                r = str(reason or "")
+                if r in ("local.play_end", "audio.played", "server.started"):
+                    self._vc_ld_begin_new_speech_turn(r)
+                elif (
+                    r.startswith("key.")
+                    and getattr(self, "_vc_pending_interrupt_uplink", 0) != 1
+                    and getattr(self, "is_ruppt_tts", 0) != 1
+                ):
+                    self._vc_ld_begin_new_speech_turn(r)
+        except Exception:
+            pass
         try:
             if getattr(self, "VC_record_state_flag", 0) == 1:
                 self.VC_Speech_Start_now()
@@ -257,8 +235,15 @@ class VCSessionMediaMixin:
         try:
             if getattr(self, "VC_mode", 0) == 0:
                 self._long_dialogue_key_arm = 0
+                # 打断开麦:须等 server.canceled 再 settle;否则迟到 tts.started 会抢播
                 if hasattr(self, "_long_dialogue_end_interrupt"):
-                    self._long_dialogue_end_interrupt()
+                    _intr_open = (
+                        getattr(self, "_vc_pending_interrupt_uplink", 0) == 1
+                        or getattr(self, "is_ruppt_tts", 0) == 1
+                        or getattr(self, "_drop_tts_audio", 0) == 1
+                    )
+                    if not _intr_open:
+                        self._long_dialogue_end_interrupt()
             elif getattr(self, "VC_mode", -1) == 1:
                 if hasattr(self, "_ptt_on_record_hardware_started"):
                     self._ptt_on_record_hardware_started()
@@ -280,11 +265,6 @@ class VCSessionMediaMixin:
             self._vc_user_record_entry_long(reason)
 
     def _vc_user_record_entry_long(self, reason=""):
-        """
-        长对话开麦:
-        - 按键/打断续聊:先 start.mp3,滴声结束后再上行+蓝慢闪
-        - 自动续聊(server.started/play_end 等):不播 start,直接上行
-        """
         try:
             self._vc_user_record_open_reason = str(reason or "")
             self._vc_defer_listening_led = 1
@@ -331,9 +311,6 @@ class VCSessionMediaMixin:
                 pass
 
     def _vc_user_record_entry_ptt(self, reason=""):
-        """
-        单对话 PTT 长按:立即录音+上行;按键开录时并行播 start.mp3;松手 end_speech 停传。
-        """
         try:
             self._vc_user_record_open_reason = str(reason or "")
             self._vc_defer_listening_led = 0
@@ -538,7 +515,14 @@ class VCSessionMediaMixin:
                         return True
                 except Exception:
                     pass
-                print("[VC][uplink_ctrl] Deny START by canceled cause=not_user_interrupt")
+                print("[VC][up] Deny START by canceled cause=not_user_interrupt")
+                return False
+            if (
+                reason == "completed"
+                and getattr(self, "_vc_expect_dialog_tts", 0) == 1
+                and getattr(self, "_turn_has_tts_started", 0) == 0
+            ):
+                print("[VC][up] Deny START by completed cause=await_tts")
                 return False
             if (
                 reason == "canceled"
@@ -563,13 +547,13 @@ class VCSessionMediaMixin:
                     # 长对话:有有效 ASR 的 completed 才自动下一轮(空 ASR 不抢开麦)
                     pass
                 else:
-                    print("[VC][uplink_ctrl] Deny START by {} cause=empty_asr".format(reason))
+                    print("[VC][up] Deny START by {} cause=empty_asr".format(reason))
                     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")
+                    print("[VC][up] Deny START by completed cause=llm_without_tts")
                     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")
@@ -592,7 +576,7 @@ class VCSessionMediaMixin:
             last = getattr(self, last_attr, 0)
             if last and utime.ticks_diff(now, last) < debounce_ms:
                 tag = "interrupt_debounce" if user_intr else "auto_debounce"
-                print("[VC][uplink_ctrl] Deny START by {} cause={} ({}ms)".format(
+                print("[VC][up] Deny START by {} cause={} ({}ms)".format(
                     reason, tag, debounce_ms))
                 return False
             setattr(self, last_attr, now)
@@ -615,7 +599,7 @@ class VCSessionMediaMixin:
                 else:
                     try:
                         if reason in ("canceled", "local.play_end", "audio.played", "key.press/continue"):
-                            print("[VC][uplink_ctrl] Deny START by {} cause=Close_flag".format(reason))
+                            print("[VC][up] Deny START by {} cause=Close_flag".format(reason))
                     except Exception:
                         pass
                     return
@@ -646,7 +630,7 @@ class VCSessionMediaMixin:
         try:
             if not self._vc_capture_allowed(reason):
                 try:
-                    print("[VC][uplink_ctrl] Deny START by {} cause=playback_busy".format(reason))
+                        print("[VC][up] deny {} busy".format(reason))
                 except Exception:
                     pass
                 return
@@ -658,9 +642,9 @@ class VCSessionMediaMixin:
             if getattr(self.fsm, "session", VCSessionState.Disconnected) != VCSessionState.Started:
                 try:
                     if reason in _key_reasons:
-                        print("[VC][uplink_ctrl] Defer START by {} (wait server.started)".format(reason))
+                        print("[VC][up] defer {}".format(reason))
                     elif reason in ("canceled", "local.play_end", "audio.played"):
-                        print("[VC][uplink_ctrl] Deny START by {} cause=fsm.session".format(reason))
+                        print("[VC][up] deny {} sess".format(reason))
                 except Exception:
                     pass
                 return
@@ -669,46 +653,22 @@ class VCSessionMediaMixin:
             if turn not in (VCTurnState.Listening, VCTurnState.SpeechEnded):
                 try:
                     if reason in _key_reasons or reason in ("canceled", "local.play_end", "audio.played"):
-                        print("[VC][uplink_ctrl] Deny START by {} cause=fsm.turn({})".format(reason, turn))
+                        print("[VC][up] deny {} t={}".format(reason, turn))
                 except Exception:
                     pass
                 return
         except Exception:
             return
 
-        # 若当前不在录音上行,才允许开新一轮
-        if getattr(self, "VC_record_state_flag", 0) != 0:
-            # canceled 打断后:可能是僵死 flag=2(started 未真开麦),先停录复位再开麦
-            if reason == "canceled":
-                try:
-                    self._vc_clear_stale_record_state("uplink_start/canceled")
-                except Exception:
-                    pass
-            if getattr(self, "VC_record_state_flag", 0) != 0:
-                try:
-                    if reason in ("canceled", "local.play_end", "audio.played"):
-                        print("[VC][uplink_ctrl] Deny START by {} cause=VC_record_state_flag({})".format(
-                            reason, getattr(self, "VC_record_state_flag", 0)))
-                except Exception:
-                    pass
-                return
-
-        # 新一轮开始录音:允许上行(上一轮可能已置 silence 闸门)
-        if getattr(self, "record_data_save_flag", 0) == 0:
-            self._server_vad_silence = 0
-
-        print("[VC][uplink_ctrl] Request START by {}".format(reason))
+        # 打断 cancel_wait 超时已开麦:迟到的 server.canceled 勿停录重开(避免双 start.mp3)
         try:
             if (
                 reason == "canceled"
                 and getattr(self, "VC_mode", 0) == 0
-                and (
-                    getattr(self, "_vc_pending_interrupt_uplink", 0) == 1
-                    or getattr(self, "is_ruppt_tts", 0) == 1
-                )
+                and getattr(self, "_vc_pending_interrupt_uplink", 0) == 1
                 and getattr(self, "VC_record_state_flag", 0) != 0
             ):
-                print("[VC][uplink_ctrl] interrupt -> already recording, skip restart")
+                print("[VC][up] intr skip")
                 try:
                     self._long_dialogue_key_arm = 0
                     if hasattr(self, "_long_dialogue_end_interrupt"):
@@ -722,9 +682,32 @@ class VCSessionMediaMixin:
             pass
         try:
             if reason == "canceled" and getattr(self, "_vc_pending_interrupt_uplink", 0) == 1:
-                print("[VC][uplink_ctrl] interrupt -> start (canceled)")
+                print("[VC][up] intr start")
         except Exception:
             pass
+
+        # 若当前不在录音上行,才允许开新一轮
+        if getattr(self, "VC_record_state_flag", 0) != 0:
+            # canceled 打断后:可能是僵死 flag=2(started 未真开麦),先停录复位再开麦
+            if reason == "canceled":
+                try:
+                    self._vc_clear_stale_record_state("uplink_start/canceled")
+                except Exception:
+                    pass
+            if getattr(self, "VC_record_state_flag", 0) != 0:
+                try:
+                    if reason in ("canceled", "local.play_end", "audio.played"):
+                        print("[VC][up] deny {} rs={}".format(
+                            reason, getattr(self, "VC_record_state_flag", 0)))
+                except Exception:
+                    pass
+                return
+
+        # 新一轮开始录音:允许上行(上一轮可能已置 silence 闸门)
+        if getattr(self, "record_data_save_flag", 0) == 0:
+            self._server_vad_silence = 0
+
+        print("[VC][up] Request START by {}".format(reason))
         self._vc_uplink_start_recording(reason)
     def _uplink_request_stop(self, reason="", *args, **kwargs):
         # 统一入口:避免多处同时 stop 造成冲突
@@ -734,18 +717,18 @@ class VCSessionMediaMixin:
         if getattr(self, "VC_record_state_flag", 0) == 0:
             try:
                 if getattr(self, "VC_mode", 0) != 1:
-                    print("[VC][long_idle] _uplink_request_stop skip (record_state==0) reason={}".format(reason))
+                    print("[VC][idle] stop skip rs=0 r={}".format(reason))
             except Exception:
                 pass
             return
 
         try:
             if getattr(self, "VC_mode", 0) != 1:
-                print("[VC][long_idle] step=uplink_stop_exec reason={} VC_record_state_flag={}".format(
+                print("[VC][idle] uplink_stop r={} rs={}".format(
                     reason, getattr(self, "VC_record_state_flag", -1)))
         except Exception:
             pass
-        print("[VC][uplink_ctrl] Request STOP by {}".format(reason))
+        print("[VC][up] Request STOP by {}".format(reason))
         self.VC_Speech_End()
     def _uplink_mark_start(self, first_len=0):
         try:
@@ -757,8 +740,17 @@ class VCSessionMediaMixin:
             self._uplink_tx_fail_cnt = 0
             self._uplink_tx_fail_bytes = 0
             self._uplink_tx_fail_log_ms = 0
-            print("[VC][uplink] START len={} {} VC_record_state_flag={}".format(
-                first_len, self._vc_fsm_log_snip(), self.VC_record_state_flag))
+            try:
+                if (
+                    getattr(self, "VC_mode", -1) == 0
+                    and not getattr(self, "_vc_turn_wall_start_ms", 0)
+                ):
+                    self._vc_turn_wall_start_ms = utime.ticks_ms()
+                    self._vc_turn_wall_had_asr_completed = 0
+                    self._vc_turn_cap_fired = 0
+            except Exception:
+                pass
+            print("[VC][up] START len={} rs={}".format(first_len, self.VC_record_state_flag))
         except Exception:
             pass
 
@@ -772,28 +764,31 @@ class VCSessionMediaMixin:
             last = getattr(self, "_uplink_tx_fail_log_ms", 0)
             if not last or utime.ticks_diff(now, last) >= 800:
                 self._uplink_tx_fail_log_ms = now
-                print(
-                    "[VC][uplink] TX_FAIL reason={} len={} fail_cnt={} fail_bytes={} {}".format(
-                        reason or "?",
-                        n,
-                        self._uplink_tx_fail_cnt,
-                        self._uplink_tx_fail_bytes,
-                        self._vc_fsm_log_snip(),
-                    )
-                )
+                print("[VC][up] TX_FAIL r={} n={} fc={}".format(
+                    reason or "?", n, self._uplink_tx_fail_cnt))
         except Exception:
             pass
 
+    def _vc_uplink_send_silence_tail_ms(self, ms=1000):
+        try:
+            if getattr(self, "AI_client_VC_isconnect", 0) != 1:
+                return 0
+            n = 32 * int(ms)
+            if n <= 0:
+                return 0
+            self.AI_client_VC_send(bytes(n))
+            print("[VC][up] silence_tail ms={} b={}".format(int(ms), n))
+            return n
+        except Exception:
+            return 0
+
     def _uplink_mark_end(self, reason=""):
         try:
             if self._uplink_active != 1:
                 return
             dur = utime.ticks_diff(utime.ticks_ms(), self._uplink_start_ms) if self._uplink_start_ms else -1
-            fc = getattr(self, "_uplink_tx_fail_cnt", 0)
-            fb = getattr(self, "_uplink_tx_fail_bytes", 0)
-            print("[VC][uplink] END reason={} bytes={} dur_ms={} tx_fail_cnt={} tx_fail_bytes={} {} VC_record_state_flag={}".format(
-                reason, self._uplink_bytes, dur, fc, fb,
-                self._vc_fsm_log_snip(), self.VC_record_state_flag))
+            print("[VC][up] END r={} b={} d={} rs={}".format(
+                reason, self._uplink_bytes, dur, self.VC_record_state_flag))
         except Exception:
             pass
         self._uplink_active = 0
@@ -802,11 +797,29 @@ class VCSessionMediaMixin:
         self._uplink_tx_fail_cnt = 0
         self._uplink_tx_fail_bytes = 0
         self._uplink_tx_fail_log_ms = 0
-
-        # 最近一次 TTS 的语义来源(dialog/prompt/mode_switch_long/mode_switch_ptt/...)
         self._last_tts_kind = ""
-        # 下一个“无 dialog_id 的提示音 TTS”在 tts.started 时采用该 kind
         self._pending_tts_kind = ""
+    def _vc_uplink_flush_record_queue(self):
+        try:
+            if getattr(self, "record_data_save_flag", 0) != 0:
+                return
+            q = getattr(self, "AI_record_tmp_queue", None)
+            if not q:
+                return
+            ps = int(getattr(self, "_server_vad_silence", 0))
+            self._server_vad_silence = 0
+            n = 0
+            while q.empty() != True:
+                b = q.get()
+                if b:
+                    self.AI_client_VC_send(b)
+                    n += len(b)
+            self._server_vad_silence = ps
+            if n:
+                print("[VC][up] flush n={}".format(n))
+        except Exception:
+            pass
+
     def request_uplink_start(self, reason=""):
         EventMesh.publish("publish_AI_btnInfo", {"func": self._uplink_request_start, "data": reason})
     def request_uplink_stop(self, reason=""):
@@ -873,6 +886,12 @@ class VCSessionMediaMixin:
                     return
             except Exception:
                 pass
+            try:
+                if getattr(self, "_vc_ld_awaiting_reply", 0) == 1:
+                    self._vc_publish_led("thinking", "audio_cb/finish/await_llm")
+                    return
+            except Exception:
+                pass
             try:
                 if self._vc_should_hold_listening_led():
                     self._vc_publish_listening_for_mode("audio_cb/finish/listen_hold")
@@ -903,8 +922,10 @@ class VCSessionMediaMixin:
         if self.VC_record_state_flag  == 1:
             self.VC_record_state_flag =2
         try:
-            if hasattr(self, "_vc_turn_wall_arm_if_needed"):
-                self._vc_turn_wall_arm_if_needed()
+            if getattr(self, "VC_mode", -1) == 0 and not getattr(self, "_vc_turn_wall_start_ms", 0):
+                self._vc_turn_wall_start_ms = utime.ticks_ms()
+                self._vc_turn_wall_had_asr_completed = 0
+                self._vc_turn_cap_fired = 0
         except Exception:
             pass
         try:
@@ -913,6 +934,31 @@ class VCSessionMediaMixin:
         except Exception:
             pass
         print("[VC] Speech_Start_now record_state={}".format(self.VC_record_state_flag))
+
+    def _vc_release_play_pcm_for_record(self):
+        try:
+            if getattr(self, "VC_play_TYPE", None) != VC_play_TYPE_PCM:
+                return
+            if getattr(self, "play_pcm", None) is None:
+                return
+            try:
+                self.play_pcm.stream_stop()
+                self.play_pcm.close()
+            except Exception:
+                pass
+            self.play_pcm = None
+            self.VC_Play_flag = 0
+            gap = 30
+            try:
+                if "vc_config" in globals() and vc_config:
+                    gap = int(vc_config.get_int("PTT_POST_MUTE_MS", gap))
+            except Exception:
+                pass
+            if gap > 0:
+                utime.sleep_ms(gap)
+        except Exception:
+            pass
+
     def VC_Speech_Start(self):
         try:
             
@@ -925,8 +971,7 @@ class VCSessionMediaMixin:
                 # 新一轮开始录音:允许上行(上一轮可能已置 silence 闸门)
                 self._server_vad_silence = 0
                 try:
-                    if hasattr(self, "_vc_turn_wall_clear"):
-                        self._vc_turn_wall_clear()
+                    self._vc_turn_wall_clear(reset_merge=1)
                 except Exception:
                     pass
                 self.AI_ASR_str = ""
@@ -951,6 +996,7 @@ class VCSessionMediaMixin:
                                 usys.print_exception(e)
                     elif self.VC_record_TYPE == VC_record_TYPE_PCM:
                         print("record_pcm.stream_read_start()!!!!!!!!!!!!")
+                        self._vc_release_play_pcm_for_record()
                         _rec_started = 0
                         for _ri in range(2):
                             try:
@@ -990,12 +1036,8 @@ class VCSessionMediaMixin:
                 self.VC_record_state_flag = 1
                 # 录音硬件已起:协议侧应对应 audio.create(PTT)或长对话直传 PCM
                 try:
-                    print("[VC][proto] record_start sr={} pcm_type={} VC_mode={} session_started={} "
-                          "ptt_audio_created={} thread_id={}".format(
-                              self.VC_record_samplerate, self.VC_record_TYPE, self.VC_mode,
-                              getattr(self, "_vc_session_started", -1),
-                              getattr(self, "_ptt_audio_created", -1),
-                              self.VC_record_thread_id))
+                    print("[VC][p] rec sr={} m={}".format(
+                        self.VC_record_samplerate, self.VC_mode))
                 except Exception:
                     pass
                 # 聆听灯与上行一并开启(见 _vc_user_record_uplink_open)
@@ -1020,10 +1062,20 @@ class VCSessionMediaMixin:
             usys.print_exception(e)   
     def VC_Speech_End(self):
         try:
+            ld_commit = 0
+            try:
+                if getattr(self, "VC_mode", -1) == 0:
+                    ld_commit = (
+                        getattr(self, "_vc_turn_cap_fired", 0) == 1
+                        or getattr(self, "_vc_ld_awaiting_reply", 0) == 1
+                        or getattr(self, "_vc_ld_merge_frozen", 0) == 1
+                    )
+            except Exception:
+                ld_commit = 0
             self._uplink_mark_end("VC_Speech_End")
             try:
-                if hasattr(self, "_vc_turn_wall_clear"):
-                    self._vc_turn_wall_clear()
+                if not ld_commit:
+                    self._vc_turn_wall_clear(reset_merge=1)
             except Exception:
                 pass
             self.AI_ASR_flag = 0
@@ -1090,12 +1142,14 @@ class VCSessionMediaMixin:
                     and getattr(self, "Close_flag", 0) == 0
                     and getattr(self, "AI_client_VC_isconnect", 0) == 1
                 ):
-                    if (
-                        getattr(self, "_vc_turn_cap_fired", 0) == 1
-                        or getattr(self, "_vc_ld_merge_frozen", 0) == 1
-                        or getattr(self, "_vc_ld_awaiting_reply", 0) == 1
-                    ) and self._vc_effective_asr_text():
+                    if ld_commit and self._vc_effective_asr_text():
                         self._vc_publish_led("thinking", "VC_Speech_End/force_commit")
+                    elif self._vc_effective_asr_text() and (
+                        getattr(self, "_turn_has_asr_completed", 0) == 1
+                        or getattr(self, "_vc_expect_dialog_tts", 0) == 1
+                        or getattr(self, "_turn_has_llm_created", 0) == 1
+                    ):
+                        self._vc_publish_led("thinking", "VC_Speech_End/await_llm")
                     else:
                         self._vc_publish_listening_for_mode("VC_Speech_End/long_wait")
                 else:
@@ -1244,10 +1298,8 @@ class VCSessionMediaMixin:
                        
                     else:
                         try:
-                            if hasattr(self, "_vc_long_dialogue_turn_cap_tick"):
-                                self._vc_long_dialogue_turn_cap_tick()
-                            if hasattr(self, "_vc_asr_completed_stop_tick"):
-                                self._vc_asr_completed_stop_tick()
+                            self._vc_long_dialogue_turn_cap_tick()
+                            self._vc_asr_completed_stop_tick()
                         except Exception:
                             pass
                         VAD_data_count = 0
@@ -1425,6 +1477,14 @@ class VCSessionMediaMixin:
                 return
         except Exception:
             pass
+        try:
+            if (
+                hasattr(self, "_long_dialogue_should_drop_inbound_tts")
+                and self._long_dialogue_should_drop_inbound_tts()
+            ):
+                return
+        except Exception:
+            pass
         #print("下发音频数据1:{}".format(len(data[0])))
         ret=self.AI_audio_queue.put(data)
         try:
@@ -1862,9 +1922,6 @@ class VCSessionMediaMixin:
             if not ready and q_empty and tts_done_ms:
                 try:
                     if since_done >= 15000 and pcm_pending <= slack:
-                        print(
-                            "[TTS] play_end_force since_done={}ms pcm_pending={}".format(
-                                since_done, pcm_pending))
                         ready = True
                 except Exception:
                     pass