mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 03:54:31 +00:00
Compare commits
4
Commits
vst-linux-v1
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eef58c88c3 | ||
|
|
1a7e2bf084 | ||
|
|
32c00cdd78 | ||
|
|
8297afc449 |
@@ -8,6 +8,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Core reader for source rigs (feedpak 1.18.0).** A pack can declare what a
|
||||
MIDI part should sound like by binding a rig; core now reads that binding and
|
||||
hands it to the client instead of dropping it. Three parts: the
|
||||
`tone_changes` WS message carries the pack's rig bindings (`base_rig`, and
|
||||
`rig` per change) alongside the tone names it already sent; the manifest
|
||||
`rigs:` key loads the pack's rig library (`rigs.json`, spec §7.9) verbatim;
|
||||
and the binding precedence is resolved per spec §5.1/§5.2 — a manifest
|
||||
arrangement entry's `tones` replaces the arrangement JSON's **wholesale**
|
||||
(no field-level merge), while top-level `drum_tones` binds the primary drum
|
||||
part as the fallback a `type: drums` entry's own `tones` outranks. Core
|
||||
deliberately stops there: it does not select a realization or apply the
|
||||
`intent.gm` floor, which belong to whatever actually voices the part. Packs
|
||||
that bind no rig produce a byte-identical `tone_changes` payload, so existing
|
||||
consumers are unaffected.
|
||||
- **Opt-in career venue packs (#122)** — higher-tier venue crowd media
|
||||
(`club`, `arena`) is no longer bundled; the app downloads each pack on demand
|
||||
from its release when you reach the venue (sha256-verified), keeping the
|
||||
@@ -299,6 +313,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
engine (`app.js`, `highway.js`, `playSong`, `showScreen`, the capability registry).
|
||||
|
||||
### Fixed
|
||||
- **Count-in follows the song's meter and its pickup measure.** The count-in
|
||||
(loop wrap, section practice, and the "Countdown before song" setting) always
|
||||
clicked exactly four beats, so a 3/4 song was counted in 4/4, and a song
|
||||
opening with a pickup (anacrusis) had the pickup enter where the downbeat
|
||||
belonged — putting the player a beat ahead for the whole song. The bar length
|
||||
now comes from the `song_timeline` beats already on the highway
|
||||
(`measure >= 0` marks downbeats; no new plumbing, since the `time_signatures`
|
||||
map is streamed to plugins rather than stored in the frontend), and a first
|
||||
bar shorter than that meter shortens the count by its length: a 1-beat pickup
|
||||
in 4/4 counts "1 2 3" and the music enters on 4. Songs without beats — pre-chart,
|
||||
minigames, synthetic highways — still get four.
|
||||
- **GP8 asset resolution honours the directory the registry named.**
|
||||
`<EmbeddedFilePath>` is matched on filename stem so a format variant of the
|
||||
same recording can win (an `.ogg` beside the declared `.mp3` is copied out
|
||||
|
||||
@@ -690,7 +690,7 @@ The highway WebSocket at `/ws/highway/{filename}?arrangement={index}` streams th
|
||||
| `anchors` | `{ type, data: [{ time, fret, width }] }` | Fret zoom anchors |
|
||||
| `chord_templates` | `{ type, data: [{ name, frets: [6] }] }` | Named chord shapes |
|
||||
| `lyrics` | `{ type, data: [{ w, t, d }], source }` | Syllables: `w`=word, `t`=time, `d`=duration. `-` joins to previous, `+` = line break. `source` is one of `"xml"`, `"whisperx"`, `"user"` — UI can use it to render an "auto-transcribed" badge for `whisperx`. Sloppaks always include `source` (legacy sloppaks without a `lyrics_source` manifest key default to `"xml"` at load time). Loose folders set it based on which extractor matched. Absent only when no lyrics fired the message at all |
|
||||
| `tone_changes` | `{ type: 'tone_changes', base, data: [{ time, name }] }` | Optional — tone change events relative to the arrangement base tone; only sent if tones were found |
|
||||
| `tone_changes` | `{ type: 'tone_changes', base, base_rig?, data: [{ t, name, rig? }] }` | Optional — tone change events relative to the arrangement base tone; only sent if tones were found. Note the time key is **`t`**, not `time` (both the sloppak path and the legacy XML path emit `t`). `base_rig` and each entry's `rig` are the pack's **rig bindings** — ids into [`rigs.json`](https://github.com/got-feedback/feedpak-spec/blob/main/spec/feedpak-v1.md#79-rigsjson) (feedpak §6.9/§7.9), carried through verbatim and **not** resolved by core: selecting a realization and applying the `intent.gm` floor belong to whatever voices the part. Both are **omitted entirely** when the chart binds no rig, so consumers predating the rig model see the payload they always did. |
|
||||
| `notes` | `{ type, data: [{ t, s, f, sus, ho, po, sl, bn, ... }] }` | Single notes |
|
||||
| `chords` | `{ type, data: [{ t, notes: [{ s, f, sus, ... }] }] }` | Chord events |
|
||||
| `phrases` | `{ type, data: [{ start_time, end_time, max_difficulty, levels: [{ difficulty, notes, chords, anchors, handshapes }] }], total }` | Optional — per-phrase difficulty ladder for master-difficulty slider (feedBack#48). Only sent when the source chart carries multi-level phrase data (phrase-aware sloppak). Sent in chunks (`data` is a batch, `total` is the full count across messages) to avoid multi-MB single frames. Absent for GP imports and legacy sloppak; consumers must treat missing message as "single fixed difficulty — slider disabled". |
|
||||
|
||||
@@ -774,20 +774,29 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
|
||||
# (Arrangement.tones, populated by the converter), so read it straight
|
||||
# off `arr` rather than walking for XML that doesn't exist.
|
||||
if is_slop:
|
||||
# `sloppak_tone_changes` builds the (base, sorted changes) pair
|
||||
# from `Arrangement.tones`, skipping non-string names and
|
||||
# non-finite/non-numeric times — unit-tested in test_tones.py.
|
||||
# `sloppak_tone_changes` builds the (base, base_rig, sorted
|
||||
# changes) triple from `Arrangement.tones`, skipping non-string
|
||||
# names, non-finite/non-numeric times, and unusable rig ids —
|
||||
# unit-tested in test_tones.py.
|
||||
from tones import sloppak_tone_changes
|
||||
base_name, tone_changes = sloppak_tone_changes(getattr(arr, "tones", None))
|
||||
base_name, base_rig, tone_changes = sloppak_tone_changes(
|
||||
getattr(arr, "tones", None)
|
||||
)
|
||||
# Send when there's a base tone OR timed changes — a single-tone
|
||||
# arrangement has a base but no switches, and the highway should
|
||||
# still be able to show the initial tone.
|
||||
if tone_changes or base_name:
|
||||
await websocket.send_json({
|
||||
payload = {
|
||||
"type": "tone_changes",
|
||||
"base": base_name,
|
||||
"data": tone_changes,
|
||||
})
|
||||
}
|
||||
# `base_rig` is additive (feedpak-spec §6.9) — omitted entirely
|
||||
# when the chart binds no rig, so consumers that predate the rig
|
||||
# model see the exact payload they always did.
|
||||
if base_rig:
|
||||
payload["base_rig"] = base_rig
|
||||
await websocket.send_json(payload)
|
||||
else:
|
||||
xml_paths = sorted(_xml_walk("*.xml"))
|
||||
|
||||
|
||||
+193
-72
@@ -121,6 +121,41 @@ def partition_stems(stems: list[dict]) -> tuple[dict | None, list[dict]]:
|
||||
return full, [s for s in stems if str(s.get("id", "")) != FULL_MIX_STEM_ID]
|
||||
|
||||
|
||||
def _resolve_pack_path(source_dir: Path, rel: str, label: str) -> Path | None:
|
||||
"""Resolve a manifest-relative path, contained inside the pack. None if not.
|
||||
|
||||
Every manifest key that names a file routes through here. A crafted manifest
|
||||
must not read outside the sloppak directory via path traversal
|
||||
(e.g. `../../etc`), and a symlink loop or permission error on `.resolve()`
|
||||
must disable that one file rather than abort the whole load — so both
|
||||
failures are caught, and both are warnings rather than raises.
|
||||
|
||||
The two branches log differently on purpose: a `ValueError` means the path
|
||||
resolved *outside* the pack (a crafted or broken manifest), an `OSError`
|
||||
means it could not be resolved at all (symlink loop, permissions). Reading
|
||||
"escapes source_dir" in the logs and reading "resolution failed" lead an
|
||||
operator to very different places, so the distinction is worth two lines.
|
||||
|
||||
Returns the resolved path — **existence is NOT checked here**. Callers
|
||||
differ on that deliberately: a missing optional side-file is silent, while a
|
||||
missing arrangement skips an entry, so each caller keeps its own `.exists()`
|
||||
(or `.is_file()`) test and its own control flow.
|
||||
|
||||
`label` names the manifest key in the log message ("keys", "song_timeline",
|
||||
a drum part's id, …).
|
||||
"""
|
||||
try:
|
||||
p = (source_dir / rel).resolve()
|
||||
p.relative_to(source_dir.resolve())
|
||||
except ValueError:
|
||||
log.warning("sloppak: %s path %r escapes source_dir — skipped", label, rel)
|
||||
return None
|
||||
except OSError as e:
|
||||
log.warning("sloppak: %s path resolution failed (%s) — skipped", label, e)
|
||||
return None
|
||||
return p
|
||||
|
||||
|
||||
def _legacy_full_mix(manifest: dict, source_dir: Path) -> str | None:
|
||||
"""Full mix from the DEPRECATED `original_audio:` manifest key, or None.
|
||||
|
||||
@@ -152,16 +187,8 @@ def _legacy_full_mix(manifest: dict, source_dir: Path) -> str | None:
|
||||
if not isinstance(rel_raw, str) or not rel_raw.strip():
|
||||
return None
|
||||
rel = rel_raw.strip()
|
||||
try:
|
||||
target = (source_dir / rel).resolve()
|
||||
target.relative_to(source_dir.resolve())
|
||||
except ValueError:
|
||||
log.warning("sloppak: original_audio path %r escapes source_dir — skipped", rel)
|
||||
return None
|
||||
except OSError as e:
|
||||
log.warning("sloppak: original_audio path resolution failed (%s) — skipped", e)
|
||||
return None
|
||||
if not target.is_file():
|
||||
target = _resolve_pack_path(source_dir, rel, "original_audio")
|
||||
if target is None or not target.is_file():
|
||||
return None
|
||||
log.info(
|
||||
"sloppak: pack uses the deprecated `original_audio:` key (%r) — the full mix "
|
||||
@@ -698,6 +725,14 @@ class LoadedSloppak:
|
||||
# absent / unreadable / malformed. Streamed over the highway WS as a
|
||||
# `keys` message; consumers (renderers, plugins) read it from there.
|
||||
keys: dict | None = None
|
||||
# Parsed `rigs.json` payload (manifest `rigs:` key, spec §7.9) — the pack's
|
||||
# library of engine-agnostic signal chains: effect chains and, since
|
||||
# feedpak 1.18.0, MIDI-voiced sound sources. Arrangements bind rigs to time
|
||||
# by referencing a rig `id` from `tones.base_rig` / `tones.changes[].rig`
|
||||
# (§6.9), which `lib/tones.py` carries onto the wire. None when absent /
|
||||
# unreadable / malformed. Rig objects are kept verbatim — this loader does
|
||||
# not select realizations or apply the `intent.gm` floor.
|
||||
rigs: dict | None = None
|
||||
# Sanitized song-level tempo + time-signature maps from `song_timeline.json`
|
||||
# (feedpak 1.2.0). `tempos`: [{time, bpm}]; `time_signatures`: [{time, ts}].
|
||||
# None when absent/empty. Streamed over the highway WS (`tempos` /
|
||||
@@ -749,20 +784,8 @@ def _load_drum_tab_file(source_dir: Path, rel: str, label: str) -> dict | None:
|
||||
permissive — a missing file disables that part silently; a traversal,
|
||||
parse, or validation failure disables it with a warning, never aborting
|
||||
the load."""
|
||||
# Constrain to source_dir to prevent a crafted manifest from reading
|
||||
# files outside the sloppak directory via path traversal (e.g. ../../etc).
|
||||
# Wrap both resolve() calls in a broad handler: symlink loops and
|
||||
# permission errors on .resolve() should disable drums, not abort load.
|
||||
try:
|
||||
dt_path = (source_dir / rel).resolve()
|
||||
dt_path.relative_to(source_dir.resolve())
|
||||
except ValueError:
|
||||
log.warning("sloppak: %s path %r escapes source_dir — skipped", label, rel)
|
||||
return None
|
||||
except OSError as e:
|
||||
log.warning("sloppak: %s path resolution failed (%s) — skipped", label, e)
|
||||
return None
|
||||
if not dt_path.exists():
|
||||
dt_path = _resolve_pack_path(source_dir, rel, label)
|
||||
if dt_path is None or not dt_path.exists():
|
||||
return None
|
||||
try:
|
||||
raw = load_json(dt_path)
|
||||
@@ -776,18 +799,117 @@ def _load_drum_tab_file(source_dir: Path, rel: str, label: str) -> dict | None:
|
||||
return raw
|
||||
|
||||
|
||||
def _load_rigs_file(source_dir: Path, rel: str) -> dict | None:
|
||||
"""Load the pack's rig library (manifest `rigs:` key, spec §7.9).
|
||||
|
||||
Returns `{"version": int, "rigs": [...]}` or None. Same permissive posture
|
||||
as every other side-file: missing / unreadable / malformed -> None, never
|
||||
fatal — spec §7.9 is explicit that a rig library a Reader can't use MUST NOT
|
||||
fail the pack.
|
||||
|
||||
Rig objects are kept **verbatim**. Only entries that could never be
|
||||
addressed are dropped — a rig is reachable solely by `id` (from
|
||||
`tones.base_rig` / `changes[].rig`), so a non-dict entry or one without a
|
||||
usable string id is unreferenceable by construction. Everything else,
|
||||
including unknown `role` / `engine` / `kind` values and `ext` namespaces,
|
||||
passes through untouched, because this loader does not interpret rigs:
|
||||
realization selection and the `intent.gm` fallback belong to whatever
|
||||
voices the part.
|
||||
"""
|
||||
try:
|
||||
r_path = (source_dir / rel).resolve()
|
||||
r_path.relative_to(source_dir.resolve())
|
||||
except ValueError:
|
||||
log.warning("sloppak: rigs path %r escapes source_dir — skipped", rel)
|
||||
return None
|
||||
except OSError as e:
|
||||
log.warning("sloppak: rigs path resolution failed (%s) — skipped", e)
|
||||
return None
|
||||
if not r_path.exists():
|
||||
return None
|
||||
try:
|
||||
raw = load_json(r_path)
|
||||
except Exception as e:
|
||||
log.warning("sloppak: failed to parse rigs %r: %s", rel, e)
|
||||
return None
|
||||
if not isinstance(raw, dict):
|
||||
log.warning("sloppak: rigs %r ignored — expected dict, got %s",
|
||||
rel, type(raw).__name__)
|
||||
return None
|
||||
if not isinstance(raw.get("rigs"), list):
|
||||
log.warning("sloppak: rigs %r ignored — 'rigs' must be a list", rel)
|
||||
return None
|
||||
|
||||
clean_rigs: list[dict] = []
|
||||
seen: set[str] = set()
|
||||
for rig in raw["rigs"]:
|
||||
if not isinstance(rig, dict):
|
||||
continue
|
||||
rid = rig.get("id")
|
||||
if not isinstance(rid, str) or not rid.strip():
|
||||
continue
|
||||
# Normalize the library side of the lookup the same way the reference
|
||||
# side is normalized in lib/tones.py — otherwise a pack with padded ids
|
||||
# fails to resolve against a stripped `base_rig` / `rig`.
|
||||
rid = rid.strip()
|
||||
# A duplicate id makes `tones.base_rig` ambiguous, which would surface
|
||||
# as the wrong sound rather than an error. First wins, loudly.
|
||||
if rid in seen:
|
||||
log.warning("sloppak: rigs %r has duplicate rig id %r — later one ignored",
|
||||
rel, rid)
|
||||
continue
|
||||
seen.add(rid)
|
||||
clean_rigs.append({**rig, "id": rid})
|
||||
|
||||
# int only — a float version (incl. NaN/Inf, which json.loads accepts)
|
||||
# would raise on int(); default rather than abort an optional side-file.
|
||||
_ver = raw.get("version")
|
||||
return {
|
||||
"version": _ver if isinstance(_ver, int) and not isinstance(_ver, bool) else 1,
|
||||
"rigs": clean_rigs,
|
||||
}
|
||||
|
||||
|
||||
def _entry_tones(entry: dict) -> dict | None:
|
||||
"""A manifest entry's `tones` binding, or None when it doesn't carry one.
|
||||
|
||||
Spec §5.2: a manifest arrangement entry's `tones` overrides the arrangement
|
||||
JSON's `tones` **wholesale** — no field-level merge. This normalizes the
|
||||
"does it carry one" test for both the arrangement path and the drum path.
|
||||
|
||||
An empty dict reads as *absent*, not as "override to silence": it is what a
|
||||
Writer emits by accident, `arrangement_from_wire` already normalizes the
|
||||
in-JSON `{}` to None the same way, and treating it as an override would let
|
||||
a stray empty object silently unbind a part's sound.
|
||||
"""
|
||||
tones = entry.get("tones")
|
||||
return tones if isinstance(tones, dict) and tones else None
|
||||
|
||||
|
||||
def _resolve_drum_parts(
|
||||
source_dir: Path,
|
||||
drum_tab_rel: object,
|
||||
drum_tab_data: dict | None,
|
||||
drum_pointer_entries: list[dict],
|
||||
drum_tones: dict | None = None,
|
||||
) -> tuple[dict | None, list[dict] | None]:
|
||||
"""Resolve drum pointers into a primary-first list with unique ids."""
|
||||
"""Resolve drum pointers into a primary-first list with unique ids.
|
||||
|
||||
Also binds each part's sound (feedpak 1.18.0). The precedence mirrors the
|
||||
`drum_tab` alias rule this function already implements: a `type: drums`
|
||||
entry's own `tones` wins for that part, and the song-level `drum_tones` is
|
||||
the fallback for the **primary** part only. A Reader MUST NOT apply both to
|
||||
the same part (spec §5.1/§5.2), which is why the primary picks one or the
|
||||
other here rather than merging them.
|
||||
"""
|
||||
if drum_tab_data is None and not drum_pointer_entries:
|
||||
return drum_tab_data, None
|
||||
|
||||
primary_id = "drums"
|
||||
primary_name = None
|
||||
# The primary's own binding, lifted from its alias pointer entry when it has
|
||||
# one. Stays None if no entry claims the primary — `drum_tones` fills in.
|
||||
primary_tones = None
|
||||
extra_parts: list[dict] = []
|
||||
seen_rels: set[str] = set()
|
||||
# Use the same canonical, traversal-safe identity as zip member lookup so
|
||||
@@ -811,6 +933,11 @@ def _resolve_drum_parts(
|
||||
primary_id = entry_id
|
||||
if entry_name:
|
||||
primary_name = entry_name
|
||||
# This entry IS the primary (an alias pointer at the same file), so
|
||||
# its binding is the primary's — and it outranks `drum_tones`.
|
||||
_alias_tones = _entry_tones(entry)
|
||||
if _alias_tones is not None:
|
||||
primary_tones = _alias_tones
|
||||
continue
|
||||
tab = _load_drum_tab_file(source_dir, rel, f"drum part {entry_id or rel}")
|
||||
if tab is None:
|
||||
@@ -821,6 +948,9 @@ def _resolve_drum_parts(
|
||||
"name": entry_name
|
||||
or (tab_name if isinstance(tab_name, str) and tab_name else "Drums"),
|
||||
"drum_tab": tab,
|
||||
# Non-primary parts bind through their own entry only; `drum_tones`
|
||||
# is explicitly the primary's fallback, never theirs.
|
||||
"tones": _entry_tones(entry),
|
||||
})
|
||||
|
||||
parts: list[dict] = []
|
||||
@@ -829,7 +959,14 @@ def _resolve_drum_parts(
|
||||
if primary_name is None:
|
||||
tab_name = drum_tab_data.get("name")
|
||||
primary_name = tab_name if isinstance(tab_name, str) and tab_name else "Drums"
|
||||
parts.append({"id": primary_id, "name": primary_name, "drum_tab": drum_tab_data})
|
||||
parts.append({
|
||||
"id": primary_id,
|
||||
"name": primary_name,
|
||||
"drum_tab": drum_tab_data,
|
||||
# Entry `tones` takes precedence; `drum_tones` is the fallback. One
|
||||
# or the other, never both on the same part (spec §5.1).
|
||||
"tones": primary_tones if primary_tones is not None else drum_tones,
|
||||
})
|
||||
used_ids.add(primary_id)
|
||||
|
||||
next_generated_id = 2
|
||||
@@ -909,16 +1046,8 @@ def load_song(
|
||||
continue
|
||||
data = None
|
||||
if rel:
|
||||
try:
|
||||
arr_path = (source_dir / rel).resolve()
|
||||
arr_path.relative_to(source_dir.resolve())
|
||||
except ValueError:
|
||||
log.warning("sloppak: arrangement path %r escapes source_dir — skipped", rel)
|
||||
continue
|
||||
except OSError as e:
|
||||
log.warning("sloppak: arrangement path resolution failed (%s) — skipped", e)
|
||||
continue
|
||||
if not arr_path.exists():
|
||||
arr_path = _resolve_pack_path(source_dir, rel, "arrangement")
|
||||
if arr_path is None or not arr_path.exists():
|
||||
continue
|
||||
try:
|
||||
data = load_json(arr_path)
|
||||
@@ -948,6 +1077,14 @@ def load_song(
|
||||
# _finite_float keeps a malformed manifest NaN/Infinity from
|
||||
# poisoning the song_info JSON (same guard as the wire path).
|
||||
arr.cent_offset = _finite_float(entry["centOffset"])
|
||||
# `tones` overrides WHOLESALE, unlike the field-level overrides above:
|
||||
# the entry's object replaces the arrangement JSON's entirely, with no
|
||||
# per-field merge (spec §5.2). A Writer SHOULD NOT emit both, but when
|
||||
# one does, a half-merged sound — this pack's base with that pack's
|
||||
# changes — would be worse than either source alone.
|
||||
_entry_tone_block = _entry_tones(entry)
|
||||
if _entry_tone_block is not None:
|
||||
arr.tones = _entry_tone_block
|
||||
|
||||
# Beats/sections can live on the arrangement itself in the wire format.
|
||||
# If the manifest-level arrangement JSON carries them, pull them onto
|
||||
@@ -980,15 +1117,7 @@ def load_song(
|
||||
notation_rel = notation_rel.strip()
|
||||
if not notation_rel:
|
||||
continue
|
||||
try:
|
||||
nt_path = (source_dir / notation_rel).resolve()
|
||||
nt_path.relative_to(source_dir.resolve())
|
||||
except ValueError:
|
||||
log.warning("sloppak: notation path %r escapes source_dir — skipped", notation_rel)
|
||||
nt_path = None
|
||||
except OSError as e:
|
||||
log.warning("sloppak: notation path resolution failed (%s) — skipped", e)
|
||||
nt_path = None
|
||||
nt_path = _resolve_pack_path(source_dir, notation_rel, "notation")
|
||||
raw_nt = None
|
||||
if nt_path is not None and nt_path.exists():
|
||||
try:
|
||||
@@ -1020,8 +1149,15 @@ def load_song(
|
||||
|
||||
# Keep the dense compatibility logic independently testable and guarantee
|
||||
# ids are unique before the highway exposes them as selectors.
|
||||
# Top-level `drum_tones` (spec §5.1) binds the song-level drum part — the
|
||||
# fallback for packs without `type: drums` arrangements. Same shape as an
|
||||
# arrangement entry's `tones`; `_resolve_drum_parts` owns the precedence.
|
||||
_raw_drum_tones = manifest.get("drum_tones")
|
||||
drum_tones_data = _raw_drum_tones if isinstance(_raw_drum_tones, dict) and _raw_drum_tones else None
|
||||
|
||||
drum_tab_data, drum_parts = _resolve_drum_parts(
|
||||
source_dir, drum_tab_rel, drum_tab_data, drum_pointer_entries,
|
||||
drum_tones_data,
|
||||
)
|
||||
|
||||
# Drum-only sloppak: every GP track was percussion, so it ships a
|
||||
@@ -1061,15 +1197,7 @@ def load_song(
|
||||
time_sigs_data: list | None = None
|
||||
song_timeline_rel = manifest.get("song_timeline")
|
||||
if isinstance(song_timeline_rel, str) and song_timeline_rel:
|
||||
try:
|
||||
st_path = (source_dir / song_timeline_rel).resolve()
|
||||
st_path.relative_to(source_dir.resolve())
|
||||
except ValueError:
|
||||
log.warning("sloppak: song_timeline path %r escapes source_dir — skipped", song_timeline_rel)
|
||||
st_path = None
|
||||
except OSError as e:
|
||||
log.warning("sloppak: song_timeline path resolution failed (%s) — skipped", e)
|
||||
st_path = None
|
||||
st_path = _resolve_pack_path(source_dir, song_timeline_rel, "song_timeline")
|
||||
if st_path is not None and st_path.exists():
|
||||
try:
|
||||
raw = load_json(st_path)
|
||||
@@ -1159,15 +1287,7 @@ def load_song(
|
||||
# downstream through the WS path.
|
||||
lyrics_rel = manifest.get("lyrics")
|
||||
if isinstance(lyrics_rel, str) and lyrics_rel:
|
||||
try:
|
||||
lyr_path = (source_dir / lyrics_rel).resolve()
|
||||
lyr_path.relative_to(source_dir.resolve())
|
||||
except ValueError:
|
||||
log.warning("sloppak: lyrics path %r escapes source_dir — skipped", lyrics_rel)
|
||||
lyr_path = None
|
||||
except OSError as e:
|
||||
log.warning("sloppak: lyrics path resolution failed (%s) — skipped", e)
|
||||
lyr_path = None
|
||||
lyr_path = _resolve_pack_path(source_dir, lyrics_rel, "lyrics")
|
||||
if lyr_path is not None and lyr_path.exists():
|
||||
try:
|
||||
raw = load_json(lyr_path)
|
||||
@@ -1272,15 +1392,7 @@ def load_song(
|
||||
keys_data: dict | None = None
|
||||
keys_rel = manifest.get("keys")
|
||||
if isinstance(keys_rel, str) and keys_rel:
|
||||
try:
|
||||
k_path = (source_dir / keys_rel).resolve()
|
||||
k_path.relative_to(source_dir.resolve())
|
||||
except ValueError:
|
||||
log.warning("sloppak: keys path %r escapes source_dir — skipped", keys_rel)
|
||||
k_path = None
|
||||
except OSError as e:
|
||||
log.warning("sloppak: keys path resolution failed (%s) — skipped", e)
|
||||
k_path = None
|
||||
k_path = _resolve_pack_path(source_dir, keys_rel, "keys")
|
||||
if k_path is not None and k_path.exists():
|
||||
try:
|
||||
raw = load_json(k_path)
|
||||
@@ -1325,6 +1437,14 @@ def load_song(
|
||||
"events": clean_events,
|
||||
}
|
||||
|
||||
# Optional rigs.json — the pack's rig library (manifest `rigs:` key,
|
||||
# spec §7.9). Loaded here so the highway WS can hand it to whatever voices
|
||||
# the part; the bindings that reference it ride the arrangement's `tones`.
|
||||
rigs_data: dict | None = None
|
||||
rigs_rel = manifest.get("rigs")
|
||||
if isinstance(rigs_rel, str) and rigs_rel:
|
||||
rigs_data = _load_rigs_file(source_dir, rigs_rel)
|
||||
|
||||
_fpv = manifest.get("feedpak_version")
|
||||
# The pack's full mix. Normally the RESERVED `full` stem partitioned out
|
||||
# above (spec §5.3) — no path work needed, it was validated with the other
|
||||
@@ -1355,6 +1475,7 @@ def load_song(
|
||||
tempos=tempos_data,
|
||||
time_signatures=time_sigs_data,
|
||||
keys=keys_data,
|
||||
rigs=rigs_data,
|
||||
notation_by_id=notation_by_id_data,
|
||||
arrangement_ids=arrangement_ids_acc,
|
||||
full_mix=full_mix_data,
|
||||
|
||||
+25
-9
@@ -32,20 +32,29 @@ def tokens(s: str) -> set[str]:
|
||||
return {t for t in re.split(r"[^a-z0-9]+", (s or "").lower()) if t}
|
||||
|
||||
|
||||
def sloppak_tone_changes(arr_tones) -> tuple[str, list[dict]]:
|
||||
def sloppak_tone_changes(arr_tones) -> tuple[str, str, list[dict]]:
|
||||
"""Build the highway tone-change payload from an arrangement's tone block.
|
||||
|
||||
Given ``Arrangement.tones`` (the dict embedded in the sloppak, or ``None``),
|
||||
returns ``(base, changes)`` where ``base`` is the initial tone name and
|
||||
``changes`` is a time-sorted ``[{"t", "name"}]`` list. Non-string names,
|
||||
non-dict entries, and non-numeric / non-finite times are skipped — a
|
||||
hand-edited or third-party sloppak must not crash the highway WebSocket
|
||||
or emit NaN/inf (which the client's ``JSON.parse`` rejects).
|
||||
returns ``(base, base_rig, changes)`` where ``base`` is the initial tone
|
||||
name, ``base_rig`` is the ``rigs.json`` rig id bound to it (feedpak-spec
|
||||
§6.9; ``""`` when absent), and ``changes`` is a time-sorted
|
||||
``[{"t", "name", "rig"?}]`` list. Non-string names, non-dict entries, and
|
||||
non-numeric / non-finite times are skipped — a hand-edited or third-party
|
||||
sloppak must not crash the highway WebSocket or emit NaN/inf (which the
|
||||
client's ``JSON.parse`` rejects).
|
||||
|
||||
``rig`` / ``base_rig`` are carried through but NOT resolved against
|
||||
``rigs.json`` here: this builder only preserves the binding the chart
|
||||
declared. Realization selection and the ``intent.gm`` fallback (§7.9) belong
|
||||
to the consumer that actually voices the part.
|
||||
"""
|
||||
if not isinstance(arr_tones, dict):
|
||||
return "", []
|
||||
return "", "", []
|
||||
base_val = arr_tones.get("base", "")
|
||||
base = base_val.strip() if isinstance(base_val, str) else ""
|
||||
base_rig_val = arr_tones.get("base_rig", "")
|
||||
base_rig = base_rig_val.strip() if isinstance(base_rig_val, str) else ""
|
||||
|
||||
changes: list[dict] = []
|
||||
raw_changes = arr_tones.get("changes")
|
||||
@@ -65,6 +74,13 @@ def sloppak_tone_changes(arr_tones) -> tuple[str, list[dict]]:
|
||||
continue
|
||||
if not math.isfinite(t):
|
||||
continue
|
||||
changes.append({"t": round(t, 3), "name": name})
|
||||
change = {"t": round(t, 3), "name": name}
|
||||
# ponytail: `rig` only when it's a usable id — a non-string or blank
|
||||
# value is dropped rather than forwarded, so a consumer can treat
|
||||
# presence of the key as "this change binds a rig".
|
||||
rig = c.get("rig")
|
||||
if isinstance(rig, str) and rig.strip():
|
||||
change["rig"] = rig.strip()
|
||||
changes.append(change)
|
||||
changes.sort(key=lambda x: x["t"])
|
||||
return base, changes
|
||||
return base, base_rig, changes
|
||||
|
||||
+78
-5
@@ -1,4 +1,4 @@
|
||||
// Count-in — the 1-2-3-4 click before playback, plus the song-credits overlay that
|
||||
// Count-in — the one-bar click before playback, plus the song-credits overlay that
|
||||
// shares its lifecycle and timers.
|
||||
//
|
||||
// The third slice out of app.js's strongly-connected core, and the first that had to
|
||||
@@ -40,6 +40,75 @@ export function playClick(high = false) {
|
||||
osc.stop(_audioCtx.currentTime + 0.08);
|
||||
}
|
||||
|
||||
// ── How many clicks lead into `startT` ──────────────────────────────────
|
||||
// One bar, derived from the song_timeline beats: `window.highway.getBeats()`
|
||||
// is the only meter data the frontend holds (the `time_signatures` map is
|
||||
// streamed to plugins, not stored here). Beats carry `measure >= 0` on
|
||||
// downbeats, so the gap between consecutive downbeats IS the bar length —
|
||||
// which is why a 3/4 song no longer gets four clicks.
|
||||
//
|
||||
// A first bar shorter than that is a pickup (anacrusis), and the count is
|
||||
// shortened by its length so the music enters on its real beat: a 1-beat
|
||||
// pickup in 4/4 counts "1 2 3" and the pickup lands on 4. Counting a full
|
||||
// four there puts the pickup where the downbeat belongs, and the player comes
|
||||
// in a beat late for the whole song.
|
||||
export function countInBeats(startT) {
|
||||
const DEFAULT = 4; // pre-chart, synthetic highway (minigames), or no beats
|
||||
let beats = null;
|
||||
try {
|
||||
if (window.highway && typeof window.highway.getBeats === 'function') {
|
||||
beats = window.highway.getBeats();
|
||||
}
|
||||
} catch (_) { /* fall through to the default */ }
|
||||
if (!Array.isArray(beats) || beats.length < 2) return DEFAULT;
|
||||
|
||||
const downbeats = [];
|
||||
for (let i = 0; i < beats.length; i++) {
|
||||
if (beats[i] && beats[i].measure >= 0) downbeats.push(i);
|
||||
}
|
||||
if (downbeats.length < 2) return DEFAULT;
|
||||
|
||||
// Bar length = the most common gap between downbeats. The mode rather than
|
||||
// the first gap: it ignores a short pickup bar and a short final bar, and
|
||||
// survives an isolated meter change mid-song. The beats trailing the last
|
||||
// downbeat count as a candidate too — otherwise a song of pickup + one bar
|
||||
// offers only the pickup's own gap and the count collapses to it.
|
||||
const gapCounts = new Map();
|
||||
const addGap = (gap) => gapCounts.set(gap, (gapCounts.get(gap) || 0) + 1);
|
||||
for (let k = 1; k < downbeats.length; k++) {
|
||||
addGap(downbeats[k] - downbeats[k - 1]);
|
||||
}
|
||||
addGap(beats.length - downbeats[downbeats.length - 1]);
|
||||
let barLen = DEFAULT;
|
||||
let bestCount = 0;
|
||||
for (const [gap, n] of gapCounts) {
|
||||
// Tie → the longer bar: a pickup's short gap must not outvote the
|
||||
// real meter when the song is too short to repeat it.
|
||||
if (n > bestCount || (n === bestCount && gap > barLen)) {
|
||||
barLen = gap;
|
||||
bestCount = n;
|
||||
}
|
||||
}
|
||||
|
||||
// The beat playback resumes on. The 50 ms tolerance matches the seek
|
||||
// precision the loop-wrap path already assumes.
|
||||
const startIdx = beats.findIndex(b => b && b.time >= startT - 0.05);
|
||||
if (startIdx === -1) return barLen; // past the last beat
|
||||
if (!(beats[startIdx].measure >= 0)) return barLen; // resuming mid-bar
|
||||
|
||||
const nextDownbeat = downbeats.find(d => d > startIdx);
|
||||
if (nextDownbeat === undefined) return barLen; // the last downbeat
|
||||
const thisBar = nextDownbeat - startIdx;
|
||||
if (thisBar <= 0) return barLen;
|
||||
|
||||
// Only the song's FIRST bar can be a pickup. A short bar anywhere else is
|
||||
// a meter change (or a truncated final bar), and counting it as a pickup
|
||||
// would leave almost no count-in at all — so elsewhere we simply count
|
||||
// that bar's own length, which is also what a mid-song meter change wants.
|
||||
if (startIdx === downbeats[0] && thisBar < barLen) return barLen - thisBar;
|
||||
return thisBar;
|
||||
}
|
||||
|
||||
let _countingIn = false;
|
||||
let _countOverlay = null;
|
||||
// Generation token so teardown can cancel an in-progress count-in. Each
|
||||
@@ -273,12 +342,15 @@ export async function startCountIn(opts = {}) {
|
||||
function beginCount() {
|
||||
const bpm = window.highway.getBPM(loopA);
|
||||
const beatInterval = 60 / bpm;
|
||||
// One bar of the meter at loop A (a short bar there is counted short,
|
||||
// same as the song-start pickup).
|
||||
const clicks = countInBeats(loopA);
|
||||
let count = 0;
|
||||
|
||||
function tick() {
|
||||
if (gen !== _countInGen) return; // teardown mid-count
|
||||
count++;
|
||||
if (count > 4) {
|
||||
if (count > clicks) {
|
||||
hideCountOverlay();
|
||||
_countingIn = false;
|
||||
if (window._juceMode) {
|
||||
@@ -320,7 +392,7 @@ export async function startCountIn(opts = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
// Start-of-song count-in: a 4-beat click before playback begins, gated by the
|
||||
// Start-of-song count-in: a one-bar click before playback begins, gated by the
|
||||
// "Countdown before song" setting (Gameplay tab). Mirrors the loop count-in's
|
||||
// overlay + click + gen-token cancellation, but counts from the song's current
|
||||
// position (0 at song start) with no loop A/B rewind. startCountIn() is loop-
|
||||
@@ -340,14 +412,15 @@ export async function startSongCountIn() {
|
||||
if (gen !== _countInGen) return; // teardown during pause
|
||||
const startT = S.lastAudioTime || 0;
|
||||
let bpm = window.highway.getBPM(startT);
|
||||
// Pre-chart / malformed-tempo fallback: 4 beats at 120 BPM (500 ms each).
|
||||
// Pre-chart / malformed-tempo fallback: 120 BPM (500 ms per beat).
|
||||
if (!Number.isFinite(bpm) || bpm <= 0) bpm = 120;
|
||||
const beatInterval = 60 / bpm;
|
||||
const clicks = countInBeats(startT);
|
||||
let count = 0;
|
||||
function tick() {
|
||||
if (gen !== _countInGen) return; // teardown mid-count
|
||||
count++;
|
||||
if (count > 4) {
|
||||
if (count > clicks) {
|
||||
hideCountOverlay();
|
||||
_countingIn = false;
|
||||
// Hand off to the normal play path — togglePlay() flips isPlaying,
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
// Verify `countInBeats(startT)` in static/js/count-in.js sizes the count-in
|
||||
// to the song's own bar rather than a hardcoded four clicks.
|
||||
//
|
||||
// Two behaviours are under test:
|
||||
// 1. Meter — a 3/4 song gets three clicks, not four.
|
||||
// 2. Pickup (anacrusis) — a first bar shorter than the meter shortens the
|
||||
// count so the pickup enters on its real beat (1-beat pickup in 4/4 →
|
||||
// "1 2 3", music on 4). A full four there puts the pickup where the
|
||||
// downbeat belongs and the player comes in a beat late all song.
|
||||
//
|
||||
// The meter is read from the song_timeline beats (`window.highway.getBeats()`,
|
||||
// `measure >= 0` on downbeats) because that is the only meter data the
|
||||
// frontend holds — the `time_signatures` map is streamed to plugins, not
|
||||
// stored here.
|
||||
//
|
||||
// Same extraction approach as loop_restart.test.js: pull the function source
|
||||
// out of the module and evaluate it in a vm sandbox with a stubbed highway,
|
||||
// rather than loading the ESM module and its DOM-coupled imports.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const COUNT_IN_JS = path.join(__dirname, '..', '..', 'static', 'js', 'count-in.js');
|
||||
|
||||
// Brace-match the function body out of the source. Brittle by design:
|
||||
// a rename fails loudly here rather than silently skipping coverage.
|
||||
function extractFunction(src, signature) {
|
||||
const start = src.indexOf(signature);
|
||||
if (start === -1) throw new Error(`extractFunction: '${signature}' not found`);
|
||||
const openBrace = src.indexOf('{', start + signature.length);
|
||||
let depth = 1;
|
||||
let i = openBrace + 1;
|
||||
while (i < src.length && depth > 0) {
|
||||
const ch = src[i];
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') depth--;
|
||||
i++;
|
||||
}
|
||||
if (depth !== 0) throw new Error(`extractFunction: unbalanced braces after '${signature}'`);
|
||||
return src.slice(start, i);
|
||||
}
|
||||
|
||||
const src = fs.readFileSync(COUNT_IN_JS, 'utf8');
|
||||
// Drop the `export` keyword so the body evaluates as a plain declaration.
|
||||
const fnSrc = extractFunction(src, 'export function countInBeats')
|
||||
.replace(/^export\s+/, '');
|
||||
|
||||
// `beats` is the song_timeline shape: {time, measure}, measure >= 0 only on
|
||||
// downbeats. `getBeats` may also be absent entirely (pre-chart / minigame).
|
||||
function load(beats) {
|
||||
const sandbox = {
|
||||
window: beats === undefined
|
||||
? { highway: {} }
|
||||
: { highway: { getBeats: () => beats } },
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(`${fnSrc}; globalThis.__fn = countInBeats;`, sandbox);
|
||||
return sandbox.__fn;
|
||||
}
|
||||
|
||||
// Build a beats array: `bars` full bars of `beatsPerBar`, optionally preceded
|
||||
// by a pickup of `pickup` beats. One beat per 0.5 s throughout.
|
||||
function makeBeats({ beatsPerBar = 4, bars = 4, pickup = 0 } = {}) {
|
||||
const out = [];
|
||||
let t = 0;
|
||||
let measure = 0;
|
||||
if (pickup > 0) {
|
||||
for (let i = 0; i < pickup; i++) {
|
||||
out.push({ time: t, measure: i === 0 ? measure : -1 });
|
||||
t += 0.5;
|
||||
}
|
||||
measure++;
|
||||
}
|
||||
for (let b = 0; b < bars; b++) {
|
||||
for (let i = 0; i < beatsPerBar; i++) {
|
||||
out.push({ time: t, measure: i === 0 ? measure : -1 });
|
||||
t += 0.5;
|
||||
}
|
||||
measure++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Meter ────────────────────────────────────────────────────────────────
|
||||
|
||||
test('countInBeats counts a full bar in 4/4', () => {
|
||||
const countInBeats = load(makeBeats({ beatsPerBar: 4 }));
|
||||
assert.equal(countInBeats(0), 4);
|
||||
});
|
||||
|
||||
test('countInBeats counts three in 3/4 (was hardcoded four)', () => {
|
||||
const countInBeats = load(makeBeats({ beatsPerBar: 3 }));
|
||||
assert.equal(countInBeats(0), 3);
|
||||
});
|
||||
|
||||
test('countInBeats counts six in 6/8', () => {
|
||||
const countInBeats = load(makeBeats({ beatsPerBar: 6 }));
|
||||
assert.equal(countInBeats(0), 6);
|
||||
});
|
||||
|
||||
// ── Pickup (anacrusis) ───────────────────────────────────────────────────
|
||||
|
||||
test('countInBeats shortens the count by a 1-beat pickup in 4/4', () => {
|
||||
const countInBeats = load(makeBeats({ beatsPerBar: 4, pickup: 1 }));
|
||||
assert.equal(countInBeats(0), 3, 'counts 1-2-3 so the pickup lands on 4');
|
||||
});
|
||||
|
||||
test('countInBeats shortens the count by a 2-beat pickup in 4/4', () => {
|
||||
const countInBeats = load(makeBeats({ beatsPerBar: 4, pickup: 2 }));
|
||||
assert.equal(countInBeats(0), 2);
|
||||
});
|
||||
|
||||
test('countInBeats handles a pickup in 3/4', () => {
|
||||
const countInBeats = load(makeBeats({ beatsPerBar: 3, pickup: 1 }));
|
||||
assert.equal(countInBeats(0), 2);
|
||||
});
|
||||
|
||||
test('countInBeats finds the meter when the song is only a pickup plus one bar', () => {
|
||||
// Gap counts tie (one 1-beat gap, one 4-beat gap) — the longer bar is the
|
||||
// meter, so this must be 3 rather than 0.
|
||||
const countInBeats = load(makeBeats({ beatsPerBar: 4, bars: 1, pickup: 1 }));
|
||||
assert.equal(countInBeats(0), 3);
|
||||
});
|
||||
|
||||
// ── Resuming somewhere other than the song top ───────────────────────────
|
||||
|
||||
test('countInBeats counts a full bar at a mid-song downbeat, pickup notwithstanding', () => {
|
||||
const beats = makeBeats({ beatsPerBar: 4, pickup: 1 });
|
||||
const countInBeats = load(beats);
|
||||
// Index 5 is the downbeat of the second full bar (1 pickup + 4 beats).
|
||||
assert.equal(beats[5].measure >= 0, true, 'fixture sanity: index 5 is a downbeat');
|
||||
assert.equal(countInBeats(beats[5].time), 4);
|
||||
});
|
||||
|
||||
test('countInBeats counts a mid-song meter change by that bar, not as a pickup', () => {
|
||||
// 4/4 throughout, except one 3-beat bar at index 8. Treating a short bar
|
||||
// anywhere but the song's first as a pickup would count a single click.
|
||||
const beats = [];
|
||||
let t = 0;
|
||||
const push = (n, measure) => {
|
||||
for (let i = 0; i < n; i++) { beats.push({ time: t, measure: i === 0 ? measure : -1 }); t += 0.5; }
|
||||
};
|
||||
push(4, 0); push(4, 1); push(3, 2); push(4, 3); push(4, 4);
|
||||
const countInBeats = load(beats);
|
||||
assert.equal(beats[8].measure, 2, 'fixture sanity: index 8 opens the 3-beat bar');
|
||||
assert.equal(countInBeats(beats[8].time), 3, 'counts the short bar itself');
|
||||
assert.equal(countInBeats(0), 4, 'the 4/4 opening is unaffected');
|
||||
});
|
||||
|
||||
test('countInBeats counts a full bar when resuming mid-bar', () => {
|
||||
const beats = makeBeats({ beatsPerBar: 4 });
|
||||
const countInBeats = load(beats);
|
||||
assert.equal(countInBeats(beats[2].time), 4); // third beat of bar 1
|
||||
});
|
||||
|
||||
test('countInBeats tolerates a start time slightly past the beat (seek slop)', () => {
|
||||
const countInBeats = load(makeBeats({ beatsPerBar: 4, pickup: 1 }));
|
||||
assert.equal(countInBeats(0.02), 3);
|
||||
});
|
||||
|
||||
// ── Fallbacks ────────────────────────────────────────────────────────────
|
||||
|
||||
test('countInBeats falls back to four without a beats array', () => {
|
||||
assert.equal(load(undefined)(0), 4, 'no getBeats (pre-chart / minigame)');
|
||||
assert.equal(load([])(0), 4, 'empty beats');
|
||||
assert.equal(load(null)(0), 4, 'null beats');
|
||||
});
|
||||
|
||||
test('countInBeats falls back to four when beats carry no downbeat labels', () => {
|
||||
const beats = [0, 0.5, 1.0, 1.5, 2.0].map(time => ({ time, measure: -1 }));
|
||||
assert.equal(load(beats)(0), 4);
|
||||
});
|
||||
|
||||
test('countInBeats falls back to four with only one downbeat', () => {
|
||||
const beats = [
|
||||
{ time: 0, measure: 0 },
|
||||
{ time: 0.5, measure: -1 },
|
||||
{ time: 1.0, measure: -1 },
|
||||
];
|
||||
assert.equal(load(beats)(0), 4);
|
||||
});
|
||||
|
||||
test('countInBeats counts a full bar past the last beat', () => {
|
||||
const beats = makeBeats({ beatsPerBar: 3 });
|
||||
assert.equal(load(beats)(9999), 3);
|
||||
});
|
||||
@@ -88,6 +88,10 @@ function buildSandbox() {
|
||||
playClick: () => {},
|
||||
showCountOverlay: () => {},
|
||||
hideCountOverlay: () => {},
|
||||
// beginCount sizes the count to the bar at loop A; the wrap-path
|
||||
// assertions below don't depend on how many clicks it decides on.
|
||||
// Covered directly in count_in_beats.test.js.
|
||||
countInBeats: () => 4,
|
||||
|
||||
// Stubbed DOM access. Anything querying for a button just gets a
|
||||
// permissive object that ignores writes.
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""VST-pack slicing (tools/content_packs.build_vst_pack): each platform pack
|
||||
keeps only its own binaries + the shared bundle files, drops the rest, and is
|
||||
reproducible."""
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from tools import content_packs
|
||||
|
||||
|
||||
def _fake_vst_tree(root: Path):
|
||||
# One fat .vst3 with all three platform binaries + shared files, plus a
|
||||
# src/ build tree that must never ship.
|
||||
c = root / "amps" / "Foo.vst3" / "Contents"
|
||||
(c / "MacOS").mkdir(parents=True)
|
||||
(c / "x86_64-win").mkdir(parents=True)
|
||||
(c / "x86_64-linux").mkdir(parents=True)
|
||||
(c / "Resources").mkdir(parents=True)
|
||||
(c / "MacOS" / "Foo").write_bytes(b"mac-binary")
|
||||
(c / "x86_64-win" / "Foo.vst3").write_bytes(b"win-binary")
|
||||
(c / "x86_64-linux" / "Foo.so").write_bytes(b"linux-binary")
|
||||
(c / "Info.plist").write_bytes(b"<plist/>")
|
||||
(c / "Resources" / "moduleinfo.json").write_bytes(b"{}")
|
||||
(root / "src" / "build").mkdir(parents=True)
|
||||
(root / "src" / "build" / "junk.o").write_bytes(b"objfile")
|
||||
|
||||
|
||||
def _names(zip_path):
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
return set(zf.namelist())
|
||||
|
||||
|
||||
def test_slice_keeps_target_platform_and_shared_drops_foreign(tmp_path):
|
||||
root = tmp_path / "vst"
|
||||
_fake_vst_tree(root)
|
||||
content_packs.build_vst_pack(root, tmp_path / "mac.zip", "mac")
|
||||
names = _names(tmp_path / "mac.zip")
|
||||
|
||||
base = "amps/Foo.vst3/Contents"
|
||||
assert f"{base}/MacOS/Foo" in names # target binary kept
|
||||
assert f"{base}/Info.plist" in names # shared kept
|
||||
assert f"{base}/Resources/moduleinfo.json" in names # shared kept
|
||||
assert f"{base}/x86_64-win/Foo.vst3" not in names # foreign dropped
|
||||
assert f"{base}/x86_64-linux/Foo.so" not in names # foreign dropped
|
||||
assert not any(n.startswith("src/") for n in names) # build trees never ship
|
||||
|
||||
|
||||
def test_each_platform_gets_its_own_binary(tmp_path):
|
||||
root = tmp_path / "vst"
|
||||
_fake_vst_tree(root)
|
||||
wanted = {"mac": "MacOS/Foo", "win": "x86_64-win/Foo.vst3", "linux": "x86_64-linux/Foo.so"}
|
||||
for plat, rel in wanted.items():
|
||||
content_packs.build_vst_pack(root, tmp_path / f"{plat}.zip", plat)
|
||||
names = _names(tmp_path / f"{plat}.zip")
|
||||
assert f"amps/Foo.vst3/Contents/{rel}" in names
|
||||
others = [v for k, v in wanted.items() if k != plat]
|
||||
for o in others:
|
||||
assert f"amps/Foo.vst3/Contents/{o}" not in names
|
||||
|
||||
|
||||
def test_slice_is_reproducible(tmp_path):
|
||||
root = tmp_path / "vst"
|
||||
_fake_vst_tree(root)
|
||||
a = content_packs.build_vst_pack(root, tmp_path / "a.zip", "linux")
|
||||
b = content_packs.build_vst_pack(root, tmp_path / "b.zip", "linux")
|
||||
assert a == b and a["sha256"]
|
||||
|
||||
|
||||
def test_slice_pins_create_system_for_cross_runner_reproducibility(tmp_path, monkeypatch):
|
||||
# ZipInfo defaults create_system from the host OS (0 on Windows, 3 on Unix),
|
||||
# and it lands in the central directory — so without an explicit pin the same
|
||||
# tree hashes differently on a Windows runner, breaking the precomputable-hash
|
||||
# guarantee exactly where it matters (native .vst3 are built on Windows). A
|
||||
# same-machine reproducibility test can't catch that; simulate win32 and
|
||||
# assert the pin forces 3 regardless.
|
||||
monkeypatch.setattr(zipfile.sys, "platform", "win32")
|
||||
root = tmp_path / "vst"
|
||||
_fake_vst_tree(root)
|
||||
content_packs.build_vst_pack(root, tmp_path / "w.zip", "linux")
|
||||
with zipfile.ZipFile(tmp_path / "w.zip") as zf:
|
||||
assert all(i.create_system == 3 for i in zf.infolist())
|
||||
|
||||
|
||||
def test_unknown_platform_rejected(tmp_path):
|
||||
root = tmp_path / "vst"
|
||||
_fake_vst_tree(root)
|
||||
try:
|
||||
content_packs.build_vst_pack(root, tmp_path / "x.zip", "bsd")
|
||||
except ValueError as e:
|
||||
assert "unknown platform" in str(e)
|
||||
else:
|
||||
raise AssertionError("build_vst_pack accepted an unknown platform")
|
||||
@@ -0,0 +1,207 @@
|
||||
"""End-to-end test for the sloppak loader recognising a `rigs:` manifest key
|
||||
(rigs.json — the pack-level library of engine-agnostic rigs, spec §7.9) and
|
||||
surfacing the payload on the LoadedSloppak.
|
||||
|
||||
The governing posture: rig objects pass through VERBATIM. This loader does not
|
||||
select realizations or apply the `intent.gm` floor — it only makes the library
|
||||
addressable by `id`, which is what `tones.base_rig` / `tones.changes[].rig`
|
||||
reference."""
|
||||
|
||||
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, rigs_payload) -> Path:
|
||||
"""Minimal directory-form sloppak; writes rigs.json when a payload is given.
|
||||
|
||||
Unique filename per test (tmp_path leaf) so the module-level
|
||||
resolve_source_dir cache isn't poisoned across tests."""
|
||||
pak = root / f"{root.name}.sloppak"
|
||||
pak.mkdir()
|
||||
arr_dir = pak / "arrangements"
|
||||
arr_dir.mkdir()
|
||||
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))
|
||||
|
||||
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))
|
||||
|
||||
if rigs_payload is not None:
|
||||
(pak / "rigs.json").write_text(json.dumps(rigs_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)
|
||||
|
||||
|
||||
# ── Happy path ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_load_song_attaches_rigs_when_manifest_opts_in(tmp_path: Path):
|
||||
"""A source rig (spec §7.9 1.18.0) survives the load intact — including the
|
||||
`soundfont` realization and the `intent.gm` floor a consumer needs to voice
|
||||
the part."""
|
||||
payload = {
|
||||
"version": 1,
|
||||
"rigs": [
|
||||
{
|
||||
"id": "grand-piano",
|
||||
"name": "Grand Piano",
|
||||
"instrument": "keys",
|
||||
"blocks": [
|
||||
{
|
||||
"role": "source",
|
||||
"name": "Concert Grand",
|
||||
"intent": {"kind": "instrument", "gm": {"program": 0}},
|
||||
"realizations": [
|
||||
{"engine": "soundfont", "format": "sf2",
|
||||
"ref": "sounds/grand.sf2", "bank": 0, "program": 0},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
pak = _write_dir_sloppak(tmp_path, {"rigs": "rigs.json"}, payload)
|
||||
loaded = _load(pak, tmp_path)
|
||||
assert loaded.rigs is not None
|
||||
assert loaded.rigs["version"] == 1
|
||||
assert loaded.rigs["rigs"] == payload["rigs"]
|
||||
|
||||
|
||||
def test_load_song_rigs_absent_without_manifest_key(tmp_path: Path):
|
||||
"""The file alone must not opt a pack in — the manifest is the opt-in
|
||||
(spec §9.1, "manifest opt-in, file off to the side")."""
|
||||
pak = _write_dir_sloppak(tmp_path, {}, {"version": 1, "rigs": []})
|
||||
assert _load(pak, tmp_path).rigs is None
|
||||
|
||||
|
||||
# ── Verbatim passthrough ─────────────────────────────────────────────────────
|
||||
|
||||
def test_load_song_preserves_unknown_rig_content(tmp_path: Path):
|
||||
"""Unknown `role` / `engine` / `kind` values and `ext` namespaces MUST
|
||||
survive (spec §7.9) — core does not interpret rigs, so it must not prune
|
||||
what a newer writer or a plugin put there."""
|
||||
payload = {
|
||||
"version": 2,
|
||||
"rigs": [
|
||||
{
|
||||
"id": "future-rig",
|
||||
"blocks": [
|
||||
{"role": "quantum-flux", "intent": {"kind": "not-yet-invented"},
|
||||
"realizations": [{"engine": "some-future-engine", "ref": "x.bin"}],
|
||||
"ext": {"vendor.custom": {"anything": [1, 2, 3]}}},
|
||||
],
|
||||
"graph": {"nodes": ["input", "output"], "edges": [["input", "output"]]},
|
||||
"ext": {"vendor.rig": "kept"},
|
||||
},
|
||||
],
|
||||
}
|
||||
pak = _write_dir_sloppak(tmp_path, {"rigs": "rigs.json"}, payload)
|
||||
loaded = _load(pak, tmp_path)
|
||||
assert loaded.rigs["version"] == 2
|
||||
assert loaded.rigs["rigs"] == payload["rigs"]
|
||||
|
||||
|
||||
# ── Addressability ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_load_song_drops_unaddressable_rigs_and_normalizes_ids(tmp_path: Path):
|
||||
"""A rig is reachable only by `id`, so entries without a usable one are
|
||||
unreferenceable by construction. Ids are stripped to match the reference
|
||||
side, which lib/tones.py strips before it reaches the wire."""
|
||||
payload = {
|
||||
"rigs": [
|
||||
"not-a-dict",
|
||||
{"name": "no id at all"},
|
||||
{"id": "", "name": "blank id"},
|
||||
{"id": " ", "name": "whitespace id"},
|
||||
{"id": 7, "name": "non-string id"},
|
||||
{"id": " padded-rig ", "name": "Padded"},
|
||||
{"id": "plain-rig", "name": "Plain"},
|
||||
],
|
||||
}
|
||||
pak = _write_dir_sloppak(tmp_path, {"rigs": "rigs.json"}, payload)
|
||||
loaded = _load(pak, tmp_path)
|
||||
assert [r["id"] for r in loaded.rigs["rigs"]] == ["padded-rig", "plain-rig"]
|
||||
# Everything except the normalized id is untouched.
|
||||
assert loaded.rigs["rigs"][0]["name"] == "Padded"
|
||||
# `version` defaults when the file omits it.
|
||||
assert loaded.rigs["version"] == 1
|
||||
|
||||
|
||||
def test_load_song_first_rig_wins_on_duplicate_id(tmp_path: Path):
|
||||
"""A duplicate id makes `tones.base_rig` ambiguous, which would surface as
|
||||
the wrong sound rather than an error."""
|
||||
payload = {
|
||||
"rigs": [
|
||||
{"id": "dupe", "name": "First"},
|
||||
{"id": "dupe", "name": "Second"},
|
||||
{"id": " dupe ", "name": "Third, padded into a collision"},
|
||||
],
|
||||
}
|
||||
pak = _write_dir_sloppak(tmp_path, {"rigs": "rigs.json"}, payload)
|
||||
loaded = _load(pak, tmp_path)
|
||||
assert len(loaded.rigs["rigs"]) == 1
|
||||
assert loaded.rigs["rigs"][0]["name"] == "First"
|
||||
|
||||
|
||||
# ── Permissive posture (spec §7.9: never fail the pack) ──────────────────────
|
||||
|
||||
def test_load_song_survives_malformed_rigs(tmp_path: Path):
|
||||
"""Malformed / missing / traversing rig libraries disable rigs, never the
|
||||
pack — the song itself must still load."""
|
||||
cases = [
|
||||
{"version": 1, "rigs": "not-a-list"}, # wrong `rigs` type
|
||||
["top-level-not-a-dict"], # wrong document type
|
||||
{"version": 1}, # no `rigs` key at all
|
||||
]
|
||||
for i, payload in enumerate(cases):
|
||||
sub = tmp_path / f"case{i}"
|
||||
sub.mkdir()
|
||||
pak = _write_dir_sloppak(sub, {"rigs": "rigs.json"}, payload)
|
||||
loaded = _load(pak, sub)
|
||||
assert loaded.rigs is None, f"case {i} should disable rigs"
|
||||
assert loaded.song is not None, f"case {i} must not fail the pack"
|
||||
|
||||
|
||||
def test_load_song_survives_unparseable_rigs(tmp_path: Path):
|
||||
pak = _write_dir_sloppak(tmp_path, {"rigs": "rigs.json"}, None)
|
||||
(pak / "rigs.json").write_text("{ not json at all ")
|
||||
loaded = _load(pak, tmp_path)
|
||||
assert loaded.rigs is None
|
||||
assert loaded.song is not None
|
||||
|
||||
|
||||
def test_load_song_survives_missing_rigs_file(tmp_path: Path):
|
||||
pak = _write_dir_sloppak(tmp_path, {"rigs": "rigs.json"}, None)
|
||||
loaded = _load(pak, tmp_path)
|
||||
assert loaded.rigs is None
|
||||
assert loaded.song is not None
|
||||
|
||||
|
||||
def test_load_song_rejects_traversing_rigs_path(tmp_path: Path):
|
||||
"""A crafted manifest must not read outside the pack."""
|
||||
(tmp_path / "outside.json").write_text(json.dumps({"rigs": [{"id": "leaked"}]}))
|
||||
pak = _write_dir_sloppak(tmp_path, {"rigs": "../outside.json"}, None)
|
||||
loaded = _load(pak, tmp_path)
|
||||
assert loaded.rigs is None
|
||||
assert loaded.song is not None
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Loader coverage for the manifest-vs-in-JSON `tones` precedence cascade
|
||||
(feedpak 1.18.0, spec §5.1 / §5.2).
|
||||
|
||||
Two rules, both about *which* sound binding wins, neither about interpreting it:
|
||||
|
||||
- A manifest arrangement entry's `tones` replaces the arrangement JSON's
|
||||
`tones` **WHOLESALE** — no field-level merge. A half-merged block (this
|
||||
source's `base` with that source's `changes`) would be a sound nobody
|
||||
authored, so the two never blend.
|
||||
- Top-level `drum_tones` binds the song-level (primary) drum part and is the
|
||||
fallback; a `type: drums` entry's own `tones` takes precedence, and a
|
||||
Reader MUST NOT apply both to the same part.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
import sloppak as sloppak_mod
|
||||
|
||||
|
||||
IN_JSON_TONES = {
|
||||
"base": "In-JSON Clean",
|
||||
"base_rig": "injson-clean",
|
||||
"changes": [{"t": 5.0, "name": "In-JSON Lead", "rig": "injson-lead"}],
|
||||
}
|
||||
ENTRY_TONES = {
|
||||
"base": "Entry Grand",
|
||||
"base_rig": "entry-grand",
|
||||
"changes": [{"t": 9.0, "name": "Entry Rhodes", "rig": "entry-rhodes"}],
|
||||
}
|
||||
|
||||
|
||||
def _tab(name: str) -> dict:
|
||||
return {
|
||||
"version": 1,
|
||||
"name": name,
|
||||
"kit": [{"id": "kick", "name": "Kick"}],
|
||||
"hits": [{"t": 1.0, "p": "kick", "v": 100}],
|
||||
}
|
||||
|
||||
|
||||
def _write_pak(root: Path, manifest_extras: dict, arr_tones: dict | None = None,
|
||||
files: dict[str, dict] | None = None) -> Path:
|
||||
"""Directory-form sloppak with one Lead arrangement, optionally carrying an
|
||||
in-JSON `tones` block, plus any extra files."""
|
||||
pak = root / f"{root.name}.sloppak"
|
||||
pak.mkdir()
|
||||
arr_dir = pak / "arrangements"
|
||||
arr_dir.mkdir()
|
||||
arr = {
|
||||
"name": "Lead", "tuning": [0, 0, 0, 0, 0, 0], "capo": 0,
|
||||
"notes": [], "chords": [], "anchors": [], "handshapes": [],
|
||||
"templates": [], "beats": [], "sections": [],
|
||||
}
|
||||
if arr_tones is not None:
|
||||
arr["tones"] = arr_tones
|
||||
(arr_dir / "lead.json").write_text(json.dumps(arr))
|
||||
|
||||
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))
|
||||
for rel, payload in (files or {}).items():
|
||||
(pak / rel).write_text(json.dumps(payload))
|
||||
return pak
|
||||
|
||||
|
||||
def _load(pak_path: Path, tmp_path: Path):
|
||||
cache = tmp_path / "cache"
|
||||
cache.mkdir()
|
||||
return sloppak_mod.load_song(pak_path.name, pak_path.parent, cache)
|
||||
|
||||
|
||||
# ── Arrangement entry vs in-JSON (§5.2) ──────────────────────────────────────
|
||||
|
||||
def test_entry_tones_replaces_in_json_wholesale(tmp_path: Path):
|
||||
"""The entry object replaces the in-JSON one entirely — no key survives
|
||||
from the loser, not even ones the winner doesn't define."""
|
||||
entry_tones = {"base": "Entry Only"} # no base_rig, no changes
|
||||
pak = _write_pak(
|
||||
tmp_path,
|
||||
{"arrangements": [{"id": "lead", "name": "Lead",
|
||||
"file": "arrangements/lead.json",
|
||||
"tones": entry_tones}]},
|
||||
arr_tones=IN_JSON_TONES,
|
||||
)
|
||||
arr = _load(pak, tmp_path).song.arrangements[0]
|
||||
assert arr.tones == entry_tones
|
||||
# The in-JSON `base_rig` and `changes` must NOT have been merged in.
|
||||
assert "base_rig" not in arr.tones
|
||||
assert "changes" not in arr.tones
|
||||
|
||||
|
||||
def test_in_json_tones_survive_when_entry_has_none(tmp_path: Path):
|
||||
pak = _write_pak(tmp_path, {}, arr_tones=IN_JSON_TONES)
|
||||
assert _load(pak, tmp_path).song.arrangements[0].tones == IN_JSON_TONES
|
||||
|
||||
|
||||
def test_empty_entry_tones_is_absent_not_an_override(tmp_path: Path):
|
||||
"""`{}` reads as "didn't specify", not "override to silence" — otherwise a
|
||||
stray empty object silently unbinds the part's sound."""
|
||||
pak = _write_pak(
|
||||
tmp_path,
|
||||
{"arrangements": [{"id": "lead", "name": "Lead",
|
||||
"file": "arrangements/lead.json", "tones": {}}]},
|
||||
arr_tones=IN_JSON_TONES,
|
||||
)
|
||||
assert _load(pak, tmp_path).song.arrangements[0].tones == IN_JSON_TONES
|
||||
|
||||
|
||||
def test_malformed_entry_tones_is_ignored(tmp_path: Path):
|
||||
"""A non-dict `tones` must not override, and must not crash the load."""
|
||||
pak = _write_pak(
|
||||
tmp_path,
|
||||
{"arrangements": [{"id": "lead", "name": "Lead",
|
||||
"file": "arrangements/lead.json",
|
||||
"tones": ["not", "a", "dict"]}]},
|
||||
arr_tones=IN_JSON_TONES,
|
||||
)
|
||||
assert _load(pak, tmp_path).song.arrangements[0].tones == IN_JSON_TONES
|
||||
|
||||
|
||||
def test_entry_tones_binds_a_notation_only_arrangement(tmp_path: Path):
|
||||
"""§5.2: entry `tones` is available whether or not the arrangement has a
|
||||
`file` — a keys part is a notation-only entry, and binding its sound is the
|
||||
whole point of the 1.18.0 work."""
|
||||
notation = {"version": 1, "measures": []}
|
||||
pak = _write_pak(
|
||||
tmp_path,
|
||||
{"arrangements": [{"id": "keys", "name": "Keys",
|
||||
"notation": "notation_keys.json",
|
||||
"tones": ENTRY_TONES}]},
|
||||
files={"notation_keys.json": notation},
|
||||
)
|
||||
arr = _load(pak, tmp_path).song.arrangements[0]
|
||||
assert arr.name == "Keys"
|
||||
assert arr.tones == ENTRY_TONES
|
||||
|
||||
|
||||
# ── drum_tones vs entry tones (§5.1) ─────────────────────────────────────────
|
||||
|
||||
def test_drum_tones_binds_the_primary_part(tmp_path: Path):
|
||||
pak = _write_pak(
|
||||
tmp_path,
|
||||
{"drum_tab": "drum_tab.json", "drum_tones": ENTRY_TONES},
|
||||
files={"drum_tab.json": _tab("Drums")},
|
||||
)
|
||||
parts = _load(pak, tmp_path).drum_parts
|
||||
assert len(parts) == 1
|
||||
assert parts[0]["tones"] == ENTRY_TONES
|
||||
|
||||
|
||||
def test_entry_tones_outrank_drum_tones_on_the_primary(tmp_path: Path):
|
||||
"""An alias pointer entry naming the same file IS the primary, so its own
|
||||
binding wins — and `drum_tones` must not also be applied."""
|
||||
alias_tones = {"base": "Alias Kit", "base_rig": "alias-kit"}
|
||||
pak = _write_pak(
|
||||
tmp_path,
|
||||
{
|
||||
"drum_tab": "drum_tab.json",
|
||||
"drum_tones": ENTRY_TONES,
|
||||
"arrangements": [
|
||||
{"id": "lead", "name": "Lead", "file": "arrangements/lead.json"},
|
||||
{"id": "drums", "name": "Drums", "type": "drums",
|
||||
"drum_tab": "drum_tab.json", "tones": alias_tones},
|
||||
],
|
||||
},
|
||||
files={"drum_tab.json": _tab("Drums")},
|
||||
)
|
||||
parts = _load(pak, tmp_path).drum_parts
|
||||
assert len(parts) == 1
|
||||
assert parts[0]["tones"] == alias_tones
|
||||
|
||||
|
||||
def test_drum_tones_does_not_leak_to_secondary_parts(tmp_path: Path):
|
||||
"""`drum_tones` is the PRIMARY's fallback only. A second drummer with no
|
||||
binding of its own gets None — not the primary's kit."""
|
||||
live_tones = {"base": "Live Kit", "base_rig": "live-kit"}
|
||||
pak = _write_pak(
|
||||
tmp_path,
|
||||
{
|
||||
"drum_tab": "drum_tab.json",
|
||||
"drum_tones": ENTRY_TONES,
|
||||
"arrangements": [
|
||||
{"id": "lead", "name": "Lead", "file": "arrangements/lead.json"},
|
||||
{"id": "drums-live", "name": "Drums (Live)", "type": "drums",
|
||||
"drum_tab": "drum_tab_live.json", "tones": live_tones},
|
||||
{"id": "drums-prog", "name": "Drums (Prog)", "type": "drums",
|
||||
"drum_tab": "drum_tab_prog.json"},
|
||||
],
|
||||
},
|
||||
files={
|
||||
"drum_tab.json": _tab("Drums"),
|
||||
"drum_tab_live.json": _tab("Drums Live"),
|
||||
"drum_tab_prog.json": _tab("Drums Prog"),
|
||||
},
|
||||
)
|
||||
parts = {p["id"]: p for p in _load(pak, tmp_path).drum_parts}
|
||||
assert parts["drums"]["tones"] == ENTRY_TONES # primary, from drum_tones
|
||||
assert parts["drums-live"]["tones"] == live_tones # own entry
|
||||
assert parts["drums-prog"]["tones"] is None # no binding, no leak
|
||||
|
||||
|
||||
def test_drum_parts_carry_none_when_pack_binds_nothing(tmp_path: Path):
|
||||
"""A pack with drums and no sound binding at all still loads, with the key
|
||||
present and None — consumers can read `part["tones"]` unconditionally."""
|
||||
pak = _write_pak(
|
||||
tmp_path,
|
||||
{"drum_tab": "drum_tab.json"},
|
||||
files={"drum_tab.json": _tab("Drums")},
|
||||
)
|
||||
parts = _load(pak, tmp_path).drum_parts
|
||||
assert parts[0]["tones"] is None
|
||||
|
||||
|
||||
def test_malformed_drum_tones_is_ignored(tmp_path: Path):
|
||||
pak = _write_pak(
|
||||
tmp_path,
|
||||
{"drum_tab": "drum_tab.json", "drum_tones": "not-a-dict"},
|
||||
files={"drum_tab.json": _tab("Drums")},
|
||||
)
|
||||
assert _load(pak, tmp_path).drum_parts[0]["tones"] is None
|
||||
+57
-8
@@ -6,16 +6,17 @@ from tones import sloppak_tone_changes
|
||||
# ── sloppak_tone_changes (highway payload builder) ───────────────────────────
|
||||
|
||||
def test_sloppak_tone_changes_sorts_and_returns_base():
|
||||
base, changes = sloppak_tone_changes({
|
||||
base, base_rig, changes = sloppak_tone_changes({
|
||||
"base": "Clean",
|
||||
"changes": [{"t": 12.5, "name": "Drive"}, {"t": 3.0, "name": "Clean"}],
|
||||
})
|
||||
assert base == "Clean"
|
||||
assert base_rig == ""
|
||||
assert changes == [{"t": 3.0, "name": "Clean"}, {"t": 12.5, "name": "Drive"}]
|
||||
|
||||
|
||||
def test_sloppak_tone_changes_skips_malformed_markers():
|
||||
_, changes = sloppak_tone_changes({
|
||||
_, _, changes = sloppak_tone_changes({
|
||||
"changes": [
|
||||
{"t": "nan", "name": "BadStr"},
|
||||
{"t": float("inf"), "name": "Inf"},
|
||||
@@ -29,18 +30,66 @@ def test_sloppak_tone_changes_skips_malformed_markers():
|
||||
|
||||
|
||||
def test_sloppak_tone_changes_handles_none_and_bad_base():
|
||||
assert sloppak_tone_changes(None) == ("", [])
|
||||
base, changes = sloppak_tone_changes({"base": 123, "changes": []})
|
||||
assert base == "" and changes == []
|
||||
assert sloppak_tone_changes(None) == ("", "", [])
|
||||
base, base_rig, changes = sloppak_tone_changes({"base": 123, "changes": []})
|
||||
assert base == "" and base_rig == "" and changes == []
|
||||
|
||||
|
||||
def test_sloppak_tone_changes_non_dict_input():
|
||||
"""A truthy non-dict payload must not crash."""
|
||||
assert sloppak_tone_changes(["not", "a", "dict"]) == ("", [])
|
||||
assert sloppak_tone_changes("nope") == ("", [])
|
||||
assert sloppak_tone_changes(["not", "a", "dict"]) == ("", "", [])
|
||||
assert sloppak_tone_changes("nope") == ("", "", [])
|
||||
|
||||
|
||||
def test_sloppak_tone_changes_non_list_changes():
|
||||
"""A truthy non-list `changes` value must not raise on iteration."""
|
||||
base, changes = sloppak_tone_changes({"base": "Clean", "changes": 1})
|
||||
base, _, changes = sloppak_tone_changes({"base": "Clean", "changes": 1})
|
||||
assert base == "Clean" and changes == []
|
||||
|
||||
|
||||
# ── rig bindings (feedpak-spec 1.18.0 §6.9) ──────────────────────────────────
|
||||
|
||||
def test_sloppak_tone_changes_carries_rig_bindings():
|
||||
"""`base_rig` and per-change `rig` reach the wire — the binding a chart
|
||||
declares is what core must hand the consumer that voices the part."""
|
||||
base, base_rig, changes = sloppak_tone_changes({
|
||||
"base": "Clean Rhythm",
|
||||
"base_rig": "clean-rhythm",
|
||||
"changes": [
|
||||
{"t": 12.5, "name": "Lead Drive", "rig": "lead-drive"},
|
||||
{"t": 48.0, "name": "Clean Rhythm", "rig": "clean-rhythm"},
|
||||
],
|
||||
})
|
||||
assert base == "Clean Rhythm"
|
||||
assert base_rig == "clean-rhythm"
|
||||
assert changes == [
|
||||
{"t": 12.5, "name": "Lead Drive", "rig": "lead-drive"},
|
||||
{"t": 48.0, "name": "Clean Rhythm", "rig": "clean-rhythm"},
|
||||
]
|
||||
|
||||
|
||||
def test_sloppak_tone_changes_omits_unusable_rig_ids():
|
||||
"""A non-string or blank `rig` is dropped rather than forwarded, so a
|
||||
consumer can treat presence of the key as "this change binds a rig"."""
|
||||
_, base_rig, changes = sloppak_tone_changes({
|
||||
"base_rig": " ",
|
||||
"changes": [
|
||||
{"t": 1.0, "name": "A", "rig": 7},
|
||||
{"t": 2.0, "name": "B", "rig": ""},
|
||||
{"t": 3.0, "name": "C", "rig": None},
|
||||
{"t": 4.0, "name": "D", "rig": " padded-id "},
|
||||
],
|
||||
})
|
||||
assert base_rig == ""
|
||||
assert changes == [
|
||||
{"t": 1.0, "name": "A"},
|
||||
{"t": 2.0, "name": "B"},
|
||||
{"t": 3.0, "name": "C"},
|
||||
{"t": 4.0, "name": "D", "rig": "padded-id"},
|
||||
]
|
||||
|
||||
|
||||
def test_sloppak_tone_changes_non_string_base_rig():
|
||||
"""A non-string `base_rig` must not crash or leak a non-id onto the wire."""
|
||||
_, base_rig, _ = sloppak_tone_changes({"base": "Clean", "base_rig": 42})
|
||||
assert base_rig == ""
|
||||
|
||||
+117
-14
@@ -74,6 +74,52 @@ def build_pack(src_dir: Path, out_zip: Path) -> dict:
|
||||
return {"sha256": hashlib.sha256(data).hexdigest(), "bytes": len(data)}
|
||||
|
||||
|
||||
# rig_builder ships "fat" .vst3 bundles carrying all three platforms inside
|
||||
# Contents/. A pack for one platform keeps that platform's binary dir + the
|
||||
# shared bundle files, and drops the other two.
|
||||
VST_PLATFORM_DIRS = {"mac": "MacOS", "win": "x86_64-win", "linux": "x86_64-linux"}
|
||||
|
||||
|
||||
def build_vst_pack(vst_root: Path, out_zip: Path, platform: str) -> dict:
|
||||
"""Reproducibly zip the VST tree keeping only `platform`'s binaries.
|
||||
|
||||
Slices each fat .vst3: everything is kept except the two foreign platform
|
||||
dirs (MacOS / x86_64-win / x86_64-linux) and the vst/src build trees. Arc
|
||||
names are relative to vst_root so the download endpoint extracts straight
|
||||
into <plugin>/vst/. Same reproducible-build guarantees as build_pack.
|
||||
"""
|
||||
if platform not in VST_PLATFORM_DIRS:
|
||||
raise ValueError(f"unknown platform {platform!r} (want mac/win/linux)")
|
||||
foreign = set(VST_PLATFORM_DIRS.values()) - {VST_PLATFORM_DIRS[platform]}
|
||||
files = []
|
||||
for p in sorted(vst_root.rglob("*"), key=lambda q: q.as_posix()):
|
||||
if not p.is_file():
|
||||
continue
|
||||
rel = p.relative_to(vst_root)
|
||||
if rel.parts and rel.parts[0] == "src": # skip C++/JUCE build trees
|
||||
continue
|
||||
if set(rel.parts) & foreign: # drop foreign-platform binaries
|
||||
continue
|
||||
files.append((p, rel))
|
||||
if not files:
|
||||
raise ValueError(f"no VST files to pack for {platform} in {vst_root}")
|
||||
out_zip.parent.mkdir(parents=True, exist_ok=True)
|
||||
with zipfile.ZipFile(out_zip, "w", zipfile.ZIP_STORED) as zf:
|
||||
for p, rel in files:
|
||||
info = zipfile.ZipInfo(rel.as_posix(), date_time=(1980, 1, 1, 0, 0, 0))
|
||||
info.compress_type = zipfile.ZIP_STORED
|
||||
# Pin create_system like build_pack: ZipInfo defaults it from the
|
||||
# host OS (0 on Windows, 3 on Unix), which would otherwise make the
|
||||
# same pack hash differently across runners. VST packs are the most
|
||||
# likely to be built on Windows (native .vst3), so without this pin
|
||||
# the precomputable-hash guarantee breaks exactly where it's needed.
|
||||
info.create_system = 3
|
||||
info.external_attr = 0o644 << 16
|
||||
zf.writestr(info, p.read_bytes())
|
||||
data = out_zip.read_bytes()
|
||||
return {"sha256": hashlib.sha256(data).hexdigest(), "bytes": len(data)}
|
||||
|
||||
|
||||
def manifest_entry(out_zip: Path, url: str) -> dict:
|
||||
"""Pack info as the download-path expects it: {url, sha256, bytes}."""
|
||||
return {"url": url,
|
||||
@@ -96,25 +142,47 @@ def pack_url(pack_id: str, version: int, repo: str = REPO) -> str:
|
||||
f"{pack_tag(pack_id, version)}/{pack_asset(pack_id, version)}")
|
||||
|
||||
|
||||
def publish(pack_id: str, version: int, zip_path: Path, repo: str = REPO) -> None:
|
||||
# VST packs use the same immutable per-pack convention, keyed by platform:
|
||||
# tag `vst-<plat>-v<N>`, asset `vst-<plat>-pack-v<N>.zip`. The manifest they
|
||||
# emit is keyed by platform (mac/win/linux) — the shape the rig_builder plugin's
|
||||
# data/vst_packs.json consumes.
|
||||
def vst_tag(platform: str, version: int) -> str:
|
||||
return f"vst-{platform}-v{version}"
|
||||
|
||||
|
||||
def vst_asset(platform: str, version: int) -> str:
|
||||
return f"vst-{platform}-pack-v{version}.zip"
|
||||
|
||||
|
||||
def vst_url(platform: str, version: int, repo: str = REPO) -> str:
|
||||
return (f"https://github.com/{repo}/releases/download/"
|
||||
f"{vst_tag(platform, version)}/{vst_asset(platform, version)}")
|
||||
|
||||
|
||||
def _publish_release(tag: str, zip_path: Path, title: str, notes: str,
|
||||
repo: str = REPO) -> None:
|
||||
"""Create the per-pack release if missing, then upload the versioned zip.
|
||||
|
||||
Tags are immutable: a media change means a new version (v1 → v2), never a
|
||||
re-upload — so no --clobber. gh errors if the asset already exists, which is
|
||||
the right guard against overwriting a published, referenced pack.
|
||||
"""
|
||||
tag = pack_tag(pack_id, version)
|
||||
if subprocess.run(["gh", "release", "view", tag, "--repo", repo],
|
||||
capture_output=True).returncode != 0:
|
||||
subprocess.run(
|
||||
["gh", "release", "create", tag, "--repo", repo, "--latest=false",
|
||||
"--title", f"{pack_id.capitalize()} venue pack v{version}",
|
||||
"--notes", "Opt-in career venue pack. Not a code release."],
|
||||
"--title", title, "--notes", notes],
|
||||
check=True)
|
||||
subprocess.run(
|
||||
["gh", "release", "upload", tag, str(zip_path), "--repo", repo], check=True)
|
||||
|
||||
|
||||
def publish(pack_id: str, version: int, zip_path: Path, repo: str = REPO) -> None:
|
||||
_publish_release(pack_tag(pack_id, version), zip_path,
|
||||
f"{pack_id.capitalize()} venue pack v{version}",
|
||||
"Opt-in career venue pack. Not a code release.", repo)
|
||||
|
||||
|
||||
def _pack_id(src_dir: Path) -> str:
|
||||
return src_dir.name
|
||||
|
||||
@@ -129,6 +197,10 @@ def main(argv=None) -> int:
|
||||
help="write zips here + a file:// manifest.json; no upload")
|
||||
ap.add_argument("--publish", action="store_true",
|
||||
help="create/upload the per-pack release; emit release URLs")
|
||||
ap.add_argument("--vst", action="store_true",
|
||||
help="slice one rig VST root (src[0]) into per-platform "
|
||||
"vst-<plat>-v<N> packs; manifest keyed by platform "
|
||||
"(the shape rig_builder's data/vst_packs.json wants)")
|
||||
ap.add_argument("--manifest", type=Path,
|
||||
help="write the {id: {url,sha256,bytes}} map here (default: stdout)")
|
||||
ap.add_argument("--selfcheck", action="store_true", help="run the round-trip demo and exit")
|
||||
@@ -141,16 +213,30 @@ def main(argv=None) -> int:
|
||||
|
||||
out_dir = args.local if args.local else Path(args.src[0]).parent / "_packs"
|
||||
manifest = {}
|
||||
for src in args.src:
|
||||
pid = _pack_id(src)
|
||||
zip_path = out_dir / pack_asset(pid, args.version)
|
||||
build_pack(src, zip_path)
|
||||
if args.publish:
|
||||
publish(pid, args.version, zip_path)
|
||||
url = pack_url(pid, args.version)
|
||||
else:
|
||||
url = (out_dir.resolve() / zip_path.name).as_uri()
|
||||
manifest[pid] = manifest_entry(zip_path, url)
|
||||
if args.vst:
|
||||
vst_root = args.src[0]
|
||||
for plat in VST_PLATFORM_DIRS:
|
||||
zip_path = out_dir / vst_asset(plat, args.version)
|
||||
build_vst_pack(vst_root, zip_path, plat)
|
||||
if args.publish:
|
||||
_publish_release(vst_tag(plat, args.version), zip_path,
|
||||
f"Rig VST pack ({plat}) v{args.version}",
|
||||
"Opt-in per-platform rig VST pack. Not a code release.")
|
||||
url = vst_url(plat, args.version)
|
||||
else:
|
||||
url = (out_dir.resolve() / zip_path.name).as_uri()
|
||||
manifest[plat] = manifest_entry(zip_path, url)
|
||||
else:
|
||||
for src in args.src:
|
||||
pid = _pack_id(src)
|
||||
zip_path = out_dir / pack_asset(pid, args.version)
|
||||
build_pack(src, zip_path)
|
||||
if args.publish:
|
||||
publish(pid, args.version, zip_path)
|
||||
url = pack_url(pid, args.version)
|
||||
else:
|
||||
url = (out_dir.resolve() / zip_path.name).as_uri()
|
||||
manifest[pid] = manifest_entry(zip_path, url)
|
||||
|
||||
out = json.dumps(manifest, indent=2)
|
||||
if args.manifest:
|
||||
@@ -183,6 +269,23 @@ def _selfcheck() -> int:
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
names = zf.namelist()
|
||||
assert set(names) == {"manifest.json", "bored.mp4"}, names
|
||||
|
||||
# VST slice: keep target platform + shared, drop foreign, reproducible.
|
||||
c = td / "vst" / "Foo.vst3" / "Contents"
|
||||
for d in ("MacOS", "x86_64-win", "x86_64-linux", "Resources"):
|
||||
(c / d).mkdir(parents=True)
|
||||
(c / "MacOS" / "Foo").write_bytes(b"mac")
|
||||
(c / "x86_64-linux" / "Foo.so").write_bytes(b"linux")
|
||||
(c / "Info.plist").write_bytes(b"<plist/>")
|
||||
vzip = td / vst_asset("linux", 1)
|
||||
vinfo = build_vst_pack(td / "vst", vzip, "linux")
|
||||
assert vinfo == build_vst_pack(td / "vst", td / "v2.zip", "linux"), \
|
||||
"vst slice is not reproducible"
|
||||
with zipfile.ZipFile(vzip) as zf:
|
||||
vnames = set(zf.namelist())
|
||||
assert "Foo.vst3/Contents/x86_64-linux/Foo.so" in vnames
|
||||
assert "Foo.vst3/Contents/Info.plist" in vnames
|
||||
assert not any("MacOS" in n for n in vnames), vnames
|
||||
print("content_packs selfcheck: ok")
|
||||
return 0
|
||||
|
||||
|
||||
Reference in New Issue
Block a user