mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-18 22:42:25 +00:00
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:
co-authored by
Claude Fable 5
parent
0e3522ccc3
commit
1784765822
@@ -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))
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user