wangzhibo
10 天以前 220e4fab27b7f419b3e89e2f2d28ae789aac09d7
xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/runtime.py
@@ -30,8 +30,10 @@
    default_shadowing_playlist,
    expand_playlist_plays,
    lesson_day_index,
    load_last_lesson_params,
    mark_lesson_played_today,
    parse_playlist,
    playlist_from_params,
    remember_last_lesson_params,
    resolve_play_speed,
    speeds_for_repeats,
)
@@ -43,7 +45,7 @@
    match_registry_voice,
    voice_menu_text,
)
from .session import begin_session, current_session, end_session, parse_skill_event
from .session import begin_session, current_session, end_session, parse_skill_event, schedule_close_parent_except
if TYPE_CHECKING:
    from core.connection import ConnectionHandler
@@ -70,6 +72,26 @@
    "用户说退出中英对话、回到聊天时调用 switch_app,app_id=chat。"
)
BILINGUAL_PAIR_PROMPTS = {
    "zh-en": BILINGUAL_PROMPT,
    "zh-fr": (
        "你正在进行中法对照对话练习,不是翻译机,不要把用户的话原样译回去交差。"
        "用户说中文时你用法语完整回答;用户说法语时你用中文完整回答。"
        "不要两种语言同时输出。用户说退出对话、回到聊天时调用 switch_app,app_id=chat。"
    ),
    "zh-ja": (
        "你正在进行中日对照对话练习,不是翻译机,不要把用户的话原样译回去交差。"
        "用户说中文时你用日语完整回答;用户说日语时你用中文完整回答。"
        "不要两种语言同时输出。用户说退出对话、回到聊天时调用 switch_app,app_id=chat。"
    ),
}
TRANSLATOR_PAIR_PROMPTS = {
    "zh-en": TRANSLATOR_PROMPT,
    "zh-fr": "你是一台翻译机。只输出译文。中文译成法语,法语译成中文;其它语言默认译成中文。",
    "zh-ja": "你是一台翻译机。只输出译文。中文译成日语,日语译成中文;其它语言默认译成中文。",
}
CHAT_PROACTIVE_HINT = (
    "你是用户的语音伙伴。进入对话后可以先主动短句邀请聊天,例如「我们来聊天吧」。"
    "用户开口后按正常对话回答,不要反复催促。"
@@ -92,8 +114,13 @@
SD_LAYOUT_MODES = ("PLAY_AUDIO", "SHADOWING")
SHADOWING_LISTEN_RATIO = 1.2
SHADOWING_LISTEN_MIN_MS = 800
REPEATER_END_PHRASE = "复读结束"
REPEATER_END_PHRASE = "泛听结束"
SHADOWING_END_PHRASE = "跟读结束"
REPEATER_START_PHRASE = "开始泛听任务"
SHADOWING_START_PHRASE = "开始跟读任务"
CHAT_START_PHRASE = "开始对话任务"
TRANSLATION_START_PHRASE = "开始翻译任务"
BILINGUAL_START_PHRASE = "开始中英对话任务"
def source_priority(source: str) -> int:
@@ -282,17 +309,30 @@
        handler.upload_functions_desc()
def _apply_prompt(conn: "ConnectionHandler", app: Dict[str, Any]) -> None:
def _normalize_language_pair(raw: Any) -> str:
    pair = str(raw or "zh-en").strip().lower().replace("_", "-")
    if pair in ("cn-en", "zh-cn-en", "zhcn-en"):
        return "zh-en"
    return pair or "zh-en"
def _apply_prompt(conn: "ConnectionHandler", app: Dict[str, Any], params: Optional[Dict[str, Any]] = None) -> None:
    mode = app.get("system_prompt_mode") or "none"
    if conn.original_system_prompt is None:
        conn.original_system_prompt = conn.prompt
    pair = _normalize_language_pair((params or {}).get("language_pair"))
    conn.language_pair = pair
    conn.language_pair_fallback = pair not in BILINGUAL_PAIR_PROMPTS and pair not in TRANSLATOR_PAIR_PROMPTS
    prompt_pair = "zh-en" if conn.language_pair_fallback else pair
    if mode == "translator":
        pair_prompt = TRANSLATOR_PAIR_PROMPTS.get(prompt_pair, TRANSLATOR_PROMPT)
        conn.change_system_prompt(
            "你现在是英语翻译机。用户说话后应调用 translate;用户说退出翻译、回到聊天时调用 switch_app,app_id=chat。"
            pair_prompt
            + "用户说话后应调用 translate;用户说退出翻译、回到聊天时调用 switch_app,app_id=chat。"
            "不要讲笑话、不要查天气、不要闲聊。"
        )
    elif mode == "bilingual":
        conn.change_system_prompt(BILINGUAL_PROMPT)
        conn.change_system_prompt(BILINGUAL_PAIR_PROMPTS.get(prompt_pair, BILINGUAL_PROMPT))
    elif mode == "agent_proactive":
        base = conn.original_system_prompt or conn.prompt or ""
        if CHAT_PROACTIVE_HINT not in (base or ""):
@@ -378,6 +418,28 @@
        speak_txt(conn, text)
    except Exception as e:
        logger.bind(tag=TAG).warning(f"主动播报失败: {e}")
async def _play_bind_prompt(conn: "ConnectionHandler") -> None:
    from core.handle.receiveAudioHandle import check_bind_device
    await check_bind_device(conn)
async def prompt_bind_if_needed(conn: "ConnectionHandler") -> bool:
    """未绑定设备时播 6 位绑定码,并阻止欢迎词/进场词。已绑定则返回 False。"""
    event = getattr(conn, "bind_completed_event", None)
    if event is not None and not event.is_set():
        try:
            await asyncio.wait_for(event.wait(), timeout=8)
        except asyncio.TimeoutError:
            pass
    if not getattr(conn, "need_bind", False):
        return False
    if not await wait_for_tts_channels(conn, timeout=8):
        logger.bind(tag=TAG).warning("TTS通道未就绪,仍尝试播放绑定码")
    await _play_bind_prompt(conn)
    return True
def _greeting_text(conn: "ConnectionHandler") -> str:
@@ -575,7 +637,15 @@
            step_index,
            wav_bytes,
            conn=conn,
            text=asr,
        )
    elif audio_id and asr:
        try:
            from core.audio_archive import write_sidecar_for_relative
            write_sidecar_for_relative(conn, audio_id, asr)
        except Exception:
            pass
    try:
        from core.handle.reportHandle import enqueue_asr_report
@@ -829,6 +899,112 @@
    return _parse_mp3_list_names(result)
async def _list_sd_mp3_names_safe(conn: "ConnectionHandler"):
    try:
        names = await _list_sd_mp3_names(conn)
        return names if isinstance(names, list) else []
    except Exception as exc:
        logger.bind(tag=TAG).warning(f"列举 SD 课文失败: {exc}")
        return None
def _play_lesson_names(play_params: Optional[Dict[str, Any]]) -> list:
    from .lesson_audio import unique_lesson_names
    if not isinstance(play_params, dict):
        return []
    items = play_params.get("items")
    if isinstance(items, list) and items:
        return unique_lesson_names(items)
    return unique_lesson_names([play_params])
def _lesson_sync_timeout_sec(conn: "ConnectionHandler") -> float:
    runtime = {}
    cfg = getattr(conn, "config", None)
    if isinstance(cfg, dict) and isinstance(cfg.get("app_runtime"), dict):
        runtime = cfg.get("app_runtime") or {}
    raw = runtime.get("lesson_sync_timeout_sec", 90)
    try:
        return max(0.0, float(raw))
    except (TypeError, ValueError):
        return 90.0
async def _request_lesson_sync(
    conn: "ConnectionHandler",
    missing: list,
    task_id: str,
    params: Optional[Dict[str, Any]],
) -> None:
    from config.manage_api_client import sync_lesson_audio
    from .lesson_audio import conn_org_id, course_id_for, lesson_audio_base_url, unique_lesson_names
    files = unique_lesson_names(missing)
    if not files:
        return
    params = params or {}
    lesson_id = str(params.get("lesson_id") or params.get("lesson") or "l1.001").strip() or "l1.001"
    org_id = conn_org_id(conn, params)
    payload = {
        "macAddress": getattr(conn, "device_id", None) or "",
        "courseId": course_id_for(task_id, lesson_id),
        "orgId": org_id,
        "baseUrl": lesson_audio_base_url(getattr(conn, "config", None), org_id),
        "files": files,
    }
    result = await sync_lesson_audio(payload)
    if result is None:
        logger.bind(tag=TAG).warning(f"课文 MQTT 同步失败 files={len(files)}")
    else:
        logger.bind(tag=TAG).info(f"课文 MQTT 同步已下发 org={org_id} files={len(files)}")
async def _wait_lesson_files(conn: "ConnectionHandler", needed: list, timeout_sec: float) -> bool:
    from .lesson_audio import missing_lesson_names
    if not needed:
        return False
    deadline = asyncio.get_running_loop().time() + max(0.0, timeout_sec)
    while True:
        present = await _list_sd_mp3_names_safe(conn)
        if present is not None and not missing_lesson_names(needed, present):
            return True
        remaining = deadline - asyncio.get_running_loop().time()
        if remaining <= 0:
            return False
        await asyncio.sleep(min(2.0, remaining))
async def _ensure_lesson_mp3s(
    conn: "ConnectionHandler",
    names: list,
    task_id: str,
    params: Optional[Dict[str, Any]],
) -> bool:
    """READY if SD already has the package. Otherwise PRELOAD via MQTT then wait. Device paths have no org."""
    from .lesson_audio import missing_lesson_names, unique_lesson_names
    needed = unique_lesson_names(names)
    if not needed:
        return False
    present = await _list_sd_mp3_names_safe(conn)
    if present is None:
        logger.bind(tag=TAG).warning("无法列举 SD 课文,跳过同步以免误报")
        return True
    missing = missing_lesson_names(needed, present)
    if not missing:
        return True
    params = params or {}
    conn.lesson_syncing = True
    _speak(conn, "正在同步音频,请稍等。")
    await _request_lesson_sync(conn, missing, task_id, params)
    timeout = _lesson_sync_timeout_sec(conn)
    ready = await _wait_lesson_files(conn, needed, timeout)
    conn.lesson_syncing = False
    return ready
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")
@@ -839,9 +1015,7 @@
    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))
    items = playlist_from_params(_task_for(conn), params, "shadowing", default_shadowing_playlist)
    compact: List[Dict[str, Any]] = []
    for raw in items:
        if not isinstance(raw, dict):
@@ -919,13 +1093,17 @@
    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()
        lesson = str(params.get("lesson_id") or 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 = ""
        if not lesson:
            conn.shadowing_clips = []
            conn.shadowing_prompt = ""
            return False
        names = await _list_sd_mp3_names(conn)
        clips = lesson_mp3_clips(
            names,
            getattr(conn, "active_app_id", "english_learning"),
@@ -948,6 +1126,9 @@
    if not await _prepare_shadowing_sd(conn, params):
        return False
    play = _begin_shadowing_playlist(conn, params)
    names = _play_lesson_names(play)
    if not await _ensure_lesson_mp3s(conn, names, getattr(conn, "current_task_id", "shadowing") or "shadowing", params):
        return False
    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):
@@ -972,20 +1153,6 @@
        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
@@ -1010,6 +1177,32 @@
            await _execute_named_tool_play(conn, play_params)
    except Exception as exc:
        logger.bind(tag=TAG).warning(f"延后播放课文失败: {exc}")
def _lesson_start_phrase(task_id: str) -> str:
    if task_id == "repeater":
        return REPEATER_START_PHRASE
    if task_id == "shadowing":
        return SHADOWING_START_PHRASE
    return ""
def _schedule_talk_start_phrase(task_id: str) -> str:
    if task_id == "conversation":
        return CHAT_START_PHRASE
    if task_id == "live_translate":
        return TRANSLATION_START_PHRASE
    if task_id == "bilingual_chat":
        return BILINGUAL_START_PHRASE
    return ""
def _speak_lesson_start(conn: "ConnectionHandler", task_id: str) -> None:
    phrase = _lesson_start_phrase(task_id)
    if not phrase:
        return
    conn.sentence_id = uuid.uuid4().hex
    _speak(conn, phrase)
def _lesson_end_speech(conn: "ConnectionHandler", phrase: str) -> str:
@@ -1065,7 +1258,7 @@
            getattr(conn, "repeater_play_name", "") or "",
        )
    except Exception as exc:
        logger.bind(tag=TAG).warning(f"记录复读天数失败: {exc}")
        logger.bind(tag=TAG).warning(f"记录泛听天数失败: {exc}")
def _apply_playback_speed(
@@ -1102,7 +1295,7 @@
        conn.repeater_speed_queue = []
        play["speed"] = speeds[0]
        play["repeat"] = repeat_n
    # 复读清单结束由 Server 收 audio.completed 后切回对话。设备不要 finish_task 写 NVS。
    # 泛听清单结束由 Server 收 audio.completed 后切回对话。设备不要 finish_task 写 NVS。
    play["finish_task"] = False
    return play
@@ -1113,9 +1306,7 @@
    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)
    items = playlist_from_params(task, params, "repeater", default_repeater_playlist)
    normalized = []
    for raw in items:
        if not isinstance(raw, dict):
@@ -1170,7 +1361,7 @@
            )
            await _execute_named_tool_play(conn, play_params)
        except Exception as exc:
            logger.bind(tag=TAG).warning(f"复读下一遍失败: {exc}")
            logger.bind(tag=TAG).warning(f"泛听下一遍失败: {exc}")
            conn.repeater_speed_queue = []
            return False
        return True
@@ -1185,7 +1376,7 @@
        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}")
        logger.bind(tag=TAG).warning(f"泛听下一首失败: {exc}")
        conn.repeater_playlist = []
        return False
    return True
@@ -1246,13 +1437,45 @@
        if not play_args["name"]:
            return ActionResponse(
                action=Action.RESPONSE,
                response="复读机已打开,但 SD 卡上还没有可播放的课文。",
                response="泛听已打开,但 SD 卡上还没有可播放的课文。",
            )
        return ActionResponse(
            action=Action.RESPONSE,
            response=(result.response if result else None) or "这台设备没有本地复读播放能力",
            response=(result.response if result else None) or "这台设备没有本地泛听播放能力",
        )
    return None
def _apply_last_lesson_params(
    conn: "ConnectionHandler",
    task_id: str,
    params: Optional[Dict[str, Any]],
    source: str,
) -> Dict[str, Any]:
    params = dict(params or {})
    if task_id not in ("repeater", "shadowing"):
        return params
    device_id = str(getattr(conn, "device_id", "") or "")
    has_lesson = str(params.get("lesson_id") or params.get("lesson") or "").strip()
    if has_lesson:
        remember_last_lesson_params(device_id, task_id, params)
        return params
    saved = load_last_lesson_params(device_id, task_id)
    if saved:
        merged = dict(saved)
        for key, value in params.items():
            if value not in (None, "", []):
                merged[key] = value
        return merged
    return params
def _lesson_missing_speech(task_id: str, params: Optional[Dict[str, Any]], fallback: str) -> str:
    kind = "泛听" if task_id == "repeater" else "跟读"
    lesson = str((params or {}).get("lesson_id") or (params or {}).get("lesson") or "").strip()
    if not lesson:
        return f"还没有下发过{kind}课文,请先在家长端选一课。"
    return fallback
async def start_app_task(
@@ -1267,7 +1490,15 @@
) -> ActionResponse:
    """Single entry for MQTT / schedule / voice / default chat."""
    _ensure_runtime_fields(conn)
    if await prompt_bind_if_needed(conn):
        return ActionResponse(action=Action.NONE, response="设备未绑定")
    conn.client_abort = False
    tts = getattr(conn, "tts", None)
    if tts is not None:
        tts.tts_stop_request = False
    params = params or {}
    if params.get("announce") is False:
        speak_switch = False
    app, task = get_task(app_id, task_id)
    if not app:
        return ActionResponse(action=Action.ERROR, response=f"未知的 App: {app_id}")
@@ -1278,13 +1509,18 @@
        return ActionResponse(action=Action.ERROR, response=f"未知的 Task: {app_id}/{task_id}")
    resolved_task_id = task_id or app.get("default_task") or "conversation"
    params = _apply_last_lesson_params(conn, resolved_task_id, params, source)
    new_p = source_priority(source)
    cur_p = source_priority(getattr(conn, "current_source", "default_chat"))
    same_task = (
        getattr(conn, "active_app_id", "") == app_id
        and getattr(conn, "current_task_id", "") == resolved_task_id
    )
    if new_p < cur_p and not same_task:
    # 口令回对话要能退出家长即时/计划任务。其它低优先级切换仍然拦住。
    voice_exit_to_chat = (
        source == "voice" and app_id == "chat" and resolved_task_id == "conversation"
    )
    if new_p < cur_p and not same_task and not voice_exit_to_chat:
        logger.bind(tag=TAG).info(
            f"拒绝低优先级任务 {source}({new_p}) < {conn.current_source}({cur_p})"
        )
@@ -1292,6 +1528,9 @@
            action=Action.NONE,
            response="当前有更高优先级的任务,先完成后再切换",
        )
    if source in ("voice", "mqtt_immediate", "schedule"):
        schedule_close_parent_except(conn, source, app_id, resolved_task_id)
    prev_app_id = getattr(conn, "active_app_id", "chat")
    prev_task_id = getattr(conn, "current_task_id", "conversation")
@@ -1310,29 +1549,56 @@
    conn.current_source = source
    conn.tool_policy = app.get("tool_policy") or "inherit_agent"
    conn.tool_allowlist = list(app.get("tools") or [])
    _apply_prompt(conn, app)
    _apply_prompt(conn, app, params)
    _refresh_tools(conn)
    logger.bind(tag=TAG).info(
        f"start_app_task app={app_id} task={resolved_task_id} mode={conn.interaction_mode} source={source}"
    )
    device_ready = bool(params.get("sd_ready"))
    if device_ready:
        speak_switch = False
        logger.bind(tag=TAG).info("课文已在 SD,先播开始语,再由设备本地开播")
        _speak_lesson_start(conn, resolved_task_id)
    if app_id == "chat":
        end_session(conn, "user_exit")
        # 家长下发的对话要记一条执行记录;开机欢迎词 / 口令回对话不要另开 session。
        if source in ("mqtt_immediate", "schedule"):
            begin_session(conn, app_id, resolved_task_id, source, conn.interaction_mode)
    elif switching:
        begin_session(conn, app_id, resolved_task_id, source, conn.interaction_mode)
    pending_play = None
    if execute_skills:
    lesson_missing = False
    if execute_skills and not device_ready:
        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_params = _begin_repeater_playlist(conn, task, params)
                pending_play = {"kind": "repeater", "params": play_params}
                items = play_params.get("items") if isinstance(play_params, dict) else None
                if not items and not (isinstance(play_params, dict) and play_params.get("name")):
                    lesson_missing = True
                else:
                    names = _play_lesson_names(play_params)
                    if not await _ensure_lesson_mp3s(conn, names, resolved_task_id, params):
                        lesson_missing = True
                    else:
                        pending_play = {"kind": "repeater", "params": play_params}
            elif skill_name == "self.power_off":
                await _execute_named_tool(conn, "self_power_off", {})
    if source == "schedule":
        talk_phrase = _schedule_talk_start_phrase(resolved_task_id)
        if talk_phrase:
            conn.sentence_id = uuid.uuid4().hex
            _speak(conn, talk_phrase)
            speak_switch = False
            if resolved_task_id == "conversation":
                conn.chat_greeted = True
    announce = ""
    boot_chat = app_id == "chat" and source == "default_chat"
@@ -1348,17 +1614,48 @@
                _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:
        if lesson_missing:
            prefix = announce or "现在已经切换至泛听模式。"
            announce = _lesson_missing_speech(
                resolved_task_id,
                params,
                f"{prefix}这课还没有放到SD卡。",
            )
        if getattr(conn, "language_pair_fallback", False) and conn.interaction_mode in (
            "BILINGUAL",
            "TRANSLATION",
        ):
            announce = f"{announce or ''}这套语言先用中英来练。"
        if conn.interaction_mode == "SHADOWING" and not device_ready:
            play_params = _begin_shadowing_playlist(conn, params)
            names = _play_lesson_names(play_params)
            if not names:
                if await _prepare_shadowing_sd(conn, params):
                    play_params = _begin_shadowing_playlist(conn, params)
                    names = _play_lesson_names(play_params)
            if not names:
                prefix = announce or "现在已经切换至跟读模式。"
                announce = f"{prefix}跟读课文还没有放到SD卡。"
                announce = _lesson_missing_speech(
                    resolved_task_id,
                    params,
                    f"{prefix}跟读课文还没有放到SD卡。",
                )
            elif not await _ensure_lesson_mp3s(conn, names, resolved_task_id, params):
                prefix = announce or "现在已经切换至跟读模式。"
                announce = _lesson_missing_speech(
                    resolved_task_id,
                    params,
                    f"{prefix}跟读课文还没有放到SD卡。",
                )
            else:
                announce = announce or "现在已经切换至跟读模式,请跟着课文读。"
                announce = _lesson_start_phrase(resolved_task_id) or announce or "开始跟读任务"
                pending_play = {
                    "kind": "shadowing",
                    "params": _begin_shadowing_playlist(conn, params),
                    "params": play_params,
                }
        start_phrase = _lesson_start_phrase(resolved_task_id)
        if start_phrase and pending_play and not lesson_missing:
            announce = start_phrase
        if speak_switch and announce:
            conn.sentence_id = uuid.uuid4().hex
            conn.pending_play_after_tts = pending_play
@@ -1375,11 +1672,11 @@
            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):
    elif conn.interaction_mode == "SHADOWING" and not device_ready 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, "跟读课文还没有放到SD卡。")
            _speak(conn, _lesson_missing_speech(resolved_task_id, params, "跟读课文还没有放到SD卡。"))
    elif pending_play:
        conn.pending_play_after_tts = pending_play
        await maybe_flush_pending_play_after_tts(conn)
@@ -1634,7 +1931,10 @@
        )
        return True
    if mode == "PLAY_AUDIO" and name == "audio.completed":
    repeater_done = name == "audio.completed" and (
        mode == "PLAY_AUDIO" or task_id == "repeater"
    )
    if repeater_done:
        continued = await _continue_repeater_if_needed(conn)
        session = getattr(conn, "task_session", None)
        if session: