diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md index d5837fc..713b506 100644 --- a/.specify/memory/constitution.md +++ b/.specify/memory/constitution.md @@ -100,7 +100,9 @@ do not collide in `sys.modules`. The whole point of Slopsmith is that a user points it at an existing song library folder and it Just Works. The library is scanned and indexed in `meta.db` (SQLite via `MetadataDB`). The open Sloppak -format (`lib/sloppak.py`, `docs/sloppak-spec.md`) is the preferred +format (`lib/sloppak.py`; specified at +[got-feedback/feedback-feedpak-spec](https://github.com/got-feedback/feedback-feedpak-spec), +published as feedpak — same format) is the preferred format and the home for new features; loose-folder XML charts (`lib/loosefolder.py`) are also discovered and played as a first-class format. Both must keep playing across releases. diff --git a/CLAUDE.md b/CLAUDE.md index b92650f..0d3c2a2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -532,7 +532,13 @@ lyrics.json Syllable-level lyrics (optional) Sloppak is the preferred format for new features. The [Stems plugin](https://github.com/topkoa/slopsmith-plugin-stems) provides live stem mixing for sloppak songs. -**Full developer reference:** [docs/sloppak-spec.md](docs/sloppak-spec.md) — manifest schema, arrangement wire format, and how to extend the format with new data types (drum tab, key/scale annotations, etc.). +**Full developer reference:** the authoritative format spec now lives in its own repo — +[got-feedback/feedback-feedpak-spec](https://github.com/got-feedback/feedback-feedpak-spec) +([`spec/feedpak-v1.md`](https://github.com/got-feedback/feedback-feedpak-spec/blob/main/spec/feedpak-v1.md)): +manifest schema, arrangement wire format, and how to extend the format with new data types (drum +tab, key/scale annotations, etc.). Published as **feedpak**; this codebase still uses the legacy +**sloppak** name internally — same on-disk format. [docs/sloppak-spec.md](docs/sloppak-spec.md) is +a local pointer + code map. **Key code:** - `lib/sloppak.py` — format detection, zip/directory resolution, metadata extraction, song loading diff --git a/VERSION b/VERSION index 1866a36..0d91a54 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.9 +0.3.0 diff --git a/docs/sloppak-hand-editing.md b/docs/sloppak-hand-editing.md index 4d4c585..3e3dfba 100644 --- a/docs/sloppak-hand-editing.md +++ b/docs/sloppak-hand-editing.md @@ -4,7 +4,7 @@ A `.sloppak` is just a zip of plain files: some YAML, some JSON, some OGG audio, This guide walks through the most common edits, aimed at musicians who are comfortable with a text editor and Audacity but don't live on the command line. -> For the format **schema** (what every field means, how the wire format works, how to extend the format with new data types), see [sloppak-spec.md](sloppak-spec.md). This document is the **how-do-I-actually-edit-mine** companion. +> For the format **schema** (what every field means, how the wire format works, how to extend the format with new data types), see the authoritative [feedpak spec](https://github.com/got-feedback/feedback-feedpak-spec/blob/main/spec/feedpak-v1.md) (the local [sloppak-spec.md](sloppak-spec.md) is now a pointer to it). This document is the **how-do-I-actually-edit-mine** companion. --- @@ -242,7 +242,7 @@ For 4-string bass, only indices 0–3 are meaningful; leave 4 and 5 at `0`. ### What *not* to put in `manifest.yaml` -Don't add per-machine settings (audio device picks, MIDI port IDs), UI state, or your own play counts. The sloppak holds the song's authored data — anything that varies by user or machine lives in Slopsmith's config dir or the metadata DB. See [sloppak-spec.md §5.7](sloppak-spec.md#57-dont-break-the-manifest-contract) for the full list. +Don't add per-machine settings (audio device picks, MIDI port IDs), UI state, or your own play counts. The sloppak holds the song's authored data — anything that varies by user or machine lives in Slopsmith's config dir or the metadata DB. See [feedpak spec §9.5](https://github.com/got-feedback/feedback-feedpak-spec/blob/main/spec/feedpak-v1.md#95-what-does-not-belong-in-a-feedpak) for the full list. --- @@ -261,6 +261,6 @@ For your own use, you can skip this entirely — Slopsmith reads the directory f ## Out of scope (for now) -- **Authoring a sloppak from scratch** (no Guitar Pro / MusicXML source file) — that's a developer task. Start at [sloppak-spec.md §4.2](sloppak-spec.md#42-writing-python-server-side). -- **Editing notes / chords in `arrangements/*.json`** — technically possible but extremely tedious by hand: hundreds of objects with short field names per song. The fields are documented in [sloppak-spec.md §3](sloppak-spec.md#3-arrangement-json--the-wire-format), but for any real chart edit you want the [Arrangement Editor plugin](https://github.com/got-feedback/feedback-plugin-editor). +- **Authoring a sloppak from scratch** (no Guitar Pro / MusicXML source file) — that's a developer task. Start at [feedpak spec §8 (Reading and writing)](https://github.com/got-feedback/feedback-feedpak-spec/blob/main/spec/feedpak-v1.md#8-reading-and-writing). +- **Editing notes / chords in `arrangements/*.json`** — technically possible but extremely tedious by hand: hundreds of objects with short field names per song. The fields are documented in [feedpak spec §6 (Arrangement JSON)](https://github.com/got-feedback/feedback-feedpak-spec/blob/main/spec/feedpak-v1.md#6-arrangement-json), but for any real chart edit you want the [Arrangement Editor plugin](https://github.com/got-feedback/feedback-plugin-editor). - **Loudness normalization / advanced stem processing** — out of scope here; standard Audacity or ffmpeg workflows apply to any OGG file before you drop it into `stems/`. diff --git a/docs/sloppak-spec.md b/docs/sloppak-spec.md index 7e83f9d..ad9b438 100644 --- a/docs/sloppak-spec.md +++ b/docs/sloppak-spec.md @@ -1,939 +1,49 @@ -# Sloppak Format — Developer Guide +# Sloppak / feedpak Format — moved -Sloppak is Slopsmith's open, hand-editable song format. This guide is for developers who want to **read**, **write**, or **extend** the format — including adding new data types like drum tabs, vocal pitches, lighting cues, key/scale annotations, or anything else a future visualization plugin might need. +The full format specification that used to live here has moved to its own repository and is now +the **authoritative, versioned reference**: -> If you're a **user** wanting to modify an existing sloppak — record your own rhythm stem, fix metadata, swap cover art, replace a Demucs split — see [sloppak-hand-editing.md](sloppak-hand-editing.md). That guide is the practical, step-by-step companion to this developer reference. +> **📖 https://github.com/got-feedback/feedback-feedpak-spec** +> — normative spec ([`spec/feedpak-v1.md`](https://github.com/got-feedback/feedback-feedpak-spec/blob/main/spec/feedpak-v1.md)), +> JSON Schemas, examples, and a reference validator. -The authoritative format reference lives in code (`lib/sloppak.py`, `lib/song.py`); this doc explains the why, the how, and the conventions you should follow when adding to it. +Update bookmarks to point there. This page is a thin pointer kept at the original path so existing +links keep resolving. ---- +## Naming: `sloppak` here, `feedpak` in the spec -## 1. Format at a glance +The published format is named **feedpak** (extension `.feedpak`, manifest key `feedpak_version`). +This codebase still uses the legacy **sloppak** name internally — `lib/sloppak.py`, the +`.sloppak` extension, `SLOPSMITH_*` env vars, etc. **They describe the same on-disk format.** The +rename is repo/public-facing only for now (see the top-level workspace `CLAUDE.md`), so when the +spec says `feedpak` / `feedpak_version`, the packs this server reads and writes today are the same +structure under the `.sloppak` name. The internal rename is a separate, later effort. -A sloppak exists in **two interchangeable forms**: +## Hand-editing a pack -| Form | What it is | Used for | -|---|---|---| -| **Directory** | A folder named `*.sloppak/` containing the files below | Authoring, hand editing, plugin development | -| **Zip archive** | A `.sloppak` file (zip with the same files inside) | Distribution | +For the practical "how do I edit my own pack" walkthrough (record your own stem, fix metadata, +swap cover art, replace a stem split), see the companion guide that stays in this repo: +[sloppak-hand-editing.md](sloppak-hand-editing.md). -Both forms hold identical contents. Slopsmith resolves either transparently — zip files are unpacked to a cache the first time they're opened (see `resolve_source_dir()` in [lib/sloppak.py](../lib/sloppak.py)). +## Where the format maps to code (this repo) -### Directory layout - -``` -my-song.sloppak/ -├── manifest.yaml # Required — all metadata + file index -├── arrangements/ -│ ├── lead.json # One JSON per playable arrangement -│ ├── rhythm.json -│ └── bass.json -├── stems/ -│ ├── full.ogg # Mixed audio (initial single-stem output; may be absent after stem splitting) -│ ├── guitar.ogg # Optional individual stems -│ ├── bass.ogg -│ ├── drums.ogg -│ ├── vocals.ogg -│ └── other.ogg -├── lyrics.json # Optional — syllable-level lyrics -└── cover.jpg # Optional — album art -``` - -Three rules to remember: - -1. **`manifest.yaml` is the index.** Nothing inside the sloppak is auto-discovered — every file path is listed in the manifest. This makes the format predictable: no scanning, no guessing. (One historical exception: the cover-art handler in `server.py` falls back to `cover.jpg` when `manifest.cover` is missing. New code should not add similar filename fallbacks.) -2. **Filenames in `manifest.yaml` are POSIX paths**, relative to the sloppak root (forward slashes, no leading `/`). -3. **YAML for the manifest, JSON for everything else.** YAML is hand-editable for users; JSON is fast-parsed and easy to round-trip in code. - ---- - -## 2. `manifest.yaml` reference - -Minimal valid manifest: - -```yaml -title: "Black Hole Sun" -artist: "Soundgarden" -duration: 320.5 -arrangements: - - id: lead - name: Lead - file: arrangements/lead.json - tuning: [0, 0, 0, 0, 0, 0] - capo: 0 -stems: - - id: full - file: stems/full.ogg - default: true -``` - -Full set of currently-recognized top-level keys: - -| Key | Type | Required | Description | -|---|---|---|---| -| `title` | string | yes | Song title | -| `artist` | string | yes | Artist name | -| `album` | string | no | Album | -| `year` | int | no | Release year | -| `duration` | float | yes | Song length in seconds | -| `arrangements` | list | yes | Playable arrangements (see §2.1) | -| `stems` | list | yes | Audio stems (see §2.2) | -| `stem_separation` | object | no | Structured metadata when stems were produced by an automated separation engine (currently `demucs`). Shape: `{engine, model, version}`. See §2.2 for fields + semver semantics per [slopsmith#357](https://github.com/got-feedback/feedback/issues/357). Omitted for single-stem sloppaks (`stems: [{id: full, ...}]`) and for hand-edited / user-recorded stems | -| `lyrics` | string | no | Path to lyrics JSON | -| `lyrics_source` | string | no | Where the lyrics came from: `xml` (vocals XML from the chart source), `whisperx` (auto-transcribed), or `user` (hand-edited). Absent on legacy sloppaks — readers should treat missing as `xml` | -| `lyric_transcription` | object | no | Structured metadata when lyrics came from an automated engine (currently `whisperx`). Same shape as the parent `stem_separation` block defined by [slopsmith#357](https://github.com/got-feedback/feedback/issues/357) — see §2.3 for fields and semver semantics. Omitted for authored lyrics (`xml`/`user`) | -| `vocal_pitch` | string | no | Path to per-syllable pitch JSON (`{"version": 1, "notes": [{t, d, midi}, ...]}`). Consumed by [slopsmith-plugin-lyrics-karaoke](https://github.com/got-feedback/feedback-plugin-lyrics-karaoke) to render karaoke note bars. See §2.4 | -| `pitch_extraction` | object | no | Structured metadata when pitch was extracted by an automated engine (currently `crepe` via the demucs server's `/pitch` endpoint). Same shape as `stem_separation` / `lyric_transcription`. Omitted for hand-edited pitch tracks | -| `cover` | string | no | Path to cover image | -| `preview` | string | no | Path to a short preview audio clip (OGG) at the sloppak root. Populated when the source carries a separate short browser-preview clip (decoded to `preview.ogg`); absent otherwise. Consumed by [`slopsmith-plugin-song-preview`](https://github.com/got-feedback/feedback-plugin-song-preview) for hover-to-listen previews in the library | -| `song_timeline` | string | no | Path to a `song_timeline.json` file carrying song-wide beats and sections (see §5.3). When present, its data takes priority over any beats/sections embedded in arrangement JSONs. Older readers ignore the key and fall back to reading beats/sections from the first arrangement JSON as before | -| `drum_tab` | string | no | Path to `drum_tab.json` — per-piece drum hits (see §5.3). Implemented end-to-end as of slopsmith#344 | - -Unknown keys are **silently ignored** by the loader. This is deliberate — it's the extensibility hook (see §5). - -### 2.1. `arrangements[]` - -Each entry describes one playable arrangement and points at its JSON file: - -```yaml -arrangements: - - id: lead # filesystem-safe stable ID, used for filenames - name: Lead # display name (Lead/Rhythm/Bass/Combo are sorted first) - file: arrangements/lead.json - tuning: [0, 0, 0, 0, 0, 0] # six semitone offsets from E A D G B E - capo: 0 - centOffset: 0.0 # optional float, cents; default 0.0 -``` - -- `tuning` is a list of semitone offsets from standard `E2 A2 D2 G3 B3 E4`. **Six elements is the standard six-string convention** and the only length `lib/tunings.py` produces friendly names for; 5- and 7-string content is accepted by the loader and falls through to a numeric label. For bass, the four bass strings are at indices 0–3; the other two slots are `0`. Consumers should not hard-code `len(tuning) == 6`. -- `name` controls the sort order in the UI: `Lead > Combo > Rhythm > Bass > everything else`. -- `centOffset` is a pitch-shift value in cents. Commonly `-1200.0` for extended-range bass arrangements tuned one octave down; small non-zero values for songs mastered at a non-A440 reference pitch (e.g. A443 ≈ +11.8 cents). Absent / `0.0` means no shift. Exposed to plugins via `getSongInfo().centOffset`. -- Manifest-level `tuning`, `capo`, and `centOffset` **override** anything embedded in the arrangement JSON. The arrangement JSON's own values are fallbacks. -- `notation` (optional string) — path to a `notation_.json` file carrying standard musical notation data for this arrangement (see §5.3). When present, the loader surfaces it on `LoadedSloppak.notation_by_id[id]` and the highway WS streams `notation_info` + `notation_measures` messages. The `file:` key may be omitted when `notation:` is present — the loader creates a stub arrangement so the notation file can be the sole data source. - -### 2.2. `stems[]` - -```yaml -stems: - - id: full - file: stems/full.ogg - default: true # plays by default when the song opens - - id: guitar - file: stems/guitar.ogg - default: true - - id: drums - file: stems/drums.ogg - default: false -``` - -- `id` is referenced by the Stems plugin and any other consumer; keep it stable. -- `default` accepts `true`/`false`, or strings (`"on"`/`"off"`/`"true"`/etc.) for hand-edited manifests. -- A freshly converted sloppak from `lib/sloppak_convert.py` starts with a single `{id: full, file: stems/full.ogg, ...}` entry. After stem-splitting (Demucs), `full.ogg` is removed and the manifest is rewritten with per-instrument entries (`guitar`, `bass`, `drums`, `vocals`, `other`). The format requires only that `stems` is non-empty — there's no specific filename or id that must always be present. - -When stems were produced by an automated separation engine (Demucs), an optional `stem_separation` block records which engine + model produced them. Per [slopsmith#357](https://github.com/got-feedback/feedback/issues/357): - -```yaml -stem_separation: - engine: demucs # stable engine id; only `demucs` today - model: htdemucs_6s # specific model name (htdemucs_6s / htdemucs_ft / htdemucs / mdx_extra / ...) - version: 1.0.0 # semver for slopsmith's stem-artifact contract -``` - -Fields: -- `engine` — stable identifier for the separation engine. Currently always `demucs`. New engines (e.g. a hypothetical `spleeter`) would get their own stable id. -- `model` — the engine-specific model id used for this split. For Demucs this is the `-n` flag value. -- `version` — semver for Slopsmith's stem-artifact contract (independent of upstream Demucs / model versions). Bump per the same semantics #357 defines: patch = metadata-only fixes, minor = backward-compatible additions, major = stem set / packing / post-processing changed and existing splits should be regenerated. - -Omitted for single-stem sloppaks (`stems: [{id: full, ...}]` — no automated separation ran) and for hand-edited / user-recorded stems. The RFC reserves a separate `stem_authoring` sibling block for the hand-edit case; that's deferred to a follow-up. - -A remote Demucs server can use this block as part of a cache key so that changing the model or major version naturally produces a cache miss. Local plugin jobs should preserve this metadata in job state and in any copied/downloaded manifests. - -### 2.3. `lyrics` - -If present, points at a JSON file containing a flat list of syllable objects: - -```json -[ - {"t": 12.34, "d": 0.18, "w": "Hel"}, - {"t": 12.52, "d": 0.22, "w": "lo-"}, - {"t": 13.10, "d": 0.30, "w": "world"} -] -``` - -| Field | Meaning | -|---|---| -| `t` | Time in seconds | -| `d` | Duration in seconds | -| `w` | Syllable text. Trailing `-` joins to the next syllable as one word; trailing `+` marks the last syllable of a line (renderer wraps after it). Both are suffixes on a real syllable — not standalone entries. See `static/highway.js` for the rendering: `raw.endsWith('+')` flags end-of-line, and `sylText` strips the trailing marker before drawing | - -When lyrics are present, the optional top-level `lyrics_source` key records where they came from. The assembler sets it to `xml` when the lyrics were parsed from the source chart's vocals XML; the WhisperX auto-transcription fallback (`scripts/transcribe_lyrics.py`, or `--auto-lyrics` on the split scripts) sets it to `whisperx`. Hand-edited lyrics should bump it to `user` so UI consumers can render a different badge (or no badge) than for machine-generated lyrics. The key is absent on sloppaks produced before this field existed — readers should treat missing as `xml` for backward compatibility. - -When `lyrics_source` is `whisperx` (or any future automated engine), an optional `lyric_transcription` block records which engine + model produced the file. Shape mirrors the parent `stem_separation` RFC ([slopsmith#357](https://github.com/got-feedback/feedback/issues/357)): - -```yaml -lyric_transcription: - engine: whisperx # stable engine id - model: medium # the WhisperX model size that ran (tiny/base/small/medium/large-v2/large-v3) - version: 1.0.0 # semver for slopsmith's lyric-transcription artifact contract -``` - -Fields: -- `engine` — stable identifier for the transcription engine; currently always `whisperx`. -- `model` — the engine-specific model id used for this transcription. -- `version` — semver for Slopsmith's lyric-transcription artifact contract (independent of upstream Whisper / WhisperX versions). Bump per the same semantics #357 defines for stems: patch = metadata-only fixes, minor = backward-compatible additions, major = output shape changed and existing transcriptions should be regenerated. - -Omitted for authored lyrics (`xml` / `user`). A remote WhisperX server can use this block as part of a cache key the same way #357 envisions for stems — caches should miss whenever any of the three fields change, ensuring stale transcriptions don't get returned after a model bump. - -### 2.4. `vocal_pitch` - -If present, points at a JSON file holding per-syllable pitch data — the karaoke companion to `lyrics`. Consumed by [slopsmith-plugin-lyrics-karaoke](https://github.com/got-feedback/feedback-plugin-lyrics-karaoke) to render karaoke-style note bars over the lyric text. Shape: - -```json -{ - "version": 1, - "notes": [ - {"t": 12.34, "d": 0.40, "midi": 64}, - {"t": 12.78, "d": 0.55, "midi": 67} - ] -} -``` - -| Field | Meaning | -|---|---| -| `version` | Schema version of this `vocal_pitch.json` file (currently the integer `1`). Bump on a breaking change to the `notes` entry shape. This is *not* the same as the top-level `pitch_extraction.version` block below, which is a semver string used as a cache-key for the extractor engine | -| `notes` | List of pitch entries, one per syllable that the extractor could lock onto. `t` + `d` mirror the matching `lyrics.json` entry; `midi` is the MIDI note number (60 = middle C). Syllables the extractor couldn't pitch (silent / sub-confidence) are omitted from this list — it may be shorter than `lyrics.json` | - -When pitch came from an automated engine (the demucs server's `/pitch` endpoint, which runs CREPE), the optional top-level `pitch_extraction` block records which engine + model produced the file. Same shape and semver-string semantics as `stem_separation` / `lyric_transcription` — distinct from the in-file integer `version` field above: - -```yaml -pitch_extraction: - engine: crepe - model: v1 - version: 1.0.0 -``` - -Omitted for hand-edited pitch tracks. As with the other two automated-artifact blocks, a remote pitch server can use this for cache-key invalidation. - -The sloppak assembler runs pitch extraction automatically when `pitch_extraction.enabled` is set in its config AND a server URL is configured (either `pitch_extraction.server_url` or the shared `demucs_server_url`) AND the sloppak has lyrics + a `stems/vocals.ogg` after the split pass — either because `_maybe_transcribe_lyrics` just produced them via WhisperX OR because they were already on disk (from the source chart's vocals XML, hand-authoring, or an earlier build). Pitch is *not* coupled to `whisperx.enabled` — setting `pitch_extraction.enabled=true` alone (with WhisperX off) is enough to retro-generate pitch over any existing on-disk lyrics. Sloppaks built before this field existed simply don't carry it — readers should treat missing `vocal_pitch` as "no pitch data, fall back to whatever the karaoke plugin's local-extraction path produces (if any)". - ---- - -## 3. Arrangement JSON — the wire format - -Arrangement JSON files use the **wire format** produced by `arrangement_to_wire()` — the on-disk representation of a complete arrangement. Slopsmith's `/ws/highway/{filename}` endpoint transports similar data as a sequence of typed messages (`notes`, `chords`, `anchors`, `chord_templates`, `phrases`, …) rather than as one identical top-level JSON object. In practice, the WebSocket stream reuses the same per-object field names where applicable, but it should not be treated as a byte-for-byte match for `arrangements/*.json`. - -The authoritative serializer/deserializer is in [lib/song.py](../lib/song.py): - -- `arrangement_to_wire(arr) → dict` — write -- `arrangement_from_wire(dict) → Arrangement` — read - -### 3.1. Top-level shape - -```json -{ - "name": "Lead", - "tuning": [0, 0, 0, 0, 0, 0], - "capo": 0, - "centOffset": 0.0, /* optional, float cents, default 0.0 */ - "notes": [ /* see 3.2 */ ], - "chords": [ /* see 3.3 */ ], - "anchors": [ /* see 3.4 */ ], - "handshapes": [ /* see 3.5 */ ], - "templates": [ /* see 3.6 */ ], - "phrases": [ /* optional, see 3.7 */ ], - "tones": { /* optional, see 3.9 */ }, - "beats": [ /* see 3.8, only on first arrangement */ ], - "sections": [ /* see 3.8, only on first arrangement */ ] -} -``` - -`beats` and `sections` are **song-level** but live on the first arrangement's JSON for legacy reasons — `lib/sloppak.py` hoists them to the `Song` object on load. If you author multiple arrangements, only put them in one file. **New sloppaks should use `song_timeline.json` instead** (see §2 and §5.3) — when the manifest carries a `song_timeline:` key pointing at a schema-valid file, its beats/sections **replace** whatever the arrangement JSONs loaded (the override is applied after arrangement loading, so a valid `song_timeline.json` always wins). Arrangement-JSON beats/sections remain supported for backward compatibility with all existing sloppaks and are the fallback when the file is absent or invalid. - -### 3.2. Notes - -Field names are short on purpose — these get streamed thousands of times per song. Don't expand them. - -```json -{ - "t": 12.345, // time (s) - "s": 2, // string (0 = lowest) - "f": 7, // fret (0 = open, 24 = max) - "sus": 0.5, // sustain (s, 0 = none) - "sl": 9, // pitched slide-to fret (-1 = no slide) - "slu": -1, // unpitched slide-to fret (-1 = no slide) - "bn": 1.0, // bend amount in semitones - "ho": false, // hammer-on - "po": false, // pull-off - "hm": false, // natural harmonic - "hp": false, // pinch harmonic - "pm": false, // palm mute - "mt": false, // string mute - "vb": false, // vibrato - "tr": false, // tremolo - "ac": false, // accent - "tp": false, // tap - "ln": false, // link-next (chord linking metadata; renderers may ignore — runtime linking is derived from proximity) - "fhm": false, // fret-hand mute - "plk": false, // pluck (pop, bass) - "slp": false, // slap (bass) - "rh": -1, // right-hand fingering (-1 = unset) - "pkd": -1, // pick direction (-1 = unset, 0 = down, 1 = up) - "ig": false // ignore (chart-author flag — note is rendered but not scored / sequenced) -} -``` - -Default values: numbers → `0` or `-1` (slides / `rh` / `pkd`), bools → `false`. Omit fields equal to their default if you're authoring by hand — the parser fills them in. **Encoders should default-omit the newer technique keys** (`ln`, `fhm`, `plk`, `slp`, `rh`, `pkd`, `ig`) — the highway streams notes thousands of times per song, so trimming the common case keeps the WebSocket payload tight. The pre-existing keys are still emitted unconditionally to preserve the legacy wire contract. - -### 3.3. Chords - -A chord groups note-shaped objects under a single time: - -```json -{ - "t": 30.0, - "id": 12, // index into templates[] - "hd": false, // high-density flag - "notes": [ - {"s": 0, "f": 3, "sus": 0.0, ...}, - {"s": 1, "f": 5, "sus": 0.0, ...} - ] -} -``` - -Chord notes use the same field set as standalone notes, **except `t` is omitted** (the chord carries the time). The fingering / shape lookup is `chord.id → templates[id]`. - -### 3.4. Anchors - -Where the fretting hand sits. Drives the highway zoom box. - -```json -{"time": 12.0, "fret": 5, "width": 4} -``` - -### 3.5. Hand shapes - -Spans during which a chord shape is held: - -```json -{"chord_id": 12, "start_time": 30.0, "end_time": 31.5, "arp": false} -``` - -- `chord_id` (`int`, default `0`) — index into `templates[]`; identifies which chord template the span is holding. -- `start_time` (`float`, default `0.0`) — start of the span in seconds. -- `end_time` (`float`, default `0.0`) — end of the span in seconds. -- `arp` (`bool`, default `false`, allowed values `true`/`false`) — whether this hand shape should be treated as an arpeggio span rather than a fully-strummed chord hold. - -### 3.6. Chord templates - -Named shapes referenced by `chord.id` and `handshape.chord_id`: - -```json -{ - "name": "Em7", - "displayName": "Em7", - "arp": false, - "fingers": [-1, 2, 1, -1, -1, -1], - "frets": [ 0, 2, 2, 0, 0, 0] -} -``` - -- `name` (`string`, default `""`) — canonical template name used by the parser / authoring data. -- `displayName` (`string`, default `name`) — label shown in the UI; source XML may use this for display-specific variants such as `-arp`. -- `arp` (`bool`, default `false`, allowed values `true`/`false`) — whether the template is flagged as arpeggiated. Parsed from explicit XML attributes (`arpeggio` / `arp`, any common casing) or inferred from `displayName` markers such as `-arp`. -- `fingers` (`int[6]`, default `[-1, -1, -1, -1, -1, -1]`) — fretting-hand finger numbers, lowest string first. `-1` = unused string, `0` = open string / no fretting finger, `1..4` = index/middle/ring/pinky. -- `frets` (`int[6]`, default `[-1, -1, -1, -1, -1, -1]`) — fret numbers, lowest string first. `-1` = unused string, `0` = open string, positive values = fretted note. - -### 3.7. Phrases (optional, multi-difficulty data) - -Sources that carry per-phrase difficulty ladders (phrase-aware arrangement XML) include this. GP imports and legacy sloppaks omit it: - -```json -"phrases": [ - { - "start_time": 0.0, - "end_time": 12.5, - "max_difficulty": 4, - "levels": [ - { "difficulty": 0, "notes": [...], "chords": [...], "anchors": [...], "handshapes": [...] }, - { "difficulty": 1, "notes": [...], "chords": [...], "anchors": [...], "handshapes": [...] }, - ... - ] - } -] -``` - -If you're writing a converter that doesn't have multi-difficulty data, **omit the `phrases` key entirely** (don't emit `"phrases": []`). A missing key signals "no ladder, disable the master-difficulty slider"; an empty list is the same in current code but reads ambiguously. - -### 3.8. Beats and sections - -```json -"beats": [{"time": 0.5, "measure": 1}, {"time": 1.0, "measure": -1}, ...], -"sections": [{"name": "verse", "number": 1, "time": 12.5}, ...] -``` - -`measure: -1` = sub-beat (not a downbeat). Section `name` follows the usual song-structure conventions (`intro`, `verse`, `chorus`, `bridge`, `solo`, `outro`, …). - -### 3.9. Tones (optional) - -`tones` carries the arrangement's guitar tones — the amp/pedal/cabinet gear and the in-song tone switches. It's populated when the source chart carries tone data (`lib/tones.py`); a sloppak authored from scratch may omit it entirely. - -```json -"tones": { - "base": "Clean Rhythm", - "changes": [ - {"t": 12.5, "name": "Lead Drive"}, - {"t": 48.0, "name": "Clean Rhythm"} - ], - "definitions": [ - { - "Name": "Clean Rhythm", - "Key": "Tone_A", - "GearList": { /* raw gear blocks: Amp, PrePedal1-4, … */ } - } - ] -} -``` - -- `base` (string) — the tone in effect before the first change. -- `changes` (list, time-sorted) — `{"t": seconds, "name": str}` tone switches. The highway draws a marker at each. Omit when the arrangement never switches tone. -- `definitions` (list) — the **raw tone objects** (`Name`, `Key`, `GearList`), copied verbatim from the source chart's tone manifest. The Tones plugin parses these into the rendered signal chain (it owns the gear-name/image map, so the data is stored unparsed here). - -All three sub-keys are individually optional; an arrangement with none of them simply omits `tones`. Readers that don't know about tones ignore the key (the loader preserves it verbatim). - ---- - -## 4. Reading and writing sloppaks programmatically - -### 4.1. Reading (Python, server-side) - -```python -from pathlib import Path -from sloppak import load_song, load_manifest - -# Quick metadata only (parses manifest, skips arrangement JSONs) -manifest = load_manifest(Path("song.sloppak")) - -# Full song load (manifest + all arrangements + lyrics) -loaded = load_song("song.sloppak", dlc_root=Path("/dlc"), unpack_cache_root=Path("/cache")) -print(loaded.song.title, len(loaded.song.arrangements)) -print(loaded.stems) # [{"id": "full", "file": "stems/full.ogg", "default": True}] -print(loaded.manifest) # raw dict — read your custom keys here -``` - -### 4.2. Writing (Python, server-side) - -There's no general-purpose writer in `lib/` yet. The current writer lives in [lib/sloppak_convert.py](../lib/sloppak_convert.py) inside the sloppak assembly function — it's the single source of truth for "how a sloppak gets built." If you need to write sloppaks from a new source, copy the structure of that function: - -1. Build a `work_dir/` in temp. -2. Write `arrangements/{id}.json` per arrangement using `arrangement_to_wire()`. -3. Encode audio to OGG into `stems/`. -4. Optionally write `lyrics.json`, `cover.jpg`. -5. Compose the `manifest` dict and dump as YAML with `yaml.safe_dump(manifest, sort_keys=False, allow_unicode=True)`. -6. Either `shutil.copytree(work_dir, out)` for directory form, or `_zip_dir(work_dir, out)` for zip form. - -Always use `yaml.safe_dump` (not `yaml.dump`) and pass `sort_keys=False` so the human-readable order is preserved. - -### 4.3. Reading (JavaScript, plugin-side) - -Plugins typically don't read the sloppak file directly — they consume the `/ws/highway/{filename}` WebSocket stream (see `CLAUDE.md` for the message protocol), which produces the same shapes. If you specifically need raw manifest access from the browser, expose it through a custom backend route in your plugin's `routes.py` and fetch it. - ---- - -## 5. Extending the format — adding new data - -Sloppak is designed to be extended without breaking older readers. The conventions below come from how `lyrics`, `stems`, and the optional `phrases` ladder were each added. - -### 5.1. The golden rule: **manifest opt-in, file off to the side** - -New data types should follow this pattern: - -1. **Drop a new file** alongside the standard ones (e.g., `drums.json`, `keys.json`, `lighting.json`). -2. **Add a manifest key** that *points at* that file (e.g., `drum_tab: drums.json`). -3. **Make consumers gate on the manifest key**: if the key is absent, do nothing. Never auto-discover by filename — that breaks the "manifest is the index" rule. - -So a sloppak with drum tabs would look like: - -```yaml -# manifest.yaml -title: "Song" -artist: "Band" -duration: 240.0 -arrangements: [...] -stems: [...] -drum_tab: drum_tab.json # ← new key -``` - -``` -my-song.sloppak/ -├── manifest.yaml -├── arrangements/... -├── stems/... -└── drum_tab.json # ← new file -``` - -Older Slopsmith readers ignore the unknown `drum_tab` key (the loader uses `manifest.get("drum_tab")` / unknown keys pass through). Your plugin checks for it and renders accordingly. **Zero coordination needed with core.** - -### 5.2. Naming conventions for new keys and files - -- **Manifest keys**: `snake_case`, descriptive, singular when the value is one thing (`lyrics`, `cover`, `drum_tab`), plural when it's a list (`stems`, `arrangements`). -- **File names**: lowercase, hyphenated or underscored, JSON for structured data, OGG for audio, JPG/PNG for images. -- **Inside JSON**: short field names for hot-path data that gets streamed thousands of times (`t`, `s`, `f` — see §3.2). Long names are fine for one-off metadata. -- **Time fields**: always `t` or `time` (not `start`, not `timestamp`) — and always **seconds as floats**, not ms or ticks. Be consistent with the existing wire format. -- **Indexes / IDs**: stable, filesystem-safe, lowercase. Don't reuse a source format's internal numeric IDs unless you have to. - -### 5.3. Worked examples for the kinds of additions you mentioned - -#### Drum tab - -`drum_tab.json` carries per-piece hits authored on top of the song's audio. -Implemented end-to-end as of slopsmith#344 (drums-from-scratch): the loader -in `lib/sloppak.py` parses it, `lib/drums.py` defines the canonical piece-id -vocabulary, and `/ws/highway/{filename}` streams it as `drum_tab` + chunked -`drum_hits` messages. - -```json -{ - "version": 1, - "name": "Drums", - "kit": [ - {"id": "kick", "name": "Kick"}, - {"id": "snare", "name": "Snare"}, - {"id": "hh_closed", "name": "Hi-hat (closed)"}, - {"id": "hh_open", "name": "Hi-hat (open)"}, - {"id": "crash_r", "name": "Crash (right)"}, - {"id": "ride", "name": "Ride"} - ], - "hits": [ - {"t": 0.500, "p": "kick", "v": 110}, - {"t": 0.750, "p": "snare", "v": 92}, - {"t": 0.750, "p": "hh_closed", "v": 70}, - {"t": 1.000, "p": "snare", "v": 60, "g": true}, - {"t": 1.250, "p": "snare", "v": 105, "f": true}, - {"t": 4.000, "p": "crash_r", "v": 120, "k": 0.080} - ] -} -``` - -Manifest: - -```yaml -drum_tab: drum_tab.json -``` - -##### Hit fields - -| key | type | meaning | -| --- | --- | --- | -| `t` | float seconds | hit time, required, monotonic in `hits[]` | -| `p` | string | piece-id from the closed list below; required | -| `v` | int 1-127 | velocity (default 100) | -| `g` | bool | ghost note (renders smaller / outline-only) | -| `f` | bool | flam (renders a small leading ghost glyph 30 ms early) | -| `k` | float seconds | cymbal-choke tail duration (renders a fade-out) | - -##### Canonical piece-id vocabulary - -A closed list lives in `lib/drums.py::PIECES`. Open/closed hi-hat are -**distinct piece-ids**, not articulation flags — hit detection must reject -a closed-hat strike on an open-hat note, which it can only do if the -articulation is part of the piece-id. - -| piece-id | category | default GM MIDI | default shape | -| --- | --- | --- | --- | -| `kick` | kick | 35, 36 | bar (full-width across all non-kick lanes) | -| `snare` | drum | 38, 40 | rectangle | -| `snare_xstick` | drum | 37 | hatched rectangle | -| `tom_hi` | drum | 50, 48 | rectangle | -| `tom_mid` | drum | 47, 45 | rectangle | -| `tom_low` | drum | 43 | rectangle | -| `tom_floor` | drum | 41 | rectangle | -| `hh_closed` | cymbal | 42 | filled circle | -| `hh_open` | cymbal | 46 | ring (outline) circle | -| `hh_pedal` | cymbal | 44 | small circle with × | -| `stack` | cymbal | 30 | jagged circle (no GM standard — reuses 30 from extended-percussion range) | -| `crash_l` | cymbal | 49 | circle | -| `crash_r` | cymbal | 57 | circle | -| `splash` | cymbal | 55 | small circle | -| `china` | cymbal | 52 | jagged circle | -| `ride` | cymbal | 51, 59 | circle | -| `ride_bell` | cymbal | 53 | circle with centre dot | -| `bell` | cymbal | 80 | circle with centre dot (no GM standard — reuses "Mute Triangle") | - -Unknown piece-ids round-trip through the loader (forward-compat); the -client just renders them as a default rectangle. - -##### Wire format - -Streamed as two highway-WS message types: - -```json -{ "type": "drum_tab", "version": 1, "name": "Drums", - "kit": [{"id": "kick", "name": "Kick"}, ...], "total": 1234 } -``` - -…followed by one or more chunks of 500 hits: - -```json -{ "type": "drum_hits", "data": [{"t": 0.5, "p": "kick", "v": 110}, ...], - "total": 1234 } -``` - -##### Design notes - -- `kit[]` is the legend — fixed metadata, separated from hot-path data. -- `hits[]` uses short field names because this list can be thousands long. -- `v` defaults to 100; ghost / flam / choke flags are all optional. -- Older sloppaks whose drums are encoded as guitar notes (`midi = string*24 + fret`) still play — the drums plugin keeps a legacy decoder that reads the standard `notes` stream and synthesises `drum_hits` from it. - -#### Song timeline (beats and sections as a top-level file) - -`song_timeline.json` moves song-wide beats and sections out of the first -arrangement JSON and into a dedicated file. Implemented in `lib/sloppak.py` -alongside the notation format: the loader reads the manifest's optional -`song_timeline:` key, validates the file, and populates `Song.beats` / -`Song.sections` from it, taking priority over any beats/sections embedded -in arrangement JSONs. - -```json -{ - "version": 1, - "beats": [ - {"time": 0.500, "measure": 1}, - {"time": 1.000, "measure": -1}, - {"time": 1.500, "measure": -1}, - {"time": 2.000, "measure": 2} - ], - "sections": [ - {"name": "intro", "number": 1, "time": 0.0}, - {"name": "verse", "number": 1, "time": 16.0}, - {"name": "chorus", "number": 1, "time": 32.0} - ] -} -``` - -Manifest: - -```yaml -song_timeline: song_timeline.json -``` - -| Field in `beats[]` | Type | Notes | -|---|---|---| -| `time` | float seconds | Beat timestamp. Matches the existing arrangement-JSON wire convention | -| `measure` | int | 1-based downbeat number. `-1` = sub-beat (not a downbeat) | - -| Field in `sections[]` | Type | Notes | -|---|---|---| -| `name` | string | song-structure convention: `intro`, `verse`, `chorus`, `bridge`, `solo`, `outro`, … | -| `number` | int | Section repeat number | -| `time` | float seconds | Section start | - -**Backward compatibility.** Sloppaks without `song_timeline:` continue to -work — the loader falls through to reading beats/sections from the first -arrangement JSON exactly as before. No migration is needed. - -**New sloppaks** should put beats/sections here and leave arrangement JSONs -free of timeline data. This is especially important for notation-only -arrangements (see below) where there may be no arrangement JSON at all. - ---- - -#### Notation format (standard musical notation per arrangement) - -The notation format promotes keys, piano, violin, and any other -staff-notation instrument to first-class status with their own data -structure, separate from the guitar wire format. Implemented in -`lib/sloppak.py` and `lib/notation.py`; the highway WS streams -`notation_info` + `notation_measures` messages when notation data is -present for the active arrangement. - -**Architecture: per-arrangement, not song-wide.** Unlike `drum_tab` -(one drum track per song, top-level manifest key), notation is -per-instrument. A song could carry both `notation_keys.json` and -`notation_violin.json`. The manifest key lives on the **arrangement -entry**, not at the top level. - -```yaml -arrangements: - - id: keys - name: Keys - type: piano - notation: notation_keys.json # per-arrangement sub-key - # file: is optional when notation: is present -``` - -```text -my-song.sloppak/ -├── manifest.yaml -├── song_timeline.json -├── notation_keys.json -└── stems/ - └── full.ogg -``` - -**`notation_.json` — file schema:** - -```json -{ - "version": 1, - "instrument": "piano", - "staves": [ - {"id": "rh", "clef": "G2", "label": "Right Hand"}, - {"id": "lh", "clef": "F4", "label": "Left Hand"} - ], - "measures": [ - { - "idx": 1, - "t": 0.0, - "ts": [4, 4], - "ks": 0, - "tempo": 120.0, - "staves": { - "rh": { - "voices": [{"v": 1, "beats": [ - {"t": 0.000, "dur": 4, "notes": [{"midi": 64}]}, - {"t": 0.500, "dur": 4, "notes": [{"midi": 67}]} - ]}] - }, - "lh": { - "voices": [{"v": 1, "beats": [ - {"t": 0.000, "dur": 1, "notes": [{"midi": 52}, {"midi": 60}]} - ]}] - } - } - } - ] -} -``` - -**Top-level fields:** - -| Field | Type | Notes | -|---|---|---| -| `version` | int | Always `1`. Bump on breaking schema change | -| `instrument` | string | Mirrors arrangement `type`: `piano`, `violin`, `guitar`, etc. Makes the file self-describing | -| `rights` | string | Optional copyright / rights text (MusicXML ``). Omit when absent | -| `lyricist` | string | Optional lyricist credit (MusicXML ``). Omit when absent | -| `arranger` | string | Optional arranger credit (MusicXML ``). Omit when absent | -| `staves` | list | Static staff definitions. Each has `id` (stable, referenced by `measures[].staves` keys), `clef` (see below), and optional `label` | -| `measures` | list | Ordered measure data — the hot path | - -**Clef vocabulary** (defined in `lib/notation.py::CLEFS`): - -| Value | Meaning | -|---|---| -| `G2` | Treble clef — guitar, violin, flute, piano RH | -| `F4` | Bass clef — bass guitar, cello, piano LH | -| `C3` | Alto clef — viola | -| `C4` | Tenor clef — cello upper register, trombone | -| `neutral` | Unpitched / percussion staff | - -**Measure fields:** - -| Field | Type | Notes | -|---|---|---| -| `idx` | int | 1-based measure number | -| `t` | float | Time in seconds at measure downbeat | -| `ts` | int[2] | Time signature `[numerator, denominator]`. Omit if unchanged | -| `beat_groups` | int[] | Beat grouping for compound and irregular meters, as a list of integers. Each integer is the count of time-signature denominator units in that primary beat group. The sum must equal the time-signature numerator. E.g. 6/8 → `[3, 3]`; 9/8 → `[3, 3, 3]`; 7/8 → `[2, 2, 3]`; 5/8 → `[2, 3]` or `[3, 2]`. Omit for simple meters (2/4, 3/4, 4/4) where grouping is unambiguous. Renderers translate this to their own beam-grouping API at render time — this field is renderer-agnostic. | -| `ks` | int | Key signature: semitones from C, −7 to +7 (negative = flats, positive = sharps). Omit if unchanged | -| `tempo` | float | BPM. Omit if unchanged | -| `pickup` | bool | `true` when this measure is an anacrusis (pickup / upbeat) shorter than the time signature implies (MusicXML `implicit="yes"`). Renderers suppress the measure number and start counting from the next full measure. Omit when false | -| `staves` | object | Keyed by staff `id`. Each staff has optional `clef` (omit if unchanged) and `voices` | - -**Beat fields** (inside `staves → voices → beats`): - -| Field | Default | Notes | -|---|---|---| -| `t` | required | Time in seconds | -| `dur` | required | Duration denominator: `1`=whole, `2`=half, `4`=quarter, `8`=eighth, `16`=sixteenth, `32`=thirty-second | -| `dot` | omit | Augmentation dots: `1`=dotted, `2`=double-dotted | -| `rest` | omit | `true` if this beat is a rest; `notes` is omitted | -| `tu` | omit | Tuplet: `[numerator, denominator]`, e.g. `[3, 2]` for triplet | -| `beat_pos` | omit | Exact position within the measure as a rational `[numerator, denominator]` pair, where the denominator is the time-signature denominator. E.g. beat 2 in 6/8 (the second dotted quarter) = `[3, 8]`. Avoids floating-point imprecision when deriving beat position from tempo and absolute time. Omit if not set by the importer. Renderers that do not recognise this field derive position from `t` and the tempo map as before. | -| `notes` | omit | List of note objects (omit for rests) | -| `dyn` | omit | Dynamic: `ppp`, `pp`, `p`, `mp`, `mf`, `f`, `ff`, `fff` | -| `slr` | omit | Slur start | -| `slre` | omit | Slur end | -| `grace` | omit | Grace-note beat, typed: `"a"` = acciaccatura (slashed, steals time from the previous note; MusicXML ``), `"p"` = appoggiatura (unslashed, steals time from the following note; ``). The beat's `dur` is the grace note's written duration. Vocabulary in `lib/notation.py::GRACE_TYPES` | -| `arp` | omit | `true` when the beat's chord is arpeggiated (rolled; MusicXML ``) | -| `ferm` | omit | `true` when the beat carries a fermata (MusicXML ``) | -| `spd` / `sph` / `spu` | omit | Sustain pedal: pedal **d**own / **h**old-through-this-beat / **u**p. This is the only pedal encoding — there is deliberately no separate `ped` field. MusicXML mapping: `` → `spd`, `` → `spu` + `spd` on the same beat (re-pedal), `` → `spu`; beats inside an active pedal span carry `sph` | -| Additional beat effects | omit | `cre`, `dec`, `vib`, `vibw`, `fade`, `pm`, `lr`, `slap`, `pop`, `tap`, `su`, `sd`, `rasg`, `golpe`, `wah`, `txt`, `chrd` — all optional, omit when absent | - -**Note fields** (inside `beats → notes`): - -| Field | Default | Notes | -|---|---|---| -| `midi` | required | MIDI pitch 0–127. Unambiguous — no string/fret/tuning indirection | -| `tied` | omit | Tied from the previous beat | -| `acc` | omit | Accidental override: `null`/omit = derive from key sig; `0` = force natural (♮); `−2`/`−1`/`1`/`2` = double-flat/flat/sharp/double-sharp | -| `stem` | omit | Force stem direction: `"up"` or `"down"` (MusicXML ``). Omit to let the renderer decide. Vocabulary in `lib/notation.py::STEM_DIRECTIONS` | -| Additional note effects | omit | `stc`, `ten`, `ac`, `hac`, `vib`, `vibw`, `dead`, `ghost`, `fng`, `rfng`, `str`, `harm`, `bend`, `slide`, `trill`, `ho`, `po`, `tp`, `barre` — all optional | - -**Wire format.** `song_info` carries `has_notation: bool`. Notation data -is streamed as two highway-WS message types after `sections`, before `anchors`: - -```json -{"type": "notation_info", "version": 1, "instrument": "piano", - "staves": [...], "total": 64} -``` - -…followed by one or more chunks of 32 measures: - -```json -{"type": "notation_measures", "data": [...], "total": 64} -``` - -`total` is the measure count across **all** chunks. Clients accumulate `data` arrays until the accumulated measure count reaches `total` (an individual chunk's `data.length` says nothing — every full chunk of a multi-chunk stream is shorter than `total`). The `anchors` frame that follows the notation block is a secondary end-of-block signal. - -**`lib/notation.py`** is the vocabulary library: `SCHEMA_VERSION`, `CLEFS`, `DURATIONS`, `validate_notation()`, `measure_to_wire()`, `measures_to_wire()`. - -**Legacy fallback.** Sloppaks that carry keys as guitar wire format (Clone Hero converted content) continue to work — the notation plugin checks for the `notation` key on the arrangement entry. When absent, it falls back to decoding guitar wire format notes via `midi = s * 24 + f`. - -**v1 non-features (accepted limitations).** The following are deliberately -out of schema v1; they ship, if ever, as **additive v1.x patches** (new -optional fields old consumers ignore — the permissive validator passes -unknown fields through by design): - -- Microtonal pitch (anything finer than the ±2 semitone `acc` vocabulary). -- Figured bass. -- Mid-measure key-signature, time-signature, or clef changes (all three are - measure-granular in v1). -- Ottava lines (`ott`), repeat/volta barline semantics (`barline`), - ornaments beyond trills (mordents, turns), tremolo (`trem`), and notated - glissando lines (`glis`). - -Importers MUST drop these source features with a logged warning rather than -approximate them into wrong notation; renderers MUST NOT invent semantics -for field names from this list before a v1.x patch specifies them. - ---- - -#### Key / scale annotations (for theory-aware visualizations) - -`keys.json` mirroring the `sections[]` shape: - -```json -{ - "version": 1, - "events": [ - {"t": 0.0, "key": "Em", "scale": "natural_minor"}, - {"t": 64.5, "key": "G", "scale": "major"}, - {"t": 142.0, "key": "Em", "scale": "natural_minor"} - ] -} -``` - -Manifest: - -```yaml -keys: keys.json -``` - -Each entry implicitly applies until the next event. Same model as `sections[]`. - -#### Vocal pitch contour (a different shape, a different key) - -The canonical `vocal_pitch` key + file (defined in §2.4) is the -per-syllable note format consumed by the karaoke plugin — -`{version: 1, notes: [{t, d, midi}]}`. If you want to ship a finer- -grained pitch *contour* (one sample every 20 ms, Hz instead of MIDI), -that's a different shape and should ride on its own manifest key so -the two don't collide: - -```yaml -vocal_pitch_contour: vocal_pitch_contour.json -``` - -```json -{ - "version": 1, - "samples": [ - {"t": 0.000, "hz": 220.5}, - {"t": 0.020, "hz": 222.1} - ] -} -``` - -Per §5.1, manifest keys are cheap — reach for a new one when the -schema diverges, don't overload an existing key with a second shape. - -### 5.4. `version` field — always include it - -Every new file should have `"version": 1` at the top. It's free insurance: when you change the schema later, `version: 2` consumers can branch on it. Old consumers without that branch ignore the file (or fall back gracefully). - -### 5.5. Stay backward-compatible - -If you change a field that already shipped: - -- **Adding fields** is always safe (older readers ignore them). -- **Removing fields** breaks older readers. Don't. -- **Repurposing fields** (changing meaning or units) is the worst — bump `version` and branch. - -If you're tempted to remove or repurpose: leave the old field, add a new one, and sunset the old one over a release or two. - -### 5.6. When to put data inside an arrangement vs. its own file - -- **Inside arrangement JSON** (`arrangements/lead.json`): - - Data that is *per-arrangement* and *per-instrument* (notes, chords, anchors, hand-shapes — guitar specifics). - - Data that meaningfully differs between Lead and Rhythm versions of the same song. -- **Its own file** (and pointed-at via manifest key): - - Data that is *song-wide* (lyrics, beats, sections, tempo map, drum tab, lighting, key/scale changes). - - Data that may be authored or generated independently of the playable arrangement (a stem split, an AI-generated drum tab). - -Beats and sections historically lived inside the first arrangement JSON (early arrangement XML put them there). The `song_timeline.json` file (see §5.3) is the correct home for new sloppaks — the loader reads it first and it takes priority. New song-wide data should always be its own file. - -### 5.7. Don't break the manifest contract - -A few things that should *not* end up in `manifest.yaml`: - -- **Per-machine settings** (DMX universes, IPs, output device picks) — those go in `${CONFIG_DIR}/...json`, not the sloppak. -- **UI state** (last zoom level, panel sizes) — `localStorage` only. -- **User progress / play counts** — Slopsmith stores these in its metadata DB, not in the sloppak. - -The sloppak holds **the song's authored data**. Anything that varies by user or by machine is out. - ---- - -## 6. Quick reference — file types you'll touch - -| File | Format | Schema lives in | Authority | -|---|---|---|---| -| `manifest.yaml` | YAML | `lib/sloppak.py` (`load_manifest`, `extract_meta`) | This doc + the loader | -| `arrangements/*.json` | JSON | `lib/song.py` (`arrangement_to_wire`, `arrangement_from_wire`) | The wire-format functions | -| `lyrics.json` | JSON (flat list) | `lib/sloppak.py` (passed through to `Song.lyrics`) | This doc §2.3 | -| `song_timeline.json` | JSON | `lib/sloppak.py` (loader) | This doc §5.3 | -| `notation_.json` | JSON | `lib/notation.py` (`validate_notation`, `measures_to_wire`) | This doc §5.3 | -| `stems/*.ogg` | OGG Vorbis | — | Convention: `q:a 5` for size/quality balance | -| `cover.jpg` | JPEG | — | Convention: square, 500–1500 px on a side | -| Your new file | JSON (preferred) | Your plugin's spec doc | You | - ---- - -## 7. Testing your extension - -If you add a new file type or manifest key: - -1. **Round-trip test**: write a sample, load it, write it back, compare. Add to `tests/test_sloppak.py`. -2. **Backward-compat test**: load a sloppak that *doesn't* have your new key — your code must not crash, and the song must still play. -3. **Hand-edit test**: open the directory form in a text editor, change a field by hand, reload Slopsmith. The format is meant to be hand-editable; your additions should preserve that. -4. **Both forms**: test with both the directory form and the zipped form. The unpack cache is invalidated based on mtime and size, so you can repackage and reload without restarting the server. - -The full pytest suite (`pytest`) must stay green before any PR. - ---- - -## 8. Where to look in the code +The spec is implementation-independent; this table is the feedback-specific bridge from format +concepts to the code that reads and writes them. It is **not** part of the format. | For… | Read | |---|---| | Format detection, source resolution, zip unpacking | [lib/sloppak.py](../lib/sloppak.py) | | Data classes (`Note`, `Chord`, `Arrangement`, `Song`, `Phrase`) | [lib/song.py](../lib/song.py) | | Wire-format helpers (`*_to_wire` / `*_from_wire`) | [lib/song.py](../lib/song.py) | -| The reference sloppak writer | [lib/sloppak_convert.py](../lib/sloppak_convert.py) | -| Drum tab vocabulary and wire helpers | [lib/drums.py](../lib/drums.py) | +| The reference pack writer (assembly pipeline) | [lib/sloppak_convert.py](../lib/sloppak_convert.py) | +| Drum-tab vocabulary and wire helpers | [lib/drums.py](../lib/drums.py) | | Notation vocabulary and wire helpers | [lib/notation.py](../lib/notation.py) | | Live streaming over WebSocket (consumes the same shapes) | `server.py` (`/ws/highway/{filename}`) | -| The plugin system (where new viz consumers go) | [CLAUDE.md](../CLAUDE.md) — Plugin System section | +| The plugin system (where new visualization consumers go) | [CLAUDE.md](../CLAUDE.md) | | Tests | [tests/test_sloppak.py](../tests/test_sloppak.py), [tests/test_sloppak_convert.py](../tests/test_sloppak_convert.py) | + +> **Note on older section references.** Some inline code comments in this repo cite section +> numbers from the previous version of this document (e.g. "sloppak-spec §5.3"). The external spec +> renumbered its sections, so those citations are approximate — find the topic by name in the +> [feedpak spec](https://github.com/got-feedback/feedback-feedpak-spec/blob/main/spec/feedpak-v1.md) +> rather than by the old number. diff --git a/lib/gp2notation.py b/lib/gp2notation.py index 0787ec4..65b9b33 100644 --- a/lib/gp2notation.py +++ b/lib/gp2notation.py @@ -522,6 +522,10 @@ def attach_notation_to_sloppak(sloppak_dir: str | Path, arr_id: str, payload: di json.dumps(payload, separators=(",", ":")), encoding="utf-8" ) entry["notation"] = filename + # Stamp the format version while we're rewriting the manifest (spec §4), + # without downgrading an existing (possibly higher) declared version. + from sloppak import FEEDPAK_VERSION + manifest.setdefault("feedpak_version", FEEDPAK_VERSION) manifest_path.write_text( yaml.safe_dump(manifest, sort_keys=False, allow_unicode=True), encoding="utf-8", diff --git a/lib/gp2rs.py b/lib/gp2rs.py index f9d9768..9b233e9 100644 --- a/lib/gp2rs.py +++ b/lib/gp2rs.py @@ -1,5 +1,6 @@ """Convert Guitar Pro files (.gp5/.gp4/.gp3) to arrangement XML.""" +import json import logging import re import xml.etree.ElementTree as ET @@ -56,6 +57,8 @@ class RsNote: fret: int sustain: float = 0.0 bend: float = 0.0 + bend_intent: int = 0 + bend_values: list | None = None slide_to: int = -1 slide_unpitch_to: int = -1 hammer_on: bool = False @@ -191,6 +194,67 @@ def _duration_to_seconds(duration: guitarpro.Duration, tempo: float) -> float: return beats * (60.0 / tempo) +# pyguitarpro models bend-point x-positions on 0..BendEffect.maxPosition (12) +# across the note's duration; y-values are half-quarter-tone units where 12 = 6 +# semitones, so semitones = value / 2.0 (matches the scalar `bend` derivation). +_GP_BEND_MAX_POSITION = 12 + + +def _bend_intent_from_values(values: list[float]) -> int: + """Classify a bend gesture (§6.2.1) from its time-ordered semitone values: + 0 up, 1 release, 2 pre-bend, 3 pre-bend-and-release, 4 round-trip.""" + if not values: + return 0 + eps = 0.05 + first, last, peak = values[0], values[-1], max(values) + if first > eps: + if last <= eps: + return 3 # pre-bent, then released to pitch + if last < first - eps: + return 1 # held bend let down + return 2 # pre-bend held + if peak > eps and last <= eps: + return 4 # bend up and back down + return 0 # plain bend up + + +def _gp_bend_shape(bend, duration_secs: float): + """From a pyguitarpro ``BendEffect``, return ``(peak, intent, curve)``. + + ``peak`` is the bend's peak in semitones (the scalar ``bn``); ``intent`` is + the §6.2.1 ``bt`` code; ``curve`` is the time-stamped ``bnv`` list + (``[{t: seconds-from-onset, v: semitones}]``) or ``None`` when there's no + usable shape (no points, or a zero-length note collapsing every point to + ``t=0``).""" + pts = sorted(bend.points or [], key=lambda p: p.position) + if not pts: + return 0.0, 0, None + values = [round(p.value / 2.0, 1) for p in pts] + peak = round(max(values), 1) + intent = _bend_intent_from_values(values) + curve = None + if duration_secs > 0 and len(pts) >= 2: + curve = [ + {"t": round(duration_secs * (p.position / _GP_BEND_MAX_POSITION), 3), + "v": v} + for p, v in zip(pts, values) + ] + return peak, intent, curve + + +def _bend_shape_xml_attrs(n: "RsNote") -> dict: + """Optional bend-shape XML attributes for a /, default- + omitted: `bendIntent` only when non-zero, `bendValues` (a JSON-encoded + [{t,v}] curve) only when present. `_parse_note` (lib/song.py) reads these + back so a GP-imported bend curve survives import → wire → highway.""" + attrs: dict = {} + if n.bend_intent: + attrs["bendIntent"] = str(int(n.bend_intent)) + if n.bend_values: + attrs["bendValues"] = json.dumps(n.bend_values, separators=(",", ":")) + return attrs + + def _tempo_at_tick(tick: int, tempo_map: list[TempoEvent]) -> float: """Get the tempo at a given tick.""" result = tempo_map[0].tempo @@ -460,6 +524,56 @@ def _gp_string_to_rs(gp_string: int, num_strings: int) -> int: return num_strings - gp_string +def _chord_fingers(chord, frets: list[int], num_strings: int) -> list[int]: + """Per-string fingering for a chord template, in RS string order. + + pyguitarpro exposes the chord-diagram voicing on ``beat.effect.chord``: + ``chord.strings`` is a per-string fret list indexed 0 = highest string + (GP string 1), -1 = unplayed; ``chord.fingerings`` is the parallel list + of :class:`guitarpro.Fingering` enums (``open=-1, thumb=0, index=1, + middle=2, annular=3, little=4`` — already the RS finger integers). The + fingerings list may carry one trailing extra entry, so we only read the + first ``len(strings)`` of it. + + Returns a list the same width as ``frets`` (RS string index 0 = low). + Only strings that are actually played in this template (``frets[rs] >= 0``) + get a finger; everything else stays -1. A chord without a populated + voicing yields all -1, so diagram-less charts are unchanged. + """ + fingers = [-1] * len(frets) + strings = getattr(chord, "strings", None) or [] + fingerings = getattr(chord, "fingerings", None) or [] + for i, fret in enumerate(strings): + if fret is None or fret < 0: + continue # string not part of the voicing + rs = _gp_string_to_rs(i + 1, num_strings) + if not (0 <= rs < len(frets)) or frets[rs] < 0: + continue + if i < len(fingerings): + val = getattr(fingerings[i], "value", fingerings[i]) + fingers[rs] = val if isinstance(val, int) else -1 + return fingers + + +def _chord_diagram_frets(chord, num_strings: int, width: int) -> list[int]: + """RS-string-ordered absolute frets of the chord DIAGRAM voicing, padded to + ``width`` with -1. + + Used to confirm the diagram describes the voicing actually played before + enriching a template — mirrors the GP8 exact fret-pattern guard. pyguitarpro + stores absolute frets in ``chord.strings`` (``firstFret`` is display-only), + so the result compares directly against the played ``frets``.""" + out = [-1] * width + strings = getattr(chord, "strings", None) or [] + for i, fret in enumerate(strings): + if fret is None or fret < 0: + continue + rs = _gp_string_to_rs(i + 1, num_strings) + if 0 <= rs < width: + out[rs] = fret + return out + + def _is_bass_track(track: guitarpro.Track) -> bool: """Detect whether a GP track is a bass. @@ -685,12 +799,13 @@ def convert_track( # Techniques eff = note.effect if eff.bend and eff.bend.points: - # pyguitarpro bend point values are in quarter-tones - # (maxValue 12 = 3 whole tones = 6 semitones), so - # semitones = value / 2. The old /100.0 made every bend - # round to 0 (a whole-tone bend is value 4 -> 0.04). - max_bend = max(p.value for p in eff.bend.points) - rn.bend = round(max_bend / 2.0, 1) + # `bn` is the peak; `bnv`/`bt` describe the shape over + # time (§6.2.1). semitones = value / 2 (maxValue 12 = 6 + # semitones); the old /100.0 made every bend round to 0. + peak, intent, curve = _gp_bend_shape(eff.bend, dur) + rn.bend = peak + rn.bend_intent = intent + rn.bend_values = curve if eff.hammer: # HO vs PO from pitch direction off the prior note on the @@ -828,17 +943,44 @@ def convert_track( fret_key = tuple(frets) if fret_key not in chord_template_map: - # Try to get chord name from GP - chord_name = "" - if beat.effect and beat.effect.chord: - chord_name = beat.effect.chord.name or "" idx = len(chord_templates) chord_templates.append(ChordTemplate( - name=chord_name, + name="", frets=list(frets), fingers=[-1] * width, )) chord_template_map[fret_key] = idx + else: + idx = chord_template_map[fret_key] + + # Enrich the template from the GP chord diagram attached to + # this beat — but ONLY when the diagram describes the voicing + # actually played (same width-normalized fret pattern). A + # mismatched chord label/diagram would otherwise mis-name / + # finger the played template, and the back-fill would spread + # it to other strums of the same played pattern. Mirrors the + # GP8 exact fret-pattern guard. + # + # Name and fingers back-fill INDEPENDENTLY: a name-only first + # annotation must not block a later beat that carries fingers + # (and vice versa). Back-fill any still-blank field so the + # data attaches regardless of which strum carries it. + if beat.effect and beat.effect.chord: + gpc = beat.effect.chord + # Compare over the FULL string span (played width vs the + # track's string count) so a diagram that frets an + # extended string the played voicing doesn't use counts + # as a mismatch instead of being silently trimmed. + _w = max(len(frets), num_strings) + _played = frets + [-1] * (_w - len(frets)) + if _chord_diagram_frets(gpc, num_strings, _w) == _played: + ct = chord_templates[idx] + if not ct.name and gpc.name: + ct.name = gpc.name + if all(f < 0 for f in ct.fingers): + fingers = _chord_fingers(gpc, frets, num_strings) + if any(f >= 0 for f in fingers): + ct.fingers = fingers rs_chords.append(RsChord( time=t, @@ -1021,6 +1163,7 @@ def _build_xml( "tap": "1" if n.tap else "0", "ignore": "0", } + attrs.update(_bend_shape_xml_attrs(n)) ET.SubElement(notes_el, "note", **attrs) # Chords @@ -1031,25 +1174,29 @@ def _build_xml( chordId=str(ch.template_idx), highDensity="0", strum="down") for cn in ch.notes: - ET.SubElement(chord_el, "chordNote", - time=f"{cn.time:.3f}", - string=str(cn.string), - fret=str(cn.fret), - sustain=f"{cn.sustain:.3f}", - bend=f"{cn.bend:.1f}" if cn.bend else "0", - hammerOn="1" if cn.hammer_on else "0", - pullOff="1" if cn.pull_off else "0", - slideTo=str(cn.slide_to), - slideUnpitchTo=str(cn.slide_unpitch_to), - harmonic="1" if cn.harmonic else "0", - harmonicPinch="1" if cn.harmonic_pinch else "0", - palmMute="1" if cn.palm_mute else "0", - mute="1" if cn.mute else "0", - vibrato="1" if cn.vibrato else "0", - tremolo="1" if cn.tremolo else "0", - accent="1" if cn.accent else "0", - linkNext="1" if cn.link_next else "0", - tap="1" if cn.tap else "0", ignore="0") + cn_attrs = { + "time": f"{cn.time:.3f}", + "string": str(cn.string), + "fret": str(cn.fret), + "sustain": f"{cn.sustain:.3f}", + "bend": f"{cn.bend:.1f}" if cn.bend else "0", + "hammerOn": "1" if cn.hammer_on else "0", + "pullOff": "1" if cn.pull_off else "0", + "slideTo": str(cn.slide_to), + "slideUnpitchTo": str(cn.slide_unpitch_to), + "harmonic": "1" if cn.harmonic else "0", + "harmonicPinch": "1" if cn.harmonic_pinch else "0", + "palmMute": "1" if cn.palm_mute else "0", + "mute": "1" if cn.mute else "0", + "vibrato": "1" if cn.vibrato else "0", + "tremolo": "1" if cn.tremolo else "0", + "accent": "1" if cn.accent else "0", + "linkNext": "1" if cn.link_next else "0", + "tap": "1" if cn.tap else "0", + "ignore": "0", + } + cn_attrs.update(_bend_shape_xml_attrs(cn)) + ET.SubElement(chord_el, "chordNote", **cn_attrs) # Anchors anchors_el = ET.SubElement(level, "anchors", count=str(len(anchors))) diff --git a/lib/gp2rs_gpx.py b/lib/gp2rs_gpx.py index d21f2aa..a161aea 100644 --- a/lib/gp2rs_gpx.py +++ b/lib/gp2rs_gpx.py @@ -445,6 +445,93 @@ def _gp6_element_variation_to_midi(element: int, variation: int) -> int | None: return _ART_TO_MIDI.get(art_id, art_id) +# GPIF chord-diagram names → RS finger integers, +# matching the editor (E1) + gp2rs/pyguitarpro convention: +# open/unused = -1, thumb = 0, index = 1, middle = 2, ring = 3, pinky = 4. +_GPIF_FINGER_MAP = { + 'none': -1, 'open': -1, '': -1, + 'thumb': 0, + 'index': 1, + 'middle': 2, + 'ring': 3, 'annular': 3, + 'pinky': 4, 'little': 4, +} + + +def _rs_string_order(string_pitches: list[int]) -> dict[int, int]: + """Map each GPIF string index → RS string index (0 = lowest pitch). + + Mirrors the per-note transform in ``convert_file`` (sort GPIF string + indices by open pitch ascending, tiebreak on index, use the rank), so a + chord diagram's string indices land on the same RS strings as the played + notes regardless of format direction (GP6 .gpx high→low, GP8 .gp low→high). + """ + order = sorted(range(len(string_pitches)), + key=lambda i: (string_pitches[i], i)) + return {gp: rs for rs, gp in enumerate(order)} + + +def _parse_chord_diagrams(track_el, string_pitches: list[int]) -> dict: + """Map fret-pattern tuple → ``{'name', 'fingers'}`` from a track's diagrams. + + GP7/GP8 GPIF stores authored chord diagrams per track under + ``Properties/Property[@name="DiagramCollection"]/Items/Item``. Each Item + carries the chord name (its ``name`` attribute) and a ```` with + per-string ```` plus + ````. Diagram string + indices share the positional space of note ``String`` indices, so they go + through the same pitch-rank transform; ```` is the absolute fret + (``baseFret`` is display-only and not applied). + + Keying by fret pattern (width-normalised to ≥6, exactly like the template + build site) keeps the join key consistent with GP5 + the editor's + preserve-by-fret-key (E0). Returns ``{}`` when there are no diagrams or no + string tuning (orientation/width would be undefined). + """ + diagrams: dict[tuple, dict] = {} + if track_el is None or not string_pitches: + return diagrams + gp_to_rs = _rs_string_order(string_pitches) + for item in track_el.findall( + './/Property[@name="DiagramCollection"]/Items/Item'): + diag = item.find('Diagram') + if diag is None: + continue + rs_frets: dict[int, int] = {} + for fr in diag.findall('Fret'): + try: + gp = int(fr.get('string')) + fret = int(fr.get('fret')) + except (TypeError, ValueError): + continue + if fret < 0: + continue + rs = gp_to_rs.get(gp) + if rs is not None: + rs_frets[rs] = fret + if not rs_frets: + continue + width = max(6, max(rs_frets) + 1) + frets = [-1] * width + fingers = [-1] * width + for rs, fret in rs_frets.items(): + frets[rs] = fret + for pos in diag.findall('Fingering/Position'): + try: + gp = int(pos.get('string')) + except (TypeError, ValueError): + continue + rs = gp_to_rs.get(gp) + if rs is None or not (0 <= rs < width) or frets[rs] < 0: + continue + fname = (pos.get('finger') or '').strip().lower() + fingers[rs] = _GPIF_FINGER_MAP.get(fname, -1) + # First diagram wins for a given voicing (stable, deterministic). + diagrams.setdefault(tuple(frets), + {'name': item.get('name', '') or '', 'fingers': fingers}) + return diagrams + + def _gpx_percussion_midis(track_el) -> list[int]: """Flatten a drumKit ``InstrumentSet``'s articulations into a list of GM ``OutputMidiNumber``s, positionally indexed to match a note's @@ -1060,6 +1147,59 @@ def _gpx_bend_scale(root: ET.Element) -> float: return 50.0 if peak <= 400 else 2500.0 +def _gpx_bend_float(tp: dict, name: str): + """Read a GPIF bend `` value from the property map, or None.""" + el = tp.get(name) + if el is None: + return None + try: + return float(el.findtext('Float') or 0) + except (ValueError, TypeError): + return None + + +def _gpx_bend_shape(tp: dict, divisor: float, sustain: float): + """Build ``(peak, intent, curve)`` from a GPIF note's bend Properties (§6.2.1). + + GPIF describes a bend as origin / middle / destination value+offset pairs; + `value / divisor` is semitones (divisor auto-detected per file) and the + `*Offset` Properties are 0..100 (percent of the note's duration). Produces a + bnv curve of up to three points (mapping each offset to seconds-from-onset), + or ``None`` when there's no usable shape (no points, flat-zero, or a + zero-length note). When an offset Property is absent the stage falls back to + an evenly-spaced default (origin 0%, middle 50%, destination 100%). + + NOTE: offset Property names should be confirmed against a real GP8 export; + the value path matches the existing scalar-bend extraction either way.""" + from gp2rs import _bend_intent_from_values # lazy: gp2rs<->gpx circular + stages = ( + ('BendOriginValue', 'BendOriginOffset', 0.0), + ('BendMiddleValue', 'BendMiddleOffset1', 50.0), + ('BendDestinationValue', 'BendDestinationOffset', 100.0), + ) + pts = [] + for vkey, okey, default_off in stages: + v = _gpx_bend_float(tp, vkey) + if v is None: + continue + off = _gpx_bend_float(tp, okey) + if off is None: + off = default_off + off = max(0.0, min(100.0, off)) + pts.append((off, round(v / divisor, 1))) + if not pts: + return 0.0, 0, None + pts.sort(key=lambda p: p[0]) + values = [v for _, v in pts] + peak = round(max(values), 1) + intent = _bend_intent_from_values(values) + curve = None + if peak > 0 and sustain > 0 and len(pts) >= 2: + curve = [{"t": round(sustain * (off / 100.0), 3), "v": v} + for off, v in pts] + return peak, intent, curve + + def _resolve_pending_slides(rs_notes, rs_chords, pending_slides): """Resolve GP slide flags collected during the beat loop into RS slide fields, now that every note on each string is known. @@ -1277,6 +1417,10 @@ def convert_file( rs_chords: list[RsChord] = [] chord_templates: list[ChordTemplate] = [] chord_template_map: dict[tuple, int] = {} + # Authored chord diagrams (name + per-string fingering) for this track, + # keyed by fret pattern so they enrich matching played voicings. + chord_diagram_map = _parse_chord_diagrams( + track.get('_el'), track['string_pitches']) beats_out: list[RsBeat] = [] sections: list[RsSection] = [] section_counts: dict[str, int] = {} @@ -1473,21 +1617,21 @@ def convert_file( rn.pull_off = True else: rn.hammer_on = True - # Bend: peak amount (GPIF bend value → semitones, - # scale auto-detected per file in _bend_divisor). + # Bend: `bn` is the peak; `bnv`/`bt` capture + # the shape over time (§6.2.1). value/divisor + # = semitones (scale auto-detected per file). if 'Bended' in _tp: - _bv = 0.0 - for _bk in ('BendDestinationValue', - 'BendMiddleValue', 'BendOriginValue'): - _be = _tp.get(_bk) - if _be is not None: - try: - _bv = max(_bv, float( - _be.findtext('Float') or 0)) - except (ValueError, TypeError): - pass - if _bv > 0: - rn.bend = round(_bv / _bend_divisor, 1) + # Use the beat duration `dur`, not + # `rn.sustain` (zeroed for notes <= 0.2s), + # so short bends keep their bnv curve — + # matching the GP5 path, which maps over + # the raw note duration. + _peak, _intent, _curve = _gpx_bend_shape( + _tp, _bend_divisor, dur) + if _peak > 0: + rn.bend = _peak + rn.bend_intent = _intent + rn.bend_values = _curve # Slide flags: 1/2 = pitched slide to the next # note; 4 = slide out down, 8 = out up. Resolved # post-loop (needs the next note on the string). @@ -1533,8 +1677,12 @@ def convert_file( fkey = tuple(frets_t) if fkey not in chord_template_map: chord_template_map[fkey] = len(chord_templates) + _diag = chord_diagram_map.get(fkey) chord_templates.append(ChordTemplate( - name='', frets=list(frets_t), fingers=[-1] * width, + name=(_diag['name'] if _diag else ''), + frets=list(frets_t), + fingers=(list(_diag['fingers']) if _diag + else [-1] * width), )) rs_chords.append(RsChord( time=t, diff --git a/lib/sloppak.py b/lib/sloppak.py index b3e8c7f..e24f1bb 100644 --- a/lib/sloppak.py +++ b/lib/sloppak.py @@ -24,6 +24,7 @@ from __future__ import annotations import json import logging +import math import shutil import threading import zipfile @@ -32,6 +33,11 @@ from pathlib import Path log = logging.getLogger("slopsmith.lib.sloppak") +# The feedpak format version this build targets / writes (manifest +# `feedpak_version`, a semver string per spec §4). Readers tolerate any version +# (additive/MINOR compatibility); writers stamp this. +FEEDPAK_VERSION = "1.2.0" + import yaml from safepath import safe_join @@ -42,6 +48,7 @@ from song import ( Arrangement, arrangement_from_wire, _finite_float, + sanitize_tempos, ) import drums as drums_mod import notation as notation_mod @@ -82,6 +89,27 @@ def is_sloppak(path: Path) -> bool: _source_cache: dict[str, tuple[Path, float, int]] = {} _source_lock = threading.Lock() +# Full-archive unpacks (zip form) are expensive — they write every stem to +# disk. Cap how many run at once so a burst (e.g. many plays queued, or a stray +# caller looping the library) can't saturate disk/CPU, and serialize per-file so +# two callers never rmtree + re-extract the same dest simultaneously (which +# would corrupt the half-written dir the other is reading). +_UNPACK_MAX_CONCURRENCY = 2 +_unpack_semaphore = threading.BoundedSemaphore(_UNPACK_MAX_CONCURRENCY) +_unpack_locks: dict[str, threading.Lock] = {} +_unpack_locks_guard = threading.Lock() + + +def _unpack_lock_for(filename: str) -> threading.Lock: + """Return a stable per-file lock so concurrent unpacks of the same sloppak + serialize instead of racing on the same destination dir.""" + with _unpack_locks_guard: + lk = _unpack_locks.get(filename) + if lk is None: + lk = threading.Lock() + _unpack_locks[filename] = lk + return lk + def _unpack_zip(zip_path: Path, dest: Path) -> None: """Extract a sloppak zip archive into dest, replacing any previous contents. @@ -154,10 +182,26 @@ def resolve_source_dir( if path.is_dir(): resolved = path else: - # Zip form — unpack to the cache. + # Zip form — unpack to the cache. Serialize per-file (so concurrent + # callers don't rmtree + re-extract the same dest at once) and cap + # global unpack concurrency (so a burst can't saturate disk/CPU). dest = unpack_cache_root / _safe_id(filename) - _unpack_zip(path, dest) - resolved = dest + with _unpack_lock_for(filename): + # Re-check the cache inside the per-file lock — a prior holder may + # have just finished unpacking this exact (mtime, size). + with _source_lock: + cached = _source_cache.get(filename) + if ( + cached + and cached[1] == mtime + and cached[2] == size + and cached[0].exists() + ): + resolved = cached[0] + else: + with _unpack_semaphore: + _unpack_zip(path, dest) + resolved = dest with _source_lock: _source_cache[filename] = (resolved, mtime, size) @@ -224,6 +268,97 @@ def read_feedpak_version(manifest: dict) -> str | None: return None +_COVER_MEDIA_TYPES = { + ".jpg": "image/jpeg", ".jpeg": "image/jpeg", + ".png": "image/png", ".webp": "image/webp", +} + + +def _cover_media_type(name: str) -> str: + return _COVER_MEDIA_TYPES.get(Path(name).suffix.lower(), "image/jpeg") + + +def read_cover_bytes( + path: Path, manifest: dict | None = None +) -> tuple[bytes, str] | None: + """Return ``(image_bytes, media_type)`` for a sloppak's cover, or ``None``. + + Reads ONLY the cover image. For a zipped sloppak this opens the single + cover member rather than unpacking the whole archive (stems included), so + serving album art on the library grid never triggers a full extraction — + the dominant cost behind slow cover loading on scroll. + """ + try: + if manifest is None: + manifest = load_manifest(path) + except Exception: + manifest = {} + cover_rel = str((manifest or {}).get("cover") or "cover.jpg") + + if path.is_dir(): + # Directory form — read the file, guarding against escape. + cover_path = (path / cover_rel).resolve() + try: + cover_path.relative_to(path.resolve()) + except ValueError: + return None + if cover_path.is_file(): + try: + return cover_path.read_bytes(), _cover_media_type(cover_path.name) + except OSError as e: + log.warning("sloppak: failed to read cover %r: %s", cover_path, e) + return None + + # Zip form — read just the cover member, no unpack. Normalize the manifest + # name the way the filesystem would (collapse './' and 'a/../b', backslash → + # slash) so a non-canonical-but-valid cover like './cover.jpg' still resolves + # to the archive member 'cover.jpg' — matching the old unpack-then-resolve + # behavior — and reject zip-slip escape before opening. + _zip_root = Path("/_root").resolve() + safe = safe_join(_zip_root, cover_rel) + # `safe is None` → escape; `safe == _zip_root` → a degenerate name like "." + # or "subdir/.." that collapses to the root (member would be "."). Reject + # both, mirroring _unpack_zip's degenerate-root guard. + if safe is None or safe == _zip_root: + log.warning("sloppak: rejected unsafe cover name %r in %r", cover_rel, path) + return None + member = safe.relative_to(_zip_root).as_posix() + try: + with zipfile.ZipFile(str(path), "r") as zf: + try: + data = zf.read(member) + except KeyError: + return None + return data, _cover_media_type(member) + except (OSError, zipfile.BadZipFile, RuntimeError) as e: + log.warning("sloppak: failed to read cover from zip %r: %s", path, e) + return None + + +def _sanitize_time_signatures(events) -> list[dict]: + """Clean a time-signature event list (``[{time, ts:[num, den]}]``): keep + entries with a finite non-bool ``time`` and a ``ts`` of two integers >= 1, + sorted by time. Non-list / all-invalid input -> ``[]``.""" + out: list[dict] = [] + if isinstance(events, list): + for ev in events: + if not isinstance(ev, dict): + continue + t = ev.get("time") + ts = ev.get("ts") + if (not isinstance(t, (int, float)) or isinstance(t, bool) + or not math.isfinite(t)): + continue + if not isinstance(ts, list) or len(ts) != 2: + continue + if not all(isinstance(x, int) and not isinstance(x, bool) and x >= 1 + for x in ts): + continue + out.append({"time": float(t), "ts": [int(ts[0]), int(ts[1])]}) + out.sort(key=lambda e: e["time"]) + return out + + @dataclass class LoadedSloppak: """Result of loading a sloppak: the Song object plus stem descriptors.""" @@ -231,6 +366,9 @@ class LoadedSloppak: stems: list[dict] # [{"id": str, "file": str, "default": bool}] source_dir: Path manifest: dict + # The pack's declared format version (manifest `feedpak_version`, a semver + # string per spec §4). None when absent (legacy / pre-versioning packs). + feedpak_version: str | None = None # Parsed `drum_tab.json` payload when the manifest carries a `drum_tab:` # key pointing at a readable, schema-valid file. None otherwise (older # sloppaks, sloppaks without drums, sloppaks whose drum tab failed to @@ -242,6 +380,18 @@ class LoadedSloppak: # When present, its beats/sections take priority over any beats/sections # embedded in the arrangement JSONs. song_timeline: dict | None = None + # Parsed `keys.json` payload (manifest `keys:` key) — a song-level, + # instrument-independent key/scale-change track (spec §7.7). None when + # absent / unreadable / malformed. Streamed over the highway WS as a + # `keys` message; consumers (renderers, plugins) read it from there. + keys: 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` / + # `time_signatures` messages); a per-chart arrangement `tempos` overrides + # `tempos` for that chart (spec §6.10). + tempos: list | None = None + time_signatures: list | None = None # Maps arrangement id → validated notation payload. None when no # arrangement passed schema validation; a non-empty dict only when at least # one arrangement carried a `notation:` sub-key whose file loaded and passed @@ -252,9 +402,6 @@ class LoadedSloppak: # song.arrangements (not to manifest["arrangements"]) — skipped entries are # absent so indexing by song.arrangements index is safe. arrangement_ids: list[str | None] = field(default_factory=list) - # Declared `feedpak_version` from the manifest (semver string), or None when - # absent. Per the feedpak spec §4.1 an absent value is treated as "1.0.0". - feedpak_version: str | None = None def load_song( @@ -453,6 +600,8 @@ def load_song( # already loaded onto the song object — song_timeline is the authoritative # source for timeline data in sloppaks that carry it. song_timeline_data: dict | None = None + tempos_data: list | None = None + time_sigs_data: list | None = None song_timeline_rel = manifest.get("song_timeline") if isinstance(song_timeline_rel, str) and song_timeline_rel: try: @@ -535,6 +684,13 @@ def load_song( ) continue song_timeline_data = raw + # tempos / time_signatures (feedpak 1.2.0) are independent of the + # beats/sections validation above — all are optional — so load them + # whenever the payload parsed to a dict. + if isinstance(raw, dict): + tempos_data = sanitize_tempos(raw.get("tempos")) or None + time_sigs_data = _sanitize_time_signatures( + raw.get("time_signatures")) or None # Optional shared lyrics file. Same safety posture as the drum_tab # loader above: constrain the manifest-declared path to source_dir @@ -625,16 +781,80 @@ def load_song( default_on = bool(default_val) stems.append({"id": sid, "file": sfile, "default": default_on}) + # Optional keys.json — song-level, instrument-independent key/scale track + # (manifest `keys:` key, spec §7.7). Permissive like the other side-files: + # missing / unreadable / malformed -> None, never fatal. Stored as a + # sanitized {version, events:[{t, key, scale?}]} (finite t, non-empty string + # key, sorted) so the highway WS can stream it without re-validating. + 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 + if k_path is not None and k_path.exists(): + try: + raw = json.loads(k_path.read_text(encoding="utf-8")) + except Exception as e: + log.warning("sloppak: failed to parse keys %r: %s", keys_rel, e) + raw = None + if raw is not None and not isinstance(raw, dict): + log.warning("sloppak: keys %r ignored — expected dict, got %s", + keys_rel, type(raw).__name__) + elif isinstance(raw, dict): + if not isinstance(raw.get("events"), list): + log.warning("sloppak: keys %r ignored — 'events' must be a list", keys_rel) + else: + clean_events: list[dict] = [] + for ev in raw["events"]: + if not isinstance(ev, dict): + continue + # Drop events with a missing / non-numeric / non-finite + # time rather than silently rewriting them to 0.0 — a + # bad `t` makes the whole event meaningless. + t = ev.get("t") + if (not isinstance(t, (int, float)) or isinstance(t, bool) + or not math.isfinite(t)): + continue + t = float(t) + key = ev.get("key") + if not isinstance(key, str) or not key: + continue + entry = {"t": t, "key": key} + scale = ev.get("scale") + if isinstance(scale, str) and scale: + entry["scale"] = scale + clean_events.append(entry) + clean_events.sort(key=lambda e: e["t"]) + # int only — a float version (incl. NaN/Inf, which json.loads + # accepts) would raise on int(); default rather than abort the + # load of an optional side-file. + _ver = raw.get("version") + keys_data = { + "version": _ver if isinstance(_ver, int) + and not isinstance(_ver, bool) else 1, + "events": clean_events, + } + return LoadedSloppak( song=song, stems=stems, source_dir=source_dir, manifest=manifest, + feedpak_version=read_feedpak_version(manifest), drum_tab=drum_tab_data, song_timeline=song_timeline_data, + tempos=tempos_data, + time_signatures=time_sigs_data, + keys=keys_data, notation_by_id=notation_by_id_data, arrangement_ids=arrangement_ids_acc, - feedpak_version=read_feedpak_version(manifest), ) diff --git a/lib/song.py b/lib/song.py index 10e05fe..779c743 100644 --- a/lib/song.py +++ b/lib/song.py @@ -20,6 +20,13 @@ class Note: slide_to: int = -1 slide_unpitch_to: int = -1 bend: float = 0.0 + # Bend shape (§6.2.1, feedpak 1.4.0). `bend` stays the peak magnitude; + # `bend_intent` is the gesture (0 up, 1 release, 2 pre-bend, + # 3 pre-bend-release, 4 round-trip) and `bend_values` is the optional + # time-stamped curve [{t: seconds-from-onset, v: semitones}], authoritative + # when present. Both default-omitted on the wire; older readers ignore them. + bend_intent: int = 0 + bend_values: list | None = None hammer_on: bool = False pull_off: bool = False harmonic: bool = False @@ -151,6 +158,10 @@ class Arrangement: # RS2014 custom song pitch-shift field (cents). Commonly -1200.0 (one octave # down) for extended-range bass arrangements. 0.0 when absent or zero. cent_offset: float = 0.0 + # Per-chart tempo override (§6.10): [{time, bpm}]. None when the chart + # follows the song-level tempo; when present a Reader uses it for this + # chart and ignores the song-level tempo. + tempos: list | None = None @dataclass @@ -217,6 +228,16 @@ def note_to_wire(n: Note) -> dict: out["pkd"] = n.pick_direction if n.ignore: out["ig"] = True + # Bend shape (§6.2.1) — default-omitted: `bt` only when non-zero, `bnv` + # only when a curve is present. Mirrors the spec's "omit fields equal to + # their default" so a plain bend stays a single `bn` scalar on the wire. + if n.bend_intent: + out["bt"] = int(n.bend_intent) + if n.bend_values: + out["bnv"] = [ + {"t": round(p["t"], 3), "v": round(p["v"], 1)} + for p in n.bend_values + ] return out @@ -279,6 +300,33 @@ def _wire_int_optional(v, default=-1): return default +def _sanitize_bend_curve(raw): + """Clean a time-stamped bend curve (``[{t, v}]``, §6.2.1): keep entries with + a finite, non-bool numeric ``t`` and ``v``, coerced to float and sorted by + ``t``. Non-list / absent / all-invalid input -> ``None`` so an empty curve + round-trips as *omitted*, never ``[]``. ``t`` is seconds from the note + onset; ``v`` is semitones (same scale as the scalar ``bn`` peak).""" + if not isinstance(raw, list): + return None + out: list[dict] = [] + for p in raw: + if not isinstance(p, dict): + continue + t = p.get("t") + v = p.get("v") + if (not isinstance(t, (int, float)) or isinstance(t, bool) + or not math.isfinite(t)): + continue + if (not isinstance(v, (int, float)) or isinstance(v, bool) + or not math.isfinite(v)): + continue + out.append({"t": float(t), "v": float(v)}) + if not out: + return None + out.sort(key=lambda e: e["t"]) + return out + + def note_from_wire(d: dict, time: float | None = None) -> Note: return Note( time=float(d.get("t", time if time is not None else 0.0)), @@ -288,6 +336,8 @@ def note_from_wire(d: dict, time: float | None = None) -> Note: slide_to=int(d.get("sl", -1)), slide_unpitch_to=int(d.get("slu", -1)), bend=float(d.get("bn", 0.0)), + bend_intent=_wire_int_optional(d.get("bt"), 0), + bend_values=_sanitize_bend_curve(d.get("bnv")), hammer_on=bool(d.get("ho", False)), pull_off=bool(d.get("po", False)), harmonic=bool(d.get("hm", False)), @@ -585,6 +635,29 @@ def _finite_float(value, default: float = 0.0) -> float: return v if math.isfinite(v) else default +def sanitize_tempos(events) -> list[dict]: + """Clean a tempo-event list (``[{time, bpm}]``): keep entries with a finite + non-bool ``time`` and a finite ``bpm > 0``, coerced to float and sorted by + time. Non-list / all-invalid input -> ``[]``. Shared by the per-chart + arrangement ``tempos`` (§6.10) and the song-level ``song_timeline.tempos``.""" + out: list[dict] = [] + if isinstance(events, list): + for ev in events: + if not isinstance(ev, dict): + continue + t = ev.get("time") + bpm = ev.get("bpm") + if (not isinstance(t, (int, float)) or isinstance(t, bool) + or not math.isfinite(t)): + continue + if (not isinstance(bpm, (int, float)) or isinstance(bpm, bool) + or not math.isfinite(bpm) or bpm <= 0): + continue + out.append({"time": float(t), "bpm": float(bpm)}) + out.sort(key=lambda e: e["time"]) + return out + + def arrangement_to_wire(arr: Arrangement) -> dict: """Serialize an Arrangement into a JSON-ready dict matching the wire format.""" out = { @@ -612,6 +685,10 @@ def arrangement_to_wire(arr: Arrangement) -> dict: # "no tones". if arr.tones: out["tones"] = arr.tones + # Per-chart tempo override (§6.10) — additive; omit when the chart follows + # the song-level tempo (empty/None). + if arr.tempos: + out["tempos"] = list(arr.tempos) return out @@ -622,6 +699,7 @@ def arrangement_from_wire(d: dict) -> Arrangement: tuning=list(d.get("tuning", [0] * 6)), capo=int(d.get("capo", 0)), cent_offset=_finite_float(d.get("centOffset", 0.0)), + tempos=(sanitize_tempos(d.get("tempos")) or None), notes=[note_from_wire(n) for n in d.get("notes", [])], chords=[chord_from_wire(c) for c in d.get("chords", [])], anchors=[ @@ -736,6 +814,18 @@ def _chord_high_density(elem: ET.Element) -> bool: return False +def _parse_bend_values(n): + """Read a `bendValues` JSON attribute (GP import emits it; §6.2.1) and + sanitize it into a [{t,v}] curve, or None when absent/malformed.""" + raw = n.get("bendValues") + if not raw: + return None + try: + return _sanitize_bend_curve(json.loads(raw)) + except (ValueError, TypeError): + return None + + def _parse_note(n) -> Note: return Note( time=_float(n, "time"), @@ -745,6 +835,8 @@ def _parse_note(n) -> Note: slide_to=_int(n, "slideTo", -1), slide_unpitch_to=_int(n, "slideUnpitchTo", -1), bend=_float(n, "bend"), + bend_intent=_int(n, "bendIntent", 0), + bend_values=_parse_bend_values(n), hammer_on=_bool(n, "hammerOn"), pull_off=_bool(n, "pullOff"), harmonic=_bool(n, "harmonic"), diff --git a/lib/songmeta.py b/lib/songmeta.py index 55352ae..362e042 100644 --- a/lib/songmeta.py +++ b/lib/songmeta.py @@ -49,6 +49,15 @@ def _apply_to_sloppak_manifest(manifest: dict, fields: dict) -> bool: if "year" in fields: manifest["year"] = _coerce_year(fields["year"]) dirty = True + # Opportunistically declare the format version (spec §4) when we're already + # rewriting because a metadata field was supplied. Gated on `dirty` (i.e. a + # field was given) so this never forces a *standalone* rewrite with no fields + # passed, and `not in` so an existing (possibly higher) version is preserved, + # never downgraded. NB `dirty` here means "a field was supplied" — a + # supplied-but-identical value already triggers a rewrite (pre-existing). + if dirty and "feedpak_version" not in manifest: + from sloppak import FEEDPAK_VERSION + manifest["feedpak_version"] = FEEDPAK_VERSION return dirty diff --git a/plugins/highway_3d/screen.js b/plugins/highway_3d/screen.js index 0bb5431..99a23b1 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -9823,6 +9823,13 @@ // so Object.assign leaves a stale `true` from a previous // muted chord note untouched. Reset it explicitly here. _scrChordNote.fhm = cn.fhm || false; + // Same stale-scratch hazard for the bend shape: + // `bnv`/`bt` are omit-when-default on the wire, so a + // chord note without them would otherwise inherit the + // previous note's curve (and bendSemisAtTime would + // apply the wrong contour). Reset explicitly. + _scrChordNote.bnv = Array.isArray(cn.bnv) ? cn.bnv : undefined; + _scrChordNote.bt = cn.bt || 0; drawNote( _scrChordNote, now, @@ -11309,15 +11316,40 @@ return visualIdx >= (nStr - 1) * 0.5 ? -1 : 1; } + function bnvSampleAt(bnv, t) { + // Linear interpolation of a bend curve [{t, v}] (§6.2.1; t is + // seconds from the note onset) at elapsed time t. Clamps to the + // endpoints; returns 0 for an empty/invalid curve. + if (!Array.isArray(bnv) || bnv.length === 0) return 0; + if (t <= bnv[0].t) return bnv[0].v; + const last = bnv[bnv.length - 1]; + if (t >= last.t) return last.v; + for (let i = 1; i < bnv.length; i++) { + const a = bnv[i - 1], b = bnv[i]; + if (t <= b.t) { + const span = b.t - a.t; + return span > 0 ? a.v + (b.v - a.v) * ((t - a.t) / span) : b.v; + } + } + return last.v; + } + function bendSemisAtTime(n, chartTime) { + if (!(n?.sus > 0)) return 0; + // When the note carries an authoritative bend curve (§6.2.1), + // sample its real shape at the elapsed time so the gem's Y gesture + // and sustain ribbon follow the actual bend (pre-bend, round-trip, + // release, …). Negative samples clamp to 0 (upward-only Y offset). + if (Array.isArray(n.bnv) && n.bnv.length) { + return Math.max(0, bnvSampleAt(n.bnv, chartTime - n.t)); + } const bn = Number(n?.bn) || 0; - if (!(bn > 0) || !(n?.sus > 0)) return 0; + if (!(bn > 0)) return 0; const p = Math.max(0, Math.min(1, (chartTime - n.t) / Math.max(n.sus, 1e-6))); - // rise → hold → release: ramp up over the first ~35 %, hold, then - // release back down over the last ~30 %. Depicts the bend gesture - // (up and back down) rather than a monotone climb that only ever - // showed the bend going up. Drives both the sustain ribbon's Y - // contour and the gem's techniqueYNow offset. + // Fallback: synthesize rise → hold → release from the scalar peak. + // Ramp up over the first ~35 %, hold, then release over the last + // ~30 % — the bend gesture rather than a monotone climb. Drives both + // the sustain ribbon's Y contour and the gem's techniqueYNow offset. const RISE = BEND_ENV_RISE_FRAC, REL = BEND_ENV_RELEASE_FRAC; let env; if (p < RISE) env = p / RISE; @@ -11917,6 +11949,7 @@ const ribbonSusTrail = !!( (slideSt && n.f > 0 && (n.sus || 0) > 1e-4) || (Number(n.bn) > 0) + || (Array.isArray(n.bnv) && n.bnv.length > 0) || n.tr || hasTechniqueVibrato ); @@ -12087,11 +12120,17 @@ arrow.material.opacity = 1; } } - if (n.bn > 0) { + // Derive the peak from bn OR the bnv curve: a note may carry an + // authoritative curve with bn left at 0 (bn SHOULD be the peak + // whenever bnv exists — this is the robustness fallback). + const _bnvPeak = (Array.isArray(n.bnv) && n.bnv.length) + ? n.bnv.reduce((m, p) => Math.max(m, Number(p.v) || 0), 0) : 0; + const _bendPeak = Math.max(Number(n.bn) || 0, _bnvPeak); + if (_bendPeak > 0) { // Bend chevron stack — PlaneGeometry mesh so it tilts with // the gem (approachRot). Fixed world size so it perspective- // shrinks naturally without distFactor compensation. - const steps = Math.max(1, Math.min(4, Math.round(n.bn))); + const steps = Math.max(1, Math.min(4, Math.round(_bendPeak))); const bendSm = bendChevronMat(steps, activePalette[s] || 0xffffff); const l = pTechPlane.get(); l.material = _spriteMat2MeshMat(l, bendSm); diff --git a/server.py b/server.py index a7909a0..d2cae9d 100644 --- a/server.py +++ b/server.py @@ -6302,13 +6302,72 @@ def diagnostics_hardware(): +def _if_none_match_hits(header: str | None, etag: str) -> bool: + """True if an If-None-Match header matches `etag` (weak comparison). + + Handles the `*` wildcard and comma-separated lists, and ignores a weak + `W/` prefix on either side — the standard semantics for a conditional GET. + """ + if not header: + return False + bare = etag.removeprefix("W/") + for tok in header.split(","): + t = tok.strip() + if t == "*" or t.removeprefix("W/") == bare: + return True + return False + + +# Album art is served with a strong validator (an ETag on the sloppak byte +# path; FileResponse's own ETag/Last-Modified on the file paths) and revalidated +# with `no-cache`. That keeps re-scroll cheap — a conditional GET returns a +# bodyless 304 — without ever serving a stale cover. A long `immutable` max-age +# was rejected: the frontend's `?v=` buster is only second-resolution, so +# a same-second cover rewrite would keep the URL and pin the old bytes for the +# cache lifetime. Validation cost is negligible for a localhost backend. +_ART_CACHE_HEADERS = {"Cache-Control": "no-cache"} + + +def _art_etag(path: Path) -> str | None: + """Strong validator for an art file: nanosecond mtime + size (so a + same-second rewrite still changes it). None if the file can't be stat'd.""" + try: + st = path.stat() + return f'"{st.st_mtime_ns}-{st.st_size}"' + except OSError: + return None + + +def _art_conditional(etag: str | None, request: Request | None): + """Return (headers, not_modified) for an art response. `not_modified` is + True when the client's If-None-Match already matches `etag` → caller should + return a bodyless 304. Starlette's FileResponse emits an ETag but does NOT + itself evaluate If-None-Match, so every art path routes through here to get + real conditional handling.""" + headers = dict(_ART_CACHE_HEADERS) + if etag: + headers["ETag"] = etag + inm = request.headers.get("if-none-match") if request is not None else None + return headers, bool(etag) and _if_none_match_hits(inm, etag) + + +def _file_art_response(path: Path, media_type: str, request: Request | None): + """FileResponse for an on-disk art file, with no-cache + ETag and a bodyless + 304 when the client's validator still matches.""" + headers, not_modified = _art_conditional(_art_etag(path), request) + if not_modified: + return Response(status_code=304, headers=headers) + return FileResponse(str(path), media_type=media_type, headers=headers) + + @app.get("/api/song/{filename:path}/art") -async def get_song_art(filename: str): +async def get_song_art(filename: str, request: Request = None): """Serve album art for a song. Dispatches by format and returns the appropriate media type: - - Sloppak: serves `cover.jpg` (or manifest-declared cover) from - the source dir as JPEG/PNG/WebP. + - Sloppak: serves `cover.jpg` (or manifest-declared cover) read directly + from the package (the single cover member for zip-form sloppaks — no + full unpack) as JPEG/PNG/WebP. - Loose folder: serves the discovered art file directly as JPEG/PNG/WebP. """ @@ -6322,27 +6381,29 @@ async def get_song_art(filename: str): if not song_path.exists(): return JSONResponse({"error": "not found"}, 404) - # Sloppak path: pull cover.jpg from the source dir (manifest-declared or default). + # Sloppak path: read the cover (manifest-declared or default) straight from + # the package. For a zip-form sloppak this opens just the cover member — + # NOT the whole archive — so the library grid never triggers a full unpack + # of stems just to paint a thumbnail. if sloppak_mod.is_sloppak(song_path): + # Read the cover (cheap — single member, no full unpack) and validate by + # its CONTENT. A stat-based ETag would be wrong for directory-form + # sloppaks: editing cover.jpg in place changes the file's mtime, not the + # directory's, so a dir-stat ETag could emit a stale 304. Content hashing + # is correct for both dir- and zip-form. Raw byte Response lacks + # FileResponse's validators, so we attach the ETag + honor If-None-Match. try: - src = sloppak_mod.resolve_source_dir(filename, dlc, SLOPPAK_CACHE_DIR) - manifest = sloppak_mod.load_manifest(song_path) - cover_rel = str(manifest.get("cover") or "cover.jpg") - cover_path = (src / cover_rel).resolve() - # Prevent escape and fall back to default name if missing. - try: - cover_path.relative_to(src.resolve()) - except ValueError: - return JSONResponse({"error": "forbidden"}, 403) - if cover_path.exists() and cover_path.is_file(): - mt = { - ".jpg": "image/jpeg", ".jpeg": "image/jpeg", - ".png": "image/png", ".webp": "image/webp", - }.get(cover_path.suffix.lower(), "image/jpeg") - return FileResponse(str(cover_path), media_type=mt) + art = await asyncio.to_thread(sloppak_mod.read_cover_bytes, song_path) except Exception: - pass - return JSONResponse({"error": "no art"}, 404) + art = None + if art is None: + return JSONResponse({"error": "no art"}, 404) + data, mt = art + etag = f'"{hashlib.sha1(data).hexdigest()}"' + headers, not_modified = _art_conditional(etag, request) + if not_modified: + return Response(status_code=304, headers=headers) + return Response(content=data, media_type=mt, headers=headers) # Loose folder path: serve art file directly. # song_path is already validated against DLC_DIR by _resolve_dlc_path. @@ -6362,7 +6423,7 @@ async def get_song_art(filename: str): ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp", }.get(art_resolved.suffix.lower(), "image/jpeg") - return FileResponse(str(art_resolved), media_type=mt) + return _file_art_response(art_resolved, mt, request) return JSONResponse({"error": "no art"}, 404) # Custom art uploaded via /art/upload is cached as PNG under ART_CACHE_DIR; @@ -6371,7 +6432,7 @@ async def get_song_art(filename: str): safe_name = filename.replace("/", "_").replace(" ", "_") cached = art_cache / f"{safe_name}.png" if cached.exists(): - return FileResponse(str(cached), media_type="image/png") + return _file_art_response(cached, "image/png", request) return JSONResponse({"error": "no art"}, 404) @@ -6919,6 +6980,11 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1, and _notation_arr_id is not None and _notation_arr_id in loaded_slop.notation_by_id ), + # Song-level key/scale track presence (keys.json, spec §7.7) so a + # consumer can light up a key/scale display without parsing the pack. + "has_keys": bool( + is_slop and loaded_slop is not None and loaded_slop.keys is not None + ), }) # Send drum_tab when the sloppak ships one (manifest `drum_tab:` key, @@ -6959,6 +7025,31 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1, sections = [{"name": s.name, "time": s.start_time} for s in song.sections] await websocket.send_json({"type": "sections", "data": sections}) + # Send the song-level key/scale track (keys.json, spec §7.7) when the + # sloppak ships one. Consumers read it from the WS rather than the file, + # like drum_tab/beats/sections. The loader already sanitized the events + # (finite t, non-empty string key, sorted), so this is a direct send. + if is_slop and loaded_slop is not None and loaded_slop.keys is not None: + await websocket.send_json({ + "type": "keys", + "version": int(loaded_slop.keys.get("version", 1)), + "data": loaded_slop.keys.get("events") or [], + }) + + # Song-level tempo + time-signature maps (song_timeline, feedpak 1.2.0), + # plus the per-chart tempo override (§6.10): the active arrangement's own + # `tempos` wins over the song-level map for this chart. Both are + # pre-sanitized by the loader / arrangement_from_wire, so they stream + # directly. Consumers read these rather than the file. + _song_tempos = loaded_slop.tempos if (is_slop and loaded_slop is not None) else None + _tempos_out = getattr(arr, "tempos", None) or _song_tempos + if _tempos_out: + await websocket.send_json({"type": "tempos", "data": _tempos_out}) + _time_sigs = (loaded_slop.time_signatures + if (is_slop and loaded_slop is not None) else None) + if _time_sigs: + await websocket.send_json({"type": "time_signatures", "data": _time_sigs}) + # Send notation data when the sloppak ships it for the active arrangement. # Slots after sections (cursor sync depends on beats, which precede sections) # and before anchors — per docs/sloppak-spec.md §5.3. diff --git a/static/highway.js b/static/highway.js index 6ac1f2c..fd8871a 100644 --- a/static/highway.js +++ b/static/highway.js @@ -468,6 +468,23 @@ function createHighway() { return w / 2 - hw + margin + t * usable; } + /** Map a bend curve [{t, v}] (§6.2.1) to [{x, v}] with x normalized to + * 0..1 across the curve's time span (0 when the span is degenerate). + * Pure — drives the 2D bend-shape glyph. */ + function bnvNormalizedPoints(bnv, sus) { + if (!Array.isArray(bnv) || bnv.length === 0) return []; + // Map each point's time over the NOTE's span [0, sus] so it sits at its + // real fraction of the note (a bend that completes before the note ends + // draws short of the glyph's right edge). Fall back to the curve's own + // t-range only when the note has no usable sustain. + if (Number.isFinite(sus) && sus > 0) { + return bnv.map(p => ({ x: Math.min(Math.max(p.t / sus, 0), 1), v: p.v })); + } + const t0 = bnv[0].t; + const span = bnv[bnv.length - 1].t - t0; + return bnv.map(p => ({ x: span > 0 ? (p.t - t0) / span : 0, v: p.v })); + } + /** Call while lefty mirror transform is active; keeps glyphs readable. */ function fillTextReadable(text, x, y) { // ctx may be null when the 2D context was never acquired @@ -1444,7 +1461,8 @@ function createHighway() { const isPinchHarmonic = opts?.hp || false; const isChord = opts?.chord || false; const bend = opts?.bn || 0; - const slide = opts?.sl || -1; + const slide = opts?.sl ?? -1; // pitched slide-to fret (-1 = none; 0 = slide to open) + const slu = opts?.slu ?? -1; // unpitched slide-to fret (-1 = none) const hammerOn = opts?.ho || false; const pullOff = opts?.po || false; const tap = opts?.tp || false; @@ -1600,27 +1618,59 @@ function createHighway() { // Bend notation if (bend && bend > 0 && sz >= 12) { const lw = Math.max(2, sz / 10); - const arrowH = sz * 0.55 * Math.min(bend, 2); // taller for bigger bends const ay = y - half - 4; - const tipY = ay - arrowH; + // px above the gem for a bend of `v` semitones (shared by the + // curve contour and the scalar-arrow fallback). + const hOf = (v) => sz * 0.55 * Math.min(Math.max(v, 0), 2); + const bnv = Array.isArray(opts?.bnv) ? opts.bnv : null; ctx.strokeStyle = '#fff'; ctx.lineWidth = lw; - // Curved arrow - ctx.beginPath(); - ctx.moveTo(x, ay); - ctx.quadraticCurveTo(x + sz * 0.2, ay - arrowH * 0.5, x, tipY); - ctx.stroke(); + let labelTopY; // y of the highest drawn point, for the label + if (bnv && bnv.length >= 2) { + // Bend curve (§6.2.1): trace the real shape as a contour above + // the gem (round-trip rises then falls, pre-bend starts high, + // release descends, …) — `bt` is implicit in the point shape. + const pts = bnvNormalizedPoints(bnv, opts?.sus); + const gw = sz * 0.6; + const x0 = x - gw / 2; + ctx.beginPath(); + pts.forEach((pt, i) => { + const px = x0 + pt.x * gw; + const py = ay - hOf(pt.v); + if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py); + }); + ctx.stroke(); + // Arrowhead only when the gesture ends rising (plain bend / + // pre-bend); round-trip and release finish heading down. + const a = pts[pts.length - 2], b = pts[pts.length - 1]; + if (b.v > a.v + 0.05) { + const tipX = x0 + b.x * gw, tipY = ay - hOf(b.v); + ctx.beginPath(); + ctx.moveTo(tipX - sz * 0.1, tipY + sz * 0.12); + ctx.lineTo(tipX, tipY); + ctx.lineTo(tipX + sz * 0.1, tipY + sz * 0.12); + ctx.stroke(); + } + labelTopY = ay - hOf(Math.max(...pts.map(p => p.v))); + } else { + // Fallback: single curved arrow up to the scalar peak. + const arrowH = hOf(bend); // taller for bigger bends + const tipY = ay - arrowH; + ctx.beginPath(); + ctx.moveTo(x, ay); + ctx.quadraticCurveTo(x + sz * 0.2, ay - arrowH * 0.5, x, tipY); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(x - sz * 0.12, tipY + sz * 0.12); + ctx.lineTo(x, tipY); + ctx.lineTo(x + sz * 0.12, tipY + sz * 0.12); + ctx.stroke(); + labelTopY = tipY; + } - // Arrowhead - ctx.beginPath(); - ctx.moveTo(x - sz * 0.12, tipY + sz * 0.12); - ctx.lineTo(x, tipY); - ctx.lineTo(x + sz * 0.12, tipY + sz * 0.12); - ctx.stroke(); - - // Bend label: "full", "1/2", "1 1/2", "2" + // Bend label: peak magnitude — "full", "1/2", "1 1/2", "2" let label; if (bend === 0.5) label = '½'; else if (bend === 1) label = 'full'; @@ -1632,25 +1682,34 @@ function createHighway() { ctx.font = `bold ${Math.max(9, sz * 0.28) | 0}px sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; - fillTextReadable(label, x, tipY - 2); + fillTextReadable(label, x, labelTopY - 2); } if (sz < 14) return; // Skip small technique labels - // Slide indicator (diagonal arrow) - if (slide >= 0) { - const dir = slide > fret ? -1 : 1; // arrow direction (up or down the neck); mirror handles lefty + // Slide indicator (diagonal arrow). Pitched (sl) draws a solid arrow to + // the target fret; unpitched (slu) draws a dashed diagonal with no + // arrowhead (no definite target pitch). The two are mutually exclusive + // in the data; the 3D highway makes the same pitched/unpitched split. + if (slide >= 0 || slu >= 0) { + const pitched = slide >= 0; + const target = pitched ? slide : slu; + const dir = target > fret ? -1 : 1; // up or down the neck; mirror handles lefty ctx.strokeStyle = '#fff'; ctx.lineWidth = Math.max(2, sz / 10); + if (!pitched) ctx.setLineDash([Math.max(2, sz / 8), Math.max(2, sz / 8)]); ctx.beginPath(); ctx.moveTo(x - sz * 0.3, y + dir * sz * 0.3); ctx.lineTo(x + sz * 0.3, y - dir * sz * 0.3); ctx.stroke(); - // Arrowhead - ctx.beginPath(); - ctx.moveTo(x + sz * 0.3, y - dir * sz * 0.3); - ctx.lineTo(x + sz * 0.15, y - dir * sz * 0.15); - ctx.stroke(); + if (!pitched) ctx.setLineDash([]); + // Arrowhead only for a pitched slide (definite target pitch). + if (pitched) { + ctx.beginPath(); + ctx.moveTo(x + sz * 0.3, y - dir * sz * 0.3); + ctx.lineTo(x + sz * 0.15, y - dir * sz * 0.15); + ctx.stroke(); + } } // H/P/T label above note diff --git a/static/v3/index.html b/static/v3/index.html index 535d11e..bb731a2 100644 --- a/static/v3/index.html +++ b/static/v3/index.html @@ -708,6 +708,14 @@ +
+ Scoreboard + +
Venue Motion