Merge branch 'main' into chore/deprecate-sloppak-to-feedpak

# Conflicts:
#	lib/sloppak.py
This commit is contained in:
Bret Mogilefsky
2026-06-20 20:29:07 -07:00
29 changed files with 2094 additions and 1072 deletions
+3 -1
View File
@@ -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.
+7 -1
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
0.2.9
0.3.0
+4 -4
View File
@@ -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 03 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/`.
+31 -921
View File
@@ -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 03; 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_<id>.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_<id>.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 `<rights>`). Omit when absent |
| `lyricist` | string | Optional lyricist credit (MusicXML `<creator type="lyricist">`). Omit when absent |
| `arranger` | string | Optional arranger credit (MusicXML `<creator type="arranger">`). 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 `<grace slash="yes">`), `"p"` = appoggiatura (unslashed, steals time from the following note; `<grace>`). 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 `<arpeggiate>`) |
| `ferm` | omit | `true` when the beat carries a fermata (MusicXML `<fermata>`) |
| `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: `<pedal type="start">``spd`, `<pedal type="change">``spu` + `spd` on the same beat (re-pedal), `<pedal type="stop">``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 0127. 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 `<stem>`). 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_<id>.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, 5001500 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.
+4
View File
@@ -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",
+177 -30
View File
@@ -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 <note>/<chordNote>, 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)))
+163 -15
View File
@@ -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 <Position finger="..."> 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 ``<Diagram>`` with
per-string ``<Fret string=.. fret=..>`` plus
``<Fingering><Position finger=.. string=..></Fingering>``. Diagram string
indices share the positional space of note ``String`` indices, so they go
through the same pitch-rank transform; ``<Fret fret>`` 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 `<Property><Float>` 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,
+227 -7
View File
@@ -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),
)
+92
View File
@@ -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"),
+9
View File
@@ -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
+47 -8
View File
@@ -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);
+114 -23
View File
@@ -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=<mtime>` 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.
+84 -25
View File
@@ -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
+9
View File
@@ -708,6 +708,14 @@
<option value="0.5">Low</option>
</select>
</div>
<div class="v3-pop-row">
<span class="v3-pop-label" id="scoreboard-label">Scoreboard</span>
<select id="scoreboard-select" onchange="setScoreboard(this.value)" class="v3-pop-select" aria-labelledby="scoreboard-label" title="Highway scoreboard">
<option value="core" selected>Streak</option>
<option value="detailed">Detailed</option>
<option value="off">Off</option>
</select>
</div>
<div class="v3-pop-row">
<span class="v3-pop-label" id="venue-motion-label">Venue Motion</span>
<select id="venue-motion-select" class="v3-pop-select" aria-labelledby="venue-motion-label" title="Venue Motion">
@@ -858,6 +866,7 @@
<script src="/static/v3/badges.js"></script>
<script src="/static/v3/stats-recorder.js"></script>
<script src="/static/v3/live-performance-hud.js"></script>
<script src="/static/v3/scoreboard-pref.js"></script>
<script src="/static/v3/venue-viz.js"></script>
<script src="/static/v3/venue-instrument-pov.js"></script>
<!-- venue-mood-fx must load before venue-scene-3d: the scene bridge reads
+1 -1
View File
@@ -146,7 +146,7 @@
'<div class="text-xs uppercase tracking-wider text-fb-textDim">Decibels</div>' +
'<div class="text-3xl font-bold text-fb-gold mt-1">' + fmtDb(wallet.balance) + '</div>' +
'<div class="text-xs text-fb-textDim mt-1">' + fmtDb(wallet.lifetime_db) + ' earned lifetime</div>' +
'<button type="button" data-prog-shop class="mt-2 text-sm text-fb-primary hover:text-fb-primaryHi font-medium">Open Shop →</button>' +
'<button type="button" data-prog-shop class="mt-2 text-sm text-fb-primary hover:text-fb-primaryHi font-medium">Open Unlockables →</button>' +
'</div></div>' +
calibrationCard(st.onboarding) +
// Paths
+50
View File
@@ -0,0 +1,50 @@
/*
* fee[dB]ack highway scoreboard preference.
*
* The 3D/2D highway can show note-detection scoring two ways: the core v3
* live-performance HUD (#v3-live-performance-hud) and the note_detect plugin's
* own HUD (.nd-hud). Both auto-render off the same note:hit/note:miss events,
* so without a preference you get two overlapping scoreboards.
*
* This module is the single source of truth: it writes <html data-scoreboard>
* (core | detailed | off) and CSS in v3.css hides the non-selected HUD(s).
* Default is 'core'. The CSS keys the default off "not detailed and not off",
* so the right HUD is correct even before this script runs (no flash).
*/
(function () {
'use strict';
var KEY = 'highwayScoreboard';
var VALID = { core: 1, detailed: 1, off: 1 };
function read() {
var v = null;
try { v = localStorage.getItem(KEY); } catch (_e) { /* private mode */ }
return VALID[v] ? v : 'core';
}
function apply(v) {
if (document.documentElement) {
document.documentElement.setAttribute('data-scoreboard', v);
}
}
function setScoreboard(v) {
if (!VALID[v]) v = 'core';
try { localStorage.setItem(KEY, v); } catch (_e) { /* private mode */ }
apply(v);
var sel = document.getElementById('scoreboard-select');
if (sel && sel.value !== v) sel.value = v;
}
// Apply immediately so the correct HUD is set before the first note arrives.
apply(read());
// onchange="setScoreboard(this.value)" on the Settings select.
window.setScoreboard = setScoreboard;
document.addEventListener('DOMContentLoaded', function () {
var sel = document.getElementById('scoreboard-select');
if (sel) sel.value = read();
});
})();
+52 -28
View File
@@ -24,7 +24,7 @@
const NAV = [
{ key: 'home', screen: 'v3-home', label: 'Home', group: 'HOME', icon: 'home' },
{ key: 'progress', screen: 'v3-progress', label: 'Progress', group: 'HOME', icon: 'trophy' },
{ key: 'shop', screen: 'v3-shop', label: 'Shop', group: 'HOME', icon: 'tag' },
{ key: 'shop', screen: 'v3-shop', label: 'Unlockables', group: 'HOME', icon: 'tag' },
{ key: 'feedbarcade', screen: 'v3-feedbarcade', label: 'FeedBarcade', group: 'HOME', icon: 'arcade' },
{ key: 'plugins', screen: 'v3-plugins', label: 'Plugins', group: 'HOME', icon: 'plug' },
{ key: 'settings', screen: 'settings', label: 'Settings', group: 'HOME', icon: 'gear' },
@@ -33,9 +33,26 @@
{ key: 'lessons', screen: 'v3-lessons', label: 'Lessons', group: 'LIBRARY', icon: 'lessons' },
{ key: 'favorites', screen: 'favorites', label: 'Favorites', group: 'LIBRARY', icon: 'star' },
{ key: 'saved', screen: 'v3-saved', label: 'Saved for Later', group: 'LIBRARY', icon: 'bookmark' },
// Promoted plugins (group: null) — bundled plugins given their own
// first-class sidebar entry instead of the generic plugin gallery. They
// are placed by PROMOTED_PLUGINS below and their slots are filled by
// renderPromotedNav() only when the plugin is actually installed. All
// other plugins are reached solely via the single "Plugins" entry
// above. Screens are injected async by the plugin loader, so go()'s
// plugin- guard applies.
{ key: 'slopscale', screen: 'plugin-slopscale', label: 'SlopScale - Practice', group: null, icon: 'target' },
{ key: 'rig_builder', screen: 'plugin-rig_builder', label: 'Rig Builder', group: null, icon: 'amp' },
// Not in the sidebar groups, but routable (profile badge → here).
{ key: 'profile', screen: 'v3-profile', label: 'Profile', group: null, icon: 'user' },
];
// Bundled plugins promoted to dedicated sidebar entries. `anchorAfter` is
// the nav key the slot is rendered immediately below (within that key's
// group); a key that's the last item of the last group lands right after
// that group. Each is gated on the plugin actually being installed.
const PROMOTED_PLUGINS = [
{ navKey: 'slopscale', pluginId: 'slopscale', slotId: 'v3-nav-slopscale', anchorAfter: 'feedbarcade' },
{ navKey: 'rig_builder', pluginId: 'rig_builder', slotId: 'v3-nav-rig-builder', anchorAfter: 'saved' },
];
const TOPBAR_KEYS = ['home', 'songs', 'plugins', 'settings'];
const SIDEBAR_GROUPS = ['HOME', 'LIBRARY'];
@@ -53,6 +70,8 @@
lessons: 'M12 4L2 9l10 5 10-5-10-5zM6 11.5V16c0 1 2.7 2.5 6 2.5s6-1.5 6-2.5v-4.5',
trophy: 'M8 21h8m-4-4v4m-6-17h12v5a6 6 0 01-12 0V4zm12 2h2a2 2 0 01-2 4M6 6H4a2 2 0 002 4',
tag: 'M20.6 13.4l-7.2 7.2a2 2 0 01-2.8 0l-7-7V4h9.6l7.4 7.4a2 2 0 010 2zM7.5 7.5h.01',
amp: 'M4 5h16a1 1 0 011 1v12a1 1 0 01-1 1H4a1 1 0 01-1-1V6a1 1 0 011-1zm11 4a3 3 0 100 6 3 3 0 000-6zM6.5 8.5h.01M9 8.5h.01',
target: 'M12 3a9 9 0 100 18 9 9 0 000-18zm0 4a5 5 0 100 10 5 5 0 000-10zm0 4a1 1 0 100 2 1 1 0 000-2z',
};
function iconSvg(name) {
const d = ICONS[name] || ICONS.disc;
@@ -114,12 +133,19 @@
}
// ── Sidebar ───────────────────────────────────────────────────────────--
function navItemHTML(entry) {
function navItemHTML(entry, labelOverride) {
return '<a href="#/' + entry.key + '" data-v3-nav="' + entry.key + '" ' +
'class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-fb-textDim ' +
'hover:text-fb-text hover:bg-fb-card/50 transition-colors">' +
iconSvg(entry.icon) + '<span>' + entry.label + '</span></a>';
iconSvg(entry.icon) + '<span class="truncate">' + esc(labelOverride != null ? labelOverride : entry.label) + '</span></a>';
}
// Empty slot for a promoted plugin, anchored after a nav item. Filled by
// renderPromotedNav() only when the plugin is installed, so an absent
// bundle shows nothing rather than a dead entry that bounces to Plugins.
const promotedSlotHTML = (key) => PROMOTED_PLUGINS
.filter((p) => p.anchorAfter === key)
.map((p) => '<div id="' + p.slotId + '"></div>')
.join('');
function renderSidebar() {
const nav = document.getElementById('v3-nav');
if (!nav) return;
@@ -127,11 +153,10 @@
for (const group of SIDEBAR_GROUPS) {
const items = NAV.filter((n) => n.group === group);
if (!items.length) continue;
const itemsHTML = items.map((it) => navItemHTML(it) + promotedSlotHTML(it.key)).join('');
html += '<div><div class="px-3 mb-1 text-[10px] uppercase tracking-wider font-semibold text-fb-textDim/70">' +
group + '</div><div class="space-y-0.5">' + items.map(navItemHTML).join('') + '</div></div>';
group + '</div><div class="space-y-0.5">' + itemsHTML + '</div></div>';
}
// Plugins group is appended later by renderPluginNav().
html += '<div id="v3-nav-plugins"></div>';
nav.innerHTML = html;
nav.querySelectorAll('a[data-v3-nav]').forEach((a) => {
a.addEventListener('click', (e) => {
@@ -223,32 +248,31 @@
if (bd) bd.classList.add('hidden');
}
// ── Plugin nav (legacy loader is the source; UI domain is deferred) ──────
async function renderPluginNav() {
const host = document.getElementById('v3-nav-plugins');
if (!host) return;
// ── Promoted-plugin nav (legacy loader is the source; UI domain deferred) ─
// Individual plugins are NOT listed in the sidebar — the single "Plugins"
// entry (HOME group) is the one entry point to the plugin gallery. The
// PROMOTED_PLUGINS get their own first-class slots, each filled here only
// when that plugin is actually installed.
async function renderPromotedNav() {
let plugins = [];
try {
const res = await fetch('/api/plugins');
if (res.ok) plugins = await res.json();
} catch (e) { return; } // degrade: no plugin group
const withNav = (Array.isArray(plugins) ? plugins : []).filter((p) => p && p.nav && (p.nav.label || p.name));
if (!withNav.length) return;
let html = '<div class="px-3 mt-2 mb-1 text-[10px] uppercase tracking-wider font-semibold text-fb-textDim/70">PLUGINS</div><div class="space-y-0.5">';
for (const p of withNav) {
const label = (p.nav && p.nav.label) || p.name || p.id;
html += '<a href="#" data-v3-plugin="' + esc(p.id) + '" ' +
'class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-fb-textDim hover:text-fb-text hover:bg-fb-card/50 transition-colors">' +
iconSvg('plug') + '<span class="truncate">' + esc(label) + '</span></a>';
} catch (e) { return; } // degrade: no promoted slots
const list = Array.isArray(plugins) ? plugins : [];
for (const promo of PROMOTED_PLUGINS) {
const host = document.getElementById(promo.slotId);
const entry = byKey(promo.navKey);
if (!host || !entry) continue;
const plugin = list.find((p) => p && p.id === promo.pluginId);
if (!plugin) continue; // not installed → empty slot
// Use the plugin's own nav label (manifest), falling back to the
// static NAV label. navItemHTML escapes it.
const label = (plugin.nav && plugin.nav.label) || plugin.name || entry.label;
host.innerHTML = '<div class="space-y-0.5">' + navItemHTML(entry, label) + '</div>';
const a = host.querySelector('a[data-v3-nav]');
if (a) a.addEventListener('click', (e) => { e.preventDefault(); go(entry.screen); });
}
html += '</div>';
host.innerHTML = html;
host.querySelectorAll('a[data-v3-plugin]').forEach((a) => {
a.addEventListener('click', (e) => {
e.preventDefault();
go('plugin-' + a.getAttribute('data-v3-plugin'));
});
});
}
// ── showScreen wrapper (idempotent rehydration — design/05 §Rehydration) ─
@@ -276,7 +300,7 @@
renderTopbar();
ensureBackdrop();
installShowScreenHook();
renderPluginNav(); // async, non-blocking
renderPromotedNav(); // async, non-blocking
// First-run gate: onboarding overlay is owned by prompt 15. Until it
// exists, degrade gracefully and go straight to the dashboard.
+2 -2
View File
@@ -383,7 +383,7 @@
: '';
return '<div class="group relative" data-fn="' + esc(key) + '" data-library-song="' + esc(songId(song)) + '" data-library-provider="' + esc(state.provider) + '">' +
'<div class="relative aspect-square rounded-lg overflow-hidden bg-fb-card cursor-pointer" data-v3-play>' +
'<img src="' + esc(artUrl(song)) + '" alt="" class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105" onerror="this.style.visibility=\'hidden\'">' +
'<img src="' + esc(artUrl(song)) + '" alt="" loading="lazy" decoding="async" class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105" onerror="this.style.visibility=\'hidden\'">' +
tuning + checkbox + accuracyBadge(key) + fmtBadge(song) + overlay +
'<div class="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition">' +
inlineBtns +
@@ -645,7 +645,7 @@
'<div><div class="text-xs uppercase tracking-wider text-fb-textDim/70 mt-2 mb-1">' + esc(al.name || 'Unknown') + '</div>' +
(al.songs || []).map((s) => { const k = cardKey(s); const fl = fmtLabel(s); return (
'<div class="flex items-center gap-2 py-1 group" data-fn="' + esc(k) + '" data-library-song="' + esc(songId(s)) + '" data-library-provider="' + esc(state.provider) + '">' +
'<img src="' + esc(artUrl(s)) + '" alt="" class="w-8 h-8 rounded object-cover bg-fb-card cursor-pointer" data-v3-play onerror="this.style.visibility=\'hidden\'">' +
'<img src="' + esc(artUrl(s)) + '" alt="" loading="lazy" decoding="async" class="w-8 h-8 rounded object-cover bg-fb-card cursor-pointer" data-v3-play onerror="this.style.visibility=\'hidden\'">' +
'<span class="flex-1 min-w-0 cursor-pointer" data-v3-play><span class="block text-sm text-fb-text truncate">' + esc(s.title) + '</span></span>' +
(fl ? '<span class="text-[9px] font-bold px-1 py-0.5 rounded shrink-0 ' + (fl === 'SLOPPAK' ? 'bg-fb-primary/20 text-fb-primary' : 'bg-fb-card text-fb-textDim') + '">' + fl + '</span>' : '') +
(state.accuracy[k] != null ? '<span class="text-xs font-bold ' + (state.accuracy[k] >= 0.9 ? 'text-fb-good' : state.accuracy[k] >= 0.5 ? 'text-fb-mid' : 'text-fb-low') + '">' + Math.round(state.accuracy[k] * 100) + '%</span>' : '') +
+13
View File
@@ -313,6 +313,19 @@
transition: border-color .35s ease, box-shadow .35s ease, background .35s ease;
}
.v3-live-performance-hud.hidden { display: none; }
/* Highway scoreboard preference (static/v3/scoreboard-pref.js).
One note-detection scoreboard at a time on the highway:
core (default) core live-performance HUD; hide the note_detect plugin HUD
detailed note_detect .nd-hud; hide the core HUD
off hide both
The default rule keys off "not detailed and not off" so it's correct even
before the pref script sets data-scoreboard (avoids a flash of both). */
html:not([data-scoreboard="detailed"]):not([data-scoreboard="off"]) .nd-hud { display: none !important; }
html[data-scoreboard="detailed"] #v3-live-performance-hud { display: none !important; }
html[data-scoreboard="off"] .nd-hud,
html[data-scoreboard="off"] #v3-live-performance-hud { display: none !important; }
.v3-live-performance-heading {
display: flex;
align-items: baseline;
+90
View File
@@ -0,0 +1,90 @@
// Behavioural tests for the per-note bend-curve (bnv, §6.2.1) render helpers:
// `bnvNormalizedPoints` (static/highway.js, 2D glyph) and `bnvSampleAt`
// (plugins/highway_3d/screen.js, 3D Y gesture). Both are pure, so we extract
// the function source by brace-matching and eval it in isolation.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
function extractFn(src, name) {
const start = src.indexOf('function ' + name);
assert.ok(start >= 0, `function ${name} must exist`);
const open = src.indexOf('{', start);
let depth = 0;
for (let i = open; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}' && --depth === 0) return src.slice(start, i + 1);
}
throw new Error(`unbalanced braces extracting ${name}`);
}
function loadFn(file, name) {
const src = fs.readFileSync(path.join(__dirname, '..', '..', file), 'utf8');
return new Function('"use strict";' + extractFn(src, name) + `\nreturn ${name};`)();
}
const bnvNormalizedPoints = loadFn('static/highway.js', 'bnvNormalizedPoints');
const bnvSampleAt = loadFn('plugins/highway_3d/screen.js', 'bnvSampleAt');
// ── bnvNormalizedPoints (2D) ─────────────────────────────────────────────────
test('bnvNormalizedPoints normalizes t to 0..1 across the curve span (no sus)', () => {
const pts = bnvNormalizedPoints([
{ t: 0.5, v: 0 }, { t: 1.0, v: 2 }, { t: 1.5, v: 0 }]);
assert.deepEqual(pts, [
{ x: 0, v: 0 }, { x: 0.5, v: 2 }, { x: 1, v: 0 }]);
});
test('bnvNormalizedPoints maps t over the note sus span when given', () => {
// A bend that completes at t=0.4 of a 0.5s note draws to x=0.8, not x=1 —
// i.e. it stops short of the glyph's right edge (correct timing shape).
assert.deepEqual(
bnvNormalizedPoints([{ t: 0, v: 0 }, { t: 0.25, v: 1 }, { t: 0.4, v: 0 }], 0.5),
[{ x: 0, v: 0 }, { x: 0.5, v: 1 }, { x: 0.8, v: 0 }]);
// Points beyond sus clamp to 1; sus<=0 falls back to curve-span mapping.
assert.deepEqual(bnvNormalizedPoints([{ t: 0, v: 0 }, { t: 1, v: 2 }], 0.5),
[{ x: 0, v: 0 }, { x: 1, v: 2 }]);
assert.deepEqual(bnvNormalizedPoints([{ t: 0, v: 0 }, { t: 1, v: 2 }], 0),
[{ x: 0, v: 0 }, { x: 1, v: 2 }]);
});
test('bnvNormalizedPoints handles degenerate/empty input', () => {
assert.deepEqual(bnvNormalizedPoints([]), []);
assert.deepEqual(bnvNormalizedPoints(null), []);
// All-same-t span collapses x to 0 (no divide-by-zero).
assert.deepEqual(bnvNormalizedPoints([{ t: 1, v: 1 }, { t: 1, v: 2 }]),
[{ x: 0, v: 1 }, { x: 0, v: 2 }]);
});
// ── bnvSampleAt (3D) ─────────────────────────────────────────────────────────
test('bnvSampleAt linearly interpolates between points', () => {
const bnv = [{ t: 0, v: 0 }, { t: 1, v: 2 }];
assert.equal(bnvSampleAt(bnv, 0.5), 1); // midpoint
assert.equal(bnvSampleAt(bnv, 0.25), 0.5);
});
test('bnvSampleAt clamps to the endpoints', () => {
const bnv = [{ t: 0.2, v: 1 }, { t: 0.8, v: 3 }];
assert.equal(bnvSampleAt(bnv, 0), 1); // before first
assert.equal(bnvSampleAt(bnv, 5), 3); // after last
});
test('bnvSampleAt traces a round-trip curve up then back down', () => {
const bnv = [{ t: 0, v: 0 }, { t: 0.5, v: 2 }, { t: 1, v: 0 }];
assert.equal(bnvSampleAt(bnv, 0.25), 1); // rising
assert.equal(bnvSampleAt(bnv, 0.5), 2); // peak
assert.equal(bnvSampleAt(bnv, 0.75), 1); // falling
});
test('bnvSampleAt returns 0 for an empty/invalid curve', () => {
assert.equal(bnvSampleAt([], 0.5), 0);
assert.equal(bnvSampleAt(null, 0.5), 0);
});
test('bnvSampleAt tolerates a zero-width segment (duplicate t)', () => {
const bnv = [{ t: 0, v: 0 }, { t: 0.5, v: 1 }, { t: 0.5, v: 2 }, { t: 1, v: 2 }];
assert.equal(bnvSampleAt(bnv, 0.5), 1); // first matching segment wins
});
+6 -2
View File
@@ -502,8 +502,12 @@ def test_attach_notation_to_sloppak(tmp_path):
entries = {e["id"]: e for e in rewritten["arrangements"]}
assert entries["keys"]["notation"] == "notation_keys.json"
assert "notation" not in entries["lead"]
# Key order preserved (sort_keys=False round-trip).
assert list(rewritten.keys()) == ["title", "artist", "arrangements", "stems"]
# Original key order preserved (sort_keys=False round-trip); the manifest
# rewrite also stamps feedpak_version (spec §4), appended at the end.
assert list(rewritten.keys()) == [
"title", "artist", "arrangements", "stems", "feedpak_version"]
from sloppak import FEEDPAK_VERSION
assert rewritten["feedpak_version"] == FEEDPAK_VERSION
def test_attach_notation_unknown_arrangement_raises(tmp_path):
+261 -3
View File
@@ -20,9 +20,11 @@ import pytest
from gp2rs import (
GP_TICKS_PER_QUARTER,
TempoEvent,
_bend_intent_from_values,
_build_playback_schedule,
_compute_tuning,
_extract_year,
_gp_bend_shape,
_gp_string_to_rs,
_is_bass_track,
_standard_tuning_for,
@@ -795,12 +797,14 @@ def _ct_note(note_type, gp_string, fret):
)
def _ct_song(beats):
"""One-measure mock song for convert_track, standard 6-string guitar at 120 BPM."""
def _ct_song(beats, string_values=None):
"""One-measure mock song for convert_track, standard 6-string guitar at 120 BPM.
`string_values` overrides the tuning/string count (e.g. a 7-string track)."""
voice = SimpleNamespace(beats=beats)
measure = SimpleNamespace(voices=[voice])
strings = [SimpleNamespace(number=i + 1, value=v)
for i, v in enumerate([64, 59, 55, 50, 45, 40])]
for i, v in enumerate(string_values or [64, 59, 55, 50, 45, 40])]
track = SimpleNamespace(
strings=strings,
channel=SimpleNamespace(instrument=24),
@@ -862,6 +866,80 @@ def test_tied_note_without_predecessor_is_silently_dropped():
assert len(notes) == 0
# ── convert_track: bend shape (bn / bt / bnv, §6.2.1) ────────────────────────
def _ct_bend(points):
"""A pyguitarpro-shaped BendEffect: points are (position 0..12, value)
pairs where value is half-quarter-tone units (12 = 6 semitones)."""
return SimpleNamespace(
points=[SimpleNamespace(position=p, value=v) for p, v in points],
)
def test_bend_intent_classifier():
assert _bend_intent_from_values([0.0, 1.0, 2.0]) == 0 # up
assert _bend_intent_from_values([2.0, 1.0, 0.0]) == 3 # pre-bend+release
assert _bend_intent_from_values([2.0, 2.0]) == 2 # pre-bend held
assert _bend_intent_from_values([2.0, 1.0]) == 1 # release (let down)
assert _bend_intent_from_values([0.0, 2.0, 0.0]) == 4 # round-trip
assert _bend_intent_from_values([]) == 0
def test_gp_bend_shape_units_and_time():
"""value/2 = semitones; position/12 * duration = seconds-from-onset."""
# 0.5 s note, up-bend 0 → value 4 (2 semitones) at the end.
peak, intent, curve = _gp_bend_shape(_ct_bend([(0, 0), (12, 4)]), 0.5)
assert peak == 2.0
assert intent == 0
assert curve == [{"t": 0.0, "v": 0.0}, {"t": 0.5, "v": 2.0}]
# Zero-length note collapses every point to t=0 → no usable curve.
_, _, curve0 = _gp_bend_shape(_ct_bend([(0, 0), (12, 4)]), 0.0)
assert curve0 is None
# A single point carries only the peak, no curve.
_, _, curve1 = _gp_bend_shape(_ct_bend([(6, 4)]), 0.5)
assert curve1 is None
def test_bent_note_imports_with_curve_through_wire():
"""A GP up-bend imports with bn (peak) + bt + bnv, and survives
convert_track XML _parse_note note_to_wire."""
from song import _parse_note, note_to_wire
note = _ct_note(guitarpro.NoteType.normal, gp_string=1, fret=7)
# quarter @ 120 BPM = 0.5 s; round-trip bend 0 → 2 → 0 semitones.
note.effect.bend = _ct_bend([(0, 0), (6, 4), (12, 0)])
beat = _ct_beat(tick=0, dur_value=4, notes=[note])
root = ET.fromstring(convert_track(_ct_song([beat]), track_index=0)) # noqa: S314
xn = root.findall(".//notes/note")[0]
assert xn.get("bend") == "2.0"
assert xn.get("bendIntent") == "4" # round-trip
import json
assert json.loads(xn.get("bendValues")) == [
{"t": 0.0, "v": 0.0}, {"t": 0.25, "v": 2.0}, {"t": 0.5, "v": 0.0}]
wire = note_to_wire(_parse_note(xn))
assert wire["bn"] == 2.0
assert wire["bt"] == 4
assert wire["bnv"] == [
{"t": 0.0, "v": 0.0}, {"t": 0.25, "v": 2.0}, {"t": 0.5, "v": 0.0}]
def test_non_bent_note_has_no_curve():
note = _ct_note(guitarpro.NoteType.normal, gp_string=1, fret=5) # bend=None
beat = _ct_beat(tick=0, dur_value=4, notes=[note])
root = ET.fromstring(convert_track(_ct_song([beat]), track_index=0)) # noqa: S314
xn = root.findall(".//notes/note")[0]
assert xn.get("bend") == "0"
assert xn.get("bendIntent") is None
assert xn.get("bendValues") is None
from song import _parse_note
n = _parse_note(xn)
assert n.bend == 0.0
assert n.bend_intent == 0
assert n.bend_values is None
def _ct_multivoice_song(voices_beats):
"""Multi-voice variant of _ct_song. `voices_beats` is a list of beat-lists,
one per voice, all on the same single measure."""
@@ -1100,3 +1178,183 @@ def test_tie_not_extended_across_repeat_boundary():
assert sustain == pytest.approx(0.5, abs=0.01), (
f"sustain should be ~0.5 s (one quarter note), got {sustain:.3f}"
)
# ── convert_track: GP5 chord-diagram fingering extraction (E3) ───────────────
# pyguitarpro exposes the chord-diagram voicing on beat.effect.chord:
# .strings is per-string frets indexed 0 = highest string, .fingerings is the
# parallel Fingering enum list (open=-1, thumb=0, index=1, middle=2, ring=3,
# pinky=4 — already the RS finger integers). A chord beat carrying this data
# must import with per-string fingers; a chord beat without it stays all -1.
def _ct_chord(name, strings, fingerings):
return SimpleNamespace(
name=name, strings=list(strings),
fingerings=list(fingerings), length=len(strings),
)
def test_chord_diagram_fingers_extracted():
# Two-note voicing on high e (fret 3) + B (fret 2). chord.strings is
# indexed 0 = highest string, so strings[0] = high e, strings[1] = B.
note_e = _ct_note(guitarpro.NoteType.normal, gp_string=1, fret=3) # high e
note_b = _ct_note(guitarpro.NoteType.normal, gp_string=2, fret=2) # B
beat = _ct_beat(tick=0, dur_value=4, notes=[note_e, note_b])
beat.effect.chord = _ct_chord(
"Gtest",
strings=[3, 2, -1, -1, -1, -1],
fingerings=[
guitarpro.Fingering.middle, # high e -> 2
guitarpro.Fingering.index, # B -> 1
],
)
xml_str = convert_track(_ct_song([beat]), track_index=0)
root = ET.fromstring(xml_str) # noqa: S314
ct = root.find(".//chordTemplates/chordTemplate")
assert ct is not None
assert ct.get("chordName") == "Gtest"
# _gp_string_to_rs(1, 6) = 5 (high e), _gp_string_to_rs(2, 6) = 4 (B).
assert ct.get("fret5") == "3" and ct.get("finger5") == "2"
assert ct.get("fret4") == "2" and ct.get("finger4") == "1"
assert [ct.get(f"finger{i}") for i in range(0, 4)] == ["-1"] * 4
def test_chord_without_diagram_has_blank_fingers():
# A plain two-note chord (effect.chord is None) is unchanged: blank name,
# all-(-1) fingers — no regression for diagram-less charts.
note_e = _ct_note(guitarpro.NoteType.normal, gp_string=1, fret=3)
note_b = _ct_note(guitarpro.NoteType.normal, gp_string=2, fret=2)
beat = _ct_beat(tick=0, dur_value=4, notes=[note_e, note_b]) # chord=None
xml_str = convert_track(_ct_song([beat]), track_index=0)
root = ET.fromstring(xml_str) # noqa: S314
ct = root.find(".//chordTemplates/chordTemplate")
assert ct is not None
assert ct.get("chordName") == ""
assert [ct.get(f"finger{i}") for i in range(6)] == ["-1"] * 6
def test_chord_diagram_backfills_template_first_strummed_unannotated():
# The annotated chord must enrich its voicing even when an earlier,
# unannotated beat of the SAME fret pattern created the template first.
plain = _ct_beat(
tick=0, dur_value=4,
notes=[_ct_note(guitarpro.NoteType.normal, gp_string=1, fret=3),
_ct_note(guitarpro.NoteType.normal, gp_string=2, fret=2)],
) # chord=None, creates the blank template
annotated = _ct_beat(
tick=GP_TICKS_PER_QUARTER, dur_value=4,
notes=[_ct_note(guitarpro.NoteType.normal, gp_string=1, fret=3),
_ct_note(guitarpro.NoteType.normal, gp_string=2, fret=2)],
)
annotated.effect.chord = _ct_chord(
"Gtest", strings=[3, 2, -1, -1, -1, -1],
fingerings=[guitarpro.Fingering.middle, guitarpro.Fingering.index],
)
xml_str = convert_track(_ct_song([plain, annotated]), track_index=0)
root = ET.fromstring(xml_str) # noqa: S314
cts = root.findall(".//chordTemplates/chordTemplate")
assert len(cts) == 1, "same voicing must dedup to one template"
assert cts[0].get("chordName") == "Gtest"
assert cts[0].get("finger5") == "2" and cts[0].get("finger4") == "1"
def test_chord_diagram_mismatch_not_applied():
# The attached diagram describes a DIFFERENT voicing (frets 5/5) than the
# notes actually played (3/2). It must NOT enrich the played template —
# otherwise a mislabeled chord would name/finger the wrong voicing (and the
# back-fill would spread it). Name + fingers stay blank.
note_e = _ct_note(guitarpro.NoteType.normal, gp_string=1, fret=3)
note_b = _ct_note(guitarpro.NoteType.normal, gp_string=2, fret=2)
beat = _ct_beat(tick=0, dur_value=4, notes=[note_e, note_b])
beat.effect.chord = _ct_chord(
"Wrong", strings=[5, 5, -1, -1, -1, -1], # != played 3/2
fingerings=[guitarpro.Fingering.annular, guitarpro.Fingering.annular],
)
xml_str = convert_track(_ct_song([beat]), track_index=0)
root = ET.fromstring(xml_str) # noqa: S314
ct = root.find(".//chordTemplates/chordTemplate")
assert ct is not None
assert ct.get("chordName") == ""
assert [ct.get(f"finger{i}") for i in range(6)] == ["-1"] * 6
def test_chord_diagram_name_then_fingers_decoupled():
# First annotated beat carries a NAME but no fingers (all open); a later beat
# of the same voicing carries the fingers. Both must land — a name-only first
# annotation must not block the later fingers (name/fingers back-fill
# independently).
def _beat(tick, name, fingerings):
b = _ct_beat(
tick=tick, dur_value=4,
notes=[_ct_note(guitarpro.NoteType.normal, gp_string=1, fret=3),
_ct_note(guitarpro.NoteType.normal, gp_string=2, fret=2)],
)
b.effect.chord = _ct_chord(name, strings=[3, 2, -1, -1, -1, -1],
fingerings=fingerings)
return b
first = _beat(0, "Gtest",
[guitarpro.Fingering.open, guitarpro.Fingering.open])
second = _beat(GP_TICKS_PER_QUARTER, "",
[guitarpro.Fingering.middle, guitarpro.Fingering.index])
xml_str = convert_track(_ct_song([first, second]), track_index=0)
root = ET.fromstring(xml_str) # noqa: S314
cts = root.findall(".//chordTemplates/chordTemplate")
assert len(cts) == 1
assert cts[0].get("chordName") == "Gtest" # from the first (name-only) beat
# fingers from the second beat — not blocked by the first beat's name
assert cts[0].get("finger5") == "2" and cts[0].get("finger4") == "1"
def test_chord_diagram_barre_higher_position_matches():
# A voicing high on the neck: diagram strings hold ABSOLUTE frets (firstFret
# is display-only), so they match the played absolute frets and the template
# enriches. Guards against an absolute-vs-relative matching regression.
notes = [_ct_note(guitarpro.NoteType.normal, gp_string=1, fret=5),
_ct_note(guitarpro.NoteType.normal, gp_string=2, fret=5),
_ct_note(guitarpro.NoteType.normal, gp_string=3, fret=6)]
beat = _ct_beat(tick=0, dur_value=4, notes=notes)
ch = _ct_chord("A", strings=[5, 5, 6, -1, -1, -1],
fingerings=[guitarpro.Fingering.index, guitarpro.Fingering.index,
guitarpro.Fingering.middle])
ch.firstFret = 5 # display base — must not affect matching
beat.effect.chord = ch
xml_str = convert_track(_ct_song([beat]), track_index=0)
root = ET.fromstring(xml_str) # noqa: S314
ct = root.find(".//chordTemplates/chordTemplate")
assert ct is not None
assert ct.get("chordName") == "A"
assert ct.get("fret5") == "5" and ct.get("finger5") == "1"
assert ct.get("fret4") == "5" and ct.get("finger4") == "1"
assert ct.get("fret3") == "6" and ct.get("finger3") == "2"
def test_chord_diagram_extended_string_outside_played_width_not_applied():
# 7-string track. Played voicing is on strings 2 & 3 only (width 6 — the
# high e / rs6 is unused), but the diagram ALSO frets string 1 (the extended
# rs6). The extra diagram note must make this a MISMATCH, not be silently
# trimmed to a false match — so the played template stays un-enriched.
seven = [64, 59, 55, 50, 45, 40, 35] # low-B 7-string
note_b = _ct_note(guitarpro.NoteType.normal, gp_string=2, fret=3) # rs5
note_g = _ct_note(guitarpro.NoteType.normal, gp_string=3, fret=2) # rs4
beat = _ct_beat(tick=0, dur_value=4, notes=[note_b, note_g])
# diagram index0 = gp_string1 (rs6) frets 5 (NOT played); index1/2 match.
beat.effect.chord = _ct_chord(
"Bogus", strings=[5, 3, 2, -1, -1, -1, -1],
fingerings=[guitarpro.Fingering.index, guitarpro.Fingering.middle,
guitarpro.Fingering.index],
)
xml_str = convert_track(_ct_song([beat], string_values=seven), track_index=0)
root = ET.fromstring(xml_str) # noqa: S314
ct = root.find(".//chordTemplates/chordTemplate")
assert ct is not None
assert ct.get("chordName") == ""
# played template is width 6 (rs6/high-e unused) -> finger0..finger5
assert all(ct.get(f"finger{i}") == "-1" for i in range(6))
+130
View File
@@ -31,6 +31,7 @@ from gp2rs_gpx import (
_collect_tone_events,
_inject_tones,
_resolve_pending_slides,
_gpx_bend_shape,
)
from gp2rs import RsNote
@@ -55,6 +56,50 @@ def test_safe_filename_stem(name, expected):
assert ".." not in out
# ── _gpx_bend_shape (bn / bt / bnv, §6.2.1) ─────────────────────────────────
def _bend_props(**vals):
"""Build a GPIF property map {name: <Property> element} for the given
bend Float values, e.g. _bend_props(BendOriginValue=0, BendMiddleValue=100)."""
tp = {}
for name, num in vals.items():
tp[name] = ET.fromstring(
f'<Property name="{name}"><Float>{num}</Float></Property>')
return tp
def test_gpx_bend_shape_round_trip_curve():
"""origin/middle/destination value+offset → 3-point bnv; value/divisor=semis."""
tp = _bend_props(
BendOriginValue=0, BendOriginOffset=0,
BendMiddleValue=100, BendMiddleOffset1=50, # 100/50 = 2 semitones
BendDestinationValue=0, BendDestinationOffset=100,
)
peak, intent, curve = _gpx_bend_shape(tp, divisor=50.0, sustain=1.0)
assert peak == 2.0
assert intent == 4 # round-trip (up then back down)
assert curve == [
{"t": 0.0, "v": 0.0}, {"t": 0.5, "v": 2.0}, {"t": 1.0, "v": 0.0}]
def test_gpx_bend_shape_falls_back_to_even_spacing_without_offsets():
tp = _bend_props(BendOriginValue=0, BendDestinationValue=100) # no offsets
peak, intent, curve = _gpx_bend_shape(tp, divisor=50.0, sustain=1.0)
assert peak == 2.0
assert intent == 0 # plain up
# origin defaults to 0%, destination to 100%.
assert curve == [{"t": 0.0, "v": 0.0}, {"t": 1.0, "v": 2.0}]
def test_gpx_bend_shape_no_props_and_zero_length():
assert _gpx_bend_shape({}, divisor=50.0, sustain=1.0) == (0.0, 0, None)
# Peak + intent still derived for a zero-length note, but no curve.
peak, intent, curve = _gpx_bend_shape(
_bend_props(BendOriginValue=0, BendDestinationValue=100),
divisor=50.0, sustain=0.0)
assert peak == 2.0 and intent == 0 and curve is None
# ── _decompress_bcfz / _parse_bcfs input guards ─────────────────────────────
def test_decompress_bcfz_rejects_bad_magic():
@@ -684,3 +729,88 @@ def test_note_vibrato_ignores_whammy_trembar_property():
'</Properties></Note>')
tp = {p.get('name'): p for p in n.findall('.//Property')}
assert _note_has_vibrato(n, tp) is False
# ── convert_file: GP8 chord-diagram name + fingering extraction (E3) ─────────
# GP7/GP8 GPIF carries authored chord diagrams under a track's
# Property[@name="DiagramCollection"]. Each Item gives the chord name and a
# <Diagram> with per-string fret + finger. A played voicing matching that
# fret pattern must import with the diagram's name + fingers; a chart without
# a DiagramCollection must import with blank name + all-(-1) fingers.
def _gpif_chord_diagram(diagram_block: str) -> str:
# A two-note chord (low E fret 3 + A fret 2) on a low->high tuned guitar.
return f"""
<GPIF>
<Score><Title>T</Title><Artist>A</Artist></Score>
<Tracks>
<Track id="0"><Name>Lead Guitar</Name>
<Property name="Tuning"><Pitches>40 45 50 55 59 64</Pitches></Property>
{diagram_block}
</Track>
</Tracks>
<MasterBars><MasterBar><Time>4/4</Time><Bars>0</Bars></MasterBar></MasterBars>
<Bars><Bar id="0"><Voices>0</Voices></Bar></Bars>
<Voices><Voice id="0"><Beats>0</Beats></Voice></Voices>
<Beats><Beat id="0"><Rhythm ref="r0"/><Notes>0 1</Notes></Beat></Beats>
<Notes>
<Note id="0">
<Property name="String"><String>0</String></Property>
<Property name="Fret"><Fret>3</Fret></Property></Note>
<Note id="1">
<Property name="String"><String>1</String></Property>
<Property name="Fret"><Fret>2</Fret></Property></Note>
</Notes>
<Rhythms><Rhythm id="r0"><NoteValue>Quarter</NoteValue></Rhythm></Rhythms>
</GPIF>
"""
_DIAGRAM_BLOCK = """
<Property name="DiagramCollection"><Items>
<Item id="1" name="G5">
<Diagram stringCount="6" fretCount="5" baseFret="0">
<Fret string="0" fret="3"/>
<Fret string="1" fret="2"/>
<Fingering>
<Position finger="Middle" fret="3" string="0"/>
<Position finger="Index" fret="2" string="1"/>
</Fingering>
</Diagram>
</Item>
</Items></Property>
"""
def _convert_first_chord_template(monkeypatch, tmp_path, gpif):
monkeypatch.setattr(gp2rs_gpx, "_load_gpif", lambda _p: ET.fromstring(gpif))
out_files = convert_file(
"dummy.gp", str(tmp_path),
track_indices=[0], arrangement_names={0: "Lead"},
)
root = ET.parse(out_files[0]).getroot()
cts = root.findall(".//chordTemplates/chordTemplate")
assert len(cts) == 1
return cts[0]
def test_convert_file_gp8_chord_diagram_enriches_template(tmp_path, monkeypatch):
ct = _convert_first_chord_template(
monkeypatch, tmp_path, _gpif_chord_diagram(_DIAGRAM_BLOCK))
# Diagram name + per-string fingering land on the matching voicing.
assert ct.get("chordName") == "G5"
# RS string 0 = low E (fret 3, Middle=2), string 1 = A (fret 2, Index=1).
assert ct.get("fret0") == "3" and ct.get("finger0") == "2"
assert ct.get("fret1") == "2" and ct.get("finger1") == "1"
# Unplayed strings stay -1 for both fret and finger.
assert [ct.get(f"finger{i}") for i in range(2, 6)] == ["-1"] * 4
def test_convert_file_gp8_no_diagram_leaves_template_blank(tmp_path, monkeypatch):
# Same chart, no DiagramCollection -> identical import to before E3.
ct = _convert_first_chord_template(
monkeypatch, tmp_path, _gpif_chord_diagram(""))
assert ct.get("chordName") == ""
assert [ct.get(f"finger{i}") for i in range(6)] == ["-1"] * 6
# Fret pattern itself is unchanged (the join key still works).
assert ct.get("fret0") == "3" and ct.get("fret1") == "2"
+148
View File
@@ -0,0 +1,148 @@
"""Album-art fast path + conditional-caching contract.
Covers the library cover-loading perf fix: `sloppak.read_cover_bytes` reads the
cover WITHOUT unpacking the whole archive, and `GET /api/song/{f}/art` serves it
with a content validator so re-scroll gets bodyless 304s never a stale cover.
Pins, so a future refactor can't silently reintroduce:
- the full-unpack-per-cover regression (covers served straight from the zip),
- the non-canonical manifest cover name (`./cover.jpg`) 404,
- zip-slip / degenerate cover names,
- dir-form sloppaks emitting a stale 304 after an in-place cover edit.
"""
import importlib
import sys
import zipfile
import pytest
import yaml
from fastapi.testclient import TestClient
import sloppak as sloppak_mod
# ── Unit: read_cover_bytes ────────────────────────────────────────────────────
def _zip_sloppak(path, cover_name="cover.jpg", manifest_cover="cover.jpg",
cover_bytes=b"\xff\xd8\xff\xe0JPG", with_stem=True):
with zipfile.ZipFile(path, "w") as zf:
zf.writestr("manifest.yaml", yaml.safe_dump({"cover": manifest_cover}))
zf.writestr(cover_name, cover_bytes)
if with_stem:
# A big-ish stem so a regression that unpacks the whole archive
# would be doing real work, not just touching the cover.
zf.writestr("stems/full.ogg", b"OggS" + b"\x00" * 4096)
def _dir_sloppak(path, cover_bytes=b"\xff\xd8\xff\xe0JPG"):
path.mkdir(parents=True)
(path / "manifest.yaml").write_text(yaml.safe_dump({"cover": "cover.jpg"}))
(path / "cover.jpg").write_bytes(cover_bytes)
return path
def test_read_cover_from_zip(tmp_path):
z = tmp_path / "a.sloppak"
_zip_sloppak(z, cover_bytes=b"\xff\xd8\xff\xe0HELLO")
res = sloppak_mod.read_cover_bytes(z)
assert res is not None
data, mt = res
assert data == b"\xff\xd8\xff\xe0HELLO"
assert mt == "image/jpeg"
def test_read_cover_from_dir(tmp_path):
d = _dir_sloppak(tmp_path / "b.sloppak", cover_bytes=b"\xff\xd8\xff\xe0DIR")
res = sloppak_mod.read_cover_bytes(d)
assert res is not None and res[0] == b"\xff\xd8\xff\xe0DIR" and res[1] == "image/jpeg"
@pytest.mark.parametrize("manifest_cover", ["./cover.jpg", "art/../cover.jpg"])
def test_noncanonical_manifest_cover_resolves(tmp_path, manifest_cover):
"""A valid-but-non-canonical name must resolve to the real member, matching
the old unpack-then-resolve-on-filesystem behavior."""
z = tmp_path / "c.sloppak"
_zip_sloppak(z, manifest_cover=manifest_cover, cover_bytes=b"\xff\xd8\xff\xe0X")
res = sloppak_mod.read_cover_bytes(z)
assert res is not None and res[0] == b"\xff\xd8\xff\xe0X"
@pytest.mark.parametrize("bad", ["../../escape.png", ".", "subdir/..", "/abs.png", ""])
def test_unsafe_or_degenerate_cover_name_rejected(tmp_path, bad):
z = tmp_path / "d.sloppak"
# Put a real cover.jpg in the archive; the manifest points at the bad name.
_zip_sloppak(z, manifest_cover=bad if bad else "cover.jpg")
if bad == "":
# Empty falls back to the default cover.jpg (intended contract).
assert sloppak_mod.read_cover_bytes(z) is not None
else:
assert sloppak_mod.read_cover_bytes(z) is None
def test_webp_media_type(tmp_path):
z = tmp_path / "e.sloppak"
_zip_sloppak(z, cover_name="cover.webp", manifest_cover="cover.webp",
cover_bytes=b"RIFF....WEBP")
res = sloppak_mod.read_cover_bytes(z)
assert res is not None and res[1] == "image/webp"
# ── Endpoint: conditional caching ─────────────────────────────────────────────
@pytest.fixture()
def dlc_client(tmp_path, monkeypatch):
"""TestClient with a temp DLC_DIR; sync startup, no scan, no plugins."""
dlc = tmp_path / "dlc"
dlc.mkdir()
config = tmp_path / "cfg"
config.mkdir()
monkeypatch.setenv("DLC_DIR", str(dlc))
monkeypatch.setenv("CONFIG_DIR", str(config))
monkeypatch.setenv("SLOPSMITH_SYNC_STARTUP", "1")
sys.modules.pop("server", None)
server = importlib.import_module("server")
server.sloppak_mod._source_cache.clear()
monkeypatch.setattr(server, "load_plugins", lambda *a, **kw: None)
monkeypatch.setattr(server, "startup_scan", lambda: None)
static_tmp = tmp_path / "static"
static_tmp.mkdir()
monkeypatch.setattr(server, "STATIC_DIR", static_tmp)
tc = TestClient(server.app, client=("127.0.0.1", 50000))
try:
yield tc, server, dlc
finally:
tc.close()
meta_db = getattr(server, "meta_db", None)
conn = getattr(meta_db, "conn", None)
if conn is not None:
conn.close()
def test_zip_art_endpoint_conditional_304(dlc_client):
tc, _server, dlc = dlc_client
_zip_sloppak(dlc / "song.sloppak", cover_bytes=b"\xff\xd8\xff\xe0ZIP")
r1 = tc.get("/api/song/song.sloppak/art")
assert r1.status_code == 200
assert r1.content == b"\xff\xd8\xff\xe0ZIP"
assert r1.headers["cache-control"] == "no-cache"
etag = r1.headers["etag"]
assert etag
r2 = tc.get("/api/song/song.sloppak/art", headers={"If-None-Match": etag})
assert r2.status_code == 304
assert r2.content == b""
def test_dir_art_endpoint_no_stale_304_after_inplace_edit(dlc_client):
"""Editing cover.jpg in place must invalidate the validator (the dir-form
staleness bug: a dir-stat ETag would wrongly 304 here)."""
tc, _server, dlc = dlc_client
pak = _dir_sloppak(dlc / "dir.sloppak", cover_bytes=b"\xff\xd8\xff\xe0OLD")
r1 = tc.get("/api/song/dir.sloppak/art")
assert r1.status_code == 200 and r1.content == b"\xff\xd8\xff\xe0OLD"
etag_old = r1.headers["etag"]
# Replace the cover content in place (same path).
(pak / "cover.jpg").write_bytes(b"\xff\xd8\xff\xe0NEW")
r2 = tc.get("/api/song/dir.sloppak/art", headers={"If-None-Match": etag_old})
assert r2.status_code == 200
assert r2.content == b"\xff\xd8\xff\xe0NEW"
+86
View File
@@ -0,0 +1,86 @@
"""feedpak_version (spec §4): read on load + opportunistic stamp on a metadata
write. Core has no create-from-scratch path (RS-free repo); the editor plugin's
create-mode save stamping the version is a separate follow-up."""
from __future__ import annotations
import json
from pathlib import Path
import yaml
import sloppak as sloppak_mod
from sloppak import FEEDPAK_VERSION
from songmeta import write_sloppak_metadata
def _write_dir_sloppak(root: Path, manifest_extras: dict) -> Path:
pak = root / f"{root.name}.sloppak"
pak.mkdir()
arr_dir = pak / "arrangements"
arr_dir.mkdir()
(arr_dir / "lead.json").write_text(json.dumps({
"name": "Lead", "tuning": [0, 0, 0, 0, 0, 0], "capo": 0,
"notes": [], "chords": [], "anchors": [], "handshapes": [],
"templates": [], "beats": [], "sections": [],
}))
manifest = {
"title": "Test", "artist": "Tester", "album": "", "year": 2026,
"duration": 10.0,
"arrangements": [{"id": "lead", "name": "Lead", "file": "arrangements/lead.json"}],
"stems": [{"id": "full", "file": "stems/full.ogg", "default": True}],
}
manifest.update(manifest_extras)
(pak / "manifest.yaml").write_text(yaml.safe_dump(manifest, sort_keys=False))
return pak
def _load(pak: Path, tmp_path: Path):
cache = tmp_path / "cache"
cache.mkdir()
return sloppak_mod.load_song(pak.name, pak.parent, cache)
def _manifest(pak: Path) -> dict:
return yaml.safe_load((pak / "manifest.yaml").read_text(encoding="utf-8"))
# ── read ─────────────────────────────────────────────────────────────────────
def test_feedpak_version_read_from_manifest(tmp_path: Path):
pak = _write_dir_sloppak(tmp_path, {"feedpak_version": "1.2.0"})
assert _load(pak, tmp_path).feedpak_version == "1.2.0"
def test_feedpak_version_none_when_absent(tmp_path: Path):
pak = _write_dir_sloppak(tmp_path, {})
assert _load(pak, tmp_path).feedpak_version is None
def test_feedpak_version_none_when_not_a_string(tmp_path: Path):
pak = _write_dir_sloppak(tmp_path, {"feedpak_version": 12})
assert _load(pak, tmp_path).feedpak_version is None
# ── opportunistic stamp on a metadata write ──────────────────────────────────
def test_metadata_write_stamps_version_when_absent(tmp_path: Path):
pak = _write_dir_sloppak(tmp_path, {})
assert "feedpak_version" not in _manifest(pak)
assert write_sloppak_metadata(pak, {"title": "New"}) is True
m = _manifest(pak)
assert m["title"] == "New"
assert m["feedpak_version"] == FEEDPAK_VERSION
def test_metadata_write_preserves_existing_version(tmp_path: Path):
pak = _write_dir_sloppak(tmp_path, {"feedpak_version": "9.9.9"})
write_sloppak_metadata(pak, {"artist": "X"})
assert _manifest(pak)["feedpak_version"] == "9.9.9" # not downgraded
def test_metadata_no_change_does_not_add_version(tmp_path: Path):
# A no-op metadata write must NOT stamp a version (no rewrite happens).
pak = _write_dir_sloppak(tmp_path, {})
assert write_sloppak_metadata(pak, {}) is False
assert "feedpak_version" not in _manifest(pak)
+127
View File
@@ -0,0 +1,127 @@
"""End-to-end test for the sloppak loader recognising a `keys:` manifest key
(keys.json the song-level, instrument-independent key/scale track, spec §7.7)
and surfacing the sanitized payload on the LoadedSloppak."""
from __future__ import annotations
import json
from pathlib import Path
import yaml
import sloppak as sloppak_mod
def _write_dir_sloppak(root: Path, manifest_extras: dict, keys_payload) -> Path:
"""Minimal directory-form sloppak; writes keys.json when a payload is given.
Unique filename per test (tmp_path leaf) so the module-level
resolve_source_dir cache isn't poisoned across tests."""
pak = root / f"{root.name}.sloppak"
pak.mkdir()
arr_dir = pak / "arrangements"
arr_dir.mkdir()
arr = {
"name": "Lead", "tuning": [0, 0, 0, 0, 0, 0], "capo": 0,
"notes": [], "chords": [], "anchors": [], "handshapes": [],
"templates": [], "beats": [], "sections": [],
}
(arr_dir / "lead.json").write_text(json.dumps(arr))
manifest = {
"title": "Test", "artist": "Tester", "album": "", "year": 2026,
"duration": 10.0,
"arrangements": [{"id": "lead", "name": "Lead", "file": "arrangements/lead.json"}],
"stems": [{"id": "full", "file": "stems/full.ogg", "default": True}],
}
manifest.update(manifest_extras)
(pak / "manifest.yaml").write_text(yaml.safe_dump(manifest, sort_keys=False))
if keys_payload is not None:
(pak / "keys.json").write_text(json.dumps(keys_payload))
return pak
def _load(pak_path: Path, tmp_path: Path):
dlc_root = pak_path.parent
cache = tmp_path / "cache"
cache.mkdir()
return sloppak_mod.load_song(pak_path.name, dlc_root, cache)
# ── Happy path ───────────────────────────────────────────────────────────────
def test_load_song_attaches_keys_when_manifest_opts_in(tmp_path: Path):
payload = {
"version": 1,
"events": [
{"t": 0.0, "key": "Em", "scale": "natural_minor"},
{"t": 2.0, "key": "G", "scale": "major"},
],
}
pak = _write_dir_sloppak(tmp_path, {"keys": "keys.json"}, payload)
loaded = _load(pak, tmp_path)
assert loaded.keys is not None
assert loaded.keys["version"] == 1
evs = loaded.keys["events"]
assert len(evs) == 2
assert evs[0] == {"t": 0.0, "key": "Em", "scale": "natural_minor"}
assert evs[1] == {"t": 2.0, "key": "G", "scale": "major"}
# ── Absent / permissive ──────────────────────────────────────────────────────
def test_load_song_keys_absent_when_manifest_silent(tmp_path: Path):
pak = _write_dir_sloppak(tmp_path, {}, None)
assert _load(pak, tmp_path).keys is None
def test_load_song_keys_absent_when_file_missing(tmp_path: Path):
pak = _write_dir_sloppak(tmp_path, {"keys": "nope.json"}, None)
assert _load(pak, tmp_path).keys is None
def test_load_song_keys_absent_when_invalid_json(tmp_path: Path):
pak = _write_dir_sloppak(tmp_path, {"keys": "keys.json"}, None)
(pak / "keys.json").write_text("not json {{{")
assert _load(pak, tmp_path).keys is None
def test_load_song_keys_ignored_when_events_not_a_list(tmp_path: Path):
pak = _write_dir_sloppak(tmp_path, {"keys": "keys.json"},
{"version": 1, "events": "nope"})
assert _load(pak, tmp_path).keys is None
# ── Sanitization ─────────────────────────────────────────────────────────────
def test_load_song_keys_sanitizes_and_sorts(tmp_path: Path):
payload = {
"version": 1,
"events": [
{"t": 2.0, "key": "G"}, # no scale -> omitted
{"t": 0.0, "key": "Em", "scale": "major"}, # out of order
{"t": 1.0}, # no key -> dropped
{"foo": "bar"}, # not an event -> dropped
{"t": 3.0, "key": ""}, # empty key -> dropped
{"t": "bad", "key": "X"}, # non-numeric t -> dropped
"garbage", # non-dict -> dropped
],
}
pak = _write_dir_sloppak(tmp_path, {"keys": "keys.json"}, payload)
evs = _load(pak, tmp_path).keys["events"]
assert evs == [
{"t": 0.0, "key": "Em", "scale": "major"},
{"t": 2.0, "key": "G"}, # scale absent, not null
]
def test_load_song_keys_nonint_version_does_not_abort_load(tmp_path: Path):
# json.loads accepts NaN; a float/NaN version must not raise int(NaN) and
# abort the load of an OPTIONAL side-file — it falls back to version 1.
payload = {"version": float("nan"), "events": [{"t": 0.0, "key": "C"}]}
pak = _write_dir_sloppak(tmp_path, {"keys": "keys.json"}, payload)
loaded = _load(pak, tmp_path)
assert loaded.keys is not None
assert loaded.keys["version"] == 1
assert loaded.keys["events"] == [{"t": 0.0, "key": "C"}]
+44
View File
@@ -148,6 +148,50 @@ def test_song_timeline_absent_when_path_escapes_sloppak(tmp_path: Path):
assert loaded.song_timeline is None
# ── tempos + time_signatures (feedpak 1.2.0) ─────────────────────────────────
def test_song_timeline_tempos_and_time_signatures_loaded(tmp_path: Path):
payload = {
"version": 1, "beats": [], "sections": [],
"tempos": [{"time": 0.0, "bpm": 120}, {"time": 4.0, "bpm": 90}],
"time_signatures": [{"time": 0.0, "ts": [4, 4]}, {"time": 8.0, "ts": [6, 8]}],
}
pak = _write_dir_sloppak(tmp_path, {"song_timeline": "song_timeline.json"}, payload)
loaded = _load(pak, tmp_path)
assert loaded.tempos == [{"time": 0.0, "bpm": 120.0}, {"time": 4.0, "bpm": 90.0}]
assert loaded.time_signatures == [{"time": 0.0, "ts": [4, 4]},
{"time": 8.0, "ts": [6, 8]}]
def test_song_timeline_maps_absent_when_not_provided(tmp_path: Path):
payload = {"version": 1, "beats": [], "sections": []}
pak = _write_dir_sloppak(tmp_path, {"song_timeline": "song_timeline.json"}, payload)
loaded = _load(pak, tmp_path)
assert loaded.tempos is None and loaded.time_signatures is None
def test_song_timeline_maps_sanitized(tmp_path: Path):
payload = {
"version": 1, "beats": [], "sections": [],
"tempos": [{"time": 1.0, "bpm": 0}, {"time": 0.0, "bpm": 100}], # bpm 0 dropped + sorted
"time_signatures": [{"time": 0.0, "ts": [4, 4, 4]}, # 3-long dropped
{"time": 2.0, "ts": [3, 4]}],
}
pak = _write_dir_sloppak(tmp_path, {"song_timeline": "song_timeline.json"}, payload)
loaded = _load(pak, tmp_path)
assert loaded.tempos == [{"time": 0.0, "bpm": 100.0}]
assert loaded.time_signatures == [{"time": 2.0, "ts": [3, 4]}]
def test_song_timeline_maps_load_even_without_beats_or_sections(tmp_path: Path):
# tempos/time_signatures are independent of beats/sections — a payload that
# omits beats (invalid for the override path) must still surface the maps.
payload = {"version": 1, "tempos": [{"time": 0.0, "bpm": 100}]}
pak = _write_dir_sloppak(tmp_path, {"song_timeline": "song_timeline.json"}, payload)
loaded = _load(pak, tmp_path)
assert loaded.tempos == [{"time": 0.0, "bpm": 100.0}]
def test_song_timeline_absent_when_path_is_absolute(tmp_path: Path):
pak = _write_dir_sloppak(tmp_path, {"song_timeline": "/etc/passwd"}, None)
loaded = _load(pak, tmp_path)
+112
View File
@@ -18,6 +18,7 @@ from song import (
arrangement_to_wire,
chord_from_wire,
chord_to_wire,
sanitize_tempos,
compute_smart_names,
note_from_wire,
note_to_wire,
@@ -168,6 +169,85 @@ def test_note_bend_nonzero_rounded_to_one_decimal():
assert note_to_wire(n)["bn"] == 1.8
# ── Bend shape (bt / bnv, §6.2.1) ────────────────────────────────────────────
def test_note_bend_shape_round_trip():
"""A note with bend intent + a time-stamped curve survives the wire."""
n = Note(
time=0.5, string=0, fret=7, sustain=1.0,
bend=2.0,
bend_intent=4, # round-trip
bend_values=[
{"t": 0.0, "v": 0.0},
{"t": 0.25, "v": 2.0},
{"t": 0.5, "v": 0.0},
],
)
wire = note_to_wire(n)
assert wire["bt"] == 4
assert wire["bnv"] == [
{"t": 0.0, "v": 0.0},
{"t": 0.25, "v": 2.0},
{"t": 0.5, "v": 0.0},
]
assert note_from_wire(wire) == n
def test_note_bend_shape_omitted_when_default():
"""`bt`/`bnv` are default-omitted; absence decodes to 0 / None (not 0-present
/ not [])."""
wire = note_to_wire(Note(time=0.0, string=0, fret=0, bend=1.0))
assert "bt" not in wire
assert "bnv" not in wire
decoded = note_from_wire(wire)
assert decoded.bend_intent == 0
assert decoded.bend_values is None
def test_note_bend_values_rounded_on_wire():
"""`bnv` rounds `t` to 3 and `v` to 1, matching the scalar `bn` precision."""
n = Note(
time=0.0, string=0, fret=0, bend=1.0, bend_intent=1,
bend_values=[{"t": 0.123456, "v": 1.749}],
)
assert note_to_wire(n)["bnv"] == [{"t": 0.123, "v": 1.7}]
def test_note_bend_values_sanitized_from_wire():
"""Malformed `bnv` entries are dropped; bad/empty -> None; result sorted by t."""
# NaN / non-dict / non-numeric entries dropped, remaining sorted by t.
n = note_from_wire({
"t": 0.0, "s": 0, "f": 0, "bn": 2.0,
"bnv": [
{"t": 0.5, "v": 2.0},
{"t": 0.0, "v": 0.0},
{"t": "x", "v": 1.0}, # non-numeric t -> dropped
{"t": 0.25, "v": float("nan")}, # non-finite v -> dropped
"garbage", # non-dict -> dropped
],
})
assert n.bend_values == [{"t": 0.0, "v": 0.0}, {"t": 0.5, "v": 2.0}]
# Empty / non-list / all-invalid collapse to None (never []).
for bad in (None, [], "nope", [{"t": "a", "v": "b"}], [42]):
assert note_from_wire(
{"t": 0.0, "s": 0, "f": 0, "bnv": bad}).bend_values is None
def test_chord_note_carries_bend_shape():
"""Chord member notes inherit bt/bnv through chord_note_to_wire/chord_from_wire."""
c = Chord(
time=2.0, chord_id=0,
notes=[Note(
time=2.0, string=1, fret=5, bend=1.0, bend_intent=2,
bend_values=[{"t": 0.0, "v": 1.0}, {"t": 0.3, "v": 0.0}],
)],
)
decoded = chord_from_wire(chord_to_wire(c))
cn = decoded.notes[0]
assert cn.bend_intent == 2
assert cn.bend_values == [{"t": 0.0, "v": 1.0}, {"t": 0.3, "v": 0.0}]
# ── Chord round-trip ─────────────────────────────────────────────────────────
def test_chord_with_multiple_notes_round_trip():
@@ -927,3 +1007,35 @@ def test_smart_names_arrangement_properties_defaults():
assert arr.path_bass is False
assert arr.bonus_arr is False
assert arr.represent == 0
# ── tempos (per-chart §6.10 + shared sanitizer) ──────────────────────────────
def test_sanitize_tempos_filters_sorts_and_coerces():
assert sanitize_tempos([
{"time": 2.0, "bpm": 90},
{"time": 0.0, "bpm": 120},
{"time": 1.0, "bpm": 0}, # bpm <= 0 -> dropped
{"time": float("nan"), "bpm": 100}, # non-finite time -> dropped
{"bpm": 100}, # missing time -> dropped
{"time": 3.0, "bpm": float("inf")}, # non-finite bpm -> dropped
"x", # non-dict -> dropped
]) == [{"time": 0.0, "bpm": 120.0}, {"time": 2.0, "bpm": 90.0}]
assert sanitize_tempos(None) == []
assert sanitize_tempos("nope") == []
def test_arrangement_tempos_round_trip_and_omitted_when_absent():
arr = arrangement_from_wire({
"name": "Bass", "tuning": [0, 0, 0, 0, 0, 0], "capo": 0,
"tempos": [{"time": 0.0, "bpm": 60}, {"time": 2.0, "bpm": 120}],
})
assert arr.tempos == [{"time": 0.0, "bpm": 60.0}, {"time": 2.0, "bpm": 120.0}]
assert arrangement_to_wire(arr)["tempos"] == \
[{"time": 0.0, "bpm": 60.0}, {"time": 2.0, "bpm": 120.0}]
# Absent per-chart tempos -> None, and the wire key is OMITTED (not []),
# so the chart follows the song-level tempo (spec §6.10).
arr2 = arrangement_from_wire({"name": "Lead", "tuning": [0] * 6, "capo": 0})
assert arr2.tempos is None
assert "tempos" not in arrangement_to_wire(arr2)