Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions lib/sloppak.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down
219 changes: 219 additions & 0 deletions tests/test_sloppak_vocals_arrangement.py
Original file line number Diff line number Diff line change
@@ -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"
Loading