修复长对话模式下,一直播放音频,会造成一直在收录模式的bug,现在超时会自动停止。
authorSnickerSun <snickersun@163.com>
Wed, 27 May 2026 23:55:29 +0000 (07:55 +0800)
committerSnickerSun <snickersun@163.com>
Wed, 27 May 2026 23:55:29 +0000 (07:55 +0800)
.cursor/rules/002-design.mdc
2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/default_config.py
2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/products/configs/7PIN_1_5/default_config.py
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/products/configs/k8v3/default_config.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 9e9e79f..dcf28b5 100644 (file)
 ---
-description: K8吊坠 VC 录音/上传/播放/LED/按键设计动作矩阵(7PIN_1_5)
+description: K8吊坠 VC 录音/上传/播放/LED/按键设计思路(7PIN_1_5)
 alwaysApply: true
 globs: 2.sdk/project/7PIN_1_5/**/vc_*.py,2.sdk/project/7PIN_1_5/**/protocol.py
 ---
 
-# VC 录音上传与播放 — 设计动作(7PIN_1_5)
+# VC 录音上传与播放 — 设计思路(7PIN_1_5)
 
-**权威设计稿**:`1.doc/K8_diaozhui/design/录音上传与播放-演示文稿.md`  
-**量产配置**:`products/configs/k8/default_config.py`(`product_k8.ini` → `DEV_TYPE=k8`)  
-**实现骨架**:`vc_ux` / `vc_key_router` / `vc_media_arb`
+详细演示稿:`1.doc/K8_diaozhui/design/录音上传与播放-演示文稿.md`  
+量产配置:`products/configs/k8v3/default_config.py`(`product_k8v3.ini`)  
+主代码:`vc_session` / `vc_session_io` / `vc_session_media`,按键走 `vc_key_router`
 
-## k8 配置摘要(与代码绑定)
+---
+
+## 三种声音,别混
 
-| 配置项 | k8 值 | 用途 |
-|--------|-------|------|
-| `RECORD_START_TONE_FILE` | `U:/static/start.mp3` | **开始录音** local 提醒(非按键滴声) |
-| `RECORD_START_TONE_GAP_MS` | 180 | 长对话:start 播完再开上行 |
-| `VAD_SILENCE_DEFAULT` | 1500 | 长对话 VAD 断句 |
-| `PTT_HINT` | 长按说话话术 | PTT **idle 短按** → notify(`_ptt_play_idle_hint`) |
-| `PTT_MIN_RECORD_MS` | 400 | PTT 长按过短判定 |
-| `PTT_RECORD_TOO_SHORT_TTS` | 见配置 | PTT **长按过短** → notify(`_ptt_abort_short_record`) |
-| `POWEROFF_CMD_CLOSE_ONLY` | False | 语音「关机」可走真断电 |
-| `AI_ALARM_ENABLED` | False | 无闹钟 |
+- **对话 TTS**:云端 LLM 回复,走 `play_pcm`
+- **提示 TTS**:notify(模式切换、PTT 教操作等),走 `play_tts`
+- **本地音**:`start.mp3`、`think.mp3` 等,走 `_vc_local_play`
 
-**不配置**:`KEY_PRESS_TONE_FILE`(`key_prompt.mp3` 非 K8 需求;按键本身无确认音)。
+同时只能占一路:谁响谁优先;本地提示音播放时**不要停录音**。
+
+---
 
-**PTT 两条 notify 勿混**:`PTT_HINT` = 没长按、教操作;`PTT_RECORD_TOO_SHORT_TTS` = 已长按但松太快、取消录音。
+## 灯怎么亮
 
-## 术语 ↔ 代码
+- 正在上传/录音 → 蓝闪(listening)
+- 等云端想回复 → 蓝绿交替(thinking)
+- 正在播对话 → 绿(playing)
+- 没事 → 灭或 idle
+
+长对话 TTS 播完后可以自动再开麦;Power 短按结束会话后会清对话 id。
+
+---
 
-| 设计列 | 实现 |
-|--------|------|
-| **llm tts** | `play_pcm` / `tts.started` |
-| **notify tts** | `play_tts`(`vc_notify` 种类) |
-| **local play** | `_vc_local_play`、`audio_montage_play` |
+## 长对话:用户一直在说,端侧怎么想
 
-**仲裁**:llm 播 → notify/local skip;notify 播 → 关 llm+local;local 播 → 关 llm+notify;边录边播 local **不停录**。
+云端逻辑是:你停一下它认为一句结束,就开始想怎么答(LLM);你若马上又开口,它会打断(canceled)并把前后文字拼在一起。
 
-## 录音 / LED
+**端侧要跟云端配合,不能对着干**:LLM 已经开始了,就不要再无限开麦续传,否则 LLM 永远答不完,灯一直蓝闪却没有声音。
 
-- **开始上传**:以首帧 PCM 上行(`VC_Speech_Start_now`)为起点;此前三路 media end。
-- **结束**:VAD → `asr.completed`;PTT 松手 `audio.completed`。
-- **灯**:上传→listening;ASR 后 LLM→thinking;`tts.started`→playing;否则 idle。
+### 正常说一句(中间有停顿)
 
-## 按键 — AI 键(`VC_mode` 0/1)
+说完 → 云端 `asr.completed` → 先暂停上行,等约 2 秒:
 
-| UX | 长对话单击 | PTT 单击 | PTT 长按 |
-|----|------------|----------|----------|
-| idle | 开录 + `start.mp3`;**仅 Power 短按后再开用新 sid**,否则旧 sid | notify `PTT_HINT`;idle | press 开录 + `start.mp3` / release 结束 |
-| recording | 停录立即重开,旧 sid | (无) | 同单击 |
-| thinking | 打断后重开,旧 sid | 停对话→idle | 停对话后开录 |
-| tts | 停播后重开,旧 sid | 停播→idle | 停播后开录 |
+- 2 秒内没再说话 → 关麦,蓝闪等 LLM
+- 2 秒内又说了几个字 → 当作同一句继续,合并后再断句
 
-**PTT 异常**:录音 < `PTT_MIN_RECORD_MS` → **notify** `PTT_RECORD_TOO_SHORT_TTS` + cancel + idle。
+### LLM 已经开始之后
 
-## 按键 — Power 键
+- **收到 LLM 就关麦**(不再边想边传新音频)
+- 若你还接着说了,云端会 canceled;**第一次** canceled 允许再开麦把尾巴接上(补半句)
+- **同一轮里第二次** canceled → 不再续说,停麦,切 thinking,等云端用已有文字答完
+- **一轮最长约 25 秒**(`LONG_DIALOGUE_MAX_TURN_MS`),到了强制提交,同样停麦等回复
+
+### 别踩的坑(改代码时记住)
+
+- LLM 之后还无限 `continue merge` → 和云端打断打架
+- 每次 LLM 都把 canceled 计数清零 → 保护永远触发不了
+- 停麦了灯还蓝闪 → 用户以为还在录
+- 停麦后会话挂着不动 → 云端可能 IdleTimeout
+
+### 联调看这几条日志
+
+- 正常:`continue after completed`、第一次 canceled 后有 `keep/restore speech`
+- 提交:`[VC][cancel_storm]`、`[VC][turn_cap]`
+- 异常:`Deny START by canceled` 后长时间无 TTS
+
+---
+
+## k8v3 相关配置(默认值)
+
+- `VAD_SILENCE_DEFAULT=1000` — 云端断句静音,storage 可覆盖
+- `LONG_DIALOGUE_ASR_CONTINUE_MS=2000` — 句末 2 秒内续说算同一句
+- `LONG_DIALOGUE_LLM_CANCEL_MAX=2` — 一轮里第二次 canceled 强制提交
+- `LONG_DIALOGUE_MAX_TURN_MS=25000` — 单轮最长 25 秒
+- `RECORD_START_TONE_FILE` — 开录提示 `start.mp3`(不是按键滴声)
+
+---
+
+## AI 键(长对话 VC_mode=0)
+
+- **idle 短按**:开录 + start 音;Power 短按结束会话后,下次才是新对话 id
+- **recording 短按**:停录并立刻重开,同一对话
+- **thinking / tts 短按**:打断播报,再开录
+
+## AI 键(PTT VC_mode=1)
+
+- idle 短按 → 播「请长按说话」提示,不进录音
+- 长按 → start 音 + 录音,松手结束
+- 按太短 → 播「说话时间太短」并取消
+
+## Power 键
+
+- 短按:结束当前对话(Close WS),清 sid,可进休眠
+- 长按 3 秒:关机
+
+---
 
-| UX | 短按 |
-|----|------|
-| idle | cancel(如需)→ Close WS → idle/休眠 → **清 sid** |
-| recording | cancel 本次 ASR 上传 → Close → 清 sid |
-| thinking / tts | cancel 对话/播报 → Close → 清 sid |
+## 改代码常去的函数
 
-**长按 Power 3s**:关机提示音 + 断电(`vc_buttons`)。
+长对话连续说:`_vc_note_asr_result_activity`、`_long_dialogue_handle_continue_speech_cancel`、`_vc_ld_force_commit_turn`、`_vc_long_dialogue_turn_cap_tick`
 
-## 代码锚点
+按键:`KeyRouter.dispatch_press`、`handle_power_short`、`_vc_play_record_start_tone`
 
-- `KeyRouter.dispatch_press` / `handle_ptt_release` / `handle_power_short`
-- `_vc_play_record_start_tone`、`_vc_restart_record_same_dialog`
-- `_ptt_play_idle_hint`、`_ptt_abort_short_record`、`_ptt_stop_dialog_to_idle`
-- `Close()` 清 `_cur_dialog_id`(Power 后下次 AI 为新对话)
+会话结束:`Close()` 清 `_cur_dialog_id`
index 5bacccb..b6d2e72 100644 (file)
@@ -163,7 +163,13 @@ VC_IDLE_DEEP_SLEEP_SEC = 60
 VC_IDLE_DEEP_SLEEP_ENABLE = 1
 
 # 服务端断句静音(ms);句中短暂停顿建议 ≥1200;可被 storage vad_silence 覆盖
-VAD_SILENCE_DEFAULT = 300
+VAD_SILENCE_DEFAULT = 1000
+
+# 长对话:单轮最长录音(ms)、asr.completed 后续说 merge 窗(ms);0=不限制
+LONG_DIALOGUE_MAX_TURN_MS = 25000
+LONG_DIALOGUE_ASR_CONTINUE_MS = 2000
+# llm 已起后连续 canceled 达此次数则冻结续说、停麦等回复(避免一直录无 TTS)
+LONG_DIALOGUE_LLM_CANCEL_MAX = 2
 
 # =============================================================================
 # 8. 语音对话 / TTS / 智能体
index d5d13c4..4bdff7d 100644 (file)
@@ -34,6 +34,9 @@ AI_LONG_PRESS_POWER_OFF_ENABLED = False
 POWER_ON_TONE_ENABLED = True
 POWER_ON_TONE_DEFAULT = "你好,我来了"
 VAD_SILENCE_DEFAULT = 1000
+# 长对话单轮最长录音(ms):句中 vad merge 期间累计;0=不限制
+LONG_DIALOGUE_MAX_TURN_MS = 25000
+LONG_DIALOGUE_ASR_CONTINUE_MS = 2000
 VC_TTS_VOICE_TYPE_DEFAULT = None
 VC_TTS_VOICE_KEY_DEFAULT = None
 
index 06facae..39827e7 100644 (file)
@@ -153,7 +153,13 @@ VC_IDLE_DEEP_SLEEP_SEC = 60
 VC_IDLE_DEEP_SLEEP_ENABLE = 1
 
 # 服务端断句静音(ms);句中短暂停顿建议 ≥1200;可被 storage vad_silence 覆盖
-VAD_SILENCE_DEFAULT = 300
+VAD_SILENCE_DEFAULT = 1000
+
+# 长对话:单轮最长录音(ms)、asr.completed 后续说 merge 窗(ms);0=不限制
+LONG_DIALOGUE_MAX_TURN_MS = 25000
+LONG_DIALOGUE_ASR_CONTINUE_MS = 2000
+# llm 已起后连续 canceled 达此次数则冻结续说、停麦等回复(避免一直录无 TTS)
+LONG_DIALOGUE_LLM_CANCEL_MAX = 2
 
 # =============================================================================
 # 8. 语音对话 / TTS / 智能体
index 5bacccb..b6d2e72 100644 (file)
@@ -163,7 +163,13 @@ VC_IDLE_DEEP_SLEEP_SEC = 60
 VC_IDLE_DEEP_SLEEP_ENABLE = 1
 
 # 服务端断句静音(ms);句中短暂停顿建议 ≥1200;可被 storage vad_silence 覆盖
-VAD_SILENCE_DEFAULT = 300
+VAD_SILENCE_DEFAULT = 1000
+
+# 长对话:单轮最长录音(ms)、asr.completed 后续说 merge 窗(ms);0=不限制
+LONG_DIALOGUE_MAX_TURN_MS = 25000
+LONG_DIALOGUE_ASR_CONTINUE_MS = 2000
+# llm 已起后连续 canceled 达此次数则冻结续说、停麦等回复(避免一直录无 TTS)
+LONG_DIALOGUE_LLM_CANCEL_MAX = 2
 
 # =============================================================================
 # 8. 语音对话 / TTS / 智能体
index c4542f1..43b032d 100644 (file)
@@ -159,15 +159,346 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract):
         except Exception:
             return False
 
+    def _vc_asr_continue_merge_ms(self):
+        try:
+            if "vc_config" in globals() and vc_config:
+                return int(vc_config.get_int("LONG_DIALOGUE_ASR_CONTINUE_MS", 2000))
+        except Exception:
+            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
+            self._vc_ld_cancel_in_wall = 0
+            self._turn_has_llm_created = 0
+            self._vc_ld_awaiting_reply = 0
+        except Exception:
+            pass
+
+    def _vc_ld_on_tts_started(self):
+        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
+        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:
+            return False
+        if getattr(self, "_vc_ld_merge_frozen", 0) == 1:
+            return True
+        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:
+                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))
+                except Exception:
+                    pass
+                return True
+        except Exception:
+            pass
+        return False
+
+    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._server_vad_silence = 1
+            self._vc_ld_awaiting_reply = 1
+            if getattr(self, "VC_record_state_flag", 0) != 0:
+                try:
+                    self._uplink_mark_end(reason)
+                except Exception:
+                    pass
+                self.request_uplink_stop(reason)
+            if self._vc_effective_asr_text():
+                try:
+                    self._vc_publish_led("thinking", reason)
+                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:
+            if getattr(self, "_vc_asr_continue_merge", 0) != 1:
+                return False
+            comp = getattr(self, "_vc_asr_completed_ms", 0)
+            last = getattr(self, "_vc_last_asr_result_ms", 0)
+            if not comp or not last:
+                return False
+            win = self._vc_asr_continue_merge_ms()
+            now = utime.ticks_ms()
+            if utime.ticks_diff(now, comp) > win or utime.ticks_diff(now, last) > win:
+                self._vc_asr_continue_merge = 0
+                return False
+            return True
+        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:
+                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:
+                self._vc_asr_continue_merge = 1
+                self._vc_pending_asr_completed_stop = 0
+                if getattr(self, "record_data_save_flag", 0) == 0:
+                    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
+        except Exception:
+            pass
+
     def _vc_flush_deferred_vad_uplink_stop(self, reason="asr.completed"):
-        """长对话:asr.completed 再停麦,避免句中 vad.silence 把一句话切成两段。"""
+        """长对话 asr.completed:vad.silence 句末立停;无 vad 则留 merge 窗再停麦。"""
         try:
-            if not getattr(self, "_vc_defer_vad_uplink_stop", 0):
+            if not self._vc_is_long_dialogue_mode():
                 return
-            self._vc_defer_vad_uplink_stop = 0
+            if getattr(self, "record_data_save_flag", 0) != 0:
+                return
+            if getattr(self, "_vc_vad_had_active_after_silence", 0) == 1:
+                try:
+                    print("[VC][VAD] asr.completed defer stop (vad pause merged)")
+                except Exception:
+                    pass
+                return
+            try:
+                self._vc_asr_completed_ms = utime.ticks_ms()
+            except Exception:
+                pass
+            had_defer = getattr(self, "_vc_defer_vad_uplink_stop", 0)
+            if had_defer:
+                self._vc_defer_vad_uplink_stop = 0
+            elif not self._vc_effective_asr_text():
+                return
+            if had_defer:
+                self._server_vad_silence = 1
+                try:
+                    self._uplink_mark_end(reason)
+                except Exception:
+                    pass
+                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:
+                self._uplink_mark_end("asr.completed/pending_stop")
+            except Exception:
+                pass
+        except Exception:
+            pass
+
+    def _vc_asr_completed_stop_tick(self):
+        """merge 窗结束仍无续说 → 执行延后关麦。"""
+        try:
+            if not self._vc_is_long_dialogue_mode():
+                return
+            if getattr(self, "_vc_pending_asr_completed_stop", 0) != 1:
+                return
+            if self._vc_asr_continue_merge_active():
+                return
+            comp = getattr(self, "_vc_asr_completed_ms", 0)
+            if not comp:
+                return
+            if utime.ticks_diff(utime.ticks_ms(), comp) < self._vc_asr_continue_merge_ms():
+                return
+            self._vc_pending_asr_completed_stop = 0
             if getattr(self, "VC_record_state_flag", 0) != 0 and self._vc_vad_uplink_stop_eligible():
-                self.request_uplink_stop(reason)
+                try:
+                    print("[VC][ASR] merge window end -> stop uplink")
+                except Exception:
+                    pass
+                self.request_uplink_stop("asr.completed/merge_timeout")
+        except Exception:
+            pass
+
+    def _long_dialogue_cancel_preserve_speech(self):
+        """长对话 canceled:非用户打断时,录音中或 asr 续说窗内均保持/恢复上行。"""
+        if not self._vc_is_long_dialogue_mode():
+            return False
+        try:
+            if getattr(self, "_vc_ld_merge_frozen", 0) == 1:
+                return False
+            if getattr(self, "_vc_pending_interrupt_uplink", 0) == 1:
+                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 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:
+                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
+            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._long_dialogue_publish_listening("canceled/continue_speech")
+            if getattr(self, "VC_record_state_flag", 0) == 0:
+                self.request_uplink_start("canceled/continue_speech")
+            else:
+                self._server_vad_silence = 0
+                self._vc_pending_asr_completed_stop = 0
+            try:
+                print("[KEY] long_dialogue canceled -> keep/restore 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):
+        try:
+            self._vc_turn_wall_start_ms = 0
+            self._vc_turn_cap_fired = 0
+            if hasattr(self, "_vc_ld_reset_merge_freeze"):
+                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
+            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 max_ms <= 0:
+                return
+            start = getattr(self, "_vc_turn_wall_start_ms", 0)
+            if not start:
+                return
+            if utime.ticks_diff(utime.ticks_ms(), start) < max_ms:
+                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
+            try:
+                print("[VC][turn_cap] force stop elapsed_ms>={} max_ms={}".format(
+                    utime.ticks_diff(utime.ticks_ms(), start), max_ms))
+            except Exception:
+                pass
+            self._vc_defer_vad_uplink_stop = 0
+            self._vc_vad_had_active_after_silence = 0
+            self._server_vad_silence = 1
+            try:
+                self._uplink_mark_end("max_turn_ms")
+            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
 
@@ -1086,6 +1417,8 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract):
     def _long_dialogue_should_hold_listening_led(self):
         """长对话打断等待/录音:保持蓝慢闪(含 cancel→开麦 窗口)。"""
         try:
+            if getattr(self, "_vc_ld_awaiting_reply", 0) == 1:
+                return False
             if getattr(self, "_vc_defer_listening_led", 0) == 1:
                 return False
             if getattr(self, "VC_mode", -1) != 0:
@@ -2068,6 +2401,16 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract):
         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_cap_fired = 0
+        self._vc_asr_completed_ms = 0
+        self._vc_last_asr_result_ms = 0
+        self._vc_asr_continue_merge = 0
+        self._vc_pending_asr_completed_stop = 0
+        self._turn_has_llm_created = 0
+        self._vc_ld_cancel_in_wall = 0
+        self._vc_ld_merge_frozen = 0
+        self._vc_ld_awaiting_reply = 0
         # 单轮状态标志:用于判定 completed 是否应自动进入下一轮
         self._turn_has_asr_completed = 0
         self._turn_has_asr_result = 0
index b1fa492..31b96db 100644 (file)
@@ -103,18 +103,38 @@ class VCSessionIoMixin:
                 pass
             if self._vc_is_long_dialogue_mode():
                 try:
+                    if hasattr(self, "_vc_ld_on_tts_started"):
+                        self._vc_ld_on_tts_started()
                     if hasattr(self, "_long_dialogue_on_server_tts_started"):
                         self._long_dialogue_on_server_tts_started()
                 except Exception:
                     pass
         if ev == "canceled":
             try:
-                if (
-                    self._vc_is_long_dialogue_mode()
-                    and hasattr(self, "_long_dialogue_is_stale_server_cancel")
-                    and self._long_dialogue_is_stale_server_cancel()
-                ):
-                    self._long_dialogue_handle_stale_cancel()
+                keep_speech = False
+                if self._vc_is_long_dialogue_mode():
+                    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"):
+                        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")
+                    ):
+                        self._long_dialogue_handle_continue_speech_cancel()
+                    elif hasattr(self, "_long_dialogue_handle_stale_cancel"):
+                        self._long_dialogue_handle_stale_cancel()
                     return
             except Exception:
                 pass
@@ -139,9 +159,13 @@ class VCSessionIoMixin:
             self._fsm_apply_actions(actions)
             if ev == "canceled":
                 try:
+                    preserve = False
+                    if hasattr(self, "_long_dialogue_cancel_preserve_speech"):
+                        preserve = self._long_dialogue_cancel_preserve_speech()
                     if (
                         self._vc_is_long_dialogue_mode()
                         and hasattr(self, "_long_dialogue_end_interrupt")
+                        and not preserve
                         and not (
                             hasattr(self, "_long_dialogue_is_stale_server_cancel")
                             and self._long_dialogue_is_stale_server_cancel()
@@ -745,6 +769,9 @@ class VCSessionIoMixin:
                             self._llm_completed_ms = 0
                             self._vc_defer_vad_uplink_stop = 0
                             self._vc_vad_had_active_after_silence = 0
+                            self._vc_asr_completed_ms = 0
+                            self._vc_asr_continue_merge = 0
+                            self._vc_pending_asr_completed_stop = 0
                             try:
                                 self._asr_completed_tone_done = 0
                             except Exception:
@@ -772,6 +799,8 @@ 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()
                             except Exception:
                                 pass
                             if self.AI_ASR_str == "":#没识别到
@@ -901,6 +930,19 @@ class VCSessionIoMixin:
                                 self._server_vad_silence = 1
                                 self._uplink_mark_end("vad.silence")
                         elif jsload_data["event"] == "llm.created" :#思考(LLM)
+                            try:
+                                self._turn_has_llm_created = 1
+                            except Exception:
+                                pass
+                            try:
+                                if (
+                                    self._vc_is_long_dialogue_mode()
+                                    and getattr(self, "VC_record_state_flag", 0) != 0
+                                    and getattr(self, "record_data_save_flag", 0) == 0
+                                ):
+                                    self.request_uplink_stop("llm.created")
+                            except Exception:
+                                pass
                             try:
                                 if hasattr(self, "_vc_reset_long_idle_timer"):
                                     self._vc_reset_long_idle_timer("llm.created")
index 5763b7f..3915bb8 100644 (file)
@@ -528,6 +528,16 @@ class VCSessionMediaMixin:
                 and getattr(self, "_vc_pending_interrupt_uplink", 0) == 1
             )
             if reason == "canceled" and not user_intr:
+                try:
+                    if hasattr(self, "_vc_asr_continue_merge_active") and self._vc_asr_continue_merge_active():
+                        return True
+                    if (
+                        hasattr(self, "_long_dialogue_cancel_preserve_speech")
+                        and self._long_dialogue_cancel_preserve_speech()
+                    ):
+                        return True
+                except Exception:
+                    pass
                 print("[VC][uplink_ctrl] Deny START by canceled cause=not_user_interrupt")
                 return False
             if (
@@ -892,6 +902,11 @@ class VCSessionMediaMixin:
         self.VC_Speech_Start()
         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()
+        except Exception:
+            pass
         try:
             if int(getattr(self, "VC_mode", 0)) == 1:
                 self._ptt_record_open_ms = utime.ticks_ms()
@@ -909,6 +924,11 @@ class VCSessionMediaMixin:
             if self.VC_record_state_flag  == 0:
                 # 新一轮开始录音:允许上行(上一轮可能已置 silence 闸门)
                 self._server_vad_silence = 0
+                try:
+                    if hasattr(self, "_vc_turn_wall_clear"):
+                        self._vc_turn_wall_clear()
+                except Exception:
+                    pass
                 self.AI_ASR_str = ""
                 self.AI_ASR_str_tmp = ""
                 self._last_asr_result_text = ""
@@ -1001,6 +1021,11 @@ class VCSessionMediaMixin:
     def VC_Speech_End(self):
         try:
             self._uplink_mark_end("VC_Speech_End")
+            try:
+                if hasattr(self, "_vc_turn_wall_clear"):
+                    self._vc_turn_wall_clear()
+            except Exception:
+                pass
             self.AI_ASR_flag = 0
             self.AI_ASR_str_tmp = ""
             # 勿在此清空 AI_ASR_str:vad.silence 停麦常早于 asr.completed,清空会导致 check_text:len:0 连播 think 音
@@ -1065,7 +1090,14 @@ class VCSessionMediaMixin:
                     and getattr(self, "Close_flag", 0) == 0
                     and getattr(self, "AI_client_VC_isconnect", 0) == 1
                 ):
-                    self._vc_publish_listening_for_mode("VC_Speech_End/long_wait")
+                    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():
+                        self._vc_publish_led("thinking", "VC_Speech_End/force_commit")
+                    else:
+                        self._vc_publish_listening_for_mode("VC_Speech_End/long_wait")
                 else:
                     self._vc_publish_led("idle", "VC_Speech_End")
         except Exception:
@@ -1211,6 +1243,13 @@ 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()
+                        except Exception:
+                            pass
                         VAD_data_count = 0
                         if self.VC_record_state_flag <=1 :#没发送前先存起来
                             self.AI_record_tmp_queue.put(read_buf)