From 659c470eba542383987aad227b7f918100f131fa Mon Sep 17 00:00:00 2001
From: wangzhibo <wangzhibo@shsening.com>
Date: 星期五, 11 九月 2026 15:09:05 +0800
Subject: [PATCH] 小智终端 5种 场景模式 OK

---
 xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/runtime.py | 1477 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
 1 files changed, 1,426 insertions(+), 51 deletions(-)

diff --git a/xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/runtime.py b/xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/runtime.py
index e62fdcf..71dc2af 100644
--- a/xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/runtime.py
+++ b/xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/runtime.py
@@ -2,13 +2,48 @@
 
 from __future__ import annotations
 
+import asyncio
+import json
+import difflib
+import queue
+import random
+import re
 import uuid
-from typing import Any, Dict, Optional, TYPE_CHECKING
+from typing import Any, Dict, List, Optional, TYPE_CHECKING
 
 from config.logger import setup_logging
 from plugins_func.register import Action, ActionResponse
 
-from .registry import default_app_id, get_app, get_task
+from .assessment import (
+    evaluate_turn,
+    listen_window_ms,
+    pcm_snapshot_to_wav,
+    resolve_expected_text,
+    resolve_listen_ratio,
+    save_turn_wav,
+    silent_pcm_snapshot,
+    transcript_cache_dir,
+)
+from .playback import (
+    clamp_play_speed,
+    default_repeater_playlist,
+    default_shadowing_playlist,
+    expand_playlist_plays,
+    lesson_day_index,
+    mark_lesson_played_today,
+    parse_playlist,
+    resolve_play_speed,
+    speeds_for_repeats,
+)
+from .registry import (
+    default_app_id,
+    get_app,
+    get_registry,
+    get_task,
+    match_registry_voice,
+    voice_menu_text,
+)
+from .session import begin_session, current_session, end_session, parse_skill_event
 
 if TYPE_CHECKING:
     from core.connection import ConnectionHandler
@@ -28,19 +63,37 @@
     "鏈寚瀹氳瑷�鏃讹細涓枃璇戞垚鑻辨枃锛岃嫳鏂囪瘧鎴愪腑鏂囷紱鍏跺畠璇█榛樿璇戞垚涓枃銆�"
 )
 
+BILINGUAL_PROMPT = (
+    "浣犳鍦ㄨ繘琛屼腑鑻卞鐓у璇濈粌涔狅紝涓嶆槸缈昏瘧鏈猴紝涓嶈鎶婄敤鎴风殑璇濆師鏍疯瘧鍥炲幓浜ゅ樊銆�"
+    "涓ユ牸鎵ц锛氱敤鎴疯涓枃鏃朵綘鍙敤鑻辨枃瀹屾暣鍥炵瓟锛涚敤鎴疯鑻辨枃鏃朵綘鍙敤涓枃瀹屾暣鍥炵瓟銆�"
+    "涓嶈涓嫳澶规潅锛屼笉瑕佸悓鏃剁粰鍑轰袱绉嶈瑷�锛屼笉瑕侀棶瑕佷笉瑕佺炕璇戙��"
+    "鐢ㄦ埛璇撮��鍑轰腑鑻卞璇濄�佸洖鍒拌亰澶╂椂璋冪敤 switch_app锛宎pp_id=chat銆�"
+)
+
 CHAT_PROACTIVE_HINT = (
     "浣犳槸鐢ㄦ埛鐨勮闊充紮浼淬�傝繘鍏ュ璇濆悗鍙互鍏堜富鍔ㄧ煭鍙ラ個璇疯亰澶╋紝渚嬪銆屾垜浠潵鑱婂ぉ鍚с�嶃��"
     "鐢ㄦ埛寮�鍙e悗鎸夋甯稿璇濆洖绛旓紝涓嶈鍙嶅鍌績銆�"
 )
 
-VOICE_MENU = "鎯宠亰澶┿�佸惉澶嶈锛岃繕鏄墦寮�缈昏瘧锛熶篃鍙互璇存垜浠潵鑱婂ぉ鍚с��"
+CHAT_RETURN_TOPICS = ("浠婂ぉ杩囧緱鎬庝箞鏍�", "浣犳渶杩戝湪蹇欎粈涔�", "鏈変粈涔堟柊椴滀簨")
 
-CHAT_PHRASES = ("鎴戜滑鏉ヨ亰澶╁惂", "鑱婁細澶╁惂", "鍥炲埌鑱婂ぉ", "閫�鍑虹炕璇�", "缁撴潫缈昏瘧", "閫�鍑哄璇�")
-TRANSLATE_PHRASES = ("鎵撳紑缈昏瘧", "寮�濮嬬炕璇�", "鑻辫缈昏瘧", "缈昏瘧妯″紡")
-REPEATER_PHRASES = ("寮�濮嬪璇�", "鎵撳紑澶嶈", "鑻辫瀛︿範", "鍚璇�")
+DEFAULT_SHADOWING_SENTENCES = (
+    "Hello, how are you today?",
+    "I like to read English books.",
+    "The weather is nice this morning.",
+    "Can you say that again, please?",
+    "Practice a little every day.",
+)
 
 DEVICE_PLAY_TOOL = "self_mp3_play"
 DEVICE_STOP_TOOL = "self_mp3_stop"
+DEVICE_LIST_TOOL = "self_mp3_get_list"
+DEVICE_ENSURE_APP_TOOL = "self_sd_ensure_app"
+SD_LAYOUT_MODES = ("PLAY_AUDIO", "SHADOWING")
+SHADOWING_LISTEN_RATIO = 1.2
+SHADOWING_LISTEN_MIN_MS = 800
+REPEATER_END_PHRASE = "澶嶈缁撴潫"
+SHADOWING_END_PHRASE = "璺熻缁撴潫"
 
 
 def source_priority(source: str) -> int:
@@ -62,6 +115,150 @@
         conn.tool_allowlist = []
     if not hasattr(conn, "original_system_prompt"):
         conn.original_system_prompt = conn.prompt
+    if not hasattr(conn, "shadowing_index"):
+        conn.shadowing_index = 0
+    if not hasattr(conn, "shadowing_prompt"):
+        conn.shadowing_prompt = ""
+    if not hasattr(conn, "shadowing_clips"):
+        conn.shadowing_clips = []
+    if not hasattr(conn, "shadowing_awaiting_user"):
+        conn.shadowing_awaiting_user = False
+    if not hasattr(conn, "shadowing_clip_gen"):
+        conn.shadowing_clip_gen = 0
+    if not hasattr(conn, "shadowing_timeout_task"):
+        conn.shadowing_timeout_task = None
+    if not hasattr(conn, "last_asr_language"):
+        conn.last_asr_language = None
+    if not hasattr(conn, "task_session"):
+        conn.task_session = None
+    if not hasattr(conn, "last_asr_audio_snapshot"):
+        conn.last_asr_audio_snapshot = None
+    if not hasattr(conn, "shadowing_listen_pcm"):
+        conn.shadowing_listen_pcm = []
+    if not hasattr(conn, "shadowing_audio_saved_index"):
+        conn.shadowing_audio_saved_index = -1
+    if not hasattr(conn, "ensured_app_dirs"):
+        conn.ensured_app_dirs = set()
+    if not hasattr(conn, "repeater_speed_queue"):
+        conn.repeater_speed_queue = []
+    if not hasattr(conn, "repeater_play_name"):
+        conn.repeater_play_name = ""
+    if not hasattr(conn, "repeater_power_off_after"):
+        conn.repeater_power_off_after = False
+    if not hasattr(conn, "repeater_playlist"):
+        conn.repeater_playlist = []
+    if not hasattr(conn, "shadowing_last_speed"):
+        conn.shadowing_last_speed = 1.0
+    if not hasattr(conn, "shadowing_assess_index"):
+        conn.shadowing_assess_index = 0
+    if not hasattr(conn, "chat_greeted"):
+        conn.chat_greeted = False
+    if not hasattr(conn, "pending_play_after_tts"):
+        conn.pending_play_after_tts = None
+
+
+def _task_needs_sd_layout(task: Optional[Dict[str, Any]]) -> bool:
+    if not task:
+        return False
+    mode = task.get("interaction_mode") or ""
+    if mode in SD_LAYOUT_MODES:
+        return True
+    for skill in task.get("skills") or []:
+        name = (skill or {}).get("name") or ""
+        if name in ("self.mp3.play", DEVICE_PLAY_TOOL):
+            return True
+    return False
+
+
+def app_sd_layout_paths(app_id: str, app: Optional[Dict[str, Any]] = None) -> list:
+    """Return SD lesson dirs for an App: /{app_id}/{task_id}/lessons."""
+    if not app_id:
+        return []
+    if app is None:
+        app = get_app(app_id)
+    if not app:
+        return []
+    paths = []
+    for task_id, task in (app.get("tasks") or {}).items():
+        if _task_needs_sd_layout(task):
+            paths.append(f"/{app_id}/{task_id}/lessons")
+    return paths
+
+
+def _mcp_layout_ok(result) -> bool:
+    if result is None:
+        return False
+    if result.action in (Action.ERROR, Action.NOTFOUND):
+        return False
+    blob = str(getattr(result, "response", None) or getattr(result, "result", None) or "")
+    compact = blob.replace(" ", "").lower()
+    if '"ok":false' in compact:
+        return False
+    return True
+
+
+async def ensure_app_sd_dirs(conn: "ConnectionHandler", app_id: str) -> bool:
+    """Create /{app} and /{app}/{task}/lessons on device SD. Never raises; missing SD is ignored."""
+    _ensure_runtime_fields(conn)
+    app = get_app(app_id)
+    paths = app_sd_layout_paths(app_id, app)
+    if not paths:
+        return True
+    already = getattr(conn, "ensured_app_dirs", None)
+    if already is None:
+        conn.ensured_app_dirs = set()
+        already = conn.ensured_app_dirs
+    if app_id in already:
+        return True
+    try:
+        root = await _execute_named_tool(conn, DEVICE_ENSURE_APP_TOOL, {"app_id": app_id, "task_id": ""})
+        if not _mcp_layout_ok(root):
+            logger.bind(tag=TAG).warning(f"鍒涘缓璁惧 App 鐩綍澶辫触 {app_id}: {getattr(root, 'response', root)}")
+            return False
+        for task_id, task in (app.get("tasks") or {}).items():
+            if not _task_needs_sd_layout(task):
+                continue
+            result = await _execute_named_tool(
+                conn, DEVICE_ENSURE_APP_TOOL, {"app_id": app_id, "task_id": task_id}
+            )
+            if not _mcp_layout_ok(result):
+                logger.bind(tag=TAG).warning(
+                    f"鍒涘缓璁惧 Task 鐩綍澶辫触 {app_id}/{task_id}: {getattr(result, 'response', result)}"
+                )
+                return False
+        already.add(app_id)
+        logger.bind(tag=TAG).info(f"宸茬‘淇� SD 鐩綍 {paths}")
+        return True
+    except Exception as e:
+        logger.bind(tag=TAG).warning(f"鍒涘缓璁惧 App 鐩綍寮傚父 {app_id}: {e}")
+        return False
+
+
+async def ensure_all_subscribed_app_sd_dirs(conn: "ConnectionHandler") -> None:
+    """After MCP tools/list, mkdir for every registry App that needs SD. Failures do not drop WS."""
+    try:
+        apps = (get_registry().get("apps") or {})
+        for app_id, app in apps.items():
+            if app_sd_layout_paths(app_id, app):
+                await ensure_app_sd_dirs(conn, app_id)
+    except Exception as e:
+        logger.bind(tag=TAG).warning(f"璁㈤槄 App 寤虹洰褰曞け璐ワ紙蹇界暐锛�: {e}")
+
+
+def schedule_ensure_subscribed_app_sd_dirs(conn: "ConnectionHandler") -> None:
+    """Fire-and-forget so tools/list handling is not blocked waiting for MCP mkdir."""
+
+    async def _run():
+        await ensure_all_subscribed_app_sd_dirs(conn)
+
+    try:
+        loop = getattr(conn, "loop", None)
+        if loop:
+            loop.create_task(_run())
+        else:
+            asyncio.create_task(_run())
+    except Exception as e:
+        logger.bind(tag=TAG).warning(f"鏃犳硶璋冨害 App 寤虹洰褰�: {e}")
 
 
 def filter_tools(conn: "ConnectionHandler", tools: Dict[str, Any]) -> Dict[str, Any]:
@@ -94,6 +291,8 @@
             "浣犵幇鍦ㄦ槸鑻辫缈昏瘧鏈恒�傜敤鎴疯璇濆悗搴旇皟鐢� translate锛涚敤鎴疯閫�鍑虹炕璇戙�佸洖鍒拌亰澶╂椂璋冪敤 switch_app锛宎pp_id=chat銆�"
             "涓嶈璁茬瑧璇濄�佷笉瑕佹煡澶╂皵銆佷笉瑕侀棽鑱娿��"
         )
+    elif mode == "bilingual":
+        conn.change_system_prompt(BILINGUAL_PROMPT)
     elif mode == "agent_proactive":
         base = conn.original_system_prompt or conn.prompt or ""
         if CHAT_PROACTIVE_HINT not in (base or ""):
@@ -118,13 +317,58 @@
 
 async def _stop_current_skills(conn: "ConnectionHandler") -> None:
     mode = getattr(conn, "interaction_mode", "CHAT")
-    if mode == "PLAY_AUDIO":
+    _cancel_shadowing_timeout(conn)
+    if mode in ("PLAY_AUDIO", "SHADOWING"):
         await _execute_named_tool(conn, DEVICE_STOP_TOOL, {})
     conn.awaiting_voice_app_choice = False
+    conn.pending_play_after_tts = None
+    conn.shadowing_index = 0
+    conn.shadowing_prompt = ""
+    conn.shadowing_clips = []
+    conn.shadowing_awaiting_user = False
+    conn.shadowing_clip_gen = int(getattr(conn, "shadowing_clip_gen", 0) or 0) + 1
+    conn.shadowing_assess_index = 0
+    conn.shadowing_listen_pcm = []
+    conn.shadowing_audio_saved_index = -1
+    conn.repeater_speed_queue = []
+    conn.repeater_play_name = ""
+    conn.repeater_power_off_after = False
+    conn.repeater_playlist = []
+
+
+def tts_channels_ready(conn: "ConnectionHandler") -> bool:
+    tts = getattr(conn, "tts", None)
+    if tts is None:
+        return False
+    play = getattr(tts, "audio_play_priority_thread", None)
+    text_th = getattr(tts, "tts_priority_thread", None)
+    return bool(
+        play is not None
+        and play.is_alive()
+        and text_th is not None
+        and text_th.is_alive()
+    )
+
+
+async def wait_for_tts_channels(conn: "ConnectionHandler", timeout: float = 8.0) -> bool:
+    loop = asyncio.get_running_loop()
+    start = loop.time()
+    while (loop.time() - start) < timeout:
+        if tts_channels_ready(conn):
+            return True
+        await asyncio.sleep(0.1)
+    return tts_channels_ready(conn)
 
 
 def _speak(conn: "ConnectionHandler", text: str) -> None:
     if not text:
+        return
+    tts = getattr(conn, "tts", None)
+    if tts is None:
+        logger.bind(tag=TAG).warning("TTS鏈垵濮嬪寲锛岃烦杩囨挱鎶�")
+        return
+    if not tts_channels_ready(conn):
+        logger.bind(tag=TAG).warning("TTS閫氶亾鏈氨缁紝璺宠繃鎾姤")
         return
     from core.handle.intentHandler import speak_txt
 
@@ -140,6 +384,877 @@
     return (conn.config or {}).get("proactive_chat_greeting") or "鎴戜滑鏉ヨ亰澶╁惂銆�"
 
 
+def _display_name(app: Optional[Dict[str, Any]], task: Optional[Dict[str, Any]] = None) -> str:
+    if task and task.get("display_name"):
+        return str(task["display_name"])
+    if app and app.get("display_name"):
+        return str(app["display_name"])
+    if app and app.get("name"):
+        return str(app["name"])
+    return "褰撳墠妯″紡"
+
+
+def _enter_prompt(app: Dict[str, Any], task: Dict[str, Any]) -> str:
+    return (task.get("enter_prompt") or app.get("enter_prompt") or "").strip()
+
+
+def _switch_announcement(
+    prev_app: Optional[Dict[str, Any]],
+    prev_task: Optional[Dict[str, Any]],
+    app: Dict[str, Any],
+    task: Dict[str, Any],
+    app_id: str,
+) -> str:
+    if app_id == "chat":
+        prev_name = _display_name(prev_app, prev_task)
+        topic = random.choice(CHAT_RETURN_TOPICS)
+        return f"宸茬粡閫�鍑簕prev_name}锛岀幇鍦ㄦ槸瀵硅瘽妯″紡锛屾垜浠潵鑱婄偣{topic}鍚с��"
+    return _enter_prompt(app, task) or f"鐜板湪宸茬粡鍒囨崲鑷硔_display_name(app, task)}銆�"
+
+
+def _shadowing_sentences(conn: "ConnectionHandler") -> list:
+    cfg = (conn.config or {}).get("app_runtime") or {}
+    sentences = cfg.get("shadowing_sentences")
+    if not sentences:
+        _, task = get_task("english_learning", "shadowing")
+        sentences = (task or {}).get("sentences")
+    if not sentences:
+        sentences = DEFAULT_SHADOWING_SENTENCES
+    return [str(s).strip() for s in sentences if str(s).strip()]
+
+
+def lesson_mp3_clips(names, app_id: str, task_id: str, lesson_id: str = "") -> list:
+    """Pick sentence MP3s under /{app}/{task}/lessons/{lesson}/..."""
+    prefix = f"/{app_id}/{task_id}/lessons/"
+    clips = []
+    for raw in names or []:
+        name = str(raw or "").replace("\\", "/")
+        if not name.lower().endswith(".mp3"):
+            continue
+        if not name.startswith("/"):
+            name = "/" + name.lstrip("/")
+        if not name.startswith(prefix):
+            continue
+        rest = name[len(prefix) :]
+        if not rest:
+            continue
+        base = rest.rsplit("/", 1)[-1].lower()
+        if base == "full.mp3" or base.endswith("_full.mp3"):
+            continue
+        if lesson_id:
+            if rest != lesson_id and not rest.startswith(str(lesson_id) + "/"):
+                continue
+        clips.append(name)
+    clips.sort()
+    if lesson_id or not clips:
+        return clips
+    first_lesson = clips[0][len(prefix) :].split("/", 1)[0]
+    return [
+        name
+        for name in clips
+        if name[len(prefix) :] == first_lesson
+        or name[len(prefix) :].startswith(first_lesson + "/")
+    ]
+
+
+def repeater_lesson_mp3(names, app_id: str, task_id: str) -> str:
+    """Pick the full-lesson MP3 under /{app}/{task}/lessons/, else the first file there."""
+    prefix = f"/{app_id}/{task_id}/lessons/"
+    fulls = []
+    others = []
+    for raw in names or []:
+        name = str(raw or "").replace("\\", "/")
+        if not name.lower().endswith(".mp3"):
+            continue
+        if not name.startswith("/"):
+            name = "/" + name.lstrip("/")
+        if not name.startswith(prefix):
+            continue
+        base = name.rsplit("/", 1)[-1].lower()
+        if base == "full.mp3" or base.endswith("_full.mp3"):
+            fulls.append(name)
+        else:
+            others.append(name)
+    fulls.sort()
+    others.sort()
+    if fulls:
+        return fulls[0]
+    return others[0] if others else ""
+
+
+def _is_clip_path(text: str) -> bool:
+    t = (text or "").replace("\\", "/").lower()
+    return t.endswith(".mp3") or t.startswith("/")
+
+
+def _shadowing_items(conn: "ConnectionHandler") -> list:
+    clips = getattr(conn, "shadowing_clips", None) or []
+    if clips:
+        return list(clips)
+    return _shadowing_sentences(conn)
+
+
+def _begin_shadowing(conn: "ConnectionHandler", enter_text: str) -> str:
+    sentences = _shadowing_sentences(conn)
+    conn.shadowing_index = 0
+    if not sentences:
+        conn.shadowing_prompt = ""
+        return enter_text or "鐜板湪宸茬粡鍒囨崲鑷宠窡璇绘ā寮忥紝浣嗚繕娌℃湁璺熻鍙ュ瓙銆�"
+    conn.shadowing_prompt = sentences[0]
+    prefix = enter_text or "鐜板湪宸茬粡鍒囨崲鑷宠窡璇绘ā寮忋��"
+    return f"{prefix}璇疯窡鎴戣锛歿sentences[0]}"
+
+
+def _cancel_shadowing_timeout(conn: "ConnectionHandler") -> None:
+    task = getattr(conn, "shadowing_timeout_task", None)
+    if task is not None:
+        try:
+            if not task.done():
+                task.cancel()
+        except Exception:
+            pass
+    conn.shadowing_timeout_task = None
+
+
+def _task_for(conn: "ConnectionHandler") -> Optional[Dict[str, Any]]:
+    session = current_session(conn) or getattr(conn, "task_session", None)
+    app_id = getattr(conn, "active_app_id", None) or getattr(session, "app_id", None) or "english_learning"
+    task_id = getattr(conn, "current_task_id", None) or getattr(session, "task_id", None) or "shadowing"
+    _, task = get_task(app_id, task_id)
+    return task
+
+
+def _expected_for_index(conn: "ConnectionHandler", index: int) -> Optional[str]:
+    session = current_session(conn) or getattr(conn, "task_session", None)
+    app_id = getattr(conn, "active_app_id", None) or getattr(session, "app_id", "") or ""
+    task_id = getattr(conn, "current_task_id", None) or getattr(session, "task_id", "") or ""
+    clips = list(getattr(conn, "shadowing_clips", None) or [])
+    return resolve_expected_text(
+        _task_for(conn),
+        clips,
+        index,
+        transcript_cache_dir(app_id, task_id),
+    )
+
+
+def apply_deferred_assessment(
+    conn: "ConnectionHandler",
+    step_index: int,
+    session: Any = None,
+    clips: Optional[list] = None,
+    app_id: str = "",
+    task_id: str = "",
+    audio_snapshot: Any = None,
+    persist_audio: bool = True,
+) -> None:
+    """Write expected_text + metrics onto the captured session. Never raises into play."""
+    import time as _time
+
+    session = session or getattr(conn, "task_session", None)
+    if not session:
+        return
+    app_id = app_id or getattr(conn, "active_app_id", None) or getattr(session, "app_id", "") or ""
+    task_id = task_id or getattr(conn, "current_task_id", None) or getattr(session, "task_id", "") or ""
+    _, task = get_task(app_id or "english_learning", task_id or "shadowing")
+    cfg = (task or {}).get("assessment") or {}
+    clip_list = list(clips if clips is not None else (getattr(conn, "shadowing_clips", None) or []))
+    turn = session.find_turn(step_index)
+    asr = _plain_user_text((turn or {}).get("asr_text") or "")
+    expected = (turn or {}).get("expected_text") or resolve_expected_text(
+        task, clip_list, step_index, transcript_cache_dir(app_id, task_id)
+    )
+    snapshot = audio_snapshot
+    if snapshot is None:
+        snapshot = getattr(conn, "last_asr_audio_snapshot", None)
+    wav_bytes = pcm_snapshot_to_wav(snapshot)
+    existing_audio = (turn or {}).get("audio_id")
+    audio_id = existing_audio
+    if persist_audio and not existing_audio:
+        audio_id = save_turn_wav(
+            getattr(session, "task_session_id", "") or "",
+            step_index,
+            wav_bytes,
+            conn=conn,
+        )
+    try:
+        from core.handle.reportHandle import enqueue_asr_report
+
+        enqueue_asr_report(
+            conn,
+            asr or "",
+            snapshot,
+            getattr(session, "task_session_id", None),
+            int(step_index),
+        )
+    except Exception:
+        pass
+    clip = ""
+    if 0 <= int(step_index) < len(clip_list):
+        clip = str(clip_list[step_index] or "")
+    metrics = []
+    if not (cfg and cfg.get("enabled") is False):
+        metrics = evaluate_turn(
+            asr,
+            expected or "",
+            wav_bytes,
+            cfg,
+            extra={"clip": clip, "step_index": step_index},
+        )
+    session.upsert_turn(
+        index=step_index,
+        prompt_text=(turn or {}).get("prompt_text") or getattr(conn, "shadowing_prompt", "") or "",
+        expected_text=expected,
+        metrics=metrics or None,
+        assessed_at=int(_time.time() * 1000) if metrics else None,
+        audio_id=audio_id,
+    )
+    session.save()
+
+
+def _schedule_deferred_assessment(conn: "ConnectionHandler", step_index: int) -> None:
+    session = getattr(conn, "task_session", None)
+    clips = list(getattr(conn, "shadowing_clips", None) or [])
+    app_id = getattr(conn, "active_app_id", None) or getattr(session, "app_id", "") or ""
+    task_id = getattr(conn, "current_task_id", None) or getattr(session, "task_id", "") or ""
+    audio_snapshot = getattr(conn, "last_asr_audio_snapshot", None)
+
+    async def _run():
+        try:
+            apply_deferred_assessment(
+                conn,
+                step_index,
+                session=session,
+                clips=clips,
+                app_id=app_id,
+                task_id=task_id,
+                audio_snapshot=audio_snapshot,
+                persist_audio=False,
+            )
+        except Exception as exc:
+            logger.bind(tag=TAG).warning(f"璺熻鍚庤瘎浼板け璐�: {exc}")
+
+    loop = getattr(conn, "loop", None)
+    try:
+        loop = loop or asyncio.get_running_loop()
+        loop.create_task(_run())
+    except RuntimeError:
+        try:
+            apply_deferred_assessment(
+                conn,
+                step_index,
+                session=session,
+                clips=clips,
+                app_id=app_id,
+                task_id=task_id,
+                audio_snapshot=audio_snapshot,
+                persist_audio=False,
+            )
+        except Exception as exc:
+            logger.bind(tag=TAG).warning(f"璺熻鍚庤瘎浼板け璐�: {exc}")
+
+
+def _schedule_shadowing_listen_timeout(conn: "ConnectionHandler", duration_ms: int) -> None:
+    _cancel_shadowing_timeout(conn)
+    ratio = resolve_listen_ratio(conn, _task_for(conn))
+    wait_ms = listen_window_ms(
+        duration_ms or 0,
+        ratio,
+        SHADOWING_LISTEN_MIN_MS,
+        speed=getattr(conn, "shadowing_last_speed", None),
+        wall_clock=True,
+    )
+    conn.shadowing_awaiting_user = True
+    conn.shadowing_assess_index = int(getattr(conn, "shadowing_index", 0) or 0)
+    conn.shadowing_audio_saved_index = -1
+    conn.shadowing_listen_pcm = []
+    gen = int(getattr(conn, "shadowing_clip_gen", 0) or 0)
+    logger.bind(tag=TAG).info(
+        f"璺熻鍚獥 {wait_ms}ms = 瀹為檯鎾斁 {duration_ms}ms 脳 {ratio}"
+        f"锛堝�嶉�熷凡鍚湪瀹為檯鎾斁鏃堕暱閲岋級 gen={gen}"
+    )
+
+    async def _fire():
+        try:
+            await asyncio.sleep(wait_ms / 1000.0)
+        except asyncio.CancelledError:
+            return
+        if int(getattr(conn, "shadowing_clip_gen", 0) or 0) != gen:
+            return
+        if getattr(conn, "interaction_mode", "") != "SHADOWING":
+            return
+        if not getattr(conn, "shadowing_awaiting_user", False):
+            return
+        await _on_shadowing_listen_window_closed(conn)
+
+    loop = getattr(conn, "loop", None)
+    if loop:
+        conn.shadowing_timeout_task = loop.create_task(_fire())
+    else:
+        conn.shadowing_timeout_task = asyncio.create_task(_fire())
+
+
+async def close_shadowing_listen_window(conn: "ConnectionHandler") -> None:
+    """Device listen stop: archive this step once, cancel the clock, advance index."""
+    _cancel_shadowing_timeout(conn)
+    await _on_shadowing_listen_window_closed(conn)
+
+
+def _drain_asr_queue_into_shadowing_pcm(conn: "ConnectionHandler") -> int:
+    """Binary opus is queued; listen stop/timeout run on the WS loop first. Take pending PCM now."""
+    q = getattr(conn, "asr_audio_queue", None)
+    if q is None or not getattr(conn, "shadowing_awaiting_user", False):
+        return 0
+    drained = 0
+    while True:
+        try:
+            frame = q.get_nowait()
+        except (queue.Empty, EOFError, OSError):
+            break
+        if frame:
+            append_shadowing_listen_pcm(conn, frame)
+            drained += 1
+    if drained:
+        logger.bind(tag=TAG).info(f"璺熻鍏崇獥鍓嶆娊骞� ASR 闃熷垪 frames={drained}")
+    return drained
+
+
+def append_shadowing_listen_pcm(conn: "ConnectionHandler", pcm_frame: Any) -> None:
+    if getattr(conn, "interaction_mode", "") != "SHADOWING":
+        return
+    if not getattr(conn, "shadowing_awaiting_user", False):
+        return
+    if not pcm_frame:
+        return
+    buf = getattr(conn, "shadowing_listen_pcm", None)
+    if buf is None:
+        conn.shadowing_listen_pcm = []
+        buf = conn.shadowing_listen_pcm
+    buf.append(pcm_frame)
+
+
+def _take_shadowing_listen_pcm(conn: "ConnectionHandler") -> list:
+    frames = list(getattr(conn, "shadowing_listen_pcm", None) or [])
+    conn.shadowing_listen_pcm = []
+    return frames
+
+
+def save_shadowing_listen_audio(conn: "ConnectionHandler", step_index: Optional[int] = None) -> None:
+    """Persist exactly one wav per follow-read step. Never merge neighboring windows."""
+    if getattr(conn, "interaction_mode", "") != "SHADOWING":
+        return
+    index = int(
+        step_index
+        if step_index is not None
+        else (getattr(conn, "shadowing_assess_index", getattr(conn, "shadowing_index", 0)) or 0)
+    )
+    if int(getattr(conn, "shadowing_audio_saved_index", -1)) == index:
+        conn.shadowing_listen_pcm = []
+        return
+    snapshot = _take_shadowing_listen_pcm(conn)
+    if not snapshot:
+        logger.bind(tag=TAG).warning(f"璺熻鍚獥鏃� PCM锛屽啓闈欓煶鍗犱綅 step={index}")
+        snapshot = silent_pcm_snapshot()
+    conn.shadowing_audio_saved_index = index
+    from core.app_runtime.session import stash_asr_audio
+
+    stash_asr_audio(conn, snapshot)
+    try:
+        apply_deferred_assessment(conn, index, audio_snapshot=snapshot)
+    except Exception as exc:
+        logger.bind(tag=TAG).warning(f"璺熻鍚獥褰曢煶钀界洏澶辫触 step={index}: {exc}")
+
+
+async def _on_shadowing_listen_window_closed(conn: "ConnectionHandler") -> None:
+    """Clock or listen stop: archive this listen. Device already sequences play 鈫� record 鈫� play."""
+    if not getattr(conn, "shadowing_awaiting_user", False):
+        return
+    _drain_asr_queue_into_shadowing_pcm(conn)
+    conn.shadowing_awaiting_user = False
+    listened_idx = int(getattr(conn, "shadowing_index", 0) or 0)
+    listened_prompt = getattr(conn, "shadowing_prompt", "") or ""
+    save_shadowing_listen_audio(conn, listened_idx)
+    items = _shadowing_items(conn)
+    nxt = listened_idx + 1
+    conn.shadowing_index = nxt
+    if nxt < len(items):
+        conn.shadowing_prompt = items[nxt]
+    logger.bind(tag=TAG).info(f"璺熻锛氬惉绐楃粨鏉� step={listened_idx}")
+    try:
+        session = current_session(conn) or getattr(conn, "task_session", None)
+        if session:
+            if session.find_turn(listened_idx) is None:
+                session.upsert_turn(
+                    index=listened_idx,
+                    prompt_text=listened_prompt,
+                    asr_text="",
+                    expected_text=_expected_for_index(conn, listened_idx),
+                )
+            session.add_event("listen_window_closed")
+            session.add_event("listen_timeout")
+            session.save()
+    except Exception as exc:
+        logger.bind(tag=TAG).warning(f"璺熻鍚獥褰掓。澶辫触: {exc}")
+
+
+def _parse_mp3_list_names(raw: Any) -> list:
+    text = ""
+    if raw is None:
+        return []
+    if hasattr(raw, "response") or hasattr(raw, "result"):
+        text = str(getattr(raw, "response", None) or getattr(raw, "result", None) or "")
+    else:
+        text = str(raw)
+    text = text.strip()
+    if not text:
+        return []
+    try:
+        import json
+
+        data = json.loads(text)
+    except Exception:
+        return []
+    files = data.get("files") if isinstance(data, dict) else None
+    names = []
+    if isinstance(files, list):
+        for item in files:
+            if isinstance(item, dict) and item.get("name"):
+                names.append(str(item["name"]))
+            elif isinstance(item, str):
+                names.append(item)
+    return names
+
+
+async def _list_sd_mp3_names(conn: "ConnectionHandler") -> list:
+    result = await _execute_named_tool(conn, DEVICE_LIST_TOOL, {})
+    return _parse_mp3_list_names(result)
+
+
+def _is_full_lesson_mp3(name: str) -> bool:
+    base = str(name or "").replace("\\", "/").rsplit("/", 1)[-1].lower()
+    return base == "full.mp3" or base.endswith("_full.mp3")
+
+
+def _compact_shadowing_playlist(
+    conn: "ConnectionHandler",
+    params: Optional[Dict[str, Any]],
+) -> List[Dict[str, Any]]:
+    """Keep {name, repeat, speed}. Device expands repeats locally with a listen window each pass."""
+    items = parse_playlist(params)
+    if not items:
+        items = default_shadowing_playlist(_task_for(conn))
+    compact: List[Dict[str, Any]] = []
+    for raw in items:
+        if not isinstance(raw, dict):
+            continue
+        name = str(raw.get("name") or "")
+        if not name or _is_full_lesson_mp3(name):
+            continue
+        try:
+            repeat_n = int(raw.get("repeat") or 1)
+        except (TypeError, ValueError):
+            repeat_n = 1
+        if repeat_n < 1:
+            repeat_n = 1
+        compact.append(
+            {
+                "name": name,
+                "repeat": repeat_n,
+                "speed": clamp_play_speed(
+                    raw["speed"] if raw.get("speed") is not None else 1.0
+                ),
+                "power_off_after": False,
+                "finish_task": False,
+            }
+        )
+    return compact
+
+
+def _begin_shadowing_playlist(
+    conn: "ConnectionHandler",
+    params: Optional[Dict[str, Any]],
+) -> Dict[str, Any]:
+    """One shot: send the whole shadowing playlist. Device plays, records, plays."""
+    items = _compact_shadowing_playlist(conn, params)
+    clips = expand_playlist_plays(items)
+    conn.shadowing_clips = clips
+    conn.shadowing_index = 0
+    conn.shadowing_prompt = clips[0] if clips else ""
+    conn.shadowing_awaiting_user = False
+    first = items[0] if items else dict(params or {})
+    play = dict(first)
+    play["items"] = items
+    play["finish_task"] = False
+    play["listen_ratio"] = resolve_listen_ratio(conn, _task_for(conn))
+    if items:
+        conn.shadowing_last_speed = items[0].get("speed")
+    return play
+
+
+async def _play_shadowing_clip(conn: "ConnectionHandler", name: str) -> bool:
+    if getattr(conn, "interaction_mode", "") != "SHADOWING":
+        logger.bind(tag=TAG).info(f"璺宠繃璺熻鎾斁锛屽綋鍓嶅凡绂诲紑璺熻: {name}")
+        return False
+    conn.shadowing_awaiting_user = False
+    conn.shadowing_clip_gen = int(getattr(conn, "shadowing_clip_gen", 0) or 0) + 1
+    speed = resolve_play_speed(_task_for(conn))
+    conn.shadowing_last_speed = speed
+    notice = await _execute_named_tool_play(
+        conn,
+        {
+            "name": name,
+            "repeat": 1,
+            "power_off_after": False,
+            "speed": speed,
+            "finish_task": False,
+            "listen_ratio": resolve_listen_ratio(conn, _task_for(conn)),
+        },
+    )
+    if notice and notice.action in (Action.ERROR, Action.NOTFOUND):
+        return False
+    return True
+
+
+async def _prepare_shadowing_sd(conn: "ConnectionHandler", params: Optional[Dict[str, Any]]) -> bool:
+    params = params or {}
+    items = _compact_shadowing_playlist(conn, params)
+    clips = expand_playlist_plays(items)
+    if not clips:
+        names = await _list_sd_mp3_names(conn)
+        lesson = str(params.get("lesson") or "").strip()
+        single = str(params.get("name") or params.get("path") or params.get("url") or "")
+        if not lesson and single and not single.lower().endswith(".mp3"):
+            lesson = single
+        if lesson.lower().endswith(".mp3"):
+            lesson = ""
+        clips = lesson_mp3_clips(
+            names,
+            getattr(conn, "active_app_id", "english_learning"),
+            getattr(conn, "current_task_id", "shadowing"),
+            lesson,
+        )
+        clips = [name for name in clips if not _is_full_lesson_mp3(name)]
+    if not clips:
+        conn.shadowing_clips = []
+        conn.shadowing_prompt = ""
+        return False
+    conn.shadowing_clips = clips
+    conn.shadowing_index = 0
+    conn.shadowing_prompt = clips[0]
+    conn.shadowing_awaiting_user = False
+    return True
+
+
+async def _start_shadowing_sd(conn: "ConnectionHandler", params: Optional[Dict[str, Any]]) -> bool:
+    if not await _prepare_shadowing_sd(conn, params):
+        return False
+    play = _begin_shadowing_playlist(conn, params)
+    conn.shadowing_clip_gen = int(getattr(conn, "shadowing_clip_gen", 0) or 0) + 1
+    notice = await _execute_named_tool_play(conn, play)
+    if notice and notice.action in (Action.ERROR, Action.NOTFOUND):
+        return False
+    return True
+
+
+async def _fill_repeater_play_name(
+    conn: "ConnectionHandler", params: Optional[Dict[str, Any]]
+) -> Dict[str, Any]:
+    play = dict(params or {})
+    if play.get("items"):
+        return play
+    if str(play.get("name") or "").strip():
+        return play
+    stub = default_repeater_playlist(_task_for(conn))
+    if stub:
+        first = stub[0]
+        play["name"] = first.get("name") or ""
+        if play.get("repeat") is None:
+            play["repeat"] = first.get("repeat") or 1
+        if play.get("speed") is None and first.get("speed") is not None:
+            play["speed"] = first.get("speed")
+        conn.repeater_play_name = play["name"]
+        return play
+    try:
+        names = await _list_sd_mp3_names(conn)
+    except Exception as exc:
+        logger.bind(tag=TAG).warning(f"鍒椾妇澶嶈璇炬枃澶辫触: {exc}")
+        return play
+    picked = repeater_lesson_mp3(
+        names,
+        getattr(conn, "active_app_id", "") or "english_learning",
+        getattr(conn, "current_task_id", "") or "repeater",
+    )
+    if picked:
+        play["name"] = picked
+        conn.repeater_play_name = picked
+    return play
+
+
+async def maybe_flush_pending_play_after_tts(conn: "ConnectionHandler") -> None:
+    """Start queued lesson audio only after enter-prompt TTS has finished on device."""
+    _ensure_runtime_fields(conn)
+    pending = getattr(conn, "pending_play_after_tts", None)
+    if not pending:
+        return
+    conn.pending_play_after_tts = None
+    kind = pending.get("kind") if isinstance(pending, dict) else None
+    try:
+        if kind == "repeater":
+            play_params = await _fill_repeater_play_name(conn, pending.get("params") or {})
+            await _execute_named_tool_play(conn, play_params)
+        elif kind == "shadowing":
+            play_params = pending.get("params") if isinstance(pending, dict) else None
+            if not play_params:
+                play_params = _begin_shadowing_playlist(conn, {})
+            conn.shadowing_awaiting_user = False
+            conn.shadowing_clip_gen = int(getattr(conn, "shadowing_clip_gen", 0) or 0) + 1
+            await _execute_named_tool_play(conn, play_params)
+    except Exception as exc:
+        logger.bind(tag=TAG).warning(f"寤跺悗鎾斁璇炬枃澶辫触: {exc}")
+
+
+def _lesson_end_speech(conn: "ConnectionHandler", phrase: str) -> str:
+    """Short lesson-end cue plus the same default chat greeting used after 缈昏瘧/涓嫳瀵硅瘽."""
+    greeting = (_greeting_text(conn) or "").strip()
+    phrase = (phrase or "").strip()
+    if phrase and greeting:
+        if phrase.endswith("銆�") or phrase.endswith("."):
+            return f"{phrase}{greeting}"
+        return f"{phrase}銆倇greeting}"
+    return phrase or greeting
+
+
+async def _return_to_chat_after_lesson(
+    conn: "ConnectionHandler",
+    phrase: str,
+    reason: str = "play_completed",
+) -> None:
+    """Playlist already finished on device: speak the end cue now, then chat. Do not MCP-stop."""
+    conn.repeater_speed_queue = []
+    conn.repeater_playlist = []
+    end_session(conn, reason)
+    if getattr(conn, "tts", None) is not None:
+        await wait_for_tts_channels(conn, timeout=3)
+    conn.sentence_id = uuid.uuid4().hex
+    _speak(conn, _lesson_end_speech(conn, phrase))
+    await start_app_task(
+        conn,
+        "chat",
+        "conversation",
+        {},
+        source="voice",
+        execute_skills=False,
+        speak_switch=False,
+        stop_skills=False,
+    )
+
+
+async def _shadowing_play_next_or_finish(conn: "ConnectionHandler") -> None:
+    """Legacy helper: device now sequences clips. Only used if a caller still asks to finish."""
+    conn.shadowing_index = 0
+    conn.shadowing_prompt = ""
+    conn.shadowing_clips = []
+    await _return_to_chat_after_lesson(conn, SHADOWING_END_PHRASE, "finished")
+
+
+def _mark_repeater_day(conn: "ConnectionHandler") -> None:
+    try:
+        mark_lesson_played_today(
+            str(getattr(conn, "device_id", "") or ""),
+            getattr(conn, "active_app_id", "") or "",
+            getattr(conn, "current_task_id", "") or "",
+            getattr(conn, "repeater_play_name", "") or "",
+        )
+    except Exception as exc:
+        logger.bind(tag=TAG).warning(f"璁板綍澶嶈澶╂暟澶辫触: {exc}")
+
+
+def _apply_playback_speed(
+    conn: "ConnectionHandler",
+    task: Optional[Dict[str, Any]],
+    params: Optional[Dict[str, Any]],
+) -> Dict[str, Any]:
+    """Attach clamped speed. Multi-speed repeats are Server-driven (one pass per play)."""
+    play = dict(params or {})
+    try:
+        repeat_n = int(play.get("repeat") or 1)
+    except (TypeError, ValueError):
+        repeat_n = 1
+    if repeat_n < 1:
+        repeat_n = 1
+    device_id = str(getattr(conn, "device_id", "") or "")
+    app_id = getattr(conn, "active_app_id", "") or ""
+    task_id = getattr(conn, "current_task_id", "") or ""
+    lesson = str(play.get("name") or "")
+    day_index = lesson_day_index(device_id, app_id, task_id, lesson)
+    if play.get("speed") is not None:
+        speeds = [clamp_play_speed(play.get("speed"))] * repeat_n
+    else:
+        speeds = speeds_for_repeats(task, repeat_n, day_index)
+    conn.repeater_play_name = lesson
+    conn.repeater_power_off_after = bool(play.get("power_off_after") or False)
+    varied = len(speeds) > 1 and any(abs(item - speeds[0]) > 0.001 for item in speeds[1:])
+    if varied:
+        conn.repeater_speed_queue = speeds[1:]
+        play["speed"] = speeds[0]
+        play["repeat"] = 1
+        play["power_off_after"] = False
+    else:
+        conn.repeater_speed_queue = []
+        play["speed"] = speeds[0]
+        play["repeat"] = repeat_n
+    # 澶嶈娓呭崟缁撴潫鐢� Server 鏀� audio.completed 鍚庡垏鍥炲璇濄�傝澶囦笉瑕� finish_task 鍐� NVS銆�
+    play["finish_task"] = False
+    return play
+
+
+def _begin_repeater_playlist(
+    conn: "ConnectionHandler",
+    task: Optional[Dict[str, Any]],
+    params: Optional[Dict[str, Any]],
+) -> Dict[str, Any]:
+    """One shot: send the whole playlist. Repeat is just the same clip again, no mode switch."""
+    items = parse_playlist(params)
+    if not items:
+        items = default_repeater_playlist(task)
+    normalized = []
+    for raw in items:
+        if not isinstance(raw, dict):
+            continue
+        name = str(raw.get("name") or "")
+        if not name:
+            continue
+        try:
+            repeat_n = int(raw.get("repeat") or 1)
+        except (TypeError, ValueError):
+            repeat_n = 1
+        if repeat_n < 1:
+            repeat_n = 1
+        item = {
+            "name": name,
+            "repeat": repeat_n,
+            "speed": clamp_play_speed(
+                raw["speed"] if raw.get("speed") is not None else 1.0
+            ),
+            "power_off_after": bool(raw.get("power_off_after") or False),
+            "finish_task": False,
+        }
+        normalized.append(item)
+    conn.repeater_playlist = []
+    conn.repeater_speed_queue = []
+    first = normalized[0] if normalized else dict(params or {})
+    conn.repeater_play_name = str(first.get("name") or "")
+    conn.repeater_power_off_after = bool(first.get("power_off_after") or False)
+    play = dict(first)
+    play["items"] = normalized
+    play["finish_task"] = False
+    return play
+
+
+async def _continue_repeater_if_needed(conn: "ConnectionHandler") -> bool:
+    queue = list(getattr(conn, "repeater_speed_queue", None) or [])
+    if queue:
+        nxt = queue.pop(0)
+        conn.repeater_speed_queue = queue
+        last_speed = len(queue) == 0 and not list(getattr(conn, "repeater_playlist", None) or [])
+        try:
+            play_params = await _fill_repeater_play_name(
+                conn,
+                {
+                    "name": getattr(conn, "repeater_play_name", "") or "",
+                    "speed": nxt,
+                    "repeat": 1,
+                    "power_off_after": last_speed
+                    and bool(getattr(conn, "repeater_power_off_after", False)),
+                    "finish_task": False,
+                },
+            )
+            await _execute_named_tool_play(conn, play_params)
+        except Exception as exc:
+            logger.bind(tag=TAG).warning(f"澶嶈涓嬩竴閬嶅け璐�: {exc}")
+            conn.repeater_speed_queue = []
+            return False
+        return True
+
+    rest = list(getattr(conn, "repeater_playlist", None) or [])
+    if not rest:
+        return False
+    nxt, conn.repeater_playlist = rest[0], rest[1:]
+    play_params = _apply_playback_speed(conn, _task_for(conn), nxt)
+    play_params["finish_task"] = False
+    try:
+        play_params = await _fill_repeater_play_name(conn, play_params)
+        await _execute_named_tool_play(conn, play_params)
+    except Exception as exc:
+        logger.bind(tag=TAG).warning(f"澶嶈涓嬩竴棣栧け璐�: {exc}")
+        conn.repeater_playlist = []
+        return False
+    return True
+
+
+async def _execute_named_tool_play(conn, params):
+    items = params.get("items") if isinstance(params.get("items"), list) else []
+    first = items[0] if items else params
+    play_args = {
+        "name": (first.get("name") if isinstance(first, dict) else None) or params.get("name") or "",
+        "repeat": int((first.get("repeat") if isinstance(first, dict) else None) or params.get("repeat") or 1),
+        "power_off_after": bool(params.get("power_off_after") or False),
+        "speed": clamp_play_speed(
+            (first.get("speed") if isinstance(first, dict) and first.get("speed") is not None else None)
+            if items
+            else (params["speed"] if params.get("speed") is not None else 1.0)
+        ),
+        "finish_task": bool(params.get("finish_task", True)),
+        "app_id": str(getattr(conn, "active_app_id", "") or ""),
+        "task_id": str(getattr(conn, "current_task_id", "") or ""),
+        "force": True,
+    }
+    if items:
+        play_args["items"] = json.dumps(items, ensure_ascii=False)
+        play_args["speed"] = clamp_play_speed(
+            first["speed"] if first.get("speed") is not None else 1.0
+        )
+        play_args["power_off_after"] = bool(
+            items[-1].get("power_off_after") if items else False
+        )
+    if params.get("listen_ratio") is not None:
+        try:
+            play_args["listen_ratio"] = float(params["listen_ratio"])
+        except (TypeError, ValueError):
+            pass
+    if play_args["task_id"] in ("repeater", "shadowing"):
+        play_args["finish_task"] = False
+    result = await _execute_named_tool(conn, DEVICE_PLAY_TOOL, play_args)
+    blob = ""
+    if result:
+        blob = str(result.response or result.result or "")
+    compact = blob.replace(" ", "")
+    if '"duplicate_ignored":true' in compact:
+        logger.bind(tag=TAG).warning("璁惧蹇界暐浜嗗悓涓�璇炬枃鐨勪笅涓�閬嶏紝寮哄埗鍐嶆挱涓�娆�")
+        play_args["speed"] = clamp_play_speed(float(play_args["speed"]) + 0.02)
+        play_args["force"] = True
+        result = await _execute_named_tool(conn, DEVICE_PLAY_TOOL, play_args)
+        blob = ""
+        if result:
+            blob = str(result.response or result.result or "")
+        compact = blob.replace(" ", "")
+    rejected = (result and result.action in (Action.ERROR, Action.NOTFOUND)) or (
+        "no_mp3" in blob
+        or '"accepted":false' in compact
+        or '"duplicate_ignored":true' in compact
+    )
+    if rejected:
+        if not play_args["name"]:
+            return ActionResponse(
+                action=Action.RESPONSE,
+                response="澶嶈鏈哄凡鎵撳紑锛屼絾 SD 鍗′笂杩樻病鏈夊彲鎾斁鐨勮鏂囥��",
+            )
+        return ActionResponse(
+            action=Action.RESPONSE,
+            response=(result.response if result else None) or "杩欏彴璁惧娌℃湁鏈湴澶嶈鎾斁鑳藉姏",
+        )
+    return None
+
+
 async def start_app_task(
     conn: "ConnectionHandler",
     app_id: str,
@@ -147,6 +1262,8 @@
     params: Optional[Dict[str, Any]] = None,
     source: str = "voice",
     execute_skills: bool = True,
+    speak_switch: bool = True,
+    stop_skills: bool = True,
 ) -> ActionResponse:
     """Single entry for MQTT / schedule / voice / default chat."""
     _ensure_runtime_fields(conn)
@@ -176,8 +1293,16 @@
             response="褰撳墠鏈夋洿楂樹紭鍏堢骇鐨勪换鍔★紝鍏堝畬鎴愬悗鍐嶅垏鎹�",
         )
 
-    if not same_task or new_p >= cur_p:
-        await _stop_current_skills(conn)
+    prev_app_id = getattr(conn, "active_app_id", "chat")
+    prev_task_id = getattr(conn, "current_task_id", "conversation")
+    prev_app, prev_task = get_task(prev_app_id, prev_task_id)
+    switching = (prev_app_id != app_id) or (prev_task_id != resolved_task_id)
+
+    if not same_task:
+        if stop_skills:
+            await _stop_current_skills(conn)
+        if app_id != "chat":
+            conn.chat_greeted = False
 
     conn.active_app_id = app_id
     conn.current_task_id = resolved_task_id
@@ -192,51 +1317,92 @@
         f"start_app_task app={app_id} task={resolved_task_id} mode={conn.interaction_mode} source={source}"
     )
 
+    if app_id == "chat":
+        end_session(conn, "user_exit")
+    elif switching:
+        begin_session(conn, app_id, resolved_task_id, source, conn.interaction_mode)
+
+    pending_play = None
     if execute_skills:
+        if app_sd_layout_paths(app_id, app):
+            await ensure_app_sd_dirs(conn, app_id)
         for skill in task.get("skills") or []:
             skill_name = (skill or {}).get("name") or ""
             if skill_name in ("self.mp3.play", DEVICE_PLAY_TOOL):
-                play_args = {
-                    "name": params.get("name") or "",
-                    "repeat": int(params.get("repeat") or 1),
-                    "power_off_after": bool(params.get("power_off_after") or False),
-                }
-                if params.get("speed") is not None:
-                    play_args["speed"] = params.get("speed")
-                if not play_args["name"]:
-                    return ActionResponse(
-                        action=Action.RESPONSE,
-                        response="娌℃湁鎸囧畾瑕佸璇荤殑鏂囦欢鍚嶏紝璇峰厛鐢� self.mp3.get_list 纭 files[].name",
-                    )
-                result = await _execute_named_tool(conn, DEVICE_PLAY_TOOL, play_args)
-                if result and result.action in (Action.ERROR, Action.NOTFOUND):
-                    return ActionResponse(
-                        action=Action.RESPONSE,
-                        response=result.response or "杩欏彴璁惧娌℃湁鏈湴澶嶈鎾斁鑳藉姏",
-                    )
+                play_params = _begin_repeater_playlist(conn, task, params)
+                pending_play = {"kind": "repeater", "params": play_params}
             elif skill_name == "self.power_off":
                 await _execute_named_tool(conn, "self_power_off", {})
 
-    if conn.interaction_mode == "CHAT":
+    announce = ""
+    boot_chat = app_id == "chat" and source == "default_chat"
+    if boot_chat:
+        # 寮�鏈鸿繘 chat锛氬繀椤绘挱娆㈣繋璇嶃�備笉瑕佽 same_task / just_woken_up 璺宠繃銆�
+        enable = (conn.config or {}).get("enable_proactive_greeting", True)
+        if enable and not getattr(conn, "chat_greeted", False):
+            if not await wait_for_tts_channels(conn, timeout=10):
+                logger.bind(tag=TAG).error("寮�鏈烘杩庤瘝璺宠繃锛歍TS閫氶亾鏈氨缁�")
+            else:
+                conn.chat_greeted = True
+                conn.sentence_id = uuid.uuid4().hex
+                _speak(conn, _greeting_text(conn))
+    elif switching:
+        announce = _switch_announcement(prev_app, prev_task, app, task, app_id)
+        if conn.interaction_mode == "SHADOWING":
+            started = await _prepare_shadowing_sd(conn, params)
+            if not started:
+                prefix = announce or "鐜板湪宸茬粡鍒囨崲鑷宠窡璇绘ā寮忋��"
+                announce = f"{prefix}璺熻璇炬枃杩樻病鏈夋斁鍒癝D鍗°��"
+            else:
+                announce = announce or "鐜板湪宸茬粡鍒囨崲鑷宠窡璇绘ā寮忥紝璇疯窡鐫�璇炬枃璇汇��"
+                pending_play = {
+                    "kind": "shadowing",
+                    "params": _begin_shadowing_playlist(conn, params),
+                }
+        if speak_switch and announce:
+            conn.sentence_id = uuid.uuid4().hex
+            conn.pending_play_after_tts = pending_play
+            _speak(conn, announce)
+        elif pending_play:
+            conn.pending_play_after_tts = pending_play
+            await maybe_flush_pending_play_after_tts(conn)
+        if app_id == "chat":
+            conn.chat_greeted = True
+    elif conn.interaction_mode == "CHAT":
         enable = (conn.config or {}).get("enable_proactive_greeting", True)
         woken = bool(getattr(conn, "just_woken_up", False))
-        if enable and not woken:
+        if enable and not woken and not getattr(conn, "chat_greeted", False):
+            conn.chat_greeted = True
+            conn.sentence_id = uuid.uuid4().hex
             _speak(conn, _greeting_text(conn))
+    elif conn.interaction_mode == "SHADOWING" and not getattr(conn, "shadowing_clips", None):
+        started = await _start_shadowing_sd(conn, params)
+        if not started and speak_switch:
+            conn.sentence_id = uuid.uuid4().hex
+            _speak(conn, "璺熻璇炬枃杩樻病鏈夋斁鍒癝D鍗°��")
+    elif pending_play:
+        conn.pending_play_after_tts = pending_play
+        await maybe_flush_pending_play_after_tts(conn)
 
-    app_name = app.get("name") or app_id
+    app_name = _display_name(app, task)
     return ActionResponse(
         action=Action.NONE,
         result=f"{app_id}/{resolved_task_id}",
-        response=f"宸叉墦寮�{app_name}",
+        response=announce or f"宸叉墦寮�{app_name}",
     )
 
 
-async def switch_app(conn: "ConnectionHandler", app_id: str, source: str = "voice") -> ActionResponse:
+async def switch_app(
+    conn: "ConnectionHandler",
+    app_id: str,
+    source: str = "voice",
+    task_id: str = "",
+) -> ActionResponse:
     app = get_app(app_id)
     if not app:
         return ActionResponse(action=Action.ERROR, response=f"涓嶆敮鎸佺殑 App: {app_id}")
-    task_id = app.get("default_task") or ""
-    return await start_app_task(conn, app_id, task_id, {}, source)
+    resolved = task_id or app.get("default_task") or ""
+    return await start_app_task(conn, app_id, resolved, {}, source)
 
 
 async def on_user_wakeup(conn: "ConnectionHandler") -> None:
@@ -248,24 +1414,11 @@
 
 
 def match_voice_switch(text: str) -> Optional[tuple]:
-    """Map a short phrase to (app_id, task_id) or ('__menu__', '')."""
+    """Map a short phrase to (app_id, task_id) using apps.json voice_enter / voice_exit."""
     if not text:
         return None
     _, filtered = _strip(text)
-    if filtered in ("鍒囨崲妯″紡", "qie huan mo shi"):
-        return ("__menu__", "")
-    for p in CHAT_PHRASES:
-        if p in filtered:
-            return ("chat", "conversation")
-    for p in TRANSLATE_PHRASES:
-        if p in filtered:
-            return ("english_translation", "live_translate")
-    for p in REPEATER_PHRASES:
-        if p in filtered:
-            return ("english_learning", "repeater")
-    if any(k in filtered for k in ("鏁板瓧鑳岃", "鎴愯鎺ラ緳", "鍙g畻", "鍔犲噺娉�")):
-        return ("__reserved__", "")
-    return None
+    return match_registry_voice(filtered)
 
 
 def _strip(text: str) -> tuple:
@@ -274,9 +1427,23 @@
     return remove_punctuation_and_length(text)
 
 
+def is_exit_to_chat(text: str) -> bool:
+    mapped = match_voice_switch(text)
+    return bool(mapped and mapped[0] == "chat")
+
+
+def should_skip_listen_stop_asr(conn: Any) -> bool:
+    """True when listen stop is closing the mic for lesson playback, not a follow-read."""
+    mode = getattr(conn, "interaction_mode", "CHAT")
+    if mode == "PLAY_AUDIO":
+        return True
+    if mode == "SHADOWING" and not getattr(conn, "shadowing_awaiting_user", False):
+        return True
+    return False
+
+
 def is_exit_translation(text: str) -> bool:
-    _, filtered = _strip(text)
-    return any(p in filtered for p in ("閫�鍑虹炕璇�", "缁撴潫缈昏瘧", "鍥炲埌鑱婂ぉ", "鎴戜滑鏉ヨ亰澶╁惂", "鑱婁細澶╁惂"))
+    return is_exit_to_chat(text)
 
 
 def _plain_user_text(text: str) -> str:
@@ -296,6 +1463,40 @@
     return raw
 
 
+def _norm_for_echo(text: str) -> str:
+    _, filtered = _strip(text or "")
+    return re.sub(r"\s+", "", (filtered or "").lower())
+
+
+def is_echo_of_prompt(user_text: str, prompt: str) -> bool:
+    a = _norm_for_echo(user_text)
+    b = _norm_for_echo(prompt)
+    if not a or not b:
+        return False
+    if a == b or a in b or b in a:
+        return True
+    return difflib.SequenceMatcher(None, a, b).ratio() >= 0.82
+
+
+def detect_user_language(text: str, hint: Optional[str] = None) -> str:
+    if hint:
+        h = str(hint).lower()
+        if h.startswith("zh") or h in ("cn", "yue", "chinese"):
+            return "zh"
+        if h.startswith("en") or h in ("english",):
+            return "en"
+    body = _plain_user_text(text)
+    cjk = len(re.findall(r"[\u4e00-\u9fff]", body))
+    latin = len(re.findall(r"[A-Za-z]", body))
+    return "zh" if cjk >= max(latin, 1) else "en"
+
+
+def wrap_bilingual_user_text(text: str, user_lang: str) -> str:
+    if user_lang == "zh":
+        return "Respond in English only. Do not use Chinese characters.\n\n" + text
+    return "璇峰彧鐢ㄤ腑鏂囧洖绛旓紝涓嶈鍑虹幇鑻辨枃鍗曡瘝鎴栧彞瀛愩�俓n\n" + text
+
+
 def _looks_like_tool_error(text: str) -> bool:
     if not text:
         return True
@@ -307,6 +1508,180 @@
     return False
 
 
+def _record_shadowing_turn(
+    conn: "ConnectionHandler",
+    text: str,
+    echo_rejected: bool = False,
+    index: Optional[int] = None,
+) -> None:
+    session = current_session(conn)
+    if not session:
+        return
+    if index is None:
+        index = int(getattr(conn, "shadowing_assess_index", getattr(conn, "shadowing_index", 0)) or 0)
+    session.upsert_turn(
+        index=index,
+        prompt_text=getattr(conn, "shadowing_prompt", "") or "",
+        asr_text=_plain_user_text(text),
+        echo_rejected=echo_rejected,
+        expected_text=_expected_for_index(conn, index),
+    )
+    session.save()
+
+
+def apply_shadowing_user_text(conn: "ConnectionHandler", text: str) -> str:
+    """Apply one ASR utterance. Returns exit | ignore | echo | recorded.
+
+    ASR never advances the clip index; the listen-window clock does.
+    Late ASR after the window still scores the sentence that was just listened to.
+    Device plays the next clip itself; Server does not MCP the next sentence.
+    """
+    if is_exit_to_chat(text):
+        end_session(conn, "user_exit")
+        return "exit"
+    if getattr(conn, "client_is_speaking", False) and not getattr(
+        conn, "shadowing_awaiting_user", False
+    ):
+        logger.bind(tag=TAG).info("璺熻锛氭挱鏀句腑蹇界暐鎷鹃煶锛岄伩鍏嶅枃鍙洖澹拌Е鍙戝璇�")
+        return "ignore"
+    prompt = getattr(conn, "shadowing_prompt", "") or ""
+    if (
+        getattr(conn, "shadowing_awaiting_user", False)
+        and prompt
+        and not _is_clip_path(prompt)
+        and is_echo_of_prompt(text, prompt)
+    ):
+        logger.bind(tag=TAG).info("璺熻锛氬拷鐣ヤ笌鑼冭杩囦簬鐩镐技鐨勮瘑鍒粨鏋�")
+        session = current_session(conn)
+        if session:
+            session.add_event("echo_rejected")
+            session.save()
+        return "echo"
+    _record_shadowing_turn(conn, text, echo_rejected=False)
+    return "recorded"
+
+
+async def handle_shadowing_turn(conn: "ConnectionHandler", text: str) -> None:
+    """Score this utterance off the chat path. Next clip is sequenced on device."""
+    outcome = apply_shadowing_user_text(conn, text)
+    if outcome == "exit":
+        _cancel_shadowing_timeout(conn)
+        await start_app_task(conn, "chat", "conversation", {}, source="voice")
+        return
+    if outcome in ("ignore", "echo"):
+        return
+
+    step_index = int(
+        getattr(conn, "shadowing_assess_index", getattr(conn, "shadowing_index", 0)) or 0
+    )
+    logger.bind(tag=TAG).info(f"璺熻 ASR 浠呰瘎浼颁笉涓婂璇� step={step_index}")
+    _schedule_deferred_assessment(conn, step_index)
+
+
+async def handle_skill_event(conn: "ConnectionHandler", text: str) -> bool:
+    """Handle `[skill_event]audio.completed|app|task|duration_ms`. Returns True if consumed."""
+    parsed = parse_skill_event(text)
+    if parsed is None:
+        return False
+    _ensure_runtime_fields(conn)
+    name = parsed.get("name") or ""
+    app_id = parsed.get("app_id") or ""
+    task_id = parsed.get("task_id") or ""
+    try:
+        duration_ms = int(parsed.get("duration_ms") or 0)
+    except (TypeError, ValueError):
+        duration_ms = 0
+    logger.bind(tag=TAG).info(f"skill_event {name} {app_id}/{task_id} duration_ms={duration_ms}")
+
+    mode = getattr(conn, "interaction_mode", "CHAT")
+    # 鍚獥鏃堕挓蹇呴』鍏堜簬 session.save()锛氱鐩�/HTTP 涓嶈兘鎸′綇 1.2脳duration 鐨勭儹璺緞銆�
+    shadowing_playlist_done = name in ("audio.playlist_completed", "playlist.completed") and (
+        mode == "SHADOWING" or task_id == "shadowing"
+    )
+    if shadowing_playlist_done:
+        _cancel_shadowing_timeout(conn)
+        if getattr(conn, "shadowing_awaiting_user", False):
+            await _on_shadowing_listen_window_closed(conn)
+        else:
+            save_shadowing_listen_audio(conn)
+        session = getattr(conn, "task_session", None)
+        if session:
+            session.add_event(name, app_id, task_id)
+        await _return_to_chat_after_lesson(conn, SHADOWING_END_PHRASE, "finished")
+        return True
+    if mode == "SHADOWING" and name == "audio.completed":
+        if getattr(conn, "shadowing_awaiting_user", False):
+            await _on_shadowing_listen_window_closed(conn)
+        conn.shadowing_clip_gen = int(getattr(conn, "shadowing_clip_gen", 0) or 0) + 1
+        _schedule_shadowing_listen_timeout(conn, duration_ms)
+        session = getattr(conn, "task_session", None)
+        if session:
+            session.add_event(name, app_id, task_id)
+        return True
+    if mode == "SHADOWING" and name == "audio.stopped":
+        session = getattr(conn, "task_session", None)
+        if session:
+            session.add_event(name, app_id, task_id)
+        end_session(conn, "play_stopped")
+        await start_app_task(
+            conn,
+            "chat",
+            "conversation",
+            {},
+            source="voice",
+            execute_skills=False,
+            speak_switch=False,
+        )
+        return True
+
+    if mode == "PLAY_AUDIO" and name == "audio.completed":
+        continued = await _continue_repeater_if_needed(conn)
+        session = getattr(conn, "task_session", None)
+        if session:
+            session.add_event(name, app_id, task_id)
+        if continued:
+            if session:
+                session.save()
+            return True
+        _mark_repeater_day(conn)
+        await _return_to_chat_after_lesson(conn, REPEATER_END_PHRASE, "play_completed")
+        return True
+    if mode == "PLAY_AUDIO" and name == "audio.stopped":
+        conn.repeater_speed_queue = []
+
+    session = getattr(conn, "task_session", None)
+    if session:
+        session.add_event(name, app_id, task_id)
+        session.save()
+
+    if name == "audio.completed":
+        end_session(conn, "play_completed")
+        await start_app_task(
+            conn,
+            "chat",
+            "conversation",
+            {},
+            source="voice",
+            execute_skills=False,
+            speak_switch=True,
+        )
+    elif name == "audio.stopped":
+        end_session(conn, "play_stopped")
+        if getattr(conn, "active_app_id", "chat") != "chat":
+            await start_app_task(
+                conn,
+                "chat",
+                "conversation",
+                {},
+                source="voice",
+                execute_skills=False,
+                speak_switch=False,
+            )
+    else:
+        logger.bind(tag=TAG).warning(f"鏈煡 skill_event: {name}")
+    return True
+
+
 async def run_translation(conn: "ConnectionHandler", text: str) -> str:
     """Translate with the connection LLM first (鏅烘帶鍙板凡閰嶇殑妯″瀷). MCP is fallback only."""
     text = _plain_user_text(text)

--
Gitblit v1.9.1