"""SD MP3 playback speed: clamp [0.5, 2.0], default 1.0. Gradients live on Server.""" from __future__ import annotations import json import os import re import time from typing import Any, Dict, List, Optional from config.config_loader import get_project_dir from config.logger import setup_logging from core.app_runtime.idiom_catalog import ( default_en_passes, extensive_paths, find_story, normalize_pass, sentence_count, shadowing_paths, ) from core.app_runtime.lesson_audio import list_lesson_clips TAG = __name__ logger = setup_logging() PLAY_SPEED_MIN = 0.5 PLAY_SPEED_MAX = 2.0 PLAY_SPEED_DEFAULT = 1.0 # 语音口令未带 lesson 时,不再使用测试清单;必须先有家长下发的课文。 TEMP_REPEATER_PLAYLIST = ( { "name": "/english_learning/repeater/lessons/l1/001/cn/01.mp3", "repeat": 1, "speed": 1.0, }, { "name": "/english_learning/repeater/lessons/l1/001/en/01.mp3", "repeat": 1, "speed": 0.8, }, { "name": "/english_learning/repeater/lessons/l1/001/en/01.mp3", "repeat": 2, "speed": 0.9, }, { "name": "/english_learning/repeater/lessons/l1/001/en/01.mp3", "repeat": 5, "speed": 1.0, }, ) TEMP_SHADOWING_PLAYLIST = tuple( { "name": f"/english_learning/shadowing/lessons/l1/001/en/{index:02d}.mp3", "repeat": 1, "speed": 0.8, } for index in range(1, 59) ) def expand_lesson_playlist( task_id: str, lesson_id: str, repeat: Any = None, speed: Any = None, language: Any = None, en_passes: Any = None, cn_repeat: Any = None, cn_speed: Any = None, sentence_count_override: Any = None, ) -> List[Dict[str, Any]]: """lesson_id + optional repeat/speed → compact playlist. Unknown lesson returns [].""" lesson = str(lesson_id or "").strip() tid = str(task_id or "").strip() if not lesson: return [] story = find_story(lesson) if tid == "repeater": items = _expand_repeater(lesson, story, en_passes, cn_repeat, cn_speed) elif tid == "shadowing": items = _expand_shadowing(lesson, story, speed, language, sentence_count_override) else: return [] return _apply_repeat_speed(items, repeat, speed if tid == "repeater" else None) def _lesson_lang_paths(kind: str, lesson: str, lang: str, catalog_paths: List[str]) -> List[str]: """Prefer MP3s that actually exist under the lesson folder. Catalog is only a fallback.""" listed = list_lesson_clips(None, None, kind, lesson, lang) if listed is not None: return listed return [path for path in catalog_paths if path] def _expand_repeater( lesson: str, story: Optional[Dict[str, Any]], en_passes: Any, cn_repeat: Any, cn_speed: Any, ) -> List[Dict[str, Any]]: if story is None: return [] catalog_cn, catalog_en = extensive_paths(story, lesson) cn_paths = _lesson_lang_paths("repeater", lesson, "cn", [catalog_cn]) en_paths = _lesson_lang_paths("repeater", lesson, "en", [catalog_en]) if not cn_paths or not en_paths: return [] try: cn_times = max(1, int(cn_repeat)) if cn_repeat is not None else 1 except (TypeError, ValueError): cn_times = 1 cn_rate = clamp_play_speed(cn_speed) if cn_speed is not None else 1.0 items: List[Dict[str, Any]] = [ {"name": path, "repeat": min(10, cn_times), "speed": cn_rate} for path in cn_paths ] passes = [] if isinstance(en_passes, list): for raw in en_passes: row = normalize_pass(raw) if row: passes.append(row) if not passes: passes = default_en_passes() for row in passes[:8]: for path in en_paths: items.append( {"name": path, "repeat": row["repeat"], "speed": clamp_play_speed(row["speed"])} ) return items def _expand_shadowing( lesson: str, story: Optional[Dict[str, Any]], speed: Any, language: Any, sentence_count_override: Any, ) -> List[Dict[str, Any]]: if story is None: return [] lang = str(language or "en").strip().lower() if lang not in ("cn", "en"): lang = "en" listed = list_lesson_clips(None, None, "shadowing", lesson, lang) if listed is not None: paths = listed else: count = None if sentence_count_override is not None: try: count = int(sentence_count_override) except (TypeError, ValueError): count = None if count is None: count = sentence_count(story) paths = shadowing_paths(story, lang, count, lesson) if not paths: return [] rate = clamp_play_speed(speed) if speed is not None else 0.8 return [ {"name": name, "repeat": 1, "speed": rate} for name in paths ] def _apply_repeat_speed(items: List[Dict[str, Any]], repeat: Any, speed: Any) -> List[Dict[str, Any]]: if repeat is not None: try: count = max(1, int(repeat)) except (TypeError, ValueError): count = 1 for item in items: item["repeat"] = count if speed is not None: value = clamp_play_speed(speed) for item in items: item["speed"] = value return items def playlist_from_params( task: Optional[Dict[str, Any]], params: Optional[Dict[str, Any]], task_id: str, default_factory=None, ) -> List[Dict[str, Any]]: """Explicit items win; lesson_id expands catalog. No last lesson → empty (not the test playlist).""" params = params or {} explicit = parse_playlist(params) if explicit: return explicit lesson_id = str(params.get("lesson_id") or params.get("lesson") or "").strip() if lesson_id: return expand_lesson_playlist( task_id, lesson_id, params.get("repeat"), params.get("speed"), language=params.get("language") or params.get("lang"), en_passes=params.get("en_passes"), cn_repeat=params.get("cn_repeat"), cn_speed=params.get("cn_speed"), sentence_count_override=params.get("sentence_count"), ) return [] def clamp_play_speed(raw: Any) -> float: try: value = float(raw) except (TypeError, ValueError): return PLAY_SPEED_DEFAULT if value != value: # NaN return PLAY_SPEED_DEFAULT if value < PLAY_SPEED_MIN: return PLAY_SPEED_MIN if value > PLAY_SPEED_MAX: return PLAY_SPEED_MAX return value def playback_config(task: Optional[Dict[str, Any]]) -> Dict[str, Any]: cfg = (task or {}).get("playback") return cfg if isinstance(cfg, dict) else {} def _progress_path(device_id: str) -> str: safe = re.sub(r"[^A-Za-z0-9_-]+", "_", device_id or "unknown") or "unknown" return os.path.join(get_project_dir(), "data", "playback_progress", f"{safe}.json") def _load_progress(device_id: str) -> Dict[str, Any]: path = _progress_path(device_id) if not os.path.isfile(path): return {} try: with open(path, "r", encoding="utf-8") as handle: data = json.load(handle) return data if isinstance(data, dict) else {} except Exception as exc: logger.bind(tag=TAG).warning(f"读取播放进度失败: {exc}") return {} def _save_progress(device_id: str, data: Dict[str, Any]) -> None: path = _progress_path(device_id) try: os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w", encoding="utf-8") as handle: json.dump(data, handle, ensure_ascii=False, indent=2) except Exception as exc: logger.bind(tag=TAG).warning(f"保存播放进度失败: {exc}") _LAST_LESSON_KEYS = ( "lesson_id", "lesson", "en_passes", "cn_repeat", "cn_speed", "language", "lang", "speed", "on_complete", "listen_ratio", "sentence_count", "org_id", "orgId", ) def _last_lesson_path(device_id: str) -> str: safe = re.sub(r"[^A-Za-z0-9_-]+", "_", device_id or "unknown") or "unknown" return os.path.join(get_project_dir(), "data", "last_lesson", f"{safe}.json") def _compact_last_lesson(params: Optional[Dict[str, Any]]) -> Dict[str, Any]: src = params or {} out: Dict[str, Any] = {} for key in _LAST_LESSON_KEYS: if key in src and src[key] not in (None, ""): out[key] = src[key] lesson = str(out.get("lesson_id") or out.get("lesson") or "").strip() if lesson: out["lesson_id"] = lesson return out def remember_last_lesson_params(device_id: str, task_id: str, params: Optional[Dict[str, Any]]) -> None: kind = str(task_id or "").strip() if kind not in ("repeater", "shadowing"): return compact = _compact_last_lesson(params) if not str(compact.get("lesson_id") or "").strip(): return path = _last_lesson_path(device_id) data: Dict[str, Any] = {} if os.path.isfile(path): try: with open(path, "r", encoding="utf-8") as handle: loaded = json.load(handle) if isinstance(loaded, dict): data = loaded except Exception: data = {} data[kind] = compact try: os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w", encoding="utf-8") as handle: json.dump(data, handle, ensure_ascii=False, indent=2) except Exception as exc: logger.bind(tag=TAG).warning(f"保存上次课文失败: {exc}") def load_last_lesson_params(device_id: str, task_id: str) -> Dict[str, Any]: kind = str(task_id or "").strip() path = _last_lesson_path(device_id) if kind not in ("repeater", "shadowing") or not os.path.isfile(path): return {} try: with open(path, "r", encoding="utf-8") as handle: data = json.load(handle) except Exception: return {} row = data.get(kind) if isinstance(data, dict) else None if not isinstance(row, dict): return {} return _compact_last_lesson(row) def _lesson_key(app_id: str, task_id: str) -> str: return f"{app_id or ''}/{task_id or ''}" def lesson_day_index( device_id: str, app_id: str, task_id: str, lesson: str = "", ) -> int: """0-based count of distinct calendar days this lesson has already been played.""" data = _load_progress(device_id) bucket = data.get(_lesson_key(app_id, task_id)) or {} lessons = bucket.get("lessons") if isinstance(bucket, dict) else {} key = lesson or "_default" days = lessons.get(key) if isinstance(lessons, dict) else None if not isinstance(days, list): return 0 return len([d for d in days if d]) def mark_lesson_played_today( device_id: str, app_id: str, task_id: str, lesson: str = "", ) -> None: today = time.strftime("%Y-%m-%d") data = _load_progress(device_id) key = _lesson_key(app_id, task_id) bucket = data.get(key) if not isinstance(bucket, dict): bucket = {} lessons = bucket.get("lessons") if not isinstance(lessons, dict): lessons = {} lesson_key = lesson or "_default" days = lessons.get(lesson_key) if not isinstance(days, list): days = [] if today not in days: days.append(today) lessons[lesson_key] = days bucket["lessons"] = lessons data[key] = bucket _save_progress(device_id, data) def daily_base_speed(task: Optional[Dict[str, Any]], day_index: int = 0) -> float: cfg = playback_config(task) daily = cfg.get("daily_speeds") if isinstance(daily, list) and daily: idx = max(0, int(day_index or 0)) if idx >= len(daily): idx = len(daily) - 1 return clamp_play_speed(daily[idx]) if cfg.get("speed") is not None: return clamp_play_speed(cfg.get("speed")) return PLAY_SPEED_DEFAULT def speeds_for_repeats( task: Optional[Dict[str, Any]], repeat_n: int = 1, day_index: int = 0, ) -> List[float]: """One speed per pass. repeat=1 uses daily curve; repeat>1 samples repeat_speeds if set.""" n = 1 try: n = int(repeat_n or 1) except (TypeError, ValueError): n = 1 if n < 1: n = 1 base = daily_base_speed(task, day_index) curve = playback_config(task).get("repeat_speeds") if n == 1 or not isinstance(curve, list) or not curve: return [base] * n values = [clamp_play_speed(item) for item in curve] if n == len(values): return values if n == 1: return [values[0]] out: List[float] = [] last = len(values) - 1 for i in range(n): pos = (i / (n - 1)) * last lo = int(pos) hi = min(lo + 1, last) frac = pos - lo out.append(clamp_play_speed(values[lo] * (1.0 - frac) + values[hi] * frac)) return out def resolve_play_speed( task: Optional[Dict[str, Any]], day_index: int = 0, explicit: Any = None, ) -> float: if explicit is not None: return clamp_play_speed(explicit) return daily_base_speed(task, day_index) def normalize_media_name(raw: Any) -> str: """SD files[].name, MQTT path, or HTTP url → /app/task/lessons/... form.""" name = str(raw or "").strip().replace("\\", "/") if not name: return "" for marker in ("/english_learning/", "english_learning/"): idx = name.find(marker) if idx >= 0: rest = name[idx:] return rest if rest.startswith("/") else "/" + rest if "://" in name: path = name.split("://", 1)[1] _, _, rest = path.partition("/") if rest: return "/" + rest.lstrip("/") return "" if not name.startswith("/"): name = "/" + name.lstrip("/") return name def _item_repeat(raw: Dict[str, Any]) -> int: for key in ("repeat", "count", "play_count"): if raw.get(key) is None: continue try: value = int(raw.get(key)) except (TypeError, ValueError): continue return max(1, value) return 1 def playlist_item_from_value(raw: Any) -> Optional[Dict[str, Any]]: if isinstance(raw, str): name = normalize_media_name(raw) if not name: return None return {"name": name, "repeat": 1} if not isinstance(raw, dict): return None name = normalize_media_name( raw.get("name") or raw.get("path") or raw.get("target_path") or raw.get("url") or "" ) if not name: return None item: Dict[str, Any] = {"name": name, "repeat": _item_repeat(raw)} if raw.get("speed") is not None: item["speed"] = clamp_play_speed(raw.get("speed")) if raw.get("power_off_after") is not None: item["power_off_after"] = bool(raw.get("power_off_after")) return item PLAYLIST_ITEM_MAX = 256 def parse_playlist(params: Optional[Dict[str, Any]]) -> List[Dict[str, Any]]: """Repeater/shadowing playlist. Each item is {name, repeat, optional speed}.""" params = params or {} raw_items = params.get("items") or params.get("playlist") or params.get("clips") if isinstance(raw_items, list) and raw_items: items = [] for raw in raw_items: item = playlist_item_from_value(raw) if item: items.append(item) if len(items) > PLAYLIST_ITEM_MAX: logger.bind(tag=TAG).warning( f"playlist truncated incoming={len(items)} max={PLAYLIST_ITEM_MAX}" ) items = items[:PLAYLIST_ITEM_MAX] return items item = playlist_item_from_value( { "name": params.get("name") or params.get("path") or params.get("url") or "", "repeat": params.get("repeat") or params.get("count") or 1, "speed": params.get("speed"), "power_off_after": params.get("power_off_after"), } ) if item and item["name"]: return [item] return [] def expand_playlist_plays(items: Optional[List[Dict[str, Any]]]) -> List[str]: """Flatten {name, repeat} into one path per play. Shadowing uses this so each pass has its own listen window.""" clips: List[str] = [] for item in items or []: if not isinstance(item, dict): continue name = str(item.get("name") or "") if not name: continue clips.extend([name] * _item_repeat(item)) return clips def default_repeater_playlist(task: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]: """Only used when a caller still has an explicit default_playlist on the task.""" raw = (task or {}).get("default_playlist") if isinstance(raw, list) and raw: items = parse_playlist({"items": raw}) if items: return items return [] def default_shadowing_playlist(task: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]: """Only used when a caller still has an explicit default_playlist on the task.""" raw = (task or {}).get("default_playlist") if isinstance(raw, list) and raw: items = parse_playlist({"items": raw}) if items: return items return []