"""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 TAG = __name__ logger = setup_logging() PLAY_SPEED_MIN = 0.5 PLAY_SPEED_MAX = 2.0 PLAY_SPEED_DEFAULT = 1.0 # 小程序/定时任务下发清单之前的临时课文。路径相对 SD 卡根(/english_learning/...)。 TEMP_REPEATER_PLAYLIST = ( { "name": "/english_learning/shadowing/lessons/unit01/01.mp3", "repeat": 10, "speed": PLAY_SPEED_DEFAULT, }, { "name": "/english_learning/shadowing/lessons/unit01/02.mp3", "repeat": 5, "speed": PLAY_SPEED_DEFAULT, }, ) TEMP_SHADOWING_PLAYLIST = ( { "name": "/english_learning/shadowing/lessons/unit01/01.mp3", "repeat": 5, "speed": PLAY_SPEED_DEFAULT, }, { "name": "/english_learning/shadowing/lessons/unit01/02.mp3", "repeat": 10, "speed": PLAY_SPEED_DEFAULT, }, ) 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}") 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 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) 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]]: """No downlink yet: task.default_playlist, else the unit01 01×10 / 02×5 stub.""" raw = (task or {}).get("default_playlist") if isinstance(raw, list) and raw: items = parse_playlist({"items": raw}) if items: return items return [dict(item) for item in TEMP_REPEATER_PLAYLIST] def default_shadowing_playlist(task: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]: """No downlink yet: task.default_playlist, else the unit01 01×5 / 02×10 stub.""" raw = (task or {}).get("default_playlist") if isinstance(raw, list) and raw: items = parse_playlist({"items": raw}) if items: return items return [dict(item) for item in TEMP_SHADOWING_PLAYLIST]