mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 06:14:29 +00:00
Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a979eafe6 | ||
|
|
7ef13fc8c7 | ||
|
|
a858617d71 | ||
|
|
351b273ab5 | ||
|
|
e33df9a720 | ||
|
|
b8382139ca | ||
|
|
587fbbea81 | ||
|
|
e64378da78 | ||
|
|
e8db65afcb | ||
|
|
293dc86d83 | ||
|
|
c557742174 | ||
|
|
1183f100ee | ||
|
|
9b71d8ddcb | ||
|
|
d5e184d6e0 | ||
|
|
a72c0d2e17 | ||
|
|
8f800e07b1 | ||
|
|
21997f4b5c | ||
|
|
36aeea67ac | ||
|
|
a7a93e9bef | ||
|
|
ba31fd9150 | ||
|
|
a39e03559d | ||
|
|
c7fb074111 | ||
|
|
fb06e288e1 | ||
|
|
313348a1ff | ||
|
|
21b8a6cd49 |
@@ -35,6 +35,8 @@ plugins/minigames/__pycache__/
|
||||
!plugins/tuner/
|
||||
!plugins/tuner/**
|
||||
plugins/tuner/__pycache__/
|
||||
!plugins/input_setup/
|
||||
!plugins/input_setup/**
|
||||
node_modules/
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -153,6 +153,18 @@ The legacy chart-coupled surface — `highway.setNoteStateProvider(fn)`, the sin
|
||||
|
||||
Diagnostics live under `slopsmith.note_detection_capability.v1` and contain provider ids/labels/kinds, binding summaries (requester, provider, redacted context, target size), availability, and the last bounded outcome (event, binding, provider, MIDI number, hit flag) — never raw audio buffers, sample data, device labels, or song identity.
|
||||
|
||||
## MIDI-Input Domain
|
||||
|
||||
The MIDI-input slice (spec 012, issues #873/#880) promotes `midi-input` as a **core-owned** provider-coordinator implemented by [static/capabilities/midi-input.js](../static/capabilities/midi-input.js) — the MIDI analog of `audio-input`. It is deliberately separate from `audio-input` (whose source/`open` contract is audio-frame-centric: channel shapes, sample buffers) because MIDI carries discrete messages, not audio; and it is **not** owned by any feature plugin, so the device-access boundary outlives the input-setup wizard (exactly as `audio-input` is `core.audio.session`-owned). Consumers — the `input_setup` onboarding wizard, the `piano`/keys and `drums` plugins, and (as a follow-up, #881) note-detection's Web-MIDI provider — converge here on ONE device-access boundary: one permission prompt, one source list, one redaction boundary, retiring private per-plugin `navigator.requestMIDIAccess()` calls.
|
||||
|
||||
Native providers register source summaries with `providerId`, a stable `sourceId`, a derived redaction-safe `logicalSourceKey` (`providerId::sourceId`), `kind: "midi"`, a label, and `availability`. The public command surface is `inspect`, `list-sources`, `discover`, `select-source`, `open-source`, and `close-source`; provider operations are `source.enumerate`, `source.describe`, `source.open`, and `source.close`. `inspect`, `list-sources`, and `select-source` are prompt-free and never request MIDI access. Unlike audio (where `getUserMedia` gates labels and `open-source` is the prompt), Web-MIDI's `requestMIDIAccess()` gates the whole input list, so **`discover` is the permission boundary** and records `denied`/`unavailable` outcomes; `open-source` then attaches a shared listener session and never re-prompts.
|
||||
|
||||
Selected input is persisted by `logicalSourceKey` (`slopsmith.midiInput.selectedLogicalSourceKey`) when browser storage is available. Compatible requesters share one open session per source; each later calls `close-source`, and the provider receives `source.close` only after the last requester releases. Live MIDI message delivery (for the "play a note / hit a pad" calibration check) is exposed to in-page consumers through the public `window.slopsmith.midiInput` session handle only — never as raw capability events or diagnostics.
|
||||
|
||||
The reserved `midi-control` domain is the planned **sibling** for control mappings (CC/pitchbend/note → action routing) and will consume `midi-input` for device access (spec 013 / #882); this slice carves the device control plane out so `midi-control` can stay mappings-only. `midi-control` stays RESERVED (documentation-only) until a concrete mapping consumer + tests exist, per the future-domain governance.
|
||||
|
||||
Diagnostics live under `slopsmith.midi_input.diagnostics.v1` and contain provider ids, source ids/keys/kinds/availability, the selected key, and open-session keys — **device labels are redacted** and no raw MIDI messages are ever included.
|
||||
|
||||
## Capability Roles
|
||||
|
||||
Use capability declarations for provider/requester/observer relationships:
|
||||
|
||||
@@ -108,7 +108,7 @@ These domains are planned but should stay out of the runtime graph until a host
|
||||
| `ui.player-overlays` | exclusive-owner | safe | Overlay contributions layered over player or highway surfaces. | Overlay placement and z-order rules that coexist with legacy overlays. |
|
||||
| `plugins` | exclusive-owner | privileged | Plugin enable/disable/install/update workflows. | Visible user confirmation, rollback, and disabled-handler enforcement. |
|
||||
| `jobs` | multi-provider | privileged | Long-running jobs, cancellation, status, failures. | Scheduling limits, cancellation semantics, and user-visible failures. |
|
||||
| `midi-control` | multi-provider | sensitive | MIDI device providers and control mappings. | Device consent and redacted diagnostics. |
|
||||
| `midi-control` | multi-provider | sensitive | MIDI control mappings only (CC/pitchbend/note → action routing), consuming `midi-input` for device access. Device discovery/selection/open is split out to the delivered `midi-input` domain (spec 012). | A concrete mapping/routing workflow on top of the `midi-input` device plane (#882). |
|
||||
| `audio-input` | multi-provider | sensitive | Audio input device providers, source selection, open/close lifecycle, shared sessions, and redacted failure diagnostics. | Promoted by the audio graph/session slice and implemented by the audio-input control-plane slice. |
|
||||
| `tempo-clock` | multi-provider | safe | Tempo/clock provider registration and consumers. | A concrete tempo source and consumer workflow. |
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ Core domains also have a review scope. **Active contract** domains are wired to
|
||||
|
||||
| note-detection | provider-coordinator | sensitive | inspect, register-provider, unregister-provider, open-binding, close-binding, set-target, clear-target | pitch.estimate, verify.target | Detection-binding control plane (spec 009): providers (midi/engine/js) serve primitives; each requester binds its own redacted tuning context; consumers own judgment, hit/miss flow as observability events. Legacy `highway.setNoteStateProvider` is an accounted shim. Diagnostics carry provider/binding summaries and bounded outcomes — no raw audio, sample data, device labels, or song identity. |
|
||||
|
||||
| midi-input | provider-coordinator | sensitive | inspect, list-sources, discover, select-source, open-source, close-source | source.enumerate, source.describe, source.open, source.close | Core-owned MIDI device control plane (spec 012), the MIDI analog of `audio-input`. Inspect/list/select are prompt-free; `discover` is the Web-MIDI permission boundary (`requestMIDIAccess()` gates the whole input list) and records denied/unavailable outcomes; `open-source` attaches a shared listener session and never re-prompts. Selection persists by redaction-safe `logicalSourceKey`. Diagnostics redact device labels and never include raw MIDI messages or live handles. |
|
||||
|
||||
Privileged commands are roadmap-only until they have: a visible user confirmation path, diagnostics redaction rules, failure recovery, and tests that prove disabled or incompatible participants cannot execute handlers.
|
||||
|
||||
## Expected Future Domains
|
||||
@@ -38,7 +40,7 @@ These domains are expected future capability contracts, not current runtime grap
|
||||
| ui.player-overlays | exclusive-owner | safe | register-contribution, mount, unmount, set-visible, reorder-by-policy, inspect | Needs overlay placement rules that coexist with legacy highway overlays. |
|
||||
| plugins | exclusive-owner | privileged | enable, disable, install-missing, update, inspect | Needs explicit user confirmation for writes/install/update. |
|
||||
| jobs | multi-provider | privileged | register, inspect, cancel | Needs scheduling limits, cancellation semantics, and user-visible failures. |
|
||||
| midi-control | multi-provider | sensitive | register, inspect | Needs device consent and redacted diagnostics. |
|
||||
| midi-control | multi-provider | sensitive | list-mappings, get-mapping, set-mapping, delete-mapping, activate-mapping, inspect | Mappings ONLY — CC/pitchbend/note → semantic action routing (spec 013). Device discovery/selection/open is NOT this domain's job: it consumes the delivered `midi-input` domain for device access. Needs a concrete mapping consumer (the MIDI control plugin / drums learn-mode) + redacted diagnostics (no raw MIDI streams) before promotion. |
|
||||
| tempo-clock | multi-provider | safe | register, inspect | Needs a concrete provider and consumer workflow. |
|
||||
|
||||
Planned domains should also stay out of the runtime graph until Slopsmith ships the corresponding user-facing workflows.
|
||||
|
||||
@@ -4,7 +4,7 @@ A `.sloppak` is just a zip of plain files: some YAML, some JSON, some OGG audio,
|
||||
|
||||
This guide walks through the most common edits, aimed at musicians who are comfortable with a text editor and Audacity but don't live on the command line.
|
||||
|
||||
> For the format **schema** (what every field means, how the wire format works, how to extend the format with new data types), see [sloppak-spec.md](sloppak-spec.md). This document is the **how-do-I-actually-edit-mine** companion.
|
||||
> For the format **schema** (what every field means, how the wire format works, how to extend the format with new data types), see the authoritative [feedpak spec](https://github.com/got-feedback/feedback-feedpak-spec/blob/main/spec/feedpak-v1.md) (the local [sloppak-spec.md](sloppak-spec.md) is now a pointer to it). This document is the **how-do-I-actually-edit-mine** companion.
|
||||
|
||||
---
|
||||
|
||||
@@ -242,7 +242,7 @@ For 4-string bass, only indices 0–3 are meaningful; leave 4 and 5 at `0`.
|
||||
|
||||
### What *not* to put in `manifest.yaml`
|
||||
|
||||
Don't add per-machine settings (audio device picks, MIDI port IDs), UI state, or your own play counts. The sloppak holds the song's authored data — anything that varies by user or machine lives in Slopsmith's config dir or the metadata DB. See [sloppak-spec.md §5.7](sloppak-spec.md#57-dont-break-the-manifest-contract) for the full list.
|
||||
Don't add per-machine settings (audio device picks, MIDI port IDs), UI state, or your own play counts. The sloppak holds the song's authored data — anything that varies by user or machine lives in Slopsmith's config dir or the metadata DB. See [feedpak spec §9.5](https://github.com/got-feedback/feedback-feedpak-spec/blob/main/spec/feedpak-v1.md#95-what-does-not-belong-in-a-feedpak) for the full list.
|
||||
|
||||
---
|
||||
|
||||
@@ -261,6 +261,6 @@ For your own use, you can skip this entirely — Slopsmith reads the directory f
|
||||
|
||||
## Out of scope (for now)
|
||||
|
||||
- **Authoring a sloppak from scratch** (no Guitar Pro / MusicXML source file) — that's a developer task. Start at [sloppak-spec.md §4.2](sloppak-spec.md#42-writing-python-server-side).
|
||||
- **Editing notes / chords in `arrangements/*.json`** — technically possible but extremely tedious by hand: hundreds of objects with short field names per song. The fields are documented in [sloppak-spec.md §3](sloppak-spec.md#3-arrangement-json--the-wire-format), but for any real chart edit you want the [Arrangement Editor plugin](https://github.com/got-feedback/feedback-plugin-editor).
|
||||
- **Authoring a sloppak from scratch** (no Guitar Pro / MusicXML source file) — that's a developer task. Start at [feedpak spec §8 (Reading and writing)](https://github.com/got-feedback/feedback-feedpak-spec/blob/main/spec/feedpak-v1.md#8-reading-and-writing).
|
||||
- **Editing notes / chords in `arrangements/*.json`** — technically possible but extremely tedious by hand: hundreds of objects with short field names per song. The fields are documented in [feedpak spec §6 (Arrangement JSON)](https://github.com/got-feedback/feedback-feedpak-spec/blob/main/spec/feedpak-v1.md#6-arrangement-json), but for any real chart edit you want the [Arrangement Editor plugin](https://github.com/got-feedback/feedback-plugin-editor).
|
||||
- **Loudness normalization / advanced stem processing** — out of scope here; standard Audacity or ffmpeg workflows apply to any OGG file before you drop it into `stems/`.
|
||||
|
||||
+31
-921
@@ -1,939 +1,49 @@
|
||||
# Sloppak Format — Developer Guide
|
||||
# Sloppak / feedpak Format — moved
|
||||
|
||||
Sloppak is Slopsmith's open, hand-editable song format. This guide is for developers who want to **read**, **write**, or **extend** the format — including adding new data types like drum tabs, vocal pitches, lighting cues, key/scale annotations, or anything else a future visualization plugin might need.
|
||||
The full format specification that used to live here has moved to its own repository and is now
|
||||
the **authoritative, versioned reference**:
|
||||
|
||||
> If you're a **user** wanting to modify an existing sloppak — record your own rhythm stem, fix metadata, swap cover art, replace a Demucs split — see [sloppak-hand-editing.md](sloppak-hand-editing.md). That guide is the practical, step-by-step companion to this developer reference.
|
||||
> **📖 https://github.com/got-feedback/feedback-feedpak-spec**
|
||||
> — normative spec ([`spec/feedpak-v1.md`](https://github.com/got-feedback/feedback-feedpak-spec/blob/main/spec/feedpak-v1.md)),
|
||||
> JSON Schemas, examples, and a reference validator.
|
||||
|
||||
The authoritative format reference lives in code (`lib/sloppak.py`, `lib/song.py`); this doc explains the why, the how, and the conventions you should follow when adding to it.
|
||||
Update bookmarks to point there. This page is a thin pointer kept at the original path so existing
|
||||
links keep resolving.
|
||||
|
||||
---
|
||||
## Naming: `sloppak` here, `feedpak` in the spec
|
||||
|
||||
## 1. Format at a glance
|
||||
The published format is named **feedpak** (extension `.feedpak`, manifest key `feedpak_version`).
|
||||
This codebase still uses the legacy **sloppak** name internally — `lib/sloppak.py`, the
|
||||
`.sloppak` extension, `SLOPSMITH_*` env vars, etc. **They describe the same on-disk format.** The
|
||||
rename is repo/public-facing only for now (see the top-level workspace `CLAUDE.md`), so when the
|
||||
spec says `feedpak` / `feedpak_version`, the packs this server reads and writes today are the same
|
||||
structure under the `.sloppak` name. The internal rename is a separate, later effort.
|
||||
|
||||
A sloppak exists in **two interchangeable forms**:
|
||||
## Hand-editing a pack
|
||||
|
||||
| Form | What it is | Used for |
|
||||
|---|---|---|
|
||||
| **Directory** | A folder named `*.sloppak/` containing the files below | Authoring, hand editing, plugin development |
|
||||
| **Zip archive** | A `.sloppak` file (zip with the same files inside) | Distribution |
|
||||
For the practical "how do I edit my own pack" walkthrough (record your own stem, fix metadata,
|
||||
swap cover art, replace a stem split), see the companion guide that stays in this repo:
|
||||
[sloppak-hand-editing.md](sloppak-hand-editing.md).
|
||||
|
||||
Both forms hold identical contents. Slopsmith resolves either transparently — zip files are unpacked to a cache the first time they're opened (see `resolve_source_dir()` in [lib/sloppak.py](../lib/sloppak.py)).
|
||||
## Where the format maps to code (this repo)
|
||||
|
||||
### Directory layout
|
||||
|
||||
```
|
||||
my-song.sloppak/
|
||||
├── manifest.yaml # Required — all metadata + file index
|
||||
├── arrangements/
|
||||
│ ├── lead.json # One JSON per playable arrangement
|
||||
│ ├── rhythm.json
|
||||
│ └── bass.json
|
||||
├── stems/
|
||||
│ ├── full.ogg # Mixed audio (initial single-stem output; may be absent after stem splitting)
|
||||
│ ├── guitar.ogg # Optional individual stems
|
||||
│ ├── bass.ogg
|
||||
│ ├── drums.ogg
|
||||
│ ├── vocals.ogg
|
||||
│ └── other.ogg
|
||||
├── lyrics.json # Optional — syllable-level lyrics
|
||||
└── cover.jpg # Optional — album art
|
||||
```
|
||||
|
||||
Three rules to remember:
|
||||
|
||||
1. **`manifest.yaml` is the index.** Nothing inside the sloppak is auto-discovered — every file path is listed in the manifest. This makes the format predictable: no scanning, no guessing. (One historical exception: the cover-art handler in `server.py` falls back to `cover.jpg` when `manifest.cover` is missing. New code should not add similar filename fallbacks.)
|
||||
2. **Filenames in `manifest.yaml` are POSIX paths**, relative to the sloppak root (forward slashes, no leading `/`).
|
||||
3. **YAML for the manifest, JSON for everything else.** YAML is hand-editable for users; JSON is fast-parsed and easy to round-trip in code.
|
||||
|
||||
---
|
||||
|
||||
## 2. `manifest.yaml` reference
|
||||
|
||||
Minimal valid manifest:
|
||||
|
||||
```yaml
|
||||
title: "Black Hole Sun"
|
||||
artist: "Soundgarden"
|
||||
duration: 320.5
|
||||
arrangements:
|
||||
- id: lead
|
||||
name: Lead
|
||||
file: arrangements/lead.json
|
||||
tuning: [0, 0, 0, 0, 0, 0]
|
||||
capo: 0
|
||||
stems:
|
||||
- id: full
|
||||
file: stems/full.ogg
|
||||
default: true
|
||||
```
|
||||
|
||||
Full set of currently-recognized top-level keys:
|
||||
|
||||
| Key | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `title` | string | yes | Song title |
|
||||
| `artist` | string | yes | Artist name |
|
||||
| `album` | string | no | Album |
|
||||
| `year` | int | no | Release year |
|
||||
| `duration` | float | yes | Song length in seconds |
|
||||
| `arrangements` | list | yes | Playable arrangements (see §2.1) |
|
||||
| `stems` | list | yes | Audio stems (see §2.2) |
|
||||
| `stem_separation` | object | no | Structured metadata when stems were produced by an automated separation engine (currently `demucs`). Shape: `{engine, model, version}`. See §2.2 for fields + semver semantics per [slopsmith#357](https://github.com/got-feedback/feedback/issues/357). Omitted for single-stem sloppaks (`stems: [{id: full, ...}]`) and for hand-edited / user-recorded stems |
|
||||
| `lyrics` | string | no | Path to lyrics JSON |
|
||||
| `lyrics_source` | string | no | Where the lyrics came from: `xml` (vocals XML from the chart source), `whisperx` (auto-transcribed), or `user` (hand-edited). Absent on legacy sloppaks — readers should treat missing as `xml` |
|
||||
| `lyric_transcription` | object | no | Structured metadata when lyrics came from an automated engine (currently `whisperx`). Same shape as the parent `stem_separation` block defined by [slopsmith#357](https://github.com/got-feedback/feedback/issues/357) — see §2.3 for fields and semver semantics. Omitted for authored lyrics (`xml`/`user`) |
|
||||
| `vocal_pitch` | string | no | Path to per-syllable pitch JSON (`{"version": 1, "notes": [{t, d, midi}, ...]}`). Consumed by [slopsmith-plugin-lyrics-karaoke](https://github.com/got-feedback/feedback-plugin-lyrics-karaoke) to render karaoke note bars. See §2.4 |
|
||||
| `pitch_extraction` | object | no | Structured metadata when pitch was extracted by an automated engine (currently `crepe` via the demucs server's `/pitch` endpoint). Same shape as `stem_separation` / `lyric_transcription`. Omitted for hand-edited pitch tracks |
|
||||
| `cover` | string | no | Path to cover image |
|
||||
| `preview` | string | no | Path to a short preview audio clip (OGG) at the sloppak root. Populated when the source carries a separate short browser-preview clip (decoded to `preview.ogg`); absent otherwise. Consumed by [`slopsmith-plugin-song-preview`](https://github.com/got-feedback/feedback-plugin-song-preview) for hover-to-listen previews in the library |
|
||||
| `song_timeline` | string | no | Path to a `song_timeline.json` file carrying song-wide beats and sections (see §5.3). When present, its data takes priority over any beats/sections embedded in arrangement JSONs. Older readers ignore the key and fall back to reading beats/sections from the first arrangement JSON as before |
|
||||
| `drum_tab` | string | no | Path to `drum_tab.json` — per-piece drum hits (see §5.3). Implemented end-to-end as of slopsmith#344 |
|
||||
|
||||
Unknown keys are **silently ignored** by the loader. This is deliberate — it's the extensibility hook (see §5).
|
||||
|
||||
### 2.1. `arrangements[]`
|
||||
|
||||
Each entry describes one playable arrangement and points at its JSON file:
|
||||
|
||||
```yaml
|
||||
arrangements:
|
||||
- id: lead # filesystem-safe stable ID, used for filenames
|
||||
name: Lead # display name (Lead/Rhythm/Bass/Combo are sorted first)
|
||||
file: arrangements/lead.json
|
||||
tuning: [0, 0, 0, 0, 0, 0] # six semitone offsets from E A D G B E
|
||||
capo: 0
|
||||
centOffset: 0.0 # optional float, cents; default 0.0
|
||||
```
|
||||
|
||||
- `tuning` is a list of semitone offsets from standard `E2 A2 D2 G3 B3 E4`. **Six elements is the standard six-string convention** and the only length `lib/tunings.py` produces friendly names for; 5- and 7-string content is accepted by the loader and falls through to a numeric label. For bass, the four bass strings are at indices 0–3; the other two slots are `0`. Consumers should not hard-code `len(tuning) == 6`.
|
||||
- `name` controls the sort order in the UI: `Lead > Combo > Rhythm > Bass > everything else`.
|
||||
- `centOffset` is a pitch-shift value in cents. Commonly `-1200.0` for extended-range bass arrangements tuned one octave down; small non-zero values for songs mastered at a non-A440 reference pitch (e.g. A443 ≈ +11.8 cents). Absent / `0.0` means no shift. Exposed to plugins via `getSongInfo().centOffset`.
|
||||
- Manifest-level `tuning`, `capo`, and `centOffset` **override** anything embedded in the arrangement JSON. The arrangement JSON's own values are fallbacks.
|
||||
- `notation` (optional string) — path to a `notation_<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 0–127. Unambiguous — no string/fret/tuning indirection |
|
||||
| `tied` | omit | Tied from the previous beat |
|
||||
| `acc` | omit | Accidental override: `null`/omit = derive from key sig; `0` = force natural (♮); `−2`/`−1`/`1`/`2` = double-flat/flat/sharp/double-sharp |
|
||||
| `stem` | omit | Force stem direction: `"up"` or `"down"` (MusicXML `<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, 500–1500 px on a side |
|
||||
| Your new file | JSON (preferred) | Your plugin's spec doc | You |
|
||||
|
||||
---
|
||||
|
||||
## 7. Testing your extension
|
||||
|
||||
If you add a new file type or manifest key:
|
||||
|
||||
1. **Round-trip test**: write a sample, load it, write it back, compare. Add to `tests/test_sloppak.py`.
|
||||
2. **Backward-compat test**: load a sloppak that *doesn't* have your new key — your code must not crash, and the song must still play.
|
||||
3. **Hand-edit test**: open the directory form in a text editor, change a field by hand, reload Slopsmith. The format is meant to be hand-editable; your additions should preserve that.
|
||||
4. **Both forms**: test with both the directory form and the zipped form. The unpack cache is invalidated based on mtime and size, so you can repackage and reload without restarting the server.
|
||||
|
||||
The full pytest suite (`pytest`) must stay green before any PR.
|
||||
|
||||
---
|
||||
|
||||
## 8. Where to look in the code
|
||||
The spec is implementation-independent; this table is the feedback-specific bridge from format
|
||||
concepts to the code that reads and writes them. It is **not** part of the format.
|
||||
|
||||
| For… | Read |
|
||||
|---|---|
|
||||
| Format detection, source resolution, zip unpacking | [lib/sloppak.py](../lib/sloppak.py) |
|
||||
| Data classes (`Note`, `Chord`, `Arrangement`, `Song`, `Phrase`) | [lib/song.py](../lib/song.py) |
|
||||
| Wire-format helpers (`*_to_wire` / `*_from_wire`) | [lib/song.py](../lib/song.py) |
|
||||
| The reference sloppak writer | [lib/sloppak_convert.py](../lib/sloppak_convert.py) |
|
||||
| Drum tab vocabulary and wire helpers | [lib/drums.py](../lib/drums.py) |
|
||||
| The reference pack writer (assembly pipeline) | [lib/sloppak_convert.py](../lib/sloppak_convert.py) |
|
||||
| Drum-tab vocabulary and wire helpers | [lib/drums.py](../lib/drums.py) |
|
||||
| Notation vocabulary and wire helpers | [lib/notation.py](../lib/notation.py) |
|
||||
| Live streaming over WebSocket (consumes the same shapes) | `server.py` (`/ws/highway/{filename}`) |
|
||||
| The plugin system (where new viz consumers go) | [CLAUDE.md](../CLAUDE.md) — Plugin System section |
|
||||
| The plugin system (where new visualization consumers go) | [CLAUDE.md](../CLAUDE.md) |
|
||||
| Tests | [tests/test_sloppak.py](../tests/test_sloppak.py), [tests/test_sloppak_convert.py](../tests/test_sloppak_convert.py) |
|
||||
|
||||
> **Note on older section references.** Some inline code comments in this repo cite section
|
||||
> numbers from the previous version of this document (e.g. "sloppak-spec §5.3"). The external spec
|
||||
> renumbered its sections, so those citations are approximate — find the topic by name in the
|
||||
> [feedpak spec](https://github.com/got-feedback/feedback-feedpak-spec/blob/main/spec/feedpak-v1.md)
|
||||
> rather than by the old number.
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""feedpak — open song-package format loader (canonical module name).
|
||||
|
||||
The format was renamed `sloppak` → `feedpak`. The implementation currently still
|
||||
lives in :mod:`sloppak`; this module is the **canonical name going forward** and
|
||||
re-exports that public API unchanged, so new code can do::
|
||||
|
||||
import feedpak
|
||||
loaded = feedpak.load_song(filename, dlc_root, cache_root)
|
||||
|
||||
Both `.feedpak` and `.sloppak` packs are accepted everywhere (the legacy
|
||||
extension and the `sloppak` module name are kept as permanent deprecated
|
||||
aliases). The authoritative on-disk format spec is published at
|
||||
https://github.com/got-feedback/feedback-feedpak-spec.
|
||||
|
||||
When the internal rename lands, the implementation can move into this module and
|
||||
`sloppak.py` becomes the thin re-export shim instead — importers of `feedpak`
|
||||
will not need to change.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sloppak import ( # noqa: F401 (re-export)
|
||||
PACK_SUFFIXES,
|
||||
LoadedFeedpak,
|
||||
LoadedSloppak,
|
||||
extract_meta,
|
||||
get_cached_source_dir,
|
||||
is_feedpak,
|
||||
is_pack,
|
||||
is_sloppak,
|
||||
load_manifest,
|
||||
load_song,
|
||||
read_feedpak_version,
|
||||
resolve_source_dir,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"PACK_SUFFIXES",
|
||||
"LoadedFeedpak",
|
||||
"LoadedSloppak",
|
||||
"extract_meta",
|
||||
"get_cached_source_dir",
|
||||
"is_feedpak",
|
||||
"is_pack",
|
||||
"is_sloppak",
|
||||
"load_manifest",
|
||||
"load_song",
|
||||
"read_feedpak_version",
|
||||
"resolve_source_dir",
|
||||
]
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
+291
-13
@@ -1,20 +1,30 @@
|
||||
"""Sloppak — open song format loader.
|
||||
"""Open song-package format loader (feedpak; legacy name: sloppak).
|
||||
|
||||
A `.sloppak` is an open, hand-editable song package. It exists in two
|
||||
A pack is an open, hand-editable song package. It exists in two
|
||||
interchangeable forms:
|
||||
|
||||
1. **Zip archive** — a `.sloppak` file containing a `manifest.yaml`,
|
||||
arrangement JSONs, stem OGGs, optional cover/lyrics. Distribution form.
|
||||
2. **Directory** — a directory whose name ends in `.sloppak/` containing the
|
||||
same files. Authoring form.
|
||||
1. **Zip archive** — a `.feedpak` (or legacy `.sloppak`) file containing a
|
||||
`manifest.yaml`, arrangement JSONs, stem OGGs, optional cover/lyrics.
|
||||
Distribution form.
|
||||
2. **Directory** — a directory whose name ends in `.feedpak/` (or legacy
|
||||
`.sloppak/`) containing the same files. Authoring form.
|
||||
|
||||
See the format spec in the project's sloppak plan for the full layout.
|
||||
The format is now published as **feedpak** at
|
||||
https://github.com/got-feedback/feedpak-spec — that spec is the
|
||||
authoritative reference for the on-disk layout.
|
||||
|
||||
**Naming / deprecation.** The format was renamed `sloppak` → `feedpak`. This
|
||||
module keeps the `sloppak` names as **permanent deprecated aliases** so existing
|
||||
libraries and importers never break: both `.feedpak` and `.sloppak` are accepted
|
||||
everywhere, and `lib/feedpak.py` re-exports this module's public API under the
|
||||
canonical name. Prefer `feedpak` in new code.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import shutil
|
||||
import threading
|
||||
import zipfile
|
||||
@@ -23,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
|
||||
@@ -33,6 +48,7 @@ from song import (
|
||||
Arrangement,
|
||||
arrangement_from_wire,
|
||||
_finite_float,
|
||||
sanitize_tempos,
|
||||
)
|
||||
import drums as drums_mod
|
||||
import notation as notation_mod
|
||||
@@ -40,9 +56,28 @@ import notation as notation_mod
|
||||
|
||||
# ── Format detection ──────────────────────────────────────────────────────────
|
||||
|
||||
# Accepted pack extensions. `.feedpak` is canonical; `.sloppak` is the permanent
|
||||
# legacy alias (see module docstring). Both forms are byte-identical packs.
|
||||
PACK_SUFFIXES = (".feedpak", ".sloppak")
|
||||
|
||||
|
||||
def is_pack(path: Path) -> bool:
|
||||
"""True if path looks like a feedpak/sloppak pack (zip file or directory)."""
|
||||
return path.name.lower().endswith(PACK_SUFFIXES)
|
||||
|
||||
|
||||
def is_feedpak(path: Path) -> bool:
|
||||
"""Canonical name for :func:`is_pack` — accepts both `.feedpak` and `.sloppak`."""
|
||||
return is_pack(path)
|
||||
|
||||
|
||||
def is_sloppak(path: Path) -> bool:
|
||||
"""True if path looks like a sloppak (zip file or directory)."""
|
||||
return path.name.lower().endswith(".sloppak")
|
||||
"""Deprecated alias for :func:`is_pack`, kept so existing callers keep working.
|
||||
|
||||
Despite the name it accepts **both** `.feedpak` and `.sloppak` (the format
|
||||
was renamed; the legacy extension is still read). Prefer :func:`is_feedpak`.
|
||||
"""
|
||||
return is_pack(path)
|
||||
|
||||
|
||||
# ── Source resolution (zip unpack cache + directory passthrough) ──────────────
|
||||
@@ -54,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.
|
||||
@@ -126,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)
|
||||
@@ -173,12 +245,120 @@ def _read_manifest_from_zip(zip_path: Path) -> dict:
|
||||
|
||||
|
||||
def load_manifest(path: Path) -> dict:
|
||||
"""Return the parsed manifest dict for a sloppak (dir or zip)."""
|
||||
"""Return the parsed manifest dict for a pack (dir or zip)."""
|
||||
if path.is_dir():
|
||||
return _read_manifest(path)
|
||||
return _read_manifest_from_zip(path)
|
||||
|
||||
|
||||
def read_feedpak_version(manifest: dict) -> str | None:
|
||||
"""Return the manifest's declared `feedpak_version` (a semver string), or None.
|
||||
|
||||
The feedpak spec (§4.1) makes the key optional and says an absent value is
|
||||
treated as ``"1.0.0"``; callers that want that default can apply it. We return
|
||||
the declared value verbatim (or None) so the distinction "declared vs implicit"
|
||||
is preserved. Non-string values are ignored with a warning.
|
||||
"""
|
||||
raw = manifest.get("feedpak_version")
|
||||
if raw is None:
|
||||
return None
|
||||
if isinstance(raw, str) and raw.strip():
|
||||
return raw.strip()
|
||||
log.warning("feedpak: ignoring non-string feedpak_version %r", raw)
|
||||
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."""
|
||||
@@ -186,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
|
||||
@@ -197,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
|
||||
@@ -405,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:
|
||||
@@ -487,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
|
||||
@@ -577,13 +781,78 @@ 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,
|
||||
)
|
||||
@@ -659,4 +928,13 @@ def extract_meta(path: Path) -> dict:
|
||||
"stem_count": stem_count,
|
||||
# slopsmith#129: per-stem filter needs the id list, not just count.
|
||||
"stem_ids": stem_ids,
|
||||
# Declared feedpak format version (semver string) or None when absent.
|
||||
"feedpak_version": read_feedpak_version(manifest),
|
||||
}
|
||||
|
||||
|
||||
# ── Canonical-name aliases ────────────────────────────────────────────────────
|
||||
# The format was renamed sloppak → feedpak. `LoadedFeedpak` is the canonical
|
||||
# name for the loaded-pack dataclass; the `LoadedSloppak` name above stays as a
|
||||
# permanent deprecated alias. `lib/feedpak.py` re-exports the public API.
|
||||
LoadedFeedpak = LoadedSloppak
|
||||
|
||||
+92
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"id": "input_setup",
|
||||
"name": "Input Setup",
|
||||
"version": "0.1.0",
|
||||
"bundled": true,
|
||||
"private": false,
|
||||
"standards": ["capability-pipelines.v1", "plugin-runtime-idempotent.v1"],
|
||||
"script": "screen.js",
|
||||
"settings": { "html": "settings.html" },
|
||||
"description": "Per-instrument input-device selection and calibration, used during onboarding and re-launchable from Settings.",
|
||||
"category": "practice",
|
||||
"capabilities": {
|
||||
"input-calibration": {
|
||||
"roles": ["owner"],
|
||||
"commands": ["run", "status", "inspect"],
|
||||
"events": ["calibration-started", "calibration-done", "calibration-skipped"],
|
||||
"kind": "command",
|
||||
"mode": "active",
|
||||
"compatibility": "none",
|
||||
"ownership": "exclusive-owner",
|
||||
"safety": "safe",
|
||||
"description": "Owns the per-instrument input-setup wizard workflow; onboarding and Settings dispatch through the runtime.",
|
||||
"version": 1
|
||||
},
|
||||
"audio-input": {
|
||||
"roles": ["requester"],
|
||||
"requests": ["list-sources", "select-source", "open-source"],
|
||||
"mode": "active",
|
||||
"compatibility": "degrade-noop",
|
||||
"ownership": "requester-only",
|
||||
"safety": "sensitive",
|
||||
"description": "Picks the guitar/bass audio input device through the core audio-input domain.",
|
||||
"version": 1
|
||||
},
|
||||
"midi-input": {
|
||||
"roles": ["requester"],
|
||||
"requests": ["discover", "list-sources", "select-source", "open-source", "close-source"],
|
||||
"mode": "active",
|
||||
"compatibility": "degrade-noop",
|
||||
"ownership": "requester-only",
|
||||
"safety": "sensitive",
|
||||
"description": "Picks the keys/drums MIDI device through the core midi-input domain (Web-MIDI provider ships built-in with the domain).",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
/*
|
||||
* input_setup — per-instrument input-device selection & calibration.
|
||||
*
|
||||
* Bundled core plugin (constitution P-II vanilla JS). It:
|
||||
* 1. supplies a Web-MIDI source provider to the core `midi-input` domain;
|
||||
* 2. owns the `input-calibration` capability domain (run / status / inspect);
|
||||
* 3. renders the onboarding input-setup wizard (one pass per instrument):
|
||||
* - guitar/bass → pick via `audio-input`, then launch note_detect's
|
||||
* Calibration Wizard (note-detection is a deferred surface — JS API);
|
||||
* - keys/drums → pick via `midi-input`, then a live "play a note /
|
||||
* hit a pad" confirmation.
|
||||
*
|
||||
* Idempotent (plugin-runtime-idempotent.v1): re-hydration is a no-op.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
window.slopsmith = window.slopsmith || {};
|
||||
if (window.slopsmithInputSetup && window.slopsmithInputSetup.version === 1) return;
|
||||
|
||||
const capabilities = window.slopsmith.capabilities;
|
||||
const DONE_KEY = (inst) => `input_setup.done.${inst}`;
|
||||
const INSTRUMENTS = {
|
||||
guitar: { label: 'Guitar', mode: 'audio' },
|
||||
bass: { label: 'Bass', mode: 'audio' },
|
||||
keys: { label: 'Keys / Piano', mode: 'midi' },
|
||||
piano: { label: 'Keys / Piano', mode: 'midi' },
|
||||
drums: { label: 'Drums', mode: 'midi' },
|
||||
};
|
||||
|
||||
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
|
||||
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
|
||||
function _isDone(inst) { try { return window.localStorage.getItem(DONE_KEY(inst)) === '1'; } catch (_) { return false; } }
|
||||
function _markDone(inst, v) { try { if (v) window.localStorage.setItem(DONE_KEY(inst), '1'); else window.localStorage.removeItem(DONE_KEY(inst)); } catch (_) { /* private mode */ } }
|
||||
|
||||
// The Web-MIDI source provider now ships built-in with the core midi-input
|
||||
// domain (static/capabilities/midi-input.js), so input_setup is a pure
|
||||
// consumer — it just discovers/selects/opens through `window.slopsmith.midiInput`.
|
||||
|
||||
// ── audio-input helper (guitar/bass device context) ─────────────────────
|
||||
async function _audioSources() {
|
||||
if (!capabilities || typeof capabilities.command !== 'function') return { sources: [], selected: null };
|
||||
try {
|
||||
const r = await capabilities.command('audio-input', 'list-sources', { requester: 'input_setup' });
|
||||
const p = (r && r.payload) || {};
|
||||
let sources = Array.isArray(p.sources) ? p.sources : [];
|
||||
// Exclude MIDI devices some plugins export into audio-input
|
||||
// (e.g. keys-highway-3d's pseudonymized 'midi-input-N'): they aren't
|
||||
// audio inputs and the cryptic labels confuse this guitar/bass picker.
|
||||
sources = sources.filter((s) => s
|
||||
&& !/midi/i.test(String(s.providerId || ''))
|
||||
&& !/^midi-input/i.test(String(s.label || '')));
|
||||
// De-dupe by display label — the desktop engine enumerates the same
|
||||
// device under several driver types, so the same name can repeat.
|
||||
const seen = new Set();
|
||||
sources = sources.filter((s) => {
|
||||
const key = String(s.label || '').toLowerCase();
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
const selected = sources.find((s) => s && s.selected) || null;
|
||||
return { sources, selected };
|
||||
} catch (_) { return { sources: [], selected: null }; }
|
||||
}
|
||||
|
||||
// ── Wizard UI ────────────────────────────────────────────────────────────
|
||||
// Renders sequential per-instrument panels into `host`. Resolves the
|
||||
// returned promise to { completed:[...], skipped:[...] } when finished.
|
||||
function _runWizard(opts) {
|
||||
opts = opts || {};
|
||||
const instruments = (Array.isArray(opts.instruments) ? opts.instruments : [])
|
||||
.map((i) => String(i).toLowerCase()).filter((i) => INSTRUMENTS[i]);
|
||||
// De-dupe keys/piano (same MIDI flow under one label).
|
||||
const seen = new Set();
|
||||
const queue = instruments.filter((i) => { const k = INSTRUMENTS[i].label; if (seen.has(k)) return false; seen.add(k); return true; });
|
||||
|
||||
const completed = [];
|
||||
const skipped = [];
|
||||
let idx = 0;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const host = opts.host;
|
||||
if (!host) { resolve({ completed, skipped }); return; }
|
||||
|
||||
function finish() {
|
||||
_emitOwner('calibration-done', { completed: completed.slice(), skipped: skipped.slice() });
|
||||
if (typeof opts.onComplete === 'function') { try { opts.onComplete({ completed, skipped }); } catch (_) {} }
|
||||
resolve({ completed, skipped });
|
||||
}
|
||||
// Per-panel teardown run on EVERY exit (Continue or the generic "Skip
|
||||
// for now"), so an opened MIDI session/listener never leaks past the
|
||||
// panel that opened it.
|
||||
let _activeCleanup = null;
|
||||
function next() {
|
||||
if (idx >= queue.length) { finish(); return; }
|
||||
renderPanel(queue[idx]);
|
||||
}
|
||||
function advance(inst, didComplete) {
|
||||
if (_activeCleanup) { try { _activeCleanup(); } catch (_) {} _activeCleanup = null; }
|
||||
if (didComplete) { _markDone(inst, true); if (!completed.includes(inst)) completed.push(inst); }
|
||||
else { if (!skipped.includes(inst)) skipped.push(inst); }
|
||||
idx += 1;
|
||||
next();
|
||||
}
|
||||
|
||||
function shell(inst, bodyHtml, footHtml) {
|
||||
const meta = INSTRUMENTS[inst];
|
||||
host.innerHTML =
|
||||
'<div class="space-y-4">' +
|
||||
'<div><div class="text-xs uppercase tracking-wider text-fb-textDim">Input setup — step ' + (idx + 1) + ' of ' + queue.length + '</div>' +
|
||||
'<h3 class="text-lg font-bold text-fb-text mt-0.5">Set up your ' + esc(meta.label) + '</h3></div>' +
|
||||
'<div data-is-body>' + bodyHtml + '</div>' +
|
||||
'<div class="flex justify-between items-center pt-1">' +
|
||||
'<button type="button" data-is-skip class="text-sm text-fb-textDim hover:text-fb-text">Skip for now</button>' +
|
||||
'<div data-is-foot>' + (footHtml || '') + '</div></div></div>';
|
||||
host.querySelector('[data-is-skip]').addEventListener('click', () => advance(inst, false));
|
||||
}
|
||||
|
||||
// ── per-instrument panels ───────────────────────────────────────
|
||||
async function renderPanel(inst) {
|
||||
const meta = INSTRUMENTS[inst];
|
||||
if (meta.mode === 'audio') return renderAudioPanel(inst);
|
||||
return renderMidiPanel(inst);
|
||||
}
|
||||
|
||||
// Guitar/bass: show the audio source (audio-input) and launch the
|
||||
// note_detect Calibration Wizard for the deep work.
|
||||
async function renderAudioPanel(inst) {
|
||||
const { sources, selected } = await _audioSources();
|
||||
const opts2 = sources.map((s) =>
|
||||
'<option value="' + esc(s.logicalSourceKey || s.sourceId || '') + '"' + (s.selected ? ' selected' : '') + '>' + esc(s.label || 'Input') + '</option>').join('');
|
||||
const hasDetector = !!(window.noteDetect && typeof window.noteDetect.launchCalibration === 'function');
|
||||
const body =
|
||||
'<p class="text-sm text-fb-textDim">Pick your audio input, then run the calibration to set levels, channel and latency.</p>' +
|
||||
(sources.length
|
||||
? '<label class="block text-xs uppercase tracking-wider text-fb-textDim mt-3 mb-1">Audio input</label>' +
|
||||
'<select data-is-audio class="w-full bg-gray-800/50 border border-gray-700 rounded-md px-2 py-1.5 text-sm text-fb-text outline-none">' + opts2 + '</select>'
|
||||
: '<p class="text-sm text-fb-accent mt-2">No audio input detected yet — plug in your interface, or skip and set this up later.</p>') +
|
||||
(hasDetector ? '' : '<p class="text-xs text-fb-textDim mt-3">The note detector isn’t loaded here — you can calibrate later from the player.</p>');
|
||||
const foot =
|
||||
'<button type="button" data-is-cal class="bg-fb-primary hover:bg-fb-primaryHi text-white px-5 py-2 rounded-md font-medium">' +
|
||||
(hasDetector ? 'Calibrate' : 'Continue') + '</button>';
|
||||
shell(inst, body, foot);
|
||||
|
||||
const sel = host.querySelector('[data-is-audio]');
|
||||
const commitAudio = (key) => {
|
||||
if (!capabilities || !key) return;
|
||||
capabilities.command('audio-input', 'select-source', { requester: 'input_setup', payload: { logicalSourceKey: key } }).catch(() => {});
|
||||
};
|
||||
if (sel) {
|
||||
sel.addEventListener('change', () => commitAudio(sel.value));
|
||||
// The <select> shows its first option by default, but no `change`
|
||||
// fires for that implicit pick — so on a first run with nothing yet
|
||||
// selected, audio-input would calibrate against the wrong/no source.
|
||||
// Commit the shown option up-front so the displayed device is the
|
||||
// one calibrated (idempotent if it was already selected).
|
||||
if (!selected) commitAudio(sel.value);
|
||||
}
|
||||
// Tell the tuner tables / note_detect which instrument this is.
|
||||
try { fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ instrument: inst }) }); } catch (_) {}
|
||||
|
||||
host.querySelector('[data-is-cal]').addEventListener('click', () => {
|
||||
if (hasDetector) {
|
||||
window.noteDetect.launchCalibration({
|
||||
instrument: inst,
|
||||
onDone: () => advance(inst, true),
|
||||
onCancel: () => { /* stay on this panel; user can skip or retry */ },
|
||||
});
|
||||
} else {
|
||||
advance(inst, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Keys/drums: pick a MIDI device via midi-input and confirm a live hit.
|
||||
async function renderMidiPanel(inst) {
|
||||
const mi = window.slopsmith.midiInput;
|
||||
// Availability is the midi-input DOMAIN being present, not the
|
||||
// Web-MIDI browser API — the domain coordinates providers (the
|
||||
// built-in Web-MIDI one, plus any native/desktop adapter), so
|
||||
// gating on navigator.requestMIDIAccess would hide a usable
|
||||
// non-Web-MIDI provider before discover() is ever called.
|
||||
const midiAvailable = !!(mi && mi.version === 1);
|
||||
if (!midiAvailable) {
|
||||
shell(inst,
|
||||
'<p class="text-sm text-fb-accent">MIDI input isn’t available here. Connect a MIDI keyboard/e-kit in a supported environment, or skip for now.</p>',
|
||||
'<button type="button" data-is-skip2 class="bg-fb-primary hover:bg-fb-primaryHi text-white px-5 py-2 rounded-md font-medium">Continue</button>');
|
||||
host.querySelector('[data-is-skip2]').addEventListener('click', () => advance(inst, false));
|
||||
return;
|
||||
}
|
||||
const verb = inst === 'drums' ? 'hit a pad' : 'play a note';
|
||||
shell(inst,
|
||||
'<p class="text-sm text-fb-textDim">Connect your MIDI device, pick it below, then ' + verb + ' to confirm it’s working.</p>' +
|
||||
'<div class="mt-3 flex items-center gap-2">' +
|
||||
'<button type="button" data-is-scan class="text-sm text-fb-primary hover:text-fb-primaryHi">Scan for MIDI devices</button></div>' +
|
||||
'<div data-is-midi-wrap class="hidden mt-2">' +
|
||||
'<select data-is-midi class="w-full bg-gray-800/50 border border-gray-700 rounded-md px-2 py-1.5 text-sm text-fb-text outline-none"></select>' +
|
||||
'<p data-is-test class="text-sm text-fb-textDim mt-2">Waiting for input…</p></div>',
|
||||
'<button type="button" data-is-next disabled class="bg-fb-primary disabled:opacity-40 text-white px-5 py-2 rounded-md font-medium">Continue</button>');
|
||||
|
||||
const wrap = host.querySelector('[data-is-midi-wrap]');
|
||||
const select = host.querySelector('[data-is-midi]');
|
||||
const testEl = host.querySelector('[data-is-test]');
|
||||
const nextBtn = host.querySelector('[data-is-next]');
|
||||
let activeKey = null;
|
||||
let listener = null;
|
||||
let activeHandle = null;
|
||||
let openSeq = 0;
|
||||
|
||||
async function openSelected() {
|
||||
// Tear down the previous device + RESET the confirmation
|
||||
// state, so a hit on a prior device can't leave Continue
|
||||
// enabled for a newly-selected device that hasn't been heard.
|
||||
const myGen = ++openSeq;
|
||||
if (activeHandle && listener) { try { activeHandle.removeListener(listener); } catch (_) {} }
|
||||
if (activeKey) { try { mi.close({ requester: 'input_setup', logicalSourceKey: activeKey }); } catch (_) {} }
|
||||
activeHandle = null;
|
||||
listener = null;
|
||||
nextBtn.disabled = true;
|
||||
_markDone(inst, false);
|
||||
// Capture the requested key in a local: a newer openSelected()
|
||||
// overwrites the shared `activeKey`, so comparing it after the
|
||||
// awaits would let a stale open bind the wrong device.
|
||||
const requestedKey = select.value;
|
||||
activeKey = requestedKey;
|
||||
if (!requestedKey) { testEl.textContent = ''; return; }
|
||||
testEl.textContent = 'Waiting for input…';
|
||||
await mi.select(requestedKey);
|
||||
const res = await mi.open({ requester: 'input_setup', logicalSourceKey: requestedKey });
|
||||
// Discard a stale open if a newer openSelected() superseded us.
|
||||
if (myGen !== openSeq) { try { if (res) mi.close({ requester: 'input_setup', logicalSourceKey: requestedKey }); } catch (_) {} return; }
|
||||
if (!res || !res.handle) { testEl.textContent = 'Could not open this device.'; activeKey = null; return; }
|
||||
activeHandle = res.handle;
|
||||
listener = (data) => {
|
||||
// 0x90 = note-on (any channel); velocity > 0.
|
||||
if (data && (data[0] & 0xf0) === 0x90 && data[2] > 0) {
|
||||
testEl.innerHTML = '<span class="text-fb-primary font-semibold">✓ Got it</span> — device is working.';
|
||||
nextBtn.disabled = false;
|
||||
_markDone(inst, true);
|
||||
}
|
||||
};
|
||||
activeHandle.addListener(listener);
|
||||
}
|
||||
|
||||
host.querySelector('[data-is-scan]').addEventListener('click', async () => {
|
||||
await mi.discover();
|
||||
// Show every source the midi-input domain surfaces — not just
|
||||
// the built-in Web-MIDI provider — so a native/desktop MIDI
|
||||
// adapter registered with the domain is selectable too.
|
||||
const sources = window.slopsmith.midiInput.listSources() || [];
|
||||
if (!sources.length) { testEl && (testEl.textContent = ''); wrap.classList.remove('hidden'); select.innerHTML = '<option>No MIDI devices found</option>'; select.disabled = true; return; }
|
||||
wrap.classList.remove('hidden');
|
||||
select.disabled = false;
|
||||
select.innerHTML = sources.map((s) => '<option value="' + esc(s.logicalSourceKey) + '"' + (s.selected ? ' selected' : '') + '>' + esc(s.label) + '</option>').join('');
|
||||
openSelected();
|
||||
});
|
||||
select.addEventListener('change', openSelected);
|
||||
// Close the open session/listener on ANY exit (Continue or the
|
||||
// generic Skip), so a scanned+selected device doesn't keep its
|
||||
// Web-MIDI input live after the panel advances.
|
||||
_activeCleanup = () => {
|
||||
if (activeHandle && listener) { try { activeHandle.removeListener(listener); } catch (_) {} }
|
||||
if (activeKey) { try { mi.close({ requester: 'input_setup', logicalSourceKey: activeKey }); } catch (_) {} }
|
||||
activeHandle = null; listener = null; activeKey = null;
|
||||
};
|
||||
nextBtn.addEventListener('click', () => advance(inst, true));
|
||||
}
|
||||
|
||||
_emitOwner('calibration-started', { instruments: queue.slice() });
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
function _emitOwner(event, detail) {
|
||||
try { capabilities && capabilities.emitEvent && capabilities.emitEvent('input-calibration', event, detail || {}); } catch (_) {}
|
||||
}
|
||||
|
||||
// ── input-calibration owner domain ───────────────────────────────────────
|
||||
function _statusPayload(instruments) {
|
||||
const list = (Array.isArray(instruments) && instruments.length ? instruments : Object.keys(INSTRUMENTS))
|
||||
.map((i) => String(i).toLowerCase());
|
||||
const status = {};
|
||||
list.forEach((i) => { if (INSTRUMENTS[i]) status[i] = _isDone(i) ? 'done' : 'needs-setup'; });
|
||||
return status;
|
||||
}
|
||||
|
||||
if (capabilities && typeof capabilities.registerOwner === 'function') {
|
||||
capabilities.registerOwner('input-calibration', {
|
||||
pluginId: 'input_setup',
|
||||
kind: 'command',
|
||||
safety: 'safe',
|
||||
commands: ['run', 'status', 'inspect'],
|
||||
events: ['calibration-started', 'calibration-done', 'calibration-skipped'],
|
||||
description: 'Per-instrument input-setup wizard workflow (audio via audio-input + note_detect; MIDI via midi-input).',
|
||||
handlers: {
|
||||
inspect: () => ({ outcome: 'handled', payload: { available: true, status: _statusPayload() } }),
|
||||
status: (ctx) => ({ outcome: 'handled', payload: { status: _statusPayload((ctx.payload || {}).instruments) } }),
|
||||
// `run` is fire-and-launch: an interactive wizard far exceeds the
|
||||
// ~250ms handler timeout, so it starts the overlay and returns
|
||||
// immediately. Completion is signaled by the `calibration-done`
|
||||
// event (mirrors audio-monitoring `start`). A second `run` while
|
||||
// one is open is a no-op (single overlay).
|
||||
run: (ctx) => {
|
||||
const instruments = ((ctx.payload || {}).instruments) || [];
|
||||
if (!document.getElementById('input-setup-overlay')) launch(instruments);
|
||||
return { outcome: 'handled', payload: { started: true, instruments } };
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── public surface (onboarding + Settings re-entry) ──────────────────────
|
||||
function mount(container, options) {
|
||||
options = options || {};
|
||||
return _runWizard({ host: container, instruments: options.instruments || [], onComplete: options.onComplete, onSkip: options.onSkip });
|
||||
}
|
||||
|
||||
function launch(instruments) {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'input-setup-overlay';
|
||||
overlay.className = 'fixed inset-0 z-[210] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4';
|
||||
overlay.innerHTML = '<div class="bg-fb-card rounded-xl border border-fb-border/50 w-full max-w-lg p-6" data-is-host></div>';
|
||||
document.body.appendChild(overlay);
|
||||
const host = overlay.querySelector('[data-is-host]');
|
||||
return _runWizard({ host, instruments: instruments || [] }).then((r) => { overlay.remove(); return r; });
|
||||
}
|
||||
|
||||
window.slopsmithInputSetup = {
|
||||
version: 1,
|
||||
mount,
|
||||
launch,
|
||||
status: (instruments) => _statusPayload(instruments),
|
||||
};
|
||||
|
||||
// Settings-panel re-entry (settings.html "Set up input devices" button).
|
||||
// Re-runs the wizard for the player's selected instrument paths, falling
|
||||
// back to all instruments when progression isn't available.
|
||||
window._inputSetupRelaunch = async function () {
|
||||
let instruments = [];
|
||||
try {
|
||||
const r = await fetch('/api/progression');
|
||||
if (r.ok) {
|
||||
const d = await r.json();
|
||||
const paths = Array.isArray(d.paths) ? d.paths : [];
|
||||
instruments = paths.map((p) => (typeof p === 'string' ? p : (p && p.id))).filter(Boolean);
|
||||
}
|
||||
} catch (_) { /* offline — fall back below */ }
|
||||
if (!instruments.length) instruments = ['guitar', 'bass', 'keys', 'drums'];
|
||||
launch(instruments);
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,16 @@
|
||||
<div class="space-y-4 py-2">
|
||||
<div class="bg-dark-900/50 p-3 rounded-xl border border-gray-800/50">
|
||||
<h3 class="text-sm font-medium text-gray-200">Input devices & calibration</h3>
|
||||
<p class="text-[11px] text-gray-500 mt-1">
|
||||
Re-run the input setup wizard for your instrument paths — pick your audio
|
||||
input or MIDI device and confirm it's working. Guitar/bass also opens the
|
||||
calibration wizard.
|
||||
</p>
|
||||
<button type="button"
|
||||
onclick="window._inputSetupRelaunch && window._inputSetupRelaunch()"
|
||||
class="mt-3 px-4 py-2 bg-accent hover:bg-accent-light text-white text-sm font-medium rounded-lg">
|
||||
Set up input devices
|
||||
</button>
|
||||
<p id="input-setup-settings-status" class="text-[11px] text-gray-500 mt-2"></p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3355,6 +3355,10 @@ async def startup_events():
|
||||
"unregister_library_provider": unregister_library_provider,
|
||||
"register_tuning_provider": register_tuning_provider,
|
||||
"unregister_tuning_provider": unregister_tuning_provider,
|
||||
# `get_feedpak_cache_dir` is the canonical name (format renamed
|
||||
# sloppak → feedpak); `get_sloppak_cache_dir` stays as a permanent
|
||||
# deprecated alias so existing plugins keep working. Same cache dir.
|
||||
"get_feedpak_cache_dir": lambda: SLOPPAK_CACHE_DIR,
|
||||
"get_sloppak_cache_dir": lambda: SLOPPAK_CACHE_DIR,
|
||||
"register_demo_janitor_hook": register_demo_janitor_hook,
|
||||
# Unified XP service (fee[dB]ack v0.3.0). Plugins that award XP
|
||||
@@ -3827,7 +3831,7 @@ def trigger_full_rescan():
|
||||
|
||||
# ── Song upload ───────────────────────────────────────────────────────────────
|
||||
|
||||
_ALLOWED_SONG_EXTS = {".sloppak"}
|
||||
_ALLOWED_SONG_EXTS = {".feedpak", ".sloppak"} # .sloppak is the legacy alias
|
||||
_MAX_UPLOAD_BYTES = 1024 * 1024 * 1024 # 1 GB — covers sloppaks bundled with stems
|
||||
# Per-request batch cap. Lets a user drop a whole album of sloppaks at once
|
||||
# without giving a hostile client a 1000-file DoS surface via Starlette's
|
||||
@@ -4058,7 +4062,7 @@ async def _save_uploaded_song(upload: UploadFile, dlc: Path, overwrite: bool) ->
|
||||
suffix = Path(base).suffix.lower()
|
||||
if suffix not in _ALLOWED_SONG_EXTS:
|
||||
return {"status": "error", "filename": base,
|
||||
"error": "Only .sloppak files are accepted"}
|
||||
"error": "Only .feedpak (or legacy .sloppak) files are accepted"}
|
||||
|
||||
dest = dlc / base
|
||||
if dest.exists():
|
||||
@@ -4271,7 +4275,7 @@ def _library_filter_args(q: str = "", favorites: int = 0, format: str = "",
|
||||
arrangements_has: str = "", arrangements_lacks: str = "",
|
||||
stems_has: str = "", stems_lacks: str = "",
|
||||
has_lyrics: str = "", tunings: str = "") -> dict:
|
||||
fmt = format if format in ("archive", "sloppak", "loose") else ""
|
||||
fmt = format if format in ("archive", "sloppak", "feedpak", "loose") else ""
|
||||
return {
|
||||
"q": q,
|
||||
"favorites_only": bool(favorites),
|
||||
@@ -6298,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.
|
||||
"""
|
||||
@@ -6318,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.
|
||||
@@ -6358,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;
|
||||
@@ -6367,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)
|
||||
|
||||
@@ -6915,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,
|
||||
@@ -6955,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.
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
# Spec 012 — MIDI-Input Control-Plane Capability Domain
|
||||
|
||||
**Status:** active (control-plane slice) · **Issues:** #873 (impl), #880 (this spec) · **Base:** `release/v0.3.0`
|
||||
|
||||
## Summary
|
||||
|
||||
`midi-input` is a **core-owned provider-coordinator** capability domain for MIDI
|
||||
device discovery, selection, and open/close session lifecycle — the MIDI analog
|
||||
of `audio-input` (spec 006). It gives every MIDI consumer in Slopsmith (the
|
||||
`input_setup` onboarding wizard, the `piano`/keys and `drums` plugins, and — as
|
||||
a follow-up — note-detection's Web-MIDI provider) **one device-access boundary**:
|
||||
one permission prompt, one source list, one redaction boundary.
|
||||
|
||||
## Motivation
|
||||
|
||||
Today each MIDI consumer calls `navigator.requestMIDIAccess()` privately
|
||||
(piano, drums, plugin-midi, note-detection's `midi` provider kind), so there is
|
||||
no shared source list, no single permission prompt, and no common redaction of
|
||||
device labels. The onboarding input-setup step (#874/#876/#877) needs a single
|
||||
governed surface to pick and verify a MIDI device per instrument.
|
||||
|
||||
## Why not reuse `audio-input`
|
||||
|
||||
`audio-input`'s source/`source.open` contract is audio-frame-centric:
|
||||
`channelSummary`/`channelCount`/`channelShape`, `requiredChannelShape`, and
|
||||
redaction keyed to audio handles/buffers/samples. MIDI carries discrete messages
|
||||
and has no channel shape. Folding MIDI in would overload the audio contract and
|
||||
its redaction boundary. A sibling domain keeps both contracts clean and lets
|
||||
each evolve independently — the same reasoning that made `audio-input` and
|
||||
`audio-monitoring` siblings rather than one domain.
|
||||
|
||||
## Why core-owned (not plugin-owned)
|
||||
|
||||
An input control plane outlives any one feature; `audio-input` is
|
||||
`core.audio.session`-owned, not owned by a feature plugin. If `input_setup`
|
||||
owned `midi-input`, the domain's lifetime would be coupled to the wizard, and
|
||||
migrating ownership later (every consumer, persistence key, diagnostics schema
|
||||
references the owner) is costly. The domain is `core.midi-input`.
|
||||
|
||||
## Contract
|
||||
|
||||
- **Owner:** `core.midi-input`, kind `provider-coordinator`, safety `sensitive`.
|
||||
- **Public commands:** `inspect`, `list-sources`, `discover`, `select-source`,
|
||||
`open-source`, `close-source`.
|
||||
- **Provider operations:** `source.enumerate`, `source.describe`, `source.open`,
|
||||
`source.close`.
|
||||
- **Events:** `provider-registered`, `provider-unregistered`,
|
||||
`availability-changed`, `sources-changed`, `source-selected`, `source-opened`,
|
||||
`source-closed`.
|
||||
|
||||
### Sources & identity
|
||||
|
||||
Providers register source summaries with `providerId`, a stable `sourceId`, a
|
||||
derived **redaction-safe** `logicalSourceKey` (`providerId::sourceId`),
|
||||
`kind: "midi"`, a label, and `availability`. Persistence and diagnostics use the
|
||||
`logicalSourceKey`, never the human device label.
|
||||
|
||||
### Permission model (Web-MIDI nuance)
|
||||
|
||||
`requestMIDIAccess()` gates the **whole input list**, so **`discover` is the
|
||||
permission boundary** (not `open-source`, as it is for audio). `inspect` /
|
||||
`list-sources` / `select-source` are **prompt-free** and never request access.
|
||||
`discover` records `denied` / `unavailable` outcomes; `open-source` attaches a
|
||||
shared listener session to an already-discovered source and never re-prompts.
|
||||
|
||||
### Sessions
|
||||
|
||||
One shared open session per source across requesters (refcounted); the provider
|
||||
receives `source.close` only after the last requester releases. Live MIDI
|
||||
message delivery (for the "play a note / hit a pad" calibration check) is exposed
|
||||
to in-page consumers via the public `window.slopsmith.midiInput` session handle
|
||||
**only** — never as raw capability events or in diagnostics.
|
||||
|
||||
### Persistence & redaction
|
||||
|
||||
Selected source persists under `slopsmith.midiInput.selectedLogicalSourceKey`.
|
||||
Diagnostics (`slopsmith.midi_input.diagnostics.v1`) carry provider ids, source
|
||||
ids/keys/kinds/availability, the selected key, and open-session keys; device
|
||||
**labels are redacted** and **no raw MIDI messages** are ever included.
|
||||
|
||||
## Split from `midi-control`
|
||||
|
||||
The reserved `midi-control` domain is narrowed to **control mappings only**
|
||||
(CC/pitchbend/note → action routing) and will consume `midi-input` for device
|
||||
access. This spec carves out the device control plane so `midi-control` can stay
|
||||
mappings-only (#882).
|
||||
|
||||
## Consumers (separate issues)
|
||||
|
||||
- `input_setup` onboarding wizard — keys/drums device pick + verify (#876/#877).
|
||||
- `piano` / `drums` plugins — consume `midi-input` instead of private
|
||||
`requestMIDIAccess()` (via the sub-flow issues; legacy retired through bridges).
|
||||
- note-detection's Web-MIDI provider migrates onto `midi-input` (#881).
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Owner registers; appears in the Capability Inspector with the commands above.
|
||||
- `discover` is the only command that triggers `requestMIDIAccess()`;
|
||||
`inspect`/`list-sources`/`select-source` never prompt.
|
||||
- Selection persists across reload by `logicalSourceKey`.
|
||||
- Diagnostics contain no device labels or raw MIDI messages.
|
||||
- A consumer can `discover` → `select-source` → `open-source` → receive live
|
||||
note-on for the calibration check → `close-source` (session refcount releases).
|
||||
|
||||
## Out of scope (follow-ups)
|
||||
|
||||
- `midi-control` mapping/routing domain (#882).
|
||||
- note-detection provider migration onto `midi-input` (#881).
|
||||
- Retiring per-plugin `requestMIDIAccess()` in piano/drums via compatibility
|
||||
bridges (tracked with the sub-flow issues).
|
||||
@@ -0,0 +1,75 @@
|
||||
# Spec 013 — `midi-control` Mappings Domain (the midi-input/midi-control split)
|
||||
|
||||
**Status:** documented future contract (RESERVED — not in the runtime graph) ·
|
||||
**Issue:** #882 · **Depends on:** spec 012 (`midi-input`, delivered) · **Base:** `feedback/main`
|
||||
|
||||
## Summary
|
||||
|
||||
`midi-control` is the planned sibling of `midi-input`: it owns **MIDI control
|
||||
mappings** — routing CC / pitchbend / note messages to *semantic actions* (drum
|
||||
lane, transport command, effect parameter, etc.) — and **consumes `midi-input`**
|
||||
for device access. It does **not** discover, select, or open devices; that is
|
||||
`midi-input`'s job (spec 012, delivered).
|
||||
|
||||
This spec records the **split** so the boundary is unambiguous and the contract
|
||||
is ready for whoever builds the runtime slice. Per project governance
|
||||
(`docs/capability-safety-matrix.md`, `docs/capability-roadmap.md`), a future
|
||||
domain stays **documentation-only until a PR ships its host workflow, a concrete
|
||||
consumer, and tests** — so `midi-control` remains `RESERVED` in
|
||||
`static/capabilities.js` `RESERVED_FUTURE_DOMAINS` until then. This spec does not
|
||||
register a runtime domain.
|
||||
|
||||
## Why split it out
|
||||
|
||||
Before `midi-input` existed, "MIDI" meant two conflated concerns: getting bytes
|
||||
from a device, and mapping those bytes to actions. The reserved `midi-control`
|
||||
entry originally covered both. With `midi-input` delivered as the device control
|
||||
plane, `midi-control` is narrowed to **mappings only** — mirroring how
|
||||
`audio-input` (devices) is separate from `audio-effects`/`audio-mix` (what you do
|
||||
with the signal). Keeping them separate prevents a future god-domain and lets the
|
||||
device plane stabilize independently of mapping semantics.
|
||||
|
||||
## Boundary (normative)
|
||||
|
||||
- **`midi-input` owns:** device discovery (`discover`), source list, selection,
|
||||
open/close sessions, the Web-MIDI permission boundary, redacted device
|
||||
diagnostics. The raw MIDI message stream is delivered to in-page consumers via
|
||||
its session handle.
|
||||
- **`midi-control` will own:** named mappings from MIDI events (note / CC /
|
||||
pitchbend, optionally channel-scoped) to semantic actions, mapping persistence,
|
||||
active-mapping selection, and "learn" capture. It **consumes** a `midi-input`
|
||||
session for the live stream; it never calls `requestMIDIAccess` or enumerates
|
||||
devices.
|
||||
|
||||
## Proposed contract (for the future implementation slice)
|
||||
|
||||
- **Owner:** `core.midi-control` (or a first-party MIDI-control plugin),
|
||||
`multi-provider`, safety `sensitive`.
|
||||
- **Commands:** `list-mappings`, `get-mapping`, `set-mapping`, `delete-mapping`,
|
||||
`activate-mapping`, `inspect`.
|
||||
- **Mapping shape (sketch):** `{ id, label, trigger: { type: 'note'|'cc'|'pitchbend',
|
||||
number?, channel? }, action: { domain?, command?|actionId, params? } }`.
|
||||
- **Learn mode:** open a `midi-input` session, capture the next matching event,
|
||||
and bind it to the pending action (the per-plugin "learn" UIs in drums today
|
||||
are the reference behaviour to generalise).
|
||||
- **Diagnostics:** `slopsmith.midi_control.diagnostics.v1` — mapping summaries +
|
||||
bounded recent activations; **no raw MIDI streams, no device labels**.
|
||||
|
||||
## Intended consumers (promotion trigger)
|
||||
|
||||
The domain should be promoted out of RESERVED when a concrete consumer needs
|
||||
shared mappings, e.g.:
|
||||
- the generic **MIDI control plugin** (`feedback-plugin-midi`) — today an ad-hoc
|
||||
event→action mapper; the canonical first adopter.
|
||||
- **drums** note→lane mapping + "learn mode" (`feedback-plugin-drums`,
|
||||
`feedback-plugin-drum-highway-3d`) — currently per-plugin; could adopt
|
||||
`midi-control` to share mapping logic once the contract is proven.
|
||||
|
||||
Until such a consumer-driven slice exists (with host workflow + tests), this
|
||||
remains a documented contract only.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Any runtime registration / handlers (governance: no premature domain).
|
||||
- Migrating the drums/keys per-plugin mapping now — deferred to the consumer slice.
|
||||
- The device plane — owned by `midi-input` (spec 012, done).
|
||||
+2
-2
@@ -4152,10 +4152,10 @@ async function uploadSongs(fileList) {
|
||||
const files = [];
|
||||
for (const f of all) {
|
||||
const lower = f.name.toLowerCase();
|
||||
if (lower.endsWith('.sloppak')) {
|
||||
if (lower.endsWith('.feedpak') || lower.endsWith('.sloppak')) {
|
||||
files.push(f);
|
||||
} else {
|
||||
failures.push(`${f.name}: only .sloppak accepted`);
|
||||
failures.push(`${f.name}: only .feedpak (or legacy .sloppak) accepted`);
|
||||
}
|
||||
}
|
||||
if (files.length === 0) {
|
||||
|
||||
+27
-11
@@ -79,6 +79,19 @@
|
||||
const MAX_DECISIONS = 100;
|
||||
const MAX_SNAPSHOT_BYTES = 64 * 1024;
|
||||
const DEFAULT_HANDLER_TIMEOUT_MS = 250;
|
||||
// Per-(capability, command) handler-timeout overrides. A few commands front a
|
||||
// user-action / OS-permission prompt (fader reads that await a UI; MIDI/mic
|
||||
// device access) that legitimately runs far longer than the default budget,
|
||||
// so the dispatch/command surface must not fail them at 250 ms while the
|
||||
// operation is still completing through the provider.
|
||||
const COMMAND_TIMEOUTS_MS = {
|
||||
'audio-mix': { 'get-fader-value': 2100, 'set-fader-value': 2100 },
|
||||
'midi-input': { 'discover': 15000, 'open-source': 15000 },
|
||||
};
|
||||
function _commandTimeoutFor(capability, commandName) {
|
||||
const byCap = COMMAND_TIMEOUTS_MS[capability];
|
||||
return byCap ? byCap[commandName] : undefined;
|
||||
}
|
||||
const RESERVED_FUTURE_DOMAINS = new Set([
|
||||
'ui.navigation',
|
||||
'ui.plugin-screens',
|
||||
@@ -803,15 +816,18 @@
|
||||
}
|
||||
|
||||
function _withTimeout(promise, timeoutMs, participant) {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise(resolve => {
|
||||
setTimeout(() => resolve({
|
||||
outcome: 'failed',
|
||||
reason: `Handler ${participant.pluginId} timed out after ${timeoutMs} ms`,
|
||||
}), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
// Capture + clear the timer once the race settles — otherwise a handler
|
||||
// that resolves first leaves a live setTimeout (up to timeoutMs) that
|
||||
// keeps the event loop alive and, for long overrides (MIDI permission
|
||||
// commands at 15s), accumulates delayed callbacks across repeated calls.
|
||||
let timer;
|
||||
const timeout = new Promise(resolve => {
|
||||
timer = setTimeout(() => resolve({
|
||||
outcome: 'failed',
|
||||
reason: `Handler ${participant.pluginId} timed out after ${timeoutMs} ms`,
|
||||
}), timeoutMs);
|
||||
});
|
||||
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
||||
}
|
||||
|
||||
function _normalizeDecision(participant, result) {
|
||||
@@ -960,7 +976,7 @@
|
||||
if (typeof handler !== 'function') continue;
|
||||
let decision;
|
||||
try {
|
||||
const timeoutMs = Number(commandContext.timeoutMs || DEFAULT_HANDLER_TIMEOUT_MS);
|
||||
const timeoutMs = Number(commandContext.timeoutMs || _commandTimeoutFor(capabilityName, commandName) || DEFAULT_HANDLER_TIMEOUT_MS);
|
||||
const result = await _withTimeout(Promise.resolve(handler(commandContext)), timeoutMs, participant);
|
||||
decision = _normalizeDecision(participant, result);
|
||||
} catch (err) {
|
||||
@@ -1376,7 +1392,7 @@
|
||||
target: source.target || source.args?.target || null,
|
||||
payload: source.args || source.payload || {},
|
||||
claim: source.claim,
|
||||
timeoutMs: source.timeoutMs || (capability === 'audio-mix' && (commandName === 'get-fader-value' || commandName === 'set-fader-value') ? 2100 : undefined),
|
||||
timeoutMs: source.timeoutMs || _commandTimeoutFor(capability, commandName),
|
||||
});
|
||||
const status = _dispatchStatus(result);
|
||||
_emitEvent(capability, 'dispatched', { command: commandName, status, result, source: source.source || source.requester || 'dispatch' });
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
// Core MIDI-input capability domain (spec 012 control plane).
|
||||
//
|
||||
// The MIDI analog of `audio-input`: a core-owned provider-coordinator over MIDI
|
||||
// device discovery, selection, and open/close session lifecycle. It is NOT
|
||||
// owned by any feature plugin (an input plane outlives any feature, exactly as
|
||||
// `audio-input` is `core.audio.session`-owned), and it is deliberately separate
|
||||
// from `audio-input` (whose source/open contract is audio-frame-centric:
|
||||
// channel shapes, sample buffers) — MIDI carries discrete messages, not audio.
|
||||
//
|
||||
// Consumers (the input_setup wizard, the piano/keys and drums plugins, and —
|
||||
// later — note-detection's Web-MIDI provider) converge on ONE device-access
|
||||
// boundary here: one permission prompt, one source list, one redaction
|
||||
// boundary, replacing private per-plugin `navigator.requestMIDIAccess()` calls.
|
||||
//
|
||||
// Web-MIDI nuance vs audio: `requestMIDIAccess()` gates the whole input LIST, so
|
||||
// `discover` (not `open-source`) is the permission boundary for MIDI. `inspect`
|
||||
// / `list-sources` / `select-source` stay prompt-free and never request access.
|
||||
//
|
||||
// Live message delivery (needed by the "play a note / hit a pad" calibration
|
||||
// check) is exposed to in-page consumers through the public global's session
|
||||
// handle, never as raw capability events or diagnostics.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
window.slopsmith = window.slopsmith || {};
|
||||
const capabilities = window.slopsmith.capabilities;
|
||||
if (!capabilities || capabilities.version !== 1) return;
|
||||
if (window.slopsmith.midiInput && window.slopsmith.midiInput.version === 1) return;
|
||||
|
||||
const STORAGE_KEY = 'slopsmith.midiInput.selectedLogicalSourceKey';
|
||||
|
||||
// providerId → { id, label, participantId, handlers:{ enumerate, open, close } }
|
||||
// handlers are LIVE functions supplied in-page via the public global; they
|
||||
// never travel through the capability `command` payload.
|
||||
const providers = new Map();
|
||||
// logicalSourceKey → { sourceId, providerId, logicalSourceKey, kind, label, availability }
|
||||
const sources = new Map();
|
||||
// logicalSourceKey → { refs:Set<requester>, handle } — one shared open
|
||||
// session per source; the provider is closed only after the last release.
|
||||
const sessions = new Map();
|
||||
// logicalSourceKey → Promise — in-flight provider.open() calls, so concurrent
|
||||
// opens for the same source coalesce onto one provider session instead of each
|
||||
// calling provider.open() (which, for Web-MIDI, would overwrite the shared
|
||||
// input.onmidimessage handler and orphan the earlier session/handle).
|
||||
const opening = new Map();
|
||||
let selectedKey = _readStorage();
|
||||
let lastOutcome = null;
|
||||
|
||||
// ── outcome helpers (mirror note-detection.js) ──────────────────────────
|
||||
function _handled(payload = {}) { lastOutcome = { outcome: 'handled' }; return { outcome: 'handled', payload }; }
|
||||
function _degraded(reason, payload = {}) { lastOutcome = { outcome: 'degraded', reason }; return { outcome: 'degraded', reason, payload }; }
|
||||
function _denied(reason, payload = {}) { lastOutcome = { outcome: 'denied', reason }; return { outcome: 'denied', reason, payload }; }
|
||||
function _unavailable(reason, payload = {}) { lastOutcome = { outcome: 'unavailable', reason }; return { outcome: 'unavailable', reason, payload }; }
|
||||
|
||||
function _emit(name, detail) {
|
||||
try { capabilities.emitEvent('midi-input', name, detail || {}); }
|
||||
catch (_) { /* eventing must not break input */ }
|
||||
}
|
||||
|
||||
function _readStorage() {
|
||||
try { return window.localStorage.getItem(STORAGE_KEY) || null; }
|
||||
catch (_) { return null; }
|
||||
}
|
||||
function _writeStorage(key) {
|
||||
try { if (key) window.localStorage.setItem(STORAGE_KEY, key); else window.localStorage.removeItem(STORAGE_KEY); return true; }
|
||||
catch (_) { return false; }
|
||||
}
|
||||
|
||||
function _str(v, fallback) { const s = (v == null ? '' : String(v)).trim(); return s || fallback; }
|
||||
|
||||
// A stable, redaction-safe key for persistence: provider + a stable source
|
||||
// id, NOT the human device label.
|
||||
function _logicalKey(providerId, sourceId) { return `${providerId}::${sourceId}`; }
|
||||
|
||||
// ── snapshots ───────────────────────────────────────────────────────────
|
||||
// listShape keeps labels (this feeds the picker UI); diagShape strips them
|
||||
// (device labels are PII-adjacent).
|
||||
function _sourceListShape() {
|
||||
return Array.from(sources.values()).map((s) => ({
|
||||
logicalSourceKey: s.logicalSourceKey,
|
||||
sourceId: s.sourceId,
|
||||
providerId: s.providerId,
|
||||
kind: s.kind,
|
||||
label: s.label,
|
||||
availability: s.availability,
|
||||
selected: s.logicalSourceKey === selectedKey,
|
||||
open: sessions.has(s.logicalSourceKey),
|
||||
}));
|
||||
}
|
||||
|
||||
function _snapshot(extra = {}) {
|
||||
return {
|
||||
available: providers.size > 0,
|
||||
providers: Array.from(providers.values()).map((p) => ({ id: p.id, label: p.label })),
|
||||
sources: _sourceListShape(),
|
||||
selected: selectedKey,
|
||||
openSessions: Array.from(sessions.keys()),
|
||||
lastOutcome: lastOutcome ? { ...lastOutcome } : null,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function _contributeDiagnostics() {
|
||||
const diagnostics = window.slopsmith && window.slopsmith.diagnostics;
|
||||
if (!diagnostics || typeof diagnostics.contribute !== 'function') return;
|
||||
try {
|
||||
const snap = _snapshot();
|
||||
// Redact device labels everywhere — keep ids/kind/availability for
|
||||
// operational observability only. No raw MIDI messages ever.
|
||||
const redacted = {
|
||||
...snap,
|
||||
providers: snap.providers.map(({ label: _l, ...safe }) => safe),
|
||||
sources: snap.sources.map(({ label: _l, ...safe }) => safe),
|
||||
};
|
||||
diagnostics.contribute('midi-input-capability', {
|
||||
schema: 'slopsmith.midi_input.diagnostics.v1',
|
||||
...redacted,
|
||||
});
|
||||
} catch (_) { /* diagnostics must not break input */ }
|
||||
}
|
||||
|
||||
// ── provider registry (live handlers via the public global) ─────────────
|
||||
function _registerProvider(input = {}) {
|
||||
const providerId = _str(input.providerId || input.id, '');
|
||||
if (!providerId) return null;
|
||||
const handlers = {
|
||||
enumerate: typeof input.enumerate === 'function' ? input.enumerate : null,
|
||||
open: typeof input.open === 'function' ? input.open : null,
|
||||
close: typeof input.close === 'function' ? input.close : null,
|
||||
};
|
||||
const participantId = _str(input.participantId, providerId);
|
||||
const wasAvailable = providers.size > 0;
|
||||
providers.set(providerId, { id: providerId, label: _str(input.label, providerId), participantId, handlers });
|
||||
// Mirror a serializable declaration into the capability graph so the
|
||||
// Inspector/diagnostics can reason about the provider relationship.
|
||||
try {
|
||||
capabilities.registerParticipant(participantId, {
|
||||
'midi-input': {
|
||||
roles: ['provider'],
|
||||
operations: ['source.enumerate', 'source.describe', 'source.open', 'source.close'],
|
||||
mode: 'active',
|
||||
safety: 'sensitive',
|
||||
runtime: true,
|
||||
description: `MIDI input provider ${_str(input.label, providerId)}.`,
|
||||
provider_policy: { providerId },
|
||||
},
|
||||
});
|
||||
} catch (_) { /* declaration is best-effort */ }
|
||||
_emit('provider-registered', { providerId });
|
||||
if (!wasAvailable) _emit('availability-changed', { available: true });
|
||||
_contributeDiagnostics();
|
||||
return { providerId };
|
||||
}
|
||||
|
||||
function _unregisterProvider(providerId) {
|
||||
providerId = _str(providerId, '');
|
||||
const provider = providers.get(providerId);
|
||||
if (!provider) return false;
|
||||
// Drop the provider's sources + any open sessions.
|
||||
for (const [key, s] of Array.from(sources.entries())) {
|
||||
if (s.providerId === providerId) {
|
||||
_closeSessionInternal(key, 'provider-unregistered');
|
||||
sources.delete(key);
|
||||
}
|
||||
}
|
||||
providers.delete(providerId);
|
||||
if (typeof capabilities.unregisterParticipant === 'function') {
|
||||
try { capabilities.unregisterParticipant(provider.participantId, 'midi-input'); } catch (_) { /* best-effort */ }
|
||||
}
|
||||
_emit('provider-unregistered', { providerId });
|
||||
if (providers.size === 0) _emit('availability-changed', { available: false });
|
||||
_contributeDiagnostics();
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── discovery (the Web-MIDI permission boundary) ────────────────────────
|
||||
async function _discover() {
|
||||
if (providers.size === 0) return _unavailable('No MIDI provider registered', _snapshot());
|
||||
let found = 0;
|
||||
for (const provider of providers.values()) {
|
||||
if (!provider.handlers.enumerate) continue;
|
||||
let list;
|
||||
try { list = await provider.handlers.enumerate(); }
|
||||
catch (e) {
|
||||
// requestMIDIAccess rejection = permission denied / unsupported.
|
||||
return _denied(_str(e && e.message, 'MIDI access denied'), _snapshot());
|
||||
}
|
||||
const fresh = new Set();
|
||||
for (const raw of (Array.isArray(list) ? list : [])) {
|
||||
const sourceId = _str(raw.sourceId || raw.id, '');
|
||||
if (!sourceId) continue;
|
||||
const key = _logicalKey(provider.id, sourceId);
|
||||
fresh.add(key);
|
||||
sources.set(key, {
|
||||
sourceId,
|
||||
providerId: provider.id,
|
||||
logicalSourceKey: key,
|
||||
kind: 'midi',
|
||||
label: _str(raw.label || raw.name, 'MIDI input'),
|
||||
availability: _str(raw.availability, 'available'),
|
||||
});
|
||||
found += 1;
|
||||
}
|
||||
// Reconcile: drop this provider's sources that vanished since the
|
||||
// last enumeration (e.g. a device unplugged, firing statechange).
|
||||
// Without this, list-sources keeps showing disconnected devices and
|
||||
// selecting/opening them later fails on stale state.
|
||||
for (const [key, s] of Array.from(sources.entries())) {
|
||||
if (s.providerId === provider.id && !fresh.has(key)) {
|
||||
// Close any live session but KEEP the selectedKey preference
|
||||
// — the device may be replugged and should re-select.
|
||||
_closeSessionInternal(key, 'device-removed');
|
||||
sources.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Restore a previously-selected source if it reappeared.
|
||||
if (selectedKey && !sources.has(selectedKey)) { /* keep the preference; it may return later */ }
|
||||
_emit('sources-changed', { count: found });
|
||||
_contributeDiagnostics();
|
||||
return _handled(_snapshot({ discovered: found }));
|
||||
}
|
||||
|
||||
function _selectSource(ctx = {}) {
|
||||
const payload = ctx.payload || {};
|
||||
const key = _str(payload.logicalSourceKey, '');
|
||||
if (!key) return _degraded('select-source requires a logicalSourceKey', _snapshot());
|
||||
if (!sources.has(key)) return _degraded(`Unknown MIDI source: ${key}`, _snapshot());
|
||||
selectedKey = key;
|
||||
_writeStorage(key);
|
||||
_emit('source-selected', { logicalSourceKey: key });
|
||||
_contributeDiagnostics();
|
||||
return _handled(_snapshot({ selected: key }));
|
||||
}
|
||||
|
||||
async function _openSource(ctx = {}) {
|
||||
const payload = ctx.payload || {};
|
||||
const requester = _str(ctx.source || ctx.requester || payload.requester, 'unknown');
|
||||
const key = _str(payload.logicalSourceKey, selectedKey || '');
|
||||
if (!key) return _degraded('No MIDI source selected', _snapshot());
|
||||
const source = sources.get(key);
|
||||
if (!source) return _degraded(`Unknown MIDI source: ${key}`, _snapshot());
|
||||
const provider = providers.get(source.providerId);
|
||||
if (!provider || !provider.handlers.open) return _unavailable('Provider cannot open MIDI input', _snapshot());
|
||||
|
||||
// Share one open session per source across requesters.
|
||||
let session = sessions.get(key);
|
||||
if (session) {
|
||||
session.refs.add(requester);
|
||||
return _handled(_snapshot({ sessionId: session.sessionId, shared: true }));
|
||||
}
|
||||
// Coalesce concurrent opens for the same source: if a provider.open() is
|
||||
// already in flight for this key, await it and adopt the resulting session
|
||||
// rather than opening the device a second time.
|
||||
if (opening.has(key)) {
|
||||
try { await opening.get(key); } catch (_) { /* fall through to retry below */ }
|
||||
session = sessions.get(key);
|
||||
if (session) {
|
||||
session.refs.add(requester);
|
||||
return _handled(_snapshot({ sessionId: session.sessionId, shared: true }));
|
||||
}
|
||||
}
|
||||
let handle;
|
||||
const openPromise = provider.handlers.open(source.sourceId, { requester });
|
||||
opening.set(key, openPromise);
|
||||
try { handle = await openPromise; }
|
||||
catch (e) { return _denied(_str(e && e.message, 'Could not open MIDI input'), _snapshot()); }
|
||||
finally { if (opening.get(key) === openPromise) opening.delete(key); }
|
||||
// A concurrent open may have won the race while we awaited; adopt its
|
||||
// session and release our redundant handle so we don't orphan a device.
|
||||
const existing = sessions.get(key);
|
||||
if (existing) {
|
||||
if (provider.handlers.close) {
|
||||
try { provider.handlers.close(source.sourceId, handle); } catch (_) { /* best-effort */ }
|
||||
}
|
||||
existing.refs.add(requester);
|
||||
return _handled(_snapshot({ sessionId: existing.sessionId, shared: true }));
|
||||
}
|
||||
session = { sessionId: `mis-${key}`, refs: new Set([requester]), handle };
|
||||
sessions.set(key, session);
|
||||
_emit('source-opened', { logicalSourceKey: key, requester });
|
||||
_contributeDiagnostics();
|
||||
return _handled(_snapshot({ sessionId: session.sessionId }));
|
||||
}
|
||||
|
||||
function _closeSessionInternal(key, reason) {
|
||||
const session = sessions.get(key);
|
||||
if (!session) return;
|
||||
const source = sources.get(key);
|
||||
const provider = source && providers.get(source.providerId);
|
||||
if (provider && provider.handlers.close) {
|
||||
try { provider.handlers.close(source.sourceId, session.handle); } catch (_) { /* best-effort */ }
|
||||
}
|
||||
sessions.delete(key);
|
||||
_emit('source-closed', { logicalSourceKey: key, reason: reason || 'closed' });
|
||||
}
|
||||
|
||||
function _closeSource(ctx = {}) {
|
||||
const payload = ctx.payload || {};
|
||||
const requester = _str(ctx.source || ctx.requester || payload.requester, 'unknown');
|
||||
const key = _str(payload.logicalSourceKey, selectedKey || '');
|
||||
const session = sessions.get(key);
|
||||
if (!session) return _handled(_snapshot({ closed: key, alreadyClosed: true }));
|
||||
session.refs.delete(requester);
|
||||
if (session.refs.size === 0) {
|
||||
_closeSessionInternal(key, 'released');
|
||||
_contributeDiagnostics();
|
||||
}
|
||||
return _handled(_snapshot({ closed: key }));
|
||||
}
|
||||
|
||||
capabilities.registerOwner('midi-input', {
|
||||
pluginId: 'core.midi-input',
|
||||
kind: 'provider-coordinator',
|
||||
safety: 'sensitive',
|
||||
commands: [
|
||||
'inspect', 'list-sources', 'discover',
|
||||
'select-source', 'open-source', 'close-source',
|
||||
],
|
||||
operations: ['source.enumerate', 'source.describe', 'source.open', 'source.close'],
|
||||
events: [
|
||||
'provider-registered', 'provider-unregistered', 'availability-changed',
|
||||
'sources-changed', 'source-selected', 'source-opened', 'source-closed',
|
||||
],
|
||||
description: 'Core-owned MIDI device control plane: discovery, selection, and shared open/close sessions. `discover` is the Web-MIDI permission boundary.',
|
||||
handlers: {
|
||||
inspect: () => _handled(_snapshot()),
|
||||
'list-sources': () => _handled(_snapshot()), // prompt-free; never requests access
|
||||
discover: (ctx) => _discover(ctx), // permission boundary
|
||||
'select-source': (ctx) => _selectSource(ctx), // prompt-free
|
||||
'open-source': (ctx) => _openSource(ctx),
|
||||
'close-source': (ctx) => _closeSource(ctx),
|
||||
},
|
||||
});
|
||||
|
||||
// ── public global (live surface for in-page consumers) ──────────────────
|
||||
// Providers register live handlers here; consumers (input_setup, piano,
|
||||
// drums) get a live session handle for the "play a note" check.
|
||||
window.slopsmith.midiInput = {
|
||||
version: 1,
|
||||
snapshot: _snapshot,
|
||||
listSources: () => _sourceListShape(),
|
||||
getSelected: () => selectedKey,
|
||||
registerProvider: _registerProvider,
|
||||
unregisterProvider: _unregisterProvider,
|
||||
discover: () => _discover(),
|
||||
select: (logicalSourceKey) => _selectSource({ payload: { logicalSourceKey } }),
|
||||
// Returns { outcome, sessionId, handle } where handle is the provider's
|
||||
// live MIDI input wrapper (exposes addListener/removeListener). The live
|
||||
// handle is surfaced ONLY through this in-page global, never through the
|
||||
// serializable `open-source` command payload. Use for calibration
|
||||
// note/pad checks.
|
||||
open: async (opts = {}) => {
|
||||
const result = await _openSource({ source: opts.requester || 'in-page', payload: opts });
|
||||
const key = _str(opts.logicalSourceKey, selectedKey || '');
|
||||
const session = sessions.get(key);
|
||||
return { ...result, handle: session ? session.handle : null, sessionId: session ? session.sessionId : null };
|
||||
},
|
||||
close: (opts = {}) => _closeSource({ source: opts.requester || 'in-page', payload: opts }),
|
||||
};
|
||||
|
||||
// ── built-in Web-MIDI provider ──────────────────────────────────────────
|
||||
// Ship a default Web-MIDI source provider so every consumer (piano, drums,
|
||||
// input_setup, …) gets MIDI devices from the domain without any one plugin
|
||||
// having to register the provider. Guarded by Web-MIDI support;
|
||||
// requestMIDIAccess() is the permission boundary, called lazily on discover.
|
||||
(function _registerBuiltinWebMidiProvider() {
|
||||
if (typeof navigator === 'undefined' || typeof navigator.requestMIDIAccess !== 'function') return;
|
||||
const BLOCK = /(midi through|thru|iac)/i; // loopback / passthrough ports
|
||||
let access = null;
|
||||
_registerProvider({
|
||||
providerId: 'web-midi',
|
||||
label: 'Web MIDI',
|
||||
// Distinct from the domain owner's participant id ('core.midi-input').
|
||||
// unregisterProvider() unregisters the provider's participant, so
|
||||
// sharing the owner's id would tear the whole domain's owner down on
|
||||
// a provider swap/hot-reload, leaving midi-input with no owner.
|
||||
participantId: 'core.midi-input.web-midi',
|
||||
enumerate: async () => {
|
||||
access = await navigator.requestMIDIAccess({ sysex: false });
|
||||
try { access.onstatechange = () => { _discover(); }; } catch (_) { /* best-effort */ }
|
||||
const out = [];
|
||||
access.inputs.forEach((input) => {
|
||||
if (BLOCK.test(input.name || '')) return;
|
||||
out.push({ sourceId: input.id, label: input.name || 'MIDI input', availability: 'available' });
|
||||
});
|
||||
return out;
|
||||
},
|
||||
open: async (sourceId) => {
|
||||
if (!access) access = await navigator.requestMIDIAccess({ sysex: false });
|
||||
const input = access.inputs.get(sourceId);
|
||||
if (!input) throw new Error('MIDI input not found');
|
||||
const listeners = new Set();
|
||||
input.onmidimessage = (e) => { listeners.forEach((fn) => { try { fn(e.data); } catch (_) { /* listener isolation */ } }); };
|
||||
return {
|
||||
addListener: (fn) => { if (typeof fn === 'function') listeners.add(fn); },
|
||||
removeListener: (fn) => listeners.delete(fn),
|
||||
_input: input,
|
||||
};
|
||||
},
|
||||
close: (sourceId, handle) => {
|
||||
if (handle && handle._input) { try { handle._input.onmidimessage = null; } catch (_) { /* best-effort */ } }
|
||||
},
|
||||
});
|
||||
})();
|
||||
|
||||
_contributeDiagnostics();
|
||||
})();
|
||||
+84
-25
@@ -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
|
||||
|
||||
+2
-1
@@ -31,6 +31,7 @@
|
||||
<script src="/static/capabilities/library-card-actions.js"></script>
|
||||
<script src="/static/capabilities/visualization.js"></script>
|
||||
<script src="/static/capabilities/note-detection.js"></script>
|
||||
<script src="/static/capabilities/midi-input.js"></script>
|
||||
</head>
|
||||
<body class="bg-dark-900 text-gray-200 font-display">
|
||||
|
||||
@@ -70,7 +71,7 @@
|
||||
|
||||
<!-- Hidden file input shared by the navbar "Upload" link. Kept at body
|
||||
level so it stays reachable regardless of which screen is active. -->
|
||||
<input type="file" id="upload-songs-file" accept=".sloppak" multiple class="hidden" onchange="uploadSongs(this.files); this.value=''">
|
||||
<input type="file" id="upload-songs-file" accept=".feedpak,.sloppak" multiple class="hidden" onchange="uploadSongs(this.files); this.value=''">
|
||||
|
||||
<!-- ══ HOME (Hero + Library) ══════════════════════════════════════════ -->
|
||||
<div id="home" class="screen active">
|
||||
|
||||
+115
-32
@@ -1,7 +1,9 @@
|
||||
/* ── Consolidated tour menu — one floating button + popover for all tours ── */
|
||||
/* Palette aligned with the app's dark theme: #4080e0 accent, #e8c040 gold,
|
||||
#181830 dark background, #cbd5e1 / #94a3b8 / #64748b text scale. Per
|
||||
CLAUDE.md → Frontend Conventions. */
|
||||
/* Palette aligned with the v3 fee[dB]ack design tokens (tailwind.config.js):
|
||||
fb-card #1e293b surfaces, fb-cardMuted #0b1220 wells, fb-primary #0ea5e9
|
||||
accent, fb-border #334155 hairlines, fb-text #f8fafc / fb-textDim #94a3b8
|
||||
text, fb-gold #e8c040 badge. Plain CSS (no Tailwind classes) so it needs no
|
||||
stylesheet rebuild. Per CLAUDE.md → Frontend Conventions. */
|
||||
|
||||
.slopsmith-tour-menu-btn {
|
||||
position: fixed;
|
||||
@@ -14,20 +16,20 @@
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: #181830;
|
||||
border: 1.5px solid #4080e0;
|
||||
color: #cbd5e1;
|
||||
background: #1e293b;
|
||||
border: 1.5px solid #0ea5e9;
|
||||
color: #f8fafc;
|
||||
font-size: 15px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 0 6px #4080e066;
|
||||
box-shadow: 0 0 6px #0ea5e966;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: box-shadow 0.2s, transform 0.15s;
|
||||
}
|
||||
.slopsmith-tour-menu-btn:hover {
|
||||
box-shadow: 0 0 12px #4080e0aa;
|
||||
box-shadow: 0 0 12px #0ea5e9aa;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.slopsmith-tour-menu-btn.has-unseen {
|
||||
@@ -41,12 +43,12 @@
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #e8c040;
|
||||
border: 2px solid #181830;
|
||||
border: 2px solid #1e293b;
|
||||
border-radius: 50%;
|
||||
}
|
||||
@keyframes tour-pulse {
|
||||
0%, 100% { box-shadow: 0 0 6px #4080e066; }
|
||||
50% { box-shadow: 0 0 16px #4080e0cc; }
|
||||
0%, 100% { box-shadow: 0 0 6px #0ea5e966; }
|
||||
50% { box-shadow: 0 0 16px #0ea5e9cc; }
|
||||
}
|
||||
|
||||
.slopsmith-tour-menu-popover {
|
||||
@@ -62,12 +64,12 @@
|
||||
room for the trigger button + its bottom inset. */
|
||||
max-height: calc(100vh - 80px);
|
||||
overflow-y: auto;
|
||||
background: #181830;
|
||||
border: 1px solid #4080e044;
|
||||
background: #1e293b;
|
||||
border: 1px solid #0ea5e944;
|
||||
border-radius: 8px;
|
||||
padding: 6px;
|
||||
font-size: 13px;
|
||||
color: #cbd5e1;
|
||||
color: #f8fafc;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.slopsmith-tour-menu-popover .tour-menu-header {
|
||||
@@ -75,13 +77,13 @@
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #64748b;
|
||||
border-bottom: 1px solid #1e1e3a;
|
||||
color: #94a3b8;
|
||||
border-bottom: 1px solid #334155;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.slopsmith-tour-menu-popover .tour-menu-empty {
|
||||
padding: 10px;
|
||||
color: #64748b;
|
||||
color: #94a3b8;
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -95,23 +97,23 @@
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: #cbd5e1;
|
||||
color: #f8fafc;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.slopsmith-tour-menu-popover .tour-menu-item:hover {
|
||||
background: #1a1a30;
|
||||
color: #fff;
|
||||
background: #334155;
|
||||
color: #f8fafc;
|
||||
}
|
||||
/* Keyboard focus gets an explicit ring instead of relying on the hover
|
||||
background — matches the :focus-visible treatment elsewhere in the
|
||||
app (style.css). Pointer focus is left alone. */
|
||||
.slopsmith-tour-menu-popover .tour-menu-item:focus-visible {
|
||||
background: #1a1a30;
|
||||
color: #fff;
|
||||
outline: 2px solid #4080e0;
|
||||
background: #334155;
|
||||
color: #f8fafc;
|
||||
outline: 2px solid #0ea5e9;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.slopsmith-tour-menu-popover .tour-menu-item-label {
|
||||
@@ -127,11 +129,11 @@
|
||||
}
|
||||
.slopsmith-tour-menu-popover .tour-menu-item-status.is-new {
|
||||
background: #e8c040;
|
||||
color: #181830;
|
||||
color: #0f172a;
|
||||
}
|
||||
.slopsmith-tour-menu-popover .tour-menu-item-status.is-seen {
|
||||
background: #1e1e3a;
|
||||
color: #64748b;
|
||||
background: #0b1220;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* ── First-visit toast — anchored above the menu button ── */
|
||||
@@ -141,12 +143,12 @@
|
||||
bottom: 56px;
|
||||
right: 12px;
|
||||
z-index: 202;
|
||||
background: #181830;
|
||||
border: 1px solid #4080e044;
|
||||
background: #1e293b;
|
||||
border: 1px solid #0ea5e944;
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
color: #cbd5e1;
|
||||
color: #f8fafc;
|
||||
max-width: 240px;
|
||||
line-height: 1.4;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
|
||||
@@ -159,7 +161,7 @@
|
||||
.slopsmith-tour-prompt .tour-prompt-more {
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.slopsmith-tour-prompt .tour-prompt-buttons {
|
||||
display: flex;
|
||||
@@ -178,11 +180,92 @@
|
||||
opacity: 0.85;
|
||||
}
|
||||
.slopsmith-tour-prompt button[data-action="start"] {
|
||||
background: #4080e0;
|
||||
background: #0ea5e9;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.slopsmith-tour-prompt button[data-action="dismiss"] {
|
||||
background: #1e1e3a;
|
||||
background: #334155;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* ── Shepherd bubble theme — override the vendored light default ──────────── */
|
||||
/* The vendored static/vendor/shepherd.css ships Shepherd's stock LIGHT theme
|
||||
(white bubble, black text, blue buttons), which clashes with the dark v3 UI.
|
||||
These overrides load after it (index.html order) and recolor the spotlight
|
||||
bubbles to the fb-* tokens. The vendored file is left untouched so it stays
|
||||
upgradable. */
|
||||
.shepherd-element {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.55);
|
||||
max-width: 360px;
|
||||
}
|
||||
.shepherd-content {
|
||||
background: #1e293b;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.shepherd-text {
|
||||
color: #cbd5e1;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.45;
|
||||
padding: 0.85em 0.9em;
|
||||
}
|
||||
.shepherd-title {
|
||||
color: #f8fafc;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
/* Title row: drop the light-grey header fill the stock theme paints behind a
|
||||
titled step, so the header blends into the card. */
|
||||
.shepherd-has-title .shepherd-content .shepherd-header {
|
||||
background: transparent;
|
||||
padding: 0.85em 0.9em 0;
|
||||
}
|
||||
/* Arrow must match the bubble surface (stock paints it white, and grey behind
|
||||
a bottom-placed titled step). */
|
||||
.shepherd-arrow:before {
|
||||
background: #1e293b;
|
||||
}
|
||||
.shepherd-element.shepherd-has-title[data-popper-placement^="bottom"] > .shepherd-arrow:before {
|
||||
background-color: #1e293b;
|
||||
}
|
||||
.shepherd-footer {
|
||||
padding: 0 0.75rem 0.75rem;
|
||||
}
|
||||
/* Primary button (Next / Done) → fb-primary. */
|
||||
.shepherd-button {
|
||||
background: #0ea5e9;
|
||||
color: #fff;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
padding: 0.4rem 1.1rem;
|
||||
}
|
||||
.shepherd-button:not(:disabled):hover {
|
||||
background: #38bdf8;
|
||||
color: #fff;
|
||||
}
|
||||
/* Secondary button (Back / Skip) → muted slate. */
|
||||
.shepherd-button.shepherd-button-secondary {
|
||||
background: #334155;
|
||||
color: #f8fafc;
|
||||
}
|
||||
.shepherd-button.shepherd-button-secondary:not(:disabled):hover {
|
||||
background: #475569;
|
||||
color: #f8fafc;
|
||||
}
|
||||
.shepherd-cancel-icon {
|
||||
color: #94a3b8;
|
||||
}
|
||||
.shepherd-cancel-icon:hover,
|
||||
.shepherd-has-title .shepherd-content .shepherd-cancel-icon:hover {
|
||||
color: #f8fafc;
|
||||
}
|
||||
.shepherd-has-title .shepherd-content .shepherd-cancel-icon {
|
||||
color: #94a3b8;
|
||||
}
|
||||
/* Dim the page a touch more, matching the onboarding overlay (bg-black/60). */
|
||||
.shepherd-modal-overlay-container.shepherd-modal-is-visible {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
+24
-1
@@ -127,7 +127,13 @@
|
||||
}
|
||||
|
||||
function _unseenRelevant(screenId) {
|
||||
return _relevantPlugins(screenId).filter(p => !hasSeen(p.id) && !hasDismissed(p.id));
|
||||
// Drives the toast prompt + button "has-unseen" pulse. A tour registered
|
||||
// with autoPrompt:false is excluded — it's started programmatically by
|
||||
// its owner (e.g. the first-run home tour), so nagging via toast/pulse
|
||||
// would double up. It still lists in the menu and runs on demand.
|
||||
return _relevantPlugins(screenId).filter(p =>
|
||||
!hasSeen(p.id) && !hasDismissed(p.id) &&
|
||||
(_registry[p.id] ? _registry[p.id].autoPrompt !== false : true));
|
||||
}
|
||||
|
||||
// ── Menu UI ────────────────────────────────────────────────────────────
|
||||
@@ -615,7 +621,24 @@
|
||||
onStart: opts.onStart || null,
|
||||
onComplete: opts.onComplete || null,
|
||||
screens: Array.isArray(opts.screens) ? opts.screens.slice() : null,
|
||||
// autoPrompt:false opts the tour OUT of the unseen toast + button
|
||||
// pulse (it's driven programmatically by its owner, e.g. the
|
||||
// first-run home tour started from onboarding). It still lists in the
|
||||
// menu and runs via start(). Defaults to the legacy always-prompt.
|
||||
autoPrompt: opts.autoPrompt !== false,
|
||||
};
|
||||
// A `name` registers a CLIENT/CORE-owned tour — one that isn't a
|
||||
// server-discovered plugin with a tour.json — into the consolidated menu
|
||||
// catalog so it appears in the "?" menu. Never clobber a richer entry the
|
||||
// /api/plugins pass already supplied for a real plugin of the same id.
|
||||
if (opts.name && !_tourPlugins[pluginId]) {
|
||||
_tourPlugins[pluginId] = {
|
||||
id: pluginId,
|
||||
name: opts.name,
|
||||
has_screen: true,
|
||||
is_viz: false,
|
||||
};
|
||||
}
|
||||
// If the override changes the relevance for the current screen, refresh.
|
||||
_updateMenuVisibility();
|
||||
}
|
||||
|
||||
+1
-1
@@ -291,7 +291,7 @@
|
||||
const host = document.getElementById('v3-badge-instrument');
|
||||
if (!host) return;
|
||||
host.innerHTML =
|
||||
'<div class="relative">' +
|
||||
'<div id="v3-instrument-wrap" class="relative">' +
|
||||
'<button type="button" data-inst-toggle title="Instrument: ' + esc(settings.string_count + '-str ' + tuningLabel()) + '" ' +
|
||||
'class="bg-fb-card border border-fb-border/50 rounded-2xl h-[92px] w-16 flex flex-col items-center justify-center gap-2 hover:ring-1 hover:ring-fb-primary/40 transition">' +
|
||||
guitarIcon +
|
||||
|
||||
@@ -134,7 +134,7 @@
|
||||
const bars = Array.from({ length: segs }, (_, i) =>
|
||||
'<span class="flex-1 h-1.5 rounded-full ' + (i < filled ? 'bg-fb-primary' : 'bg-gray-500/40') + '"></span>').join('');
|
||||
continueCard =
|
||||
'<button id="v3-continue" class="group relative text-left rounded-xl overflow-hidden border border-fb-border/50 bg-fb-card aspect-square self-start flex flex-col justify-end">' +
|
||||
'<button id="v3-continue" data-tour="continue" class="group relative text-left rounded-xl overflow-hidden border border-fb-border/50 bg-fb-card aspect-square self-start flex flex-col justify-end">' +
|
||||
songArt(cont.art_url, 'absolute inset-0 w-full h-full object-cover opacity-60 group-hover:opacity-70 transition') +
|
||||
'<div class="absolute inset-0 bg-gradient-to-t from-black/90 via-black/40 to-transparent"></div>' +
|
||||
tuningChip(cont.tuning_name, 'absolute top-3 right-3') +
|
||||
@@ -146,7 +146,7 @@
|
||||
'<span class="absolute top-3 left-3 text-fb-text/80 group-hover:text-fb-text">▶</span></button>';
|
||||
} else if (pick) {
|
||||
continueCard =
|
||||
'<button id="v3-pick" data-fn="' + esc(pick.filename) + '" class="group relative text-left rounded-xl overflow-hidden border border-fb-border/50 bg-fb-card aspect-square self-start flex flex-col justify-end">' +
|
||||
'<button id="v3-pick" data-tour="continue" data-fn="' + esc(pick.filename) + '" class="group relative text-left rounded-xl overflow-hidden border border-fb-border/50 bg-fb-card aspect-square self-start flex flex-col justify-end">' +
|
||||
songArt(libArtUrl(pick), 'absolute inset-0 w-full h-full object-cover opacity-60 group-hover:opacity-70 transition') +
|
||||
'<div class="absolute inset-0 bg-gradient-to-t from-black/90 via-black/40 to-transparent"></div>' +
|
||||
tuningChip(pick.tuning_name, 'absolute top-3 right-3') +
|
||||
@@ -157,7 +157,7 @@
|
||||
'<span class="absolute top-3 left-3 text-fb-text/80 group-hover:text-fb-text">▶</span></button>';
|
||||
} else {
|
||||
continueCard =
|
||||
'<div class="rounded-xl border border-fb-border/50 bg-fb-card/60 aspect-square self-start flex flex-col items-center justify-center text-center p-4">' +
|
||||
'<div data-tour="continue" class="rounded-xl border border-fb-border/50 bg-fb-card/60 aspect-square self-start flex flex-col items-center justify-center text-center p-4">' +
|
||||
'<div class="text-fb-textDim text-sm mb-3">Pick a song to get started</div>' +
|
||||
'<button id="v3-continue-pick" class="bg-fb-card hover:bg-fb-card/70 border border-fb-border/50 text-fb-text text-sm px-4 py-2 rounded-md">Browse library</button></div>';
|
||||
}
|
||||
@@ -188,7 +188,7 @@
|
||||
'<a href="' + esc(changelogUrl) + '" target="_blank" rel="noopener" class="text-fb-primary hover:text-fb-primaryHi">Patch Notes for ' + esc(ver) + '</a>?</p>' : '') +
|
||||
// Featured grid: hero + continue
|
||||
'<div class="grid lg:grid-cols-3 gap-6 mt-6">' +
|
||||
'<div class="lg:col-span-2 relative rounded-xl overflow-hidden min-h-[480px] flex items-center bg-fb-bg">' +
|
||||
'<div id="v3-hero" class="lg:col-span-2 relative rounded-xl overflow-hidden min-h-[480px] flex items-center bg-fb-bg">' +
|
||||
// Hero artwork (neon note-highway), right-anchored. Placeholder
|
||||
// cropped from the design mock — swap static/v3/brand/hero.png for
|
||||
// the designer's high-res original (same path) when available.
|
||||
|
||||
@@ -94,6 +94,7 @@
|
||||
<script src="/static/capabilities/library-card-actions.js"></script>
|
||||
<script src="/static/capabilities/visualization.js"></script>
|
||||
<script src="/static/capabilities/note-detection.js"></script>
|
||||
<script src="/static/capabilities/midi-input.js"></script>
|
||||
</head>
|
||||
<body class="h-screen flex overflow-hidden bg-fb-sidebar text-fb-text font-display">
|
||||
|
||||
@@ -707,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">
|
||||
@@ -857,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
|
||||
@@ -874,6 +884,10 @@
|
||||
<script src="/static/v3/songs.js"></script>
|
||||
<script src="/static/v3/lessons.js"></script>
|
||||
<script src="/static/v3/dashboard.js"></script>
|
||||
<!-- First-run home tour: spotlights the home cards via the shared tour
|
||||
engine (tour-engine.js, loaded above). Auto-runs once after onboarding
|
||||
(triggered from profile.js finish()); replayable from the "?" menu. -->
|
||||
<script src="/static/v3/onboarding-tour.js"></script>
|
||||
<script src="/static/v3/feedbarcade.js"></script>
|
||||
<script src="/static/v3/player-chrome.js"></script>
|
||||
<script>
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* fee[dB]ack v0.3.0 — first-run home tour.
|
||||
*
|
||||
* Registers a spotlight tour over the home-page cards with the shared tour
|
||||
* engine (window.slopsmithTour / Shepherd) and auto-runs it once, the first
|
||||
* time the user lands on the home page after completing onboarding. It stays
|
||||
* replayable forever from the per-screen "?" tour menu (registered with
|
||||
* screens: ['v3-home']).
|
||||
*
|
||||
* Anchors (all stable, on-screen while #v3-home is active):
|
||||
* #v3-hero hero / Start Playing (dashboard.js)
|
||||
* [data-tour="continue"] continue / pick a song (dashboard.js, 3 variants)
|
||||
* #v3-instrument-wrap instrument selector badge (badges.js, topbar)
|
||||
* #v3-tuner-wrap tuner badge (badges.js, topbar)
|
||||
* #v3-audio-routing audio routing card (dashboard.js)
|
||||
* [data-v3-open-profile] profile badge (profile.js, topbar)
|
||||
* #v3-nav left sidebar navigation (index.html)
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var TOUR_ID = 'home-onboarding';
|
||||
|
||||
// Each step dims the page and spotlights one element (shape: 'spotlight').
|
||||
// waitFor blocks the step until its target exists, so the async dashboard
|
||||
// re-render kicked off by 'v3:profile-updated' can't race the first step.
|
||||
function buildSteps() {
|
||||
return [
|
||||
{
|
||||
id: 'hero', shape: 'spotlight', position: 'bottom',
|
||||
selector: '#v3-hero', waitFor: '#v3-hero',
|
||||
title: 'Welcome to fee[dB]ack',
|
||||
content: 'This is your home base. Hit Start Playing to drop straight into a song from your library.',
|
||||
},
|
||||
{
|
||||
id: 'continue', shape: 'spotlight', position: 'left',
|
||||
selector: '[data-tour="continue"]', waitFor: '[data-tour="continue"]',
|
||||
title: 'Pick up where you left off',
|
||||
content: 'Your last song resumes right here in one click. Before you’ve played anything, it’s a quick random pick to get you going.',
|
||||
},
|
||||
{
|
||||
id: 'instrument', shape: 'spotlight', position: 'bottom',
|
||||
selector: '#v3-instrument-wrap', waitFor: '#v3-instrument-wrap',
|
||||
title: 'Choose your instrument',
|
||||
content: 'Set your instrument, string count and tuning here. The highway, tuner and scoring all adapt to this selection.',
|
||||
},
|
||||
{
|
||||
id: 'tuner', shape: 'spotlight', position: 'bottom',
|
||||
selector: '#v3-tuner-wrap', waitFor: '#v3-tuner-wrap',
|
||||
title: 'Tune up first',
|
||||
content: 'Open the tuner and match each string until the meter centers — accurate tuning means accurate scoring.',
|
||||
},
|
||||
{
|
||||
id: 'audio', shape: 'spotlight', position: 'top',
|
||||
selector: '#v3-audio-routing', waitFor: '#v3-audio-routing',
|
||||
title: 'Your signal path',
|
||||
content: 'Input → amp / NAM / IR → output, at a glance. Set up and monitor your gear from this card.',
|
||||
},
|
||||
{
|
||||
id: 'profile', shape: 'spotlight', position: 'bottom',
|
||||
selector: '[data-v3-open-profile]', waitFor: '[data-v3-open-profile]',
|
||||
title: 'Track your progress',
|
||||
content: 'Your profile, avatar and rank live here. Watch your accuracy climb and level up as you play.',
|
||||
},
|
||||
{
|
||||
id: 'nav', shape: 'spotlight', position: 'right',
|
||||
selector: '#v3-nav', waitFor: '#v3-nav',
|
||||
title: 'Find everything here',
|
||||
content: 'Browse your full library, lessons and plugins anytime. You can replay this tour from the ? button in the corner.',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function register() {
|
||||
var t = window.slopsmithTour;
|
||||
if (!t || typeof t.register !== 'function') return false;
|
||||
t.register(TOUR_ID, {
|
||||
name: 'Welcome tour', // label in the "?" tour menu
|
||||
screens: ['v3-home'], // relevant only on the home screen
|
||||
autoPrompt: false, // we auto-run it from onboarding; no toast nag
|
||||
buildSteps: buildSteps,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// Auto-run once after a genuine onboarding completion. profile.js gates the
|
||||
// call on !editing (a profile edit must not relaunch it); we additionally
|
||||
// honour the engine's own seen/dismissed state so it never repeats and a
|
||||
// dismissal isn't nagged. Stays replayable from the "?" menu either way.
|
||||
function startFirstRun() {
|
||||
var t = window.slopsmithTour;
|
||||
if (!t || typeof t.start !== 'function') return;
|
||||
try {
|
||||
if (t.hasSeen(TOUR_ID) || t.hasDismissed(TOUR_ID)) return;
|
||||
} catch (e) { /* private mode — fall through and attempt once */ }
|
||||
// Make sure the home screen is in view so the spotlight targets exist;
|
||||
// the per-step waitFor handles the async dashboard render.
|
||||
if (typeof window.showScreen === 'function') {
|
||||
try { window.showScreen('v3-home'); } catch (e) { /* best-effort */ }
|
||||
}
|
||||
// Defer a frame so the 'v3:profile-updated' dashboard re-render has a
|
||||
// chance to begin before Shepherd starts polling for the first target.
|
||||
var raf = window.requestAnimationFrame || function (fn) { return setTimeout(fn, 16); };
|
||||
raf(function () { try { t.start(TOUR_ID); } catch (e) { /* degrade */ } });
|
||||
}
|
||||
|
||||
window.v3OnboardingTour = { startFirstRun: startFirstRun };
|
||||
|
||||
// tour-engine.js assigns window.slopsmithTour at script-eval time, so if it
|
||||
// is loaded before us register() succeeds immediately; otherwise retry once
|
||||
// the DOM (and the engine) are ready.
|
||||
if (!register()) {
|
||||
document.addEventListener('DOMContentLoaded', register, { once: true });
|
||||
}
|
||||
})();
|
||||
+155
-18
@@ -153,6 +153,55 @@
|
||||
// diagnostic sloppak at 100% — or skip and reach Mastery Rank 1 anyway).
|
||||
// The profile POST always lands before the step-3 choice so onboarded=1 is
|
||||
// never blocked by the calibration decision. Editing keeps the single form.
|
||||
// Run the input-device setup wizard (the input_setup plugin's
|
||||
// `input-calibration` domain) for the chosen instrument paths — BETWEEN path
|
||||
// selection and the calibration challenge — so the diagnostic runs against a
|
||||
// calibrated input. Capability-idiomatic: dispatch `run` (fire-and-launch)
|
||||
// and await the `calibration-done` event. Degrades gracefully when the
|
||||
// plugin/runtime is absent so onboarding can never be stranded.
|
||||
// Wait (bounded) for the bundled input_setup plugin to finish registering
|
||||
// its input-calibration owner. Plugins load asynchronously, so onboarding
|
||||
// can reach this step before the plugin is ready — without this wait the
|
||||
// mandatory wizard is skipped by a load-order race (it dispatches, gets a
|
||||
// no-owner outcome, and falls through to the calibration challenge). The
|
||||
// public global is set at the end of the plugin's screen.js, after the
|
||||
// owner is registered, so it is a reliable readiness signal.
|
||||
function waitForInputSetup(timeoutMs) {
|
||||
const ready = () => !!(window.slopsmithInputSetup && typeof window.slopsmithInputSetup.launch === 'function');
|
||||
return new Promise((resolve) => {
|
||||
if (ready()) { resolve(true); return; }
|
||||
const t0 = Date.now();
|
||||
const iv = setInterval(() => {
|
||||
if (ready()) { clearInterval(iv); resolve(true); }
|
||||
else if (Date.now() - t0 >= timeoutMs) { clearInterval(iv); resolve(false); }
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
async function runInputSetup(paths) {
|
||||
const instruments = (Array.isArray(paths) ? paths : []).map((p) => String(p).toLowerCase());
|
||||
if (!instruments.length) return;
|
||||
// Don't let a plugin-load race skip the mandatory input-setup step.
|
||||
if (!(await waitForInputSetup(8000))) return;
|
||||
const caps = window.slopsmith && window.slopsmith.capabilities;
|
||||
if (!caps || typeof caps.command !== 'function') {
|
||||
try { await window.slopsmithInputSetup.launch(instruments); } catch (e) { /* proceed */ }
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
let settled = false;
|
||||
let unsub = null;
|
||||
const done = () => { if (settled) return; settled = true; try { unsub && unsub(); } catch (e) { /* noop */ } resolve(); };
|
||||
try { unsub = typeof caps.subscribe === 'function' ? caps.subscribe('input-calibration:calibration-done', done) : null; } catch (e) { unsub = null; }
|
||||
// `run` is fire-and-launch; completion arrives via the event above.
|
||||
// A non-handled outcome (no owner / plugin absent / error) means
|
||||
// nothing was launched, so proceed immediately.
|
||||
caps.command('input-calibration', 'run', { requester: 'onboarding', payload: { instruments } })
|
||||
.then((r) => { if (!r || r.outcome !== 'handled') done(); })
|
||||
.catch(() => done());
|
||||
});
|
||||
}
|
||||
|
||||
function show(profile, opts) {
|
||||
opts = opts || {};
|
||||
const editing = !!opts.editing;
|
||||
@@ -160,7 +209,7 @@
|
||||
|
||||
const stepDots = editing ? '' :
|
||||
'<div class="flex justify-center gap-1.5 mt-3" id="v3-ob-dots">' +
|
||||
[1, 2, 3].map((n) => '<span data-dot="' + n + '" class="w-2 h-2 rounded-full bg-fb-border"></span>').join('') +
|
||||
[1, 2, 3, 4].map((n) => '<span data-dot="' + n + '" class="w-2 h-2 rounded-full bg-fb-border"></span>').join('') +
|
||||
'</div>';
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
@@ -184,15 +233,24 @@
|
||||
'<button type="button" id="v3-ob-upload-btn" class="text-sm text-fb-primary hover:text-fb-primaryHi">Upload your own</button>' +
|
||||
'<input type="file" id="v3-ob-upload" accept="image/*" class="hidden">' +
|
||||
'<span id="v3-ob-preview"></span></div></div></div>' +
|
||||
// Step 2 — instrument paths (first-run only; tiles filled on entry).
|
||||
// Step 2 — song directory (where the user's songs live).
|
||||
'<div id="v3-ob-step2" class="hidden">' +
|
||||
'<label class="block text-xs uppercase tracking-wider text-fb-textDim mb-2">Song directory</label>' +
|
||||
'<p class="text-sm text-fb-textDim mb-3">Choose the folder where your songs are stored. We’ll scan it to build your library. You can change this later in Settings.</p>' +
|
||||
'<div class="flex gap-2">' +
|
||||
'<input id="v3-ob-songdir" type="text" placeholder="Path to your songs folder" ' +
|
||||
'class="flex-1 bg-gray-800/50 border border-gray-700 rounded-md px-3 py-2 text-sm text-fb-text outline-none focus:border-fb-primary focus:ring-1 focus:ring-fb-primary">' +
|
||||
'<button type="button" id="v3-ob-songdir-browse" class="hidden px-3 py-2 rounded-md text-sm bg-gray-800/50 border border-gray-700 text-fb-text hover:border-fb-primary whitespace-nowrap">Browse…</button>' +
|
||||
'</div></div>' +
|
||||
// Step 3 — instrument paths (first-run only; tiles filled on entry).
|
||||
'<div id="v3-ob-step3" class="hidden">' +
|
||||
'<label class="block text-xs uppercase tracking-wider text-fb-textDim mb-2">Pick your instrument path(s)</label>' +
|
||||
'<p class="text-sm text-fb-textDim mb-3">Each path levels up by completing challenges — together they make up your Mastery Rank. You can add more later.</p>' +
|
||||
'<div id="v3-ob-paths" class="grid grid-cols-3 gap-2"></div></div>' +
|
||||
// Step 3 — calibration offer (first-run only).
|
||||
'<div id="v3-ob-step3" class="hidden">' +
|
||||
// Step 4 — calibration offer (first-run only).
|
||||
'<div id="v3-ob-step4" class="hidden">' +
|
||||
'<label class="block text-xs uppercase tracking-wider text-fb-textDim mb-2">Calibration challenge</label>' +
|
||||
'<p class="text-sm text-fb-textDim">Prove your setup: play the <span class="text-fb-text">Slopsmith Diagnostic</span> with note detection and finish at <span class="text-fb-text font-semibold">100% accuracy</span> to reach <span class="text-fb-text font-semibold">Mastery Rank 1</span>.</p>' +
|
||||
'<p class="text-sm text-fb-textDim">Prove your setup: play the <span class="text-fb-text">fee[dB]ack Diagnostic</span> with note detection and finish at <span class="text-fb-text font-semibold">100% accuracy</span> to reach <span class="text-fb-text font-semibold">Mastery Rank 1</span>.</p>' +
|
||||
'<p class="text-sm text-fb-textDim mt-2">Not ready? Skip it and you’ll start at Rank 1 anyway — you can still play it later from the Progress screen.</p></div>' +
|
||||
'<p id="v3-ob-error" class="text-sm text-fb-accent hidden"></p>' +
|
||||
'<div class="flex justify-end gap-3">' +
|
||||
@@ -211,9 +269,12 @@
|
||||
const skipBtn = overlay.querySelector('#v3-ob-skip');
|
||||
let selected = null; // { type:'default', value } | { type:'upload', value:url }
|
||||
let step = 1; // first-run wizard step (editing stays on 1)
|
||||
let selectedPaths = []; // step-2 picks
|
||||
let songDir = ''; // step-2 song directory pick
|
||||
let selectedPaths = []; // step-3 picks
|
||||
let pathsAvailable = false; // any tiles rendered? (false → don't strand the user)
|
||||
let diagnosticFilename = null; // from /api/progression (step-3 "Play it now")
|
||||
let diagnosticFilename = null; // from /api/progression (step-4 "Play it now")
|
||||
const songDirEl = overlay.querySelector('#v3-ob-songdir');
|
||||
const songDirBrowse = overlay.querySelector('#v3-ob-songdir-browse');
|
||||
|
||||
if (editing && profile) {
|
||||
nameEl.value = profile.display_name || '';
|
||||
@@ -229,6 +290,10 @@
|
||||
const haveAvatar = !!selected || (editing && profile && !!profile.avatar_url);
|
||||
submit.disabled = !(nameEl.value.trim().length >= 1 && haveAvatar);
|
||||
} else if (step === 2) {
|
||||
// Song directory — require a non-empty path to proceed; "Skip
|
||||
// for now" is available for users who'll set it later.
|
||||
submit.disabled = !songDir.trim();
|
||||
} else if (step === 3) {
|
||||
// ≥1 path required — unless none could be offered (offline /
|
||||
// empty content), where blocking would strand onboarding.
|
||||
submit.disabled = pathsAvailable && selectedPaths.length < 1;
|
||||
@@ -240,7 +305,7 @@
|
||||
function setStep(n) {
|
||||
step = n;
|
||||
errEl.classList.add('hidden');
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
overlay.querySelector('#v3-ob-step' + i).classList.toggle('hidden', i !== n);
|
||||
}
|
||||
overlay.querySelectorAll('#v3-ob-dots [data-dot]').forEach((d) => {
|
||||
@@ -250,11 +315,14 @@
|
||||
const subtitle = overlay.querySelector('#v3-ob-subtitle');
|
||||
if (subtitle) {
|
||||
subtitle.textContent = n === 1 ? 'Set up your player profile'
|
||||
: n === 2 ? 'Choose your instrument paths'
|
||||
: n === 2 ? 'Point us at your songs'
|
||||
: n === 3 ? 'Choose your instrument paths'
|
||||
: 'One last thing — calibrate your setup';
|
||||
}
|
||||
submit.textContent = n === 3 ? 'Play it now' : 'Next';
|
||||
skipBtn.classList.toggle('hidden', n !== 3);
|
||||
submit.textContent = n === 4 ? 'Play it now' : 'Next';
|
||||
// Skip is offered on the song-directory step (configure later) and
|
||||
// the calibration challenge.
|
||||
skipBtn.classList.toggle('hidden', !(n === 2 || n === 4));
|
||||
refreshSubmit();
|
||||
}
|
||||
|
||||
@@ -347,6 +415,43 @@
|
||||
|
||||
if (editing) overlay.querySelector('#v3-ob-cancel')?.addEventListener('click', () => overlay.remove());
|
||||
|
||||
// ── Song directory (step 2) ──────────────────────────────────────────
|
||||
if (songDirEl) {
|
||||
songDirEl.addEventListener('input', () => { songDir = songDirEl.value.trim(); refreshSubmit(); });
|
||||
}
|
||||
// Native folder picker on desktop; web users type/paste the path.
|
||||
const _desktop = window.slopsmithDesktop;
|
||||
if (songDirBrowse && _desktop && typeof _desktop.pickDirectory === 'function') {
|
||||
songDirBrowse.classList.remove('hidden');
|
||||
songDirBrowse.addEventListener('click', async () => {
|
||||
try {
|
||||
const picked = await _desktop.pickDirectory();
|
||||
if (picked) { songDirEl.value = picked; songDir = picked; refreshSubmit(); }
|
||||
} catch (e) { /* user cancelled / unavailable */ }
|
||||
});
|
||||
}
|
||||
// Save the song directory to settings + kick a library scan so the
|
||||
// user's songs appear. Throws (with a message) on an invalid folder.
|
||||
async function saveSongDir() {
|
||||
const dir = ((songDirEl && songDirEl.value) || '').trim();
|
||||
if (!dir) return; // skipped — leave unconfigured (settable later)
|
||||
const res = await fetch('/api/settings', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ dlc_dir: dir }),
|
||||
});
|
||||
// /api/settings reports an invalid folder as a 200 with an `error`
|
||||
// field (a bare dict return, not a non-2xx status), so a res.ok-only
|
||||
// check would treat the failure as success and advance without saving.
|
||||
// Inspect the body too.
|
||||
let data = null;
|
||||
try { data = await res.json(); } catch (e) { /* non-JSON body */ }
|
||||
if (!res.ok || (data && data.error)) {
|
||||
throw new Error((data && data.error) || 'That folder couldn’t be set — check the path and try again.');
|
||||
}
|
||||
// Non-fatal: scan kicks off the library build in the background.
|
||||
try { await fetch('/api/rescan', { method: 'POST' }); } catch (e) { /* best-effort */ }
|
||||
}
|
||||
|
||||
async function postProfile() {
|
||||
const res = await fetch('/api/profile', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
@@ -357,7 +462,8 @@
|
||||
return body;
|
||||
}
|
||||
|
||||
async function finish() {
|
||||
async function finish(finishOpts) {
|
||||
finishOpts = finishOpts || {};
|
||||
overlay.remove();
|
||||
await fetchProgress();
|
||||
if (window.v3Progression && typeof window.v3Progression.refresh === 'function') {
|
||||
@@ -366,6 +472,16 @@
|
||||
renderBadge();
|
||||
renderProfileScreen();
|
||||
if (window.slopsmith && window.slopsmith.emit) window.slopsmith.emit('v3:profile-updated', _profile);
|
||||
// First-run only: after a genuine onboarding completion (not a
|
||||
// profile edit), kick off the one-time home tour — but NOT when we're
|
||||
// about to launch the diagnostic ("Play it now"), which navigates to
|
||||
// the player; the tour would otherwise spotlight hidden home elements
|
||||
// and steal focus. The Skip path stays on home, so it runs there.
|
||||
// The engine's seen/dismissed state keeps it once; replayable from "?".
|
||||
if (!editing && !finishOpts.launchingSong &&
|
||||
window.v3OnboardingTour && typeof window.v3OnboardingTour.startFirstRun === 'function') {
|
||||
try { window.v3OnboardingTour.startFirstRun(); } catch (e) { /* never block onboarding */ }
|
||||
}
|
||||
}
|
||||
|
||||
submit.addEventListener('click', async () => {
|
||||
@@ -380,12 +496,23 @@
|
||||
}
|
||||
if (step === 1) {
|
||||
setStep(2);
|
||||
loadPathTiles();
|
||||
setTimeout(() => { try { songDirEl && songDirEl.focus(); } catch (e) { /* noop */ } }, 50);
|
||||
return;
|
||||
}
|
||||
if (step === 2) {
|
||||
// Save the song directory + kick a library scan, then continue
|
||||
// to instrument paths. "Skip for now" leaves it unconfigured.
|
||||
submit.disabled = true;
|
||||
try {
|
||||
await saveSongDir();
|
||||
setStep(3);
|
||||
loadPathTiles();
|
||||
} catch (e) { showErr(e.message || 'Could not set the song directory.'); refreshSubmit(); }
|
||||
return;
|
||||
}
|
||||
if (step === 3) {
|
||||
// Create the profile (onboarded=1) BEFORE the calibration choice
|
||||
// so closing the overlay at step 3 can never lose the profile.
|
||||
// so closing the overlay at the challenge can never lose the profile.
|
||||
submit.disabled = true;
|
||||
try {
|
||||
_profile = await postProfile();
|
||||
@@ -403,19 +530,29 @@
|
||||
throw new Error(msg);
|
||||
}
|
||||
}
|
||||
setStep(3);
|
||||
// New step: input-device selection + calibration, between
|
||||
// path selection and the note-detect calibration challenge.
|
||||
await runInputSetup(selectedPaths);
|
||||
setStep(4);
|
||||
} catch (e) { showErr(e.message || 'Could not save profile.'); refreshSubmit(); }
|
||||
return;
|
||||
}
|
||||
// Step 3 — "Play it now": leave calibration pending (it completes
|
||||
// Step 4 — "Play it now": leave calibration pending (it completes
|
||||
// through the normal scored-stats path) and launch the diagnostic.
|
||||
const target = diagnosticFilename;
|
||||
await finish();
|
||||
await finish({ launchingSong: !!target });
|
||||
if (target && typeof window.playSong === 'function') window.playSong(target);
|
||||
});
|
||||
|
||||
skipBtn.addEventListener('click', async () => {
|
||||
// Step 3 — skip: Mastery Rank 1 immediately, calibration stays
|
||||
// Step 2 — skip the song directory (the user can set it later in
|
||||
// Settings). Proceed straight to instrument paths.
|
||||
if (step === 2) {
|
||||
setStep(3);
|
||||
loadPathTiles();
|
||||
return;
|
||||
}
|
||||
// Step 4 — skip: Mastery Rank 1 immediately, calibration stays
|
||||
// replayable from the Progress screen.
|
||||
skipBtn.disabled = true;
|
||||
try {
|
||||
|
||||
@@ -103,7 +103,7 @@
|
||||
'<div class="flex items-center justify-between gap-3 flex-wrap">' +
|
||||
'<div class="min-w-0">' +
|
||||
'<h3 class="text-lg font-bold text-fb-text">Calibration challenge</h3>' +
|
||||
'<p class="text-sm text-fb-textDim mt-1">Play the <span class="text-fb-text">Slopsmith Diagnostic</span> with note detection and finish at ' +
|
||||
'<p class="text-sm text-fb-textDim mt-1">Play the <span class="text-fb-text">fee[dB]ack Diagnostic</span> with note detection and finish at ' +
|
||||
'<span class="text-fb-text font-semibold">100% accuracy</span>' +
|
||||
(pending ? ' to reach Mastery Rank 1.' : ' to prove your setup (you skipped this — rank already granted).') + '</p></div>' +
|
||||
'<div class="flex items-center gap-2 shrink-0">' +
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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>' : '') +
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const { createWindow, ROOT } = require('./capabilities_test_harness');
|
||||
|
||||
const CAPABILITIES_JS = path.join(ROOT, 'static', 'capabilities.js');
|
||||
const MIDI_INPUT_JS = path.join(ROOT, 'static', 'capabilities', 'midi-input.js');
|
||||
|
||||
function loadMidiInput(options = {}) {
|
||||
const window = createWindow(options);
|
||||
const context = vm.createContext(window);
|
||||
vm.runInContext(fs.readFileSync(CAPABILITIES_JS, 'utf8'), context, { filename: CAPABILITIES_JS });
|
||||
vm.runInContext(fs.readFileSync(MIDI_INPUT_JS, 'utf8'), context, { filename: MIDI_INPUT_JS });
|
||||
return window;
|
||||
}
|
||||
|
||||
// A fake provider whose enumerate/open/close are observable by the test.
|
||||
function fakeProvider(window, overrides = {}) {
|
||||
const calls = { enumerate: 0, open: [], close: [] };
|
||||
window.slopsmith.midiInput.registerProvider({
|
||||
providerId: 'web-midi',
|
||||
label: 'Web MIDI',
|
||||
participantId: 'input_setup',
|
||||
enumerate: async () => { calls.enumerate += 1; return overrides.sources || [{ sourceId: 'dev1', label: 'My Keyboard' }]; },
|
||||
open: async (sourceId) => { calls.open.push(sourceId); return { addListener() {}, removeListener() {}, _id: sourceId }; },
|
||||
close: (sourceId, handle) => { calls.close.push(sourceId); },
|
||||
...overrides.handlers,
|
||||
});
|
||||
return calls;
|
||||
}
|
||||
|
||||
test('midi-input registers an active sensitive provider-coordinator', () => {
|
||||
const window = loadMidiInput();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const pipeline = api.inspect('midi-input');
|
||||
assert.ok(pipeline, 'midi-input pipeline exists');
|
||||
const owner = (pipeline.participants || []).find(p => p.pluginId === 'core.midi-input');
|
||||
assert.ok(owner, 'core.midi-input owner registered');
|
||||
assert.equal(owner.safety, 'sensitive');
|
||||
assert.equal(owner.kind, 'provider-coordinator');
|
||||
for (const cmd of ['inspect', 'list-sources', 'discover', 'select-source', 'open-source', 'close-source']) {
|
||||
assert.ok(owner.commands.includes(cmd), `owner exposes ${cmd}`);
|
||||
}
|
||||
assert.equal(window.slopsmith.midiInput.version, 1);
|
||||
});
|
||||
|
||||
test('list-sources and select-source are prompt-free (never enumerate)', async () => {
|
||||
const window = loadMidiInput();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const calls = fakeProvider(window);
|
||||
const listed = await api.dispatch({ capability: 'midi-input', command: 'list-sources', source: 'tester' });
|
||||
assert.equal(listed.outcome, 'handled');
|
||||
assert.equal(calls.enumerate, 0, 'list-sources must not request MIDI access');
|
||||
});
|
||||
|
||||
test('discover is the permission boundary and surfaces sources', async () => {
|
||||
const window = loadMidiInput();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const calls = fakeProvider(window);
|
||||
const r = await api.dispatch({ capability: 'midi-input', command: 'discover', source: 'tester' });
|
||||
assert.equal(r.outcome, 'handled');
|
||||
assert.equal(calls.enumerate, 1, 'discover requests MIDI access exactly once');
|
||||
const sources = window.slopsmith.midiInput.listSources();
|
||||
assert.equal(sources.length, 1);
|
||||
assert.equal(sources[0].logicalSourceKey, 'web-midi::dev1');
|
||||
assert.equal(sources[0].kind, 'midi');
|
||||
});
|
||||
|
||||
test('re-discovery drops sources for devices that vanished', async () => {
|
||||
const window = loadMidiInput();
|
||||
let devices = [{ sourceId: 'dev1', label: 'A' }, { sourceId: 'dev2', label: 'B' }];
|
||||
window.slopsmith.midiInput.registerProvider({
|
||||
providerId: 'web-midi', label: 'Web MIDI',
|
||||
enumerate: async () => devices,
|
||||
open: async () => ({ addListener() {}, removeListener() {} }),
|
||||
close: () => {},
|
||||
});
|
||||
await window.slopsmith.midiInput.discover();
|
||||
assert.equal(window.slopsmith.midiInput.listSources().length, 2);
|
||||
devices = [{ sourceId: 'dev1', label: 'A' }]; // dev2 unplugged
|
||||
await window.slopsmith.midiInput.discover();
|
||||
const keys = window.slopsmith.midiInput.listSources().map((s) => s.logicalSourceKey);
|
||||
assert.equal(keys.length, 1, 'vanished device is dropped from the source list');
|
||||
assert.equal(keys[0], 'web-midi::dev1');
|
||||
});
|
||||
|
||||
test('discover with no provider reports unavailable', async () => {
|
||||
const window = loadMidiInput();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const r = await api.dispatch({ capability: 'midi-input', command: 'discover', source: 'tester' });
|
||||
assert.equal(r.outcome, 'unavailable');
|
||||
});
|
||||
|
||||
test('discover surfaces denied when MIDI access is rejected', async () => {
|
||||
const window = loadMidiInput();
|
||||
const api = window.slopsmith.capabilities;
|
||||
fakeProvider(window, { handlers: { enumerate: async () => { throw new Error('SecurityError: permission denied'); } } });
|
||||
const r = await api.dispatch({ capability: 'midi-input', command: 'discover', source: 'tester' });
|
||||
assert.equal(r.outcome, 'denied');
|
||||
assert.match(r.reason, /denied/i);
|
||||
});
|
||||
|
||||
test('select-source persists by logicalSourceKey', async () => {
|
||||
const window = loadMidiInput();
|
||||
const api = window.slopsmith.capabilities;
|
||||
fakeProvider(window);
|
||||
await api.dispatch({ capability: 'midi-input', command: 'discover', source: 'tester' });
|
||||
const sel = await api.dispatch({ capability: 'midi-input', command: 'select-source', source: 'tester', payload: { logicalSourceKey: 'web-midi::dev1' } });
|
||||
assert.equal(sel.outcome, 'handled');
|
||||
assert.equal(window.__storage.get('slopsmith.midiInput.selectedLogicalSourceKey'), 'web-midi::dev1');
|
||||
assert.ok(window.slopsmith.midiInput.listSources()[0].selected);
|
||||
});
|
||||
|
||||
test('open/close share one session and release on the last requester', async () => {
|
||||
const window = loadMidiInput();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const calls = fakeProvider(window);
|
||||
await window.slopsmith.midiInput.discover();
|
||||
await window.slopsmith.midiInput.select('web-midi::dev1');
|
||||
const a = await api.dispatch({ capability: 'midi-input', command: 'open-source', source: 'reqA', payload: { logicalSourceKey: 'web-midi::dev1' } });
|
||||
const b = await api.dispatch({ capability: 'midi-input', command: 'open-source', source: 'reqB', payload: { logicalSourceKey: 'web-midi::dev1' } });
|
||||
assert.equal(a.outcome, 'handled');
|
||||
assert.equal(b.outcome, 'handled');
|
||||
assert.equal(calls.open.length, 1, 'provider.open called once for a shared session');
|
||||
// First release keeps the session open; second closes it.
|
||||
await api.dispatch({ capability: 'midi-input', command: 'close-source', source: 'reqA', payload: { logicalSourceKey: 'web-midi::dev1' } });
|
||||
assert.equal(calls.close.length, 0, 'session stays open while a requester holds it');
|
||||
await api.dispatch({ capability: 'midi-input', command: 'close-source', source: 'reqB', payload: { logicalSourceKey: 'web-midi::dev1' } });
|
||||
assert.equal(calls.close.length, 1, 'provider.close after the last release');
|
||||
});
|
||||
|
||||
test('concurrent opens for one source coalesce onto a single provider.open', async () => {
|
||||
const window = loadMidiInput();
|
||||
const api = window.slopsmith.capabilities;
|
||||
// A provider whose open() stays pending until we release it, so both
|
||||
// dispatches are genuinely in flight at the same time.
|
||||
let release;
|
||||
const gate = new Promise((r) => { release = r; });
|
||||
const calls = { open: 0, close: 0 };
|
||||
window.slopsmith.midiInput.registerProvider({
|
||||
providerId: 'web-midi', label: 'Web MIDI',
|
||||
enumerate: async () => [{ sourceId: 'dev1', label: 'My Keyboard' }],
|
||||
open: async () => { calls.open += 1; await gate; return { addListener() {}, removeListener() {} }; },
|
||||
close: () => { calls.close += 1; },
|
||||
});
|
||||
await window.slopsmith.midiInput.discover();
|
||||
await window.slopsmith.midiInput.select('web-midi::dev1');
|
||||
const p1 = api.dispatch({ capability: 'midi-input', command: 'open-source', source: 'reqA', payload: { logicalSourceKey: 'web-midi::dev1' } });
|
||||
const p2 = api.dispatch({ capability: 'midi-input', command: 'open-source', source: 'reqB', payload: { logicalSourceKey: 'web-midi::dev1' } });
|
||||
release();
|
||||
const [a, b] = await Promise.all([p1, p2]);
|
||||
assert.equal(a.outcome, 'handled');
|
||||
assert.equal(b.outcome, 'handled');
|
||||
assert.equal(calls.open, 1, 'provider.open called exactly once despite concurrent opens');
|
||||
// Both requesters joined the single shared session: it survives the first
|
||||
// release and only closes on the last, with exactly one provider.close.
|
||||
await api.dispatch({ capability: 'midi-input', command: 'close-source', source: 'reqA', payload: { logicalSourceKey: 'web-midi::dev1' } });
|
||||
assert.equal(calls.close, 0, 'shared session stays open while reqB holds it');
|
||||
await api.dispatch({ capability: 'midi-input', command: 'close-source', source: 'reqB', payload: { logicalSourceKey: 'web-midi::dev1' } });
|
||||
assert.equal(calls.close, 1, 'provider.close once after the last requester releases');
|
||||
});
|
||||
|
||||
test('public open() surfaces the live handle (in-page only)', async () => {
|
||||
const window = loadMidiInput();
|
||||
fakeProvider(window);
|
||||
await window.slopsmith.midiInput.discover();
|
||||
await window.slopsmith.midiInput.select('web-midi::dev1');
|
||||
const res = await window.slopsmith.midiInput.open({ requester: 'input_setup', logicalSourceKey: 'web-midi::dev1' });
|
||||
assert.equal(res.outcome, 'handled');
|
||||
assert.ok(res.handle && typeof res.handle.addListener === 'function', 'live handle exposed via public global');
|
||||
});
|
||||
|
||||
// Load the domain with a Web-MIDI-capable navigator so the built-in provider
|
||||
// self-registers (the shared harness has no navigator, so it normally skips).
|
||||
function loadWithWebMidi(inputs) {
|
||||
const window = createWindow();
|
||||
window.navigator = {
|
||||
requestMIDIAccess: async () => ({
|
||||
onstatechange: null,
|
||||
inputs: new Map(inputs.map((i) => [i.id, { id: i.id, name: i.name, onmidimessage: null }])),
|
||||
}),
|
||||
};
|
||||
const context = vm.createContext(window);
|
||||
vm.runInContext(fs.readFileSync(CAPABILITIES_JS, 'utf8'), context, { filename: CAPABILITIES_JS });
|
||||
vm.runInContext(fs.readFileSync(MIDI_INPUT_JS, 'utf8'), context, { filename: MIDI_INPUT_JS });
|
||||
return window;
|
||||
}
|
||||
|
||||
test('built-in Web-MIDI provider self-registers + discovers, filtering loopback ports', async () => {
|
||||
const window = loadWithWebMidi([
|
||||
{ id: 'kb1', name: 'My Keyboard' },
|
||||
{ id: 'thru', name: 'Midi Through Port-0' }, // loopback → filtered out
|
||||
]);
|
||||
const api = window.slopsmith.capabilities;
|
||||
assert.ok(api.inspect('midi-input').participants.some(p => p.pluginId === 'core.midi-input'),
|
||||
'built-in provider registered without any plugin');
|
||||
const r = await api.dispatch({ capability: 'midi-input', command: 'discover', source: 'tester' });
|
||||
assert.equal(r.outcome, 'handled');
|
||||
const sources = window.slopsmith.midiInput.listSources();
|
||||
assert.equal(sources.length, 1, 'loopback/passthrough ports are filtered');
|
||||
assert.equal(sources[0].logicalSourceKey, 'web-midi::kb1');
|
||||
});
|
||||
|
||||
test('diagnostics are redaction-safe (no device labels, no raw messages)', async () => {
|
||||
const window = loadMidiInput();
|
||||
fakeProvider(window);
|
||||
await window.slopsmith.midiInput.discover();
|
||||
const contrib = window.slopsmith.diagnostics.snapshotContributions()['midi-input-capability'];
|
||||
assert.ok(contrib, 'midi-input contributes diagnostics');
|
||||
assert.equal(contrib.schema, 'slopsmith.midi_input.diagnostics.v1');
|
||||
const serialized = JSON.stringify(contrib);
|
||||
assert.ok(!serialized.includes('My Keyboard'), 'device labels are redacted from diagnostics');
|
||||
for (const s of contrib.sources) assert.ok(!('label' in s), 'source entries carry no label');
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Coverage for the additive sloppak → feedpak rename (back-compat foundation).
|
||||
|
||||
The format was renamed `sloppak` → `feedpak`. This phase is purely additive: both
|
||||
the `.feedpak` and legacy `.sloppak` extensions must load, the new `feedpak`
|
||||
module must re-export the loader, the `feedpak_version` manifest key must be read,
|
||||
and every legacy `sloppak`-named symbol must keep working. Nothing sloppak breaks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
import sloppak as sloppak_mod
|
||||
import feedpak as feedpak_mod
|
||||
|
||||
|
||||
def _build(root: Path, suffix: str, manifest_extras: dict | None = None) -> Path:
|
||||
"""Build a minimal directory-form pack with the given extension."""
|
||||
pak = root / f"{root.name}{suffix}"
|
||||
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": [],
|
||||
}))
|
||||
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 or {})
|
||||
(pak / "manifest.yaml").write_text(yaml.safe_dump(manifest, sort_keys=False))
|
||||
return pak
|
||||
|
||||
|
||||
def _load(mod, pak: Path, tmp_path: Path):
|
||||
cache = tmp_path / "cache"
|
||||
cache.mkdir()
|
||||
return mod.load_song(pak.name, pak.parent, cache)
|
||||
|
||||
|
||||
# ── Both extensions are detected ──────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.parametrize("suffix", [".feedpak", ".sloppak"])
|
||||
def test_detection_accepts_both_extensions(tmp_path: Path, suffix: str):
|
||||
pak = tmp_path / f"song{suffix}"
|
||||
assert sloppak_mod.is_pack(pak)
|
||||
assert sloppak_mod.is_feedpak(pak)
|
||||
assert sloppak_mod.is_sloppak(pak) # deprecated alias still accepts .feedpak
|
||||
|
||||
|
||||
def test_detection_rejects_other_extensions(tmp_path: Path):
|
||||
assert not sloppak_mod.is_pack(tmp_path / "song.zip")
|
||||
assert not sloppak_mod.is_pack(tmp_path / "song.mp3")
|
||||
|
||||
|
||||
# ── Both extensions load, via both module names ───────────────────────────────
|
||||
|
||||
@pytest.mark.parametrize("suffix", [".feedpak", ".sloppak"])
|
||||
def test_load_via_sloppak_module(tmp_path: Path, suffix: str):
|
||||
pak = _build(tmp_path, suffix)
|
||||
loaded = _load(sloppak_mod, pak, tmp_path)
|
||||
assert loaded.song.title == "Test"
|
||||
assert loaded.stems[0]["id"] == "full"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("suffix", [".feedpak", ".sloppak"])
|
||||
def test_load_via_feedpak_module(tmp_path: Path, suffix: str):
|
||||
"""The canonical `feedpak` module re-exports the loader and loads both forms."""
|
||||
pak = _build(tmp_path, suffix)
|
||||
loaded = _load(feedpak_mod, pak, tmp_path)
|
||||
assert loaded.song.title == "Test"
|
||||
|
||||
|
||||
def test_feedpak_module_reexports_match_sloppak():
|
||||
for name in ("load_song", "load_manifest", "extract_meta", "resolve_source_dir",
|
||||
"is_pack", "is_feedpak", "is_sloppak", "read_feedpak_version"):
|
||||
assert getattr(feedpak_mod, name) is getattr(sloppak_mod, name)
|
||||
assert feedpak_mod.LoadedFeedpak is sloppak_mod.LoadedSloppak
|
||||
|
||||
|
||||
# ── feedpak_version is read ───────────────────────────────────────────────────
|
||||
|
||||
def test_feedpak_version_read_when_present(tmp_path: Path):
|
||||
pak = _build(tmp_path, ".feedpak", {"feedpak_version": "1.0.0"})
|
||||
loaded = _load(feedpak_mod, pak, tmp_path)
|
||||
assert loaded.feedpak_version == "1.0.0"
|
||||
assert feedpak_mod.extract_meta(pak)["feedpak_version"] == "1.0.0"
|
||||
|
||||
|
||||
def test_feedpak_version_none_when_absent(tmp_path: Path):
|
||||
pak = _build(tmp_path, ".feedpak")
|
||||
loaded = _load(feedpak_mod, pak, tmp_path)
|
||||
assert loaded.feedpak_version is None
|
||||
assert feedpak_mod.extract_meta(pak)["feedpak_version"] is None
|
||||
|
||||
|
||||
def test_feedpak_version_ignores_non_string(tmp_path: Path):
|
||||
pak = _build(tmp_path, ".feedpak", {"feedpak_version": 1})
|
||||
loaded = _load(feedpak_mod, pak, tmp_path)
|
||||
assert loaded.feedpak_version is None
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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"}]
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user