mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-12 14:48:31 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61d8122052 | ||
|
|
e57ad16caa | ||
|
|
9075939668 | ||
|
|
435e273927 |
+14
-5
@@ -8,6 +8,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- **Core reader for source rigs (feedpak 1.18.0).** A pack can declare what a
|
||||||
|
MIDI part should sound like by binding a rig; core now reads that binding and
|
||||||
|
hands it to the client instead of dropping it. Three parts: the
|
||||||
|
`tone_changes` WS message carries the pack's rig bindings (`base_rig`, and
|
||||||
|
`rig` per change) alongside the tone names it already sent; the manifest
|
||||||
|
`rigs:` key loads the pack's rig library (`rigs.json`, spec §7.9) verbatim;
|
||||||
|
and the binding precedence is resolved per spec §5.1/§5.2 — a manifest
|
||||||
|
arrangement entry's `tones` replaces the arrangement JSON's **wholesale**
|
||||||
|
(no field-level merge), while top-level `drum_tones` binds the primary drum
|
||||||
|
part as the fallback a `type: drums` entry's own `tones` outranks. Core
|
||||||
|
deliberately stops there: it does not select a realization or apply the
|
||||||
|
`intent.gm` floor, which belong to whatever actually voices the part. Packs
|
||||||
|
that bind no rig produce a byte-identical `tone_changes` payload, so existing
|
||||||
|
consumers are unaffected.
|
||||||
- **Opt-in career venue packs (#122)** — higher-tier venue crowd media
|
- **Opt-in career venue packs (#122)** — higher-tier venue crowd media
|
||||||
(`club`, `arena`) is no longer bundled; the app downloads each pack on demand
|
(`club`, `arena`) is no longer bundled; the app downloads each pack on demand
|
||||||
from its release when you reach the venue (sha256-verified), keeping the
|
from its release when you reach the venue (sha256-verified), keeping the
|
||||||
@@ -183,11 +197,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
spec shape (moves `original/full.ogg` → `stems/full.ogg`, adds the `full` stem
|
spec shape (moves `original/full.ogg` → `stems/full.ogg`, adds the `full` stem
|
||||||
at `default: off`, drops the key); the fallback and the aliases are removed once
|
at `default: off`, drops the key); the fallback and the aliases are removed once
|
||||||
they are migrated (#945).
|
they are migrated (#945).
|
||||||
- **Folder Library previews on hover, like the grid and list views.** The Folders
|
|
||||||
view's cards and rows now carry the standard `data-fn` / `data-v3-play` markup,
|
|
||||||
so the existing **Song Preview** plugin previews them on hover exactly like the
|
|
||||||
other views (same audio, same behaviour) — Folder Library ships no preview code
|
|
||||||
of its own.
|
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- **Genres fall back to MusicBrainz enrichment** — the effective genre now
|
- **Genres fall back to MusicBrainz enrichment** — the effective genre now
|
||||||
|
|||||||
@@ -690,7 +690,7 @@ The highway WebSocket at `/ws/highway/{filename}?arrangement={index}` streams th
|
|||||||
| `anchors` | `{ type, data: [{ time, fret, width }] }` | Fret zoom anchors |
|
| `anchors` | `{ type, data: [{ time, fret, width }] }` | Fret zoom anchors |
|
||||||
| `chord_templates` | `{ type, data: [{ name, frets: [6] }] }` | Named chord shapes |
|
| `chord_templates` | `{ type, data: [{ name, frets: [6] }] }` | Named chord shapes |
|
||||||
| `lyrics` | `{ type, data: [{ w, t, d }], source }` | Syllables: `w`=word, `t`=time, `d`=duration. `-` joins to previous, `+` = line break. `source` is one of `"xml"`, `"whisperx"`, `"user"` — UI can use it to render an "auto-transcribed" badge for `whisperx`. Sloppaks always include `source` (legacy sloppaks without a `lyrics_source` manifest key default to `"xml"` at load time). Loose folders set it based on which extractor matched. Absent only when no lyrics fired the message at all |
|
| `lyrics` | `{ type, data: [{ w, t, d }], source }` | Syllables: `w`=word, `t`=time, `d`=duration. `-` joins to previous, `+` = line break. `source` is one of `"xml"`, `"whisperx"`, `"user"` — UI can use it to render an "auto-transcribed" badge for `whisperx`. Sloppaks always include `source` (legacy sloppaks without a `lyrics_source` manifest key default to `"xml"` at load time). Loose folders set it based on which extractor matched. Absent only when no lyrics fired the message at all |
|
||||||
| `tone_changes` | `{ type: 'tone_changes', base, data: [{ time, name }] }` | Optional — tone change events relative to the arrangement base tone; only sent if tones were found |
|
| `tone_changes` | `{ type: 'tone_changes', base, base_rig?, data: [{ t, name, rig? }] }` | Optional — tone change events relative to the arrangement base tone; only sent if tones were found. Note the time key is **`t`**, not `time` (both the sloppak path and the legacy XML path emit `t`). `base_rig` and each entry's `rig` are the pack's **rig bindings** — ids into [`rigs.json`](https://github.com/got-feedback/feedpak-spec/blob/main/spec/feedpak-v1.md#79-rigsjson) (feedpak §6.9/§7.9), carried through verbatim and **not** resolved by core: selecting a realization and applying the `intent.gm` floor belong to whatever voices the part. Both are **omitted entirely** when the chart binds no rig, so consumers predating the rig model see the payload they always did. |
|
||||||
| `notes` | `{ type, data: [{ t, s, f, sus, ho, po, sl, bn, ... }] }` | Single notes |
|
| `notes` | `{ type, data: [{ t, s, f, sus, ho, po, sl, bn, ... }] }` | Single notes |
|
||||||
| `chords` | `{ type, data: [{ t, notes: [{ s, f, sus, ... }] }] }` | Chord events |
|
| `chords` | `{ type, data: [{ t, notes: [{ s, f, sus, ... }] }] }` | Chord events |
|
||||||
| `phrases` | `{ type, data: [{ start_time, end_time, max_difficulty, levels: [{ difficulty, notes, chords, anchors, handshapes }] }], total }` | Optional — per-phrase difficulty ladder for master-difficulty slider (feedBack#48). Only sent when the source chart carries multi-level phrase data (phrase-aware sloppak). Sent in chunks (`data` is a batch, `total` is the full count across messages) to avoid multi-MB single frames. Absent for GP imports and legacy sloppak; consumers must treat missing message as "single fixed difficulty — slider disabled". |
|
| `phrases` | `{ type, data: [{ start_time, end_time, max_difficulty, levels: [{ difficulty, notes, chords, anchors, handshapes }] }], total }` | Optional — per-phrase difficulty ladder for master-difficulty slider (feedBack#48). Only sent when the source chart carries multi-level phrase data (phrase-aware sloppak). Sent in chunks (`data` is a batch, `total` is the full count across messages) to avoid multi-MB single frames. Absent for GP imports and legacy sloppak; consumers must treat missing message as "single fixed difficulty — slider disabled". |
|
||||||
|
|||||||
@@ -774,20 +774,29 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
|
|||||||
# (Arrangement.tones, populated by the converter), so read it straight
|
# (Arrangement.tones, populated by the converter), so read it straight
|
||||||
# off `arr` rather than walking for XML that doesn't exist.
|
# off `arr` rather than walking for XML that doesn't exist.
|
||||||
if is_slop:
|
if is_slop:
|
||||||
# `sloppak_tone_changes` builds the (base, sorted changes) pair
|
# `sloppak_tone_changes` builds the (base, base_rig, sorted
|
||||||
# from `Arrangement.tones`, skipping non-string names and
|
# changes) triple from `Arrangement.tones`, skipping non-string
|
||||||
# non-finite/non-numeric times — unit-tested in test_tones.py.
|
# names, non-finite/non-numeric times, and unusable rig ids —
|
||||||
|
# unit-tested in test_tones.py.
|
||||||
from tones import sloppak_tone_changes
|
from tones import sloppak_tone_changes
|
||||||
base_name, tone_changes = sloppak_tone_changes(getattr(arr, "tones", None))
|
base_name, base_rig, tone_changes = sloppak_tone_changes(
|
||||||
|
getattr(arr, "tones", None)
|
||||||
|
)
|
||||||
# Send when there's a base tone OR timed changes — a single-tone
|
# Send when there's a base tone OR timed changes — a single-tone
|
||||||
# arrangement has a base but no switches, and the highway should
|
# arrangement has a base but no switches, and the highway should
|
||||||
# still be able to show the initial tone.
|
# still be able to show the initial tone.
|
||||||
if tone_changes or base_name:
|
if tone_changes or base_name:
|
||||||
await websocket.send_json({
|
payload = {
|
||||||
"type": "tone_changes",
|
"type": "tone_changes",
|
||||||
"base": base_name,
|
"base": base_name,
|
||||||
"data": tone_changes,
|
"data": tone_changes,
|
||||||
})
|
}
|
||||||
|
# `base_rig` is additive (feedpak-spec §6.9) — omitted entirely
|
||||||
|
# when the chart binds no rig, so consumers that predate the rig
|
||||||
|
# model see the exact payload they always did.
|
||||||
|
if base_rig:
|
||||||
|
payload["base_rig"] = base_rig
|
||||||
|
await websocket.send_json(payload)
|
||||||
else:
|
else:
|
||||||
xml_paths = sorted(_xml_walk("*.xml"))
|
xml_paths = sorted(_xml_walk("*.xml"))
|
||||||
|
|
||||||
|
|||||||
+148
-2
@@ -698,6 +698,14 @@ class LoadedSloppak:
|
|||||||
# absent / unreadable / malformed. Streamed over the highway WS as a
|
# absent / unreadable / malformed. Streamed over the highway WS as a
|
||||||
# `keys` message; consumers (renderers, plugins) read it from there.
|
# `keys` message; consumers (renderers, plugins) read it from there.
|
||||||
keys: dict | None = None
|
keys: dict | None = None
|
||||||
|
# Parsed `rigs.json` payload (manifest `rigs:` key, spec §7.9) — the pack's
|
||||||
|
# library of engine-agnostic signal chains: effect chains and, since
|
||||||
|
# feedpak 1.18.0, MIDI-voiced sound sources. Arrangements bind rigs to time
|
||||||
|
# by referencing a rig `id` from `tones.base_rig` / `tones.changes[].rig`
|
||||||
|
# (§6.9), which `lib/tones.py` carries onto the wire. None when absent /
|
||||||
|
# unreadable / malformed. Rig objects are kept verbatim — this loader does
|
||||||
|
# not select realizations or apply the `intent.gm` floor.
|
||||||
|
rigs: dict | None = None
|
||||||
# Sanitized song-level tempo + time-signature maps from `song_timeline.json`
|
# Sanitized song-level tempo + time-signature maps from `song_timeline.json`
|
||||||
# (feedpak 1.2.0). `tempos`: [{time, bpm}]; `time_signatures`: [{time, ts}].
|
# (feedpak 1.2.0). `tempos`: [{time, bpm}]; `time_signatures`: [{time, ts}].
|
||||||
# None when absent/empty. Streamed over the highway WS (`tempos` /
|
# None when absent/empty. Streamed over the highway WS (`tempos` /
|
||||||
@@ -776,18 +784,117 @@ def _load_drum_tab_file(source_dir: Path, rel: str, label: str) -> dict | None:
|
|||||||
return raw
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def _load_rigs_file(source_dir: Path, rel: str) -> dict | None:
|
||||||
|
"""Load the pack's rig library (manifest `rigs:` key, spec §7.9).
|
||||||
|
|
||||||
|
Returns `{"version": int, "rigs": [...]}` or None. Same permissive posture
|
||||||
|
as every other side-file: missing / unreadable / malformed -> None, never
|
||||||
|
fatal — spec §7.9 is explicit that a rig library a Reader can't use MUST NOT
|
||||||
|
fail the pack.
|
||||||
|
|
||||||
|
Rig objects are kept **verbatim**. Only entries that could never be
|
||||||
|
addressed are dropped — a rig is reachable solely by `id` (from
|
||||||
|
`tones.base_rig` / `changes[].rig`), so a non-dict entry or one without a
|
||||||
|
usable string id is unreferenceable by construction. Everything else,
|
||||||
|
including unknown `role` / `engine` / `kind` values and `ext` namespaces,
|
||||||
|
passes through untouched, because this loader does not interpret rigs:
|
||||||
|
realization selection and the `intent.gm` fallback belong to whatever
|
||||||
|
voices the part.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
r_path = (source_dir / rel).resolve()
|
||||||
|
r_path.relative_to(source_dir.resolve())
|
||||||
|
except ValueError:
|
||||||
|
log.warning("sloppak: rigs path %r escapes source_dir — skipped", rel)
|
||||||
|
return None
|
||||||
|
except OSError as e:
|
||||||
|
log.warning("sloppak: rigs path resolution failed (%s) — skipped", e)
|
||||||
|
return None
|
||||||
|
if not r_path.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
raw = load_json(r_path)
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("sloppak: failed to parse rigs %r: %s", rel, e)
|
||||||
|
return None
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
log.warning("sloppak: rigs %r ignored — expected dict, got %s",
|
||||||
|
rel, type(raw).__name__)
|
||||||
|
return None
|
||||||
|
if not isinstance(raw.get("rigs"), list):
|
||||||
|
log.warning("sloppak: rigs %r ignored — 'rigs' must be a list", rel)
|
||||||
|
return None
|
||||||
|
|
||||||
|
clean_rigs: list[dict] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for rig in raw["rigs"]:
|
||||||
|
if not isinstance(rig, dict):
|
||||||
|
continue
|
||||||
|
rid = rig.get("id")
|
||||||
|
if not isinstance(rid, str) or not rid.strip():
|
||||||
|
continue
|
||||||
|
# Normalize the library side of the lookup the same way the reference
|
||||||
|
# side is normalized in lib/tones.py — otherwise a pack with padded ids
|
||||||
|
# fails to resolve against a stripped `base_rig` / `rig`.
|
||||||
|
rid = rid.strip()
|
||||||
|
# A duplicate id makes `tones.base_rig` ambiguous, which would surface
|
||||||
|
# as the wrong sound rather than an error. First wins, loudly.
|
||||||
|
if rid in seen:
|
||||||
|
log.warning("sloppak: rigs %r has duplicate rig id %r — later one ignored",
|
||||||
|
rel, rid)
|
||||||
|
continue
|
||||||
|
seen.add(rid)
|
||||||
|
clean_rigs.append({**rig, "id": rid})
|
||||||
|
|
||||||
|
# int only — a float version (incl. NaN/Inf, which json.loads accepts)
|
||||||
|
# would raise on int(); default rather than abort an optional side-file.
|
||||||
|
_ver = raw.get("version")
|
||||||
|
return {
|
||||||
|
"version": _ver if isinstance(_ver, int) and not isinstance(_ver, bool) else 1,
|
||||||
|
"rigs": clean_rigs,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_tones(entry: dict) -> dict | None:
|
||||||
|
"""A manifest entry's `tones` binding, or None when it doesn't carry one.
|
||||||
|
|
||||||
|
Spec §5.2: a manifest arrangement entry's `tones` overrides the arrangement
|
||||||
|
JSON's `tones` **wholesale** — no field-level merge. This normalizes the
|
||||||
|
"does it carry one" test for both the arrangement path and the drum path.
|
||||||
|
|
||||||
|
An empty dict reads as *absent*, not as "override to silence": it is what a
|
||||||
|
Writer emits by accident, `arrangement_from_wire` already normalizes the
|
||||||
|
in-JSON `{}` to None the same way, and treating it as an override would let
|
||||||
|
a stray empty object silently unbind a part's sound.
|
||||||
|
"""
|
||||||
|
tones = entry.get("tones")
|
||||||
|
return tones if isinstance(tones, dict) and tones else None
|
||||||
|
|
||||||
|
|
||||||
def _resolve_drum_parts(
|
def _resolve_drum_parts(
|
||||||
source_dir: Path,
|
source_dir: Path,
|
||||||
drum_tab_rel: object,
|
drum_tab_rel: object,
|
||||||
drum_tab_data: dict | None,
|
drum_tab_data: dict | None,
|
||||||
drum_pointer_entries: list[dict],
|
drum_pointer_entries: list[dict],
|
||||||
|
drum_tones: dict | None = None,
|
||||||
) -> tuple[dict | None, list[dict] | None]:
|
) -> tuple[dict | None, list[dict] | None]:
|
||||||
"""Resolve drum pointers into a primary-first list with unique ids."""
|
"""Resolve drum pointers into a primary-first list with unique ids.
|
||||||
|
|
||||||
|
Also binds each part's sound (feedpak 1.18.0). The precedence mirrors the
|
||||||
|
`drum_tab` alias rule this function already implements: a `type: drums`
|
||||||
|
entry's own `tones` wins for that part, and the song-level `drum_tones` is
|
||||||
|
the fallback for the **primary** part only. A Reader MUST NOT apply both to
|
||||||
|
the same part (spec §5.1/§5.2), which is why the primary picks one or the
|
||||||
|
other here rather than merging them.
|
||||||
|
"""
|
||||||
if drum_tab_data is None and not drum_pointer_entries:
|
if drum_tab_data is None and not drum_pointer_entries:
|
||||||
return drum_tab_data, None
|
return drum_tab_data, None
|
||||||
|
|
||||||
primary_id = "drums"
|
primary_id = "drums"
|
||||||
primary_name = None
|
primary_name = None
|
||||||
|
# The primary's own binding, lifted from its alias pointer entry when it has
|
||||||
|
# one. Stays None if no entry claims the primary — `drum_tones` fills in.
|
||||||
|
primary_tones = None
|
||||||
extra_parts: list[dict] = []
|
extra_parts: list[dict] = []
|
||||||
seen_rels: set[str] = set()
|
seen_rels: set[str] = set()
|
||||||
# Use the same canonical, traversal-safe identity as zip member lookup so
|
# Use the same canonical, traversal-safe identity as zip member lookup so
|
||||||
@@ -811,6 +918,11 @@ def _resolve_drum_parts(
|
|||||||
primary_id = entry_id
|
primary_id = entry_id
|
||||||
if entry_name:
|
if entry_name:
|
||||||
primary_name = entry_name
|
primary_name = entry_name
|
||||||
|
# This entry IS the primary (an alias pointer at the same file), so
|
||||||
|
# its binding is the primary's — and it outranks `drum_tones`.
|
||||||
|
_alias_tones = _entry_tones(entry)
|
||||||
|
if _alias_tones is not None:
|
||||||
|
primary_tones = _alias_tones
|
||||||
continue
|
continue
|
||||||
tab = _load_drum_tab_file(source_dir, rel, f"drum part {entry_id or rel}")
|
tab = _load_drum_tab_file(source_dir, rel, f"drum part {entry_id or rel}")
|
||||||
if tab is None:
|
if tab is None:
|
||||||
@@ -821,6 +933,9 @@ def _resolve_drum_parts(
|
|||||||
"name": entry_name
|
"name": entry_name
|
||||||
or (tab_name if isinstance(tab_name, str) and tab_name else "Drums"),
|
or (tab_name if isinstance(tab_name, str) and tab_name else "Drums"),
|
||||||
"drum_tab": tab,
|
"drum_tab": tab,
|
||||||
|
# Non-primary parts bind through their own entry only; `drum_tones`
|
||||||
|
# is explicitly the primary's fallback, never theirs.
|
||||||
|
"tones": _entry_tones(entry),
|
||||||
})
|
})
|
||||||
|
|
||||||
parts: list[dict] = []
|
parts: list[dict] = []
|
||||||
@@ -829,7 +944,14 @@ def _resolve_drum_parts(
|
|||||||
if primary_name is None:
|
if primary_name is None:
|
||||||
tab_name = drum_tab_data.get("name")
|
tab_name = drum_tab_data.get("name")
|
||||||
primary_name = tab_name if isinstance(tab_name, str) and tab_name else "Drums"
|
primary_name = tab_name if isinstance(tab_name, str) and tab_name else "Drums"
|
||||||
parts.append({"id": primary_id, "name": primary_name, "drum_tab": drum_tab_data})
|
parts.append({
|
||||||
|
"id": primary_id,
|
||||||
|
"name": primary_name,
|
||||||
|
"drum_tab": drum_tab_data,
|
||||||
|
# Entry `tones` takes precedence; `drum_tones` is the fallback. One
|
||||||
|
# or the other, never both on the same part (spec §5.1).
|
||||||
|
"tones": primary_tones if primary_tones is not None else drum_tones,
|
||||||
|
})
|
||||||
used_ids.add(primary_id)
|
used_ids.add(primary_id)
|
||||||
|
|
||||||
next_generated_id = 2
|
next_generated_id = 2
|
||||||
@@ -948,6 +1070,14 @@ def load_song(
|
|||||||
# _finite_float keeps a malformed manifest NaN/Infinity from
|
# _finite_float keeps a malformed manifest NaN/Infinity from
|
||||||
# poisoning the song_info JSON (same guard as the wire path).
|
# poisoning the song_info JSON (same guard as the wire path).
|
||||||
arr.cent_offset = _finite_float(entry["centOffset"])
|
arr.cent_offset = _finite_float(entry["centOffset"])
|
||||||
|
# `tones` overrides WHOLESALE, unlike the field-level overrides above:
|
||||||
|
# the entry's object replaces the arrangement JSON's entirely, with no
|
||||||
|
# per-field merge (spec §5.2). A Writer SHOULD NOT emit both, but when
|
||||||
|
# one does, a half-merged sound — this pack's base with that pack's
|
||||||
|
# changes — would be worse than either source alone.
|
||||||
|
_entry_tone_block = _entry_tones(entry)
|
||||||
|
if _entry_tone_block is not None:
|
||||||
|
arr.tones = _entry_tone_block
|
||||||
|
|
||||||
# Beats/sections can live on the arrangement itself in the wire format.
|
# Beats/sections can live on the arrangement itself in the wire format.
|
||||||
# If the manifest-level arrangement JSON carries them, pull them onto
|
# If the manifest-level arrangement JSON carries them, pull them onto
|
||||||
@@ -1020,8 +1150,15 @@ def load_song(
|
|||||||
|
|
||||||
# Keep the dense compatibility logic independently testable and guarantee
|
# Keep the dense compatibility logic independently testable and guarantee
|
||||||
# ids are unique before the highway exposes them as selectors.
|
# ids are unique before the highway exposes them as selectors.
|
||||||
|
# Top-level `drum_tones` (spec §5.1) binds the song-level drum part — the
|
||||||
|
# fallback for packs without `type: drums` arrangements. Same shape as an
|
||||||
|
# arrangement entry's `tones`; `_resolve_drum_parts` owns the precedence.
|
||||||
|
_raw_drum_tones = manifest.get("drum_tones")
|
||||||
|
drum_tones_data = _raw_drum_tones if isinstance(_raw_drum_tones, dict) and _raw_drum_tones else None
|
||||||
|
|
||||||
drum_tab_data, drum_parts = _resolve_drum_parts(
|
drum_tab_data, drum_parts = _resolve_drum_parts(
|
||||||
source_dir, drum_tab_rel, drum_tab_data, drum_pointer_entries,
|
source_dir, drum_tab_rel, drum_tab_data, drum_pointer_entries,
|
||||||
|
drum_tones_data,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Drum-only sloppak: every GP track was percussion, so it ships a
|
# Drum-only sloppak: every GP track was percussion, so it ships a
|
||||||
@@ -1325,6 +1462,14 @@ def load_song(
|
|||||||
"events": clean_events,
|
"events": clean_events,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Optional rigs.json — the pack's rig library (manifest `rigs:` key,
|
||||||
|
# spec §7.9). Loaded here so the highway WS can hand it to whatever voices
|
||||||
|
# the part; the bindings that reference it ride the arrangement's `tones`.
|
||||||
|
rigs_data: dict | None = None
|
||||||
|
rigs_rel = manifest.get("rigs")
|
||||||
|
if isinstance(rigs_rel, str) and rigs_rel:
|
||||||
|
rigs_data = _load_rigs_file(source_dir, rigs_rel)
|
||||||
|
|
||||||
_fpv = manifest.get("feedpak_version")
|
_fpv = manifest.get("feedpak_version")
|
||||||
# The pack's full mix. Normally the RESERVED `full` stem partitioned out
|
# The pack's full mix. Normally the RESERVED `full` stem partitioned out
|
||||||
# above (spec §5.3) — no path work needed, it was validated with the other
|
# above (spec §5.3) — no path work needed, it was validated with the other
|
||||||
@@ -1355,6 +1500,7 @@ def load_song(
|
|||||||
tempos=tempos_data,
|
tempos=tempos_data,
|
||||||
time_signatures=time_sigs_data,
|
time_signatures=time_sigs_data,
|
||||||
keys=keys_data,
|
keys=keys_data,
|
||||||
|
rigs=rigs_data,
|
||||||
notation_by_id=notation_by_id_data,
|
notation_by_id=notation_by_id_data,
|
||||||
arrangement_ids=arrangement_ids_acc,
|
arrangement_ids=arrangement_ids_acc,
|
||||||
full_mix=full_mix_data,
|
full_mix=full_mix_data,
|
||||||
|
|||||||
+25
-9
@@ -32,20 +32,29 @@ def tokens(s: str) -> set[str]:
|
|||||||
return {t for t in re.split(r"[^a-z0-9]+", (s or "").lower()) if t}
|
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]]:
|
def sloppak_tone_changes(arr_tones) -> tuple[str, str, list[dict]]:
|
||||||
"""Build the highway tone-change payload from an arrangement's tone block.
|
"""Build the highway tone-change payload from an arrangement's tone block.
|
||||||
|
|
||||||
Given ``Arrangement.tones`` (the dict embedded in the sloppak, or ``None``),
|
Given ``Arrangement.tones`` (the dict embedded in the sloppak, or ``None``),
|
||||||
returns ``(base, changes)`` where ``base`` is the initial tone name and
|
returns ``(base, base_rig, changes)`` where ``base`` is the initial tone
|
||||||
``changes`` is a time-sorted ``[{"t", "name"}]`` list. Non-string names,
|
name, ``base_rig`` is the ``rigs.json`` rig id bound to it (feedpak-spec
|
||||||
non-dict entries, and non-numeric / non-finite times are skipped — a
|
§6.9; ``""`` when absent), and ``changes`` is a time-sorted
|
||||||
hand-edited or third-party sloppak must not crash the highway WebSocket
|
``[{"t", "name", "rig"?}]`` list. Non-string names, non-dict entries, and
|
||||||
or emit NaN/inf (which the client's ``JSON.parse`` rejects).
|
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).
|
||||||
|
|
||||||
|
``rig`` / ``base_rig`` are carried through but NOT resolved against
|
||||||
|
``rigs.json`` here: this builder only preserves the binding the chart
|
||||||
|
declared. Realization selection and the ``intent.gm`` fallback (§7.9) belong
|
||||||
|
to the consumer that actually voices the part.
|
||||||
"""
|
"""
|
||||||
if not isinstance(arr_tones, dict):
|
if not isinstance(arr_tones, dict):
|
||||||
return "", []
|
return "", "", []
|
||||||
base_val = arr_tones.get("base", "")
|
base_val = arr_tones.get("base", "")
|
||||||
base = base_val.strip() if isinstance(base_val, str) else ""
|
base = base_val.strip() if isinstance(base_val, str) else ""
|
||||||
|
base_rig_val = arr_tones.get("base_rig", "")
|
||||||
|
base_rig = base_rig_val.strip() if isinstance(base_rig_val, str) else ""
|
||||||
|
|
||||||
changes: list[dict] = []
|
changes: list[dict] = []
|
||||||
raw_changes = arr_tones.get("changes")
|
raw_changes = arr_tones.get("changes")
|
||||||
@@ -65,6 +74,13 @@ def sloppak_tone_changes(arr_tones) -> tuple[str, list[dict]]:
|
|||||||
continue
|
continue
|
||||||
if not math.isfinite(t):
|
if not math.isfinite(t):
|
||||||
continue
|
continue
|
||||||
changes.append({"t": round(t, 3), "name": name})
|
change = {"t": round(t, 3), "name": name}
|
||||||
|
# ponytail: `rig` only when it's a usable id — a non-string or blank
|
||||||
|
# value is dropped rather than forwarded, so a consumer can treat
|
||||||
|
# presence of the key as "this change binds a rig".
|
||||||
|
rig = c.get("rig")
|
||||||
|
if isinstance(rig, str) and rig.strip():
|
||||||
|
change["rig"] = rig.strip()
|
||||||
|
changes.append(change)
|
||||||
changes.sort(key=lambda x: x["t"])
|
changes.sort(key=lambda x: x["t"])
|
||||||
return base, changes
|
return base, base_rig, changes
|
||||||
|
|||||||
@@ -160,7 +160,6 @@ Each song object (built by `_meta()`):
|
|||||||
- `filename` is the full relative path from the DLC root — pass it directly to `window.playSong()`.
|
- `filename` is the full relative path from the DLC root — pass it directly to `window.playSong()`.
|
||||||
- `added` is a Unix timestamp (float, seconds) from `stat().st_mtime` — convert with `new Date(added * 1000)`. Always recomputed fresh (it changes when a file moves), even on a metadata-cache hit.
|
- `added` is a Unix timestamp (float, seconds) from `stat().st_mtime` — convert with `new Date(added * 1000)`. Always recomputed fresh (it changes when a file moves), even on a metadata-cache hit.
|
||||||
- `arrangements` / `stems` are flat lists of **strings**, even though `extract_meta()` returns them as objects.
|
- `arrangements` / `stems` are flat lists of **strings**, even though `extract_meta()` returns them as objects.
|
||||||
- Hover-preview isn't resolved here at all — the cards just carry `data-fn`/`data-v3-play` markup and the `song_preview` plugin handles it (see Preview on Hover).
|
|
||||||
|
|
||||||
### extract_meta returns arrangements/stems as objects, not strings
|
### extract_meta returns arrangements/stems as objects, not strings
|
||||||
|
|
||||||
@@ -330,21 +329,13 @@ Drag-and-drop uses **pointer events** (mousedown/mousemove/mouseup), not the HTM
|
|||||||
- **Esc cancels** — resolves with `null`, same as Cancel (applies to rename, delete, create folder/subfolder, move song)
|
- **Esc cancels** — resolves with `null`, same as Cancel (applies to rename, delete, create folder/subfolder, move song)
|
||||||
- **Enter confirms** — submits, equivalent to OK
|
- **Enter confirms** — submits, equivalent to OK
|
||||||
|
|
||||||
## Preview on Hover
|
|
||||||
|
|
||||||
The Folder Library does **not** implement hover-preview itself — the `song_preview` plugin does, for the whole app. Its hover loop finds song elements by the selector `#v3-songs [data-fn]` (with a `[data-v3-play]` playable surface) and its `MutationObserver` watches the `#v3-songs` subtree — and the folder view (`#lib-folder-tree`) renders **inside** `#v3-songs`. So all folder_library has to do is give each card/row the standard markup:
|
|
||||||
|
|
||||||
- **`_songCard`** — `card.dataset.fn = song.filename` (raw filename) + `data-v3-play` on the art wrap (the surface `song_preview` overlays its indicator on).
|
|
||||||
- **`_songRow`** — `row.dataset.fn = song.filename` + `data-v3-play` on the thumb.
|
|
||||||
|
|
||||||
`song_preview` then previews folder view exactly like the grid/list — same audio (its `/audio` endpoint), same availability/404 handling, same indicator — with **zero preview code here**. If you change the card/row structure, keep `data-fn` (raw, not URL-encoded) and a `[data-v3-play]` descendant, or `song_preview` will stop recognising the cards.
|
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
Implemented since the original release: **nested subfolders** (recursive tree + create-inside-folder), drag-and-drop, sort, advanced filtering, server-side tree filtering synced to the host library, and the warm metadata cache. Hover-preview is provided by the `song_preview` plugin via the `data-fn` markup above (not implemented here).
|
Implemented since the original release: **nested subfolders** (recursive tree + create-inside-folder), drag-and-drop, sort, advanced filtering, server-side tree filtering synced to the host library, and the warm metadata cache.
|
||||||
|
|
||||||
Not yet implemented, in rough priority order:
|
Not yet implemented, in rough priority order:
|
||||||
|
|
||||||
|
- **Auto-play on hover** — with an on/off toggle saved to localStorage.
|
||||||
- **Bulk move** — multi-select songs and move them all at once.
|
- **Bulk move** — multi-select songs and move them all at once.
|
||||||
- **Thumbnail performance** — faster loading and smoother scrolling with large libraries.
|
- **Thumbnail performance** — faster loading and smoother scrolling with large libraries.
|
||||||
- **Adjustable thumbnail/row sizes** — user-resizable song cards and list rows.
|
- **Adjustable thumbnail/row sizes** — user-resizable song cards and list rows.
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ A FeedBack (fee[dB]ack) plugin that organizes your `.sloppak` / `.feedpak` DLC s
|
|||||||
- **List & Grid views** — toggle between a compact list with thumbnails or a full album art card grid
|
- **List & Grid views** — toggle between a compact list with thumbnails or a full album art card grid
|
||||||
- **Album art** — pulls art automatically for every song in both views
|
- **Album art** — pulls art automatically for every song in both views
|
||||||
- **One-click playback** — click any song to start playing immediately
|
- **One-click playback** — click any song to start playing immediately
|
||||||
- **Preview song on hover** — hover a song to hear a quick preview of its audio (powered by the Song Preview plugin, the same way the grid and list views work)
|
|
||||||
- **Sort options** — sort songs by title, artist, duration, year, tuning, or recently added with an asc/desc toggle
|
- **Sort options** — sort songs by title, artist, duration, year, tuning, or recently added with an asc/desc toggle
|
||||||
- **Advanced filters** — filter by arrangements, stems, lyrics, and tuning with include and exclude support
|
- **Advanced filters** — filter by arrangements, stems, lyrics, and tuning with include and exclude support
|
||||||
- **Folder management** — create, rename, and delete folders without leaving the plugin
|
- **Folder management** — create, rename, and delete folders without leaving the plugin
|
||||||
@@ -55,7 +54,6 @@ Folder Library ships bundled with FeedBack as a core plugin (`"bundled": true`),
|
|||||||
| Switch to grid view | Click the grid icon in the toolbar |
|
| Switch to grid view | Click the grid icon in the toolbar |
|
||||||
| Switch to list view | Click the list icon in the toolbar |
|
| Switch to list view | Click the list icon in the toolbar |
|
||||||
| Play a song | Click any song row or card |
|
| Play a song | Click any song row or card |
|
||||||
| Preview song on hover | Hover a song — the Song Preview plugin plays a quick clip (same as the grid/list views) |
|
|
||||||
| Sort songs | Use the sort dropdown in the toolbar |
|
| Sort songs | Use the sort dropdown in the toolbar |
|
||||||
| Toggle sort direction | Click the arrow button next to the sort dropdown |
|
| Toggle sort direction | Click the arrow button next to the sort dropdown |
|
||||||
| Open filters | Click the filter icon in the toolbar |
|
| Open filters | Click the filter icon in the toolbar |
|
||||||
@@ -82,8 +80,7 @@ Folder Library started life as a standalone plugin with its own version line, bu
|
|||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
- [ ] Compatibility with core settings — respect Accessibility → Interface size
|
- [ ] Auto play song on hover (with an on/off toggle)
|
||||||
- [ ] Core parity — Metadata, Unmatched, and Upload should work in the folder view too
|
|
||||||
- [ ] Bulk move — select multiple songs and move them at once
|
- [ ] Bulk move — select multiple songs and move them at once
|
||||||
- [ ] Thumbnail performance — faster loading and smoother scrolling with large song libraries
|
- [ ] Thumbnail performance — faster loading and smoother scrolling with large song libraries
|
||||||
- [ ] Adjustable thumbnail and row sizes — resize song cards and list rows to suit your preference
|
- [ ] Adjustable thumbnail and row sizes — resize song cards and list rows to suit your preference
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "folder_library",
|
"id": "folder_library",
|
||||||
"name": "Folder Library",
|
"name": "Folder Library",
|
||||||
"version": "1.9.0",
|
"version": "1.8.0",
|
||||||
"bundled": true,
|
"bundled": true,
|
||||||
"nav": { "label": "Folders", "screen": "plugin-folder_library" },
|
"nav": { "label": "Folders", "screen": "plugin-folder_library" },
|
||||||
"screen": "screen.html",
|
"screen": "screen.html",
|
||||||
|
|||||||
@@ -735,11 +735,10 @@ function createFolderSurface(cfg) {
|
|||||||
var card = document.createElement('div');
|
var card = document.createElement('div');
|
||||||
card.className = 'flex flex-col rounded-lg overflow-hidden cursor-pointer group transition-transform duration-100 hover:scale-105';
|
card.className = 'flex flex-col rounded-lg overflow-hidden cursor-pointer group transition-transform duration-100 hover:scale-105';
|
||||||
card.style.background = '#1a1d2e';
|
card.style.background = '#1a1d2e';
|
||||||
card.dataset.fn = song.filename; // data-fn (raw): song_preview's hover loop finds cards by this
|
card.dataset.filename = song.filename;
|
||||||
|
|
||||||
var artWrap = document.createElement('div');
|
var artWrap = document.createElement('div');
|
||||||
artWrap.style.cssText = 'position:relative; width:100%; padding-bottom:100%; background:#111827; overflow:hidden;';
|
artWrap.style.cssText = 'position:relative; width:100%; padding-bottom:100%; background:#111827; overflow:hidden;';
|
||||||
artWrap.setAttribute('data-v3-play', ''); // the playable surface song_preview overlays its indicator on
|
|
||||||
var img = document.createElement('img');
|
var img = document.createElement('img');
|
||||||
img.style.cssText = 'position:absolute; inset:0; width:100%; height:100%; object-fit:cover;';
|
img.style.cssText = 'position:absolute; inset:0; width:100%; height:100%; object-fit:cover;';
|
||||||
img.alt = ''; img.loading = 'lazy';
|
img.alt = ''; img.loading = 'lazy';
|
||||||
@@ -805,11 +804,10 @@ function createFolderSurface(cfg) {
|
|||||||
function _songRow(song, folderName) {
|
function _songRow(song, folderName) {
|
||||||
var row = document.createElement('div');
|
var row = document.createElement('div');
|
||||||
row.className = 'flex items-center gap-3 px-3 py-2 rounded cursor-pointer hover:bg-dark-500 group transition-colors duration-100';
|
row.className = 'flex items-center gap-3 px-3 py-2 rounded cursor-pointer hover:bg-dark-500 group transition-colors duration-100';
|
||||||
row.dataset.fn = song.filename; // data-fn (raw): song_preview's hover loop finds rows by this
|
row.dataset.filename = song.filename;
|
||||||
|
|
||||||
var thumb = document.createElement('div');
|
var thumb = document.createElement('div');
|
||||||
thumb.style.cssText = 'width:36px; height:36px; border-radius:4px; overflow:hidden; background:#111827; flex-shrink:0; position:relative;';
|
thumb.style.cssText = 'width:36px; height:36px; border-radius:4px; overflow:hidden; background:#111827; flex-shrink:0; position:relative;';
|
||||||
thumb.setAttribute('data-v3-play', ''); // marks the row previewable for song_preview
|
|
||||||
var tImg = document.createElement('img');
|
var tImg = document.createElement('img');
|
||||||
tImg.loading = 'lazy';
|
tImg.loading = 'lazy';
|
||||||
tImg.src = '/api/song/' + song.filename.split('/').map(encodeURIComponent).join('/') + '/art';
|
tImg.src = '/api/song/' + song.filename.split('/').map(encodeURIComponent).join('/') + '/art';
|
||||||
@@ -1709,16 +1707,8 @@ function createFolderSurface(cfg) {
|
|||||||
init: _init,
|
init: _init,
|
||||||
onScreenChanged: _onScreenChanged,
|
onScreenChanged: _onScreenChanged,
|
||||||
render: _render,
|
render: _render,
|
||||||
// Helpers exposed for tests. visibleWindow is pure; songCard/songRow
|
// Pure window arithmetic, exposed for tests (no DOM needed).
|
||||||
// need a DOM (the tests supply a minimal element mock) and pin the
|
__test: { visibleWindow: _visibleWindow, VIRTUAL_MIN: VIRTUAL_MIN, VIRTUAL_BUFFER: VIRTUAL_BUFFER },
|
||||||
// song_preview integration markup (data-fn + a data-v3-play surface).
|
|
||||||
__test: {
|
|
||||||
visibleWindow: _visibleWindow,
|
|
||||||
VIRTUAL_MIN: VIRTUAL_MIN,
|
|
||||||
VIRTUAL_BUFFER: VIRTUAL_BUFFER,
|
|
||||||
songCard: _songCard,
|
|
||||||
songRow: _songRow,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,116 +0,0 @@
|
|||||||
// song_preview integration markup (feedBack — Folders view hover preview).
|
|
||||||
//
|
|
||||||
// The Folder Library does NOT implement hover-preview itself. It relies on the
|
|
||||||
// separate `song_preview` plugin, exactly like the grid and list views. That
|
|
||||||
// plugin's host adapter finds previewable elements with the selector
|
|
||||||
// `#v3-songs [data-fn]` and requires each to contain a `[data-v3-play]`
|
|
||||||
// descendant (the surface it overlays its indicator on), reading the raw
|
|
||||||
// filename from `data-fn`.
|
|
||||||
//
|
|
||||||
// So the ENTIRE contract Folder Library owns is: every song card and row it
|
|
||||||
// renders must carry `data-fn` (raw filename) and expose a `[data-v3-play]`
|
|
||||||
// surface. If a refactor drops either, folder cards silently stop previewing
|
|
||||||
// while grid/list keep working — a regression that's invisible without a live
|
|
||||||
// song_preview install. These tests pin the markup so that can't happen.
|
|
||||||
|
|
||||||
const { test } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
const vm = require('node:vm');
|
|
||||||
|
|
||||||
// A minimal DOM element mock — just enough for _songCard / _songRow to run.
|
|
||||||
// No jsdom in this repo (see virtual_list.test.js); the element tracks the few
|
|
||||||
// things the contract cares about: dataset, attributes, and a child tree that
|
|
||||||
// querySelector('[data-v3-play]') can walk.
|
|
||||||
function makeEl(tag) {
|
|
||||||
const attrs = {};
|
|
||||||
const el = {
|
|
||||||
tagName: String(tag || '').toUpperCase(),
|
|
||||||
style: {}, // supports .cssText and arbitrary props
|
|
||||||
dataset: {},
|
|
||||||
className: '',
|
|
||||||
children: [],
|
|
||||||
parentNode: null,
|
|
||||||
addEventListener() {},
|
|
||||||
removeEventListener() {},
|
|
||||||
setAttribute(k, v) { attrs[k] = String(v); },
|
|
||||||
getAttribute(k) { return k in attrs ? attrs[k] : null; },
|
|
||||||
hasAttribute(k) { return k in attrs; },
|
|
||||||
appendChild(child) { el.children.push(child); if (child) child.parentNode = el; return child; },
|
|
||||||
classList: { add() {}, remove() {}, contains() { return false; }, toggle() {} },
|
|
||||||
remove() {},
|
|
||||||
// Only the '[data-v3-play]'-style attribute selector is needed.
|
|
||||||
querySelector(sel) {
|
|
||||||
const attr = sel.replace(/^\[|\]$/g, '');
|
|
||||||
const stack = el.children.slice();
|
|
||||||
while (stack.length) {
|
|
||||||
const n = stack.shift();
|
|
||||||
if (n && n.hasAttribute && n.hasAttribute(attr)) return n;
|
|
||||||
if (n && n.children) stack.push(...n.children);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return el;
|
|
||||||
}
|
|
||||||
|
|
||||||
function load() {
|
|
||||||
const window = {
|
|
||||||
console,
|
|
||||||
document: {
|
|
||||||
readyState: 'complete',
|
|
||||||
addEventListener() {},
|
|
||||||
getElementById() { return null; },
|
|
||||||
querySelector() { return null; },
|
|
||||||
querySelectorAll() { return []; },
|
|
||||||
createElement(tag) { return makeEl(tag); },
|
|
||||||
},
|
|
||||||
addEventListener() {},
|
|
||||||
localStorage: { getItem() { return null; }, setItem() {} },
|
|
||||||
performance: { now: () => 0 },
|
|
||||||
setInterval() { return 0; },
|
|
||||||
clearInterval() {},
|
|
||||||
requestAnimationFrame() { return 0; },
|
|
||||||
cancelAnimationFrame() {},
|
|
||||||
getComputedStyle() { return { overflowY: 'visible', paddingTop: '0px', paddingBottom: '0px' }; },
|
|
||||||
innerHeight: 800,
|
|
||||||
};
|
|
||||||
window.window = window;
|
|
||||||
window.globalThis = window;
|
|
||||||
const ctx = vm.createContext(window);
|
|
||||||
vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'screen.js'), 'utf8'), ctx, { filename: 'screen.js' });
|
|
||||||
assert.ok(window.folderLibrary && window.folderLibrary.__test, 'plugin must expose __test');
|
|
||||||
return window.folderLibrary.__test;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { songCard, songRow } = load();
|
|
||||||
|
|
||||||
// A raw filename with a subfolder + spaces — the kind of value song_preview
|
|
||||||
// URL-encodes downstream, so it must reach data-fn verbatim, not pre-encoded.
|
|
||||||
const FILENAME = 'Some Artist/A Song.sloppak';
|
|
||||||
const SONG = { filename: FILENAME, title: 'A Song', artist: 'Some Artist' };
|
|
||||||
|
|
||||||
test('song_preview helpers are exposed for the markup contract', () => {
|
|
||||||
assert.equal(typeof songCard, 'function');
|
|
||||||
assert.equal(typeof songRow, 'function');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('grid card carries data-fn (raw) and a data-v3-play surface', () => {
|
|
||||||
const card = songCard(SONG, 'Unsorted');
|
|
||||||
assert.equal(card.dataset.fn, FILENAME, 'data-fn must be the raw, un-encoded filename');
|
|
||||||
assert.ok(card.querySelector('[data-v3-play]'), 'card must contain a [data-v3-play] surface');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('list row carries data-fn (raw) and a data-v3-play surface', () => {
|
|
||||||
const row = songRow(SONG, 'Unsorted');
|
|
||||||
assert.equal(row.dataset.fn, FILENAME, 'data-fn must be the raw, un-encoded filename');
|
|
||||||
assert.ok(row.querySelector('[data-v3-play]'), 'row must contain a [data-v3-play] surface');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('card renders without depending on any optional song metadata', () => {
|
|
||||||
// song_preview only needs filename; the card must build from a bare song
|
|
||||||
// (no duration/arrangements/stems/lyrics/tuning/year) without throwing.
|
|
||||||
assert.doesNotThrow(() => songCard({ filename: FILENAME }, 'Unsorted'));
|
|
||||||
assert.doesNotThrow(() => songRow({ filename: FILENAME }, 'Unsorted'));
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
"""End-to-end test for the sloppak loader recognising a `rigs:` manifest key
|
||||||
|
(rigs.json — the pack-level library of engine-agnostic rigs, spec §7.9) and
|
||||||
|
surfacing the payload on the LoadedSloppak.
|
||||||
|
|
||||||
|
The governing posture: rig objects pass through VERBATIM. This loader does not
|
||||||
|
select realizations or apply the `intent.gm` floor — it only makes the library
|
||||||
|
addressable by `id`, which is what `tones.base_rig` / `tones.changes[].rig`
|
||||||
|
reference."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
import sloppak as sloppak_mod
|
||||||
|
|
||||||
|
|
||||||
|
def _write_dir_sloppak(root: Path, manifest_extras: dict, rigs_payload) -> Path:
|
||||||
|
"""Minimal directory-form sloppak; writes rigs.json when a payload is given.
|
||||||
|
|
||||||
|
Unique filename per test (tmp_path leaf) so the module-level
|
||||||
|
resolve_source_dir cache isn't poisoned across tests."""
|
||||||
|
pak = root / f"{root.name}.sloppak"
|
||||||
|
pak.mkdir()
|
||||||
|
arr_dir = pak / "arrangements"
|
||||||
|
arr_dir.mkdir()
|
||||||
|
arr = {
|
||||||
|
"name": "Lead", "tuning": [0, 0, 0, 0, 0, 0], "capo": 0,
|
||||||
|
"notes": [], "chords": [], "anchors": [], "handshapes": [],
|
||||||
|
"templates": [], "beats": [], "sections": [],
|
||||||
|
}
|
||||||
|
(arr_dir / "lead.json").write_text(json.dumps(arr))
|
||||||
|
|
||||||
|
manifest = {
|
||||||
|
"title": "Test", "artist": "Tester", "album": "", "year": 2026,
|
||||||
|
"duration": 10.0,
|
||||||
|
"arrangements": [{"id": "lead", "name": "Lead", "file": "arrangements/lead.json"}],
|
||||||
|
"stems": [{"id": "full", "file": "stems/full.ogg", "default": True}],
|
||||||
|
}
|
||||||
|
manifest.update(manifest_extras)
|
||||||
|
(pak / "manifest.yaml").write_text(yaml.safe_dump(manifest, sort_keys=False))
|
||||||
|
|
||||||
|
if rigs_payload is not None:
|
||||||
|
(pak / "rigs.json").write_text(json.dumps(rigs_payload))
|
||||||
|
return pak
|
||||||
|
|
||||||
|
|
||||||
|
def _load(pak_path: Path, tmp_path: Path):
|
||||||
|
dlc_root = pak_path.parent
|
||||||
|
cache = tmp_path / "cache"
|
||||||
|
cache.mkdir()
|
||||||
|
return sloppak_mod.load_song(pak_path.name, dlc_root, cache)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Happy path ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_load_song_attaches_rigs_when_manifest_opts_in(tmp_path: Path):
|
||||||
|
"""A source rig (spec §7.9 1.18.0) survives the load intact — including the
|
||||||
|
`soundfont` realization and the `intent.gm` floor a consumer needs to voice
|
||||||
|
the part."""
|
||||||
|
payload = {
|
||||||
|
"version": 1,
|
||||||
|
"rigs": [
|
||||||
|
{
|
||||||
|
"id": "grand-piano",
|
||||||
|
"name": "Grand Piano",
|
||||||
|
"instrument": "keys",
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"role": "source",
|
||||||
|
"name": "Concert Grand",
|
||||||
|
"intent": {"kind": "instrument", "gm": {"program": 0}},
|
||||||
|
"realizations": [
|
||||||
|
{"engine": "soundfont", "format": "sf2",
|
||||||
|
"ref": "sounds/grand.sf2", "bank": 0, "program": 0},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
pak = _write_dir_sloppak(tmp_path, {"rigs": "rigs.json"}, payload)
|
||||||
|
loaded = _load(pak, tmp_path)
|
||||||
|
assert loaded.rigs is not None
|
||||||
|
assert loaded.rigs["version"] == 1
|
||||||
|
assert loaded.rigs["rigs"] == payload["rigs"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_song_rigs_absent_without_manifest_key(tmp_path: Path):
|
||||||
|
"""The file alone must not opt a pack in — the manifest is the opt-in
|
||||||
|
(spec §9.1, "manifest opt-in, file off to the side")."""
|
||||||
|
pak = _write_dir_sloppak(tmp_path, {}, {"version": 1, "rigs": []})
|
||||||
|
assert _load(pak, tmp_path).rigs is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Verbatim passthrough ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_load_song_preserves_unknown_rig_content(tmp_path: Path):
|
||||||
|
"""Unknown `role` / `engine` / `kind` values and `ext` namespaces MUST
|
||||||
|
survive (spec §7.9) — core does not interpret rigs, so it must not prune
|
||||||
|
what a newer writer or a plugin put there."""
|
||||||
|
payload = {
|
||||||
|
"version": 2,
|
||||||
|
"rigs": [
|
||||||
|
{
|
||||||
|
"id": "future-rig",
|
||||||
|
"blocks": [
|
||||||
|
{"role": "quantum-flux", "intent": {"kind": "not-yet-invented"},
|
||||||
|
"realizations": [{"engine": "some-future-engine", "ref": "x.bin"}],
|
||||||
|
"ext": {"vendor.custom": {"anything": [1, 2, 3]}}},
|
||||||
|
],
|
||||||
|
"graph": {"nodes": ["input", "output"], "edges": [["input", "output"]]},
|
||||||
|
"ext": {"vendor.rig": "kept"},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
pak = _write_dir_sloppak(tmp_path, {"rigs": "rigs.json"}, payload)
|
||||||
|
loaded = _load(pak, tmp_path)
|
||||||
|
assert loaded.rigs["version"] == 2
|
||||||
|
assert loaded.rigs["rigs"] == payload["rigs"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Addressability ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_load_song_drops_unaddressable_rigs_and_normalizes_ids(tmp_path: Path):
|
||||||
|
"""A rig is reachable only by `id`, so entries without a usable one are
|
||||||
|
unreferenceable by construction. Ids are stripped to match the reference
|
||||||
|
side, which lib/tones.py strips before it reaches the wire."""
|
||||||
|
payload = {
|
||||||
|
"rigs": [
|
||||||
|
"not-a-dict",
|
||||||
|
{"name": "no id at all"},
|
||||||
|
{"id": "", "name": "blank id"},
|
||||||
|
{"id": " ", "name": "whitespace id"},
|
||||||
|
{"id": 7, "name": "non-string id"},
|
||||||
|
{"id": " padded-rig ", "name": "Padded"},
|
||||||
|
{"id": "plain-rig", "name": "Plain"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
pak = _write_dir_sloppak(tmp_path, {"rigs": "rigs.json"}, payload)
|
||||||
|
loaded = _load(pak, tmp_path)
|
||||||
|
assert [r["id"] for r in loaded.rigs["rigs"]] == ["padded-rig", "plain-rig"]
|
||||||
|
# Everything except the normalized id is untouched.
|
||||||
|
assert loaded.rigs["rigs"][0]["name"] == "Padded"
|
||||||
|
# `version` defaults when the file omits it.
|
||||||
|
assert loaded.rigs["version"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_song_first_rig_wins_on_duplicate_id(tmp_path: Path):
|
||||||
|
"""A duplicate id makes `tones.base_rig` ambiguous, which would surface as
|
||||||
|
the wrong sound rather than an error."""
|
||||||
|
payload = {
|
||||||
|
"rigs": [
|
||||||
|
{"id": "dupe", "name": "First"},
|
||||||
|
{"id": "dupe", "name": "Second"},
|
||||||
|
{"id": " dupe ", "name": "Third, padded into a collision"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
pak = _write_dir_sloppak(tmp_path, {"rigs": "rigs.json"}, payload)
|
||||||
|
loaded = _load(pak, tmp_path)
|
||||||
|
assert len(loaded.rigs["rigs"]) == 1
|
||||||
|
assert loaded.rigs["rigs"][0]["name"] == "First"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Permissive posture (spec §7.9: never fail the pack) ──────────────────────
|
||||||
|
|
||||||
|
def test_load_song_survives_malformed_rigs(tmp_path: Path):
|
||||||
|
"""Malformed / missing / traversing rig libraries disable rigs, never the
|
||||||
|
pack — the song itself must still load."""
|
||||||
|
cases = [
|
||||||
|
{"version": 1, "rigs": "not-a-list"}, # wrong `rigs` type
|
||||||
|
["top-level-not-a-dict"], # wrong document type
|
||||||
|
{"version": 1}, # no `rigs` key at all
|
||||||
|
]
|
||||||
|
for i, payload in enumerate(cases):
|
||||||
|
sub = tmp_path / f"case{i}"
|
||||||
|
sub.mkdir()
|
||||||
|
pak = _write_dir_sloppak(sub, {"rigs": "rigs.json"}, payload)
|
||||||
|
loaded = _load(pak, sub)
|
||||||
|
assert loaded.rigs is None, f"case {i} should disable rigs"
|
||||||
|
assert loaded.song is not None, f"case {i} must not fail the pack"
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_song_survives_unparseable_rigs(tmp_path: Path):
|
||||||
|
pak = _write_dir_sloppak(tmp_path, {"rigs": "rigs.json"}, None)
|
||||||
|
(pak / "rigs.json").write_text("{ not json at all ")
|
||||||
|
loaded = _load(pak, tmp_path)
|
||||||
|
assert loaded.rigs is None
|
||||||
|
assert loaded.song is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_song_survives_missing_rigs_file(tmp_path: Path):
|
||||||
|
pak = _write_dir_sloppak(tmp_path, {"rigs": "rigs.json"}, None)
|
||||||
|
loaded = _load(pak, tmp_path)
|
||||||
|
assert loaded.rigs is None
|
||||||
|
assert loaded.song is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_song_rejects_traversing_rigs_path(tmp_path: Path):
|
||||||
|
"""A crafted manifest must not read outside the pack."""
|
||||||
|
(tmp_path / "outside.json").write_text(json.dumps({"rigs": [{"id": "leaked"}]}))
|
||||||
|
pak = _write_dir_sloppak(tmp_path, {"rigs": "../outside.json"}, None)
|
||||||
|
loaded = _load(pak, tmp_path)
|
||||||
|
assert loaded.rigs is None
|
||||||
|
assert loaded.song is not None
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
"""Loader coverage for the manifest-vs-in-JSON `tones` precedence cascade
|
||||||
|
(feedpak 1.18.0, spec §5.1 / §5.2).
|
||||||
|
|
||||||
|
Two rules, both about *which* sound binding wins, neither about interpreting it:
|
||||||
|
|
||||||
|
- A manifest arrangement entry's `tones` replaces the arrangement JSON's
|
||||||
|
`tones` **WHOLESALE** — no field-level merge. A half-merged block (this
|
||||||
|
source's `base` with that source's `changes`) would be a sound nobody
|
||||||
|
authored, so the two never blend.
|
||||||
|
- Top-level `drum_tones` binds the song-level (primary) drum part and is the
|
||||||
|
fallback; a `type: drums` entry's own `tones` takes precedence, and a
|
||||||
|
Reader MUST NOT apply both to the same part.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
import sloppak as sloppak_mod
|
||||||
|
|
||||||
|
|
||||||
|
IN_JSON_TONES = {
|
||||||
|
"base": "In-JSON Clean",
|
||||||
|
"base_rig": "injson-clean",
|
||||||
|
"changes": [{"t": 5.0, "name": "In-JSON Lead", "rig": "injson-lead"}],
|
||||||
|
}
|
||||||
|
ENTRY_TONES = {
|
||||||
|
"base": "Entry Grand",
|
||||||
|
"base_rig": "entry-grand",
|
||||||
|
"changes": [{"t": 9.0, "name": "Entry Rhodes", "rig": "entry-rhodes"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _tab(name: str) -> dict:
|
||||||
|
return {
|
||||||
|
"version": 1,
|
||||||
|
"name": name,
|
||||||
|
"kit": [{"id": "kick", "name": "Kick"}],
|
||||||
|
"hits": [{"t": 1.0, "p": "kick", "v": 100}],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _write_pak(root: Path, manifest_extras: dict, arr_tones: dict | None = None,
|
||||||
|
files: dict[str, dict] | None = None) -> Path:
|
||||||
|
"""Directory-form sloppak with one Lead arrangement, optionally carrying an
|
||||||
|
in-JSON `tones` block, plus any extra files."""
|
||||||
|
pak = root / f"{root.name}.sloppak"
|
||||||
|
pak.mkdir()
|
||||||
|
arr_dir = pak / "arrangements"
|
||||||
|
arr_dir.mkdir()
|
||||||
|
arr = {
|
||||||
|
"name": "Lead", "tuning": [0, 0, 0, 0, 0, 0], "capo": 0,
|
||||||
|
"notes": [], "chords": [], "anchors": [], "handshapes": [],
|
||||||
|
"templates": [], "beats": [], "sections": [],
|
||||||
|
}
|
||||||
|
if arr_tones is not None:
|
||||||
|
arr["tones"] = arr_tones
|
||||||
|
(arr_dir / "lead.json").write_text(json.dumps(arr))
|
||||||
|
|
||||||
|
manifest = {
|
||||||
|
"title": "Test", "artist": "Tester", "album": "", "year": 2026,
|
||||||
|
"duration": 10.0,
|
||||||
|
"arrangements": [{"id": "lead", "name": "Lead", "file": "arrangements/lead.json"}],
|
||||||
|
"stems": [{"id": "full", "file": "stems/full.ogg", "default": True}],
|
||||||
|
}
|
||||||
|
manifest.update(manifest_extras)
|
||||||
|
(pak / "manifest.yaml").write_text(yaml.safe_dump(manifest, sort_keys=False))
|
||||||
|
for rel, payload in (files or {}).items():
|
||||||
|
(pak / rel).write_text(json.dumps(payload))
|
||||||
|
return pak
|
||||||
|
|
||||||
|
|
||||||
|
def _load(pak_path: Path, tmp_path: Path):
|
||||||
|
cache = tmp_path / "cache"
|
||||||
|
cache.mkdir()
|
||||||
|
return sloppak_mod.load_song(pak_path.name, pak_path.parent, cache)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Arrangement entry vs in-JSON (§5.2) ──────────────────────────────────────
|
||||||
|
|
||||||
|
def test_entry_tones_replaces_in_json_wholesale(tmp_path: Path):
|
||||||
|
"""The entry object replaces the in-JSON one entirely — no key survives
|
||||||
|
from the loser, not even ones the winner doesn't define."""
|
||||||
|
entry_tones = {"base": "Entry Only"} # no base_rig, no changes
|
||||||
|
pak = _write_pak(
|
||||||
|
tmp_path,
|
||||||
|
{"arrangements": [{"id": "lead", "name": "Lead",
|
||||||
|
"file": "arrangements/lead.json",
|
||||||
|
"tones": entry_tones}]},
|
||||||
|
arr_tones=IN_JSON_TONES,
|
||||||
|
)
|
||||||
|
arr = _load(pak, tmp_path).song.arrangements[0]
|
||||||
|
assert arr.tones == entry_tones
|
||||||
|
# The in-JSON `base_rig` and `changes` must NOT have been merged in.
|
||||||
|
assert "base_rig" not in arr.tones
|
||||||
|
assert "changes" not in arr.tones
|
||||||
|
|
||||||
|
|
||||||
|
def test_in_json_tones_survive_when_entry_has_none(tmp_path: Path):
|
||||||
|
pak = _write_pak(tmp_path, {}, arr_tones=IN_JSON_TONES)
|
||||||
|
assert _load(pak, tmp_path).song.arrangements[0].tones == IN_JSON_TONES
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_entry_tones_is_absent_not_an_override(tmp_path: Path):
|
||||||
|
"""`{}` reads as "didn't specify", not "override to silence" — otherwise a
|
||||||
|
stray empty object silently unbinds the part's sound."""
|
||||||
|
pak = _write_pak(
|
||||||
|
tmp_path,
|
||||||
|
{"arrangements": [{"id": "lead", "name": "Lead",
|
||||||
|
"file": "arrangements/lead.json", "tones": {}}]},
|
||||||
|
arr_tones=IN_JSON_TONES,
|
||||||
|
)
|
||||||
|
assert _load(pak, tmp_path).song.arrangements[0].tones == IN_JSON_TONES
|
||||||
|
|
||||||
|
|
||||||
|
def test_malformed_entry_tones_is_ignored(tmp_path: Path):
|
||||||
|
"""A non-dict `tones` must not override, and must not crash the load."""
|
||||||
|
pak = _write_pak(
|
||||||
|
tmp_path,
|
||||||
|
{"arrangements": [{"id": "lead", "name": "Lead",
|
||||||
|
"file": "arrangements/lead.json",
|
||||||
|
"tones": ["not", "a", "dict"]}]},
|
||||||
|
arr_tones=IN_JSON_TONES,
|
||||||
|
)
|
||||||
|
assert _load(pak, tmp_path).song.arrangements[0].tones == IN_JSON_TONES
|
||||||
|
|
||||||
|
|
||||||
|
def test_entry_tones_binds_a_notation_only_arrangement(tmp_path: Path):
|
||||||
|
"""§5.2: entry `tones` is available whether or not the arrangement has a
|
||||||
|
`file` — a keys part is a notation-only entry, and binding its sound is the
|
||||||
|
whole point of the 1.18.0 work."""
|
||||||
|
notation = {"version": 1, "measures": []}
|
||||||
|
pak = _write_pak(
|
||||||
|
tmp_path,
|
||||||
|
{"arrangements": [{"id": "keys", "name": "Keys",
|
||||||
|
"notation": "notation_keys.json",
|
||||||
|
"tones": ENTRY_TONES}]},
|
||||||
|
files={"notation_keys.json": notation},
|
||||||
|
)
|
||||||
|
arr = _load(pak, tmp_path).song.arrangements[0]
|
||||||
|
assert arr.name == "Keys"
|
||||||
|
assert arr.tones == ENTRY_TONES
|
||||||
|
|
||||||
|
|
||||||
|
# ── drum_tones vs entry tones (§5.1) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_drum_tones_binds_the_primary_part(tmp_path: Path):
|
||||||
|
pak = _write_pak(
|
||||||
|
tmp_path,
|
||||||
|
{"drum_tab": "drum_tab.json", "drum_tones": ENTRY_TONES},
|
||||||
|
files={"drum_tab.json": _tab("Drums")},
|
||||||
|
)
|
||||||
|
parts = _load(pak, tmp_path).drum_parts
|
||||||
|
assert len(parts) == 1
|
||||||
|
assert parts[0]["tones"] == ENTRY_TONES
|
||||||
|
|
||||||
|
|
||||||
|
def test_entry_tones_outrank_drum_tones_on_the_primary(tmp_path: Path):
|
||||||
|
"""An alias pointer entry naming the same file IS the primary, so its own
|
||||||
|
binding wins — and `drum_tones` must not also be applied."""
|
||||||
|
alias_tones = {"base": "Alias Kit", "base_rig": "alias-kit"}
|
||||||
|
pak = _write_pak(
|
||||||
|
tmp_path,
|
||||||
|
{
|
||||||
|
"drum_tab": "drum_tab.json",
|
||||||
|
"drum_tones": ENTRY_TONES,
|
||||||
|
"arrangements": [
|
||||||
|
{"id": "lead", "name": "Lead", "file": "arrangements/lead.json"},
|
||||||
|
{"id": "drums", "name": "Drums", "type": "drums",
|
||||||
|
"drum_tab": "drum_tab.json", "tones": alias_tones},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
files={"drum_tab.json": _tab("Drums")},
|
||||||
|
)
|
||||||
|
parts = _load(pak, tmp_path).drum_parts
|
||||||
|
assert len(parts) == 1
|
||||||
|
assert parts[0]["tones"] == alias_tones
|
||||||
|
|
||||||
|
|
||||||
|
def test_drum_tones_does_not_leak_to_secondary_parts(tmp_path: Path):
|
||||||
|
"""`drum_tones` is the PRIMARY's fallback only. A second drummer with no
|
||||||
|
binding of its own gets None — not the primary's kit."""
|
||||||
|
live_tones = {"base": "Live Kit", "base_rig": "live-kit"}
|
||||||
|
pak = _write_pak(
|
||||||
|
tmp_path,
|
||||||
|
{
|
||||||
|
"drum_tab": "drum_tab.json",
|
||||||
|
"drum_tones": ENTRY_TONES,
|
||||||
|
"arrangements": [
|
||||||
|
{"id": "lead", "name": "Lead", "file": "arrangements/lead.json"},
|
||||||
|
{"id": "drums-live", "name": "Drums (Live)", "type": "drums",
|
||||||
|
"drum_tab": "drum_tab_live.json", "tones": live_tones},
|
||||||
|
{"id": "drums-prog", "name": "Drums (Prog)", "type": "drums",
|
||||||
|
"drum_tab": "drum_tab_prog.json"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
files={
|
||||||
|
"drum_tab.json": _tab("Drums"),
|
||||||
|
"drum_tab_live.json": _tab("Drums Live"),
|
||||||
|
"drum_tab_prog.json": _tab("Drums Prog"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
parts = {p["id"]: p for p in _load(pak, tmp_path).drum_parts}
|
||||||
|
assert parts["drums"]["tones"] == ENTRY_TONES # primary, from drum_tones
|
||||||
|
assert parts["drums-live"]["tones"] == live_tones # own entry
|
||||||
|
assert parts["drums-prog"]["tones"] is None # no binding, no leak
|
||||||
|
|
||||||
|
|
||||||
|
def test_drum_parts_carry_none_when_pack_binds_nothing(tmp_path: Path):
|
||||||
|
"""A pack with drums and no sound binding at all still loads, with the key
|
||||||
|
present and None — consumers can read `part["tones"]` unconditionally."""
|
||||||
|
pak = _write_pak(
|
||||||
|
tmp_path,
|
||||||
|
{"drum_tab": "drum_tab.json"},
|
||||||
|
files={"drum_tab.json": _tab("Drums")},
|
||||||
|
)
|
||||||
|
parts = _load(pak, tmp_path).drum_parts
|
||||||
|
assert parts[0]["tones"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_malformed_drum_tones_is_ignored(tmp_path: Path):
|
||||||
|
pak = _write_pak(
|
||||||
|
tmp_path,
|
||||||
|
{"drum_tab": "drum_tab.json", "drum_tones": "not-a-dict"},
|
||||||
|
files={"drum_tab.json": _tab("Drums")},
|
||||||
|
)
|
||||||
|
assert _load(pak, tmp_path).drum_parts[0]["tones"] is None
|
||||||
+57
-8
@@ -6,16 +6,17 @@ from tones import sloppak_tone_changes
|
|||||||
# ── sloppak_tone_changes (highway payload builder) ───────────────────────────
|
# ── sloppak_tone_changes (highway payload builder) ───────────────────────────
|
||||||
|
|
||||||
def test_sloppak_tone_changes_sorts_and_returns_base():
|
def test_sloppak_tone_changes_sorts_and_returns_base():
|
||||||
base, changes = sloppak_tone_changes({
|
base, base_rig, changes = sloppak_tone_changes({
|
||||||
"base": "Clean",
|
"base": "Clean",
|
||||||
"changes": [{"t": 12.5, "name": "Drive"}, {"t": 3.0, "name": "Clean"}],
|
"changes": [{"t": 12.5, "name": "Drive"}, {"t": 3.0, "name": "Clean"}],
|
||||||
})
|
})
|
||||||
assert base == "Clean"
|
assert base == "Clean"
|
||||||
|
assert base_rig == ""
|
||||||
assert changes == [{"t": 3.0, "name": "Clean"}, {"t": 12.5, "name": "Drive"}]
|
assert changes == [{"t": 3.0, "name": "Clean"}, {"t": 12.5, "name": "Drive"}]
|
||||||
|
|
||||||
|
|
||||||
def test_sloppak_tone_changes_skips_malformed_markers():
|
def test_sloppak_tone_changes_skips_malformed_markers():
|
||||||
_, changes = sloppak_tone_changes({
|
_, _, changes = sloppak_tone_changes({
|
||||||
"changes": [
|
"changes": [
|
||||||
{"t": "nan", "name": "BadStr"},
|
{"t": "nan", "name": "BadStr"},
|
||||||
{"t": float("inf"), "name": "Inf"},
|
{"t": float("inf"), "name": "Inf"},
|
||||||
@@ -29,18 +30,66 @@ def test_sloppak_tone_changes_skips_malformed_markers():
|
|||||||
|
|
||||||
|
|
||||||
def test_sloppak_tone_changes_handles_none_and_bad_base():
|
def test_sloppak_tone_changes_handles_none_and_bad_base():
|
||||||
assert sloppak_tone_changes(None) == ("", [])
|
assert sloppak_tone_changes(None) == ("", "", [])
|
||||||
base, changes = sloppak_tone_changes({"base": 123, "changes": []})
|
base, base_rig, changes = sloppak_tone_changes({"base": 123, "changes": []})
|
||||||
assert base == "" and changes == []
|
assert base == "" and base_rig == "" and changes == []
|
||||||
|
|
||||||
|
|
||||||
def test_sloppak_tone_changes_non_dict_input():
|
def test_sloppak_tone_changes_non_dict_input():
|
||||||
"""A truthy non-dict payload must not crash."""
|
"""A truthy non-dict payload must not crash."""
|
||||||
assert sloppak_tone_changes(["not", "a", "dict"]) == ("", [])
|
assert sloppak_tone_changes(["not", "a", "dict"]) == ("", "", [])
|
||||||
assert sloppak_tone_changes("nope") == ("", [])
|
assert sloppak_tone_changes("nope") == ("", "", [])
|
||||||
|
|
||||||
|
|
||||||
def test_sloppak_tone_changes_non_list_changes():
|
def test_sloppak_tone_changes_non_list_changes():
|
||||||
"""A truthy non-list `changes` value must not raise on iteration."""
|
"""A truthy non-list `changes` value must not raise on iteration."""
|
||||||
base, changes = sloppak_tone_changes({"base": "Clean", "changes": 1})
|
base, _, changes = sloppak_tone_changes({"base": "Clean", "changes": 1})
|
||||||
assert base == "Clean" and changes == []
|
assert base == "Clean" and changes == []
|
||||||
|
|
||||||
|
|
||||||
|
# ── rig bindings (feedpak-spec 1.18.0 §6.9) ──────────────────────────────────
|
||||||
|
|
||||||
|
def test_sloppak_tone_changes_carries_rig_bindings():
|
||||||
|
"""`base_rig` and per-change `rig` reach the wire — the binding a chart
|
||||||
|
declares is what core must hand the consumer that voices the part."""
|
||||||
|
base, base_rig, changes = sloppak_tone_changes({
|
||||||
|
"base": "Clean Rhythm",
|
||||||
|
"base_rig": "clean-rhythm",
|
||||||
|
"changes": [
|
||||||
|
{"t": 12.5, "name": "Lead Drive", "rig": "lead-drive"},
|
||||||
|
{"t": 48.0, "name": "Clean Rhythm", "rig": "clean-rhythm"},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
assert base == "Clean Rhythm"
|
||||||
|
assert base_rig == "clean-rhythm"
|
||||||
|
assert changes == [
|
||||||
|
{"t": 12.5, "name": "Lead Drive", "rig": "lead-drive"},
|
||||||
|
{"t": 48.0, "name": "Clean Rhythm", "rig": "clean-rhythm"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_sloppak_tone_changes_omits_unusable_rig_ids():
|
||||||
|
"""A non-string or blank `rig` is dropped rather than forwarded, so a
|
||||||
|
consumer can treat presence of the key as "this change binds a rig"."""
|
||||||
|
_, base_rig, changes = sloppak_tone_changes({
|
||||||
|
"base_rig": " ",
|
||||||
|
"changes": [
|
||||||
|
{"t": 1.0, "name": "A", "rig": 7},
|
||||||
|
{"t": 2.0, "name": "B", "rig": ""},
|
||||||
|
{"t": 3.0, "name": "C", "rig": None},
|
||||||
|
{"t": 4.0, "name": "D", "rig": " padded-id "},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
assert base_rig == ""
|
||||||
|
assert changes == [
|
||||||
|
{"t": 1.0, "name": "A"},
|
||||||
|
{"t": 2.0, "name": "B"},
|
||||||
|
{"t": 3.0, "name": "C"},
|
||||||
|
{"t": 4.0, "name": "D", "rig": "padded-id"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_sloppak_tone_changes_non_string_base_rig():
|
||||||
|
"""A non-string `base_rig` must not crash or leak a non-id onto the wire."""
|
||||||
|
_, base_rig, _ = sloppak_tone_changes({"base": "Clean", "base_rig": 42})
|
||||||
|
assert base_rig == ""
|
||||||
|
|||||||
Reference in New Issue
Block a user