Merge pull request #533 from got-feedback/feat/feedpak-jsonc

feat(core): read .jsonc data files (strip C-style comments) (feedpak-spec §8)
This commit is contained in:
K. O. A.
2026-07-01 03:35:09 -04:00
committed by GitHub
5 changed files with 407 additions and 12 deletions
+1
View File
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **`.jsonc` support for feedpak data files** (feedpak-spec §8, FEP #3 / PR #13). Hand-edited packs may now use the `.jsonc` extension (JSON with C-style `//` line and `/* */` block comments) for any data file the manifest points at — arrangements, notation sidecars, `drum_tab`, `song_timeline`, `lyrics`, and `keys`. New shared `lib/jsonc.py` provides `parse_jsonc(text)` + `load_json(path)` (auto-detects `.jsonc` by suffix, string-aware so comment-like text inside JSON string values is preserved) and is now used by every reader in `lib/sloppak.py` (six side-file sites) and `scripts/lift_keys_notation.py` (three arrangement / song_timeline read sites). The strip regex mirrors the reference validator in `feedpak-spec/tools/validate.py`. This is an additive (MINOR) change: `.jsonc` is opt-in, so any pack that keeps its data files as `.json` is unaffected and needs no regeneration. Note that a `.jsonc` file containing real comments only loads on a reader that implements §8 — a pre-this-change reader calls bare `json.loads` and fails on the comments rather than ignoring them, so don't hand out `.jsonc` packs to older hosts. Tests: `tests/test_sloppak_jsonc_load.py` (covers all six side-file types, the lift helper, and the string-boundary preservation rule end-to-end).
- **The highway now loads the part that matches your selected instrument — a bass player gets the Bass arrangement, not the default Lead/guitar chart.** When you open a song without an explicit arrangement, the WebSocket handler (`server.py` `highway_ws`) reads your selected `instrument` from `config.json` (the same file it already reads for your default-arrangement preference) and routes to the matching part: **bass → the Bass arrangement**; guitar — and any unknown/future instrument (drums, keys) — falls through to the existing preference/most-notes default, which already lands on a guitar part. Previously the instrument selector only fed the tuner, so a bass player was handed a guitar chart (and a tune/coverage check then compared a 4-string bass against a 6-string part). An **explicit arrangement request always wins** (a manual arrangement switch is untouched), and a bass player's saved default-arrangement preference is still honored **within** the bass parts (so a preferred `Bass 2` / `Alt. Bass` wins over the canonical Bass), so this only changes the *default* part chosen on load. Server-only — every launch path already flows through the WS, so there's no client change. This is the instrument↔chart-routing piece the working-tuning series leans on (otherwise coverage compares across instruments). Tests: `tests/test_highway_ws_instrument_routing.py` (bass→Bass, bass-honors-pref, bass-no-bass-part→guitar, guitar→default, explicit-wins).
- **Host "working tuning" — a live, app-wide record of what tuning your instrument is *actually* in right now (foundation; no behavior change yet).** Introduces `window.feedBack.workingTuning`, a host-owned, session-lived state distinct from any one song's tuning and from a soft opt-in default: the offsets + string-count + reference pitch the player's instrument is currently in, plus an `assumed`/`verified` provenance flag. It's **per-instrument** — your guitar's current tuning and your bass's are kept *separately* (keyed like the instrument selector, e.g. `guitar-6` / `bass-4`), so switching instruments surfaces that instrument's own remembered tuning and you only ever deal with the one you've selected. It exists so a retune — or an instrument swap mid-session — is reflected **everywhere** (the highway, the library/song-picker, and plugins like the tuner, Virtuoso, and the minigames) instead of being re-derived per surface or wrongly assumed from a fixed profile. Modeled on the shipped `tuning` capability + the `feedBack.theme` read-API: a **synchronous `get(instrument?)`** (returns the selected instrument's state, defaulting to the seed until known), a `set(state, {provenance, instrument})` mutator (the tuner becomes the sole writer in the next change), `setCurrentInstrument()` for the selector, `resetToDefault()`, and a `working-tuning-changed` event that fires on every change **and once on hydration** (carrying which instrument changed) so a late-mounting consumer is never stuck on stale state. State is **in-memory, seeded from `/api/settings` on boot and reset on restart** — a stale "you're in drop-A" assumption is worse than re-asking. Registered as a separate `working-tuning` **exclusive-owner** capability (tuner = writer, the rest = requesters). This is the foundation (plumbing only — nothing writes to it yet) of the working-tuning series, which fixes the tuner gate only ever prompting *away from* a fixed "home" tuning (never back) and makes the current tuning a first-class signal the whole app shares. Offsets use the same per-string semitone vocabulary as song tunings, so fully custom/extended tunings (e.g. a drop-A 8-string) are first-class. Frontend-only: new `static/capabilities/working-tuning.js`, loaded from `static/index.html` + `static/v3/index.html`.
- **The v3 Songs grid is now DOM-virtualized — card-node count stays bounded no matter how big the library is or how far you scroll.** The grid used to append every scrolled page and never let go, so a 2000-song library grew the DOM from 24 → 624 → 2001 card nodes as you scrolled (layout/memory cost scaling with depth). It now renders only the **visible window** of cards (± a small overscan); a sizer element sized to the whole library (`ceil(total/cols) × rowH`) gives the scrollbar its full geometry while the grid is absolutely positioned to the first visible row. `state.songs` is a sparse, absolutely-indexed store fetched a page at a time on demand — using the stage-1 **keyset cursor** for contiguous forward scroll (O(page)) and falling back to `OFFSET page=` for jumps/restore/non-keyset providers (collections, remote). Verified bounded (~60 nodes for a 2001-song library while the count still reads "2001 songs"). The **AZ rail now seeks directly**: `sort_letters` gives a letter's first-row index (cumulative of prior buckets), converted to a scrollTop in O(1) — no more paging through every intervening row (a bounded forward scan covers the rare legacy provider without `sort_letters`). Select-mode selections, accuracy badges, the ⋮ card menu, plugin card actions, scroll-restore (now scrollTop-based, since geometry is stable), and the tree/folder views all survive cards leaving and re-entering the DOM. Plugins that decorate cards get a stable `window.v3Songs.visibleCards()` accessor + a `v3:library-window-rendered` event instead of assuming every card is present (the highway-stutter lesson). Stage 2 of the virtualized-grid project (got-feedback/feedBack#636 item 3), building on the stage-1 keyset data layer below. Frontend-only: `static/v3/songs.js`, `static/v3/v3.css`. Tests: `tests/browser/v3-grid-virtualization.spec.ts` (bounded-DOM invariant across a 2001-song scroll + direct rail jump), updated `tests/js/v3_az_rail.test.js` + `tests/js/v3_songs_scroll.test.js`.
+56
View File
@@ -0,0 +1,56 @@
"""JSONC support — JSON with C-style comments.
Per feedpak-spec §8: when a manifest pointer resolves to a ``.jsonc`` file, a
Reader MUST strip ``//`` line comments and ``/* */`` block comments before
parsing the JSON content. This module implements that stripping in a single
shared place so every sloppak/feedpak reader in this repo parses ``.jsonc``
the same way (string-aware so comment-like text inside JSON strings survives).
The regex mirrors the reference implementation in ``feedpak-spec/tools/validate.py``.
``load_json(path)`` auto-detects ``.jsonc`` by suffix; plain ``.json`` (and any
other extension) goes straight through ``json.loads``. Use it as a drop-in
replacement for ``json.loads(path.read_text(encoding="utf-8"))``.
"""
from __future__ import annotations
import json
import re
from pathlib import Path
# Match JSON string literals (preserved), // line comments, and /* block */
# comments. A single combined alternation processed by `sub` with a callback
# that keeps strings and replaces comments with the empty string — so
# comment-like text inside a string literal is never stripped.
_JSONC_STRIP_RE = re.compile(
r'"(?:[^"\\]|\\.)*"|' # string literal — keep as-is
r'//.*|' # // line comment — strip
r'/\*[\s\S]*?\*/', # /* block comment */ — strip
)
def parse_jsonc(text: str) -> object:
"""Parse a JSONC string, stripping C-style comments before JSON parsing.
Handles ``//`` line comments and ``/* */`` block comments, respecting
string boundaries so that comment-like text inside strings is preserved.
Raises ``json.JSONDecodeError`` on malformed JSON (after stripping).
"""
stripped = _JSONC_STRIP_RE.sub(
lambda m: m.group(0) if m.group(0).startswith('"') else '',
text,
)
return json.loads(stripped)
def load_json(path: Path) -> object:
"""Read and parse a JSON/JSONC file by path.
Files ending in ``.jsonc`` are stripped of comments via :func:`parse_jsonc`;
all other files are parsed as plain JSON. UTF-8 encoded, matching every
other reader in this repo.
"""
raw = path.read_text(encoding="utf-8")
if path.name.lower().endswith(".jsonc"):
return parse_jsonc(raw)
return json.loads(raw)
+7 -7
View File
@@ -13,7 +13,6 @@ See the format spec in the project's sloppak plan for the full layout.
from __future__ import annotations
import json
import logging
import math
import shutil
@@ -37,6 +36,7 @@ SONG_EXTS = (FEEDPAK_EXT, SLOPPAK_EXT) # accepted on read/discovery
import yaml
from jsonc import load_json
from safepath import safe_join
from song import (
Song,
@@ -423,7 +423,7 @@ def load_song(
if not arr_path.exists():
continue
try:
data = json.loads(arr_path.read_text(encoding="utf-8"))
data = load_json(arr_path)
except Exception as e:
log.debug("sloppak: failed to parse arrangement %r: %s", rel, e)
continue
@@ -489,7 +489,7 @@ def load_song(
raw_nt = None
if nt_path is not None and nt_path.exists():
try:
raw_nt = json.loads(nt_path.read_text(encoding="utf-8"))
raw_nt = load_json(nt_path)
except Exception as e:
log.warning("sloppak: failed to parse notation %r: %s", notation_rel, e)
if raw_nt is not None:
@@ -528,7 +528,7 @@ def load_song(
dt_path = None
if dt_path is not None and dt_path.exists():
try:
raw = json.loads(dt_path.read_text(encoding="utf-8"))
raw = load_json(dt_path)
except Exception as e:
log.warning("sloppak: failed to parse drum_tab %r: %s", drum_tab_rel, e)
raw = None
@@ -588,7 +588,7 @@ def load_song(
st_path = None
if st_path is not None and st_path.exists():
try:
raw = json.loads(st_path.read_text(encoding="utf-8"))
raw = load_json(st_path)
except Exception as e:
log.warning("sloppak: failed to parse song_timeline %r: %s", song_timeline_rel, e)
raw = None
@@ -686,7 +686,7 @@ def load_song(
lyr_path = None
if lyr_path is not None and lyr_path.exists():
try:
raw = json.loads(lyr_path.read_text(encoding="utf-8"))
raw = load_json(lyr_path)
except Exception as e:
log.debug("sloppak: failed to parse lyrics %r: %s", lyrics_rel, e)
raw = None
@@ -773,7 +773,7 @@ def load_song(
k_path = None
if k_path is not None and k_path.exists():
try:
raw = json.loads(k_path.read_text(encoding="utf-8"))
raw = load_json(k_path)
except Exception as e:
log.warning("sloppak: failed to parse keys %r: %s", keys_rel, e)
raw = None
+8 -5
View File
@@ -60,6 +60,8 @@ import yaml # noqa: E402
import notation as notation_mod # noqa: E402, F401 (re-exported for tests)
from jsonc import load_json # noqa: E402
# The wire→notation heuristic core lives in ``lib/notation_lift.py`` so it can
# be reused in-process (e.g. by the Arrangement Editor's notation save path)
# rather than being copy-pasted out of this one-time CLI. Re-exported here so
@@ -106,15 +108,16 @@ def _parse_time_signature(raw: object) -> tuple[int, int]:
def _load_song_beats(pak: Path, manifest: dict) -> list[dict]:
"""Song-level beats: ``song_timeline.json`` when present, else the first
"""Song-level beats: the ``song_timeline`` file when present, else the first
arrangement JSON that carries a non-empty ``beats`` array (the loader's
legacy convention)."""
legacy convention). Both the timeline and arrangement files are read via
``load_json``, so either may be ``.json`` or ``.jsonc``."""
st_rel = manifest.get("song_timeline")
if isinstance(st_rel, str) and st_rel:
st_path = _safe_child(pak, st_rel)
if st_path is not None and st_path.is_file():
try:
data = json.loads(st_path.read_text(encoding="utf-8"))
data = load_json(st_path)
# An empty beats list is not an authoritative timeline — fall
# through to the arrangement JSONs rather than ending up with
# zero downbeats and skipping the whole sloppak.
@@ -134,7 +137,7 @@ def _load_song_beats(pak: Path, manifest: dict) -> list[dict]:
if arr_path is None or not arr_path.is_file():
continue
try:
data = json.loads(arr_path.read_text(encoding="utf-8"))
data = load_json(arr_path)
except (OSError, ValueError):
continue
if isinstance(data, dict):
@@ -219,7 +222,7 @@ def lift_sloppak(pak: Path, *, dry_run: bool = False) -> list[str]:
continue
try:
arr_data = json.loads(arr_path.read_text(encoding="utf-8"))
arr_data = load_json(arr_path)
except (OSError, ValueError) as e:
log.warning("%s/%s: unreadable arrangement JSON (%s) — skipped",
pak.name, arr_id, e)
+335
View File
@@ -0,0 +1,335 @@
"""Tests for .jsonc support in the sloppak/feedpak loaders (feedpak-spec §8).
When a manifest pointer resolves to a ``.jsonc`` file, the reader MUST strip
C-style comments (``//`` line and ``/* */`` block) before parsing. These tests
exercise every side-file reader in ``lib/sloppak.py`` (arrangement, notation,
drum_tab, song_timeline, lyrics, keys) plus the arrangement/song_timeline
reads in ``scripts/lift_keys_notation.py`` against ``.jsonc`` inputs, and pin
the string-boundary rule (comment-like text inside a JSON string survives).
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
import yaml
import sloppak as sloppak_mod
from jsonc import parse_jsonc
from scripts.lift_keys_notation import _load_song_beats
# ── jsonc.parse_jsonc unit tests ─────────────────────────────────────────────
def test_parse_jsonc_strips_line_comment():
assert parse_jsonc('{"a": 1 // c\n}') == {"a": 1}
def test_parse_jsonc_strips_block_comment():
assert parse_jsonc('{"a": /* x */ 2}') == {"a": 2}
def test_parse_jsonc_strips_multiline_block_comment():
text = '{\n /* multi\n line */\n "a": 1\n}'
assert parse_jsonc(text) == {"a": 1}
def test_parse_jsonc_preserves_comment_like_text_in_strings():
# ``//`` and ``/*`` inside a JSON string literal must NOT be treated as
# comments — the string-aware regex keeps them verbatim.
text = '{"url": "https://x/y", "note": "// not a comment /* still not */"}'
out = parse_jsonc(text)
assert out["url"] == "https://x/y"
assert out["note"] == "// not a comment /* still not */"
def test_parse_jsonc_rejects_malformed():
with pytest.raises(json.JSONDecodeError):
parse_jsonc('{"a": // comment breaks value\n}')
def test_parse_jsonc_plain_json_passes_through():
assert parse_jsonc('{"a": 1}') == {"a": 1}
# ── Fixture builder ──────────────────────────────────────────────────────────
def _base_arrangement(*, beats=None) -> dict:
return {
"name": "Lead", "tuning": [0, 0, 0, 0, 0, 0], "capo": 0,
"notes": [], "chords": [], "anchors": [], "handshapes": [],
"templates": [], "beats": beats or [], "sections": [],
}
def _write_sloppak(root: Path, *, manifest_extras: dict, side_files: dict) -> Path:
"""Build a minimal directory-form sloppak.
``side_files`` maps a pak-relative filename (e.g. ``"arrangements/lead.jsonc"``)
to the exact text to write — so the caller controls comments / extensions.
"""
pak = root / f"{root.name}.sloppak"
pak.mkdir()
(pak / "arrangements").mkdir()
# A default lead arrangement file the manifest references; can be overridden
# via side_files if the caller wants a .jsonc arrangement.
if "arrangements/lead.json" not in side_files and "arrangements/lead.jsonc" not in side_files:
(pak / "arrangements" / "lead.json").write_text(
json.dumps(_base_arrangement())
)
for rel, text in side_files.items():
target = pak / rel
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(text, encoding="utf-8")
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))
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)
# ── Arrangement .jsonc ───────────────────────────────────────────────────────
def test_load_arrangement_jsonc_with_comments(tmp_path: Path):
arr_text = (
'// lead chart\n'
'{\n'
' "name": "Lead",\n'
' "tuning": [0, 0, 0, 0, 0, 0],\n'
' "capo": 0,\n'
' /* no notes yet */\n'
' "notes": [{"t": 0.5, "s": 0, "f": 5, "sus": 0}],\n'
' "chords": [], "anchors": [], "handshapes": [], "templates": [],\n'
' "beats": [], "sections": []\n'
'}'
)
pak = _write_sloppak(
tmp_path,
manifest_extras={
"arrangements": [{"id": "lead", "name": "Lead",
"file": "arrangements/lead.jsonc"}]
},
side_files={"arrangements/lead.jsonc": arr_text},
)
loaded = _load(pak, tmp_path)
assert len(loaded.song.arrangements) == 1
arr = loaded.song.arrangements[0]
assert arr.name == "Lead"
assert len(arr.notes) == 1
assert arr.notes[0].fret == 5
def test_load_arrangement_jsonc_malformed_is_skipped(tmp_path: Path):
# A .jsonc file that is still invalid after stripping comments must be
# skipped gracefully (the loader's existing permissive path), not crash.
pak = _write_sloppak(
tmp_path,
manifest_extras={
"arrangements": [{"id": "lead", "name": "Lead",
"file": "arrangements/lead.jsonc"}]
},
side_files={"arrangements/lead.jsonc": '{"notes": // broken\n}'},
)
loaded = _load(pak, tmp_path)
# The malformed arrangement is skipped; load still returns a song shell.
assert len(loaded.song.arrangements) == 0
# ── Notation .jsonc ──────────────────────────────────────────────────────────
def test_load_notation_jsonc_with_comments(tmp_path: Path):
from tests.test_sloppak_notation_load import VALID_NOTATION
payload = VALID_NOTATION
text = (
'/* notation for keys */\n'
+ json.dumps(payload)
)
pak = _write_sloppak(
tmp_path,
manifest_extras={
"arrangements": [{"id": "lead", "name": "Lead",
"file": "arrangements/lead.json",
"notation": "notation_lead.jsonc"}]
},
side_files={"notation_lead.jsonc": text},
)
loaded = _load(pak, tmp_path)
assert loaded.notation_by_id is not None
assert "lead" in loaded.notation_by_id
# ── drum_tab .jsonc ──────────────────────────────────────────────────────────
def test_load_drum_tab_jsonc_with_comments(tmp_path: Path):
payload = {
"version": 1, "name": "Drums",
"kit": [{"id": "kick", "name": "Kick"}],
"hits": [{"t": 0.5, "p": "kick", "v": 110}],
}
text = '// drum tab\n' + json.dumps(payload)
pak = _write_sloppak(
tmp_path,
manifest_extras={"drum_tab": "drum_tab.jsonc"},
side_files={"drum_tab.jsonc": text},
)
loaded = _load(pak, tmp_path)
assert loaded.drum_tab is not None
assert loaded.drum_tab["hits"][0]["p"] == "kick"
# ── song_timeline .jsonc ─────────────────────────────────────────────────────
def test_load_song_timeline_jsonc_with_comments(tmp_path: Path):
payload = {
"beats": [{"time": 0.0, "measure": 0}, {"time": 0.5, "measure": 0}],
"sections": [{"name": "intro", "number": 0, "time": 0.0}],
}
text = '/* timeline */\n' + json.dumps(payload)
pak = _write_sloppak(
tmp_path,
manifest_extras={"song_timeline": "song_timeline.jsonc"},
side_files={"song_timeline.jsonc": text},
)
loaded = _load(pak, tmp_path)
assert loaded.song_timeline is not None
assert len(loaded.song.beats) == 2
assert loaded.song.sections[0].name == "intro"
# ── lyrics .jsonc ────────────────────────────────────────────────────────────
def test_load_lyrics_jsonc_with_comments(tmp_path: Path):
payload = [
{"w": "Hel", "t": 0.0, "d": 0.2},
{"w": "lo", "t": 0.2, "d": 0.3},
]
text = '// syllable lyrics\n' + json.dumps(payload)
pak = _write_sloppak(
tmp_path,
manifest_extras={"lyrics": "lyrics.jsonc"},
side_files={"lyrics.jsonc": text},
)
loaded = _load(pak, tmp_path)
assert len(loaded.song.lyrics) == 2
assert loaded.song.lyrics[0]["w"] == "Hel"
# ── keys .jsonc ──────────────────────────────────────────────────────────────
def test_load_keys_jsonc_with_comments(tmp_path: Path):
payload = {
"version": 1,
"events": [{"t": 0.0, "key": "Em", "scale": "natural_minor"}],
}
text = '/* key/scale track */\n' + json.dumps(payload)
pak = _write_sloppak(
tmp_path,
manifest_extras={"keys": "keys.jsonc"},
side_files={"keys.jsonc": text},
)
loaded = _load(pak, tmp_path)
assert loaded.keys is not None
assert loaded.keys["events"][0]["key"] == "Em"
# ── Comment-like text inside strings is preserved end-to-end ─────────────────
def test_load_arrangement_jsonc_preserves_comment_like_string_values(tmp_path: Path):
# A note whose fret label (if it had one) or a string field contains ``//``
# must survive the strip. We put a ``//`` inside the arrangement name and a
# ``/* */`` inside a beat section name to exercise both comment shapes.
arr_text = (
'{\n'
' "name": "Lead // solo",\n'
' "tuning": [0, 0, 0, 0, 0, 0], "capo": 0,\n'
' "notes": [], "chords": [], "anchors": [], "handshapes": [], "templates": [],\n'
' "beats": [],\n'
' "sections": [{"name": "verse /* important */", "number": 0, "time": 0.0}]\n'
'}'
)
pak = _write_sloppak(
tmp_path,
manifest_extras={
"arrangements": [{"id": "lead", "name": "Lead",
"file": "arrangements/lead.jsonc"}]
},
side_files={"arrangements/lead.jsonc": arr_text},
)
loaded = _load(pak, tmp_path)
arr = loaded.song.arrangements[0]
# The manifest's ``name: Lead`` overrides the arrangement JSON's name, so
# assert on the section name instead — it flows straight from the parsed
# .jsonc with no manifest override, proving the comment-like text inside
# the string survived the strip.
assert arr.name == "Lead"
assert loaded.song.sections[0].name == "verse /* important */"
# ── lift_keys_notation reads .jsonc arrangements / song_timeline ─────────────
def test_lift_keys_notation_loads_song_beats_from_jsonc_timeline(tmp_path: Path):
"""``_load_song_beats`` (the helper the lifter uses to find downbeats) must
read a ``.jsonc`` song_timeline when the manifest points at one."""
timeline_text = (
'// timeline\n'
+ json.dumps({
"beats": [{"time": 0.0, "measure": 0}, {"time": 0.5, "measure": 0},
{"time": 1.0, "measure": 1}],
"sections": [],
})
)
pak = tmp_path / "song.sloppak"
pak.mkdir()
(pak / "arrangements").mkdir()
(pak / "arrangements" / "keys.json").write_text(json.dumps(_base_arrangement()))
(pak / "song_timeline.jsonc").write_text(timeline_text, encoding="utf-8")
manifest = {
"title": "T", "artist": "A", "duration": 4.0,
"arrangements": [{"id": "keys", "name": "Keys",
"file": "arrangements/keys.json"}],
"song_timeline": "song_timeline.jsonc",
"stems": [{"id": "full", "file": "stems/full.ogg"}],
}
(pak / "manifest.yaml").write_text(yaml.safe_dump(manifest, sort_keys=False))
beats = _load_song_beats(pak, manifest)
assert len(beats) == 3
assert beats[2]["measure"] == 1
def test_lift_keys_notation_falls_back_to_jsonc_arrangement_beats(tmp_path: Path):
"""When no song_timeline is present, ``_load_song_beats`` falls back to the
first arrangement JSON carrying beats — and that arrangement may be ``.jsonc``."""
arr_text = (
'/* keys */\n'
+ json.dumps({
**_base_arrangement(),
"beats": [{"time": 0.0, "measure": 0}, {"time": 1.0, "measure": 1}],
})
)
pak = tmp_path / "song.sloppak"
pak.mkdir()
(pak / "arrangements").mkdir()
(pak / "arrangements" / "keys.jsonc").write_text(arr_text, encoding="utf-8")
manifest = {
"title": "T", "artist": "A", "duration": 4.0,
"arrangements": [{"id": "keys", "name": "Keys",
"file": "arrangements/keys.jsonc"}],
"stems": [{"id": "full", "file": "stems/full.ogg"}],
}
(pak / "manifest.yaml").write_text(yaml.safe_dump(manifest, sort_keys=False))
beats = _load_song_beats(pak, manifest)
assert len(beats) == 2
assert beats[1]["measure"] == 1