mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 02:14:29 +00:00
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:
@@ -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 **A–Z 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`.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user