"""Tone helpers for sloppak playback. A feedBack arrangement may carry a tone block — the initial tone name plus in-song tone switches — embedded inline in the arrangement JSON (see ``lib/song.py`` ``arrangement_to_wire`` / the ``tones`` wire key). This module turns that already-embedded block into the (base, changes) payload the highway WebSocket sends to the client. The proprietary-archive tone-extraction path (lifting tone definitions out of an unpacked encrypted archive) has been removed. FeedBack reads tones only from its own ``.sloppak`` / arrangement JSON; it never reads or decrypts proprietary archive formats. """ from __future__ import annotations import logging import math import re log = logging.getLogger("feedBack.lib.tones") def tokens(s: str) -> set[str]: """Split a name or file stem into lowercased alphanumeric tokens. Used for fuzzy arrangement↔XML matching: arrangement names carry spaces ("Bonus Lead") while file stems are underscored ("song_bonus_lead"), and a plain substring check is ambiguous ("lead" is a substring of "bonuslead"). Shared with the playback path in `server.py` so the two stay consistent. """ return {t for t in re.split(r"[^a-z0-9]+", (s or "").lower()) if t} def sloppak_tone_changes(arr_tones) -> tuple[str, list[dict]]: """Build the highway tone-change payload from an arrangement's tone block. Given ``Arrangement.tones`` (the dict embedded in the sloppak, or ``None``), returns ``(base, changes)`` where ``base`` is the initial tone name and ``changes`` is a time-sorted ``[{"t", "name"}]`` list. Non-string names, non-dict entries, and non-numeric / non-finite times are skipped — a hand-edited or third-party sloppak must not crash the highway WebSocket or emit NaN/inf (which the client's ``JSON.parse`` rejects). """ if not isinstance(arr_tones, dict): return "", [] base_val = arr_tones.get("base", "") base = base_val.strip() if isinstance(base_val, str) else "" changes: list[dict] = [] raw_changes = arr_tones.get("changes") if not isinstance(raw_changes, list): # A truthy non-list (e.g. `1`) would raise TypeError on iteration. raw_changes = [] for c in raw_changes: if not isinstance(c, dict): continue t = c.get("t") name = c.get("name") if t is None or not isinstance(name, str) or not name: continue try: t = float(t) except (TypeError, ValueError): continue if not math.isfinite(t): continue changes.append({"t": round(t, 3), "name": name}) changes.sort(key=lambda x: x["t"]) return base, changes