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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EJ6BDHb3K4ZHUDDAHMtKCV
Signed-off-by: ChrisBeWithYou <chris@rifflarr.local>
This commit is contained in:
ChrisBeWithYou
2026-07-22 12:00:05 -05:00
co-authored by Claude Fable 5
parent 0e3522ccc3
commit 1784765822
2 changed files with 314 additions and 0 deletions
+219
View File
@@ -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"