From 1784765822fc82a7e455c8bd036afa8035d446a9 Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou Date: Wed, 22 Jul 2026 12:00:05 -0500 Subject: [PATCH] feat(sloppak): project a synthetic Vocals arrangement from vocal_pitch A sloppak carrying a sung-melody sidecar (`vocal_pitch`, feedpak-spec 7.2) previously had no vocal arrangement identity: the melody was not selectable in the player, visualizers had nothing to auto-match, and - critically - a sung run had no honest place to record stats. Posting against the selected fretted arrangement index would corrupt that arrangement's best_accuracy/best_score and credit progression to the wrong instrument; posting an out-of-range index is rejected outright. Fix: project a synthetic chartless "Vocals" arrangement (type "vocals") wherever arrangements are enumerated, mirroring the drum-only placeholder contract: - load_song(): appended after the arrangement loop so the melody is selectable and `matchesArrangement` auto-select can key on it. The entry carries no notes - the melody itself stays in the sidecar, which vocal visualizers fetch directly. Melody-only paks (no fretted arrangements) now load, deriving song length from the last sung note's end when the manifest omits a duration; the sidecar read is path-confined and fully permissive (a malformed file only costs the duration fallback, never the load). - extract_meta(): the metadata mirror, so the library index and the /api/stats arrangement-count validation agree with the player's arrangement list. Both sides suppress the projection when the pak already ships an explicit vocal arrangement (editor-authored `type`, or a vocal-ish name); a chartless vocal-NAMED manifest entry suppresses only the metadata side (it is already counted there) while load_song projects its playable counterpart, keeping the counts in step. instrument_for_arrangement() already maps the name to "vocals", so career/progression attribution works with no further changes. Tests: 12 new (projection both sides, dedupe by type and by name, melody-only duration fallback, malformed/escaping sidecar permissive- ness, count parity load_song vs extract_meta, instrument attribution); sloppak/stats/progression/arrangement/drum slice green (459 passed). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EJ6BDHb3K4ZHUDDAHMtKCV Signed-off-by: ChrisBeWithYou --- lib/sloppak.py | 95 ++++++++++ tests/test_sloppak_vocals_arrangement.py | 219 +++++++++++++++++++++++ 2 files changed, 314 insertions(+) create mode 100644 tests/test_sloppak_vocals_arrangement.py diff --git a/lib/sloppak.py b/lib/sloppak.py index 1081faac..760f8187 100644 --- a/lib/sloppak.py +++ b/lib/sloppak.py @@ -851,6 +851,40 @@ def _resolve_drum_parts( return drum_tab_data, parts +_VOCALISH_NAMES = ("vocal", "voice", "karaoke") + + +def _has_vocal_arrangement(song: Song) -> bool: + """True when the loader already produced an explicitly vocal arrangement + (by editor-authored ``type`` or by name), so the vocals projection must + not add a duplicate.""" + for arr in song.arrangements: + if str(getattr(arr, "type", "") or "").strip().lower() in ("vocals", "vocal"): + return True + name = str(arr.name or "").lower() + if any(tok in name for tok in _VOCALISH_NAMES): + return True + return False + + +def _load_vocal_pitch_for_duration(source_dir: Path, rel: str) -> dict | None: + """Path-confined, fully permissive read of the ``vocal_pitch`` sidecar — + used only for the melody-only duration fallback. Any failure returns + None: a malformed sidecar costs the fallback, never the load.""" + try: + vp_path = (source_dir / rel).resolve() + vp_path.relative_to(source_dir.resolve()) + except (ValueError, OSError): + return None + if not vp_path.exists(): + return None + try: + data = load_json(vp_path) + except Exception: + return None + return data if isinstance(data, dict) else None + + def load_song( filename: str, dlc_root: Path, @@ -1052,6 +1086,43 @@ def load_song( song.arrangements.append(Arrangement(name="Drums")) arrangement_ids_acc.append(None) + # Vocals-arrangement projection: a sloppak that carries a sung-melody + # sidecar (`vocal_pitch`, feedpak-spec §7.2) gets a synthetic chartless + # "Vocals" arrangement so the melody is selectable in the player, vocal + # visualizers can auto-match it, and sung runs land in their own honest + # `song_stats` bucket instead of being posted against a fretted + # arrangement's records (`instrument_for_arrangement` already maps the + # name to "vocals"). The synthetic entry carries no notes — the melody + # itself stays in the sidecar, which vocal visualizers fetch directly. + # Skipped when the loader already produced an explicitly vocal + # arrangement (a chartless vocal-NAMED manifest entry was dropped by the + # loop above, so it does not suppress the projection — the projection is + # what makes that authored intent playable). + vocal_pitch_rel = manifest.get("vocal_pitch") + if isinstance(vocal_pitch_rel, str) and vocal_pitch_rel.strip(): + if not _has_vocal_arrangement(song): + if song.song_length <= 0: + # Duration fallback for melody-only paks (same contract as the + # drum-only placeholder): last sung note's end + tail. The + # sidecar read is path-confined and fully permissive — a + # malformed file only costs the fallback, never the load. + _vp = _load_vocal_pitch_for_duration(source_dir, vocal_pitch_rel.strip()) + _max_t = 0.0 + for _n in (_vp.get("notes") if isinstance(_vp, dict) else None) or []: + if not isinstance(_n, dict): + continue + try: + _max_t = max( + _max_t, + float(_n.get("t", 0) or 0) + float(_n.get("d", 0) or 0), + ) + except (TypeError, ValueError): + continue + if _max_t > 0: + song.song_length = _max_t + 2.0 + song.arrangements.append(Arrangement(name="Vocals", type="vocals")) + arrangement_ids_acc.append(None) + # Optional song_timeline.json — top-level manifest key per sloppak-spec §5.3. # When present, its beats and sections override whatever the arrangement JSONs # already loaded onto the song object — song_timeline is the authoritative @@ -1413,6 +1484,30 @@ def extract_meta(path: Path) -> dict: "notes": 0, # unknown without loading; fine for the index } ) + # Vocals-arrangement projection — the metadata mirror of load_song()'s. + # A `vocal_pitch` sidecar (feedpak-spec §7.2) projects a synthetic + # "Vocals" entry so the library and the `song_stats` arrangement-count + # validation agree with the player's arrangement list; without it a + # sung run would be rejected (index out of range) or, worse, recorded + # against a fretted arrangement. Suppressed when the manifest already + # declares a vocal-ish entry (even a chartless one — that entry is + # counted by the loop above, and load_song projects its playable + # counterpart, so the counts stay in step). + _vp_rel = manifest.get("vocal_pitch") + if isinstance(_vp_rel, str) and _vp_rel.strip(): + _has_vocalish = False + for entry in arr_list: + if not isinstance(entry, dict): + continue + _ename = str(entry.get("name", entry.get("id", "")) or "").lower() + _etype = str(entry.get("type") or "").strip().lower() + if _etype in ("vocals", "vocal") or any(t in _ename for t in _VOCALISH_NAMES): + _has_vocalish = True + break + if not _has_vocalish: + arrangements.append( + {"index": len(arrangements), "name": "Vocals", "notes": 0} + ) # Sort like archive path: Lead > Combo > Rhythm > Bass priority = {"Lead": 0, "Combo": 1, "Rhythm": 2, "Bass": 3} arrangements.sort(key=lambda a: priority.get(a["name"], 99)) diff --git a/tests/test_sloppak_vocals_arrangement.py b/tests/test_sloppak_vocals_arrangement.py new file mode 100644 index 00000000..98fdf2ad --- /dev/null +++ b/tests/test_sloppak_vocals_arrangement.py @@ -0,0 +1,219 @@ +"""Vocals-arrangement projection: a sloppak carrying a sung-melody sidecar +(`vocal_pitch`, feedpak-spec §7.2) projects a synthetic chartless "Vocals" +arrangement in BOTH `load_song()` (player selectability, visualizer +auto-match) and `extract_meta()` (library index + the `song_stats` +arrangement-count validation), so sung runs get their own honest stats +bucket instead of being posted against a fretted arrangement.""" + +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, + vocal_pitch_payload: dict | None = None, + include_lead: bool = True, +) -> Path: + """Minimal directory-form sloppak; unique name per test (cache safety).""" + pak = root / f"{root.name}.sloppak" + pak.mkdir() + arr_dir = pak / "arrangements" + arr_dir.mkdir() + + arrangements = [] + if include_lead: + 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)) + arrangements.append( + {"id": "lead", "name": "Lead", "file": "arrangements/lead.json"} + ) + + manifest = { + "title": "Test", + "artist": "Tester", + "album": "", + "year": 2026, + "duration": 10.0, + "arrangements": arrangements, + "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 vocal_pitch_payload is not None: + (pak / "vocal_pitch.json").write_text(json.dumps(vocal_pitch_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) + + +_PITCH = {"version": 1, "notes": [{"t": 1.0, "d": 0.5, "midi": 62}, + {"t": 8.0, "d": 2.0, "midi": 69}]} + + +# ── load_song projection ───────────────────────────────────────────────────── + +def test_vocal_pitch_projects_synthetic_vocals_arrangement(tmp_path: Path): + pak = _write_dir_sloppak(tmp_path, {"vocal_pitch": "vocal_pitch.json"}, _PITCH) + loaded = _load(pak, tmp_path) + names = [a.name for a in loaded.song.arrangements] + assert names == ["Lead", "Vocals"] + vox = loaded.song.arrangements[-1] + assert vox.type == "vocals" + assert vox.notes == [] # chartless — the melody stays in the sidecar + + +def test_no_projection_without_vocal_pitch_key(tmp_path: Path): + pak = _write_dir_sloppak(tmp_path, {}, None) + loaded = _load(pak, tmp_path) + assert [a.name for a in loaded.song.arrangements] == ["Lead"] + + +def test_no_projection_when_sidecar_missing_is_still_projected(tmp_path: Path): + # The manifest key is the contract (matching every sidecar consumer): a + # declared-but-absent file still projects, and the visualizer's own + # status endpoint reports the chart missing. The load must not crash. + pak = _write_dir_sloppak(tmp_path, {"vocal_pitch": "vocal_pitch.json"}, None) + loaded = _load(pak, tmp_path) + assert [a.name for a in loaded.song.arrangements] == ["Lead", "Vocals"] + + +def test_no_duplicate_when_explicit_vocal_arrangement_loaded(tmp_path: Path): + # An author-shipped vocal arrangement WITH a chart file suppresses the + # projection (both by type and by name). + pak = _write_dir_sloppak(tmp_path, {"vocal_pitch": "vocal_pitch.json"}, _PITCH) + arr = { + "name": "Lead Vocals", + "tuning": [0, 0, 0, 0, 0, 0], + "capo": 0, + "notes": [], "chords": [], "anchors": [], + "handshapes": [], "templates": [], "beats": [], "sections": [], + } + (pak / "arrangements" / "vox.json").write_text(json.dumps(arr)) + manifest = yaml.safe_load((pak / "manifest.yaml").read_text()) + manifest["arrangements"].append( + {"id": "vox", "name": "Lead Vocals", "file": "arrangements/vox.json"} + ) + (pak / "manifest.yaml").write_text(yaml.safe_dump(manifest, sort_keys=False)) + loaded = _load(pak, tmp_path) + names = [a.name for a in loaded.song.arrangements] + assert names == ["Lead", "Lead Vocals"] # no synthetic duplicate + + +def test_melody_only_pak_loads_with_duration_fallback(tmp_path: Path): + # A pak with ONLY a vocal_pitch sidecar (no fretted arrangements) must + # still load — and derive its length from the last sung note, mirroring + # the drum-only placeholder contract. + pak = _write_dir_sloppak( + tmp_path, + {"vocal_pitch": "vocal_pitch.json", "duration": 0}, + _PITCH, + include_lead=False, + ) + loaded = _load(pak, tmp_path) + assert [a.name for a in loaded.song.arrangements] == ["Vocals"] + assert loaded.song.song_length == 12.0 # 8.0 + 2.0 sustain + 2.0 tail + + +def test_melody_only_pak_with_malformed_sidecar_still_loads(tmp_path: Path): + pak = _write_dir_sloppak( + tmp_path, + {"vocal_pitch": "vocal_pitch.json", "duration": 0}, + None, + include_lead=False, + ) + (pak / "vocal_pitch.json").write_text("not json {{{") + loaded = _load(pak, tmp_path) + # Projection still happens; only the duration fallback is lost. + assert [a.name for a in loaded.song.arrangements] == ["Vocals"] + assert loaded.song.song_length == 0.0 + + +def test_sidecar_path_escape_only_costs_the_duration_fallback(tmp_path: Path): + pak = _write_dir_sloppak( + tmp_path, + {"vocal_pitch": "../outside.json", "duration": 0}, + None, + include_lead=False, + ) + (tmp_path / "outside.json").write_text(json.dumps(_PITCH)) + loaded = _load(pak, tmp_path) + assert [a.name for a in loaded.song.arrangements] == ["Vocals"] + assert loaded.song.song_length == 0.0 # confined read refused the escape + + +# ── extract_meta projection (the stats-bucket mirror) ──────────────────────── + +def test_extract_meta_projects_vocals_entry(tmp_path: Path): + pak = _write_dir_sloppak(tmp_path, {"vocal_pitch": "vocal_pitch.json"}, _PITCH) + meta = sloppak_mod.extract_meta(pak) + names = [a["name"] for a in meta["arrangements"]] + assert names == ["Lead", "Vocals"] + # indexes must be contiguous after the priority re-sort + assert [a["index"] for a in meta["arrangements"]] == [0, 1] + + +def test_extract_meta_count_matches_load_song(tmp_path: Path): + # The whole point: `song_stats` validates the posted arrangement index + # against extract_meta's count — it must agree with the player's list. + pak = _write_dir_sloppak(tmp_path, {"vocal_pitch": "vocal_pitch.json"}, _PITCH) + meta = sloppak_mod.extract_meta(pak) + loaded = _load(pak, tmp_path) + assert len(meta["arrangements"]) == len(loaded.song.arrangements) + + +def test_extract_meta_suppressed_by_declared_vocalish_entry(tmp_path: Path): + # A manifest-declared vocal-ish entry (even chartless) is already counted + # by extract_meta's loop; the projection must not double it. load_song + # drops the chartless entry and projects — so the counts stay in step. + pak = _write_dir_sloppak(tmp_path, {"vocal_pitch": "vocal_pitch.json"}, _PITCH) + manifest = yaml.safe_load((pak / "manifest.yaml").read_text()) + manifest["arrangements"].append({"id": "vox", "name": "Vocal Melody"}) + (pak / "manifest.yaml").write_text(yaml.safe_dump(manifest, sort_keys=False)) + meta = sloppak_mod.extract_meta(pak) + names = [a["name"] for a in meta["arrangements"]] + assert names.count("Vocals") == 0 # no synthetic duplicate + assert "Vocal Melody" in names + loaded = _load(pak, tmp_path) + assert len(meta["arrangements"]) == len(loaded.song.arrangements) + + +def test_extract_meta_no_projection_without_key(tmp_path: Path): + pak = _write_dir_sloppak(tmp_path, {}, None) + meta = sloppak_mod.extract_meta(pak) + assert [a["name"] for a in meta["arrangements"]] == ["Lead"] + + +# ── downstream instrument attribution ──────────────────────────────────────── + +def test_projected_entry_maps_to_vocals_instrument(tmp_path: Path): + from progression import instrument_for_arrangement + + pak = _write_dir_sloppak(tmp_path, {"vocal_pitch": "vocal_pitch.json"}, _PITCH) + meta = sloppak_mod.extract_meta(pak) + vox_entry = next(a for a in meta["arrangements"] if a["name"] == "Vocals") + assert instrument_for_arrangement(vox_entry) == "vocals"