mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 19:29:33 +00:00
Merge remote-tracking branch 'origin/main' into feat/feedpak-jsonc
Signed-off-by: topkoa <topkoa@gmail.com> # Conflicts: # CHANGELOG.md
This commit is contained in:
@@ -39,7 +39,7 @@ jobs:
|
|||||||
first=$(printf '%s\n' "$hits" | head -n1)
|
first=$(printf '%s\n' "$hits" | head -n1)
|
||||||
file=$(printf '%s' "$first" | cut -d: -f1)
|
file=$(printf '%s' "$first" | cut -d: -f1)
|
||||||
line=$(printf '%s' "$first" | cut -d: -f2)
|
line=$(printf '%s' "$first" | cut -d: -f2)
|
||||||
echo "::error file=${file},line=${line}::print() or traceback.print_exc() found in server.py, lib/, or a bundled plugin routes.py. Use the slopsmith logger (lib/logging_setup.py) — see issues #155 / #242."
|
echo "::error file=${file},line=${line}::print() or traceback.print_exc() found in server.py, lib/, or a bundled plugin routes.py. Use the feedBack logger (lib/logging_setup.py) — see issues #155 / #242."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ jobs:
|
|||||||
|
|
||||||
tailwind-fresh:
|
tailwind-fresh:
|
||||||
# Guard that the committed static/tailwind.min.css is in sync with source.
|
# Guard that the committed static/tailwind.min.css is in sync with source.
|
||||||
# The Play CDN's runtime JIT was removed (slopsmith-desktop#110); a prebuilt
|
# The Play CDN's runtime JIT was removed (feedBack-desktop#110); a prebuilt
|
||||||
# stylesheet only contains classes the scanner saw at build time, so stale
|
# stylesheet only contains classes the scanner saw at build time, so stale
|
||||||
# CSS silently ships unstyled elements. Rebuild and fail on any diff.
|
# CSS silently ships unstyled elements. Rebuild and fail on any diff.
|
||||||
name: tailwind-fresh
|
name: tailwind-fresh
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
name: Sync VERSION from desktop release
|
name: Sync VERSION from desktop release
|
||||||
|
|
||||||
# Updates the VERSION file in this repo whenever slopsmith-desktop
|
# Updates the VERSION file in this repo whenever feedBack-desktop
|
||||||
# publishes a new tagged release. slopsmith-desktop's build.yml
|
# publishes a new tagged release. feedBack-desktop's build.yml
|
||||||
# dispatches the `desktop-released` event at the end of a successful
|
# dispatches the `desktop-released` event at the end of a successful
|
||||||
# tag build (see docs in CLAUDE.md). A `workflow_dispatch` trigger is
|
# tag build (see docs in CLAUDE.md). A `workflow_dispatch` trigger is
|
||||||
# kept for manual testing / recovery.
|
# kept for manual testing / recovery.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ build/
|
|||||||
.env*
|
.env*
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.vscode/
|
.vscode/
|
||||||
|
data/web_library.db
|
||||||
static/*.ogg
|
static/*.ogg
|
||||||
static/*.mp3
|
static/*.mp3
|
||||||
static/*.wav
|
static/*.wav
|
||||||
@@ -20,9 +21,15 @@ plugins/*/
|
|||||||
# treats them identically to user-installed ones) but are bundled with
|
# treats them identically to user-installed ones) but are bundled with
|
||||||
# the default container image and marked `"bundled": true` in their
|
# the default container image and marked `"bundled": true` in their
|
||||||
# manifest. Add new core plugins as `!plugins/<id>/` exceptions.
|
# manifest. Add new core plugins as `!plugins/<id>/` exceptions.
|
||||||
|
!plugins/achievements/
|
||||||
|
!plugins/achievements/**
|
||||||
|
plugins/achievements/__pycache__/
|
||||||
!plugins/highway_3d/
|
!plugins/highway_3d/
|
||||||
!plugins/highway_3d/**
|
!plugins/highway_3d/**
|
||||||
plugins/highway_3d/__pycache__/
|
plugins/highway_3d/__pycache__/
|
||||||
|
!plugins/folder_library/
|
||||||
|
!plugins/folder_library/**
|
||||||
|
plugins/folder_library/__pycache__/
|
||||||
!plugins/app_tour_library/
|
!plugins/app_tour_library/
|
||||||
!plugins/app_tour_library/**
|
!plugins/app_tour_library/**
|
||||||
!plugins/app_tour_settings/
|
!plugins/app_tour_settings/
|
||||||
@@ -48,3 +55,4 @@ Thumbs.db
|
|||||||
*.tmp
|
*.tmp
|
||||||
*.swp
|
*.swp
|
||||||
.idea/
|
.idea/
|
||||||
|
plugins/support_creators
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Slopsmith Constitution
|
# FeedBack Constitution
|
||||||
|
|
||||||
> Slopsmith is a self-hosted, single-user web app for browsing, playing, and
|
> FeedBack is a self-hosted, single-user web app for browsing, playing, and
|
||||||
> practicing interactive music notation, built around its own open `.sloppak`
|
> practicing interactive music notation, built around its own open `.sloppak`
|
||||||
> chart format (charts imported from Guitar Pro / MusicXML or authored in the
|
> chart format (charts imported from Guitar Pro / MusicXML or authored in the
|
||||||
> built-in editor). This constitution captures the non-negotiable principles
|
> built-in editor). This constitution captures the non-negotiable principles
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
### I. Self-Hosted, Single-User, Docker-First
|
### I. Self-Hosted, Single-User, Docker-First
|
||||||
|
|
||||||
Slopsmith targets one user running one container against a personal
|
FeedBack targets one user running one container against a personal
|
||||||
song library folder. There is no multi-tenant model, no
|
song library folder. There is no multi-tenant model, no
|
||||||
authentication, no rate limiting, and no shared backend. Deployment is
|
authentication, no rate limiting, and no shared backend. Deployment is
|
||||||
expressed as a single `docker compose up -d` against the bundled
|
expressed as a single `docker compose up -d` against the bundled
|
||||||
@@ -41,12 +41,12 @@ is Tailwind CSS, served as a prebuilt static stylesheet
|
|||||||
(`static/tailwind.min.css`, regenerated by `scripts/build-tailwind.sh`)
|
(`static/tailwind.min.css`, regenerated by `scripts/build-tailwind.sh`)
|
||||||
— never the runtime Play CDN, whose on-the-fly JIT rescans the DOM on
|
— never the runtime Play CDN, whose on-the-fly JIT rescans the DOM on
|
||||||
the main thread and caused sustained frame drops with the 3D highway
|
the main thread and caused sustained frame drops with the 3D highway
|
||||||
(slopsmith-desktop#110). No React, Vue, Svelte, bundler, transpiler, or
|
(feedBack-desktop#110). No React, Vue, Svelte, bundler, transpiler, or
|
||||||
TypeScript appears in the core static tree, and no build step runs on
|
TypeScript appears in the core static tree, and no build step runs on
|
||||||
the serve path: the Tailwind build is a maintainer-only one-shot whose
|
the serve path: the Tailwind build is a maintainer-only one-shot whose
|
||||||
output is committed, so Docker / desktop / end users never build. New
|
output is committed, so Docker / desktop / end users never build. New
|
||||||
features extend `app.js` and the existing globals (`window.playSong`,
|
features extend `app.js` and the existing globals (`window.playSong`,
|
||||||
`window.showScreen`, `window.createHighway`, `window.slopsmith`).
|
`window.showScreen`, `window.createHighway`, `window.feedBack`).
|
||||||
|
|
||||||
**Non-negotiable rules**
|
**Non-negotiable rules**
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ do not collide in `sys.modules`.
|
|||||||
sibling imports. Bare `import sibling` works during transition but
|
sibling imports. Bare `import sibling` works during transition but
|
||||||
triggers a startup warning when a name collides.
|
triggers a startup warning when a name collides.
|
||||||
- Plugins MUST register routes under `/api/plugins/<plugin_id>/...`,
|
- Plugins MUST register routes under `/api/plugins/<plugin_id>/...`,
|
||||||
use `window.slopsmith.emit/on` for cross-plugin communication, and
|
use `window.feedBack.emit/on` for cross-plugin communication, and
|
||||||
prefix their `localStorage` keys with their plugin id.
|
prefix their `localStorage` keys with their plugin id.
|
||||||
- Plugins inherit this constitution and may layer additional rules in
|
- Plugins inherit this constitution and may layer additional rules in
|
||||||
their own `CLAUDE.md`, but MUST NOT relax core principles (e.g. a
|
their own `CLAUDE.md`, but MUST NOT relax core principles (e.g. a
|
||||||
@@ -97,7 +97,7 @@ do not collide in `sys.modules`.
|
|||||||
|
|
||||||
### IV. Backwards-Compatible Chart Library
|
### IV. Backwards-Compatible Chart Library
|
||||||
|
|
||||||
The whole point of Slopsmith is that a user points it at an existing
|
The whole point of FeedBack is that a user points it at an existing
|
||||||
song library folder and it Just Works. The library is scanned and
|
song library folder and it Just Works. The library is scanned and
|
||||||
indexed in `meta.db` (SQLite via `MetadataDB`). The open Sloppak
|
indexed in `meta.db` (SQLite via `MetadataDB`). The open Sloppak
|
||||||
format (`lib/sloppak.py`; specified at
|
format (`lib/sloppak.py`; specified at
|
||||||
@@ -145,7 +145,7 @@ push and PR to `main` against Python 3.12.
|
|||||||
All backend output goes through the stdlib `logging` pipeline configured
|
All backend output goes through the stdlib `logging` pipeline configured
|
||||||
by `lib/logging_setup.py`, controlled by `LOG_LEVEL` / `LOG_FORMAT` /
|
by `lib/logging_setup.py`, controlled by `LOG_LEVEL` / `LOG_FORMAT` /
|
||||||
`LOG_FILE`. Plugins receive a pre-configured `context["log"]` namespaced
|
`LOG_FILE`. Plugins receive a pre-configured `context["log"]` namespaced
|
||||||
to `slopsmith.plugin.<id>` and MUST use it instead of `print`. HTTP
|
to `feedBack.plugin.<id>` and MUST use it instead of `print`. HTTP
|
||||||
responses carry a `X-Request-ID` header from `CorrelationIdMiddleware`
|
responses carry a `X-Request-ID` header from `CorrelationIdMiddleware`
|
||||||
and the same id appears as `request_id` in JSON log lines. The
|
and the same id appears as `request_id` in JSON log lines. The
|
||||||
"Settings → Export Diagnostics" bundle (`lib/diagnostics_bundle.py`)
|
"Settings → Export Diagnostics" bundle (`lib/diagnostics_bundle.py`)
|
||||||
@@ -171,7 +171,7 @@ User configuration lives in two places: server-side under `CONFIG_DIR`
|
|||||||
(SQLite `meta.db`, `config.yaml`, plugin opted-in files) and client-
|
(SQLite `meta.db`, `config.yaml`, plugin opted-in files) and client-
|
||||||
side in browser `localStorage`. Both can be exported and re-imported
|
side in browser `localStorage`. Both can be exported and re-imported
|
||||||
as a single bundle (`POST /api/settings/import`,
|
as a single bundle (`POST /api/settings/import`,
|
||||||
`GET /api/settings/export`, slopsmith#113). Import is two-phase:
|
`GET /api/settings/export`, feedBack#113). Import is two-phase:
|
||||||
phase-1 validates the entire bundle (schema, paths, encoding) and
|
phase-1 validates the entire bundle (schema, paths, encoding) and
|
||||||
phase-2 commits each file atomically via temp+rename. Plugins opt
|
phase-2 commits each file atomically via temp+rename. Plugins opt
|
||||||
their server-side files into the bundle via
|
their server-side files into the bundle via
|
||||||
@@ -188,7 +188,7 @@ no `..`, no absolute paths).
|
|||||||
Importing a bundle whose schema predates the running plugin's code
|
Importing a bundle whose schema predates the running plugin's code
|
||||||
MUST restore bytes verbatim — the plugin copes at next load.
|
MUST restore bytes verbatim — the plugin copes at next load.
|
||||||
- The `VERSION` file is the single source of truth for the running
|
- The `VERSION` file is the single source of truth for the running
|
||||||
release; it is auto-bumped from `slopsmith-desktop` releases via
|
release; it is auto-bumped from `feedBack-desktop` releases via
|
||||||
`.github/workflows/sync-version.yml`. Manual edits are reserved for
|
`.github/workflows/sync-version.yml`. Manual edits are reserved for
|
||||||
out-of-band recovery only.
|
out-of-band recovery only.
|
||||||
|
|
||||||
@@ -219,7 +219,7 @@ no `..`, no absolute paths).
|
|||||||
|
|
||||||
- **Branching**: never push directly to `main`. Always feature branch
|
- **Branching**: never push directly to `main`. Always feature branch
|
||||||
+ PR. Exception: the automated `VERSION` bump from
|
+ PR. Exception: the automated `VERSION` bump from
|
||||||
`slopsmith-desktop`'s release job, which commits to `main` as
|
`feedBack-desktop`'s release job, which commits to `main` as
|
||||||
`github-actions[bot]`.
|
`github-actions[bot]`.
|
||||||
- **Reviews**: PRs run the local Codex review loop
|
- **Reviews**: PRs run the local Codex review loop
|
||||||
(`feedback_codex_preflight.md`) and the GitHub Copilot review pass
|
(`feedback_codex_preflight.md`) and the GitHub Copilot review pass
|
||||||
|
|||||||
+83
-33
@@ -8,46 +8,96 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- **`.jsonc` support for feedpak data files** (feedpak-spec §8, FEP #3 / PR #13). Hand-edited packs may now use the `.jsonc` extension (JSON with C-style `//` line and `/* */` block comments) for any data file the manifest points at — arrangements, notation sidecars, `drum_tab`, `song_timeline`, `lyrics`, and `keys`. New shared `lib/jsonc.py` provides `parse_jsonc(text)` + `load_json(path)` (auto-detects `.jsonc` by suffix, string-aware so comment-like text inside JSON string values is preserved) and is now used by every reader in `lib/sloppak.py` (six side-file sites) and `scripts/lift_keys_notation.py` (three arrangement / song_timeline read sites). The strip regex mirrors the reference validator in `feedpak-spec/tools/validate.py`. This is an additive (MINOR) change: older readers parse `.jsonc` files as plain JSON and ignore comments via the spec's forward-compatibility rules, so no existing pack needs regeneration. Tests: `tests/test_sloppak_jsonc_load.py` (covers all six side-file types, the lift helper, and the string-boundary preservation rule end-to-end).
|
- **`.jsonc` support for feedpak data files** (feedpak-spec §8, FEP #3 / PR #13). Hand-edited packs may now use the `.jsonc` extension (JSON with C-style `//` line and `/* */` block comments) for any data file the manifest points at — arrangements, notation sidecars, `drum_tab`, `song_timeline`, `lyrics`, and `keys`. New shared `lib/jsonc.py` provides `parse_jsonc(text)` + `load_json(path)` (auto-detects `.jsonc` by suffix, string-aware so comment-like text inside JSON string values is preserved) and is now used by every reader in `lib/sloppak.py` (six side-file sites) and `scripts/lift_keys_notation.py` (three arrangement / song_timeline read sites). The strip regex mirrors the reference validator in `feedpak-spec/tools/validate.py`. This is an additive (MINOR) change: `.jsonc` is opt-in, so any pack that keeps its data files as `.json` is unaffected and needs no regeneration. Note that a `.jsonc` file containing real comments only loads on a reader that implements §8 — a pre-this-change reader calls bare `json.loads` and fails on the comments rather than ignoring them, so don't hand out `.jsonc` packs to older hosts. Tests: `tests/test_sloppak_jsonc_load.py` (covers all six side-file types, the lift helper, and the string-boundary preservation rule end-to-end).
|
||||||
- **Guitar Pro → notation importer (`lib/gp2notation.py`)** (slopsmith#825 WS4b, epic #828). Piano/keys tracks imported from Guitar Pro (GPIF: `.gpx` GP6 / `.gp` GP7-8) now produce real Sloppak Notation Format data (sloppak-spec §5.3) alongside the `midi = string*24 + fret` guitar wire encoding. `gp2rs_gpx.convert_file` writes a `<stem>.notation.json` sidecar next to each keys arrangement XML (best-effort — a notation bug never breaks the RS-XML conversion), and `gp2notation.attach_notation_to_sloppak()` is the assembly-side helper that renames it into `notation_<id>.json` + adds the per-arrangement `notation:` manifest sub-key. Voice→staff routing salvages the logic from PR #703 (whose `stf` wire-field approach this supersedes): GP voice position 0 → `rh` staff (`G2`), positions ≥ 1 → `lh` (`F4`); a forced-LH track (the merged `Piano LH` partner from `_find_piano_pairs`, or a standalone track named `… LH`) routes everything to `lh` — preserving authored hand crossings instead of inferring hands from pitch. Emits measures with absolute `t` from the bar-indexed tempo map, change-only `ts`/`tempo`/`ks`, and `beat_groups` for compound/irregular meters (6/8 → `[3,3]`, 9/8 → `[3,3,3]`, 5/8 → `[2,3]`, 7/8 → `[2,2,3]` — cf. the slopsmith#261 denominator pitfalls); beats carry `dur`/`dot`/`tu`/`rest` from GP rhythms and notes carry absolute `midi` (String+Fret resolves via the string template's concert pitches, Tone+Octave via `(octave+1)*12 + step`) with `tied` continuations kept as real beats (engraving needs the tied notehead — unlike the RS-XML walk, which drops them and extends sustain). Timing reuses the `gp2rs_gpx` machinery (bar-indexed tempo map, per-beat rhythm durations, `_note_midi`) so notation lines up with the RS XML the highway plays — with one deliberate divergence: double dots advance time ×1.75 (vs. the RS-XML walk's single-dot ×1.5 approximation) so a written `dot: 2` agrees with the emitted beat times; sharing the walk itself stays tracked in slopsmith#618. Tests: `tests/test_gp2notation.py`.
|
- **The highway now loads the part that matches your selected instrument — a bass player gets the Bass arrangement, not the default Lead/guitar chart.** When you open a song without an explicit arrangement, the WebSocket handler (`server.py` `highway_ws`) reads your selected `instrument` from `config.json` (the same file it already reads for your default-arrangement preference) and routes to the matching part: **bass → the Bass arrangement**; guitar — and any unknown/future instrument (drums, keys) — falls through to the existing preference/most-notes default, which already lands on a guitar part. Previously the instrument selector only fed the tuner, so a bass player was handed a guitar chart (and a tune/coverage check then compared a 4-string bass against a 6-string part). An **explicit arrangement request always wins** (a manual arrangement switch is untouched), and a bass player's saved default-arrangement preference is still honored **within** the bass parts (so a preferred `Bass 2` / `Alt. Bass` wins over the canonical Bass), so this only changes the *default* part chosen on load. Server-only — every launch path already flows through the WS, so there's no client change. This is the instrument↔chart-routing piece the working-tuning series leans on (otherwise coverage compares across instruments). Tests: `tests/test_highway_ws_instrument_routing.py` (bass→Bass, bass-honors-pref, bass-no-bass-part→guitar, guitar→default, explicit-wins).
|
||||||
- **Legacy keys → notation lifter (`scripts/lift_keys_notation.py`)** (slopsmith#825 WS4c, epic #828). One-time batch converter that lifts existing **directory-form** piano/keys sloppaks from the legacy guitar wire encoding (`midi = s*24 + f`) into real Sloppak Notation Format files (sloppak-spec §5.3). Candidates are arrangements whose name matches `\b(keys|piano|keyboard|synth)\b` (case-insensitive); each gets a `notation_<id>.json` plus the per-arrangement `notation:` manifest sub-key. Measures derive from the song-level `beats` downbeats (`measure >= 0`; `song_timeline.json` preferred, first-arrangement fallback), with per-measure tempo from downbeat spacing (emitted only on a > 1 BPM change). Durations come from the wire sustain (`sus`, legacy `l` alias) when present, else the gap to the next onset in the same hand — quantized to the nearest plain/single-dotted `{1,2,4,8,16,32}` denominator at the local tempo, floored at a 32nd. Hands are split heuristically: onsets within 10 ms form a group; a group spanning > 12 semitones splits at its largest internal interval gap (low side → `lh`), otherwise the whole group goes by mean pitch vs middle C — single-staff output when everything lands on one hand. Idempotent (arrangements already carrying `notation:` are skipped; an orphan `notation_<id>.json` without the manifest key is refused, not overwritten) with `--dry-run` support; every payload is checked via `notation.validate_notation` before write. Honest caveat: the manifest is round-tripped through PyYAML (`safe_load` + `safe_dump(sort_keys=False)`) — key order survives, YAML comments/custom formatting do not (the script warns when comments are present). Zip-form `.sloppak` files are reported and skipped. Tests: `tests/test_lift_keys_notation.py`.
|
- **Host "working tuning" — a live, app-wide record of what tuning your instrument is *actually* in right now (foundation; no behavior change yet).** Introduces `window.feedBack.workingTuning`, a host-owned, session-lived state distinct from any one song's tuning and from a soft opt-in default: the offsets + string-count + reference pitch the player's instrument is currently in, plus an `assumed`/`verified` provenance flag. It's **per-instrument** — your guitar's current tuning and your bass's are kept *separately* (keyed like the instrument selector, e.g. `guitar-6` / `bass-4`), so switching instruments surfaces that instrument's own remembered tuning and you only ever deal with the one you've selected. It exists so a retune — or an instrument swap mid-session — is reflected **everywhere** (the highway, the library/song-picker, and plugins like the tuner, Virtuoso, and the minigames) instead of being re-derived per surface or wrongly assumed from a fixed profile. Modeled on the shipped `tuning` capability + the `feedBack.theme` read-API: a **synchronous `get(instrument?)`** (returns the selected instrument's state, defaulting to the seed until known), a `set(state, {provenance, instrument})` mutator (the tuner becomes the sole writer in the next change), `setCurrentInstrument()` for the selector, `resetToDefault()`, and a `working-tuning-changed` event that fires on every change **and once on hydration** (carrying which instrument changed) so a late-mounting consumer is never stuck on stale state. State is **in-memory, seeded from `/api/settings` on boot and reset on restart** — a stale "you're in drop-A" assumption is worse than re-asking. Registered as a separate `working-tuning` **exclusive-owner** capability (tuner = writer, the rest = requesters). This is the foundation (plumbing only — nothing writes to it yet) of the working-tuning series, which fixes the tuner gate only ever prompting *away from* a fixed "home" tuning (never back) and makes the current tuning a first-class signal the whole app shares. Offsets use the same per-string semitone vocabulary as song tunings, so fully custom/extended tunings (e.g. a drop-A 8-string) are first-class. Frontend-only: new `static/capabilities/working-tuning.js`, loaded from `static/index.html` + `static/v3/index.html`.
|
||||||
- **Notation schema v1 freeze — completeness batch** (slopsmith#822, epic #828). Adds the low-hanging-fruit fields ahead of content production: top-level credits `rights`/`lyricist`/`arranger`; measure `pickup` (anacrusis); beat `arp` (arpeggiate), `ferm` (fermata), and **typed grace notes** — `grace: "a"` (acciaccatura, MusicXML `grace/@slash=yes`) / `"p"` (appoggiatura); note `stem` (`"up"`/`"down"` force). Pedal is settled as the existing `spd`/`sph`/`spu` trio with a documented MusicXML `<pedal start|change|stop>` mapping — no separate `ped` field. A new "v1 non-features" spec subsection pins the accepted limitations (microtonal, figured bass, mid-measure key/time/clef changes, `ott`/`barline`/ornaments/`trem`/`glis`) as additive-v1.x territory. `lib/notation.py` gains the `GRACE_TYPES`, `STEM_DIRECTIONS`, and `DYNAMICS` vocabularies; the validator stays permissive by design.
|
- **The v3 Songs grid is now DOM-virtualized — card-node count stays bounded no matter how big the library is or how far you scroll.** The grid used to append every scrolled page and never let go, so a 2000-song library grew the DOM from 24 → 624 → 2001 card nodes as you scrolled (layout/memory cost scaling with depth). It now renders only the **visible window** of cards (± a small overscan); a sizer element sized to the whole library (`ceil(total/cols) × rowH`) gives the scrollbar its full geometry while the grid is absolutely positioned to the first visible row. `state.songs` is a sparse, absolutely-indexed store fetched a page at a time on demand — using the stage-1 **keyset cursor** for contiguous forward scroll (O(page)) and falling back to `OFFSET page=` for jumps/restore/non-keyset providers (collections, remote). Verified bounded (~60 nodes for a 2001-song library while the count still reads "2001 songs"). The **A–Z rail now seeks directly**: `sort_letters` gives a letter's first-row index (cumulative of prior buckets), converted to a scrollTop in O(1) — no more paging through every intervening row (a bounded forward scan covers the rare legacy provider without `sort_letters`). Select-mode selections, accuracy badges, the ⋮ card menu, plugin card actions, scroll-restore (now scrollTop-based, since geometry is stable), and the tree/folder views all survive cards leaving and re-entering the DOM. Plugins that decorate cards get a stable `window.v3Songs.visibleCards()` accessor + a `v3:library-window-rendered` event instead of assuming every card is present (the highway-stutter lesson). Stage 2 of the virtualized-grid project (got-feedback/feedBack#636 item 3), building on the stage-1 keyset data layer below. Frontend-only: `static/v3/songs.js`, `static/v3/v3.css`. Tests: `tests/browser/v3-grid-virtualization.spec.ts` (bounded-DOM invariant across a 2001-song scroll + direct rail jump), updated `tests/js/v3_az_rail.test.js` + `tests/js/v3_songs_scroll.test.js`.
|
||||||
- **Notation format — standard musical notation as a first-class sloppak type.** Promotes keys, piano, violin, and any other staff-notation instrument out of the guitar wire format and into their own data structure, following the same promotion path used for drums (slopsmith#344). New `lib/notation.py` defines the canonical vocabulary (`CLEFS`, `DURATIONS`, `SCHEMA_VERSION`), a permissive `validate_notation()` check, and `measures_to_wire()` / `measure_to_wire()` wire helpers. `lib/sloppak.py::load_song` reads a new per-arrangement `notation:` sub-key from each arrangement entry in the manifest (Option B: per-arrangement, not song-wide), applies path-traversal guards, validates the parsed JSON via `validate_notation()`, and surfaces all notation payloads on `LoadedSloppak.notation_by_id` (a `dict[str, dict]` keyed by arrangement id). A failed or missing notation file for one arrangement does not abort or skip the arrangement itself — partial-failure isolation mirrors the drum tab loader. `file:` is now optional when `notation:` is present: the loader creates a minimal stub arrangement so a notation-only arrangement entry does not require a guitar wire format JSON. `/ws/highway/{filename}` gains two new message types — `notation_info` (staves, instrument, total measure count) and chunked `notation_measures` (32 measures per chunk) — streamed after `sections` and before `anchors`; `song_info` carries a new `has_notation: bool` flag so viz pickers can auto-activate the notation plugin regardless of arrangement name. The notation file schema is measure-structured (`measure → staff → voice → beat → note`), uses MIDI for pitch (no string/fret/tuning indirection), and carries the full set of effects that alphaTab can render. See `docs/sloppak-spec.md` §5.3 for the full schema. Open questions resolved per the piano/keys epic (slopsmith#828 / #822): Option B (per-arrangement `notation:` sub-key) and `file:`-optional-when-`notation:`-present are the endorsed design.
|
- **Keyset (cursor) pagination for the library grid — the data layer for an upcoming virtualized grid, and a latent paging bug fixed along the way.** Every library sort now carries a unique `filename` tiebreak, making the order **total** — which fixes a latent bug where rows sharing a sort key (e.g. two songs by the same artist) could be skipped or duplicated across `OFFSET` pages. `GET /api/library` gains an opaque `after` cursor + a `next_cursor` in the response: passing the cursor back fetches the next page with a **WHERE-seek** instead of `OFFSET`, so deep paging is O(page) regardless of depth. The seek is NULL-aware and exactly `OFFSET`-equivalent (verified across artist/title/recent, ascending + descending, including the legacy `dir=desc` shape and NULL sort keys); unknown/compound sorts and bad cursors fall back to `OFFSET`, and only the local provider is handed a cursor (collections/remote page by `OFFSET`). New composite `(artist NOCASE, filename)` / `(title NOCASE, filename)` / `(mtime, filename)` indexes cover the order. This is stage 1 of the virtualized-grid project (got-feedback/feedBack#636 item 3); the DOM-recycling render window builds on it next. Tests: `tests/test_library_keyset.py` (keyset==OFFSET parity, stable tiebreak, dir=desc, NULL keys, cursor fallback).
|
||||||
|
- **Smart collections — save a set of library filters as a live, auto-updating source.** A collection is a saved `/api/library` query (e.g. "Drop-D tunings", "sloppak only", "recently added") that stays live: it's registered as a **library provider**, so it shows up in the v3 Songs source picker and inherits the whole grid UI — paging, stats, the A–Z rail, art — for free, with **no new screen**. Storage reuses the playlist subsystem (a `playlists.rules` JSON blob = a smart collection; membership is the live filter result, not stored songs, and collections are excluded from the manual-playlist list + read-only to playlist mutations). New `GET`/`POST`/`PUT`/`DELETE /api/collections`; a per-collection `SmartCollectionProvider` delegates `query_page`/`query_stats`/`query_artists` to the local DB with the stored rules applied; providers are re-registered from a boot scan so collections survive a restart. Rules mirror the raw `/api/library` query params (unknown keys dropped, never 500). Frontend: a "+ Save as collection" action in the v3 filter drawer (shown when filters are active) names the current filter set and switches to it. The charrette's "the homelab primitive FeedBack was missing" pick (got-feedback/feedBack#636 item 2); richer rule fields (accuracy, genre, difficulty) follow as the metadata work lands. Tests: `tests/test_collections_api.py`, `tests/js/v3_collections.test.js`.
|
||||||
|
- **The settings backup now includes your library database + custom art — your scores, favorites, playlists, and play history are no longer the one thing a backup can't save.** `GET /api/settings/export` gains an additive `core_server_files` section carrying a **consistent snapshot of `web_library.db`** (taken via the SQLite online-backup API, so it's a complete single file even while the server is running) plus any custom **playlist covers** and **avatar** (`CONFIG_DIR/playlist_covers/`, `CONFIG_DIR/avatars/`). On `POST /api/settings/import` the database is **staged** to `web_library.db.restore` rather than written over the live, open DB; it's swapped in at the next startup (`_apply_pending_db_restore`, before the connection opens), which also clears the old WAL sidecars so a stale `-wal` can't be replayed onto the restored file — the import response sets `restart_required: true` and warns accordingly. Custom art is written immediately. The bundle stays backward-compatible (older servers ignore the new section). Came out of the library design charrette (dev-ops lens's top "protect irreplaceable data" pick, got-feedback/feedBack#636). _Known gap:_ custom uploaded **song** art is still commingled with the rebuildable thumbnail cache in `art_cache/`, so it isn't bundled yet (a tracked follow-up). Tests: `tests/test_settings_export_library_db.py` (snapshot consistency, staged-not-live restore, sidecar clearing, traversal rejection, full round-trip).
|
||||||
|
- **A persisted wishlist — keep a list of songs you want but don't own yet.** New `wanted` table + `GET`/`POST`/`DELETE /api/wanted` give FeedBack the *arr-style "Wanted/Monitored" primitive it was missing: an entry is a *not-owned* song (artist/title/source/source_ref/note), so it lives in its own table rather than the playlist subsystem (which references owned local files). The API is idempotent on identity (case-insensitive artist+title, plus source+source_ref), so a producer — the `find_more` ownership-diff, or a manual add — can re-post without duplicating. Newest-first. Backend primitive for the charrette's wishlist finding (got-feedback/feedBack#636 item 4); the consuming UI lives in the producing plugin. Tests: `tests/test_wanted_api.py`.
|
||||||
|
- **Practice-aware library home — a "Repertoire" meter + a "Keep practicing" shelf on the v3 Songs page.** The library opened cold into a flat sorted grid; now the unfiltered grid front door leads with two practice-aware surfaces built entirely from data already on hand (no new endpoints or stored state). A **Repertoire meter** shows how much of your library you can actually play — *"Repertoire: 12 of 80 songs · 7 in progress"* with a progress bar — counting songs at or above the same mastery threshold the green accuracy badge uses (≥ 90% best accuracy) over the unfiltered library total. A **"Keep practicing" shelf** is a horizontal row of your recently-played-but-not-yet-mastered songs (newest first, click to play) — the practice-accuracy-driven "continue" rail a media server can't do. Both reuse `/api/stats/best` (already loaded for the card badges) + `/api/stats/recent`; they show **only** on the grid view when you aren't searching/filtering/selecting, refresh after a song is scored, and collapse to nothing on an empty library. Soft-gamification only — descriptive encouragement (goal-gradient / endowed-progress), never content-gating, decay, or nagging. Frontend-only: `static/v3/songs.js` (`renderLibraryHome`/`_repertoireCounts`), `static/v3/v3.css`. Came out of the library design charrette (the UX + gamification lenses' top pick). Tests: `tests/js/v3_keep_practicing.test.js`.
|
||||||
|
- **A–Z fast-scroll rail on the v3 Songs grid.** A vertical letter rail (Plex/Radarr/iOS-contacts pattern) pinned to the right edge next to the scrollbar lets you jump the library to a starting letter — tap a letter, drag to scrub with a live letter bubble, or arrow-key between letters. It shows **only** for the grid view + alphabetical (artist/title) sorts, and only offers letters actually present in the current sort **and filter set**, so a tap always lands on a real card (absent letters are dimmed + non-interactive). Because the grid is forward-only, server-paged infinite scroll, a jump pages through to the target card and scrolls to it (a newer jump supersedes an in-flight one); a keyset-seek + virtualized window is the noted scaling follow-up for very large libraries. Backend: `/api/library/stats` now accepts `sort` and returns an additive `sort_letters` map (songs-per-first-letter of the active sort column — artist or title), filter-synced; the legacy `letters` (distinct-artist) field is unchanged for the dashboard + classic tree. Frontend: `static/v3/songs.js` (`refreshRail`/`jumpToLetter`, cards tagged with `data-letter`), `static/v3/v3.css` (`.v3-azrail`). The classic (v2) tree already had letter selection; this brings the new grid to parity. Tests: `tests/test_library_filters.py` (sort_letters artist/title + song-vs-artist counting), `tests/test_library_providers.py` (sort forwarded to providers), `tests/js/v3_az_rail.test.js`.
|
||||||
|
- **Playlists get content-dependent covers + custom art.** Playlist cards were a tiny `🎵` emoji on an empty square. Now a playlist's cover reflects its contents: **empty → the icon**, **a few songs → the first song's album art**, **4+ songs → a 2×2 art mosaic**. You can also **upload a custom cover** (a "Cover" button in the playlist detail view → image picker; "Remove cover" reverts to the content view). `MetadataDB.list_playlists()` now returns each playlist's first few song `art_urls`; `GET /api/playlists` and `GET /api/playlists/{id}` add `cover_url` when a custom cover exists. New routes `POST` / `GET` / `DELETE /api/playlists/{id}/cover` store a small PNG thumbnail under `CONFIG_DIR/playlist_covers/` (PIL-converted, like song-art upload); the cover is removed with the playlist. Frontend: `playlistCoverHtml(p)` in `static/v3/playlists.js`. Tests: `tests/test_playlists_api.py` (art_urls + cover roundtrip / reject-non-image / delete-cleanup), `tests/js/v3_playlist_cover.test.js`.
|
||||||
|
- **v3 Songs: "Add to playlist" is now on each song's ⋮ "More" menu.** Previously a song could only be added to a playlist through select-mode (the checkbox → batch bar). The per-card overflow menu now has an **Add to playlist** row that targets that one song, reusing the same picker (choose a listed number or type a new name to create it). The select-mode batch flow and the single-song menu now share one extracted `addFilenamesToPlaylist(filenames)` helper in `static/v3/songs.js` (both grid and tree rows, since they share `openCardMenu`). Tests: `tests/js/v3_add_to_playlist_menu.test.js`.
|
||||||
|
- **Resume where you left off — leaving a song now snapshots your place so an exit is recoverable, not a restart-from-zero.** Exiting the player (`showScreen()` teardown, before audio unload) writes `{song, arrangement, position, speed}` to `localStorage` (`feedBack.resumeSession`), and a non-blocking **"Resume practice"** pill offers it back on the next non-player screen (and on the next app launch). Clicking Resume re-enters the song, restores the arrangement + playback speed, and seeks to the saved position via the existing `_audioSeek` funnel; `playSong()` gains a `{ resume: {position, speed} }` option that arms a `song:ready`-consumed restore instead of the normal autostart, so the two never fight over playback. The snapshot is deliberately conservative — ignored for a song you barely started (< 3s) or had basically finished (within 5s of the end), cleared on natural song-end and once consumed, and expired after 24h. The pill is self-contained (inline-styled, body-appended, works identically in the classic and v3 shells with no Tailwind rebuild), never blocks, and a dismiss forgets the current snapshot for the session. This pairs with the Escape focus fix: now that Escape reliably leaves regardless of focus, an *accidental* exit is one tap to undo. Public surface: `window.resumeLastSession()` / `window.feedBack.resumeLastSession`. (The broader nav-state work — returning to a song after wandering into Settings → Tone Builder — is a separate, larger track; this lands the player-session slice.) Tests: `tests/browser/resume-session.spec.ts` (snapshot guards, staleness, pill show/hide/dismiss, resume consumption).
|
||||||
|
- **Optional "Ask before leaving a song" confirm (Gameplay tab, default OFF).** A new client-only toggle (`confirmExitSong` in `localStorage`, in the v3 Gameplay settings + the Gameplay "Reset" set) for players who want a guard against an accidental exit. **Off by default — Escape leaves instantly, zero change for everyone else.** When on, a *user-initiated* exit (the player-scope Escape shortcut, or the player's ✕) opens a small true-modal confirm instead of leaving; auto-exit on song-end and a results screen's own Close are unaffected (they call `closeCurrentSong()` directly, which stays the unguarded actual-exit). The confirm honors the team's refined asks: **opening it pauses the song** (so it isn't running or being scored behind the prompt) and **Stay resumes exactly what was paused**; **Escape = Stay** — the dialog's capture-phase handler *dismisses* it (now consistent with every other modal and the generic `_confirmDialog`'s Esc=cancel), so a second Escape returns you to the (resumed) song rather than leaving; **Space/Enter (or click) Leave** by natively activating the default-focused "Leave" button ("just get me out"). Pause/resume run through the canonical `togglePlay()` path (HTML5 + `_juceMode`), guarded so a count-in, an already-paused song, or a teardown/seek/end behind the modal can't mis-resume. It's a real modal (`role="dialog" aria-modal="true"` / `.feedBack-modal`) with **Tab trapped inside it** and a **backdrop click that also Stays**, so the Escape/Space focus carve-outs treat it as a trap and don't fire player-back / play-pause behind it. The player Escape shortcut and the v3 ✕ route through a shared `window.requestExitSong()` gate (the ✕ also becomes origin-aware, matching Escape). Tests: `tests/browser/exit-confirm.spec.ts` (default-off instant exit, confirm-on opens + stays, second-Escape stays, backdrop stays, Stay/Leave, Enter-leaves); the audio pause/resume is verified manually on web + desktop (the mock song has no backing track).
|
||||||
|
- **Folder Library — a bundled core plugin (`plugins/folder_library/`) that browses the DLC library by its on-disk folder tree.** Surfaces top-level folders → subfolders → songs (root-level songs land in `(Unsorted)`), with in-app folder management (create / rename / delete nested folders), song moves via dialog or drag-and-drop, and sort/filter that mirrors the host library's filter state. Wired into both the classic (v2) library toolbar and the v3 Songs page as a third **Folders** view alongside grid/tree; the plugin's `screen.js` is loaded once by the host and reused (idempotent IIFEs). Supersedes the former standalone "Folder Organizer" community plugin (removed from the README list). Backend (`routes.py`) registers `/api/plugins/folder_library/{tree,folder/create,folder/rename,folder/delete,song/move}`; **all filesystem mutations are confined to `DLC_DIR` and validated against path traversal** (per-segment name validation plus a resolved-containment check on `song/move`), and folder deletion relocates every song — de-duplicating colliding names — so a name clash never destroys a song. A two-level cache keeps re-opening folders fast. Tests: `tests/plugins/folder_library/test_routes.py` (path-safety helpers + move-traversal and delete-no-data-loss end-to-end).
|
||||||
|
- **Full-screen (immersive) plugin screens — opt-in via `"fullscreen": true` in `plugin.json`.** DAW-style plugin UIs (e.g. a practice studio) need the whole viewport, not a scrolling content page below the topbar — embedded in the v3 shell they get cut off at the bottom with dead space up top. A plugin can now declare a top-level `"fullscreen": true`; `plugins/__init__.py` surfaces it as the `fullscreen` boolean on `/api/plugins` (mirroring the `settings_category` plumbing). When such a plugin's screen is active, `static/v3/shell.js` toggles `html.fb-immersive` from `syncActive()` (so it tracks every navigation incl. deep-link), and `static/v3/v3.css` hides the topbar, collapses the sidebar to a functional **icon rail** (kept reachable — Escape is bound only on player/settings scopes, so a fully-hidden sidebar would trap the user), and lets the active plugin screen fill `#v3-main`. Mirrors the existing `ss-follower-pre` chrome-hide pattern. Additive + opt-in: plugins without the flag are unaffected. Tests: `tests/test_plugins.py::test_fullscreen_flag_parsed_from_manifest`.
|
||||||
|
- **Achievements wall sync — background drain worker (epic PR3, client side).** The bundled `achievements` plugin gains a dead-letter sync worker that POSTs queued Feat unlocks (and removals) to the hosted **feedback-achievements** wall service (separate repo). Idle unless a wall URL is configured (`FEEDBACK_ACHIEVEMENTS_WALL_URL`); uses `requests` with the baked-in client-token header, mirroring `lib/lyrics_transcribe`'s outbound pattern (explicit timeout, no raise on non-2xx). **Dead-letter, never drop** (pure `engine.drain_decision`): network error / `429` / `5xx` → keep `pending` (retry); other `4xx` → `dead_letter` (diagnosable, replayable); `2xx` → delete on server ack. A row leaves the queue only on ack or a user opt-out. `remove-me` now enqueues a wall removal keyed by the reused `player_hash`. Verified by an end-to-end staging round-trip (earn a Feat → drains onto the wall with name + short hash → `remove-me` → wall empties) with **no IP** in tables or access logs. Tests: `tests/plugins/achievements/test_sync.py` (decision table + ack/retry/dead-letter retention + four-field payload on the wire). The hosted service itself (FastAPI + SQLite-on-disk, Feats-only, hidden-until-first-global-unlock, profanity filter, in-memory rate limit, Render blueprint, migration tool) lives in the new `feedback-achievements` repo.
|
||||||
|
- **Achievements wall — opt-in, privacy controls & data-minimization gate (epic PR2).** Sharing earned **Feats** on the (forthcoming) public wall is strictly opt-in. A new **onboarding step** (`static/v3/profile.js`, inserted after song-directory / before instrument paths — the wizard is now five steps) presents a plain-language card: it publishes only your display name and the Feats you earn, never songs/skills/scores, and is **off by default**. The bundled plugin's Settings panel (`plugins/achievements/settings.html`, mounted under the **System** tab via `settings.category`) carries the same toggle plus a **"Remove me from the wall"** button (`POST /api/plugins/achievements/remove-me` — wipes local synced state offline + enqueues a wall removal). Core adds `achievements_enabled` (bool, default `false`) to `_default_settings()` + the `/api/settings` validation block + `_RESETTABLE_SETTINGS_KEYS` in `server.py`, mirrored to `localStorage` in `app.js loadSettings()`. **Data-minimization contract (binding, code-enforced):** every outbound payload is built by a single explicit-dict serializer (`engine.build_wall_payload`, never `dict(row)`/`**model`) whose key-set is **exactly** `{display_name, player_hash, achievement_id, unlocked_at}` with `achievement_id` always a **Feat** id — a unit test asserts the four-field set and goes red on a fifth. Enqueue is doubly gated: it happens only when opted-in **and** a profile identity (name + the reused `player_hash`) exists; **competency unlocks never enqueue** (integration law). Tests: `tests/plugins/achievements/test_datamin.py` (key-set, opt-out/identity/competency gating) + `tests/test_settings_api.py` (flag persists/validates/resettable).
|
||||||
|
- **Achievements & Feats of Power — local engine + tabbed Profile (epic PR1).** The Profile screen (`static/v3/profile.js`) becomes **tabbed** exactly like the v3 Settings page (`.fb-tabbar` / `.fb-tab[data-tab]` / `.fb-tabpanel[data-tab]`, active-tab persisted in `localStorage 'v3-profile-tab'`): a **Profile** (main) tab carrying the existing header + best-scores cards plus a new **Feats of Power** trophy shelf mount (`#v3-profile-feats-slot`, earned-only / hidden-until-earned), and an **Achievements** tab with a plugin mount (`#v3-profile-achievements-mount`) + `[data-empty-for]` empty note. Core dispatches a new **`v3:profile-rendered`** event after every render (mirrors `v3:settings-rendered`) so the plugin re-injects on each profile entry. A new bundled **`plugins/achievements/`** plugin owns the engine: SQLite under `<config_dir>/achievements/achievements.db` (`unlocks` / `counters` / `comp_ledger` / `sync_queue`), pure threshold/criterion math in the testable sibling `engine.py` (P-V), and routes under `/api/plugins/achievements/` (`activity`, `report-unlock`, `report-criterion`, `catalog`, `earned`, `feats`, `remove-me`). **Two surfaces, one engine, structurally separated (integration law):** **Feats** (activity/volume — Note Hunter, Marathon, Untouchable, Road Warrior, Time Served, Encore, two 🥚 secrets) read activity counters only, evaluated from a batched `song:ended` activity POST (notes only when **notedetect** is present — graceful degradation, no fake progress); **competency Achievements** (baseline: First Steps / Ascendant / Steady Hands / Renaissance + per-instrument Apprentice·Journeyman·Master / Personal Best / Challenger) are evaluated from **progression events only** and never re-derived from activity. The Achievements catalogue is always shown (locked = greyed), grouped by a secondary pill row over the **real progression paths** (Global / Guitar / Bass / Drums / Keys — auto-extends to new paths) with a per-category "X / Y earned" badge, defaulting to the player's primary path. Source plugins contribute their own competency defs and report unlocks through a versioned **`window.feedBack.achievements`** API (`register`/`registerAll`/`unlock`/`progress`), load-order-safe via the `window.__feedBackAchievementsPending` queue + an `achievements:ready` event (minigames pending-queue pattern); an absent source contributes nothing (no dead greyed rows). Opt-in publishing to a hosted Feats wall, the Settings privacy toggle, and the data-minimization gate land in epic PR2/PR3. Tests: `tests/plugins/achievements/test_engine.py` + `test_routes.py` (incl. the integration-law assertion that a competency unlock never reaches the Feats shelf).
|
||||||
|
- **v3 settings page redesigned as a tabbed, card-row layout.** The single long scrolling settings screen becomes a horizontal tab bar (Gameplay / Audio / Graphics / Keybinds / Progression / Mic / Plugins / System) over card rows — each a leading icon + title + description with the control (toggle/dropdown/slider) on the right, plus a per-category "Reset" action. The markup lives in `static/v3/index.html` (so existing element ids keep hydrating through the unchanged `app.js` `loadSettings()`/`persistSetting()` path); a new `static/v3/settings.js` owns tab switching + active-tab persistence (`localStorage 'v3-settings-tab'`), the per-category reset, and a read-only **Keybinds** reference built from the live shortcut registry (`window.getAllShortcuts()`); styling is plain CSS in `static/v3/v3.css` (no Tailwind rebuild). **Plugins choose their settings tab** via a new optional `settings.category` field in `plugin.json` (`plugins/__init__.py` surfaces it as `settings_category`; `app.js` mounts each plugin's `<details>` panel into `#plugin-settings-<category>`, falling back to the generic Plugins tab) — `highway_3d` ships `category: "graphics"`; the out-of-repo notedetect/progression plugins should declare `"mic"` / `"progression"`. **New gameplay settings:** **Countdown before song** (a four-beat count-in before playback, wired end-to-end via the existing count-in engine + the song-start autostart path; key `countdown_before_song`, default off); **Miss penalty** (`miss_penalty`) and **Fail behavior** (`fail_behavior`) are persisted now but not yet consumed by scoring (shown with a "Not yet active" badge). "Note highway speed" surfaces the existing `master_difficulty` and stays in sync with the player-popover difficulty slider. New `POST /api/settings/reset` clears chosen keys back to defaults. Tests: `tests/test_settings_api.py` (new keys + reset), `tests/test_plugins.py::test_settings_category_parsed_from_manifest`, `tests/browser/settings-tabbed.spec.ts`.
|
||||||
|
- **Full-mix audio exposed alongside stems for the stem mixer's auto-switch.** `lib/sloppak.py::load_song` now parses the optional manifest `original_audio:` key (the single pre-separation mixdown, e.g. `original/full.ogg`) into a new `LoadedSloppak.original_audio` field, with the same path-traversal guard and permissive "missing → disabled" posture as the `drum_tab` loader. The highway WS `song_info` frame additively carries three new fields next to `stems`: `original_audio_url` (served by the existing `/api/sloppak/{filename}/file/{rel_path}` endpoint, `None` when the pack ships stems only), `has_original_audio`, and `has_stems` (mirroring the `has_drum_tab`/`has_keys` flag convention). The stems plugin consumes `original_audio_url` to play the untouched single file while every stem slider is at unity and switch to the separate stems the moment one drops below 100%. **Migration notes:** the `song_info` message shape is a stable contract — these are purely additive; all existing fields are unchanged. `audio_url` still points at stem[0] when stems exist (it is only the degraded native fallback); the one behavioural change is that a stem-less, full-mix-only sloppak now sets `audio_url` to the full mix instead of emitting `audio_error`, so it plays natively.
|
||||||
|
- **Autoplay & auto-exit — a global "click it, it plays; finish, you're back at the menu" option (default ON).** New single Settings toggle (`autoplayExit` in `localStorage`, surfaced in both the v3 and classic settings screens; absence of the key = enabled) that closes the friction at both ends of the play loop. **Autoplay:** `playSong()` previously loaded a chart paused, requiring a Play press; a one-shot flag armed per fresh load is now consumed by the next `song:ready` (highway.js) to auto-start via the existing `togglePlay()` path (HTML5 + `_juceMode` + count-in). Arrangement switches / seeks reuse the same `song:ready` event but never arm the flag, so they don't auto-restart. **Auto-exit:** on `song:ended`, core returns to the launching menu after a short grace delay — unless a visible full-screen results/dialog overlay is on top (detected via `[role=dialog][aria-modal]` / `.fixed.inset-0` with a `getClientRects()` visibility test that works for `position:fixed`), in which case the return is deferred so that score screen's own Close button (calling `window.closeCurrentSong()`) drives the exit. A plugin can also defer explicitly via the new `window.feedBack.holdAutoExit()` (called synchronously from its own `song:ended` handler — core's listener runs first). Both paths mean **no external plugin PR is required** for a results screen to be respected. **Context-aware destination:** the player's remembered origin (`_playerOriginScreen`) now honours any real launch screen instead of clamping to library/home/favorites, and a one-shot `window.feedBack.setReturnScreen(id)` override lets the lessons catalog (`static/v3/lessons.js`) send a finished lesson back to the lessons screen — not the song library — even though the external tutorials plugin owns the `playSong` call. Also exposes a read-only `window.feedBack.autoplayExit` getter for plugins. Songs and lessons share the same `playSong` → highway path, so both inherit the behaviour. Core-only (`static/app.js`, `static/v3/lessons.js`, both `index.html`s); the end-of-song score screen itself remains a plugin. Optional polish (not required — the overlay heuristic already covers it): external scoring/note-detection plugins (e.g. SlopScale) may call `holdAutoExit()` + `closeCurrentSong()` for an exact, heuristic-free handoff.
|
||||||
|
- **"Song Editor" promoted to a first-class v3 sidebar item.** The editor
|
||||||
|
plugin (`id: editor`) now gets its own dedicated sidebar entry — under the
|
||||||
|
Library group, just below Songs — via the existing `PROMOTED_PLUGINS`
|
||||||
|
mechanism in `static/v3/shell.js`, instead of being reachable only through
|
||||||
|
the generic Plugins gallery. Gated on the plugin actually being installed
|
||||||
|
(`renderPromotedNav` checks `/api/plugins`), so it appears only when the
|
||||||
|
editor is loaded. The displayed label comes from the plugin's manifest
|
||||||
|
`nav.label`.
|
||||||
|
- **Guitar Pro → notation importer (`lib/gp2notation.py`)** (feedBack#825 WS4b, epic #828). Piano/keys tracks imported from Guitar Pro (GPIF: `.gpx` GP6 / `.gp` GP7-8) now produce real Sloppak Notation Format data (sloppak-spec §5.3) alongside the `midi = string*24 + fret` guitar wire encoding. `gp2rs_gpx.convert_file` writes a `<stem>.notation.json` sidecar next to each keys arrangement XML (best-effort — a notation bug never breaks the RS-XML conversion), and `gp2notation.attach_notation_to_sloppak()` is the assembly-side helper that renames it into `notation_<id>.json` + adds the per-arrangement `notation:` manifest sub-key. Voice→staff routing salvages the logic from PR #703 (whose `stf` wire-field approach this supersedes): GP voice position 0 → `rh` staff (`G2`), positions ≥ 1 → `lh` (`F4`); a forced-LH track (the merged `Piano LH` partner from `_find_piano_pairs`, or a standalone track named `… LH`) routes everything to `lh` — preserving authored hand crossings instead of inferring hands from pitch. Emits measures with absolute `t` from the bar-indexed tempo map, change-only `ts`/`tempo`/`ks`, and `beat_groups` for compound/irregular meters (6/8 → `[3,3]`, 9/8 → `[3,3,3]`, 5/8 → `[2,3]`, 7/8 → `[2,2,3]` — cf. the feedBack#261 denominator pitfalls); beats carry `dur`/`dot`/`tu`/`rest` from GP rhythms and notes carry absolute `midi` (String+Fret resolves via the string template's concert pitches, Tone+Octave via `(octave+1)*12 + step`) with `tied` continuations kept as real beats (engraving needs the tied notehead — unlike the RS-XML walk, which drops them and extends sustain). Timing reuses the `gp2rs_gpx` machinery (bar-indexed tempo map, per-beat rhythm durations, `_note_midi`) so notation lines up with the RS XML the highway plays — with one deliberate divergence: double dots advance time ×1.75 (vs. the RS-XML walk's single-dot ×1.5 approximation) so a written `dot: 2` agrees with the emitted beat times; sharing the walk itself stays tracked in feedBack#618. Tests: `tests/test_gp2notation.py`.
|
||||||
|
- **Legacy keys → notation lifter (`scripts/lift_keys_notation.py`)** (feedBack#825 WS4c, epic #828). One-time batch converter that lifts existing **directory-form** piano/keys sloppaks from the legacy guitar wire encoding (`midi = s*24 + f`) into real Sloppak Notation Format files (sloppak-spec §5.3). Candidates are arrangements whose name matches `\b(keys|piano|keyboard|synth)\b` (case-insensitive); each gets a `notation_<id>.json` plus the per-arrangement `notation:` manifest sub-key. Measures derive from the song-level `beats` downbeats (`measure >= 0`; `song_timeline.json` preferred, first-arrangement fallback), with per-measure tempo from downbeat spacing (emitted only on a > 1 BPM change). Durations come from the wire sustain (`sus`, legacy `l` alias) when present, else the gap to the next onset in the same hand — quantized to the nearest plain/single-dotted `{1,2,4,8,16,32}` denominator at the local tempo, floored at a 32nd. Hands are split heuristically: onsets within 10 ms form a group; a group spanning > 12 semitones splits at its largest internal interval gap (low side → `lh`), otherwise the whole group goes by mean pitch vs middle C — single-staff output when everything lands on one hand. Idempotent (arrangements already carrying `notation:` are skipped; an orphan `notation_<id>.json` without the manifest key is refused, not overwritten) with `--dry-run` support; every payload is checked via `notation.validate_notation` before write. Honest caveat: the manifest is round-tripped through PyYAML (`safe_load` + `safe_dump(sort_keys=False)`) — key order survives, YAML comments/custom formatting do not (the script warns when comments are present). Zip-form `.sloppak` files are reported and skipped. Tests: `tests/test_lift_keys_notation.py`.
|
||||||
|
- **Notation schema v1 freeze — completeness batch** (feedBack#822, epic #828). Adds the low-hanging-fruit fields ahead of content production: top-level credits `rights`/`lyricist`/`arranger`; measure `pickup` (anacrusis); beat `arp` (arpeggiate), `ferm` (fermata), and **typed grace notes** — `grace: "a"` (acciaccatura, MusicXML `grace/@slash=yes`) / `"p"` (appoggiatura); note `stem` (`"up"`/`"down"` force). Pedal is settled as the existing `spd`/`sph`/`spu` trio with a documented MusicXML `<pedal start|change|stop>` mapping — no separate `ped` field. A new "v1 non-features" spec subsection pins the accepted limitations (microtonal, figured bass, mid-measure key/time/clef changes, `ott`/`barline`/ornaments/`trem`/`glis`) as additive-v1.x territory. `lib/notation.py` gains the `GRACE_TYPES`, `STEM_DIRECTIONS`, and `DYNAMICS` vocabularies; the validator stays permissive by design.
|
||||||
|
- **Notation format — standard musical notation as a first-class sloppak type.** Promotes keys, piano, violin, and any other staff-notation instrument out of the guitar wire format and into their own data structure, following the same promotion path used for drums (feedBack#344). New `lib/notation.py` defines the canonical vocabulary (`CLEFS`, `DURATIONS`, `SCHEMA_VERSION`), a permissive `validate_notation()` check, and `measures_to_wire()` / `measure_to_wire()` wire helpers. `lib/sloppak.py::load_song` reads a new per-arrangement `notation:` sub-key from each arrangement entry in the manifest (Option B: per-arrangement, not song-wide), applies path-traversal guards, validates the parsed JSON via `validate_notation()`, and surfaces all notation payloads on `LoadedSloppak.notation_by_id` (a `dict[str, dict]` keyed by arrangement id). A failed or missing notation file for one arrangement does not abort or skip the arrangement itself — partial-failure isolation mirrors the drum tab loader. `file:` is now optional when `notation:` is present: the loader creates a minimal stub arrangement so a notation-only arrangement entry does not require a guitar wire format JSON. `/ws/highway/{filename}` gains two new message types — `notation_info` (staves, instrument, total measure count) and chunked `notation_measures` (32 measures per chunk) — streamed after `sections` and before `anchors`; `song_info` carries a new `has_notation: bool` flag so viz pickers can auto-activate the notation plugin regardless of arrangement name. The notation file schema is measure-structured (`measure → staff → voice → beat → note`), uses MIDI for pitch (no string/fret/tuning indirection), and carries the full set of effects that alphaTab can render. See `docs/sloppak-spec.md` §5.3 for the full schema. Open questions resolved per the piano/keys epic (feedBack#828 / #822): Option B (per-arrangement `notation:` sub-key) and `file:`-optional-when-`notation:`-present are the endorsed design.
|
||||||
- **`song_timeline.json` — beats and sections as a top-level file.** A new optional top-level file pointed at by a new manifest key (`song_timeline: song_timeline.json`) provides the correct home for song-wide beats and sections, replacing the legacy convention of embedding them in the first arrangement JSON. The loader in `lib/sloppak.py` reads and validates the file (must be a dict with `beats` and `sections` as lists), clears and repopulates `Song.beats` / `Song.sections` from it when present, and stores the raw dict on `LoadedSloppak.song_timeline`. The existing arrangement-JSON fallback is fully preserved: all existing sloppaks that omit `song_timeline:` continue to load without any change. This is a prerequisite for notation-only sloppaks, which may have no arrangement JSON at all and therefore no carrier for beats/sections data. New sloppaks should put beats/sections in `song_timeline.json` only. See `docs/sloppak-spec.md` §2 and §5.3.
|
- **`song_timeline.json` — beats and sections as a top-level file.** A new optional top-level file pointed at by a new manifest key (`song_timeline: song_timeline.json`) provides the correct home for song-wide beats and sections, replacing the legacy convention of embedding them in the first arrangement JSON. The loader in `lib/sloppak.py` reads and validates the file (must be a dict with `beats` and `sections` as lists), clears and repopulates `Song.beats` / `Song.sections` from it when present, and stores the raw dict on `LoadedSloppak.song_timeline`. The existing arrangement-JSON fallback is fully preserved: all existing sloppaks that omit `song_timeline:` continue to load without any change. This is a prerequisite for notation-only sloppaks, which may have no arrangement JSON at all and therefore no carrier for beats/sections data. New sloppaks should put beats/sections in `song_timeline.json` only. See `docs/sloppak-spec.md` §2 and §5.3.
|
||||||
- **`note-detection` capability domain promoted — control plane (spec 009)** (slopsmith#727/#728, epic #828). New core host `static/capabilities/note-detection.js`: provider registry (kinds `midi`/`engine`/`js`, primitives `pitch.estimate`/`verify.target`), requester-owned context-scoped detection bindings (`open-binding`/`close-binding`/`set-target`/`clear-target` — each binding carries its own redacted tuning context, independent of the host's loaded song, per spec-009 FR-003), and hit/miss/verdict observability events (consumers own judgment). The legacy chart-coupled `highway.setNoteStateProvider` surface keeps working and is wrapped for compatibility-shim hit accounting. Diagnostics (`slopsmith.note_detection_capability.v1`) carry provider/binding summaries and bounded outcomes — no raw audio, device labels, or song identity. Migrating the chart path, Step Mode verify, minigames YIN, and the engine verifier onto bindings is the remainder of the spec-009 slice.
|
- **`note-detection` capability domain promoted — control plane (spec 009)** (feedBack#727/#728, epic #828). New core host `static/capabilities/note-detection.js`: provider registry (kinds `midi`/`engine`/`js`, primitives `pitch.estimate`/`verify.target`), requester-owned context-scoped detection bindings (`open-binding`/`close-binding`/`set-target`/`clear-target` — each binding carries its own redacted tuning context, independent of the host's loaded song, per spec-009 FR-003), and hit/miss/verdict observability events (consumers own judgment). The legacy chart-coupled `highway.setNoteStateProvider` surface keeps working and is wrapped for compatibility-shim hit accounting. Diagnostics (`feedBack.note_detection_capability.v1`) carry provider/binding summaries and bounded outcomes — no raw audio, device labels, or song identity. Migrating the chart path, Step Mode verify, minigames YIN, and the engine verifier onto bindings is the remainder of the spec-009 slice.
|
||||||
- **`visualization` capability domain promoted (cap:6)** (slopsmith#828). New core host `static/capabilities/visualization.js` registers a provider-coordinator owning the highway renderer surface: commands `inspect` / `list-providers` / `select-renderer` / `clear-renderer` (selection delegates to the existing picker so persistence, WebGL2 gating, and fallback stay single-sourced), events `providers-refreshed` / `renderer-changed` / `renderer-ready` / `renderer-failed`. Legacy discovery (`type: "visualization"` manifests, `window.slopsmithViz_*` globals) keeps working unchanged and is accounted as compatibility shims with hit counts. `static/app.js` attributes every renderer change (auto-match / user-select / fallback) and auto-match outcomes into the domain. Diagnostics (`slopsmith.visualization_capability.v1`) carry provider ids/labels/context types, active renderer + selection source, last auto-match outcome, and last failure — no song filenames/titles. Per-panel (splitscreen) selection is a tracked follow-up.
|
- **`visualization` capability domain promoted (cap:6)** (feedBack#828). New core host `static/capabilities/visualization.js` registers a provider-coordinator owning the highway renderer surface: commands `inspect` / `list-providers` / `select-renderer` / `clear-renderer` (selection delegates to the existing picker so persistence, WebGL2 gating, and fallback stay single-sourced), events `providers-refreshed` / `renderer-changed` / `renderer-ready` / `renderer-failed`. Legacy discovery (`type: "visualization"` manifests, `window.feedBackViz_*` globals) keeps working unchanged and is accounted as compatibility shims with hit counts. `static/app.js` attributes every renderer change (auto-match / user-select / fallback) and auto-match outcomes into the domain. Diagnostics (`feedBack.visualization_capability.v1`) carry provider ids/labels/context types, active renderer + selection source, last auto-match outcome, and last failure — no song filenames/titles. Per-panel (splitscreen) selection is a tracked follow-up.
|
||||||
- **Viz picker routes notation arrangements** (slopsmith#826, epic #828). `window.slopsmith.currentSong` gains `hasNotation` (sibling of `hasDrumTab`) from the `song_info` frame's `has_notation` flag, so notation viz plugins (Staff View, Keys Highway 3D) can gate `matchesArrangement` on data presence instead of arrangement-name heuristics. When a notation-only arrangement (no wire notes — `file:` omitted per sloppak-spec §5.3) falls through Auto with no notation plugin installed, the built-in highway still takes the canvas but the Auto label reads "no notation view installed" and a one-shot dismissable hint points at the visualization picker — never a silently blank board.
|
- **Viz picker routes notation arrangements** (feedBack#826, epic #828). `window.feedBack.currentSong` gains `hasNotation` (sibling of `hasDrumTab`) from the `song_info` frame's `has_notation` flag, so notation viz plugins (Staff View, Keys Highway 3D) can gate `matchesArrangement` on data presence instead of arrangement-name heuristics. When a notation-only arrangement (no wire notes — `file:` omitted per sloppak-spec §5.3) falls through Auto with no notation plugin installed, the built-in highway still takes the canvas but the Auto label reads "no notation view installed" and a one-shot dismissable hint points at the visualization picker — never a silently blank board.
|
||||||
- **Keys instrument path in progression** (slopsmith#828). New `data/progression/paths/keys.json` (5 levels / 15 challenges at parity with the guitar path) plus keys-flavoured daily/weekly quest pool entries (`d.keys-one` "Ivory Tower", `w.keys-three` "Grand Recital"). `lib/progression.py::instrument_for_arrangement()` now attributes `type: piano|keys` arrangements — and names matching `keys`/`piano`/`keyboard`/`synth` on a word boundary — to the new `keys` instrument, so scored keys runs advance the path automatically. Purely content + attribution: no schema or API changes.
|
- **Keys instrument path in progression** (feedBack#828). New `data/progression/paths/keys.json` (5 levels / 15 challenges at parity with the guitar path) plus keys-flavoured daily/weekly quest pool entries (`d.keys-one` "Ivory Tower", `w.keys-three` "Grand Recital"). `lib/progression.py::instrument_for_arrangement()` now attributes `type: piano|keys` arrangements — and names matching `keys`/`piano`/`keyboard`/`synth` on a word boundary — to the new `keys` instrument, so scored keys runs advance the path automatically. Purely content + attribution: no schema or API changes.
|
||||||
- **v3 library: exact artist/album filters + scroll/page-depth restore** (slopsmith#857). The v3 Songs toolbar gains Artist and Album dropdowns (Album populates from the selected artist and stays disabled until one is chosen), backed by new exact, case-insensitive (`COLLATE NOCASE`) `artist` / `album` query params threaded through `MetadataDB._build_where` → `query_page` / `query_artists` / `query_stats` and the `/api/library`, `/api/library/artists`, `/api/library/stats` endpoints (the free-text `q` search stays fuzzy and composes with the exact filters). The artist/album catalog is fetched independently of the active artist/album selection so the dropdowns always list the full set for the current provider/search. The toolbar is now sticky so filter controls stay reachable when browsing deep libraries, and returning from the player restores the previous scroll position **and** the loaded infinite-scroll page depth via a `sessionStorage` snapshot keyed by a filter/sort/view state hash (invalidated whenever those change, so a filter change still resets to the top). Tests: `tests/test_library_filters.py` (backend artist/album filters), `tests/js/v3_songs_scroll.test.js` (state-hash + snapshot helpers).
|
- **v3 library: exact artist/album filters + scroll/page-depth restore** (feedBack#857). The v3 Songs toolbar gains Artist and Album dropdowns (Album populates from the selected artist and stays disabled until one is chosen), backed by new exact, case-insensitive (`COLLATE NOCASE`) `artist` / `album` query params threaded through `MetadataDB._build_where` → `query_page` / `query_artists` / `query_stats` and the `/api/library`, `/api/library/artists`, `/api/library/stats` endpoints (the free-text `q` search stays fuzzy and composes with the exact filters). The artist/album catalog is fetched independently of the active artist/album selection so the dropdowns always list the full set for the current provider/search. The toolbar is now sticky so filter controls stay reachable when browsing deep libraries, and returning from the player restores the previous scroll position **and** the loaded infinite-scroll page depth via a `sessionStorage` snapshot keyed by a filter/sort/view state hash (invalidated whenever those change, so a filter change still resets to the top). Tests: `tests/test_library_filters.py` (backend artist/album filters), `tests/js/v3_songs_scroll.test.js` (state-hash + snapshot helpers).
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- **v3 library scroll-restore no longer breaks the classic v2 UI or drops off-screen searches** (slopsmith#857). Two regressions in the scroll-restore work above: (1) `playSong` remapped `home`-launched songs to return to the `#v3-songs` screen unconditionally, but `static/app.js` is shared with the v2 UI (served at `/v2` / `SLOPSMITH_UI=v2`) where that screen does not exist — Esc-from-player then called `showScreen('v3-songs')`, which threw on the missing element and stranded the user on a blank screen with playback still running; the remap now applies only when `#v3-songs` is present. (2) The Songs screen-entry fast-path skips reloading to preserve scroll, but the global topbar search routed through it, so once Songs had been visited, searching from another screen navigated there without applying the new query; the screen now tracks the state hash each fetch reflects and refetches when it has drifted, keeping the scroll-preserving no-op only when nothing changed.
|
- **v3 player: opening another rail popover now closes the Section Practice popover (no more two stacked popovers).** Opening the **Practice** pill's popover and then clicking a different player-rail icon (e.g. **Plugins**) left the Practice popover open underneath the new one — looked broken (reported on macOS, 0.3.0 / 2026-06-28). The rail icons call `e.stopPropagation()` in their click handler (`static/v3/player-chrome.js`), which killed bubbling before it reached the Practice popover's outside-click dismiss bound on `document`. The dismiss (`_installSectionPracticeDismiss` in `static/app.js`) now binds in the **capture phase**, which runs before the target's handler so a descendant's `stopPropagation()` can't swallow it — mirroring how the audio-mixer popover already dismisses. Esc handling stays bubble-phase (the player's Escape-to-exit ordering is unchanged). v2 shares `app.js` and is only hardened (no rail `stopPropagation` there). Tests: `tests/js/section_practice_dismiss.test.js`.
|
||||||
- **An active custom highway renderer is no longer starved of `draw()` when it hides the canvas** (#819). The per-frame draw gate in `static/highway.js` bailed on `if (!_lastVisible) return`, which conflated two different "hidden" states: a genuine off-screen canvas (`offsetParent === null` — navigate-away / `display:none` splitscreen panel, #246) versus a renderer-set *override-hide* (`setVisible(false)`, where an opaque overlay covers the canvas but the active renderer keeps painting its own surface). The gate now only pauses everything for the off-screen case (and still pauses the default 2D renderer on an override-hide); the **active custom renderer** keeps receiving `draw()` through its own override-hide. The `highway:visibility` event still fires before the gate, so sibling overlay renderers (e.g. 3D Highway's `.h3d-wrap`) still pause. This is the core-side root cause behind the Tab View cursor freezing in single-player (slopsmith#734; worked around plugin-side in slopsmith-plugin-tabview#25).
|
- **v3 UI no longer lets you accidentally text-select the chrome.** Dragging or double-clicking across the interface used to marquee-highlight buttons, labels, the sidebar, the transport, and the note-highway HUD — which looks broken (reported on Mac + Windows). The v3 shell now defaults to `user-select: none` on `html` (`static/v3/v3.css`), then opts *content* back in — so chrome is non-selectable but the text you actually copy still works. Decided by a 4-lens panel (UX / accessibility / dev-ops / plugin-ecosystem); the guardrails are deliberate: **form fields are always re-enabled** (never break the caret / IME — no `* { user-select:none }`, which trips a WebKit input bug); **plugin screens (`.screen[id^="plugin-"]`) stay selectable by default** so a plugin's copyable text (lyrics, chord names, results) — including community plugins that don't know about this — isn't silently locked; and **core read-only content opts back in by container** via a new hand-authored **`.fb-selectable`** class — applied to the whole **Settings** panel (paths, device names, version, diagnostics, About — answering "is settings still copyable?": yes), the **now-playing song metadata** (with `pointer-events` re-enabled so the HUD text is actually reachable), and the focused **modals / dialogs / toasts / scan banner** that carry copyable errors, IDs, paths, and file names. It's cosmetic only (it protects nothing) and never used to lock copy-worthy text — errors, IDs, paths, versions, and metadata stay selectable per WCAG 2.2 (copy-paste as a permitted mechanism). Dense card lists (library grid, dashboard, profile) stay non-selectable by design — making them selectable would reintroduce the marquee-mess across cards. **v3-only** (v2 unchanged); plain CSS, no Tailwind rebuild; no desktop changes (standard OS-framed window). Plugin authors: `.fb-selectable` is documented in `CLAUDE.md` for re-enabling copyable content rendered outside a plugin screen. Tests: `tests/js/v3_user_select_policy.test.js`.
|
||||||
- **Screensaver no longer kicks in during windowed-mode playback** (#686). While a song is playing, `static/app.js` now holds a [Screen Wake Lock](https://developer.mozilla.org/en-US/docs/Web/API/Screen_Wake_Lock_API) (`navigator.wakeLock.request('screen')`) so the OS display/screensaver stays awake even though only audio + the highway animation are active and the keyboard/mouse are idle. The lock is acquired on `song:play`/`song:resume` and released on `song:pause`/`song:ended`/`song:stop` (kept only while actually playing), and re-acquired on `visibilitychange` when the tab refocuses (the API auto-releases a lock whenever the page is hidden). Both the HTML5 `<audio>` and JUCE desktop playback paths emit the same `song:*` events, so the fix covers both. In slopsmith-desktop (Electron), where `navigator.wakeLock` is unreliable, it also drives a native `powerSaveBlocker` bridge via the optional `window.slopsmithDesktop.power.setScreenAwake` hook when present; both calls degrade silently where unsupported. Note: the browser Wake Lock API is secure-context only, so in a plain browser this is active on `localhost` / HTTPS only — a session opened over plain HTTP to a LAN IP (e.g. `http://192.168.1.100:8000`) won't keep the screen awake; front it with HTTPS or use the desktop app (see README → reverse-proxy notes).
|
- **Input-setup wizard no longer collapses an audio device's driver-type variants into one entry.** On Windows the desktop engine enumerates the same interface once per host API (ASIO / Windows Audio / DirectSound), and the wizard's audio picker (`plugins/input_setup/screen.js`) de-duped the source list by display **label** — so the variants (which share a name) collapsed to a single choice, silently keeping whichever sorted first (often *not* the low-latency ASIO one the player wants). The audio-input capability already collapses true duplicates by `logicalSourceKey` (`_visibleInputSources` in `static/capabilities/audio-session.js`), and the variants each have a **distinct** key, so the wizard's extra label-collapse was redundant for real dupes and destructive for these — it also could drop the variant that was actually `selected`. Removed it; the picker now lists every selectable input. Pairs with feedBack-desktop's change to label each source with its driver type (e.g. "Focusrite (ASIO)") so the now-distinct entries are legible.
|
||||||
|
- **3D Highway FPS counter no longer hides behind the v3 "Up Next" pill.** The on-highway FPS readout (Settings → Graphics → 3D Highway → Show FPS counter) is pinned to the top-right of the highway overlay — the same corner the v3 player chrome stacks its persistent **Up Next** pill and live-performance HUD into, on a higher layer that paints over the canvas. So the readout sat *behind* that chrome and couldn't be read — precisely when a tester had turned it on to judge performance (it also made the separate "Up Next won't turn off" complaint worse, since the default-on pill covered the counter regardless). The counter now stays top-right but drops just **below** whichever of that chrome is showing: `highway_3d`'s `screen.js` measures the lowest visible top-right v3 HUD element (`#v3-upnext` / `#v3-live-performance-hud` / `#hud-time`) and floors the FPS box's Y beneath it. Element refs are resolved once and cached (no per-frame `querySelector`, per the plugin perf rules) and only consulted while the counter is actually drawn; gated on `window.feedBack.uiVersion === 'v3'` so the classic (v2) UI is byte-for-byte unaffected. `plugins/highway_3d/plugin.json` version → `3.30.1` (cache-buster). (For reading raw perf numbers unobstructed, the core perf HUD — `localStorage.highwayPerfHud='1'` — still renders above all chrome and additionally shows the adaptive render-scale.)
|
||||||
|
- **3D Highway fret-number row no longer clips off the bottom edge when the camera zooms in on a centred span.** The heat-coloured fret-number row is drawn as a band *below* the board (`sY(lowest) − S_GAP*1.4`), but the camera's self-correcting framing only anchors the board **centre** to the lower third of the screen — it reserved no headroom for that row. So a tight zoom on a centred active span (worst around mid-neck; fine when the span sits at either end of the neck, which is why testers saw it "only when centered" and "not every song") dropped the numbers past the bottom edge. Tilt can't fix it there (it would only trade a bottom clip for a top clip), so `camUpdate()` now **dollies the camera back just enough to bring the row back into frame**: it projects the row band with the final camera and, when it falls below a safe NDC line (`FRET_ROW_FIT_NDC_MIN`), raises a capped, hysteretic `_fretRowFitBoost` applied to the `curDist` lerp target (the span-driven zoom still owns zooming *in*). The boost rises promptly (proportional to the deficit), relaxes lazily past a deadband, and is capped (`FRET_ROW_FIT_BOOST_MAX`, +60%) so the zoom can't pop or hunt; it cooperates with the tilt loop (pull-back shrinks the scene, tilt keeps the centre anchored) and yields entirely to the Camera Director free-cam. Surgical: passages where the row is already visible never trigger it, so framing is unchanged everywhere it already worked. `plugins/highway_3d/plugin.json` version → `3.30.2` (cache-buster). Tests: `tests/js/highway_3d_camera_framing.test.js` (guard constants, the boosted `curDist` lerp, the projected-row hysteresis, free-cam yield).
|
||||||
|
- **v3 Songs grid now refreshes after a Settings rescan / DLC-folder change — no app restart needed.** On a fresh install, pointing at a DLC folder in Settings and running a scan left the Songs section empty until a restart (the scan *did* populate the library — `_background_scan` re-reads `config.json` fresh — but the v3 grid never reloaded). The Settings **Rescan / Full Rescan** handlers only refreshed the classic (v2) library via `loadLibrary()`; the v3 grid (`static/v3/songs.js`) had no listener for a scan it didn't start itself (only its own upload path self-refreshed via `watchUploadScan`), so its cached, pre-DLC (empty) DOM/snapshot survived a sidebar return until a full reload. The rescan handlers now emit a **`library:changed`** event (`static/app.js`); the v3 grid listens and **reloads if it's the active screen, else marks itself dirty** so the next entry does a full re-fetch instead of restoring the stale snapshot (a `_libraryDirty` short-circuit ahead of every cached-DOM fast-path in `onV3SongsScreenEnter`). Tests: `tests/js/v3_library_refresh.test.js` (the emit + the reload/dirty wiring).
|
||||||
|
- **Edit Metadata modal: the Year is now editable.** You could set a year when authoring a pak but the Songs → Edit Metadata modal had no Year field, so it could never be changed afterward. The backend (`POST /api/song/<f>/meta`) already accepted and normalized `year` (writes it into the file via `songmeta`, survives a rescan) — only the UI omitted it. Added a **Year** input to `openEditModal()` (populated from the song's existing year) and included `year` in `saveEditModal()`'s POST body (`static/app.js`). Both the v3 card menu and the legacy edit button already pass the year through, so both surfaces get the field.
|
||||||
|
- **Edit Metadata modal no longer closes when a click-drag is released on the backdrop.** Selecting text inside a field and releasing the mouse past the modal's edge dismissed the form without warning (the `click` event's target resolved to the backdrop), discarding the edit. Backdrop dismissal now requires the **mousedown to have started on the backdrop** too — tracked per-modal and decided by a new pure `_editModalShouldClose(clickTarget, modalEl, downOnBackdrop)` helper (`static/app.js`). Cancel / ✕ still close on a normal click. Tests: `tests/js/edit_metadata_modal.test.js` (year in the POST body + the backdrop-close decision table).
|
||||||
|
- **Built-in diagnostic sloppak rebranded "Slopsmith" → "FeedBack" in the song name.** PR #586 renamed the file to `feedBack-diagnostic-basic-guitar.sloppak` but never regenerated the archive, so the manifest inside still carried `title: Slopsmith Diagnostic — Basic Guitar` / `artist: Slopsmith` (and the same heading in `DIAGNOSTIC.md`) — the stale name testers saw in the library/player and the onboarding calibration step, even though the build script, server, and docs all already say "FeedBack Diagnostic — Basic Guitar". Regenerated `docs/diagnostics/feedBack-diagnostic-basic-guitar.sloppak` from `docs/diagnostics/build_diagnostic_basic_guitar.py` so the committed artifact matches its source generator (title/artist/heading now "FeedBack"; chart, stem, and `diagnostic:` metadata unchanged). No code change — the rename in #586 just needed the rebuild.
|
||||||
|
- **v3 song/lesson accuracy badges now refresh on the first return from a song — no restart needed.** PR #574 added a `stats:recorded` → in-place badge repaint, but the repaint never matched a card. The event (like `song:loading`) carries the filename **`encodeURIComponent`'d** — exactly as `playCard` hands it to `playSong` (the highway WS `decodeURIComponent`s it back) — whereas library cards key on the **decoded** `localFilename` (`data-fn`), and `/api/stats/best` is server-canonicalized to that same decoded key (`server.py` `_canonical_song_filename`). So `repaintAccuracy`'s `data-fn !== key` check rejected every card and `state.accuracy[encoded]` was `undefined`, leaving the just-earned badge stale until a full `render()` (app restart / search / re-enter the screen) — which is why it "came back after a restart." `static/v3/songs.js` now decodes the `stats:recorded` filename back into the card / `state.accuracy` key space via a small `decFn` helper before marking dirty and repainting (idempotent for already-decoded names; falls back to the original on malformed input so a real filename containing a literal `%` is never corrupted), so both the immediate repaint and the `onV3SongsScreenEnter` deferred path land on the right card. Tests: `tests/js/v3_songs_score_badge_refresh.test.js`.
|
||||||
|
- **Escape now exits a song (and leaves Settings) even when a transport/rail control button holds keyboard focus.** Clicking a player control (Play / FF / RW / Restart) left that `<button>` focused, and `_shortcutDispatchBlocked()` in `static/app.js` treats any focused `INPUT/SELECT/TEXTAREA/BUTTON` as an "interactive control" and bails before the shortcut registry runs — so the player-scope `Escape → Back` shortcut never fired until the user clicked empty canvas to blur the control ("Escape in song not consistent"). Space already had a player-screen carve-out (#593) that let it fire through a focused control; Escape did not. Generalized that carve-out to Escape, scoped to the player **and** settings screens (both register an `Escape = Back` shortcut, and settings had the identical latent bug). The earlier guards are preserved and still win: text inputs are exempted first (Escape there clears/blurs the field), the Section Practice popover already claims Escape before the carve-out, and a true modal layered over the screen (`[role="dialog"][aria-modal="true"]` / `.feedBack-modal`) still traps Escape so it closes the modal rather than ejecting past it. Escape becomes a reliable, focus-independent "Back" — making it monotonic groundwork for an optional exit-confirm. Plugins that register a player-scope `Escape` shortcut benefit identically (they were broken the same way). Tests: `tests/browser/keyboard-shortcuts.spec.ts` (focused-button repro, text-input no-exit, no-escape-past-modal, Section Practice popover, settings twin-bug).
|
||||||
|
- **The v3 "Up Next" pill can now be turned off — new "Show 'Up Next'" gameplay toggle (default ON).** The v0.3.0 player chrome's persistent upcoming-section pill (`#v3-upnext`, drawn by `static/v3/player-chrome.js`'s `updateUpNext()`) shipped with no off switch, so it always showed during playback whenever a section was upcoming — overlapping the top-right FPS HUD and ignoring the 3D-highway "Show 'Up Next' section card" checkbox (a *different*, in-canvas widget that was demoted to default-off precisely because this pill is the canonical readout). Users reading the pill as the same setting saw "disabled in settings but still there." Adds a real core toggle following the `autoplayExit` idiom: a client-only `showUpNext` `localStorage` pref (absence = enabled), a **Show "Up Next"** switch in the Gameplay settings tab (`static/v3/index.html`), reader/writer + `loadSettings()` hydration + a read-only `window.feedBack.showUpNext` getter in `static/app.js`, and a gate at the top of `updateUpNext()` that hides the pill when off. Disabling mid-playback hides it immediately; re-enabling re-shows it on the next chrome tick (~6 Hz). Added to `RESET_MAP.gameplay.local` in `static/v3/settings.js` so the Gameplay "Reset" restores the default-on state. Default ON = zero change for existing users. No Tailwind rebuild (plain markup + existing classes).
|
||||||
|
- **v3 list/tree view brought to parity with the grid: select mode, parts chips, and song actions — plus a stale-CSS Docker fix.** Re-lands a previously-reverted change. **Frontend (`static/v3/songs.js`):** entering select mode no longer collapses the tree — `loadTree()` now captures the expanded artist groups (`details[open]` keyed by `data-artist`) before the "Loading…" wipe and restores them on rebuild, so toggling select mode (which re-renders via `reload()`) keeps groups open and selection usable; tree rows gain a display-only checkbox + selection ring, the same fav / save-for-later / overflow-menu cluster as the grid card (always shown, all bound by `wireCards()`), and a capture-phase select guard mirroring the grid so clicking a row or arrangement chip in select mode selects instead of playing (`<summary>` headers sit outside `[data-fn]`, so native expand/collapse is untouched). **Docker fix (`static/tailwind.min.css`):** the committed Tailwind stylesheet was stale — `.sm\:flex` (and the other utilities behind #582's `hidden sm:flex` arrangement chips and the new action cluster) were never compiled in, so they rendered `display:none` on the Docker build (which serves the committed CSS as-is; Desktop rebuilds from source so it looked fine). Regenerated with the pinned `tailwindcss@3.4.19` via `scripts/build-tailwind.sh` so Docker matches Desktop and #582's chips render on every Docker deploy. Regression tests: `tests/browser/v3-tree-select.spec.ts`.
|
||||||
|
- **Space bar now plays/pauses on the player screen even when a sidebar nav link or rail button has focus.** When any `<button>` in the player rail (viz, audio, mixer, lyrics, plugins, advanced), a sidebar nav link, or a popover control held keyboard focus, pressing Space was swallowed by `_shortcutDispatchBlocked` → `_isInsideInteractiveControl` (which treats `BUTTON`/`A` as interactive), so the Space shortcut never reached the dispatcher and `togglePlay()` never ran. `_shortcutDispatchBlocked` (`static/app.js`) now extends the same carve-out already used for the Section Practice bar: while the player screen is active, Space is always routed through the shortcut system — the dispatcher calls `e.preventDefault()` before invoking the handler, so the focused element does not also activate. Text inputs (`_isTextInput`) remain exempted first, so typing space in a search/input field still works normally, and focus inside a true modal dialog (`role="dialog" aria-modal="true"` / `.feedBack-modal`) layered over the player is also exempted so Space reaches the modal's focused control (e.g. its Close button) instead of toggling playback behind it — non-modal player popovers/toasts (loop A/B, arrangement pin) stay covered. Regression tests in `tests/browser/keyboard-shortcuts.spec.ts` cover the focused-rail-button play/pause, the text-input exemption, and the modal-dialog exemption.
|
||||||
|
- **A song's accuracy badge now updates on its library card right after you play it — no restart needed.** The v3 library (`static/v3/songs.js`) loaded the best-accuracy map (`/api/stats/best`) once into `state.accuracy` at render time and only ever refreshed it on a full re-render; the play→return flow takes the screen-entry fast-path that restores the cached grid DOM without re-fetching, so a just-earned score stayed invisible until the next restart re-ran `render()`. The `stats-recorder` now emits a `stats:recorded` event (carrying `filename`/`arrangement`) once the scored `POST /api/stats` resolves on the server — the correct moment, since `song:stop` fires before the POST completes. `songs.js` listens: if the library is the active screen it re-fetches `/api/stats/best` and patches the affected card/row badge in place; otherwise it marks the filename dirty and `onV3SongsScreenEnter` applies it on return (a failed fetch keeps the entry dirty to retry). Badge markup was factored into a shared `accuracyBadge(filename, variant)` (grid pill + tree-row percentage, both tagged `.fb-acc-badge`) so the in-place `repaintAccuracy` can find and replace them without a full list re-render (scroll/pagination preserved). The old empty `song:stop` "refresh lazily next render" placeholder is replaced.
|
||||||
|
- **Changing Settings → 3D Highway → Fret spacing no longer ejects you to the home screen.** The `highway_3d` plugin's `h3dSetFretSpacing` was the lone 3D-highway setting that called `location.reload()` to apply — and since the SPA boots with `#home` as the active screen (`index.html` `.screen.active`), the reload dropped the user out of Settings onto the homescreen. It now applies live like every other 3D-highway setting: it rebinds the module-scope `_h3dFretUniform` flag (so panels mounted later this session pick up the new mode), recomputes the two `fretX`-derived scalars that were baked at init (`_fretLabelScaleRefW` for fret-label sprite scaling, `FRET_WIDTH_MID` for camera hysteresis), and broadcasts a `fretSpacing` change over the existing `_bgEmitChange` pub-sub so every mounted panel rebuilds its board via `buildBoard()`. Per-frame note geometry already reads `fretX` live and needs no rebuild. No page reload, so the Settings screen stays put. Source-level regression tests in `tests/js/highway_3d_fret_spacing.test.js` now pin the no-reload / live-rebuild behavior.
|
||||||
|
- **v3 library scroll-restore no longer breaks the classic v2 UI or drops off-screen searches** (feedBack#857). Two regressions in the scroll-restore work above: (1) `playSong` remapped `home`-launched songs to return to the `#v3-songs` screen unconditionally, but `static/app.js` is shared with the v2 UI (served at `/v2` / `FEEDBACK_UI=v2`) where that screen does not exist — Esc-from-player then called `showScreen('v3-songs')`, which threw on the missing element and stranded the user on a blank screen with playback still running; the remap now applies only when `#v3-songs` is present. (2) The Songs screen-entry fast-path skips reloading to preserve scroll, but the global topbar search routed through it, so once Songs had been visited, searching from another screen navigated there without applying the new query; the screen now tracks the state hash each fetch reflects and refetches when it has drifted, keeping the scroll-preserving no-op only when nothing changed.
|
||||||
|
- **An active custom highway renderer is no longer starved of `draw()` when it hides the canvas** (#819). The per-frame draw gate in `static/highway.js` bailed on `if (!_lastVisible) return`, which conflated two different "hidden" states: a genuine off-screen canvas (`offsetParent === null` — navigate-away / `display:none` splitscreen panel, #246) versus a renderer-set *override-hide* (`setVisible(false)`, where an opaque overlay covers the canvas but the active renderer keeps painting its own surface). The gate now only pauses everything for the off-screen case (and still pauses the default 2D renderer on an override-hide); the **active custom renderer** keeps receiving `draw()` through its own override-hide. The `highway:visibility` event still fires before the gate, so sibling overlay renderers (e.g. 3D Highway's `.h3d-wrap`) still pause. This is the core-side root cause behind the Tab View cursor freezing in single-player (feedBack#734; worked around plugin-side in feedBack-plugin-tabview#25).
|
||||||
|
- **Screensaver no longer kicks in during windowed-mode playback** (#686). While a song is playing, `static/app.js` now holds a [Screen Wake Lock](https://developer.mozilla.org/en-US/docs/Web/API/Screen_Wake_Lock_API) (`navigator.wakeLock.request('screen')`) so the OS display/screensaver stays awake even though only audio + the highway animation are active and the keyboard/mouse are idle. The lock is acquired on `song:play`/`song:resume` and released on `song:pause`/`song:ended`/`song:stop` (kept only while actually playing), and re-acquired on `visibilitychange` when the tab refocuses (the API auto-releases a lock whenever the page is hidden). Both the HTML5 `<audio>` and JUCE desktop playback paths emit the same `song:*` events, so the fix covers both. In feedBack-desktop (Electron), where `navigator.wakeLock` is unreliable, it also drives a native `powerSaveBlocker` bridge via the optional `window.feedBackDesktop.power.setScreenAwake` hook when present; both calls degrade silently where unsupported. Note: the browser Wake Lock API is secure-context only, so in a plain browser this is active on `localhost` / HTTPS only — a session opened over plain HTTP to a LAN IP (e.g. `http://192.168.1.100:8000`) won't keep the screen awake; front it with HTTPS or use the desktop app (see README → reverse-proxy notes).
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
- **Practice plugin first-class sidebar slot now points at Virtuoso.** The bundled practice plugin was rebranded/re-homed from the SlopScale fork (`id: slopscale`) to `got-feedback/feedBack-plugin-virtuoso` (`id: virtuoso`); the desktop bundle swap is feedBack-desktop#31. `static/v3/shell.js` still promoted `slopscale`, whose id no longer ships — so `renderPromotedNav()` (gated on the plugin appearing in `/api/plugins`) would have found no match and the dedicated sidebar slot would have gone dark, dropping Virtuoso to the generic Plugins gallery. Update the NAV entry + `PROMOTED_PLUGINS` slot `slopscale` → `virtuoso` (`screen: plugin-virtuoso`, label "Virtuoso - Practice", same FeedBarcade anchor + `target` icon) so the practice plugin keeps its first-class entry. Also clear the now-dead `slopscale` id from the Plugins-gallery curated category map (`static/v3/plugins-page.js`) and add `virtuoso: 'practice'` as a defensive fallback (the manifest's `category: "practice"` is authoritative, so it lands on the practice board regardless), and refresh the stale SlopScale references in `README.md` + `docs/plugin-capability-inventory.md`. Must land with the bundle swap or the practice plugin regresses in the UI.
|
||||||
- **3D highway: realistic curved metal frets.** The fret wires are now bowed `TubeGeometry` (the middle strings push away from the camera so the row of frets reads as wrapping a cylindrical neck — a depth cue) rendered with a lit `MeshStandardMaterial` instead of the old flat, straight `MeshBasicMaterial` boxes, so the scene's ambient + directional light glints across the rounded surface for a polished-steel look. The existing per-frame highlight is preserved unchanged: frets inside the active anchor still turn gold (`0xD8A636`), which under the metallic shading reads as brass. Metalness is kept moderate (0.4, not full-metal) because the scene has no envMap — a PBR full-metal surface would reflect black — with a dim emissive floor so frets stay legible down the fogged neck. Backported from the `highway_babylon` plugin's "hit-zone fret bars". All knobs (`FRET_BOW_DZ`, metalness/roughness/emissive) are tunable constants. `plugins/highway_3d` v3.25.0.
|
- **3D highway: realistic curved metal frets.** The fret wires are now bowed `TubeGeometry` (the middle strings push away from the camera so the row of frets reads as wrapping a cylindrical neck — a depth cue) rendered with a lit `MeshStandardMaterial` instead of the old flat, straight `MeshBasicMaterial` boxes, so the scene's ambient + directional light glints across the rounded surface for a polished-steel look. The existing per-frame highlight is preserved unchanged: frets inside the active anchor still turn gold (`0xD8A636`), which under the metallic shading reads as brass. Metalness is kept moderate (0.4, not full-metal) because the scene has no envMap — a PBR full-metal surface would reflect black — with a dim emissive floor so frets stay legible down the fogged neck. Backported from the `highway_babylon` plugin's "hit-zone fret bars". All knobs (`FRET_BOW_DZ`, metalness/roughness/emissive) are tunable constants. `plugins/highway_3d` v3.25.0.
|
||||||
- **3D highway: section + tone HUD cards now default OFF.** The v0.3.0 player chrome carries a persistent "Up Next" pill, making the in-canvas section card redundant by default (it doubled the readout, slopsmith feedback); the tone HUD follows the same less-is-more default. Both remain available in Settings → 3D Highway (visibility/position/size unchanged); users who previously toggled either explicitly keep their stored preference — only the untouched default flips. `plugins/highway_3d` v3.24.1.
|
- **3D highway: section + tone HUD cards now default OFF.** The v0.3.0 player chrome carries a persistent "Up Next" pill, making the in-canvas section card redundant by default (it doubled the readout, feedBack feedback); the tone HUD follows the same less-is-more default. Both remain available in Settings → 3D Highway (visibility/position/size unchanged); users who previously toggled either explicitly keep their stored preference — only the untouched default flips. `plugins/highway_3d` v3.24.1.
|
||||||
- **Perf**: replace runtime Tailwind Play CDN with a prebuilt static stylesheet (`static/tailwind.min.css`). The Play CDN's runtime JIT scanned the DOM ~1.8x/sec on the main thread (~37 ms blocking spans), dropping ~26% of frames in long playback sessions with the 3D highway as default. Theme extensions (dark/accent/gold colors, Inter font) move to `tailwind.config.js`; regen via `bash scripts/build-tailwind.sh`. No runtime build step — the generated CSS is committed. Fixes slopsmith-desktop#110.
|
- **Perf**: replace runtime Tailwind Play CDN with a prebuilt static stylesheet (`static/tailwind.min.css`). The Play CDN's runtime JIT scanned the DOM ~1.8x/sec on the main thread (~37 ms blocking spans), dropping ~26% of frames in long playback sessions with the 3D highway as default. Theme extensions (dark/accent/gold colors, Inter font) move to `tailwind.config.js`; regen via `bash scripts/build-tailwind.sh`. No runtime build step — the generated CSS is committed. Fixes feedBack-desktop#110.
|
||||||
- **Perf**: reduce per-frame allocations in the 2D highway chord + lyric render paths. `_ensureChordRenderCache` now also caches `sortedNotes` / `nonZeroNotes` / `nonZeroFrets` / `allMuted` / `hasMultipleNotes` (computed once per chord, invalidated on `src` / `_inverted` / `chordTemplates` change — the third key catches a stale `isOpen`-derived classification when the WS `chord_templates` message lands after the final `chords` chunk), so `drawChords` no longer re-sorts / re-filters / spreads min-max per visible chord per frame. The in-chord unison bend classification is folded inline (no `chordPositions.filter` × 2 per frame). `drawLyrics` memoizes `ctx.measureText` results in a two-level `Map<fontSize, Map<text, width>>` so cache hits don't allocate a composite string key. Lit-sustain shimmer in `drawSustains` swaps the 4 per-note-per-frame `Math.random()` calls for a 64-entry precomputed jitter LUT (xorshift32-seeded — the LUT contents are reload-stable and test-reproducible; rendered shimmer is deterministic per `createHighway()` instance, since the seed includes that instance's `_frameIdx`) indexed by `(frameIdx + n.s + ⌊n.t·60⌋)`, visually indistinguishable and allocation-free.
|
- **Perf**: reduce per-frame allocations in the 2D highway chord + lyric render paths. `_ensureChordRenderCache` now also caches `sortedNotes` / `nonZeroNotes` / `nonZeroFrets` / `allMuted` / `hasMultipleNotes` (computed once per chord, invalidated on `src` / `_inverted` / `chordTemplates` change — the third key catches a stale `isOpen`-derived classification when the WS `chord_templates` message lands after the final `chords` chunk), so `drawChords` no longer re-sorts / re-filters / spreads min-max per visible chord per frame. The in-chord unison bend classification is folded inline (no `chordPositions.filter` × 2 per frame). `drawLyrics` memoizes `ctx.measureText` results in a two-level `Map<fontSize, Map<text, width>>` so cache hits don't allocate a composite string key. Lit-sustain shimmer in `drawSustains` swaps the 4 per-note-per-frame `Math.random()` calls for a 64-entry precomputed jitter LUT (xorshift32-seeded — the LUT contents are reload-stable and test-reproducible; rendered shimmer is deterministic per `createHighway()` instance, since the seed includes that instance's `_frameIdx`) indexed by `(frameIdx + n.s + ⌊n.t·60⌋)`, visually indistinguishable and allocation-free.
|
||||||
|
- **Perf**: the load-adaptive render scale (`_adaptRenderScale`, #654) no longer visibly hunts up/down on passages that hover near the frame budget (testers saw "quality going up and down" with the 3D highway). Downscaling stays prompt to protect the frame rate, but **upscaling is now lazy**: a smaller step (×1.06 vs ×1.1) on a longer cooldown (`_AUTO_UPSCALE_COOLDOWN_MS` 2500 ms vs the 600 ms adjust cooldown), reset on any downscale, and gated by a predictive guard — it only upscales when the projected cost *after* the step (≈ cost × step², since draw cost tracks the pixel count) still clears the high budget. The scale therefore settles just inside the 7–12 ms deadband instead of oscillating across it. No new public API; the `_autoScaleMin` "Min res" floor is unchanged.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- **`c` library hotkey ("Convert to .sloppak") removed from core.** Core hardcoded a plugin-specific shortcut: a documentation-only `registerShortcut({ key: 'c', scope: 'library' })` no-op plus a `c → button.sloppak-convert-btn` entry in the library keydown handler that fired the Sloppak Converter plugin's button. Per the plugins-own-their-behavior principle, core no longer ships this hotkey — the convert button still works by click, and the Sloppak Converter plugin can register its own `c` shortcut via `window.registerShortcut()` if keyboard access is wanted. The `f` (favorite) and `e` (edit) library hotkeys, which drive core buttons, are unchanged. Help-modal/registry tests in `tests/browser/keyboard-shortcuts.spec.ts` updated to drop the `c` assertions.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- **Player progression: Mastery Rank, instrument-path challenges, daily/weekly quests, Decibels currency, cosmetics shop (spec 010).** Onboarding gains two steps: pick one or more **instrument paths** (Guitar / Bass / Drums — data-driven, more can ship as content) and a **calibration challenge** offer (play the bundled Slopsmith Diagnostic with note detection at 100% accuracy — or skip; either way you reach **Mastery Rank 1**, and a skipped calibration can still be completed later from the Progress screen). Each path levels by completing a content-defined number of **challenges** (any order) from that level's set; Mastery Rank = onboarding rank + the sum of path levels, starting at 0 on a fresh install. The existing unified XP backend is untouched but the frontend renames it to **Decibels (dB)** — a spendable currency earned ONLY by playing (songs, FeedBarcade rounds, quest rewards; no real-money path exists or may be added) — with spend tracked in a separate wallet so lifetime earnings stay monotonic. Rotating **daily/weekly quests** (deterministic per period, lazy instantiation, local-midnight / Monday resets) award dB and feed `quest_completed` challenges. A new **Progress** screen (rank hero, per-path checklists, quest countdowns, add-a-path) and **Shop** screen (themes via CSS-variable swaps under `html[data-fb-theme]`, avatar frames; atomic balance-checked purchases — 402 on insufficient dB, 409 on re-buy) join the v3 nav, and the topbar badge now shows Rank + challenge-set progress + dB balance. All definitions live in `data/progression/` JSON (paths/levels/challenges, quest pools, shop catalog) — adding a rank, challenge, quest, or cosmetic is a content edit + restart, never code; invalid content degrades to logged warnings. New tables (additive + idempotent): `progression_state`, `player_paths`, `challenge_progress`, `quest_state`, `wallet`, `shop_owned`, `shop_equipped`. New endpoints: `GET /api/progression`, `POST /api/progression/paths|onboarding|events` (events whitelists `minigame_run`; `song_completed` stays server-derived inside `POST /api/stats`, which now resolves the instrument server-side and reports an additive `progression` outcome key), `GET /api/shop`, `POST /api/shop/buy|equip`; equipped cosmetics ride along on `GET /api/profile`. A new **`progression` capability domain** (core-owned, kind: command, safety: safe — `inspect`, `record-event`, `list-shop`, `buy-item`/`equip-item` gated on user action) emits `challenge-completed`/`quest-completed`/`path-level-up`/`rank-changed`/`db-changed`/`calibration-completed`/`cosmetic-equipped`, mirrored as `progression:*` window events, with a redaction-safe diagnostics contributor; backend plugins get the symmetric `record_progression_event` context hook (the bundled minigames hub reports runs through it, guarded for standalone). Spec: `specs/010-progression-domain/`. Tests: `tests/test_progression.py`, `tests/test_progression_api.py`. **Migration notes:** existing XP totals carry over as lifetime dB (balance = lifetime − spent); resetting a per-source XP contribution (e.g. a minigames profile reset) after spending can clamp the spendable balance to 0 until new dB is earned; drums-path v1 content uses currently-satisfiable goals (arcade rounds, quests, any-instrument plays) until drums scoring lands.
|
- **Player progression: Mastery Rank, instrument-path challenges, daily/weekly quests, Decibels currency, cosmetics shop (spec 010).** Onboarding gains two steps: pick one or more **instrument paths** (Guitar / Bass / Drums — data-driven, more can ship as content) and a **calibration challenge** offer (play the bundled FeedBack Diagnostic with note detection at 100% accuracy — or skip; either way you reach **Mastery Rank 1**, and a skipped calibration can still be completed later from the Progress screen). Each path levels by completing a content-defined number of **challenges** (any order) from that level's set; Mastery Rank = onboarding rank + the sum of path levels, starting at 0 on a fresh install. The existing unified XP backend is untouched but the frontend renames it to **Decibels (dB)** — a spendable currency earned ONLY by playing (songs, FeedBarcade rounds, quest rewards; no real-money path exists or may be added) — with spend tracked in a separate wallet so lifetime earnings stay monotonic. Rotating **daily/weekly quests** (deterministic per period, lazy instantiation, local-midnight / Monday resets) award dB and feed `quest_completed` challenges. A new **Progress** screen (rank hero, per-path checklists, quest countdowns, add-a-path) and **Shop** screen (themes via CSS-variable swaps under `html[data-fb-theme]`, avatar frames; atomic balance-checked purchases — 402 on insufficient dB, 409 on re-buy) join the v3 nav, and the topbar badge now shows Rank + challenge-set progress + dB balance. All definitions live in `data/progression/` JSON (paths/levels/challenges, quest pools, shop catalog) — adding a rank, challenge, quest, or cosmetic is a content edit + restart, never code; invalid content degrades to logged warnings. New tables (additive + idempotent): `progression_state`, `player_paths`, `challenge_progress`, `quest_state`, `wallet`, `shop_owned`, `shop_equipped`. New endpoints: `GET /api/progression`, `POST /api/progression/paths|onboarding|events` (events whitelists `minigame_run`; `song_completed` stays server-derived inside `POST /api/stats`, which now resolves the instrument server-side and reports an additive `progression` outcome key), `GET /api/shop`, `POST /api/shop/buy|equip`; equipped cosmetics ride along on `GET /api/profile`. A new **`progression` capability domain** (core-owned, kind: command, safety: safe — `inspect`, `record-event`, `list-shop`, `buy-item`/`equip-item` gated on user action) emits `challenge-completed`/`quest-completed`/`path-level-up`/`rank-changed`/`db-changed`/`calibration-completed`/`cosmetic-equipped`, mirrored as `progression:*` window events, with a redaction-safe diagnostics contributor; backend plugins get the symmetric `record_progression_event` context hook (the bundled minigames hub reports runs through it, guarded for standalone). Spec: `specs/010-progression-domain/`. Tests: `tests/test_progression.py`, `tests/test_progression_api.py`. **Migration notes:** existing XP totals carry over as lifetime dB (balance = lifetime − spent); resetting a per-source XP contribution (e.g. a minigames profile reset) after spending can clamp the spendable balance to 0 until new dB is earned; drums-path v1 content uses currently-satisfiable goals (arcade rounds, quests, any-instrument plays) until drums scoring lands.
|
||||||
- **3D highway score FX (notedetect game-scoring layer).** The bundled `highway_3d` renderer now visualizes the scoring layer shipped in slopsmith-plugin-notedetect ≥1.13: floating **"+N" score pops** above each judged gem (sourced from the note-state provider's new `{ points, mult, popKey }` verdict fields — chord members share the chord-level `popKey`, so a chord pops once, not once per string), plus session-level FX from the new `notedetect:fx` event — a particle burst at the strike line on streak milestones (25/50/every 100), an expanding ring pulse on multiplier tier-ups (×2/×3/×4), and a brief red wash when a ≥10 streak breaks. Colors and the pop font follow the user's notedetect scoring-UI skin (`slopsmith_notedetect_skin`: neon/esports/metal, refreshed live on the `notedetect:skin` bus event). Everything renders on the existing 2D overlay canvas from fixed-size slot pools — no Three.js geometry, no text-sprite cache traffic, near-zero cost when idle — and degrades to a silent no-op with older notedetect builds (the new fields/events simply never arrive). Splitscreen panels scope FX to their own detector instance via the bubbling per-panel `notedetect:fx` dispatch. `plugins/highway_3d` v3.24.1.
|
- **3D highway score FX (notedetect game-scoring layer).** The bundled `highway_3d` renderer now visualizes the scoring layer shipped in feedBack-plugin-notedetect ≥1.13: floating **"+N" score pops** above each judged gem (sourced from the note-state provider's new `{ points, mult, popKey }` verdict fields — chord members share the chord-level `popKey`, so a chord pops once, not once per string), plus session-level FX from the new `notedetect:fx` event — a particle burst at the strike line on streak milestones (25/50/every 100), an expanding ring pulse on multiplier tier-ups (×2/×3/×4), and a brief red wash when a ≥10 streak breaks. Colors and the pop font follow the user's notedetect scoring-UI skin (`feedBack_notedetect_skin`: neon/esports/metal, refreshed live on the `notedetect:skin` bus event). Everything renders on the existing 2D overlay canvas from fixed-size slot pools — no Three.js geometry, no text-sprite cache traffic, near-zero cost when idle — and degrades to a silent no-op with older notedetect builds (the new fields/events simply never arrive). Splitscreen panels scope FX to their own detector instance via the bubbling per-panel `notedetect:fx` dispatch. `plugins/highway_3d` v3.24.1.
|
||||||
- **3D highway: slide direction arrows + gem-follow animation.** Slide notes now show a ›/‹ arrow indicating which way the slide goes — on the note/gem itself, as an early preview on the neck before the note arrives, and (optionally) chained further ahead for multi-leg slides — each independently toggleable in Settings → 3D Highway (`slideArrowApproachVisible`, `slideArrowNeckVisible`, `slideArrowChainPreviewVisible`). The note gem also now visually glides from its starting fret to the slide's destination over the note's sustain and holds there through the brief post-sustain linger, instead of snapping back to the starting fret — most noticeable on unpitched "slide to nothing" notes. `plugins/highway_3d` v3.25.2.
|
- **3D highway: slide direction arrows + gem-follow animation.** Slide notes now show a ›/‹ arrow indicating which way the slide goes — on the note/gem itself, as an early preview on the neck before the note arrives, and (optionally) chained further ahead for multi-leg slides — each independently toggleable in Settings → 3D Highway (`slideArrowApproachVisible`, `slideArrowNeckVisible`, `slideArrowChainPreviewVisible`). The note gem also now visually glides from its starting fret to the slide's destination over the note's sustain and holds there through the brief post-sustain linger, instead of snapping back to the starting fret — most noticeable on unpitched "slide to nothing" notes. `plugins/highway_3d` v3.25.2.
|
||||||
- **3D highway: up to 3 upcoming-note ghost previews per string, with fade-in/grow.** Each string now previews up to 3 upcoming notes (was 1) on a fixed 0.6 s fade-in/grow ramp, so tight same-string runs no longer pop in at full size right before impact and the player can read note order ahead of time. `isBlocked` (the pre-impact ghost suppression in a note's last 150 ms) is now scoped to chord notes only — for lead notes it had been blinking the ghost out right before each sustained note in dense runs. (Slide notes stay excluded too, per the slide-arrow work above, since their gem glides off the start fret.) `plugins/highway_3d` v3.26.0.
|
- **3D highway: up to 3 upcoming-note ghost previews per string, with fade-in/grow.** Each string now previews up to 3 upcoming notes (was 1) on a fixed 0.6 s fade-in/grow ramp, so tight same-string runs no longer pop in at full size right before impact and the player can read note order ahead of time. `isBlocked` (the pre-impact ghost suppression in a note's last 150 ms) is now scoped to chord notes only — for lead notes it had been blinking the ghost out right before each sustained note in dense runs. (Slide notes stay excluded too, per the slide-arrow work above, since their gem glides off the start fret.) `plugins/highway_3d` v3.26.0.
|
||||||
- **Enable/disable plugins from the v3 Pedalboard (footswitch backend).** Every `/api/plugins` entry now carries an `enabled` boolean (default `true`), and a new `POST /api/plugins/{plugin_id}/enabled` endpoint (`{"enabled": <bool>}` → `{"id", "enabled"}`) persists the choice to `CONFIG_DIR/plugin_state.json` (only non-default `enabled:false` entries are stored; a missing/corrupt file is tolerated and never crashes startup). The loader **skips disabled plugins at startup** — no requirements install, no `routes.setup()`, no screen/nav/capabilities — while still surfacing them in `/api/plugins` as a disabled entry (`status:"disabled"`, `enabled:false`) so the UI can show an "off" pedal you can switch back on. Toggling persists immediately and flips the in-memory flag so the next `/api/plugins` reflects it at once (a runtime-disabled plugin's already-mounted routes/screen remain until the next restart; re-enabling a startup-skipped plugin mounts on restart). A disabled plugin is **excluded from the capability pipeline** — its capability metadata is emptied in `/api/plugins`. Guard rails keep `capability_inspector` and `app_tour_*` always enabled (disable → `400`); unknown id → `404`; missing/non-boolean `enabled` → `400`. Backend only; the v3 Pedalboard frontend consumes this contract. Docs: `docs/plugin-v3-ui.md`.
|
- **Enable/disable plugins from the v3 Pedalboard (footswitch backend).** Every `/api/plugins` entry now carries an `enabled` boolean (default `true`), and a new `POST /api/plugins/{plugin_id}/enabled` endpoint (`{"enabled": <bool>}` → `{"id", "enabled"}`) persists the choice to `CONFIG_DIR/plugin_state.json` (only non-default `enabled:false` entries are stored; a missing/corrupt file is tolerated and never crashes startup). The loader **skips disabled plugins at startup** — no requirements install, no `routes.setup()`, no screen/nav/capabilities — while still surfacing them in `/api/plugins` as a disabled entry (`status:"disabled"`, `enabled:false`) so the UI can show an "off" pedal you can switch back on. Toggling persists immediately and flips the in-memory flag so the next `/api/plugins` reflects it at once (a runtime-disabled plugin's already-mounted routes/screen remain until the next restart; re-enabling a startup-skipped plugin mounts on restart). A disabled plugin is **excluded from the capability pipeline** — its capability metadata is emptied in `/api/plugins`. Guard rails keep `capability_inspector` and `app_tour_*` always enabled (disable → `400`); unknown id → `404`; missing/non-boolean `enabled` → `400`. Backend only; the v3 Pedalboard frontend consumes this contract. Docs: `docs/plugin-v3-ui.md`.
|
||||||
- **fee[dB]ack v0.3.0 rebrand + UI redesign (opt-in, isolated).** The visible product is being renamed from **Slopsmith** to **fee[dB]ack** (the `[dB]` is a decibel pun on the practice "feedback" loop) alongside a full dashboard-style UI redesign. **Rebrand scope is the app + docs wordmark only** — the repository, Python package, ghcr Docker image, `CONFIG_DIR`, and `SLOPSMITH_*` env vars all keep the `slopsmith` name, so existing deployments and data are unaffected. The redesigned UI is additive and served behind a feature flag: `SLOPSMITH_UI=v3` flips the `/` route to the new `static/v3/` shell, and `GET /v3` always serves it; the default `/` stays byte-identical to 0.2.9 until 0.3.0 flips the default. This release adds the `static/v3/` scaffold (navy app shell + styled fee[dB]ack wordmark, brand SVG + favicon + PWA manifest with 192/512 icons), an additive `fb` Tailwind color palette (legacy `dark`/`accent`/`gold` retained) with `static/v3/**` in the content globs, and the regenerated `static/tailwind.min.css`. Vanilla JS, prebuilt Tailwind, no Play CDN (Principle II). Shell wiring, screens, profile/scoring backends, and capability-runtime integration land in subsequent v0.3.0 changes.
|
- **fee[dB]ack v0.3.0 rebrand + UI redesign (opt-in, isolated).** The visible product is being renamed from **FeedBack** to **fee[dB]ack** (the `[dB]` is a decibel pun on the practice "feedback" loop) alongside a full dashboard-style UI redesign. **Rebrand scope is the app + docs wordmark only** — the repository, Python package, ghcr Docker image, `CONFIG_DIR`, and `FEEDBACK_*` env vars all keep the `feedBack` name, so existing deployments and data are unaffected. The redesigned UI is additive and served behind a feature flag: `FEEDBACK_UI=v3` flips the `/` route to the new `static/v3/` shell, and `GET /v3` always serves it; the default `/` stays byte-identical to 0.2.9 until 0.3.0 flips the default. This release adds the `static/v3/` scaffold (navy app shell + styled fee[dB]ack wordmark, brand SVG + favicon + PWA manifest with 192/512 icons), an additive `fb` Tailwind color palette (legacy `dark`/`accent`/`gold` retained) with `static/v3/**` in the content globs, and the regenerated `static/tailwind.min.css`. Vanilla JS, prebuilt Tailwind, no Play CDN (Principle II). Shell wiring, screens, profile/scoring backends, and capability-runtime integration land in subsequent v0.3.0 changes.
|
||||||
- **fee[dB]ack v0.3.0 app shell (sidebar + topbar + routing).** The v3 shell (`static/v3/index.html`) is now a re-chromed copy of the legacy app: the new left **sidebar** (HOME / LIBRARY groups) and **topbar** (secondary nav, search, Support, badge-cluster mount points) replace the hidden legacy navbar, and new `#v3-*` screens (dashboard/plugins/profile/playlists/saved) are added — while all legacy screens (`#home` library, `#favorites`, `#settings`, `#player`, `#audio`, plugin nav containers) are kept verbatim so `static/app.js` boots **unmodified** and the whole engine (player/highway, plugin loader, capabilities, audio, library, settings) is reused as-is. Navigation is the shared `window.showScreen` across `#v3-*`, reused legacy, and `#plugin-*` screens, with a responsive hamburger and a `localStorage`/`v3:`-namespaced shell. Plugin nav is mirrored into the sidebar from `/api/plugins` (UI placement is a deferred capability domain, so this uses the legacy loader, not capability dispatch). `static/v3/shell.js` wraps `window.showScreen` via the idempotent rehydration pattern to keep sidebar/topbar active-state in sync.
|
- **fee[dB]ack v0.3.0 app shell (sidebar + topbar + routing).** The v3 shell (`static/v3/index.html`) is now a re-chromed copy of the legacy app: the new left **sidebar** (HOME / LIBRARY groups) and **topbar** (secondary nav, search, Support, badge-cluster mount points) replace the hidden legacy navbar, and new `#v3-*` screens (dashboard/plugins/profile/playlists/saved) are added — while all legacy screens (`#home` library, `#favorites`, `#settings`, `#player`, `#audio`, plugin nav containers) are kept verbatim so `static/app.js` boots **unmodified** and the whole engine (player/highway, plugin loader, capabilities, audio, library, settings) is reused as-is. Navigation is the shared `window.showScreen` across `#v3-*`, reused legacy, and `#plugin-*` screens, with a responsive hamburger and a `localStorage`/`v3:`-namespaced shell. Plugin nav is mirrored into the sidebar from `/api/plugins` (UI placement is a deferred capability domain, so this uses the legacy loader, not capability dispatch). `static/v3/shell.js` wraps `window.showScreen` via the idempotent rehydration pattern to keep sidebar/topbar active-state in sync.
|
||||||
- **fee[dB]ack v0.3.0 player profile + first-run onboarding + unified XP + streak.** Adds a single-user core **profile** (`profile`/`profile_progress`/`xp_profile` tables in `web_library.db`, additive + idempotent): display name + avatar, a stable `player_hash` (SHA-256 of the first name + a once-generated salt — stable across later renames; a future-leaderboard label, never auth), and a **streak** (any session on a calendar day keeps it; a missed day resets to 1). New endpoints: `GET/POST /api/profile`, `POST /api/profile/avatar` (base64, re-encoded to a ≤512px PNG under `CONFIG_DIR/avatars/`), `GET /api/profile/avatar/{name}` (safe-joined), `GET /api/profile/avatars` (bundled defaults under `static/v3/avatars/`), `GET /api/profile/progress` (one call for the badge), and `POST /api/xp/award`. **Unified XP:** `lib/xp.py` is the single XP curve (same math the minigames plugin shipped); the core `xp_profile` store is the one source of truth the profile badge reads, exposed to plugins via `context["award_xp"]`/`get_xp_progress`/`seed_xp`. The bundled **minigames** plugin now delegates XP to the core store (seeding once from its existing `profile.json` so earned levels carry over) — so song-play, minigames, and tutorials all feed one level. Frontend: a blocking first-run onboarding overlay (name + avatar grid + upload), the topbar profile badge (avatar, 🔥 streak, level + XP bar), and the `#v3-profile` screen. Tests: `tests/test_xp.py`, `tests/test_profile_api.py`.
|
- **fee[dB]ack v0.3.0 player profile + first-run onboarding + unified XP + streak.** Adds a single-user core **profile** (`profile`/`profile_progress`/`xp_profile` tables in `web_library.db`, additive + idempotent): display name + avatar, a stable `player_hash` (SHA-256 of the first name + a once-generated salt — stable across later renames; a future-leaderboard label, never auth), and a **streak** (any session on a calendar day keeps it; a missed day resets to 1). New endpoints: `GET/POST /api/profile`, `POST /api/profile/avatar` (base64, re-encoded to a ≤512px PNG under `CONFIG_DIR/avatars/`), `GET /api/profile/avatar/{name}` (safe-joined), `GET /api/profile/avatars` (bundled defaults under `static/v3/avatars/`), `GET /api/profile/progress` (one call for the badge), and `POST /api/xp/award`. **Unified XP:** `lib/xp.py` is the single XP curve (same math the minigames plugin shipped); the core `xp_profile` store is the one source of truth the profile badge reads, exposed to plugins via `context["award_xp"]`/`get_xp_progress`/`seed_xp`. The bundled **minigames** plugin now delegates XP to the core store (seeding once from its existing `profile.json` so earned levels carry over) — so song-play, minigames, and tutorials all feed one level. Frontend: a blocking first-run onboarding overlay (name + avatar grid + upload), the topbar profile badge (avatar, 🔥 streak, level + XP bar), and the `#v3-profile` screen. Tests: `tests/test_xp.py`, `tests/test_profile_api.py`.
|
||||||
- **fee[dB]ack v0.3.0 song-stats store (best score + accuracy, plays, resume position).** A core `song_stats` table (`web_library.db`, additive + idempotent, PK `(filename, arrangement)`) records per-song/arrangement best/last score + accuracy, play count, and last position. Endpoints: `POST /api/stats` (scored session → `plays += 1`, `best_*` = max, `last_*` = new, plus **unified-XP award** `xp_for_run(score)` and a **streak** bump, both behind try/except so a side-effect failure never drops the stat write; or position-only `lastPlayPosition` → resume touch with no `plays` change), `GET /api/stats/{filename}` (aggregated across arrangements), `GET /api/stats/recent` (joined to song title/artist/art for "Jump back in"). Scoring stays frontend-driven: `static/v3/stats-recorder.js` tallies the `note:hit`/`note:miss` events the optional `slopsmith-plugin-notedetect` already emits (and also accepts an explicit `note_detect:session-ended` summary), then POSTs on song end; it also persists resume position on pause/stop. No note-detect edit required — note-detection is a deferred capability domain, so the recorder uses those legacy events and degrades to "no accuracy" when the plugin isn't installed. Score/accuracy math is shared with the server via `lib/song_score.py`. Tests: `tests/test_song_score.py`, `tests/test_song_stats_api.py`.
|
- **fee[dB]ack v0.3.0 song-stats store (best score + accuracy, plays, resume position).** A core `song_stats` table (`web_library.db`, additive + idempotent, PK `(filename, arrangement)`) records per-song/arrangement best/last score + accuracy, play count, and last position. Endpoints: `POST /api/stats` (scored session → `plays += 1`, `best_*` = max, `last_*` = new, plus **unified-XP award** `xp_for_run(score)` and a **streak** bump, both behind try/except so a side-effect failure never drops the stat write; or position-only `lastPlayPosition` → resume touch with no `plays` change), `GET /api/stats/{filename}` (aggregated across arrangements), `GET /api/stats/recent` (joined to song title/artist/art for "Jump back in"). Scoring stays frontend-driven: `static/v3/stats-recorder.js` tallies the `note:hit`/`note:miss` events the optional `feedBack-plugin-notedetect` already emits (and also accepts an explicit `note_detect:session-ended` summary), then POSTs on song end; it also persists resume position on pause/stop. No note-detect edit required — note-detection is a deferred capability domain, so the recorder uses those legacy events and degrades to "no accuracy" when the plugin isn't installed. Score/accuracy math is shared with the server via `lib/song_score.py`. Tests: `tests/test_song_score.py`, `tests/test_song_stats_api.py`.
|
||||||
- **fee[dB]ack v0.3.0 playlists, Saved for Later, and Continue-Playing.** Core playlist management (`playlists` + `playlist_songs` tables in `web_library.db`, additive + idempotent): create/rename/delete, add/remove/reorder songs, plus a reserved **Saved for Later** system playlist (created on first use; protected from rename/delete). Endpoints: `GET/POST /api/playlists`, `GET/PATCH/DELETE /api/playlists/{id}`, `POST /api/playlists/{id}/songs`, `DELETE /api/playlists/{id}/songs/{filename}`, `POST /api/playlists/{id}/reorder`, `POST /api/saved/toggle`, and `GET /api/session/continue` (derives the resume song + last position from `song_stats`, no new table). Frontend `static/v3/playlists.js` renders the `#v3-playlists` list + detail (drag-reorder, play, remove) and `#v3-saved`, and exposes `window.v3Saved.toggle()` for a "Save for later" affordance on song cards. Favorites reuse the existing favorites screen/API. Core REST, no capability domain. Tests: `tests/test_playlists_api.py`.
|
- **fee[dB]ack v0.3.0 playlists, Saved for Later, and Continue-Playing.** Core playlist management (`playlists` + `playlist_songs` tables in `web_library.db`, additive + idempotent): create/rename/delete, add/remove/reorder songs, plus a reserved **Saved for Later** system playlist (created on first use; protected from rename/delete). Endpoints: `GET/POST /api/playlists`, `GET/PATCH/DELETE /api/playlists/{id}`, `POST /api/playlists/{id}/songs`, `DELETE /api/playlists/{id}/songs/{filename}`, `POST /api/playlists/{id}/reorder`, `POST /api/saved/toggle`, and `GET /api/session/continue` (derives the resume song + last position from `song_stats`, no new table). Frontend `static/v3/playlists.js` renders the `#v3-playlists` list + detail (drag-reorder, play, remove) and `#v3-saved`, and exposes `window.v3Saved.toggle()` for a "Save for later" affordance on song cards. Favorites reuse the existing favorites screen/API. Core REST, no capability domain. Tests: `tests/test_playlists_api.py`.
|
||||||
- **fee[dB]ack v0.3.0 Dashboard / Home.** The `#v3-home` dashboard (matching the v0.3.0 design target) composes the new backends: a "Welcome back, {name}!" banner with a patch-notes link (`/api/version`), a hero card (Start Playing / Create Lobby), a **Continue-Playing** card (`/api/session/continue` → art, tuning chip, 4-segment progress; click resumes via `playSong` + best-effort seek), a stats row (audio-routing widget placeholder until prompt 18, library count from `/api/library/stats`, plugins count from `/api/plugins` where `status==="ready"`), and a **Recently Played** grid (`/api/stats/recent`) with per-song accuracy badges (good/mid/low ramp). Each widget fetches + renders independently and degrades gracefully (missing/empty endpoint → placeholder, never blocks first paint). `static/v3/dashboard.js`; re-renders on return to Home and on profile update.
|
- **fee[dB]ack v0.3.0 Dashboard / Home.** The `#v3-home` dashboard (matching the v0.3.0 design target) composes the new backends: a "Welcome back, {name}!" banner with a patch-notes link (`/api/version`), a hero card (Start Playing / Create Lobby), a **Continue-Playing** card (`/api/session/continue` → art, tuning chip, 4-segment progress; click resumes via `playSong` + best-effort seek), a stats row (audio-routing widget placeholder until prompt 18, library count from `/api/library/stats`, plugins count from `/api/plugins` where `status==="ready"`), and a **Recently Played** grid (`/api/stats/recent`) with per-song accuracy badges (good/mid/low ramp). Each widget fetches + renders independently and degrades gracefully (missing/empty endpoint → placeholder, never blocks first paint). `static/v3/dashboard.js`; re-renders on return to Home and on profile update.
|
||||||
- **fee[dB]ack v0.3.0 tuner + instrument topbar badges.** The topbar gains an **instrument selector** (guitar/bass + string count + tuning + reference pitch) persisted via additive `/api/settings` fields (`reference_pitch` clamped 430–450, `instrument`, `string_count` 4–8, `tuning` name or semitone offsets); changing it emits `instrument:changed` so the note_detect scorer can re-tune (consumed once the external plugin adopts it). A **live tuner badge** stays idle until the user enables the mic (explicit gesture; `getUserMedia`), then shows a YIN note readout with a cents needle (green within ±5¢) using a new dependency-free `static/v3/tuner-core.js` (YIN + frequency→note/cents, honoring the reference pitch); clicking opens the full `slopsmith-plugin-tuner` screen when installed. CPU-friendly (~20 Hz, paused when the tab is hidden, respects `prefers-reduced-motion`). Tests: `tests/test_settings_instrument.py`, `tests/js/tuner_core.test.js`.
|
- **fee[dB]ack v0.3.0 tuner + instrument topbar badges.** The topbar gains an **instrument selector** (guitar/bass + string count + tuning + reference pitch) persisted via additive `/api/settings` fields (`reference_pitch` clamped 430–450, `instrument`, `string_count` 4–8, `tuning` name or semitone offsets); changing it emits `instrument:changed` so the note_detect scorer can re-tune (consumed once the external plugin adopts it). A **live tuner badge** stays idle until the user enables the mic (explicit gesture; `getUserMedia`), then shows a YIN note readout with a cents needle (green within ±5¢) using a new dependency-free `static/v3/tuner-core.js` (YIN + frequency→note/cents, honoring the reference pitch); clicking opens the full `feedBack-plugin-tuner` screen when installed. CPU-friendly (~20 Hz, paused when the tab is hidden, respects `prefers-reduced-motion`). Tests: `tests/test_settings_instrument.py`, `tests/js/tuner_core.test.js`.
|
||||||
- **fee[dB]ack v0.3.0 audio-routing widget (dashboard).** The dashboard's audio stat tile now reads the live audio session **through the capability runtime** — `audio-mix inspect` (route + faders + required kinds), `audio-input list-sources` (selected/available input), `audio-monitoring inspect` — and renders **Audio Input → VST/NAM/IR → Audio Output** with per-node state dots and a Connected/Not Connected line. It never touches `audio-mixer.js` internals or `nam_tone` routes directly; "Not Connected" is the honest browser default (no native route), and it degrades on `no-owner`/`no-handler`/`failed` or absent capabilities. Refreshes on `instrument:changed`, play/stop, capability audio events, and each Home visit. `static/v3/audio-routing.js`.
|
- **fee[dB]ack v0.3.0 audio-routing widget (dashboard).** The dashboard's audio stat tile now reads the live audio session **through the capability runtime** — `audio-mix inspect` (route + faders + required kinds), `audio-input list-sources` (selected/available input), `audio-monitoring inspect` — and renders **Audio Input → VST/NAM/IR → Audio Output** with per-node state dots and a Connected/Not Connected line. It never touches `audio-mixer.js` internals or `nam_tone` routes directly; "Not Connected" is the honest browser default (no native route), and it degrades on `no-owner`/`no-handler`/`failed` or absent capabilities. Refreshes on `instrument:changed`, play/stop, capability audio events, and each Home visit. `static/v3/audio-routing.js`.
|
||||||
- **fee[dB]ack v0.3.0 Plugins page.** The `#v3-plugins` screen renders the enriched `/api/plugins`: a "{N} active" header (`status==="ready"`), a card grid per plugin (icon, name, version, status pill with the error on failed, capability summary badges — declared domains / validation warnings / unsupported versions / shim hits / bundled / type), an **Open →** action that navigates to the plugin's injected `#plugin-<id>` screen, and All/Bundled/Visualizations filters. Surfaces a deep-link to the bundled **Capability Inspector** rather than re-implementing the graph. No new backend. `static/v3/plugins-page.js`.
|
- **fee[dB]ack v0.3.0 Plugins page.** The `#v3-plugins` screen renders the enriched `/api/plugins`: a "{N} active" header (`status==="ready"`), a card grid per plugin (icon, name, version, status pill with the error on failed, capability summary badges — declared domains / validation warnings / unsupported versions / shim hits / bundled / type), an **Open →** action that navigates to the plugin's injected `#plugin-<id>` screen, and All/Bundled/Visualizations filters. Surfaces a deep-link to the bundled **Capability Inspector** rather than re-implementing the graph. No new backend. `static/v3/plugins-page.js`.
|
||||||
- **fee[dB]ack v0.3.0 Songs / Library screen (`#v3-songs`).** A native vanilla-JS library browser over the existing `/api/library*` endpoints: provider selector (via the `library` capability, not DOM scraping), grid + tree views, sort, format filter, a tri-state filter drawer (arrangements / stems / lyrics / tunings), topbar-driven search (debounced), infinite scroll, fb song cards with **accuracy badges** (good/mid/low ramp, batched via a new `GET /api/stats/best`), favorite + save-for-later affordances, and upload (reuses the existing uploader). The "Songs" sidebar nav now opens this screen. No regression to `/api/library*`. `static/v3/songs.js`.
|
- **fee[dB]ack v0.3.0 Songs / Library screen (`#v3-songs`).** A native vanilla-JS library browser over the existing `/api/library*` endpoints: provider selector (via the `library` capability, not DOM scraping), grid + tree views, sort, format filter, a tri-state filter drawer (arrangements / stems / lyrics / tunings), topbar-driven search (debounced), infinite scroll, fb song cards with **accuracy badges** (good/mid/low ramp, batched via a new `GET /api/stats/best`), favorite + save-for-later affordances, and upload (reuses the existing uploader). The "Songs" sidebar nav now opens this screen. No regression to `/api/library*`. `static/v3/songs.js`.
|
||||||
- **`ui.library-card-injection` capability + native song-card actions (fee[dB]ack v0.3.0).** New core capability (`static/capabilities/library-card-actions.js`, owner `core.ui.library-card-injection`, exposed as `window.slopsmith.libraryCardActions`) lets plugins **register** per-song library-card actions (id, label, placement, applicability, enabled state, run handler) with `action-registered`/`action-result` events — replacing the legacy `.song-card` DOM-injection pattern (roadmap domain #9, now delivered as a frontend host). The native Songs grid renders registered actions in each card's "⋮" menu; the built-in **Edit metadata** and **Convert to E Standard (retune)** actions ship through it (`static/v3/card-actions-core.js`, calling the existing `openEditModal`/`retuneSong` globals). Songs cards also gain **arrangement chips** (play a specific arrangement) and a **multi-select** mode with batch **Add to playlist** / **Save for Later**. Recipe in `docs/capability-recipes.md`; tests in `tests/js/library_card_actions.test.js`. Migrating the external card-action plugins (Sloppak Converter, Find More, editor) onto `register(...)` is a follow-up.
|
- **`ui.library-card-injection` capability + native song-card actions (fee[dB]ack v0.3.0).** New core capability (`static/capabilities/library-card-actions.js`, owner `core.ui.library-card-injection`, exposed as `window.feedBack.libraryCardActions`) lets plugins **register** per-song library-card actions (id, label, placement, applicability, enabled state, run handler) with `action-registered`/`action-result` events — replacing the legacy `.song-card` DOM-injection pattern (roadmap domain #9, now delivered as a frontend host). The native Songs grid renders registered actions in each card's "⋮" menu; the built-in **Edit metadata** and **Convert to E Standard (retune)** actions ship through it (`static/v3/card-actions-core.js`, calling the existing `openEditModal`/`retuneSong` globals). Songs cards also gain **arrangement chips** (play a specific arrangement) and a **multi-select** mode with batch **Add to playlist** / **Save for Later**. Recipe in `docs/capability-recipes.md`; tests in `tests/js/library_card_actions.test.js`. Migrating the external card-action plugins (Sloppak Converter, Find More, editor) onto `register(...)` is a follow-up.
|
||||||
- **`centOffset` exposed via `getSongInfo()`** — the arrangement `<centOffset>` field (float, cents) is now parsed from all chart sources (loose folder XML, sloppak wire format) and sent as `centOffset` in the `song_info` WebSocket message. Plugins can read `getSongInfo().centOffset` to obtain the arrangement's pitch-shift offset — commonly `-1200.0` for extended-range bass (one octave down) or a small non-zero value for true-tuned content (e.g. A443 ≈ +11.8 cents). Defaults to `0.0` when absent.
|
- **`centOffset` exposed via `getSongInfo()`** — the arrangement `<centOffset>` field (float, cents) is now parsed from all chart sources (loose folder XML, sloppak wire format) and sent as `centOffset` in the `song_info` WebSocket message. Plugins can read `getSongInfo().centOffset` to obtain the arrangement's pitch-shift offset — commonly `-1200.0` for extended-range bass (one octave down) or a small non-zero value for true-tuned content (e.g. A443 ≈ +11.8 cents). Defaults to `0.0` when absent.
|
||||||
- **`highway.getPhrases()` and `highway.getMastery()` public plugin API** — exposes phrase timing windows (`[{ index, start_time, end_time, max_difficulty }]`) and the current mastery slider value (`0..1`) as documented, stable plugin API. Both values were already in memory and reachable via internal names; this surfaces them with intent so plugins can implement section-aware logic (e.g. tracking accuracy per phrase, suppressing difficulty changes during a hard solo) without reaching into undocumented internals. Returns `null` when the song has no phrase data (GP imports, single-difficulty charts). Pair with the existing `hasPhraseData()` to gate phrase-aware code paths.
|
- **`highway.getPhrases()` and `highway.getMastery()` public plugin API** — exposes phrase timing windows (`[{ index, start_time, end_time, max_difficulty }]`) and the current mastery slider value (`0..1`) as documented, stable plugin API. Both values were already in memory and reachable via internal names; this surfaces them with intent so plugins can implement section-aware logic (e.g. tracking accuracy per phrase, suppressing difficulty changes during a hard solo) without reaching into undocumented internals. Returns `null` when the song has no phrase data (GP imports, single-difficulty charts). Pair with the existing `hasPhraseData()` to gate phrase-aware code paths.
|
||||||
- **Tailwind freshness guard + wider plugin scan.** A new `tailwind-fresh` CI job (`.github/workflows/tests.yml`) rebuilds `static/tailwind.min.css` with the pinned `tailwindcss@3.4.19` and hard-fails on any diff, so the committed prebuilt stylesheet can no longer silently lag source (after PR #411 removed the runtime Play CDN, a stale file shipped unstyled elements with no guard). The `tailwind.config.js` plugin content glob is widened to `./plugins/**/*.{js,html}`, which also scans non-`screen.js` plugin JS (e.g. `plugins/app_tour_*/script.js`) that was previously invisible to the build. Regenerating under the wider glob is a no-op for runtime behaviour — it only adds classes that were already used in source. Groundwork for the plugin `styles` capability (constitution 1.1.0, Principle II): runtime-installed plugins ship their own compiled CSS rather than relying on core's build-time scan.
|
- **Tailwind freshness guard + wider plugin scan.** A new `tailwind-fresh` CI job (`.github/workflows/tests.yml`) rebuilds `static/tailwind.min.css` with the pinned `tailwindcss@3.4.19` and hard-fails on any diff, so the committed prebuilt stylesheet can no longer silently lag source (after PR #411 removed the runtime Play CDN, a stale file shipped unstyled elements with no guard). The `tailwind.config.js` plugin content glob is widened to `./plugins/**/*.{js,html}`, which also scans non-`screen.js` plugin JS (e.g. `plugins/app_tour_*/script.js`) that was previously invisible to the build. Regenerating under the wider glob is a no-op for runtime behaviour — it only adds classes that were already used in source. Groundwork for the plugin `styles` capability (constitution 1.1.0, Principle II): runtime-installed plugins ship their own compiled CSS rather than relying on core's build-time scan.
|
||||||
@@ -56,21 +106,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- **Audio-mix control plane** — makes `audio-mix` the player mixer source of truth. Core now exposes `list-faders`, `get-fader-value`, `set-fader-value`, `inspect-route`, and `inspect-analyser` through the capability runtime, routes native and compatibility-backed fader provider operations with a 2-second timeout, reports committed values back to the mixer UI, suppresses matching legacy faders when a native participant owns the same logical control, and expands audio-session diagnostics/Capability Inspector rendering for fader availability, source modes, bridge hits, route/analyser summaries, and timeout failures.
|
- **Audio-mix control plane** — makes `audio-mix` the player mixer source of truth. Core now exposes `list-faders`, `get-fader-value`, `set-fader-value`, `inspect-route`, and `inspect-analyser` through the capability runtime, routes native and compatibility-backed fader provider operations with a 2-second timeout, reports committed values back to the mixer UI, suppresses matching legacy faders when a native participant owns the same logical control, and expands audio-session diagnostics/Capability Inspector rendering for fader availability, source modes, bridge hits, route/analyser summaries, and timeout failures.
|
||||||
- **Audio-input control plane** — makes `audio-input` the redaction-safe source of truth for instrument input discovery and lifecycle. Core now exposes `list-sources`, `select-source`, `open-source`, and `close-source` through the capability runtime, persists selected logical sources, keeps inspect/list/select prompt-free, routes provider `source.open`/`source.close` operations with bounded outcomes, shares compatible open sessions across requesters, suppresses compatibility-backed duplicate sources when a native provider owns the same logical key, and expands audio-session diagnostics/Capability Inspector rendering for selected input, open sessions, bridge hits, storage status, and permission/device failures without exposing raw device labels or live audio handles.
|
- **Audio-input control plane** — makes `audio-input` the redaction-safe source of truth for instrument input discovery and lifecycle. Core now exposes `list-sources`, `select-source`, `open-source`, and `close-source` through the capability runtime, persists selected logical sources, keeps inspect/list/select prompt-free, routes provider `source.open`/`source.close` operations with bounded outcomes, shares compatible open sessions across requesters, suppresses compatibility-backed duplicate sources when a native provider owns the same logical key, and expands audio-session diagnostics/Capability Inspector rendering for selected input, open sessions, bridge hits, storage status, and permission/device failures without exposing raw device labels or live audio handles.
|
||||||
- **Audio-monitoring control plane** — makes `audio-monitoring` the shared live-monitoring coordinator. Core now exposes provider registration/list/selection, explicit user-action `start`, requester-counted `stop`, prompt-free `inspect`/`monitoring.status`, and `set-direct-monitor` through the capability runtime. Monitoring starts integrate with selected `audio-input` readiness, background requesters can only attach to active compatible sessions, active sessions survive song/playback stops without auto-resuming after reload, native providers suppress compatibility-backed legacy monitor paths, and diagnostics/Capability Inspector now show providers, sessions, requesters, direct-monitor state, bridge hits, and distinct safe outcomes (`provider-selection-required`, `user-action-required`, `incompatible`, `unavailable`, `stopped`, etc.) without exposing raw audio/device data.
|
- **Audio-monitoring control plane** — makes `audio-monitoring` the shared live-monitoring coordinator. Core now exposes provider registration/list/selection, explicit user-action `start`, requester-counted `stop`, prompt-free `inspect`/`monitoring.status`, and `set-direct-monitor` through the capability runtime. Monitoring starts integrate with selected `audio-input` readiness, background requesters can only attach to active compatible sessions, active sessions survive song/playback stops without auto-resuming after reload, native providers suppress compatibility-backed legacy monitor paths, and diagnostics/Capability Inspector now show providers, sessions, requesters, direct-monitor state, bridge hits, and distinct safe outcomes (`provider-selection-required`, `user-action-required`, `incompatible`, `unavailable`, `stopped`, etc.) without exposing raw audio/device data.
|
||||||
- **Playback control plane** — promotes `playback` to an active core capability domain for song transport, timing, loop, route, requester/observer, bridge, and diagnostics state. Core now exposes `inspect`, user-authorized `start`, `pause`, `resume`, `stop`, `seek`, `set-loop`, and `clear-loop` through the capability runtime while `static/app.js` keeps raw `<audio>`/JUCE handles private behind a redaction-safe adapter. Playback diagnostics use pseudonymous targets in exported bundles, local display labels only in the Capability Inspector, bounded recent outcomes/events, and bridge accounting for `window.playSong`, legacy `song:*` events, `window.slopsmith` transport helpers, loop helpers, and browser/native route handoff.
|
- **Playback control plane** — promotes `playback` to an active core capability domain for song transport, timing, loop, route, requester/observer, bridge, and diagnostics state. Core now exposes `inspect`, user-authorized `start`, `pause`, `resume`, `stop`, `seek`, `set-loop`, and `clear-loop` through the capability runtime while `static/app.js` keeps raw `<audio>`/JUCE handles private behind a redaction-safe adapter. Playback diagnostics use pseudonymous targets in exported bundles, local display labels only in the Capability Inspector, bounded recent outcomes/events, and bridge accounting for `window.playSong`, legacy `song:*` events, `window.feedBack` transport helpers, loop helpers, and browser/native route handoff.
|
||||||
- **3D highway — Tone HUD, fret dividers, chord-diagram toggle, FPS counter.** The bundled `plugins/highway_3d` gains an amber **Tone-change HUD** (shows the active tone and the next scheduled tone change; position / size / visibility configurable in settings), a **fret-dividers** toggle (vertical dividers on the highway, on by default, via `h3dBgSetFretDividersVisible`), a **chord-diagram visibility** toggle (`h3dBgSetChordDiagramVisible`), and an **FPS counter** setting migrated to `BG_DEFAULTS.fpsVisible` (drops the legacy `h3d_showFps` localStorage key). Chord-diagram position is restricted to `tl`/`tr`; legacy `bl`/`br` values are coerced on load. Perf: accent-halo shell descriptors are pre-built per string in `initScene()` and the chord-verdict cache key is encoded as a number, eliminating per-frame allocations in the `drawNote()` and chord hot paths.
|
- **3D highway — Tone HUD, fret dividers, chord-diagram toggle, FPS counter.** The bundled `plugins/highway_3d` gains an amber **Tone-change HUD** (shows the active tone and the next scheduled tone change; position / size / visibility configurable in settings), a **fret-dividers** toggle (vertical dividers on the highway, on by default, via `h3dBgSetFretDividersVisible`), a **chord-diagram visibility** toggle (`h3dBgSetChordDiagramVisible`), and an **FPS counter** setting migrated to `BG_DEFAULTS.fpsVisible` (drops the legacy `h3d_showFps` localStorage key). Chord-diagram position is restricted to `tl`/`tr`; legacy `bl`/`br` values are coerced on load. Perf: accent-halo shell descriptors are pre-built per string in `initScene()` and the chord-verdict cache key is encoded as a number, eliminating per-frame allocations in the `drawNote()` and chord hot paths.
|
||||||
- **Sloppak assembly preserves a short preview clip.** When a source chart carries a separate short browser-preview audio clip alongside the full song, the sloppak assembler now decodes it to `preview.ogg` at the sloppak root and records it under a new top-level `preview:` manifest key (POSIX relpath, same shape as `lyrics`/`cover`). A failed preview decode is logged at debug and skipped without aborting the overall build. Sources with no separate preview are unaffected. Older sloppak readers ignore the unknown `preview` key, so the change is purely additive (sloppak-spec.md §5.5 backward-compat). Documented in `docs/sloppak-spec.md` §2 alongside the other optional top-level keys. Enables [`slopsmith-plugin-song-preview`](https://github.com/got-feedback/feedback-plugin-song-preview) to render hover-to-listen previews for sloppaks without seeking into the full audio.
|
- **Sloppak assembly preserves a short preview clip.** When a source chart carries a separate short browser-preview audio clip alongside the full song, the sloppak assembler now decodes it to `preview.ogg` at the sloppak root and records it under a new top-level `preview:` manifest key (POSIX relpath, same shape as `lyrics`/`cover`). A failed preview decode is logged at debug and skipped without aborting the overall build. Sources with no separate preview are unaffected. Older sloppak readers ignore the unknown `preview` key, so the change is purely additive (sloppak-spec.md §5.5 backward-compat). Documented in `docs/sloppak-spec.md` §2 alongside the other optional top-level keys. Enables [`feedBack-plugin-song-preview`](https://github.com/got-feedback/feedBack-plugin-song-preview) to render hover-to-listen previews for sloppaks without seeking into the full audio.
|
||||||
- **Generic plugin asset route** — `GET /api/plugins/{plugin_id}/assets/{path}` serves arbitrary static files a plugin bundles under its own `assets/` directory (AudioWorklet modules, WASM, images, etc.), so plugins can self-host browser-fetchable assets without a CDN (Principle II). Containment is enforced by `lib/safepath.safe_join` against `<plugin>/assets/`, so `..` traversal, absolute paths, and NUL bytes cannot escape `assets/` to reach a plugin's Python modules. `.js` is served as `application/javascript`. First consumer: the stems plugin's pitch-preserving time-stretch worklet.
|
- **Generic plugin asset route** — `GET /api/plugins/{plugin_id}/assets/{path}` serves arbitrary static files a plugin bundles under its own `assets/` directory (AudioWorklet modules, WASM, images, etc.), so plugins can self-host browser-fetchable assets without a CDN (Principle II). Containment is enforced by `lib/safepath.safe_join` against `<plugin>/assets/`, so `..` traversal, absolute paths, and NUL bytes cannot escape `assets/` to reach a plugin's Python modules. `.js` is served as `application/javascript`. First consumer: the stems plugin's pitch-preserving time-stretch worklet.
|
||||||
- **Minigames framework — bundled as a core plugin (`plugins/minigames/`).** Promotes the upstream [`slopsmith-plugin-minigames`](https://github.com/got-feedback/feedback-plugin-minigames) repo into the core bundle so every Slopsmith install gets the framework out of the box (same promotion path used for `highway_3d`). The plugin adds a top-level **Minigames** nav link (alongside Library / Favorites / Upload — not buried in the Plugins dropdown), a library-style card grid of installed minigame plugins, and a shared profile layer (XP, level, per-game leaderboards, cross-minigame unlocks) persisted under `CONFIG_DIR/minigames/` and opted into the settings export. Other plugins that want to ship a minigame add a `minigame` block to their `plugin.json` and call `window.slopsmithMinigames.register(spec)`; the SDK exposes scoring (`createContinuous` runs a self-contained YIN tracker; `createDiscrete` / `createChord` wrap `note_detect`'s `createNoteDetector`), HUD primitives, run persistence, and a scheduler so individual minigames don't need their own DSP or backend. Backend endpoints live under `/api/plugins/minigames/{runs,profile,registry}`. The framework is plugin-shaped (not core code) per Principle III, but bundled so it ships with every install. First consumer: [`slopsmith-plugin-flappy-bend`](https://github.com/got-feedback/feedback-plugin-flappy-bend), shipped separately.
|
- **Minigames framework — bundled as a core plugin (`plugins/minigames/`).** Promotes the upstream [`feedBack-plugin-minigames`](https://github.com/got-feedback/feedBack-plugin-minigames) repo into the core bundle so every FeedBack install gets the framework out of the box (same promotion path used for `highway_3d`). The plugin adds a top-level **Minigames** nav link (alongside Library / Favorites / Upload — not buried in the Plugins dropdown), a library-style card grid of installed minigame plugins, and a shared profile layer (XP, level, per-game leaderboards, cross-minigame unlocks) persisted under `CONFIG_DIR/minigames/` and opted into the settings export. Other plugins that want to ship a minigame add a `minigame` block to their `plugin.json` and call `window.feedBackMinigames.register(spec)`; the SDK exposes scoring (`createContinuous` runs a self-contained YIN tracker; `createDiscrete` / `createChord` wrap `note_detect`'s `createNoteDetector`), HUD primitives, run persistence, and a scheduler so individual minigames don't need their own DSP or backend. Backend endpoints live under `/api/plugins/minigames/{runs,profile,registry}`. The framework is plugin-shaped (not core code) per Principle III, but bundled so it ships with every install. First consumer: [`feedBack-plugin-flappy-bend`](https://github.com/got-feedback/feedBack-plugin-flappy-bend), shipped separately.
|
||||||
- **Alpha-build heads-up banner** — when `/api/version` reports a version string containing "alpha" (case-insensitive), an amber banner appears at the top of the library section warning users that the build is in active development and may have bugs or breaking changes. The banner stays hidden on stable / beta / RC builds. No persistence or dismiss state — it's a passive notice, not a modal.
|
- **Alpha-build heads-up banner** — when `/api/version` reports a version string containing "alpha" (case-insensitive), an amber banner appears at the top of the library section warning users that the build is in active development and may have bugs or breaking changes. The banner stays hidden on stable / beta / RC builds. No persistence or dismiss state — it's a passive notice, not a modal.
|
||||||
- **Drum vocabulary expanded to 18 pieces** — adds `stack` (MIDI 30, from GM's extended-percussion range, unused by real drum-kit MIDIs) and `bell` (MIDI 80 "Mute Triangle", also unused in real drum-kit MIDIs) to `lib/drums.py` PIECES. Inserted in the iteration order so the editor / highway lane ordering is *hi-hat → stack → crash → … → ride bell → bell*. Both are cymbals; default shape `circle_jagged` (stack) / `circle_dot` (bell). Old drum tabs round-trip unchanged — the schema is permissive and existing piece-ids are untouched.
|
- **Drum vocabulary expanded to 18 pieces** — adds `stack` (MIDI 30, from GM's extended-percussion range, unused by real drum-kit MIDIs) and `bell` (MIDI 80 "Mute Triangle", also unused in real drum-kit MIDIs) to `lib/drums.py` PIECES. Inserted in the iteration order so the editor / highway lane ordering is *hi-hat → stack → crash → … → ride bell → bell*. Both are cymbals; default shape `circle_jagged` (stack) / `circle_dot` (bell). Old drum tabs round-trip unchanged — the schema is permissive and existing piece-ids are untouched.
|
||||||
- **GP / MIDI drum import surfaces unmapped notes** — `convert_drum_track_to_drumtab` (`lib/gp2rs.py`) and `convert_drum_track_from_midi` (`lib/midi_import.py`) gain an optional keyword-only `out_unmapped` parameter. Callers that pass an empty dict receive a per-MIDI record of every silently-skipped percussion note (`{midi: {"count": int, "times": [float, ...]}}`, times capped at 100 samples per note). This lets the editor plugin show a warning + manual-mapping UI on import instead of silently dropping unmapped notes. Default behavior unchanged for callers that don't opt in.
|
- **GP / MIDI drum import surfaces unmapped notes** — `convert_drum_track_to_drumtab` (`lib/gp2rs.py`) and `convert_drum_track_from_midi` (`lib/midi_import.py`) gain an optional keyword-only `out_unmapped` parameter. Callers that pass an empty dict receive a per-MIDI record of every silently-skipped percussion note (`{midi: {"count": int, "times": [float, ...]}}`, times capped at 100 samples per note). This lets the editor plugin show a warning + manual-mapping UI on import instead of silently dropping unmapped notes. Default behavior unchanged for callers that don't opt in.
|
||||||
|
|
||||||
- **Drum support from scratch** — drums are now a first-class arrangement type with their own JSON payload on disk and their own WS stream to the highway. New `lib/drums.py` defines the closed piece-id vocabulary (kick, snare, snare_xstick, hh_closed/open/pedal, tom_hi/mid/low/floor, crash_l/r, splash, china, ride, ride_bell), default GM-MIDI mappings, three preset lane configurations, and a permissive `drum_tab.json` validator. `lib/sloppak.py::load_song` reads the manifest's optional top-level `drum_tab:` key, parses + validates the JSON, and surfaces it on `LoadedSloppak.drum_tab`; the load stays permissive so a missing or malformed tab silently disables drums rather than failing the sloppak load. `/ws/highway/{filename}` gains two new message types — `drum_tab` (metadata + kit legend) and chunked `drum_hits` (500 hits per frame, same chunking as notes) — exposed to renderers via `bundle.drumTab`. `song_info` carries a `has_drum_tab` flag so viz pickers can auto-activate the drums highway regardless of which guitar arrangement is selected. `lib/gp2rs.py::convert_drum_track_to_drumtab` converts a Guitar Pro drum track to a `drum_tab.json` dict, preserving velocity verbatim, mapping hi-hat openness through the canonical piece-ids, and flagging flam / ghost / cymbal-choke articulations from GP effects. `lib/midi_import.py` gains `list_drum_tracks` + `convert_drum_track_from_midi` (channel-9 only) with heuristic flam-collapse (≤30 ms same-piece) and choke detection (cymbal note-off ≤120 ms). `docs/sloppak-spec.md` §5.3 promotes drum_tab from worked-example to canonical with the closed piece-id table and wire format. Sloppaks without a drum_tab are unaffected; legacy drums-as-guitar-notes sloppaks keep playing via the drums plugin's fallback decoder.
|
- **Drum support from scratch** — drums are now a first-class arrangement type with their own JSON payload on disk and their own WS stream to the highway. New `lib/drums.py` defines the closed piece-id vocabulary (kick, snare, snare_xstick, hh_closed/open/pedal, tom_hi/mid/low/floor, crash_l/r, splash, china, ride, ride_bell), default GM-MIDI mappings, three preset lane configurations, and a permissive `drum_tab.json` validator. `lib/sloppak.py::load_song` reads the manifest's optional top-level `drum_tab:` key, parses + validates the JSON, and surfaces it on `LoadedSloppak.drum_tab`; the load stays permissive so a missing or malformed tab silently disables drums rather than failing the sloppak load. `/ws/highway/{filename}` gains two new message types — `drum_tab` (metadata + kit legend) and chunked `drum_hits` (500 hits per frame, same chunking as notes) — exposed to renderers via `bundle.drumTab`. `song_info` carries a `has_drum_tab` flag so viz pickers can auto-activate the drums highway regardless of which guitar arrangement is selected. `lib/gp2rs.py::convert_drum_track_to_drumtab` converts a Guitar Pro drum track to a `drum_tab.json` dict, preserving velocity verbatim, mapping hi-hat openness through the canonical piece-ids, and flagging flam / ghost / cymbal-choke articulations from GP effects. `lib/midi_import.py` gains `list_drum_tracks` + `convert_drum_track_from_midi` (channel-9 only) with heuristic flam-collapse (≤30 ms same-piece) and choke detection (cymbal note-off ≤120 ms). `docs/sloppak-spec.md` §5.3 promotes drum_tab from worked-example to canonical with the closed piece-id table and wire format. Sloppaks without a drum_tab are unaffected; legacy drums-as-guitar-notes sloppaks keep playing via the drums plugin's fallback decoder.
|
||||||
- **Loose folder support** — a directory containing an audio file + arrangement XMLs, with optional `manifest.json` and album art, is now discovered, indexed, and playable as a first-class library format alongside Sloppak. The scanner walks `DLC_DIR` for non-preview audio files and treats each parent directory that also contains XMLs as a loose song. Metadata follows a `manifest.json` → XML tags → folder-name priority chain (see `lib/loosefolder.py`). Songs are tagged `format: "loose"`, render an amber `FOLDER` badge in the library, and are filterable via the new "Folder" option in the format dropdown. Audio uses the shared vgmstream/`convert_wem` pipeline, cached under `AUDIO_CACHE_DIR`. The chart `<offset>` from the first non-vocals XML is now propagated to the frontend via `song_info.offset` and applied in `highway.setTime()` so loose folders authored against non-silence-padded audio stay in sync. Pairs with the companion `slopsmith-plugin-loosefolder` plugin which adds an in-player Fix Sync UI for nudging and saving offset corrections.
|
- **Loose folder support** — a directory containing an audio file + arrangement XMLs, with optional `manifest.json` and album art, is now discovered, indexed, and playable as a first-class library format alongside Sloppak. The scanner walks `DLC_DIR` for non-preview audio files and treats each parent directory that also contains XMLs as a loose song. Metadata follows a `manifest.json` → XML tags → folder-name priority chain (see `lib/loosefolder.py`). Songs are tagged `format: "loose"`, render an amber `FOLDER` badge in the library, and are filterable via the new "Folder" option in the format dropdown. Audio uses the shared vgmstream/`convert_wem` pipeline, cached under `AUDIO_CACHE_DIR`. The chart `<offset>` from the first non-vocals XML is now propagated to the frontend via `song_info.offset` and applied in `highway.setTime()` so loose folders authored against non-silence-padded audio stay in sync. Pairs with the companion `feedBack-plugin-loosefolder` plugin which adds an in-player Fix Sync UI for nudging and saving offset corrections.
|
||||||
- Highway note-state hook (#254). New `highway.setNoteStateProvider(fn)` lets a scorer plugin publish a per-note judgment (`'hit'` / `'active'` for a sustain currently held correctly / `'miss'`, or `{ state, alpha, color }`) so the renderer lights up the **gem itself** on a correct hit and keeps a sustain trail glowing while it's still being played right — instead of a separate overlay ring near the note. The built-in 2D highway honors it in `drawNote` / `drawSustains` / the chord-frame path (bright string colour + additive halo on hits, bright vs dim sustain trail, faint red wash on misses); the bundled 3D highway reads the same data via `bundle.getNoteState` (bright string-tinted outline + bright body + glowing sustain on hit/active, red outline + suppressed body on miss). Custom renderers opt in by calling `bundle.getNoteState(note, chartTime)`. note_detect registers the provider (and still owns its HUD / diagnostic miss markers / "currently detected" indicator); renderers that ignore the hook simply don't light gems. On a confirmed hit/active the renderers add a contained "sparkle/sizzle" on the note — the 2D highway: additive throbbing halo + flickering hot core + crackling spark lines (+ an expanding shockwave ring on a fresh strike) on the gem and a glowing/jittery sustain trail; the 3D highway: a few twinkling bright dots and short crackling arc segments hugging the note's rectangle (no bloom past the note), drawn on its overlay and projected through the camera so they ride the note. Also adds `highway.isDefaultRenderer()` so overlays that position with the 2D-highway helpers (`project` / `fretX`) can skip rendering when a custom renderer is active — fixes note_detect's miss markers appearing in random places over the 3D highway. New 3D-highway setting **Show note preview on the fretboard** (on by default) toggles the board-projection ghost — the translucent preview of the upcoming note on the fretboard surface. (Note: the companion change in the **note_detect plugin repo** turns its full-screen green/red edge flash off by default and adds a toggle to re-enable it — ships separately with note_detect, not in this slopsmith release.)
|
- Highway note-state hook (#254). New `highway.setNoteStateProvider(fn)` lets a scorer plugin publish a per-note judgment (`'hit'` / `'active'` for a sustain currently held correctly / `'miss'`, or `{ state, alpha, color }`) so the renderer lights up the **gem itself** on a correct hit and keeps a sustain trail glowing while it's still being played right — instead of a separate overlay ring near the note. The built-in 2D highway honors it in `drawNote` / `drawSustains` / the chord-frame path (bright string colour + additive halo on hits, bright vs dim sustain trail, faint red wash on misses); the bundled 3D highway reads the same data via `bundle.getNoteState` (bright string-tinted outline + bright body + glowing sustain on hit/active, red outline + suppressed body on miss). Custom renderers opt in by calling `bundle.getNoteState(note, chartTime)`. note_detect registers the provider (and still owns its HUD / diagnostic miss markers / "currently detected" indicator); renderers that ignore the hook simply don't light gems. On a confirmed hit/active the renderers add a contained "sparkle/sizzle" on the note — the 2D highway: additive throbbing halo + flickering hot core + crackling spark lines (+ an expanding shockwave ring on a fresh strike) on the gem and a glowing/jittery sustain trail; the 3D highway: a few twinkling bright dots and short crackling arc segments hugging the note's rectangle (no bloom past the note), drawn on its overlay and projected through the camera so they ride the note. Also adds `highway.isDefaultRenderer()` so overlays that position with the 2D-highway helpers (`project` / `fretX`) can skip rendering when a custom renderer is active — fixes note_detect's miss markers appearing in random places over the 3D highway. New 3D-highway setting **Show note preview on the fretboard** (on by default) toggles the board-projection ghost — the translucent preview of the upcoming note on the fretboard surface. (Note: the companion change in the **note_detect plugin repo** turns its full-screen green/red edge flash off by default and adds a toggle to re-enable it — ships separately with note_detect, not in this feedBack release.)
|
||||||
- Diagnostic bundle export (#166). New "Export Diagnostics" + "Preview Bundle" buttons in Settings produce a single redacted zip combining server logs (tail of `LOG_FILE`), system info (Python/OS/version), hardware probe (CPU model + cores + freq + RAM, GPU via `nvidia-smi`/`rocm-smi`/`system_profiler`, container/Electron/bare runtime detection), full plugin inventory with git SHA + remote URL (read directly from `.git/HEAD` so it works in minimal runtime images without `git` installed) + orphan/failed-to-load detection, the browser console transcript (all levels: log/info/warn/error/debug + window.onerror + unhandledrejection, 500-entry ring buffer), browser hardware (WebGL/WebGPU adapter info, navigator + userAgentData), filtered localStorage, and per-plugin contributed diagnostics. Top-level `manifest.json` lists every file with its versioned schema id (`system.hardware.v1`, `client.console.v1`, etc.) so AI agents can dispatch by schema. Redaction is on by default: DLC paths, song filenames (`<song:HASH8>` stable per-bundle), IPv4/IPv6 addresses, bearer tokens, and `key=`/`token=` query strings are replaced. Plugins opt their backend diagnostics in via a new `diagnostics` manifest field (`server_files` allowlist mirroring `settings.server_files` semantics, plus an optional `callable: "<module>:<function>"` resolved lazily via `load_sibling`). Frontend plugins push diagnostics via `window.slopsmith.diagnostics.contribute(plugin_id, payload)`. Three new endpoints: `POST /api/diagnostics/export`, `GET /api/diagnostics/preview`, `GET /api/diagnostics/hardware`. Full bundle format spec in `docs/diagnostics-bundle-spec.md`.
|
- Diagnostic bundle export (#166). New "Export Diagnostics" + "Preview Bundle" buttons in Settings produce a single redacted zip combining server logs (tail of `LOG_FILE`), system info (Python/OS/version), hardware probe (CPU model + cores + freq + RAM, GPU via `nvidia-smi`/`rocm-smi`/`system_profiler`, container/Electron/bare runtime detection), full plugin inventory with git SHA + remote URL (read directly from `.git/HEAD` so it works in minimal runtime images without `git` installed) + orphan/failed-to-load detection, the browser console transcript (all levels: log/info/warn/error/debug + window.onerror + unhandledrejection, 500-entry ring buffer), browser hardware (WebGL/WebGPU adapter info, navigator + userAgentData), filtered localStorage, and per-plugin contributed diagnostics. Top-level `manifest.json` lists every file with its versioned schema id (`system.hardware.v1`, `client.console.v1`, etc.) so AI agents can dispatch by schema. Redaction is on by default: DLC paths, song filenames (`<song:HASH8>` stable per-bundle), IPv4/IPv6 addresses, bearer tokens, and `key=`/`token=` query strings are replaced. Plugins opt their backend diagnostics in via a new `diagnostics` manifest field (`server_files` allowlist mirroring `settings.server_files` semantics, plus an optional `callable: "<module>:<function>"` resolved lazily via `load_sibling`). Frontend plugins push diagnostics via `window.feedBack.diagnostics.contribute(plugin_id, payload)`. Three new endpoints: `POST /api/diagnostics/export`, `GET /api/diagnostics/preview`, `GET /api/diagnostics/hardware`. Full bundle format spec in `docs/diagnostics-bundle-spec.md`.
|
||||||
- Structured logging bootstrap (phase 1 of #155). Three new environment variables control server log output: `LOG_LEVEL` (default `INFO`), `LOG_FORMAT` (`text` for coloured console, `json` for one-JSON-object-per-line suitable for Loki/ELK/Promtail), and `LOG_FILE` (optional path, rotated at 10 MB with 5 backups). HTTP responses now include a `X-Request-ID` correlation header (via `CorrelationIdMiddleware`); the same request ID appears as `request_id` in structured log lines emitted via the stdlib `logging` / `structlog` APIs during that request.
|
- Structured logging bootstrap (phase 1 of #155). Three new environment variables control server log output: `LOG_LEVEL` (default `INFO`), `LOG_FORMAT` (`text` for coloured console, `json` for one-JSON-object-per-line suitable for Loki/ELK/Promtail), and `LOG_FILE` (optional path, rotated at 10 MB with 5 backups). HTTP responses now include a `X-Request-ID` correlation header (via `CorrelationIdMiddleware`); the same request ID appears as `request_id` in structured log lines emitted via the stdlib `logging` / `structlog` APIs during that request.
|
||||||
- Structured logging migration completed (phase 2 of #155, #159, #242). The 42 `print()` calls and 6 `traceback.print_exc()` calls across `server.py` and `lib/` have been migrated to levelled `slopsmith.*` loggers. Silent `except: pass` blocks in `lib/sloppak.py` and `lib/sloppak_convert.py` now surface as `log.warning` / `log.debug` with the exception attached. WebSocket handlers (`highway_ws`, `ws_retune`) bind a `ws_conn_id` contextvar at accept time so every log line within a session carries a connection ID. A CI grep guard in `.github/workflows/tests.yml` fails the build if either `print(` or `traceback.print_exc(` reappears in `server.py` or `lib/`.
|
- Structured logging migration completed (phase 2 of #155, #159, #242). The 42 `print()` calls and 6 `traceback.print_exc()` calls across `server.py` and `lib/` have been migrated to levelled `feedBack.*` loggers. Silent `except: pass` blocks in `lib/sloppak.py` and `lib/sloppak_convert.py` now surface as `log.warning` / `log.debug` with the exception attached. WebSocket handlers (`highway_ws`, `ws_retune`) bind a `ws_conn_id` contextvar at accept time so every log line within a session carries a connection ID. A CI grep guard in `.github/workflows/tests.yml` fails the build if either `print(` or `traceback.print_exc(` reappears in `server.py` or `lib/`.
|
||||||
- **Lyrics Karaoke plugin** — end-to-end karaoke setup for Sloppak songs in one workflow. The setup screen shows a per-song checklist (vocals stem / synced lyrics / per-syllable pitch) and a single "Build Karaoke" button that runs whatever's missing: Whisper alignment of pasted lyric text against the vocals stem, then `librosa.pyin` pitch extraction. Both artifacts persist inside the Sloppak (`lyrics.json`, `vocal_pitch.json`). In the player, a "Karaoke" toggle swaps the text-lyrics overlay for a horizontal pitch ribbon (one bar per syllable, vertically positioned by pitch, sweeping playhead).
|
- **Lyrics Karaoke plugin** — end-to-end karaoke setup for Sloppak songs in one workflow. The setup screen shows a per-song checklist (vocals stem / synced lyrics / per-syllable pitch) and a single "Build Karaoke" button that runs whatever's missing: Whisper alignment of pasted lyric text against the vocals stem, then `librosa.pyin` pitch extraction. Both artifacts persist inside the Sloppak (`lyrics.json`, `vocal_pitch.json`). In the player, a "Karaoke" toggle swaps the text-lyrics overlay for a horizontal pitch ribbon (one bar per syllable, vertically positioned by pitch, sweeping playhead).
|
||||||
- Settings export/import (#113). Two buttons on the Settings page bundle server config, browser localStorage, and opted-in plugin server-side files into a single versioned JSON file for backup, migration, or sharing a calibrated setup. Server-side import is all-or-nothing for safety-critical failures: phase-1 validates the entire bundle (schema, path-traversal, encoding) before any disk writes; phase-2 commits each file via temp+rename. Plugin-state mismatches between export and import are handled leniently: files referenced for a plugin that isn't loaded are skipped with a warning, files referenced for a plugin whose manifest no longer declares them are skipped with a warning, and localStorage is merged (not cleared) so first-run defaults from plugins installed after the export are preserved. Path-traversal, absolute paths, schema mismatch, and decode failures remain hard refusals. Plugins opt their server-side files in by declaring `settings.server_files` in `plugin.json` (list of relpaths under `CONFIG_DIR`; trailing `/` denotes a directory).
|
- Settings export/import (#113). Two buttons on the Settings page bundle server config, browser localStorage, and opted-in plugin server-side files into a single versioned JSON file for backup, migration, or sharing a calibrated setup. Server-side import is all-or-nothing for safety-critical failures: phase-1 validates the entire bundle (schema, path-traversal, encoding) before any disk writes; phase-2 commits each file via temp+rename. Plugin-state mismatches between export and import are handled leniently: files referenced for a plugin that isn't loaded are skipped with a warning, files referenced for a plugin whose manifest no longer declares them are skipped with a warning, and localStorage is merged (not cleared) so first-run defaults from plugins installed after the export are preserved. Path-traversal, absolute paths, schema mismatch, and decode failures remain hard refusals. Plugins opt their server-side files in by declaring `settings.server_files` in `plugin.json` (list of relpaths under `CONFIG_DIR`; trailing `/` denotes a directory).
|
||||||
- Library filtering by parts present or missing (#129, #69). New right-side Filters drawer (single button next to the format/sort row, with active-filter count badge and dismissible chips below) lets you require or exclude arrangements (Lead/Rhythm/Bass/Combo), specific stems on Sloppaks (drums/bass/vocals/piano/other), lyrics, and tuning. Multi-select within an axis is OR (Lead OR Rhythm); cross-axis is AND. State persists across reloads. New endpoint `GET /api/library/tuning-names` returns distinct tunings present in the library, ordered by musical distance.
|
- Library filtering by parts present or missing (#129, #69). New right-side Filters drawer (single button next to the format/sort row, with active-filter count badge and dismissible chips below) lets you require or exclude arrangements (Lead/Rhythm/Bass/Combo), specific stems on Sloppaks (drums/bass/vocals/piano/other), lyrics, and tuning. Multi-select within an axis is OR (Lead OR Rhythm); cross-axis is AND. State persists across reloads. New endpoint `GET /api/library/tuning-names` returns distinct tunings present in the library, ordered by musical distance.
|
||||||
@@ -78,17 +128,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- **`highway.getLyrics()` accessor.** `createHighway()` now exposes the parsed timed lyric syllables (`[{t, d, w}]`) via `getLyrics()`, mirroring `getBeats()`/`getSections()`, so overlay plugins can render karaoke without opening a second highway WebSocket. Pure accessor; no behavior change.
|
- **`highway.getLyrics()` accessor.** `createHighway()` now exposes the parsed timed lyric syllables (`[{t, d, w}]`) via `getLyrics()`, mirroring `getBeats()`/`getSections()`, so overlay plugins can render karaoke without opening a second highway WebSocket. Pure accessor; no behavior change.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- **Perf (3D highway, slopsmith#226)**: pre-warm `plugins/highway_3d/screen.js` object pools at board init. Previously the pool factory grew lazily on first `.get()` past the high-water mark, allocating a fresh `T.Mesh` mid-rAF on dense 7/8-string charts and stalling those frames; the meshes were then permanently added to `noteG` (the pool only hides on `reset()`, never removes), bloating the scene graph for the rest of the session. Pre-warming spends the cost up front. Fold the per-frame `updateStringHighlights()` per-string loop with the post-call `mGlow`/`mAccentCore` emissive writes — one walk over the per-string scratch arrays instead of two. Replace `longestConsecutiveRun`'s per-call array allocations with a `{start, len}` index pair (trades two per-call sub-array allocations for one small 2-key object — net reduction in per-visible-chord allocation churn). Opt-in perf bench harness via `?h3dbench=1` URL param: `console.log` p50/p95/max for six update() segments every 5 seconds; when the URL flag is absent the mark helpers are bound to empty functions at renderer-instance init (each `createHighway()` panel re-checks the flag), so the hot-path calls are no-ops with negligible overhead (typically JIT-inlined).
|
- **Perf (3D highway, feedBack#226)**: pre-warm `plugins/highway_3d/screen.js` object pools at board init. Previously the pool factory grew lazily on first `.get()` past the high-water mark, allocating a fresh `T.Mesh` mid-rAF on dense 7/8-string charts and stalling those frames; the meshes were then permanently added to `noteG` (the pool only hides on `reset()`, never removes), bloating the scene graph for the rest of the session. Pre-warming spends the cost up front. Fold the per-frame `updateStringHighlights()` per-string loop with the post-call `mGlow`/`mAccentCore` emissive writes — one walk over the per-string scratch arrays instead of two. Replace `longestConsecutiveRun`'s per-call array allocations with a `{start, len}` index pair (trades two per-call sub-array allocations for one small 2-key object — net reduction in per-visible-chord allocation churn). Opt-in perf bench harness via `?h3dbench=1` URL param: `console.log` p50/p95/max for six update() segments every 5 seconds; when the URL flag is absent the mark helpers are bound to empty functions at renderer-instance init (each `createHighway()` panel re-checks the flag), so the hot-path calls are no-ops with negligible overhead (typically JIT-inlined).
|
||||||
- **License**: Relicensed to AGPL-3.0-only. Prior versions claimed MIT in the README, but the bundled desktop build statically links JUCE 8 (AGPL-3.0), so AGPL terms have effectively governed the desktop distribution since JUCE was added. AGPL-3.0-only is now the canonical license for the project — see [LICENSE](LICENSE) and [CONTRIBUTING.md](CONTRIBUTING.md) (DCO sign-off + plugin licensing policy). Bundled and vendored third-party code keeps its original license.
|
- **License**: Relicensed to AGPL-3.0-only. Prior versions claimed MIT in the README, but the bundled desktop build statically links JUCE 8 (AGPL-3.0), so AGPL terms have effectively governed the desktop distribution since JUCE was added. AGPL-3.0-only is now the canonical license for the project — see [LICENSE](LICENSE) and [CONTRIBUTING.md](CONTRIBUTING.md) (DCO sign-off + plugin licensing policy). Bundled and vendored third-party code keeps its original license.
|
||||||
- Tuning sort is now ordered by musical distance from E Standard (#22) instead of alphabetical: E Standard first, then Drop D / F Standard at distance 2, then Eb Standard / F# Standard at distance 6, etc. Within a magnitude tier, down-tuned variants come before up-tuned, then alphabetical.
|
- Tuning sort is now ordered by musical distance from E Standard (#22) instead of alphabetical: E Standard first, then Drop D / F Standard at distance 2, then Eb Standard / F# Standard at distance 6, etc. Within a magnitude tier, down-tuned variants come before up-tuned, then alphabetical.
|
||||||
- Settings page restructured into separate "Slopsmith" (core) and "Plugins" sections, with each plugin's settings rendered as a collapsible panel (collapsed by default). "Plugin Updates" moved into the Plugins section.
|
- Settings page restructured into separate "FeedBack" (core) and "Plugins" sections, with each plugin's settings rendered as a collapsible panel (collapsed by default). "Plugin Updates" moved into the Plugins section.
|
||||||
- **Lyrics Sync** is now a redirect stub. Its alignment + save endpoints moved into the new Lyrics Karaoke plugin alongside the pitch extraction. Existing nav entries and bookmarks land on a "moved" page that auto-redirects to the merged plugin.
|
- **Lyrics Sync** is now a redirect stub. Its alignment + save endpoints moved into the new Lyrics Karaoke plugin alongside the pitch extraction. Existing nav entries and bookmarks land on a "moved" page that auto-redirects to the merged plugin.
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
- **Path traversal in archive extractors and library path resolution.** `lib/sloppak.py::_unpack_zip` and `server.py::_resolve_dlc_path` previously concatenated attacker-controlled entry names or filenames directly onto the extraction or library directory, so a crafted sloppak zip member or library filename with `..` segments, an absolute path, or backslash separators could write or read outside the intended directory. Any code path that unpacks a user-supplied archive (library upload, click-to-play, retune) or resolves a library path was reachable. Both locations now delegate to a new `lib/safepath.py::safe_join` helper that resolves each destination once and rejects entries that don't fall under the target directory; rejected entries are logged and skipped, the rest of the archive still extracts. The stem-split paths in `lib/sloppak_convert.py::split_stems` and `scripts/split_stems.py` previously called `ZipFile.extractall()` directly on user-supplied sloppaks; both now delegate to the same hardened `lib/sloppak.py::_unpack_zip` so every sloppak-unzip site in the codebase shares one containment guarantee. Tests in `tests/test_archive_traversal.py` and `tests/test_safepath.py` pin the contract for `../`, deep traversal, absolute paths, mixed `subdir/../../` forms, Windows-style separators, NUL bytes, names that resolve to the unpack root, and symlinked roots.
|
- **Path traversal in archive extractors and library path resolution.** `lib/sloppak.py::_unpack_zip` and `server.py::_resolve_dlc_path` previously concatenated attacker-controlled entry names or filenames directly onto the extraction or library directory, so a crafted sloppak zip member or library filename with `..` segments, an absolute path, or backslash separators could write or read outside the intended directory. Any code path that unpacks a user-supplied archive (library upload, click-to-play, retune) or resolves a library path was reachable. Both locations now delegate to a new `lib/safepath.py::safe_join` helper that resolves each destination once and rejects entries that don't fall under the target directory; rejected entries are logged and skipped, the rest of the archive still extracts. The stem-split paths in `lib/sloppak_convert.py::split_stems` and `scripts/split_stems.py` previously called `ZipFile.extractall()` directly on user-supplied sloppaks; both now delegate to the same hardened `lib/sloppak.py::_unpack_zip` so every sloppak-unzip site in the codebase shares one containment guarantee. Tests in `tests/test_archive_traversal.py` and `tests/test_safepath.py` pin the contract for `../`, deep traversal, absolute paths, mixed `subdir/../../` forms, Windows-style separators, NUL bytes, names that resolve to the unpack root, and symlinked roots.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- E Standard retune now stays metadata-consistent across a chart's arrangement files (slopsmith-plugin-notedetect#50). Previously the retune path could shift the audio and update manifests while leaving some arrangement metadata untouched, so `load_song()` later exposed the *original* tuning at runtime. `lib/retune.py` now updates every arrangement's tuning metadata consistently before applying the E Standard tuning, and raises on a partial update instead of silently packing split tuning metadata. EStd files generated before this fix should be re-converted so their metadata is consistent.
|
- E Standard retune now stays metadata-consistent across a chart's arrangement files (feedBack-plugin-notedetect#50). Previously the retune path could shift the audio and update manifests while leaving some arrangement metadata untouched, so `load_song()` later exposed the *original* tuning at runtime. `lib/retune.py` now updates every arrangement's tuning metadata consistently before applying the E Standard tuning, and raises on a partial update instead of silently packing split tuning metadata. EStd files generated before this fix should be re-converted so their metadata is consistent.
|
||||||
- Keyboard shortcut help now opens from the Player/3D Highway context when Linux/Electron reports Shift+Slash as `key="/"`, including while player controls such as the visualization picker are focused (#598).
|
- Keyboard shortcut help now opens from the Player/3D Highway context when Linux/Electron reports Shift+Slash as `key="/"`, including while player controls such as the visualization picker are focused (#598).
|
||||||
- 3D Highway left-handed mode now has regression coverage for fret-axis mirroring, board rebuilds on runtime lefty changes, and mirrored camera state including the lookahead target and shoulder offset; the maintainer guide no longer claims the renderer ignores `bundle.lefty` (#321).
|
- 3D Highway left-handed mode now has regression coverage for fret-axis mirroring, board rebuilds on runtime lefty changes, and mirrored camera state including the lookahead target and shoulder offset; the maintainer guide no longer claims the renderer ignores `bundle.lefty` (#321).
|
||||||
- Chord-level `fretHandMute` is now parsed into each note's `fret_hand_mute` (wire `fhm`) instead of being folded into `mute` (`mt`), matching `_parse_note` and preserving wire-format fidelity for both the template-expanded (synthetic-note) and explicit-`chordNote` paths. The 3D highway renders the fret-hand-mute X for `mt` *or* `fhm` notes, so the muted-chord overlay still shows. Also fixes the per-note fret-connector label vanishing exactly at the hit line (the fade now holds full opacity through `dt = 0`).
|
- Chord-level `fretHandMute` is now parsed into each note's `fret_hand_mute` (wire `fhm`) instead of being folded into `mute` (`mt`), matching `_parse_note` and preserving wire-format fidelity for both the template-expanded (synthetic-note) and explicit-`chordNote` paths. The 3D highway renders the fret-hand-mute X for `mt` *or* `fhm` notes, so the muted-chord overlay still shows. Also fixes the per-note fret-connector label vanishing exactly at the hit line (the fade now holds full opacity through `dt = 0`).
|
||||||
@@ -97,8 +147,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Demucs stem split failing on Windows desktop with `OSError: Could not load this library: libtorchcodec_core4.dll` or `ImportError: TorchCodec is required for save_with_torchcodec`. The demucs subprocess now bootstraps a `torchaudio.save` → `soundfile.write` shim before importing demucs, sidestepping the torchcodec dependency entirely. The override stays in place across torchaudio versions — soundfile's WAV writes are behaviorally equivalent for demucs's float32 outputs.
|
- Demucs stem split failing on Windows desktop with `OSError: Could not load this library: libtorchcodec_core4.dll` or `ImportError: TorchCodec is required for save_with_torchcodec`. The demucs subprocess now bootstraps a `torchaudio.save` → `soundfile.write` shim before importing demucs, sidestepping the torchcodec dependency entirely. The override stays in place across torchaudio versions — soundfile's WAV writes are behaviorally equivalent for demucs's float32 outputs.
|
||||||
- Splitscreen pop-out windows briefly flashed the library/song grid before showing the popped panel. A popup loads the full app (whose default screen, `#home`, is the library) and only swaps to the player once the splitscreen plugin loads; app init now detects `?ssFollower=1` and switches to the player screen up front, so the popup shows player chrome the whole time.
|
- Splitscreen pop-out windows briefly flashed the library/song grid before showing the popped panel. A popup loads the full app (whose default screen, `#home`, is the library) and only swaps to the player once the splitscreen plugin loads; app init now detects `?ssFollower=1` and switches to the player screen up front, so the popup shows player chrome the whole time.
|
||||||
- Sloppak assembly dropped all tone data — affected sloppaks showed no signal chain in the Tones plugin and no tone-change markers on the highway. The assembler (`lib/sloppak_convert.py`) now lifts each arrangement's tones from the source chart via the new `lib/tones.py` helper and embeds them inline in the arrangement JSON under a `tones` key (`base`, `changes`, `definitions` — see `docs/sloppak-spec.md` §3.9). The highway WebSocket reads `base`/`changes` for sloppaks, and the Tones plugin (≥ 1.1.0) reads `definitions` to render the gear chain. Sloppaks built before this release carry no tone data and must be rebuilt from their source chart to gain it.
|
- Sloppak assembly dropped all tone data — affected sloppaks showed no signal chain in the Tones plugin and no tone-change markers on the highway. The assembler (`lib/sloppak_convert.py`) now lifts each arrangement's tones from the source chart via the new `lib/tones.py` helper and embeds them inline in the arrangement JSON under a `tones` key (`base`, `changes`, `definitions` — see `docs/sloppak-spec.md` §3.9). The highway WebSocket reads `base`/`changes` for sloppaks, and the Tones plugin (≥ 1.1.0) reads `definitions` to render the gear chain. Sloppaks built before this release carry no tone data and must be rebuilt from their source chart to gain it.
|
||||||
- Tab View (slopsmith-plugin-tabview ≥ 3.0.1): the bottom row of tablature was permanently hidden behind the player controls bar (#336). The overlay reserved 60px at the *top* (clearing the transparent HUD) and extended all the way to the bottom of `#player`, where the opaque `#player-controls` (z-index 10) drew over the last row. The overlay now measures `#player-hud` and `#player-controls` dynamically and insets both edges; a `ResizeObserver` on the controls bar re-runs the inset when it wraps to a second row on narrow viewports.
|
- Tab View (feedBack-plugin-tabview ≥ 3.0.1): the bottom row of tablature was permanently hidden behind the player controls bar (#336). The overlay reserved 60px at the *top* (clearing the transparent HUD) and extended all the way to the bottom of `#player`, where the opaque `#player-controls` (z-index 10) drew over the last row. The overlay now measures `#player-hud` and `#player-controls` dynamically and insets both edges; a `ResizeObserver` on the controls bar re-runs the inset when it wraps to a second row on narrow viewports.
|
||||||
- Tab View (slopsmith-plugin-tabview ≥ 3.0.1): the cursor highlight led playback by roughly one beat (#336). alphaTab snaps `tickPosition` to the start of the *next* beat, so the cursor would race ahead by 500ms+ at typical tempos. The plugin now sends `tickPosition` one beat earlier so the snap lands on the current beat, and the highlight overlay tracks the bar cursor (`.at-cursor-bar`) instead of the next-beat cursor (`.at-cursor-beat`).
|
- Tab View (feedBack-plugin-tabview ≥ 3.0.1): the cursor highlight led playback by roughly one beat (#336). alphaTab snaps `tickPosition` to the start of the *next* beat, so the cursor would race ahead by 500ms+ at typical tempos. The plugin now sends `tickPosition` one beat earlier so the snap lands on the current beat, and the highlight overlay tracks the bar cursor (`.at-cursor-bar`) instead of the next-beat cursor (`.at-cursor-beat`).
|
||||||
|
|
||||||
### Migration notes
|
### Migration notes
|
||||||
- **Constitution amended to 1.1.0 (Principle II — Vanilla Frontend).** Prebuilt Tailwind (`static/tailwind.min.css`) is now codified as non-negotiable: no Play CDN / runtime CSS JIT anywhere, core or plugin. Plugin authors: a plugin that uses Tailwind classes not guaranteed in core — especially arbitrary values like `w-[37px]` — MUST ship its own compiled stylesheet via the new `styles` manifest key, built with `corePlugins.preflight = false`. Plugins that use only core-guaranteed utilities, or that ship no Tailwind at all, need no change. Contributors: after adding any Tailwind class to core or a bundled plugin, run `bash scripts/build-tailwind.sh` and commit the regenerated CSS, or the `tailwind-fresh` CI job fails.
|
- **Constitution amended to 1.1.0 (Principle II — Vanilla Frontend).** Prebuilt Tailwind (`static/tailwind.min.css`) is now codified as non-negotiable: no Play CDN / runtime CSS JIT anywhere, core or plugin. Plugin authors: a plugin that uses Tailwind classes not guaranteed in core — especially arbitrary values like `w-[37px]` — MUST ship its own compiled stylesheet via the new `styles` manifest key, built with `corePlugins.preflight = false`. Plugins that use only core-guaranteed utilities, or that ship no Tailwind at all, need no change. Contributors: after adding any Tailwind class to core or a bundled plugin, run `bash scripts/build-tailwind.sh` and commit the regenerated CSS, or the `tailwind-fresh` CI job fails.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Slopsmith — AI Agent Guide
|
# FeedBack — AI Agent Guide
|
||||||
|
|
||||||
Slopsmith is a self-hosted web app for browsing, playing, and practicing interactive music notation, built around its own open `.sloppak` chart format. Charts come from importing Guitar Pro (GP5/GP8) or MusicXML, or from authoring in the built-in editor. It runs as a Docker container with a FastAPI backend (`server.py`), vanilla JavaScript frontend (`static/`), shared Python libraries (`lib/`), and an extensive plugin system (`plugins/`). There are no frontend frameworks — everything is plain JS, HTML, and Tailwind CSS.
|
FeedBack is a self-hosted web app for browsing, playing, and practicing interactive music notation, built around its own open `.sloppak` chart format. Charts come from importing Guitar Pro (GP5/GP8) or MusicXML, or from authoring in the built-in editor. It runs as a Docker container with a FastAPI backend (`server.py`), vanilla JavaScript frontend (`static/`), shared Python libraries (`lib/`), and an extensive plugin system (`plugins/`). There are no frontend frameworks — everything is plain JS, HTML, and Tailwind CSS.
|
||||||
|
|
||||||
## Architecture Quick Reference
|
## Architecture Quick Reference
|
||||||
|
|
||||||
@@ -62,26 +62,26 @@ All fields except `id` and `name` are optional. Plugins can have any combination
|
|||||||
|
|
||||||
`styles` is the **opt-in** for self-hosted CSS (Principle II — prebuilt Tailwind, no Play CDN). Core's `static/tailwind.min.css` only contains classes scanned from core source at build time, so a plugin installed at runtime (community / NAS) that uses classes core didn't scan — especially arbitrary values like `text-[11px]` — renders unstyled. Declaring `styles` makes the frontend inject one versioned `<link rel="stylesheet">` into `<head>` (covering the plugin's screen *and* its settings panel) pointing at the plugin's own compiled stylesheet. The value is a **plugin-root-relative path that must live under `assets/`** (e.g. `"assets/plugin.css"`) so it serves through the sandboxed `/api/plugins/<id>/assets/...` route. Build it with `corePlugins: { preflight: false }` (utilities only — core ships the single base reset; don't duplicate it) and **never** the Tailwind Play CDN. Plugins that use only core-guaranteed utilities, or ship no Tailwind, omit `styles` and are byte-for-byte unaffected. Full authoring guide + scaffold: [docs/plugin-styles.md](docs/plugin-styles.md).
|
`styles` is the **opt-in** for self-hosted CSS (Principle II — prebuilt Tailwind, no Play CDN). Core's `static/tailwind.min.css` only contains classes scanned from core source at build time, so a plugin installed at runtime (community / NAS) that uses classes core didn't scan — especially arbitrary values like `text-[11px]` — renders unstyled. Declaring `styles` makes the frontend inject one versioned `<link rel="stylesheet">` into `<head>` (covering the plugin's screen *and* its settings panel) pointing at the plugin's own compiled stylesheet. The value is a **plugin-root-relative path that must live under `assets/`** (e.g. `"assets/plugin.css"`) so it serves through the sandboxed `/api/plugins/<id>/assets/...` route. Build it with `corePlugins: { preflight: false }` (utilities only — core ships the single base reset; don't duplicate it) and **never** the Tailwind Play CDN. Plugins that use only core-guaranteed utilities, or ship no Tailwind, omit `styles` and are byte-for-byte unaffected. Full authoring guide + scaffold: [docs/plugin-styles.md](docs/plugin-styles.md).
|
||||||
|
|
||||||
`settings.server_files` is the **opt-in** for the unified Settings export/import flow (slopsmith#113). It's a list of relpaths under `context["config_dir"]` that the plugin wants included in user-triggered backups. A trailing `/` denotes a directory (recurse). Plugins that omit this field have no server-side files exported; their state lives entirely in browser `localStorage`, which is bundled wholesale on every export. Rules:
|
`settings.server_files` is the **opt-in** for the unified Settings export/import flow (feedBack#113). It's a list of relpaths under `context["config_dir"]` that the plugin wants included in user-triggered backups. A trailing `/` denotes a directory (recurse). Plugins that omit this field have no server-side files exported; their state lives entirely in browser `localStorage`, which is bundled wholesale on every export. Rules:
|
||||||
- Relpaths only. Absolute paths, drive letters, `..` segments, and backslashes are rejected at load time with a `[Plugin]` warning.
|
- Relpaths only. Absolute paths, drive letters, `..` segments, and backslashes are rejected at load time with a `[Plugin]` warning.
|
||||||
- The same allowlist is consulted at both export and import: a bundle that references a file the *importing host*'s manifest no longer declares is skipped with a warning (handles plugin updates between export and import). A bundle that references a file your host's manifest never declared is also skipped — no surprise writes.
|
- The same allowlist is consulted at both export and import: a bundle that references a file the *importing host*'s manifest no longer declares is skipped with a warning (handles plugin updates between export and import). A bundle that references a file your host's manifest never declared is also skipped — no surprise writes.
|
||||||
- Files are encoded as `{"encoding": "json", "data": <parsed>}` for `.json` files that parse cleanly (diff-friendly), `{"encoding": "base64", "data": "..."}` otherwise (sqlite, model blobs, IRs).
|
- Files are encoded as `{"encoding": "json", "data": <parsed>}` for `.json` files that parse cleanly (diff-friendly), `{"encoding": "base64", "data": "..."}` otherwise (sqlite, model blobs, IRs).
|
||||||
- Plugins own their internal data migration. Importing a bundle whose data schema predates your current code restores bytes verbatim — your plugin must cope at next load.
|
- Plugins own their internal data migration. Importing a bundle whose data schema predates your current code restores bytes verbatim — your plugin must cope at next load.
|
||||||
- Symlinks are skipped on export and never followed on import.
|
- Symlinks are skipped on export and never followed on import.
|
||||||
|
|
||||||
`diagnostics` is the **opt-in** for the troubleshooting bundle (slopsmith#166 — Settings → Export Diagnostics). Two independent fields:
|
`diagnostics` is the **opt-in** for the troubleshooting bundle (feedBack#166 — Settings → Export Diagnostics). Two independent fields:
|
||||||
- `diagnostics.server_files` — same allowlist semantics as `settings.server_files`: relpaths under `context["config_dir"]`, no `..`, no abs paths, no backslashes, no leading dots. Files listed here are copied verbatim into `plugins/<plugin_id>/<relpath>` inside the bundle. Use this for snapshot-style state (small DB excerpts, model lists, last-error files).
|
- `diagnostics.server_files` — same allowlist semantics as `settings.server_files`: relpaths under `context["config_dir"]`, no `..`, no abs paths, no backslashes, no leading dots. Files listed here are copied verbatim into `plugins/<plugin_id>/<relpath>` inside the bundle. Use this for snapshot-style state (small DB excerpts, model lists, last-error files).
|
||||||
- `diagnostics.callable` — `"<module>:<function>"` (e.g. `"diagnostics:collect"`). Resolved lazily via `load_sibling` when the user clicks Export, then called as `func({"plugin_id": "...", "config_dir": Path(...)})`. Return `dict`/`list` → written to `plugins/<id>/callable.json`; `bytes` → `callable.bin`; `str` → `callable.txt`. Exceptions are caught and appended to the bundle's `manifest.notes` — a buggy plugin never crashes the export.
|
- `diagnostics.callable` — `"<module>:<function>"` (e.g. `"diagnostics:collect"`). Resolved lazily via `load_sibling` when the user clicks Export, then called as `func({"plugin_id": "...", "config_dir": Path(...)})`. Return `dict`/`list` → written to `plugins/<id>/callable.json`; `bytes` → `callable.bin`; `str` → `callable.txt`. Exceptions are caught and appended to the bundle's `manifest.notes` — a buggy plugin never crashes the export.
|
||||||
|
|
||||||
Plugins that omit the field contribute nothing to the bundle from the backend side. Frontend plugins can independently push state via `window.slopsmith.diagnostics.contribute(plugin_id, payload)` from their `screen.js` before the user hits Export. Bundle layout + per-file schemas: [docs/diagnostics-bundle-spec.md](docs/diagnostics-bundle-spec.md).
|
Plugins that omit the field contribute nothing to the bundle from the backend side. Frontend plugins can independently push state via `window.feedBack.diagnostics.contribute(plugin_id, payload)` from their `screen.js` before the user hits Export. Bundle layout + per-file schemas: [docs/diagnostics-bundle-spec.md](docs/diagnostics-bundle-spec.md).
|
||||||
|
|
||||||
Best practices:
|
Best practices:
|
||||||
- Embed your own `schema` field (e.g. `"my_plugin.diag.v1"`) in JSON returned by `callable` so future tooling can dispatch by version.
|
- Embed your own `schema` field (e.g. `"my_plugin.diag.v1"`) in JSON returned by `callable` so future tooling can dispatch by version.
|
||||||
- Keep payloads small (< 100 KB). Diagnostics are not a backup channel — that's `settings.server_files`.
|
- Keep payloads small (< 100 KB). Diagnostics are not a backup channel — that's `settings.server_files`.
|
||||||
- Don't include user secrets, API keys, or session tokens. The bundle is shared with maintainers / posted to GitHub issues.
|
- Don't include user secrets, API keys, or session tokens. The bundle is shared with maintainers / posted to GitHub issues.
|
||||||
|
|
||||||
`type` is an optional role hint (slopsmith#36). Supported values:
|
`type` is an optional role hint (feedBack#36). Supported values:
|
||||||
- `"visualization"` — plugin provides a highway renderer. Declaring this makes the plugin eligible for the main-player viz picker AND splitscreen's per-panel picker. Must pair with a `window.slopsmithViz_<id>` factory exporting the setRenderer contract below.
|
- `"visualization"` — plugin provides a highway renderer. Declaring this makes the plugin eligible for the main-player viz picker AND splitscreen's per-panel picker. Must pair with a `window.feedBackViz_<id>` factory exporting the setRenderer contract below.
|
||||||
- Absent → no declared role; plugin is loaded and its script runs, but it doesn't appear in role-specific UIs.
|
- Absent → no declared role; plugin is loaded and its script runs, but it doesn't appear in role-specific UIs.
|
||||||
|
|
||||||
**Backend routes** — `routes.py` must export a `setup(app, context)` function. The `context` dict provides:
|
**Backend routes** — `routes.py` must export a `setup(app, context)` function. The `context` dict provides:
|
||||||
@@ -94,9 +94,9 @@ Best practices:
|
|||||||
- `unregister_library_provider(provider_id)` — remove a plugin-provided library source by id. The built-in `local` provider cannot be removed.
|
- `unregister_library_provider(provider_id)` — remove a plugin-provided library source by id. The built-in `local` provider cannot be removed.
|
||||||
- `get_sloppak_cache_dir()` — sloppak cache path
|
- `get_sloppak_cache_dir()` — sloppak cache path
|
||||||
- `load_sibling(name)` — loads a sibling module from this plugin's directory under a unique, namespaced module name. See "Sibling imports" below.
|
- `load_sibling(name)` — loads a sibling module from this plugin's directory under a unique, namespaced module name. See "Sibling imports" below.
|
||||||
- `log` — stdlib `logging.Logger` namespaced to `slopsmith.plugin.<id>`. Pre-configured with the app-wide level, format (including JSON mode), and correlation IDs. Use this for all backend plugin output instead of `print()`. See "Backend plugin logging" below.
|
- `log` — stdlib `logging.Logger` namespaced to `feedBack.plugin.<id>`. Pre-configured with the app-wide level, format (including JSON mode), and correlation IDs. Use this for all backend plugin output instead of `print()`. See "Backend plugin logging" below.
|
||||||
|
|
||||||
**Sibling imports — use `load_sibling`, not bare imports** (slopsmith#33). The plugin loader inserts each plugin's directory onto `sys.path` so `from extractor import X` works, but Python caches imports by **module name** in `sys.modules`. Two plugins that each ship a top-level `extractor.py` (or any other generic name — `util.py`, `client.py`, `parser.py`, `config.py`, …) collide: whichever loads first wins, and the other plugin's `from extractor import X` either gets the wrong module or fails with `cannot import name 'X' from 'extractor'`.
|
**Sibling imports — use `load_sibling`, not bare imports** (feedBack#33). The plugin loader inserts each plugin's directory onto `sys.path` so `from extractor import X` works, but Python caches imports by **module name** in `sys.modules`. Two plugins that each ship a top-level `extractor.py` (or any other generic name — `util.py`, `client.py`, `parser.py`, `config.py`, …) collide: whichever loads first wins, and the other plugin's `from extractor import X` either gets the wrong module or fails with `cannot import name 'X' from 'extractor'`.
|
||||||
|
|
||||||
The fix is `context["load_sibling"](name)`, which loads the sibling under a namespaced module name (`plugin_<id>.<name>`, where plugin_id is bijectively encoded so reverse-DNS-style ids like `com.example.foo` work without colliding: `_` -> `_5f_`, `.` -> `_2e_`) so each plugin gets its own copy:
|
The fix is `context["load_sibling"](name)`, which loads the sibling under a namespaced module name (`plugin_<id>.<name>`, where plugin_id is bijectively encoded so reverse-DNS-style ids like `com.example.foo` work without colliding: `_` -> `_5f_`, `.` -> `_2e_`) so each plugin gets its own copy:
|
||||||
|
|
||||||
@@ -115,7 +115,7 @@ Notes:
|
|||||||
- Repeat calls return the cached module. Concurrent first-time calls are serialized via per-module locks so no caller observes a half-initialized module.
|
- Repeat calls return the cached module. Concurrent first-time calls are serialized via per-module locks so no caller observes a half-initialized module.
|
||||||
- Bare `import sibling` from `routes.py` still works during the transition period, but the loader prints a startup warning when it detects two plugins shipping a same-named top-level module — covering both `.py` files and package directories. Migrate to `load_sibling` to silence the warning and immunize your plugin from future ecosystem collisions. (Don't mix bare imports and `load_sibling` for the same module — they'd execute the file twice and split module-level state.)
|
- Bare `import sibling` from `routes.py` still works during the transition period, but the loader prints a startup warning when it detects two plugins shipping a same-named top-level module — covering both `.py` files and package directories. Migrate to `load_sibling` to silence the warning and immunize your plugin from future ecosystem collisions. (Don't mix bare imports and `load_sibling` for the same module — they'd execute the file twice and split module-level state.)
|
||||||
|
|
||||||
**Frontend scripts** — `screen.js` runs in the global scope via a `<script>` tag. It can access `window.playSong`, `window.showScreen`, `window.createHighway`, the `<audio>` element, and the `window.slopsmith` event emitter.
|
**Frontend scripts** — `screen.js` runs in the global scope via a `<script>` tag. It can access `window.playSong`, `window.showScreen`, `window.createHighway`, the `<audio>` element, and the `window.feedBack` event emitter.
|
||||||
|
|
||||||
**The playSong wrapper chain** — Plugins commonly wrap `window.playSong` to hook into song playback. Plugins load alphabetically, so the last-loaded (alphabetically later) wrapper runs first, while the alphabetically first plugin runs closest to the original. Be aware that `await` calls in inner wrappers yield to the event loop — WebSocket messages can arrive before outer wrappers finish setup.
|
**The playSong wrapper chain** — Plugins commonly wrap `window.playSong` to hook into song playback. Plugins load alphabetically, so the last-loaded (alphabetically later) wrapper runs first, while the alphabetically first plugin runs closest to the original. Be aware that `await` calls in inner wrappers yield to the event loop — WebSocket messages can arrive before outer wrappers finish setup.
|
||||||
|
|
||||||
@@ -123,10 +123,10 @@ Notes:
|
|||||||
|
|
||||||
### v3 UI (fee[dB]ack v0.3.0) — player-chrome contract
|
### v3 UI (fee[dB]ack v0.3.0) — player-chrome contract
|
||||||
|
|
||||||
v0.3.0 ships a redesigned UI behind a flag (`SLOPSMITH_UI=v3` or the `/v3` route);
|
v0.3.0 ships a redesigned UI behind a flag (`FEEDBACK_UI=v3` or the `/v3` route);
|
||||||
the classic UI (v2) stays the default until 0.3.0 ships, so **plugins must work in
|
the classic UI (v2) stays the default until 0.3.0 ships, so **plugins must work in
|
||||||
both**. v3 reuses the same engine (`server.py`, `app.js`, `highway.js`, `playSong`,
|
both**. v3 reuses the same engine (`server.py`, `app.js`, `highway.js`, `playSong`,
|
||||||
`showScreen`, capabilities, library providers, the `window.slopsmithViz_<id>` /
|
`showScreen`, capabilities, library providers, the `window.feedBackViz_<id>` /
|
||||||
`setRenderer` contract), so a plugin's **backend, capabilities, `nav`/`screen`,
|
`setRenderer` contract), so a plugin's **backend, capabilities, `nav`/`screen`,
|
||||||
visualization renderers, diagnostics, and settings export work unchanged** — v3
|
visualization renderers, diagnostics, and settings export work unchanged** — v3
|
||||||
surfaces `nav` in its sidebar and mounts screens exactly as v2 does.
|
surfaces `nav` in its sidebar and mounts screens exactly as v2 does.
|
||||||
@@ -140,8 +140,8 @@ control into it, you must adapt:
|
|||||||
legacy way means your control **auto-hides**, and the legacy insertion anchors
|
legacy way means your control **auto-hides**, and the legacy insertion anchors
|
||||||
(`insertBefore` the `span.text-gray-700` separator, or `button:last-child` / ✕
|
(`insertBefore` the `span.text-gray-700` separator, or `button:last-child` / ✕
|
||||||
Close) **don't exist in v3** → it lands wrong / unreachable.
|
Close) **don't exist in v3** → it lands wrong / unreachable.
|
||||||
- **Detect v3** with `window.slopsmith.uiVersion === 'v3'` and **mount into
|
- **Detect v3** with `window.feedBack.uiVersion === 'v3'` and **mount into
|
||||||
`window.slopsmith.ui.playerControlSlot()`** (a stable, always-reachable container
|
`window.feedBack.ui.playerControlSlot()`** (a stable, always-reachable container
|
||||||
— the "Plugins" rail popover) instead of `#player-controls`. Drop the dead
|
— the "Plugins" rail popover) instead of `#player-controls`. Drop the dead
|
||||||
anchors (append), and guard re-injection against the *actual* container
|
anchors (append), and guard re-injection against the *actual* container
|
||||||
(`controls.contains(myBtn)`), not a hard-coded `#player-controls`.
|
(`controls.contains(myBtn)`), not a hard-coded `#player-controls`.
|
||||||
@@ -197,18 +197,18 @@ usually an unrelated plugin's per-frame DOM work.
|
|||||||
|
|
||||||
### Visualization plugins — two complementary contracts
|
### Visualization plugins — two complementary contracts
|
||||||
|
|
||||||
Slopsmith supports two ways for a plugin to participate in the main player's visuals. They coexist; the setRenderer contract is the default for any viz that draws a highway-shaped surface, and overlays handle layered decorations on top.
|
FeedBack supports two ways for a plugin to participate in the main player's visuals. They coexist; the setRenderer contract is the default for any viz that draws a highway-shaped surface, and overlays handle layered decorations on top.
|
||||||
|
|
||||||
**Pick the right shape:**
|
**Pick the right shape:**
|
||||||
- Replacing the whole highway drawing on the existing highway canvas (your renderer owns its rendering context / resources; `createHighway()` still owns the canvas element and the rAF loop)? → **setRenderer** (section 1). Enters the viz picker. Works in both the main player and per-panel under splitscreen.
|
- Replacing the whole highway drawing on the existing highway canvas (your renderer owns its rendering context / resources; `createHighway()` still owns the canvas element and the rAF loop)? → **setRenderer** (section 1). Enters the viz picker. Works in both the main player and per-panel under splitscreen.
|
||||||
- Adding a layer on top of whichever viz is active? → **Overlay** (section 2). Navbar toggle, not in the picker.
|
- Adding a layer on top of whichever viz is active? → **Overlay** (section 2). Navbar toggle, not in the picker.
|
||||||
|
|
||||||
#### 1. setRenderer contract (slopsmith#36) — preferred
|
#### 1. setRenderer contract (feedBack#36) — preferred
|
||||||
|
|
||||||
Plugins that want to replace the main highway's draw function (per panel, per session) export a renderer factory on `window.slopsmithViz_<id>` where `<id>` matches the `id` in `plugin.json` (`type: "visualization"` required). The factory returns an object matching this shape:
|
Plugins that want to replace the main highway's draw function (per panel, per session) export a renderer factory on `window.feedBackViz_<id>` where `<id>` matches the `id` in `plugin.json` (`type: "visualization"` required). The factory returns an object matching this shape:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
window.slopsmithViz_my_viz = function () {
|
window.feedBackViz_my_viz = function () {
|
||||||
return {
|
return {
|
||||||
// Required canvas context type. Default '2d' if omitted.
|
// Required canvas context type. Default '2d' if omitted.
|
||||||
// highway.js reads this BEFORE calling init() so it can
|
// highway.js reads this BEFORE calling init() so it can
|
||||||
@@ -239,7 +239,7 @@ window.slopsmithViz_my_viz = function () {
|
|||||||
// a bundle-level helper isn't provided because it would
|
// a bundle-level helper isn't provided because it would
|
||||||
// need your renderer's own context, not the factory's.
|
// need your renderer's own context, not the factory's.
|
||||||
//
|
//
|
||||||
// bundle.getNoteState(note, chartTime) (slopsmith#254) — call
|
// bundle.getNoteState(note, chartTime) (feedBack#254) — call
|
||||||
// this per visible chart note / chord-note to find out whether
|
// this per visible chart note / chord-note to find out whether
|
||||||
// a scorer (note_detect) has flagged it 'hit' / 'active' (a
|
// a scorer (note_detect) has flagged it 'hit' / 'active' (a
|
||||||
// sustain currently being held correctly) / 'miss', so the gem
|
// sustain currently being held correctly) / 'miss', so the gem
|
||||||
@@ -283,25 +283,25 @@ Selecting this plugin in the main-player viz picker — or in splitscreen's per-
|
|||||||
- **Canvas context-type swapping.** Browsers lock a `<canvas>` to the first context type successfully acquired for its lifetime: once `getContext('2d')` succeeds, `getContext('webgl2')` on that same canvas returns `null`, and vice versa. To let arbitrary 2D ⇄ WebGL renderer swaps work mid-session, `highway.setRenderer()` reads the next renderer's `contextType` before calling its `init()` and, if it differs from the type currently bound, replaces the underlying `<canvas>` element with a fresh one via `oldCanvas.cloneNode(false)` followed by `oldCanvas.replaceWith(newCanvas)`. The factory then calls the renderer's `init(newCanvas, bundle)` with the fresh element so its `getContext()` succeeds. Practical implications:
|
- **Canvas context-type swapping.** Browsers lock a `<canvas>` to the first context type successfully acquired for its lifetime: once `getContext('2d')` succeeds, `getContext('webgl2')` on that same canvas returns `null`, and vice versa. To let arbitrary 2D ⇄ WebGL renderer swaps work mid-session, `highway.setRenderer()` reads the next renderer's `contextType` before calling its `init()` and, if it differs from the type currently bound, replaces the underlying `<canvas>` element with a fresh one via `oldCanvas.cloneNode(false)` followed by `oldCanvas.replaceWith(newCanvas)`. The factory then calls the renderer's `init(newCanvas, bundle)` with the fresh element so its `getContext()` succeeds. Practical implications:
|
||||||
- **What survives the swap.** `cloneNode(false)` preserves *every HTML attribute* on the element — `id`, `class`, inline `style`, all `data-*` and `aria-*` attributes, `role`, `tabindex`, the attribute form of `width`/`height`, and anything else a plugin attached. DOM position is preserved by `replaceWith()`, so siblings, parents, and surrounding layout are unaffected.
|
- **What survives the swap.** `cloneNode(false)` preserves *every HTML attribute* on the element — `id`, `class`, inline `style`, all `data-*` and `aria-*` attributes, `role`, `tabindex`, the attribute form of `width`/`height`, and anything else a plugin attached. DOM position is preserved by `replaceWith()`, so siblings, parents, and surrounding layout are unaffected.
|
||||||
- **What does NOT survive.** Event listeners attached via `addEventListener` are NOT cloned, and expando properties set imperatively on the JavaScript object (such as the bound rendering context, or any `canvas._myPlugin = …`-style data a plugin attached) are not carried over either. The bound rendering context being left behind on the detached element is exactly what allows the new canvas to start fresh and accept a different `getContext()` call. Note: `canvas.width`/`canvas.height` *are* reflected HTML attributes, so those values do survive the clone; `api.resize()` re-applies the backing-store dimensions on the new element after the swap regardless.
|
- **What does NOT survive.** Event listeners attached via `addEventListener` are NOT cloned, and expando properties set imperatively on the JavaScript object (such as the bound rendering context, or any `canvas._myPlugin = …`-style data a plugin attached) are not carried over either. The bound rendering context being left behind on the detached element is exactly what allows the new canvas to start fresh and accept a different `getContext()` call. Note: `canvas.width`/`canvas.height` *are* reflected HTML attributes, so those values do survive the clone; `api.resize()` re-applies the backing-store dimensions on the new element after the swap regardless.
|
||||||
- Renderers must **declare `contextType`** on the returned instance (`'2d'` or `'webgl2'`; absent → `'2d'`). Factories may also expose it as a static (`window.slopsmithViz_<id>.contextType = 'webgl2'`) so core can read it before constructing the renderer — used today by Auto-mode evaluation.
|
- Renderers must **declare `contextType`** on the returned instance (`'2d'` or `'webgl2'`; absent → `'2d'`). Factories may also expose it as a static (`window.feedBackViz_<id>.contextType = 'webgl2'`) so core can read it before constructing the renderer — used today by Auto-mode evaluation.
|
||||||
- Plugins that hold a stale reference to the highway canvas across renderer swaps — including any code that registered listeners directly on the canvas element rather than on `window`/`document` — should listen for the `highway:canvas-replaced` event on `window.slopsmith` and re-acquire / re-register. `window.slopsmith.emit` dispatches a `CustomEvent`, so the payload `{ oldCanvas, newCanvas, contextType }` lives on `event.detail`, not on the event object itself:
|
- Plugins that hold a stale reference to the highway canvas across renderer swaps — including any code that registered listeners directly on the canvas element rather than on `window`/`document` — should listen for the `highway:canvas-replaced` event on `window.feedBack` and re-acquire / re-register. `window.feedBack.emit` dispatches a `CustomEvent`, so the payload `{ oldCanvas, newCanvas, contextType }` lives on `event.detail`, not on the event object itself:
|
||||||
```js
|
```js
|
||||||
window.slopsmith.on('highway:canvas-replaced', (event) => {
|
window.feedBack.on('highway:canvas-replaced', (event) => {
|
||||||
const { oldCanvas, newCanvas, contextType } = event.detail;
|
const { oldCanvas, newCanvas, contextType } = event.detail;
|
||||||
// re-acquire / re-register against newCanvas
|
// re-acquire / re-register against newCanvas
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
Plugins that re-query `document.getElementById('highway')` lazily inside their own event handlers don't need this listener — they pick up the new element automatically (it keeps `id="highway"`).
|
Plugins that re-query `document.getElementById('highway')` lazily inside their own event handlers don't need this listener — they pick up the new element automatically (it keeps `id="highway"`).
|
||||||
- **`highway:visibility`** — fired on `window.slopsmith` whenever the highway canvas transitions between displayed and hidden. Detection is DOM-based via `canvas.offsetParent === null` (catches `display:none` on the canvas or any ancestor — e.g. splitscreen's `#highway` hide) or whatever a host explicitly sets via `highway.setVisible(bool)`. While `visible === false`, core skips the rAF `renderer.draw(bundle)` call AND the default 2D draw, so renderers don't have to no-op themselves. The event is emitted only on transitions (including the first one after `init()`), not every frame. Payload `{ visible, canvas }` lives on `event.detail`:
|
- **`highway:visibility`** — fired on `window.feedBack` whenever the highway canvas transitions between displayed and hidden. Detection is DOM-based via `canvas.offsetParent === null` (catches `display:none` on the canvas or any ancestor — e.g. splitscreen's `#highway` hide) or whatever a host explicitly sets via `highway.setVisible(bool)`. While `visible === false`, core skips the rAF `renderer.draw(bundle)` call AND the default 2D draw, so renderers don't have to no-op themselves. The event is emitted only on transitions (including the first one after `init()`), not every frame. Payload `{ visible, canvas }` lives on `event.detail`:
|
||||||
```js
|
```js
|
||||||
window.slopsmith.on('highway:visibility', (event) => {
|
window.feedBack.on('highway:visibility', (event) => {
|
||||||
const { visible, canvas } = event.detail;
|
const { visible, canvas } = event.detail;
|
||||||
// Toggle any sibling DOM your renderer mounts. The 3D Highway
|
// Toggle any sibling DOM your renderer mounts. The 3D Highway
|
||||||
// renderer hides its `.h3d-wrap` overlay here so `display:none`
|
// renderer hides its `.h3d-wrap` overlay here so `display:none`
|
||||||
// on `#highway` actually hides the visible output.
|
// on `#highway` actually hides the visible output.
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
Renderers that only paint to the slopsmith canvas don't need this listener — the rAF skip is enough. Renderers that mount sibling DOM (separate WebGL contexts, overlays, etc.) do.
|
Renderers that only paint to the feedBack canvas don't need this listener — the rAF skip is enough. Renderers that mount sibling DOM (separate WebGL contexts, overlays, etc.) do.
|
||||||
- **`highway.setVisible(bool | null)`** — forces the visibility state regardless of `offsetParent`. Pass `null` to clear the override and resume DOM-based detection. Useful when the host hides the highway via `visibility:hidden`, `opacity:0`, transforms, or clipping rather than `display:none`. The override re-emits any resulting transition immediately rather than waiting for the next rAF tick.
|
- **`highway.setVisible(bool | null)`** — forces the visibility state regardless of `offsetParent`. Pass `null` to clear the override and resume DOM-based detection. Useful when the host hides the highway via `visibility:hidden`, `opacity:0`, transforms, or clipping rather than `display:none`. The override re-emits any resulting transition immediately rather than waiting for the next rAF tick.
|
||||||
- Default-renderer ctx is closure-cached. The replace path nulls the closure ctx so stale draw paths short-circuit; the next default-renderer `init()` re-acquires the 2D context from the new canvas cleanly.
|
- Default-renderer ctx is closure-cached. The replace path nulls the closure ctx so stale draw paths short-circuit; the next default-renderer `init()` re-acquires the 2D context from the new canvas cleanly.
|
||||||
- `draw(bundle)` receives difficulty-filtered arrays — never read from `_filteredNotes` or other internals.
|
- `draw(bundle)` receives difficulty-filtered arrays — never read from `_filteredNotes` or other internals.
|
||||||
@@ -314,8 +314,8 @@ The viz picker prepends an "Auto (match arrangement)" entry that is the default
|
|||||||
Declare the predicate as a static on the factory (not the instance) so core can evaluate it without constructing a throwaway renderer:
|
Declare the predicate as a static on the factory (not the instance) so core can evaluate it without constructing a throwaway renderer:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
window.slopsmithViz_piano = function () { /* ... */ };
|
window.feedBackViz_piano = function () { /* ... */ };
|
||||||
window.slopsmithViz_piano.matchesArrangement = function (songInfo) {
|
window.feedBackViz_piano.matchesArrangement = function (songInfo) {
|
||||||
return /keys|piano|synth/i.test((songInfo && songInfo.arrangement) || '');
|
return /keys|piano|synth/i.test((songInfo && songInfo.arrangement) || '');
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
@@ -328,7 +328,7 @@ window.slopsmithViz_piano.matchesArrangement = function (songInfo) {
|
|||||||
|
|
||||||
**WebGL viz in Auto mode.** Auto evaluation runs on every `song:ready` regardless of which renderer is active. Auto-installing a WebGL renderer when the canvas is currently 2D — or reverting from a WebGL Auto pick to the default 2D — works without a reload because `setRenderer` swaps the canvas element when `contextType` differs (see "Canvas context-type swapping" above). WebGL viz can therefore safely declare `matchesArrangement` and rely on Auto. For 3D Highway specifically, `_canRun3D()` in app.js still gates Auto from picking it on machines without WebGL2 — that fallback is independent of canvas swapping.
|
**WebGL viz in Auto mode.** Auto evaluation runs on every `song:ready` regardless of which renderer is active. Auto-installing a WebGL renderer when the canvas is currently 2D — or reverting from a WebGL Auto pick to the default 2D — works without a reload because `setRenderer` swaps the canvas element when `contextType` differs (see "Canvas context-type swapping" above). WebGL viz can therefore safely declare `matchesArrangement` and rely on Auto. For 3D Highway specifically, `_canRun3D()` in app.js still gates Auto from picking it on machines without WebGL2 — that fallback is independent of canvas swapping.
|
||||||
|
|
||||||
**Per-instance settings for host plugins (slopsmith#849).** A viz provider may declare per-instance controls a consuming host (e.g. splitscreen's per-panel popover) renders generically, by adding a `settings` array to its `capabilities.visualization` manifest block: `[{ key, label, type: "toggle" | "range" | "select", default, min?, max?, step?, options? }]`. This is the capability-native, declarative replacement for the ad-hoc `factory.panelControls` static. The validated list is surfaced through the visualization host's `list-providers` snapshot, so a host reads it without knowing the plugin. **A provider that declares `settings` MUST implement `applySetting(key, value)` on its renderer instance** — the host calls it on the specific per-panel instance, which is inherently per-panel (no canvas→panel resolution, no shared global localStorage keys). `getSetting(key)` is optional (the host falls back to the declared `default`); the host owns persistence. `factory.panelControls` remains read as a legacy fallback for hosts that still consume it, but new viz should declare `settings` + `applySetting`.
|
**Per-instance settings for host plugins (feedBack#849).** A viz provider may declare per-instance controls a consuming host (e.g. splitscreen's per-panel popover) renders generically, by adding a `settings` array to its `capabilities.visualization` manifest block: `[{ key, label, type: "toggle" | "range" | "select", default, min?, max?, step?, options? }]`. This is the capability-native, declarative replacement for the ad-hoc `factory.panelControls` static. The validated list is surfaced through the visualization host's `list-providers` snapshot, so a host reads it without knowing the plugin. **A provider that declares `settings` MUST implement `applySetting(key, value)` on its renderer instance** — the host calls it on the specific per-panel instance, which is inherently per-panel (no canvas→panel resolution, no shared global localStorage keys). `getSetting(key)` is optional (the host falls back to the declared `default`); the host owns persistence. `factory.panelControls` remains read as a legacy fallback for hosts that still consume it, but new viz should declare `settings` + `applySetting`.
|
||||||
|
|
||||||
#### 2. Overlay contract — for add-on layers
|
#### 2. Overlay contract — for add-on layers
|
||||||
|
|
||||||
@@ -354,13 +354,13 @@ Overlays do NOT appear in the viz picker and do NOT declare `"type": "visualizat
|
|||||||
- **If you position with `highway.project` / `highway.fretX` (the 2D-highway geometry), gate on `highway.isDefaultRenderer()`** — those helpers describe the *built-in 2D* highway's depth curve and fret zoom. When a custom renderer (3D highway, piano, …) is active your draw hook still fires (on that renderer's 2D overlay layer), but those coordinates won't match its scene — markers land in arbitrary places. Skip rendering when `isDefaultRenderer()` is false; the custom renderer owns that feedback. Renderer-agnostic overlays (fretboard diagram, chord-label HUD — they use `getNotes()`/`getChordTemplates()` + their own layout) don't need this guard.
|
- **If you position with `highway.project` / `highway.fretX` (the 2D-highway geometry), gate on `highway.isDefaultRenderer()`** — those helpers describe the *built-in 2D* highway's depth curve and fret zoom. When a custom renderer (3D highway, piano, …) is active your draw hook still fires (on that renderer's 2D overlay layer), but those coordinates won't match its scene — markers land in arbitrary places. Skip rendering when `isDefaultRenderer()` is false; the custom renderer owns that feedback. Renderer-agnostic overlays (fretboard diagram, chord-label HUD — they use `getNotes()`/`getChordTemplates()` + their own layout) don't need this guard.
|
||||||
- **Clean up on toggle-off** — cancel rAF and remove/hide the overlay canvas so inactive overlays aren't wasting frames.
|
- **Clean up on toggle-off** — cancel rAF and remove/hide the overlay canvas so inactive overlays aren't wasting frames.
|
||||||
|
|
||||||
Reference: [fretboard plugin](https://github.com/got-feedback/feedback-plugin-fretboard) — canonical overlay implementation (navbar toggle, own canvas, 80ms active-note window).
|
Reference: [fretboard plugin](https://github.com/got-feedback/feedBack-plugin-fretboard) — canonical overlay implementation (navbar toggle, own canvas, 80ms active-note window).
|
||||||
|
|
||||||
**Why two?** setRenderer plugs into an existing highway — main-player or splitscreen-panel — reusing its WebSocket and data parsing, so the common "I want a different look for the same data" case is zero boilerplate AND multi-instance for free. Overlays compose with whatever renderer is active — they decorate rather than replace, so multiple can stack (fretboard + chord labels + practice feedback) without fighting over the canvas.
|
**Why two?** setRenderer plugs into an existing highway — main-player or splitscreen-panel — reusing its WebSocket and data parsing, so the common "I want a different look for the same data" case is zero boilerplate AND multi-instance for free. Overlays compose with whatever renderer is active — they decorate rather than replace, so multiple can stack (fretboard + chord labels + practice feedback) without fighting over the canvas.
|
||||||
|
|
||||||
A previous standalone-pane contract (`window.createMyVisualization({ container })` with its own WebSocket per pane) was used by splitscreen pre-Wave-C. It's been retired now that splitscreen calls `setRenderer` on per-panel `createHighway()` instances; if you find references in older plugin docs or external integration guides, those describe the legacy path.
|
A previous standalone-pane contract (`window.createMyVisualization({ container })` with its own WebSocket per pane) was used by splitscreen pre-Wave-C. It's been retired now that splitscreen calls `setRenderer` on per-panel `createHighway()` instances; if you find references in older plugin docs or external integration guides, those describe the legacy path.
|
||||||
|
|
||||||
#### 3. Note-state provider — for scorers that want renderers to "light up" notes (slopsmith#254)
|
#### 3. Note-state provider — for scorers that want renderers to "light up" notes (feedBack#254)
|
||||||
|
|
||||||
A scoring plugin (note_detect) can publish a per-note judgment so whichever renderer is active draws the **gem itself** lit on a correct hit, and keeps a sustain trail glowing while it's still being played correctly — instead of a separate overlay ring floating near the note.
|
A scoring plugin (note_detect) can publish a per-note judgment so whichever renderer is active draws the **gem itself** lit on a correct hit, and keeps a sustain trail glowing while it's still being played correctly — instead of a separate overlay ring floating near the note.
|
||||||
|
|
||||||
@@ -386,13 +386,13 @@ highway.setNoteStateProvider((note, chartTime) => {
|
|||||||
- The built-in 2D highway consults it in `drawNote` / `drawSustains` / the chord-frame path: 'hit'/'active' → bright string colour + additive halo + a contained "sizzle" (crackling sparks, throbbing core, a shockwave ring on a fresh strike) on the gem and a bright (vs dim) sustain trail; 'miss' → faint red wash. The bundled **3D highway** reads the same data via `bundle.getNoteState` (bright string-tinted outline + bright body + glowing sustain + a contained sparkle hugging the note rect on hit/active; red outline + suppressed body on miss). Custom renderers that want it call `bundle.getNoteState(note, chartTime)` — it null-guards and returns the normalized `{ state, alpha, color }` (or null).
|
- The built-in 2D highway consults it in `drawNote` / `drawSustains` / the chord-frame path: 'hit'/'active' → bright string colour + additive halo + a contained "sizzle" (crackling sparks, throbbing core, a shockwave ring on a fresh strike) on the gem and a bright (vs dim) sustain trail; 'miss' → faint red wash. The bundled **3D highway** reads the same data via `bundle.getNoteState` (bright string-tinted outline + bright body + glowing sustain + a contained sparkle hugging the note rect on hit/active; red outline + suppressed body on miss). Custom renderers that want it call `bundle.getNoteState(note, chartTime)` — it null-guards and returns the normalized `{ state, alpha, color }` (or null).
|
||||||
- This is orthogonal to the overlay contract: note_detect remains an overlay (HUD, diagnostic miss markers, the "currently detected" indicator) *and* a scorer that feeds this provider. A renderer that ignores `getNoteState` simply doesn't light gems — nothing breaks.
|
- This is orthogonal to the overlay contract: note_detect remains an overlay (HUD, diagnostic miss markers, the "currently detected" indicator) *and* a scorer that feeds this provider. A renderer that ignores `getNoteState` simply doesn't light gems — nothing breaks.
|
||||||
|
|
||||||
### Audio mixer fader registration (slopsmith#87)
|
### Audio mixer fader registration (feedBack#87)
|
||||||
|
|
||||||
Plugins that produce audio outside the song's `<audio>` element (NAM amp output, synth voices, etc.) can register a labeled fader so users can balance them against the song from one mixer popover in the player controls.
|
Plugins that produce audio outside the song's `<audio>` element (NAM amp output, synth voices, etc.) can register a labeled fader so users can balance them against the song from one mixer popover in the player controls.
|
||||||
|
|
||||||
```js
|
```js
|
||||||
function _registerFader() {
|
function _registerFader() {
|
||||||
const api = window.slopsmith && window.slopsmith.audio;
|
const api = window.feedBack && window.feedBack.audio;
|
||||||
if (!api) return;
|
if (!api) return;
|
||||||
api.registerFader({
|
api.registerFader({
|
||||||
id: 'my_plugin', // unique key
|
id: 'my_plugin', // unique key
|
||||||
@@ -405,10 +405,10 @@ function _registerFader() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (window.slopsmith && window.slopsmith.audio) {
|
if (window.feedBack && window.feedBack.audio) {
|
||||||
_registerFader();
|
_registerFader();
|
||||||
} else {
|
} else {
|
||||||
window.addEventListener('slopsmith:audio:ready', _registerFader, { once: true });
|
window.addEventListener('feedBack:audio:ready', _registerFader, { once: true });
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -416,7 +416,7 @@ The plugin owns persistence — the registry calls `getValue()` when the popover
|
|||||||
|
|
||||||
### Backend plugin logging
|
### Backend plugin logging
|
||||||
|
|
||||||
Use `context["log"]` for all backend plugin output. It is a stdlib `logging.Logger` namespaced to `slopsmith.plugin.<id>`, pre-configured with the app-wide level, format (including JSON mode), and correlation IDs. Never use `print()` — it bypasses correlation context and log rotation.
|
Use `context["log"]` for all backend plugin output. It is a stdlib `logging.Logger` namespaced to `feedBack.plugin.<id>`, pre-configured with the app-wide level, format (including JSON mode), and correlation IDs. Never use `print()` — it bypasses correlation context and log rotation.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def setup(app, context):
|
def setup(app, context):
|
||||||
@@ -437,19 +437,19 @@ if __name__ == "__main__":
|
|||||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
||||||
```
|
```
|
||||||
|
|
||||||
### Diagnostics contribution from frontend (slopsmith#166)
|
### Diagnostics contribution from frontend (feedBack#166)
|
||||||
|
|
||||||
Plugins that hold useful debug state in the browser (active model name, last user input, internal counters) can push it into the diagnostics bundle by calling `window.slopsmith.diagnostics.contribute(plugin_id, payload)` at any time. The contribution API is idempotent — repeated calls overwrite the previous value. Whatever was last contributed before the user hits Export Diagnostics is what lands in `plugins/<plugin_id>/client.json`.
|
Plugins that hold useful debug state in the browser (active model name, last user input, internal counters) can push it into the diagnostics bundle by calling `window.feedBack.diagnostics.contribute(plugin_id, payload)` at any time. The contribution API is idempotent — repeated calls overwrite the previous value. Whatever was last contributed before the user hits Export Diagnostics is what lands in `plugins/<plugin_id>/client.json`.
|
||||||
|
|
||||||
```js
|
```js
|
||||||
window.slopsmith.diagnostics.contribute('my_plugin', {
|
window.feedBack.diagnostics.contribute('my_plugin', {
|
||||||
schema: 'my_plugin.client_diag.v1',
|
schema: 'my_plugin.client_diag.v1',
|
||||||
active_preset: getActivePreset(),
|
active_preset: getActivePreset(),
|
||||||
last_error: _lastError,
|
last_error: _lastError,
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
Loaded from `static/diagnostics.js` ASAP in `<head>` so the console-wrap is in place before any other script runs. Available on the `window.slopsmith.diagnostics` namespace alongside `snapshotConsole()`, `snapshotHardware()`, `snapshotUa()`, `snapshotLocalStorage()`, `snapshotContributions()`. Keep your payload small (< 100 KB) and don't include secrets — bundles are shared with maintainers.
|
Loaded from `static/diagnostics.js` ASAP in `<head>` so the console-wrap is in place before any other script runs. Available on the `window.feedBack.diagnostics` namespace alongside `snapshotConsole()`, `snapshotHardware()`, `snapshotUa()`, `snapshotLocalStorage()`, `snapshotContributions()`. Keep your payload small (< 100 KB) and don't include secrets — bundles are shared with maintainers.
|
||||||
|
|
||||||
### Keyboard Shortcuts
|
### Keyboard Shortcuts
|
||||||
|
|
||||||
@@ -496,18 +496,18 @@ window.registerShortcut({
|
|||||||
- Use `localStorage` for user-facing settings, prefixed with your plugin id
|
- Use `localStorage` for user-facing settings, prefixed with your plugin id
|
||||||
- If hooking `window.playSong`, always call the original and `await` it
|
- If hooking `window.playSong`, always call the original and `await` it
|
||||||
- If hooking `window.showScreen`, clean up your state when leaving the player screen
|
- If hooking `window.showScreen`, clean up your state when leaving the player screen
|
||||||
- Use `window.slopsmith.emit()` / `window.slopsmith.on()` for inter-plugin communication
|
- Use `window.feedBack.emit()` / `window.feedBack.on()` for inter-plugin communication
|
||||||
- Use `window.registerShortcut()` to add keyboard shortcuts. Clean up with `window.unregisterShortcut(key, scope)` — pass the same scope you registered with, since the default is `'global'` and won't match `player`/`library`/`settings`/`plugin-*` bindings. For panel-scoped shortcuts, prefer `panel.clearShortcuts()`.
|
- Use `window.registerShortcut()` to add keyboard shortcuts. Clean up with `window.unregisterShortcut(key, scope)` — pass the same scope you registered with, since the default is `'global'` and won't match `player`/`library`/`settings`/`plugin-*` bindings. For panel-scoped shortcuts, prefer `panel.clearShortcuts()`.
|
||||||
|
|
||||||
## Song Formats
|
## Song Formats
|
||||||
|
|
||||||
Slopsmith supports two song formats:
|
FeedBack supports two song formats:
|
||||||
|
|
||||||
### Loose folder (XML charts)
|
### Loose folder (XML charts)
|
||||||
A directory containing arrangement XML plus an audio file (and optional `manifest.json` + album art). Discovered, indexed, and played directly — see `lib/loosefolder.py`. Metadata follows a `manifest.json` → XML tags → folder-name priority chain. Songs are tagged `format: "loose"` in the library.
|
A directory containing arrangement XML plus an audio file (and optional `manifest.json` + album art). Discovered, indexed, and played directly — see `lib/loosefolder.py`. Metadata follows a `manifest.json` → XML tags → folder-name priority chain. Songs are tagged `format: "loose"` in the library.
|
||||||
|
|
||||||
### Sloppak (open format)
|
### Sloppak (open format)
|
||||||
An open, hand-editable song package designed for Slopsmith. Exists in two interchangeable forms:
|
An open, hand-editable song package designed for FeedBack. Exists in two interchangeable forms:
|
||||||
- **Zip archive** (`.sloppak` file) — distribution form
|
- **Zip archive** (`.sloppak` file) — distribution form
|
||||||
- **Directory** (`.sloppak/` folder) — authoring form
|
- **Directory** (`.sloppak/` folder) — authoring form
|
||||||
|
|
||||||
@@ -533,8 +533,8 @@ 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.
|
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:** the authoritative format spec now lives in its own repo —
|
**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)
|
[got-feedback/feedpak-spec](https://github.com/got-feedback/feedpak-spec)
|
||||||
([`spec/feedpak-v1.md`](https://github.com/got-feedback/feedback-feedpak-spec/blob/main/spec/feedpak-v1.md)):
|
([`spec/feedpak-v1.md`](https://github.com/got-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
|
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
|
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
|
**sloppak** name internally — same on-disk format. [docs/sloppak-spec.md](docs/sloppak-spec.md) is
|
||||||
@@ -548,10 +548,11 @@ a local pointer + code map.
|
|||||||
## Frontend Conventions
|
## Frontend Conventions
|
||||||
|
|
||||||
- **No frameworks** — vanilla JS, fetch API, DOM manipulation
|
- **No frameworks** — vanilla JS, fetch API, DOM manipulation
|
||||||
- **Globals** — `highway`, `audio`, `playSong()`, `showScreen()`, `createHighway()`, `window.slopsmith`
|
- **Globals** — `highway`, `audio`, `playSong()`, `showScreen()`, `createHighway()`, `window.feedBack`
|
||||||
- **Storage** — `localStorage` for all user preferences
|
- **Storage** — `localStorage` for all user preferences
|
||||||
- **Styling** — Tailwind CSS utility classes, dark theme (`bg-dark-600`, `text-gray-300`, accent `#4080e0`, gold `#e8c040`). Tailwind is served as a **prebuilt** stylesheet (`static/tailwind.min.css`, regenerated by `bash scripts/build-tailwind.sh`), **never** the runtime Play CDN — the CDN's on-the-fly JIT rescanned the DOM on the main thread and dropped ~26% of frames with the 3D highway (slopsmith-desktop#110). The committed CSS only contains classes the build scanner saw, so CI (`tailwind-fresh`) rebuilds and diffs it; run the build script and commit when you add new classes. A plugin that uses classes not guaranteed in core (notably arbitrary values like `w-[37px]`) MUST ship its own compiled stylesheet via the `styles` manifest key, built with `corePlugins.preflight = false` (utilities only — core ships the one base reset). Plugins MUST NOT load the Tailwind Play CDN or any runtime CSS JIT. See constitution Principle II.
|
- **Styling** — Tailwind CSS utility classes, dark theme (`bg-dark-600`, `text-gray-300`, accent `#4080e0`, gold `#e8c040`). Tailwind is served as a **prebuilt** stylesheet (`static/tailwind.min.css`, regenerated by `bash scripts/build-tailwind.sh`), **never** the runtime Play CDN — the CDN's on-the-fly JIT rescanned the DOM on the main thread and dropped ~26% of frames with the 3D highway (feedBack-desktop#110). The committed CSS only contains classes the build scanner saw, so CI (`tailwind-fresh`) rebuilds and diffs it; run the build script and commit when you add new classes. A plugin that uses classes not guaranteed in core (notably arbitrary values like `w-[37px]`) MUST ship its own compiled stylesheet via the `styles` manifest key, built with `corePlugins.preflight = false` (utilities only — core ships the one base reset). Plugins MUST NOT load the Tailwind Play CDN or any runtime CSS JIT. See constitution Principle II.
|
||||||
- **Naming** — camelCase for JS functions, kebab-case for CSS classes, snake_case for plugin IDs
|
- **Naming** — camelCase for JS functions, kebab-case for CSS classes, snake_case for plugin IDs
|
||||||
|
- **Text selection (v3)** — the v3 UI defaults to `user-select: none` on `html` (in `static/v3/v3.css`) so accidental drag/double-click selection of chrome never looks broken. Form fields are always re-enabled, and a **plugin's mounted screen subtree (`.screen[id^="plugin-"]`) stays selectable by default**, so a plugin's copy-worthy text (lyrics, chord names, results, diagnostics) is unaffected — *unless your plugin renders copyable content OUTSIDE its `plugin-<id>` screen* (e.g. injected into the player chrome / a HUD overlay), which inherits the non-select default. Opt such content back in with the core-served **`.fb-selectable`** class (it sets `user-select: text` on the element + descendants; works for runtime-installed plugins since it's hand-authored in core CSS, not a scanned Tailwind utility). Never use a `* { user-select: none }` rule (breaks input carets/IME), and never use `user-select: none` to "lock" text — keep errors, IDs, paths, versions, and metadata selectable. (v2 is unchanged.)
|
||||||
- **Player layout** — `#player` is `display:flex; flex-direction:column; position:fixed; inset:0`. `#highway` is `flex:1`. `#player-controls` sits at the bottom. Hiding the highway collapses the layout — use `margin-top: auto` on controls if you need to hide it.
|
- **Player layout** — `#player` is `display:flex; flex-direction:column; position:fixed; inset:0`. `#highway` is `flex:1`. `#player-controls` sits at the bottom. Hiding the highway collapses the layout — use `margin-top: auto` on controls if you need to hide it.
|
||||||
|
|
||||||
## Backend Conventions
|
## Backend Conventions
|
||||||
@@ -583,8 +584,8 @@ Detection quality is hard to judge by eye — a player UI that "feels worse" aft
|
|||||||
|
|
||||||
Quick orientation:
|
Quick orientation:
|
||||||
- **Reference recording** lives in the gear popover on the player (gated behind Settings → Note Detection → "Detection tuning (advanced)"). Arm before pressing Play; auto-saves a WAV to `static/note_detect_recordings/` on song-end. The directory is bind-mounted, so the host-side harness can read it without a copy step.
|
- **Reference recording** lives in the gear popover on the player (gated behind Settings → Note Detection → "Detection tuning (advanced)"). Arm before pressing Play; auto-saves a WAV to `static/note_detect_recordings/` on song-end. The directory is bind-mounted, so the host-side harness can read it without a copy step.
|
||||||
- **Benchmark sloppak** ships in-tree at [docs/benchmarks/note_detect_v1/note_detect_benchmark_v1.sloppak](docs/benchmarks/note_detect_v1/note_detect_benchmark_v1.sloppak) — 8 sections each isolating a different failure mode (low-freq mono, sustained holds, hammer/pull, power chords, dense open chords, bends). Drop it directly into your sloppak DLC folder to install (don't rename — slopsmith keys off the `.sloppak` suffix even though the file is a zip under the hood). The unzipped form lands at `static/sloppak_cache/note_detect_benchmark_v1.sloppak/` after first play. Builder: [docs/benchmarks/note_detect_v1/build_benchmark.py](docs/benchmarks/note_detect_v1/build_benchmark.py).
|
- **Benchmark sloppak** ships in-tree at [docs/benchmarks/note_detect_v1/note_detect_benchmark_v1.sloppak](docs/benchmarks/note_detect_v1/note_detect_benchmark_v1.sloppak) — 8 sections each isolating a different failure mode (low-freq mono, sustained holds, hammer/pull, power chords, dense open chords, bends). Drop it directly into your sloppak DLC folder to install (don't rename — feedBack keys off the `.sloppak` suffix even though the file is a zip under the hood). The unzipped form lands at `static/sloppak_cache/note_detect_benchmark_v1.sloppak/` after first play. Builder: [docs/benchmarks/note_detect_v1/build_benchmark.py](docs/benchmarks/note_detect_v1/build_benchmark.py).
|
||||||
- **Headless harness** at [`tools/harness.js`](https://github.com/got-feedback/feedback-plugin-notedetect/blob/main/tools/harness.js) in the note_detect plugin's own repo (cloned into `plugins/note_detect/` locally) runs the same `processFrame` / `matchNotes` / `checkMisses` code path off Node, in seconds per run. Same `note_detect.diagnostic.v1` schema as the in-app Download Diagnostic button.
|
- **Headless harness** at [`tools/harness.js`](https://github.com/got-feedback/feedBack-plugin-notedetect/blob/main/tools/harness.js) in the note_detect plugin's own repo (cloned into `plugins/note_detect/` locally) runs the same `processFrame` / `matchNotes` / `checkMisses` code path off Node, in seconds per run. Same `note_detect.diagnostic.v1` schema as the in-app Download Diagnostic button.
|
||||||
- **A/V auto-calibrate** (Settings → Note Detection) reads `timing_error_ms_hits.median` and proposes the av-offset that drives it to zero. Iterative: usually converges in 2–3 Apply rounds.
|
- **A/V auto-calibrate** (Settings → Note Detection) reads `timing_error_ms_hits.median` and proposes the av-offset that drives it to zero. Iterative: usually converges in 2–3 Apply rounds.
|
||||||
|
|
||||||
**Always record at 1.0× playback speed** — half-speed takes produce all-miss garbage because chart times are absolute. **Always use `timing_error_ms_hits` (not all-matched) as a calibration signal** — the all-matched median pins near a constant when the offset is wrong, because the matcher silently snaps to neighbouring chart notes.
|
**Always record at 1.0× playback speed** — half-speed takes produce all-miss garbage because chart times are absolute. **Always use `timing_error_ms_hits` (not all-matched) as a calibration signal** — the all-matched median pins near a constant when the offset is wrong, because the matcher silently snaps to neighbouring chart notes.
|
||||||
@@ -594,14 +595,14 @@ Full developer reference (workflow recipes, harness flag table, diagnostic schem
|
|||||||
## Versioning
|
## Versioning
|
||||||
|
|
||||||
- **`VERSION`** (repo root) — single source of truth; plain semver string (e.g. `0.2.4`). Bind-mounted into the container and copied by the Dockerfile so it's always available at `/app/VERSION`.
|
- **`VERSION`** (repo root) — single source of truth; plain semver string (e.g. `0.2.4`). Bind-mounted into the container and copied by the Dockerfile so it's always available at `/app/VERSION`.
|
||||||
- **`GET /api/version`** — returns `{"version": "<contents of VERSION>", "source_url": "...", "license_url": "..."}`. The version drives the navbar badge; `source_url` / `license_url` populate the Settings → About links. `source_url` is configurable via the `APP_SOURCE_URL` env var (default `https://github.com/got-feedback/feedback`); `license_url` falls back to `source_url + "/blob/main/LICENSE"` (GitHub-style, default branch `main`) and is overridable via the `APP_LICENSE_URL` env var — set it explicitly when the source is hosted on a non-GitHub forge (GitLab/Gitea/self-hosted) or under a non-`main` default branch. Both env values must be `http(s)`; non-http(s) values are rejected and fall back to the safe default to prevent `javascript:`/`data:` hrefs.
|
- **`GET /api/version`** — returns `{"version": "<contents of VERSION>", "source_url": "...", "license_url": "..."}`. The version drives the navbar badge; `source_url` / `license_url` populate the Settings → About links. `source_url` is configurable via the `APP_SOURCE_URL` env var (default `https://github.com/got-feedback/feedBack`); `license_url` falls back to `source_url + "/blob/main/LICENSE"` (GitHub-style, default branch `main`) and is overridable via the `APP_LICENSE_URL` env var — set it explicitly when the source is hosted on a non-GitHub forge (GitLab/Gitea/self-hosted) or under a non-`main` default branch. Both env values must be `http(s)`; non-http(s) values are rejected and fall back to the safe default to prevent `javascript:`/`data:` hrefs.
|
||||||
- **Auto-sync** — `.github/workflows/sync-version.yml` rewrites `VERSION` via a `repository_dispatch` (`desktop-released`) fired from `slopsmith-desktop`'s release job. As an explicit automation-only exception to the "Never push directly to main" rule in Git Workflow below, the sync job commits straight to `main` as `github-actions[bot]` (version bumps are mechanical; the PR round-trip adds no signal). Human contributors must still go through feature branches + PRs. No manual VERSION edits needed. Use the workflow's `workflow_dispatch` trigger with `version: X.Y.Z` for manual runs (recovery / out-of-band bumps).
|
- **Auto-sync** — `.github/workflows/sync-version.yml` rewrites `VERSION` via a `repository_dispatch` (`desktop-released`) fired from `feedBack-desktop`'s release job. As an explicit automation-only exception to the "Never push directly to main" rule in Git Workflow below, the sync job commits straight to `main` as `github-actions[bot]` (version bumps are mechanical; the PR round-trip adds no signal). Human contributors must still go through feature branches + PRs. No manual VERSION edits needed. Use the workflow's `workflow_dispatch` trigger with `version: X.Y.Z` for manual runs (recovery / out-of-band bumps).
|
||||||
- **`CHANGELOG.md`** — follows [Keep a Changelog](https://keepachangelog.com/) format. Update the `[Unreleased]` section with each PR; when `slopsmith-desktop` cuts a release, rename `[Unreleased]` to the new version + date (the VERSION bump itself is automated).
|
- **`CHANGELOG.md`** — follows [Keep a Changelog](https://keepachangelog.com/) format. Update the `[Unreleased]` section with each PR; when `feedBack-desktop` cuts a release, rename `[Unreleased]` to the new version + date (the VERSION bump itself is automated).
|
||||||
|
|
||||||
## Git Workflow
|
## Git Workflow
|
||||||
|
|
||||||
- **Never push directly to main** — always create a feature branch and open a PR
|
- **Never push directly to main** — always create a feature branch and open a PR
|
||||||
- **Upstream remote** — set `upstream` to the canonical Slopsmith repository; `origin` is your fork
|
- **Upstream remote** — set `upstream` to the canonical FeedBack repository; `origin` is your fork
|
||||||
- **Plugins are gitlinks** — each plugin in `plugins/` is typically its own git repo (submodule or clone). Branch switches on the main repo can clobber plugin directories. Use `git update-index --assume-unchanged` for plugin dirs if needed.
|
- **Plugins are gitlinks** — each plugin in `plugins/` is typically its own git repo (submodule or clone). Branch switches on the main repo can clobber plugin directories. Use `git update-index --assume-unchanged` for plugin dirs if needed.
|
||||||
- **Commit style** — short imperative subject line, blank line, then body explaining *why*
|
- **Commit style** — short imperative subject line, blank line, then body explaining *why*
|
||||||
|
|
||||||
@@ -621,7 +622,7 @@ The highway WebSocket at `/ws/highway/{filename}?arrangement={index}` streams th
|
|||||||
| `tone_changes` | `{ type: 'tone_changes', base, data: [{ time, name }] }` | Optional — tone change events relative to the arrangement base tone; only sent if tones were found |
|
| `tone_changes` | `{ type: 'tone_changes', base, data: [{ time, name }] }` | Optional — tone change events relative to the arrangement base tone; only sent if tones were found |
|
||||||
| `notes` | `{ type, data: [{ t, s, f, sus, ho, po, sl, bn, ... }] }` | Single notes |
|
| `notes` | `{ type, data: [{ t, s, f, sus, ho, po, sl, bn, ... }] }` | Single notes |
|
||||||
| `chords` | `{ type, data: [{ t, notes: [{ s, f, sus, ... }] }] }` | Chord events |
|
| `chords` | `{ type, data: [{ t, notes: [{ s, f, sus, ... }] }] }` | Chord events |
|
||||||
| `phrases` | `{ type, data: [{ start_time, end_time, max_difficulty, levels: [{ difficulty, notes, chords, anchors, handshapes }] }], total }` | Optional — per-phrase difficulty ladder for master-difficulty slider (slopsmith#48). Only sent when the source chart carries multi-level phrase data (phrase-aware sloppak). Sent in chunks (`data` is a batch, `total` is the full count across messages) to avoid multi-MB single frames. Absent for GP imports and legacy sloppak; consumers must treat missing message as "single fixed difficulty — slider disabled". |
|
| `phrases` | `{ type, data: [{ start_time, end_time, max_difficulty, levels: [{ difficulty, notes, chords, anchors, handshapes }] }], total }` | Optional — per-phrase difficulty ladder for master-difficulty slider (feedBack#48). Only sent when the source chart carries multi-level phrase data (phrase-aware sloppak). Sent in chunks (`data` is a batch, `total` is the full count across messages) to avoid multi-MB single frames. Absent for GP imports and legacy sloppak; consumers must treat missing message as "single fixed difficulty — slider disabled". |
|
||||||
| `ready` | `{ type: 'ready' }` | All data sent — safe to finalize and start rendering |
|
| `ready` | `{ type: 'ready' }` | All data sent — safe to finalize and start rendering |
|
||||||
|
|
||||||
Message delivery is incremental. You may receive `loading` updates and `lyrics` before note/chord payloads; `tone_changes` comes after `lyrics` when present and may be omitted entirely. Do not finalize rendering until you receive `ready`.
|
Message delivery is incremental. You may receive `loading` updates and `lyrics` before note/chord payloads; `tone_changes` comes after `lyrics` when present and may be omitted entirely. Do not finalize rendering until you receive `ready`.
|
||||||
|
|||||||
+6
-6
@@ -1,10 +1,10 @@
|
|||||||
# Contributing to Slopsmith
|
# Contributing to FeedBack
|
||||||
|
|
||||||
Thanks for wanting to contribute! This document covers the legal and workflow expectations for code, plugins, and documentation contributions.
|
Thanks for wanting to contribute! This document covers the legal and workflow expectations for code, plugins, and documentation contributions.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Slopsmith is licensed under [AGPL-3.0-only](LICENSE). Contributions you submit (PRs, patches, documentation, plugin entries in the curated list) are licensed inbound under the same terms — **inbound = outbound**. By opening a pull request, you agree that your contribution may be distributed under AGPL-3.0-only as part of Slopsmith.
|
FeedBack is licensed under [AGPL-3.0-only](LICENSE). Contributions you submit (PRs, patches, documentation, plugin entries in the curated list) are licensed inbound under the same terms — **inbound = outbound**. By opening a pull request, you agree that your contribution may be distributed under AGPL-3.0-only as part of FeedBack.
|
||||||
|
|
||||||
## Developer Certificate of Origin (DCO)
|
## Developer Certificate of Origin (DCO)
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ If you forget to sign off, amend the most recent commit with `git commit --amend
|
|||||||
|
|
||||||
## Plugin licensing
|
## Plugin licensing
|
||||||
|
|
||||||
Plugins live in their own repositories and are loaded at runtime — see the [Plugin System section in CLAUDE.md](CLAUDE.md) for the technical contract, and [Plugin Best Practices](CLAUDE.md) for the conventions every plugin should follow (v2/v3 player chrome, the visualization contracts, and the **performance rules** — no per-frame DOM queries or broad `document.body` `MutationObserver`s — that keep the 60 fps highway smooth). Plugins are not subject to AGPL by being loaded into Slopsmith (the loader runs them as separate Python modules / browser scripts), but for the **curated plugin list** to accept your plugin we ask that it be released under an AGPL-3.0-compatible license:
|
Plugins live in their own repositories and are loaded at runtime — see the [Plugin System section in CLAUDE.md](CLAUDE.md) for the technical contract, and [Plugin Best Practices](CLAUDE.md) for the conventions every plugin should follow (v2/v3 player chrome, the visualization contracts, and the **performance rules** — no per-frame DOM queries or broad `document.body` `MutationObserver`s — that keep the 60 fps highway smooth). Plugins are not subject to AGPL by being loaded into FeedBack (the loader runs them as separate Python modules / browser scripts), but for the **curated plugin list** to accept your plugin we ask that it be released under an AGPL-3.0-compatible license:
|
||||||
|
|
||||||
- AGPL-3.0-only or AGPL-3.0-or-later
|
- AGPL-3.0-only or AGPL-3.0-or-later
|
||||||
- GPL-3.0-only or GPL-3.0-or-later
|
- GPL-3.0-only or GPL-3.0-or-later
|
||||||
@@ -37,16 +37,16 @@ Plugins live in their own repositories and are loaded at runtime — see the [Pl
|
|||||||
- ISC
|
- ISC
|
||||||
- Unlicense / CC0-1.0 / 0BSD
|
- Unlicense / CC0-1.0 / 0BSD
|
||||||
|
|
||||||
Plugins under GPL-2.0-only, LGPL-2.1-only, CDDL, EPL, or proprietary terms will not be added to the curated list. You're still free to publish and self-distribute them — Slopsmith will load any plugin a user installs locally — but they won't be promoted from the main project.
|
Plugins under GPL-2.0-only, LGPL-2.1-only, CDDL, EPL, or proprietary terms will not be added to the curated list. You're still free to publish and self-distribute them — FeedBack will load any plugin a user installs locally — but they won't be promoted from the main project.
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
Standard PR workflow described in [CLAUDE.md → Git Workflow](CLAUDE.md):
|
Standard PR workflow described in [CLAUDE.md → Git Workflow](CLAUDE.md):
|
||||||
- Never push directly to `main`.
|
- Never push directly to `main`.
|
||||||
- Create a feature branch on your fork.
|
- Create a feature branch on your fork.
|
||||||
- Open a PR against `got-feedback/feedback:main`.
|
- Open a PR against `got-feedback/feedBack:main`.
|
||||||
- Keep commits scoped and well-described; short imperative subject + `Signed-off-by` trailer.
|
- Keep commits scoped and well-described; short imperative subject + `Signed-off-by` trailer.
|
||||||
|
|
||||||
## Questions
|
## Questions
|
||||||
|
|
||||||
Open an issue or start a [Discussion](https://github.com/got-feedback/feedback/discussions) if you're unsure whether a contribution fits — much better to ask early than to find out after the work is done.
|
Open an issue or start a [Discussion](https://github.com/got-feedback/feedBack/discussions) if you're unsure whether a contribution fits — much better to ask early than to find out after the work is done.
|
||||||
|
|||||||
+15
-15
@@ -47,11 +47,11 @@ RUN cmake -S /tmp/vgmstream -B /tmp/vgmstream/build \
|
|||||||
# and update FFMPEG_RELEASE + both SHA256 ARGs below.
|
# and update FFMPEG_RELEASE + both SHA256 ARGs below.
|
||||||
FROM alpine:3.20 AS ffmpeg-fetcher
|
FROM alpine:3.20 AS ffmpeg-fetcher
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
ARG FFMPEG_RELEASE=autobuild-2026-06-01-15-02
|
ARG FFMPEG_RELEASE=autobuild-2026-06-19-23-17
|
||||||
ARG FFMPEG_BUILD_AMD64=ffmpeg-n7.1.4-7-gadcf20da26-linux64-gpl-7.1.tar.xz
|
ARG FFMPEG_BUILD_AMD64=ffmpeg-n7.1.4-145-g4cbf7a4b3d-linux64-gpl-7.1.tar.xz
|
||||||
ARG FFMPEG_BUILD_ARM64=ffmpeg-n7.1.4-7-gadcf20da26-linuxarm64-gpl-7.1.tar.xz
|
ARG FFMPEG_BUILD_ARM64=ffmpeg-n7.1.4-145-g4cbf7a4b3d-linuxarm64-gpl-7.1.tar.xz
|
||||||
ARG FFMPEG_SHA256_AMD64=afde55344990650c117fbb7cb36b38d2ab6790b06beb06a9c43a9300c9ce277a
|
ARG FFMPEG_SHA256_AMD64=03c0431e0d1aa75cc343d83bda9d2d4cd8eaa37f35b7b93465e9ff6864f5d7f8
|
||||||
ARG FFMPEG_SHA256_ARM64=03c8a7d9a7cf48d017a22a7c31acfdc8e76c5cb193923f883b0338c7baf0bd28
|
ARG FFMPEG_SHA256_ARM64=74629b88342fd94eea12b7481c8b8560ca6d497744123c0a27b98f39d767fd93
|
||||||
RUN apk add --no-cache curl xz \
|
RUN apk add --no-cache curl xz \
|
||||||
&& arch="${TARGETARCH:-$(apk --print-arch)}" \
|
&& arch="${TARGETARCH:-$(apk --print-arch)}" \
|
||||||
&& case "$arch" in \
|
&& case "$arch" in \
|
||||||
@@ -70,7 +70,7 @@ RUN apk add --no-cache curl xz \
|
|||||||
# ── Stage 1d: Build the Tailwind stylesheet over the FULL plugin set ──────
|
# ── Stage 1d: Build the Tailwind stylesheet over the FULL plugin set ──────
|
||||||
# The committed static/tailwind.min.css is generated against only the in-tree
|
# The committed static/tailwind.min.css is generated against only the in-tree
|
||||||
# plugins. Rather than ship it as-is (leaving baked-in plugins' classes
|
# plugins. Rather than ship it as-is (leaving baked-in plugins' classes
|
||||||
# unstyled now that the Play CDN's runtime JIT is gone — slopsmith#411),
|
# unstyled now that the Play CDN's runtime JIT is gone — feedBack#411),
|
||||||
# rebuild it here, after static/ + plugins/ are present, so the sheet covers
|
# rebuild it here, after static/ + plugins/ are present, so the sheet covers
|
||||||
# whatever plugins are baked into the image. Runs in a throwaway node stage so
|
# whatever plugins are baked into the image. Runs in a throwaway node stage so
|
||||||
# this build-time toolchain never lands in the final image; the runtime node
|
# this build-time toolchain never lands in the final image; the runtime node
|
||||||
@@ -94,9 +94,9 @@ FROM python:3.12-slim
|
|||||||
# Re-declare the ffmpeg ARGs so their values are available to LABEL below.
|
# Re-declare the ffmpeg ARGs so their values are available to LABEL below.
|
||||||
# ARG values don't cross stage boundaries in multi-stage builds; defaults
|
# ARG values don't cross stage boundaries in multi-stage builds; defaults
|
||||||
# must be repeated here to take effect when no --build-arg is supplied.
|
# must be repeated here to take effect when no --build-arg is supplied.
|
||||||
ARG FFMPEG_RELEASE=autobuild-2026-06-01-15-02
|
ARG FFMPEG_RELEASE=autobuild-2026-06-19-23-17
|
||||||
ARG FFMPEG_BUILD_AMD64=ffmpeg-n7.1.4-7-gadcf20da26-linux64-gpl-7.1.tar.xz
|
ARG FFMPEG_BUILD_AMD64=ffmpeg-n7.1.4-145-g4cbf7a4b3d-linux64-gpl-7.1.tar.xz
|
||||||
ARG FFMPEG_BUILD_ARM64=ffmpeg-n7.1.4-7-gadcf20da26-linuxarm64-gpl-7.1.tar.xz
|
ARG FFMPEG_BUILD_ARM64=ffmpeg-n7.1.4-145-g4cbf7a4b3d-linuxarm64-gpl-7.1.tar.xz
|
||||||
|
|
||||||
# Apply latest security updates to base packages (clears glibc deb13u3 and
|
# Apply latest security updates to base packages (clears glibc deb13u3 and
|
||||||
# similar). Done first so any subsequent installs resolve against the
|
# similar). Done first so any subsequent installs resolve against the
|
||||||
@@ -112,7 +112,7 @@ RUN apt-get update \
|
|||||||
# package drags in the full codec + TLS + graphics dependency tree
|
# package drags in the full codec + TLS + graphics dependency tree
|
||||||
# (mbedtls, gnutls28, mesa, x264, tiff, openjpeg2, libcaca, harfbuzz,
|
# (mbedtls, gnutls28, mesa, x264, tiff, openjpeg2, libcaca, harfbuzz,
|
||||||
# cairo, openldap, libcdio…), almost all of which has unfixed CVEs and
|
# cairo, openldap, libcdio…), almost all of which has unfixed CVEs and
|
||||||
# none of which Slopsmith uses. We pull a static ffmpeg binary further
|
# none of which FeedBack uses. We pull a static ffmpeg binary further
|
||||||
# down instead.
|
# down instead.
|
||||||
#
|
#
|
||||||
# vgmstream-cli is also built with -DUSE_FFMPEG=OFF (see stage 1b), so
|
# vgmstream-cli is also built with -DUSE_FFMPEG=OFF (see stage 1b), so
|
||||||
@@ -142,7 +142,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
&& rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*
|
&& rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*
|
||||||
|
|
||||||
# Node + the pinned Tailwind CLI for RUNTIME stylesheet regeneration. When a
|
# Node + the pinned Tailwind CLI for RUNTIME stylesheet regeneration. When a
|
||||||
# plugin is installed into SLOPSMITH_PLUGINS_DIR at runtime (or discovered
|
# plugin is installed into FEEDBACK_PLUGINS_DIR at runtime (or discovered
|
||||||
# there on startup), the server rebuilds static/tailwind.min.css so the
|
# there on startup), the server rebuilds static/tailwind.min.css so the
|
||||||
# plugin's classes are styled — the image-baked sheet only covered in-tree
|
# plugin's classes are styled — the image-baked sheet only covered in-tree
|
||||||
# plugins (see lib/tailwind_rebuild.py). tailwindcss is installed globally so
|
# plugins (see lib/tailwind_rebuild.py). tailwindcss is installed globally so
|
||||||
@@ -176,10 +176,10 @@ COPY --from=ffmpeg-fetcher /out/LICENSE.txt /usr/share/doc/ffmpeg/LICENSE.txt
|
|||||||
RUN chmod +x /usr/local/bin/ffmpeg /usr/local/bin/ffprobe
|
RUN chmod +x /usr/local/bin/ffmpeg /usr/local/bin/ffprobe
|
||||||
# Record provenance so the exact BtbN source can be located for GPL compliance
|
# Record provenance so the exact BtbN source can be located for GPL compliance
|
||||||
# or debugging. Inspect with: docker inspect <image> | grep -A5 ffmpeg
|
# or debugging. Inspect with: docker inspect <image> | grep -A5 ffmpeg
|
||||||
LABEL org.slopsmith.ffmpeg.release="${FFMPEG_RELEASE}" \
|
LABEL org.feedBack.ffmpeg.release="${FFMPEG_RELEASE}" \
|
||||||
org.slopsmith.ffmpeg.source.amd64="${FFMPEG_BUILD_AMD64}" \
|
org.feedBack.ffmpeg.source.amd64="${FFMPEG_BUILD_AMD64}" \
|
||||||
org.slopsmith.ffmpeg.source.arm64="${FFMPEG_BUILD_ARM64}" \
|
org.feedBack.ffmpeg.source.arm64="${FFMPEG_BUILD_ARM64}" \
|
||||||
org.slopsmith.ffmpeg.upstream="https://github.com/BtbN/FFmpeg-Builds"
|
org.feedBack.ffmpeg.upstream="https://github.com/BtbN/FFmpeg-Builds"
|
||||||
|
|
||||||
# Native vgmstream-cli built against the image's own libraries
|
# Native vgmstream-cli built against the image's own libraries
|
||||||
COPY --from=vgmstream-builder /out/vgmstream-cli /usr/local/bin/vgmstream-cli
|
COPY --from=vgmstream-builder /out/vgmstream-cli /usr/local/bin/vgmstream-cli
|
||||||
|
|||||||
@@ -4,50 +4,43 @@
|
|||||||
|
|
||||||
| Plugin | Description | Install |
|
| Plugin | Description | Install |
|
||||||
|------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------|
|
|------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------|
|
||||||
| [Create from Tab](https://github.com/got-feedback/feedback-plugin-ug) | Search Ultimate Guitar for GP tabs and convert to playable songs | `git clone ...slopsmith-plugin-ug.git ultimate_guitar` |
|
| [Create from Tab](https://github.com/got-feedback/feedBack-plugin-ug) | Search Ultimate Guitar for GP tabs and convert to playable songs | `git clone ...feedBack-plugin-ug.git ultimate_guitar` |
|
||||||
| [Import Tab](https://github.com/got-feedback/feedback-plugin-tabimport) | Drag and drop Guitar Pro files to create songs | `git clone ...slopsmith-plugin-tabimport.git tab_import` |
|
| [Import Tab](https://github.com/got-feedback/feedBack-plugin-tabimport) | Drag and drop Guitar Pro files to create songs | `git clone ...feedBack-plugin-tabimport.git tab_import` |
|
||||||
| [Practice Journal](https://github.com/got-feedback/feedback-plugin-practice) | Auto-track practice time, speed, loops. Dashboard with charts | `git clone ...slopsmith-plugin-practice.git practice_journal` |
|
| [Practice Journal](https://github.com/got-feedback/feedBack-plugin-practice) | Auto-track practice time, speed, loops. Dashboard with charts | `git clone ...feedBack-plugin-practice.git practice_journal` |
|
||||||
| [Setlist Builder](https://github.com/got-feedback/feedback-plugin-setlist) | Create ordered playlists with sequential playback | `git clone ...slopsmith-plugin-setlist.git setlist` |
|
| [Setlist Builder](https://github.com/got-feedback/feedBack-plugin-setlist) | Create ordered playlists with sequential playback | `git clone ...feedBack-plugin-setlist.git setlist` |
|
||||||
| [Metronome](https://github.com/got-feedback/feedback-plugin-metronome) | Audible click and visual beat flash synced to song tempo | `git clone ...slopsmith-plugin-metronome.git metronome` |
|
| [Metronome](https://github.com/got-feedback/feedBack-plugin-metronome) | Audible click and visual beat flash synced to song tempo | `git clone ...feedBack-plugin-metronome.git metronome` |
|
||||||
| [Tone Player](https://github.com/got-feedback/feedback-plugin-tones) | View amp/pedal/cab signal chains with gear artwork | `git clone ...slopsmith-plugin-tones.git tones` |
|
| [Tone Player](https://github.com/got-feedback/feedBack-plugin-tones) | View amp/pedal/cab signal chains with gear artwork | `git clone ...feedBack-plugin-tones.git tones` |
|
||||||
| [Fretboard View](https://github.com/got-feedback/feedback-plugin-fretboard) | Live fretboard overlay showing active notes in real-time | `git clone ...slopsmith-plugin-fretboard.git fretboard` |
|
| [Fretboard View](https://github.com/got-feedback/feedBack-plugin-fretboard) | Live fretboard overlay showing active notes in real-time | `git clone ...feedBack-plugin-fretboard.git fretboard` |
|
||||||
| [Tab View](https://github.com/got-feedback/feedback-plugin-tabview) | Scrolling guitar tablature notation via alphaTab | `git clone ...slopsmith-plugin-tabview.git tab_view` |
|
| [Tab View](https://github.com/got-feedback/feedBack-plugin-tabview) | Scrolling guitar tablature notation via alphaTab | `git clone ...feedBack-plugin-tabview.git tab_view` |
|
||||||
| [MIDI Amp Control](https://github.com/got-feedback/feedback-plugin-midi) | Auto-switch amp/modeler presets via MIDI on tone changes | `git clone ...slopsmith-plugin-midi.git midi_amp` |
|
| [MIDI Amp Control](https://github.com/got-feedback/feedBack-plugin-midi) | Auto-switch amp/modeler presets via MIDI on tone changes | `git clone ...feedBack-plugin-midi.git midi_amp` |
|
||||||
| [Section Map](https://github.com/got-feedback/feedback-plugin-sectionmap) | Color-coded song structure minimap with clickable navigation | `git clone ...slopsmith-plugin-sectionmap.git section_map` |
|
| [Section Map](https://github.com/got-feedback/feedBack-plugin-sectionmap) | Color-coded song structure minimap with clickable navigation | `git clone ...feedBack-plugin-sectionmap.git section_map` |
|
||||||
| [Arrangement Editor](https://github.com/got-feedback/feedback-plugin-editor) | DAW-like visual editor for creating and editing song note charts | `git clone ...slopsmith-plugin-editor.git editor` |
|
| [Arrangement Editor](https://github.com/got-feedback/feedBack-plugin-editor) | DAW-like visual editor for creating and editing song note charts | `git clone ...feedBack-plugin-editor.git editor` |
|
||||||
| [MIDI Capo](https://github.com/masc0t/slopsmith-plugin-midi-capo) | MIDI capo control for real-time transposition | `git clone ...slopsmith-plugin-midi-capo.git midi_capo` |
|
| [MIDI Capo](https://github.com/masc0t/slopsmith-plugin-midi-capo) | MIDI capo control for real-time transposition | `git clone ...slopsmith-plugin-midi-capo.git midi_capo` |
|
||||||
| [Note Detection](https://github.com/got-feedback/feedback-plugin-notedetect) | Real-time pitch detection and scoring against highway notes | `git clone ...slopsmith-plugin-notedetect.git note_detect` |
|
| [Note Detection](https://github.com/got-feedback/feedBack-plugin-notedetect) | Real-time pitch detection and scoring against highway notes | `git clone ...feedBack-plugin-notedetect.git note_detect` |
|
||||||
| [Find More](https://github.com/masc0t/slopsmith-plugin-find-more) | Search for more songs by the same artist | `git clone ...slopsmith-plugin-find-more.git find_more` |
|
| [Find More](https://github.com/masc0t/slopsmith-plugin-find-more) | Search for more songs by the same artist | `git clone ...slopsmith-plugin-find-more.git find_more` |
|
||||||
| [Piano Highway](https://github.com/got-feedback/feedback-plugin-piano) | Scrolling piano/keyboard view for Keys arrangements with MIDI input | `git clone ...slopsmith-plugin-piano.git piano` |
|
| [Piano Highway](https://github.com/got-feedback/feedBack-plugin-piano) | Scrolling piano/keyboard view for Keys arrangements with MIDI input | `git clone ...feedBack-plugin-piano.git piano` |
|
||||||
| [Studio](https://github.com/got-feedback/feedback-plugin-studio) | Collaborative band recording and multi-track mixing | `git clone ...slopsmith-plugin-studio.git studio` |
|
| [Studio](https://github.com/got-feedback/feedBack-plugin-studio) | Collaborative band recording and multi-track mixing | `git clone ...feedBack-plugin-studio.git studio` |
|
||||||
| [Drum Highway](https://github.com/got-feedback/feedback-plugin-drums) | Lane-based drum highway with MIDI drum pad input and built-in sounds | `git clone ...slopsmith-plugin-drums.git drums` |
|
| [Drum Highway](https://github.com/got-feedback/feedBack-plugin-drums) | Lane-based drum highway with MIDI drum pad input and built-in sounds | `git clone ...feedBack-plugin-drums.git drums` |
|
||||||
| [Split Screen](https://github.com/topkoa/slopsmith-plugin-splitscreen) | 2-4 highway panels side-by-side for multi-arrangement practice | `git clone ...slopsmith-plugin-splitscreen.git splitscreen` |
|
|
||||||
| [Stems Mixer](https://github.com/topkoa/slopsmith-plugin-stems) | Per-stem mute/volume controls for .sloppak songs | `git clone ...slopsmith-plugin-stems.git stems` |
|
|
||||||
| [Invert Highway](https://github.com/masc0t/slopsmith-plugin-invert-highway) | Flip the highway note direction | `git clone ...slopsmith-plugin-invert-highway.git invert_highway` |
|
| [Invert Highway](https://github.com/masc0t/slopsmith-plugin-invert-highway) | Flip the highway note direction | `git clone ...slopsmith-plugin-invert-highway.git invert_highway` |
|
||||||
| [Jumping Tab](https://github.com/renanboni/slopsmith-plugin-jumpingtab) | Yousician-style 2D horizontal tab with trajectory arcs and hopping ball | `git clone ...slopsmith-plugin-jumpingtab.git jumpingtab` |
|
| [Jumping Tab](https://github.com/renanboni/slopsmith-plugin-jumpingtab) | Yousician-style 2D horizontal tab with trajectory arcs and hopping ball | `git clone ...slopsmith-plugin-jumpingtab.git jumpingtab` |
|
||||||
| [Step Mode](https://github.com/got-feedback/feedback-plugin-stepmode) | Step-by-step practice mode — highway freezes at each note until played (via Note Detection) or Space | `git clone ...slopsmith-plugin-stepmode.git step_mode` |
|
| [Step Mode](https://github.com/got-feedback/feedBack-plugin-stepmode) | Step-by-step practice mode — highway freezes at each note until played (via Note Detection) or Space | `git clone ...feedBack-plugin-stepmode.git step_mode` |
|
||||||
| [Lyrics Sync](https://github.com/got-feedback/feedback-plugin-lyrics-sync) | Generate synced LRC lyrics from text + vocals stem via Whisper alignment | `git clone ...slopsmith-plugin-lyrics-sync.git lyrics_sync` |
|
| [Lyrics Sync](https://github.com/got-feedback/feedBack-plugin-lyrics-sync) | Generate synced LRC lyrics from text + vocals stem via Whisper alignment | `git clone ...feedBack-plugin-lyrics-sync.git lyrics_sync` |
|
||||||
| [Lyrics Karaoke](https://github.com/got-feedback/feedback-plugin-lyrics-karaoke) | Per-syllable karaoke pitch ribbon for sloppak songs (Whisper alignment + librosa pYIN) | `git clone ...slopsmith-plugin-lyrics-karaoke.git lyrics_karaoke` |
|
| [Lyrics Karaoke](https://github.com/got-feedback/feedBack-plugin-lyrics-karaoke) | Per-syllable karaoke pitch ribbon for sloppak songs (Whisper alignment + librosa pYIN) | `git clone ...feedBack-plugin-lyrics-karaoke.git lyrics_karaoke` |
|
||||||
| [NAM Tone Engine](https://github.com/got-feedback/feedback-plugin-nam-tone) | In-browser amp modeling with NAM WASM, cabinet IRs, tone auto-switching | `git clone ...slopsmith-plugin-nam-tone.git nam_tone` |
|
| [NAM Tone Engine](https://github.com/got-feedback/feedBack-plugin-nam-tone) | In-browser amp modeling with NAM WASM, cabinet IRs, tone auto-switching | `git clone ...feedBack-plugin-nam-tone.git nam_tone` |
|
||||||
| [Guitar Theory Lab](https://github.com/topkoa/slopsmith-plugin-guitar-theory) | Explore scales, chords, intervals, tunings, and voicings on a fully interactive fretboard | `git clone ...slopsmith-plugin-nam-tone.git guitar-theory-lab` |
|
| [Guitar Theory Lab](https://github.com/topkoa/slopsmith-plugin-guitar-theory) | Explore scales, chords, intervals, tunings, and voicings on a fully interactive fretboard | `git clone ...slopsmith-plugin-guitar-theory.git guitar-theory-lab` |
|
||||||
| [Themes](https://github.com/masc0t/slopsmith-plugin-themes) | Offers several basic recolorings of the interface | `git clone ...slopsmith-plugin-themes.git themes` |
|
| [Themes](https://github.com/masc0t/slopsmith-plugin-themes) | Offers several basic recolorings of the interface | `git clone ...slopsmith-plugin-themes.git themes` |
|
||||||
| [Update Manager](https://github.com/masc0t/slopsmith-update-manager) | Installs, updates, and uninstalls other plugins and the slopsmith core itself | `git clone ...slopsmith-update-manager.git update_manager` |
|
| [Update Manager](https://github.com/masc0t/slopsmith-update-manager) | Installs, updates, and uninstalls other plugins and the feedBack core itself | `git clone ...slopsmith-update-manager.git update_manager` |
|
||||||
| [Tuner](https://github.com/OmikronApex/slopsmith-plugin-tuner) | Floating tuner with customizable tunings | `git clone ...slopsmith-plugin-tuner.git tuner` |
|
|
||||||
| [Simplify Chords](https://github.com/bkranendonk/slopsmith-plugin-simplify-chords) | Changes complex chords on the note highway to simpler ones. Inspired by Ultimate Guitar's Simplify button. | `git clone ...slopsmith-plugin-simplify-chords.git simplify-chords` |
|
| [Simplify Chords](https://github.com/bkranendonk/slopsmith-plugin-simplify-chords) | Changes complex chords on the note highway to simpler ones. Inspired by Ultimate Guitar's Simplify button. | `git clone ...slopsmith-plugin-simplify-chords.git simplify-chords` |
|
||||||
| [Key Bindings](https://github.com/jackipicco/slopsmith-plugin-key-bindings) | Highway key bindings for keyboard and TV remote | `git clone ...slopsmith-plugin-key-bindings.git key_bindings` |
|
| [Key Bindings](https://github.com/jackipicco/slopsmith-plugin-key-bindings) | Highway key bindings for keyboard and TV remote | `git clone ...slopsmith-plugin-key-bindings.git key_bindings` |
|
||||||
| [Folder Organizer](https://github.com/Elit3d/slopsmith-plugin-folder-organizer) | Organize your sloppak DLC songs into a folder tree view, grouped by subfolder name | `git clone ...slopsmith-plugin-folder-organizer.git folder-organizer` |
|
| [Virtuoso](https://github.com/got-feedback/feedBack-plugin-virtuoso) | Practice studio for guitar & bass — scale, technique, and rhythm drills, timed workouts, and jam backing that teach skills you take off the screen. | `git clone ...feedBack-plugin-virtuoso.git virtuoso` |
|
||||||
| [SlopScale](https://github.com/ChrisBeWithYou/slopsmith-plugin-slopscale) | Scale, arpeggio, and sweep-arpeggio practice routines with 3D highway, 2D highway, and tab renderers. Pathway selector, CAGED shape-run arpeggios, and generated audio backing. | `git clone ...slopsmith-plugin-slopscale.git slopscale` |
|
|
||||||
| [NAM Rig Builder](https://github.com/Jafz2001/slopsmith-plugin-nam-rig-builder) | Map tones to chained NAM neural-amp rigs (tone3000 captures + IRs) — full pedal→amp→cab playback, per-stage bypass, and a gear catalog | `git clone ...slopsmith-plugin-nam-rig-builder.git nam_rig_builder` |
|
|
||||||
| [Audio Preview](https://github.com/saleemk/slopsmith-plugin-audio-preview) | Quick audio previews from library cards with configurable start time, volume, and duration | `git clone ...slopsmith-plugin-audio-preview.git audio_preview` |
|
| [Audio Preview](https://github.com/saleemk/slopsmith-plugin-audio-preview) | Quick audio previews from library cards with configurable start time, volume, and duration | `git clone ...slopsmith-plugin-audio-preview.git audio_preview` |
|
||||||
| [Song Mastery](https://github.com/jamesgaiser/slopsmith-plugin-song-mastery) | Auto-adjusts difficulty based on your rolling note accuracy and saves the slider position per song | `git clone ...slopsmith-plugin-song-mastery.git song_mastery` |
|
| [Song Mastery](https://github.com/jamesgaiser/slopsmith-plugin-song-mastery) | Auto-adjusts difficulty based on your rolling note accuracy and saves the slider position per song | `git clone ...slopsmith-plugin-song-mastery.git song_mastery` |
|
||||||
| [Song Preview](https://github.com/DeathlySin/slopsmith-plugin-song-preview) | Quickly hear previews of songs in your library with a clean visual indicator of what's playing. Supports .sloppak and loose folders song formats, with the visual indicator matching up to whatever theme you are using! | `git clone ...slopsmith-plugin-song-preview.git song_preview` |
|
|
||||||
| [Mobile Note Highway](https://github.com/saleemk/slopsmith-plugin-mobile-note-highway) | Touch-optimized player with collapsible controls, highway gestures, and device-adaptive layouts for phones and tablets | `git clone ...slopsmith-plugin-mobile-note-highway.git mobile_note_highway` |
|
| [Mobile Note Highway](https://github.com/saleemk/slopsmith-plugin-mobile-note-highway) | Touch-optimized player with collapsible controls, highway gestures, and device-adaptive layouts for phones and tablets | `git clone ...slopsmith-plugin-mobile-note-highway.git mobile_note_highway` |
|
||||||
| [Shuffle](https://github.com/Erikcb91/Slopsmith-Shuffle-Mode) | Random playback from your library — artist & tuning filters, auto-advance with countdown popup, note_detect compatible | `git clone https://github.com/Erikcb91/Slopsmith-Shuffle-Mode.git shuffle` |
|
|
||||||
|
|
||||||
Install any plugin by cloning it into your `plugins/` directory and restarting:
|
Install any plugin by cloning it into your `plugins/` directory and restarting:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd plugins
|
cd plugins
|
||||||
git clone https://github.com/got-feedback/feedback-plugin-ug.git ultimate_guitar
|
git clone https://github.com/got-feedback/feedBack-plugin-ug.git ultimate_guitar
|
||||||
docker compose restart
|
docker compose restart
|
||||||
```
|
```
|
||||||
|
|||||||
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
# Supporters
|
# Supporters
|
||||||
|
|
||||||
Slopsmith's development is supported by these generous people. Thank you. ❤️
|
FeedBack's development is supported by these generous people. Thank you. ❤️
|
||||||
|
|
||||||
Want to be listed here? See [Support Slopsmith](README.md#support-slopsmith).
|
Want to be listed here? See [Support FeedBack](README.md#support-feedBack).
|
||||||
|
|
||||||
## Patrons
|
## Patrons
|
||||||
|
|
||||||
|
|||||||
+12
-12
@@ -9,8 +9,8 @@
|
|||||||
# sudo bash build-proxmox-ct.sh [TARGETARCH] [OUTPUT_NAME]
|
# sudo bash build-proxmox-ct.sh [TARGETARCH] [OUTPUT_NAME]
|
||||||
#
|
#
|
||||||
# Examples:
|
# Examples:
|
||||||
# sudo bash build-proxmox-ct.sh amd64 slopsmith-ct
|
# sudo bash build-proxmox-ct.sh amd64 feedBack-ct
|
||||||
# sudo bash build-proxmox-ct.sh arm64 slopsmith-ct
|
# sudo bash build-proxmox-ct.sh arm64 feedBack-ct
|
||||||
#
|
#
|
||||||
# The resulting container ships empty; mount or copy your .sloppak /
|
# The resulting container ships empty; mount or copy your .sloppak /
|
||||||
# loose-folder library into /dlc inside the CT after import.
|
# loose-folder library into /dlc inside the CT after import.
|
||||||
@@ -26,13 +26,13 @@
|
|||||||
# sudo apt install debootstrap systemd-container tar zstd curl unzip git
|
# sudo apt install debootstrap systemd-container tar zstd curl unzip git
|
||||||
#
|
#
|
||||||
# On Proxmox, after transfer:
|
# On Proxmox, after transfer:
|
||||||
# pct restore <VMID> slopsmith-ct.tar.zst --storage local-lvm --rootfs 8 --unprivileged 1
|
# pct restore <VMID> feedBack-ct.tar.zst --storage local-lvm --rootfs 8 --unprivileged 1
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
TARGETARCH="${1:-amd64}"
|
TARGETARCH="${1:-amd64}"
|
||||||
OUTPUT_NAME="${2:-slopsmith-ct}"
|
OUTPUT_NAME="${2:-feedBack-ct}"
|
||||||
|
|
||||||
# OUTPUT_NAME is a positional arg that flows into BUILD_BASE (interpolated into
|
# OUTPUT_NAME is a positional arg that flows into BUILD_BASE (interpolated into
|
||||||
# `mkdir -p` / `rm -rf` paths) and into the final tarball name. Reject anything
|
# `mkdir -p` / `rm -rf` paths) and into the final tarball name. Reject anything
|
||||||
@@ -104,7 +104,7 @@ VENV_DIR="/opt/app-venv"
|
|||||||
PIP_VERSION="26.1.1"
|
PIP_VERSION="26.1.1"
|
||||||
DLC_DIR="/dlc"
|
DLC_DIR="/dlc"
|
||||||
CONFIG_DIR="/config"
|
CONFIG_DIR="/config"
|
||||||
SVC_USER="slopsmith"
|
SVC_USER="feedBack"
|
||||||
|
|
||||||
# Coloured logging
|
# Coloured logging
|
||||||
info() { echo -e "\033[1;34m[INFO]\033[0m $*"; }
|
info() { echo -e "\033[1;34m[INFO]\033[0m $*"; }
|
||||||
@@ -420,7 +420,7 @@ ok "Build dependencies removed."
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# 5d. Tailwind CLI for runtime stylesheet regeneration
|
# 5d. Tailwind CLI for runtime stylesheet regeneration
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# When a plugin is installed into SLOPSMITH_PLUGINS_DIR at runtime (or
|
# When a plugin is installed into FEEDBACK_PLUGINS_DIR at runtime (or
|
||||||
# discovered there on startup), the server rebuilds static/tailwind.min.css
|
# discovered there on startup), the server rebuilds static/tailwind.min.css
|
||||||
# so the plugin's classes are styled — the image-baked sheet only covers
|
# so the plugin's classes are styled — the image-baked sheet only covers
|
||||||
# in-tree plugins (see lib/tailwind_rebuild.py). tailwindcss is installed
|
# in-tree plugins (see lib/tailwind_rebuild.py). tailwindcss is installed
|
||||||
@@ -523,11 +523,11 @@ info "Creating service user '${SVC_USER}' …"
|
|||||||
r "useradd --system --home-dir ${APP_DIR} --shell /usr/sbin/nologin ${SVC_USER}"
|
r "useradd --system --home-dir ${APP_DIR} --shell /usr/sbin/nologin ${SVC_USER}"
|
||||||
ok "User '${SVC_USER}' created."
|
ok "User '${SVC_USER}' created."
|
||||||
|
|
||||||
info "Installing slopsmith-server.service …"
|
info "Installing feedBack-server.service …"
|
||||||
mkdir -p "${ROOTFS}/etc/systemd/system"
|
mkdir -p "${ROOTFS}/etc/systemd/system"
|
||||||
cat > "${ROOTFS}/etc/systemd/system/slopsmith-server.service" <<EOF
|
cat > "${ROOTFS}/etc/systemd/system/feedBack-server.service" <<EOF
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Slopsmith uvicorn server
|
Description=FeedBack uvicorn server
|
||||||
After=network.target
|
After=network.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
@@ -547,8 +547,8 @@ EOF
|
|||||||
|
|
||||||
# Enable by symlinking (avoids running systemctl inside nspawn)
|
# Enable by symlinking (avoids running systemctl inside nspawn)
|
||||||
mkdir -p "${ROOTFS}/etc/systemd/system/multi-user.target.wants"
|
mkdir -p "${ROOTFS}/etc/systemd/system/multi-user.target.wants"
|
||||||
ln -sf /etc/systemd/system/slopsmith-server.service \
|
ln -sf /etc/systemd/system/feedBack-server.service \
|
||||||
"${ROOTFS}/etc/systemd/system/multi-user.target.wants/slopsmith-server.service"
|
"${ROOTFS}/etc/systemd/system/multi-user.target.wants/feedBack-server.service"
|
||||||
ok "Service enabled."
|
ok "Service enabled."
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -662,6 +662,6 @@ cat <<DONE
|
|||||||
--start 1
|
--start 1
|
||||||
|
|
||||||
Then check the server:
|
Then check the server:
|
||||||
pct exec 200 -- systemctl status slopsmith-server
|
pct exec 200 -- systemctl status feedBack-server
|
||||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
DONE
|
DONE
|
||||||
|
|||||||
@@ -7,17 +7,17 @@ services:
|
|||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
volumes:
|
volumes:
|
||||||
# Song library folder on NAS
|
# Song library folder on NAS
|
||||||
- /volume1/music/slopsmith:/dlc
|
- /volume1/music/feedBack:/dlc
|
||||||
# Persistent config, cache, favorites, loops, practice data
|
# Persistent config, cache, favorites, loops, practice data
|
||||||
- slopsmith-config:/config
|
- feedBack-config:/config
|
||||||
environment:
|
environment:
|
||||||
- DLC_DIR=/dlc
|
- DLC_DIR=/dlc
|
||||||
- CONFIG_DIR=/config
|
- CONFIG_DIR=/config
|
||||||
# Logging (optional)
|
# Logging (optional)
|
||||||
# - LOG_LEVEL=INFO # DEBUG | INFO | WARNING | ERROR (default: INFO)
|
# - LOG_LEVEL=INFO # DEBUG | INFO | WARNING | ERROR (default: INFO)
|
||||||
# - LOG_FORMAT=json # json | text (default: text)
|
# - LOG_FORMAT=json # json | text (default: text)
|
||||||
# - LOG_FILE=/config/slopsmith.log # also write to a persistent file
|
# - LOG_FILE=/config/feedBack.log # also write to a persistent file
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
slopsmith-config:
|
feedBack-config:
|
||||||
|
|||||||
+3
-3
@@ -7,7 +7,7 @@ services:
|
|||||||
# Mount your song library folder (adjust path for your system)
|
# Mount your song library folder (adjust path for your system)
|
||||||
- ${LIBRARY_PATH:-./library}:/dlc
|
- ${LIBRARY_PATH:-./library}:/dlc
|
||||||
# Persistent config and cache
|
# Persistent config and cache
|
||||||
- slopsmith-config:/config
|
- feedBack-config:/config
|
||||||
# Mount source for live reload during development
|
# Mount source for live reload during development
|
||||||
- ./static:/app/static
|
- ./static:/app/static
|
||||||
- ./server.py:/app/server.py
|
- ./server.py:/app/server.py
|
||||||
@@ -28,10 +28,10 @@ services:
|
|||||||
# Logging (optional)
|
# Logging (optional)
|
||||||
# - LOG_LEVEL=DEBUG # DEBUG | INFO | WARNING | ERROR (default: INFO)
|
# - LOG_LEVEL=DEBUG # DEBUG | INFO | WARNING | ERROR (default: INFO)
|
||||||
# - LOG_FORMAT=json # json | text (default: text — coloured console)
|
# - LOG_FORMAT=json # json | text (default: text — coloured console)
|
||||||
# - LOG_FILE=/config/slopsmith.log # also write to a persistent file
|
# - LOG_FILE=/config/feedBack.log # also write to a persistent file
|
||||||
dns:
|
dns:
|
||||||
- 8.8.8.8
|
- 8.8.8.8
|
||||||
- 1.1.1.1
|
- 1.1.1.1
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
slopsmith-config:
|
feedBack-config:
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ Depends on: `docs/NOTE_FAILURE_SPEC.md` (read that first)
|
|||||||
**Goal:** Working note detection plugin streaming detected notes via WebSocket.
|
**Goal:** Working note detection plugin streaming detected notes via WebSocket.
|
||||||
|
|
||||||
This phase was previously tracked in a separate NOTE_DETECTION_PLUGIN_PLAN
|
This phase was previously tracked in a separate NOTE_DETECTION_PLUGIN_PLAN
|
||||||
document (in the `slopsmith-plugin-notedetect` repository). The relevant scope
|
document (in the `feedBack-plugin-notedetect` repository). The relevant scope
|
||||||
is summarized here to avoid relying on an internal git-only reference:
|
is summarized here to avoid relying on an internal git-only reference:
|
||||||
|
|
||||||
- [ ] Plugin skeleton: `slopsmith-plugin-notedetect/` with plugin.json, routes.py, screen.js
|
- [ ] Plugin skeleton: `feedBack-plugin-notedetect/` with plugin.json, routes.py, screen.js
|
||||||
- [ ] Port TonalRecall YIN detection (aubio + sounddevice) to routes.py
|
- [ ] Port TonalRecall YIN detection (aubio + sounddevice) to routes.py
|
||||||
- [ ] WebSocket at `/api/plugins/note_detect/stream` streaming `{ note, freq, confidence, time }`
|
- [ ] WebSocket at `/api/plugins/note_detect/stream` streaming `{ note, freq, confidence, time }`
|
||||||
- [ ] Device selection UI in screen.html
|
- [ ] Device selection UI in screen.html
|
||||||
@@ -138,7 +138,7 @@ shows the correct diagnostic labels.
|
|||||||
```
|
```
|
||||||
Displayed for 1.5s, then fades.
|
Displayed for 1.5s, then fades.
|
||||||
- [ ] Track `bestIteration` across all iterations for "Best" display
|
- [ ] Track `bestIteration` across all iterations for "Best" display
|
||||||
- [ ] Emit `loop:complete` event via `window.slopsmith.emit()` so other plugins
|
- [ ] Emit `loop:complete` event via `window.feedBack.emit()` so other plugins
|
||||||
(practice journal) can record the data
|
(practice journal) can record the data
|
||||||
- [ ] Reset loop history when loop boundaries change or loop is cleared
|
- [ ] Reset loop history when loop boundaries change or loop is cleared
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ late, wrong pitch, or not played at all.
|
|||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
This feature depends on the **note detection plugin** (`slopsmith-plugin-notedetect`),
|
This feature depends on the **note detection plugin** (`feedBack-plugin-notedetect`),
|
||||||
which provides real-time pitch detection via server-side aubio/YIN over WebSocket.
|
which provides real-time pitch detection via server-side aubio/YIN over WebSocket.
|
||||||
The detection plugin streams `DetectedNote` events; this spec describes the
|
The detection plugin streams `DetectedNote` events; this spec describes the
|
||||||
**matching, judgment, and rendering** layer that consumes those events.
|
**matching, judgment, and rendering** layer that consumes those events.
|
||||||
@@ -55,10 +55,10 @@ Guitar → USB Adapter → sounddevice (server)
|
|||||||
Wire format: `{ note: "A2", freq: 110.0, confidence: 0.92, time: 1.234 }`
|
Wire format: `{ note: "A2", freq: 110.0, confidence: 0.92, time: 1.234 }`
|
||||||
|
|
||||||
> **Plugin naming note:** The detection plugin's repository is named
|
> **Plugin naming note:** The detection plugin's repository is named
|
||||||
> `slopsmith-plugin-notedetect`, but the plugin registers with the id
|
> `feedBack-plugin-notedetect`, but the plugin registers with the id
|
||||||
> `note_detect` (snake_case). Its HTTP/WebSocket routes therefore appear
|
> `note_detect` (snake_case). Its HTTP/WebSocket routes therefore appear
|
||||||
> under `/api/plugins/note_detect/…`. There is no `window.slopsmithPlugin_*`
|
> under `/api/plugins/note_detect/…`. There is no `window.feedBackPlugin_*`
|
||||||
> global pattern in Slopsmith — to check whether the detection plugin is
|
> global pattern in FeedBack — to check whether the detection plugin is
|
||||||
> available at runtime, attempt a fetch to `/api/plugins/note_detect/status`
|
> available at runtime, attempt a fetch to `/api/plugins/note_detect/status`
|
||||||
> (or similar) or consult the `/api/plugins` list. Use the repo name only
|
> (or similar) or consult the `/api/plugins` list. Use the repo name only
|
||||||
> in documentation links.
|
> in documentation links.
|
||||||
@@ -335,7 +335,7 @@ The tracker must handle A-B looping:
|
|||||||
| `loopA`, `loopB` | Current A-B loop boundaries |
|
| `loopA`, `loopB` | Current A-B loop boundaries |
|
||||||
| `audio.currentTime` | Actual audio playback position |
|
| `audio.currentTime` | Actual audio playback position |
|
||||||
|
|
||||||
### New Events Emitted (via `window.slopsmith.emit`)
|
### New Events Emitted (via `window.feedBack.emit`)
|
||||||
|
|
||||||
| Event | Payload |
|
| Event | Payload |
|
||||||
|------------------------------|------------------------------------------|
|
|------------------------------|------------------------------------------|
|
||||||
@@ -373,7 +373,7 @@ There are three distinct threshold tiers — keep them conceptually separate:
|
|||||||
| `hitGlowDuration` | 0.5 | Green glow fade time (sec) |
|
| `hitGlowDuration` | 0.5 | Green glow fade time (sec) |
|
||||||
|
|
||||||
Persist these settings in plugin-local storage (e.g. `localStorage` prefixed
|
Persist these settings in plugin-local storage (e.g. `localStorage` prefixed
|
||||||
with the plugin id). Do **not** assume they can be saved through Slopsmith's
|
with the plugin id). Do **not** assume they can be saved through FeedBack's
|
||||||
`/api/settings` endpoint under a `notedetect_feedback` key — the current server
|
`/api/settings` endpoint under a `notedetect_feedback` key — the current server
|
||||||
only persists a fixed set of known settings keys. If backend support for a
|
only persists a fixed set of known settings keys. If backend support for a
|
||||||
dedicated persisted key is added later, this plugin may migrate to `/api/settings`.
|
dedicated persisted key is added later, this plugin may migrate to `/api/settings`.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Slopsmith Note Detect Bass Benchmark — v1
|
# FeedBack Note Detect Bass Benchmark — v1
|
||||||
|
|
||||||
A bass-focused companion to the guitar benchmarks
|
A bass-focused companion to the guitar benchmarks
|
||||||
(note_detect_v1 + note_detect_v2). Tests `note_detect` against bass-
|
(note_detect_v1 + note_detect_v2). Tests `note_detect` against bass-
|
||||||
|
|||||||
@@ -20,11 +20,11 @@ the guitar one:
|
|||||||
than guitar E2 at ~82 Hz. The benchmark should exercise that
|
than guitar E2 at ~82 Hz. The benchmark should exercise that
|
||||||
regime explicitly so we can spot regressions there.
|
regime explicitly so we can spot regressions there.
|
||||||
|
|
||||||
How to run inside the slopsmith container:
|
How to run inside the feedBack container:
|
||||||
|
|
||||||
docker cp docs/benchmarks/note_detect_bass_v1/build_benchmark.py \\
|
docker cp docs/benchmarks/note_detect_bass_v1/build_benchmark.py \\
|
||||||
slopsmith-web-1:/tmp/build_benchmark_bass.py
|
feedBack-web-1:/tmp/build_benchmark_bass.py
|
||||||
docker exec slopsmith-web-1 python /tmp/build_benchmark_bass.py \\
|
docker exec feedBack-web-1 python /tmp/build_benchmark_bass.py \\
|
||||||
/app/static/sloppak_cache/note_detect_benchmark_bass_v1.sloppak
|
/app/static/sloppak_cache/note_detect_benchmark_bass_v1.sloppak
|
||||||
|
|
||||||
After regenerating, copy the zip output to the tracked path with the
|
After regenerating, copy the zip output to the tracked path with the
|
||||||
@@ -351,7 +351,7 @@ def build(out_dir: Path):
|
|||||||
|
|
||||||
arrangement = {
|
arrangement = {
|
||||||
'name': 'Bass',
|
'name': 'Bass',
|
||||||
# Pad to 6 slots even on bass — slopsmith's `tuning_name()` only
|
# Pad to 6 slots even on bass — feedBack's `tuning_name()` only
|
||||||
# recognises named tunings (E Standard, Drop D, etc.) on 6-element
|
# recognises named tunings (E Standard, Drop D, etc.) on 6-element
|
||||||
# arrays, so a 4-element array shows up in the library card as the
|
# arrays, so a 4-element array shows up in the library card as the
|
||||||
# raw numeric form ("0 0 0 0") instead of "E Standard". The
|
# raw numeric form ("0 0 0 0") instead of "E Standard". The
|
||||||
@@ -371,7 +371,7 @@ def build(out_dir: Path):
|
|||||||
|
|
||||||
manifest = {
|
manifest = {
|
||||||
'title': 'Note Detect Bass Benchmark v1',
|
'title': 'Note Detect Bass Benchmark v1',
|
||||||
'artist': 'Slopsmith',
|
'artist': 'FeedBack',
|
||||||
'album': 'Note Detection Benchmark',
|
'album': 'Note Detection Benchmark',
|
||||||
'year': 2026,
|
'year': 2026,
|
||||||
'duration': round(end_t, 3),
|
'duration': round(end_t, 3),
|
||||||
@@ -389,7 +389,7 @@ def build(out_dir: Path):
|
|||||||
{'id': 'full', 'file': 'stems/full.ogg', 'default': True},
|
{'id': 'full', 'file': 'stems/full.ogg', 'default': True},
|
||||||
],
|
],
|
||||||
'benchmark': {
|
'benchmark': {
|
||||||
'id': 'slopsmith-note-detect-benchmark-bass',
|
'id': 'feedBack-note-detect-benchmark-bass',
|
||||||
'version': 1,
|
'version': 1,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -461,7 +461,7 @@ def _build_zip(src_dir: Path):
|
|||||||
|
|
||||||
|
|
||||||
def _benchmark_readme(duration_s):
|
def _benchmark_readme(duration_s):
|
||||||
return f"""# Slopsmith Note Detect Bass Benchmark — v1
|
return f"""# FeedBack Note Detect Bass Benchmark — v1
|
||||||
|
|
||||||
A bass-focused companion to the guitar benchmarks
|
A bass-focused companion to the guitar benchmarks
|
||||||
(note_detect_v1 + note_detect_v2). Tests `note_detect` against bass-
|
(note_detect_v1 + note_detect_v2). Tests `note_detect` against bass-
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Slopsmith Note Detect Benchmark — v1
|
# FeedBack Note Detect Benchmark — v1
|
||||||
|
|
||||||
A short test piece for tuning Slopsmith's `note_detect` plugin. Eight
|
A short test piece for tuning FeedBack's `note_detect` plugin. Eight
|
||||||
exercises, each isolating a specific detection failure mode. Run with
|
exercises, each isolating a specific detection failure mode. Run with
|
||||||
**Detect** enabled, play through, then export the diagnostic JSON
|
**Detect** enabled, play through, then export the diagnostic JSON
|
||||||
(Settings → Plugins → Note Detection → Download Diagnostic JSON, or
|
(Settings → Plugins → Note Detection → Download Diagnostic JSON, or
|
||||||
@@ -43,4 +43,4 @@ Share the JSON (schema `note_detect.diagnostic.v1`). It includes:
|
|||||||
## Source
|
## Source
|
||||||
|
|
||||||
Built by `docs/benchmarks/note_detect_v1/build_benchmark.py` in the
|
Built by `docs/benchmarks/note_detect_v1/build_benchmark.py` in the
|
||||||
slopsmith repo. Tweak the exercise list there and regenerate.
|
feedBack repo. Tweak the exercise list there and regenerate.
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ short exercises designed to isolate specific failure modes (open-string
|
|||||||
mono, fretted positions, octaves, sustained held notes, hammer-on /
|
mono, fretted positions, octaves, sustained held notes, hammer-on /
|
||||||
pull-off, sparse power chords, dense open chords, bends).
|
pull-off, sparse power chords, dense open chords, bends).
|
||||||
|
|
||||||
How to run inside the slopsmith container (recommended — has ffmpeg +
|
How to run inside the feedBack container (recommended — has ffmpeg +
|
||||||
pyyaml already):
|
pyyaml already):
|
||||||
|
|
||||||
docker cp docs/benchmarks/note_detect_v1/build_benchmark.py \
|
docker cp docs/benchmarks/note_detect_v1/build_benchmark.py \
|
||||||
slopsmith-web-1:/tmp/build_benchmark.py
|
feedBack-web-1:/tmp/build_benchmark.py
|
||||||
docker exec slopsmith-web-1 python /tmp/build_benchmark.py \
|
docker exec feedBack-web-1 python /tmp/build_benchmark.py \
|
||||||
/app/static/sloppak_cache/note_detect_benchmark_v1.sloppak
|
/app/static/sloppak_cache/note_detect_benchmark_v1.sloppak
|
||||||
|
|
||||||
The output sloppak lands under `static/sloppak_cache/` on the host
|
The output sloppak lands under `static/sloppak_cache/` on the host
|
||||||
@@ -26,7 +26,7 @@ import sys
|
|||||||
import wave
|
import wave
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import yaml # bundled with the slopsmith image
|
import yaml # bundled with the feedBack image
|
||||||
|
|
||||||
# ── Benchmark parameters ────────────────────────────────────────────────
|
# ── Benchmark parameters ────────────────────────────────────────────────
|
||||||
BPM = 90.0
|
BPM = 90.0
|
||||||
@@ -411,7 +411,7 @@ def build(out_dir: Path):
|
|||||||
|
|
||||||
manifest = {
|
manifest = {
|
||||||
'title': 'Note Detect Benchmark v1',
|
'title': 'Note Detect Benchmark v1',
|
||||||
'artist': 'Slopsmith',
|
'artist': 'FeedBack',
|
||||||
'album': 'Note Detection Benchmark',
|
'album': 'Note Detection Benchmark',
|
||||||
'year': 2026,
|
'year': 2026,
|
||||||
'duration': round(end_t, 3),
|
'duration': round(end_t, 3),
|
||||||
@@ -430,7 +430,7 @@ def build(out_dir: Path):
|
|||||||
# Non-standard key — picked up by future tooling that wants to
|
# Non-standard key — picked up by future tooling that wants to
|
||||||
# detect "this is the benchmark, schema v1". The loader ignores it.
|
# detect "this is the benchmark, schema v1". The loader ignores it.
|
||||||
'benchmark': {
|
'benchmark': {
|
||||||
'id': 'slopsmith-note-detect-benchmark',
|
'id': 'feedBack-note-detect-benchmark',
|
||||||
'version': 1,
|
'version': 1,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -545,9 +545,9 @@ def _build_zip(src_dir: Path):
|
|||||||
|
|
||||||
|
|
||||||
def _benchmark_readme(duration_s):
|
def _benchmark_readme(duration_s):
|
||||||
return f"""# Slopsmith Note Detect Benchmark — v1
|
return f"""# FeedBack Note Detect Benchmark — v1
|
||||||
|
|
||||||
A short test piece for tuning Slopsmith's `note_detect` plugin. Eight
|
A short test piece for tuning FeedBack's `note_detect` plugin. Eight
|
||||||
exercises, each isolating a specific detection failure mode. Run with
|
exercises, each isolating a specific detection failure mode. Run with
|
||||||
**Detect** enabled, play through, then export the diagnostic JSON
|
**Detect** enabled, play through, then export the diagnostic JSON
|
||||||
(Settings → Plugins → Note Detection → Download Diagnostic JSON, or
|
(Settings → Plugins → Note Detection → Download Diagnostic JSON, or
|
||||||
@@ -590,7 +590,7 @@ Share the JSON (schema `note_detect.diagnostic.v1`). It includes:
|
|||||||
## Source
|
## Source
|
||||||
|
|
||||||
Built by `docs/benchmarks/note_detect_v1/build_benchmark.py` in the
|
Built by `docs/benchmarks/note_detect_v1/build_benchmark.py` in the
|
||||||
slopsmith repo. Tweak the exercise list there and regenerate.
|
feedBack repo. Tweak the exercise list there and regenerate.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Slopsmith Note Detect Benchmark — v2
|
# FeedBack Note Detect Benchmark — v2
|
||||||
|
|
||||||
A slower-paced companion to v1, focused on what players can actually
|
A slower-paced companion to v1, focused on what players can actually
|
||||||
land cleanly. Half-note spacing throughout (~1.33 s between events at
|
land cleanly. Half-note spacing throughout (~1.33 s between events at
|
||||||
|
|||||||
@@ -16,11 +16,11 @@ Goals vs v1:
|
|||||||
technique handling is the next algorithm focus, separate from
|
technique handling is the next algorithm focus, separate from
|
||||||
measuring "do basic single notes + chords score correctly?"
|
measuring "do basic single notes + chords score correctly?"
|
||||||
|
|
||||||
How to run inside the slopsmith container:
|
How to run inside the feedBack container:
|
||||||
|
|
||||||
docker cp docs/benchmarks/note_detect_v2/build_benchmark.py \\
|
docker cp docs/benchmarks/note_detect_v2/build_benchmark.py \\
|
||||||
slopsmith-web-1:/tmp/build_benchmark_v2.py
|
feedBack-web-1:/tmp/build_benchmark_v2.py
|
||||||
docker exec slopsmith-web-1 python /tmp/build_benchmark_v2.py \\
|
docker exec feedBack-web-1 python /tmp/build_benchmark_v2.py \\
|
||||||
/app/static/sloppak_cache/note_detect_benchmark_v2.sloppak
|
/app/static/sloppak_cache/note_detect_benchmark_v2.sloppak
|
||||||
|
|
||||||
After regenerating, copy the zip output to the tracked path with the
|
After regenerating, copy the zip output to the tracked path with the
|
||||||
@@ -375,7 +375,7 @@ def build(out_dir: Path):
|
|||||||
|
|
||||||
manifest = {
|
manifest = {
|
||||||
'title': 'Note Detect Benchmark v2',
|
'title': 'Note Detect Benchmark v2',
|
||||||
'artist': 'Slopsmith',
|
'artist': 'FeedBack',
|
||||||
'album': 'Note Detection Benchmark',
|
'album': 'Note Detection Benchmark',
|
||||||
'year': 2026,
|
'year': 2026,
|
||||||
'duration': round(end_t, 3),
|
'duration': round(end_t, 3),
|
||||||
@@ -392,7 +392,7 @@ def build(out_dir: Path):
|
|||||||
{'id': 'full', 'file': 'stems/full.ogg', 'default': True},
|
{'id': 'full', 'file': 'stems/full.ogg', 'default': True},
|
||||||
],
|
],
|
||||||
'benchmark': {
|
'benchmark': {
|
||||||
'id': 'slopsmith-note-detect-benchmark',
|
'id': 'feedBack-note-detect-benchmark',
|
||||||
'version': 2,
|
'version': 2,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -466,7 +466,7 @@ def _build_zip(src_dir: Path):
|
|||||||
|
|
||||||
|
|
||||||
def _benchmark_readme(duration_s):
|
def _benchmark_readme(duration_s):
|
||||||
return f"""# Slopsmith Note Detect Benchmark — v2
|
return f"""# FeedBack Note Detect Benchmark — v2
|
||||||
|
|
||||||
A slower-paced companion to v1, focused on what players can actually
|
A slower-paced companion to v1, focused on what players can actually
|
||||||
land cleanly. Half-note spacing throughout (~1.33 s between events at
|
land cleanly. Half-note spacing throughout (~1.33 s between events at
|
||||||
|
|||||||
+24
-24
@@ -1,6 +1,6 @@
|
|||||||
# Capability Domains
|
# Capability Domains
|
||||||
|
|
||||||
Capability domains are Slopsmith-wide coordination surfaces for core, bundled first-party plugins, external plugins, and future adapters. Plugins declare the runtime surfaces they use in `plugin.json`; core declares and owns host workflows directly in the runtime. These declarations let diagnostics and support tools reason about behavior without relying on private globals.
|
Capability domains are FeedBack-wide coordination surfaces for core, bundled first-party plugins, external plugins, and future adapters. Plugins declare the runtime surfaces they use in `plugin.json`; core declares and owns host workflows directly in the runtime. These declarations let diagnostics and support tools reason about behavior without relying on private globals.
|
||||||
|
|
||||||
## Standards
|
## Standards
|
||||||
|
|
||||||
@@ -63,21 +63,21 @@ Route-only external plugins that participate in library workflows without regist
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The frontend exposes the current source list through `window.slopsmith.capabilities.command('library', 'list-providers')`. Public owner commands (`list-providers`, `refresh-providers`, `get-current`, `select-provider`, `sync-song`, `inspect`) are distinct from provider operations (`query-page`, `query-artists`, `query-stats`, `tuning-names`, `get-art`, `sync-song`). The app-owned handler delegates to the existing provider registry and source selector, so plugins should not scrape the `#lib-provider` dropdown.
|
The frontend exposes the current source list through `window.feedBack.capabilities.command('library', 'list-providers')`. Public owner commands (`list-providers`, `refresh-providers`, `get-current`, `select-provider`, `sync-song`, `inspect`) are distinct from provider operations (`query-page`, `query-artists`, `query-stats`, `tuning-names`, `get-art`, `sync-song`). The app-owned handler delegates to the existing provider registry and source selector, so plugins should not scrape the `#lib-provider` dropdown.
|
||||||
|
|
||||||
Capability declarations may include a short `description`. The bundled Capability Inspector shows that text on expanded domain owner cards; when it is omitted, the inspector falls back to a compact generated owner summary.
|
Capability declarations may include a short `description`. The bundled Capability Inspector shows that text on expanded domain owner cards; when it is omitted, the inspector falls back to a compact generated owner summary.
|
||||||
|
|
||||||
## Audio Graph/Session Domains
|
## Audio Graph/Session Domains
|
||||||
|
|
||||||
The audio graph/session slice promotes four player-audio domains into the runtime graph: `audio-mix`, `audio-input`, `audio-monitoring`, and `stems`. The browser module at [static/capabilities/audio-session.js](../static/capabilities/audio-session.js) owns the active session boundary, contributes diagnostics under `slopsmith.audio_session.diagnostics.v1`, and records compatibility bridge hits for legacy audio surfaces.
|
The audio graph/session slice promotes four player-audio domains into the runtime graph: `audio-mix`, `audio-input`, `audio-monitoring`, and `stems`. The browser module at [static/capabilities/audio-session.js](../static/capabilities/audio-session.js) owns the active session boundary, contributes diagnostics under `feedBack.audio_session.diagnostics.v1`, and records compatibility bridge hits for legacy audio surfaces.
|
||||||
|
|
||||||
`audio-mix`, `audio-input`, and `audio-monitoring` are core-owned provider-coordinator domains. They expose bounded inspect/register/start/stop style commands, redaction-safe diagnostics, and bridge accounting for legacy fader, analyser, input, and monitoring handshakes.
|
`audio-mix`, `audio-input`, and `audio-monitoring` are core-owned provider-coordinator domains. They expose bounded inspect/register/start/stop style commands, redaction-safe diagnostics, and bridge accounting for legacy fader, analyser, input, and monitoring handshakes.
|
||||||
|
|
||||||
For `audio-mix`, native fader providers register mix participants with stable `participantId`, `kind`, `sourceMode`, optional `logicalFaderKey`, and `fader` metadata. The public command surface is `inspect`, `list-faders`, `get-fader-value`, `set-fader-value`, `inspect-route`, `inspect-analyser`, `register-participant`, and `unregister-participant`; provider operations are `fader.get-value`, `fader.set-value`, `route.get-current`, and `analyser.get-summary`. Providers own persistence for plugin faders and must return committed values from set operations so the player mixer can display the value that actually applied.
|
For `audio-mix`, native fader providers register mix participants with stable `participantId`, `kind`, `sourceMode`, optional `logicalFaderKey`, and `fader` metadata. The public command surface is `inspect`, `list-faders`, `get-fader-value`, `set-fader-value`, `inspect-route`, `inspect-analyser`, `register-participant`, and `unregister-participant`; provider operations are `fader.get-value`, `fader.set-value`, `route.get-current`, and `analyser.get-summary`. Providers own persistence for plugin faders and must return committed values from set operations so the player mixer can display the value that actually applied.
|
||||||
|
|
||||||
Legacy `window.slopsmith.audio.registerFader(...)` remains supported as an audio-mix compatibility bridge. The bridge registers a compatibility-backed participant, wraps legacy `getValue`/`setValue` callbacks as provider operations, and preserves `window.slopsmith.audio.getFaders()` for external callers. If a native participant and a legacy fader share the same logical fader key, the native participant owns the visible control; the legacy participant is retained for diagnostics with `supersededBy` and an `overshadowed` bridge hit. Removal gates for the bridge are: native providers cover bundled mixer integrations, diagnostics show no unexpected legacy hits in normal playback, and repeated plugin hydration does not create duplicate faders.
|
Legacy `window.feedBack.audio.registerFader(...)` remains supported as an audio-mix compatibility bridge. The bridge registers a compatibility-backed participant, wraps legacy `getValue`/`setValue` callbacks as provider operations, and preserves `window.feedBack.audio.getFaders()` for external callers. If a native participant and a legacy fader share the same logical fader key, the native participant owns the visible control; the legacy participant is retained for diagnostics with `supersededBy` and an `overshadowed` bridge hit. Removal gates for the bridge are: native providers cover bundled mixer integrations, diagnostics show no unexpected legacy hits in normal playback, and repeated plugin hydration does not create duplicate faders.
|
||||||
|
|
||||||
Audio-mix diagnostics live under `slopsmith.audio_session.diagnostics.v1`. The `audio-mix` domain snapshot includes session state, participants, visible fader summaries, required participant-kind coverage, route summary, analyser summary, bridge hits, and bounded recent outcomes. Fader outcomes include operation name, participant id, fader id, status such as `committed`, `normalized`, `unavailable`, or `timeout`, and a bounded reason. Diagnostics must not include raw audio buffers, FFT arrays, device labels, stable hardware identifiers, secrets, or unredacted local paths; route/analyser payloads are summaries only.
|
Audio-mix diagnostics live under `feedBack.audio_session.diagnostics.v1`. The `audio-mix` domain snapshot includes session state, participants, visible fader summaries, required participant-kind coverage, route summary, analyser summary, bridge hits, and bounded recent outcomes. Fader outcomes include operation name, participant id, fader id, status such as `committed`, `normalized`, `unavailable`, or `timeout`, and a bounded reason. Diagnostics must not include raw audio buffers, FFT arrays, device labels, stable hardware identifiers, secrets, or unredacted local paths; route/analyser payloads are summaries only.
|
||||||
|
|
||||||
For `audio-input`, native providers register source summaries with `sourceId`, `providerId`, `logicalSourceKey`, `kind`, redaction-safe label/pseudonym, `availability`, `channelSummary`, `sourceMode`, and provider operations. The public command surface is `inspect`, `list-sources`, `register-source`, `unregister-source`, `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 open live input or call enumeration. `source.enumerate` runs only when explicitly requested by provider/user discovery. `open-source` is the permission boundary: it routes to `source.open`, attributes the requester, checks the selected source and requested channel shape, and records `handled`, `denied`, `degraded`, `failed`, `no-owner`, `no-handler`, `unsupported-command`, or `incompatible-version` outcomes.
|
For `audio-input`, native providers register source summaries with `sourceId`, `providerId`, `logicalSourceKey`, `kind`, redaction-safe label/pseudonym, `availability`, `channelSummary`, `sourceMode`, and provider operations. The public command surface is `inspect`, `list-sources`, `register-source`, `unregister-source`, `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 open live input or call enumeration. `source.enumerate` runs only when explicitly requested by provider/user discovery. `open-source` is the permission boundary: it routes to `source.open`, attributes the requester, checks the selected source and requested channel shape, and records `handled`, `denied`, `degraded`, `failed`, `no-owner`, `no-handler`, `unsupported-command`, or `incompatible-version` outcomes.
|
||||||
|
|
||||||
@@ -109,9 +109,9 @@ Core also owns the durable public mapping index at `/api/audio-effects/mappings`
|
|||||||
|
|
||||||
Providers register stable `providerId`, `pluginId`, `routeKey`, priority, availability, source mode, operations, and operation handlers. Executors register stable `executorId`, `pluginId`, `routeKey`, priority, availability, source mode, supported provider ids, supported stage kinds, optional maximum stage count, operations, and handlers. The host chooses the highest-priority enabled provider for a route unless the caller requests a specific provider, then chooses the highest-priority compatible executor for that provider and resolved plan. Compatibility means both provider-compatible and plan-compatible: a browser/WASM NAM executor can advertise `providerIds: ["nam-tone"]`, `supportedKinds: ["nam", "ir"]`, and `maxStages: 2`, so it will not be asked to execute a Rig Builder VST/full-chain plan. If a selected provider has no compatible executor and the caller supplies a fallback provider, the host may fall back to that provider; if the caller explicitly requested the original provider, the host reports `unavailable` instead of silently changing providers. The initial default route is `desktop-main`, matching the desktop native executor path planned for full-chain NAM/IR/VST playback while still allowing browser executors for non-Desktop runtimes.
|
Providers register stable `providerId`, `pluginId`, `routeKey`, priority, availability, source mode, operations, and operation handlers. Executors register stable `executorId`, `pluginId`, `routeKey`, priority, availability, source mode, supported provider ids, supported stage kinds, optional maximum stage count, operations, and handlers. The host chooses the highest-priority enabled provider for a route unless the caller requests a specific provider, then chooses the highest-priority compatible executor for that provider and resolved plan. Compatibility means both provider-compatible and plan-compatible: a browser/WASM NAM executor can advertise `providerIds: ["nam-tone"]`, `supportedKinds: ["nam", "ir"]`, and `maxStages: 2`, so it will not be asked to execute a Rig Builder VST/full-chain plan. If a selected provider has no compatible executor and the caller supplies a fallback provider, the host may fall back to that provider; if the caller explicitly requested the original provider, the host reports `unavailable` instead of silently changing providers. The initial default route is `desktop-main`, matching the desktop native executor path planned for full-chain NAM/IR/VST playback while still allowing browser executors for non-Desktop runtimes.
|
||||||
|
|
||||||
`chain.resolve` returns schema `slopsmith.audio_effects.chain_plan.v1`. A valid plan includes `planId`, `routeKey`, `providerId`, `stages`, optional `segments`, and optional redaction-safe summaries. Each stage exposes only stable opaque `stageId`, `kind` (`nam`, `ir`, `vst`, `utility`, or `bypass`), `role` (`pre-pedal`, `amp`, `cab`, `rack`, `master-pre`, etc.), opaque `assetRef`, optional opaque `stateRef`, bypass state, gain summary, and safe summary metadata. Raw file paths, URLs, model filenames, IR filenames, VST state blobs, native preset JSON, callbacks, handles, DOM nodes, audio buffers, samples, and waveform data are rejected or omitted.
|
`chain.resolve` returns schema `feedBack.audio_effects.chain_plan.v1`. A valid plan includes `planId`, `routeKey`, `providerId`, `stages`, optional `segments`, and optional redaction-safe summaries. Each stage exposes only stable opaque `stageId`, `kind` (`nam`, `ir`, `vst`, `utility`, or `bypass`), `role` (`pre-pedal`, `amp`, `cab`, `rack`, `master-pre`, etc.), opaque `assetRef`, optional opaque `stateRef`, bypass state, gain summary, and safe summary metadata. Raw file paths, URLs, model filenames, IR filenames, VST state blobs, native preset JSON, callbacks, handles, DOM nodes, audio buffers, samples, and waveform data are rejected or omitted.
|
||||||
|
|
||||||
Diagnostics live under `slopsmith.audio_effects.diagnostics.v1`. The snapshot includes provider summaries, executor summaries, route summaries, bridge hits, bounded recent outcomes, limits, and redaction notes. It intentionally omits full chain plans, stage asset references, provider-private mapping payloads, raw filenames, and song keys; diagnostics should explain which provider/executor/route failed without leaking local library structure or licensed asset names. Legacy NAM Tone/Rig Builder fetch interception, direct Desktop `loadPreset` calls, legacy tone controls, old `nam_tone.db` `tone_mappings` access, and MIDI/external effect handoffs are attributed through `audio-effects.legacy-nam-routing`, `audio-effects.legacy-native-load`, `audio-effects.legacy-tone-controls`, `audio-effects.legacy-tone-db`, and `audio-effects.legacy-midi-amp` bridge records while providers migrate.
|
Diagnostics live under `feedBack.audio_effects.diagnostics.v1`. The snapshot includes provider summaries, executor summaries, route summaries, bridge hits, bounded recent outcomes, limits, and redaction notes. It intentionally omits full chain plans, stage asset references, provider-private mapping payloads, raw filenames, and song keys; diagnostics should explain which provider/executor/route failed without leaking local library structure or licensed asset names. Legacy NAM Tone/Rig Builder fetch interception, direct Desktop `loadPreset` calls, legacy tone controls, old `nam_tone.db` `tone_mappings` access, and MIDI/external effect handoffs are attributed through `audio-effects.legacy-nam-routing`, `audio-effects.legacy-native-load`, `audio-effects.legacy-tone-controls`, `audio-effects.legacy-tone-db`, and `audio-effects.legacy-midi-amp` bridge records while providers migrate.
|
||||||
|
|
||||||
## Playback Control Plane
|
## Playback Control Plane
|
||||||
|
|
||||||
@@ -119,7 +119,7 @@ The playback slice promotes `playback` as a core-owned command domain implemente
|
|||||||
|
|
||||||
`static/app.js` remains the transport data plane. It registers a private playback adapter that can start songs, pause/resume/stop, seek, and manage loops, but the capability snapshot never exposes the `<audio>` element, JUCE player object, raw audio buffers, native route handles, samples, waveforms, recordings, local file paths, or URL payloads. Exported diagnostics use pseudonymous `target-*` ids for arrangement-scoped identity and hashed `settings-*` keys for per-song plugin settings; the local Capability Inspector may show visible title, artist, and arrangement labels for the active song.
|
`static/app.js` remains the transport data plane. It registers a private playback adapter that can start songs, pause/resume/stop, seek, and manage loops, but the capability snapshot never exposes the `<audio>` element, JUCE player object, raw audio buffers, native route handles, samples, waveforms, recordings, local file paths, or URL payloads. Exported diagnostics use pseudonymous `target-*` ids for arrangement-scoped identity and hashed `settings-*` keys for per-song plugin settings; the local Capability Inspector may show visible title, artist, and arrangement labels for the active song.
|
||||||
|
|
||||||
Legacy playback surfaces remain supported during migration and are attributed through bridges such as `playback.window-play-song`, `playback.song-events`, `playback.window-slopsmith-transport`, `playback.loop-api`, and native route handoff records. Fresh audible `start` commands require `authorization: "user-action"`; background requesters may inspect or control an existing session, but user-priority pause/stop decisions block lower-priority automation until a user action resumes or starts a new session.
|
Legacy playback surfaces remain supported during migration and are attributed through bridges such as `playback.window-play-song`, `playback.song-events`, `playback.window-feedBack-transport`, `playback.loop-api`, and native route handoff records. Fresh audible `start` commands require `authorization: "user-action"`; background requesters may inspect or control an existing session, but user-priority pause/stop decisions block lower-priority automation until a user action resumes or starts a new session.
|
||||||
|
|
||||||
## Progression Domain
|
## Progression Domain
|
||||||
|
|
||||||
@@ -127,7 +127,7 @@ The progression slice (spec 010) promotes `progression` as a core-owned command
|
|||||||
|
|
||||||
The public command surface is `inspect`, `record-event`, `list-shop`, `buy-item`, and `equip-item`. `record-event` accepts only whitelisted externally-postable event types (`minigame_run` in v1); `song_completed` is server-derived inside `/api/stats` so scored-session authority stays in one place and is denied at this surface. `buy-item` and `equip-item` require `authorization: "user-action"`. Backend plugins use the symmetric plugin-context hook `record_progression_event` (the minigames hub reports runs through it), which trusts backend code and skips the HTTP whitelist.
|
The public command surface is `inspect`, `record-event`, `list-shop`, `buy-item`, and `equip-item`. `record-event` accepts only whitelisted externally-postable event types (`minigame_run` in v1); `song_completed` is server-derived inside `/api/stats` so scored-session authority stays in one place and is denied at this surface. `buy-item` and `equip-item` require `authorization: "user-action"`. Backend plugins use the symmetric plugin-context hook `record_progression_event` (the minigames hub reports runs through it), which trusts backend code and skips the HTTP whitelist.
|
||||||
|
|
||||||
The domain emits `challenge-completed`, `quest-completed`, `path-level-up`, `rank-changed`, `db-changed`, `calibration-completed`, and `cosmetic-equipped` on the capability surface, mirrored as `progression:*` events on `window.slopsmith` for non-capability consumers. Diagnostics live under `slopsmith.progression.diag.v1` and contain content-load warnings, rank/path-level/quest counts, and wallet totals only — no song filenames or display names.
|
The domain emits `challenge-completed`, `quest-completed`, `path-level-up`, `rank-changed`, `db-changed`, `calibration-completed`, and `cosmetic-equipped` on the capability surface, mirrored as `progression:*` events on `window.feedBack` for non-capability consumers. Diagnostics live under `feedBack.progression.diag.v1` and contain content-load warnings, rank/path-level/quest counts, and wallet totals only — no song filenames or display names.
|
||||||
|
|
||||||
Decibels are earned exclusively by playing (songs, minigame runs, quest rewards); there is no real-money acquisition path and none may be added. The wallet tracks spend separately from the monotonic lifetime-earned total, so per-source XP resets and `db_earned` goals stay correct. A deferred release slice adds a `contributor` role so plugins can ship their own challenge/quest content (e.g. a drums plugin contributing drums challenges); content stays core-bundled until then.
|
Decibels are earned exclusively by playing (songs, minigame runs, quest rewards); there is no real-money acquisition path and none may be added. The wallet tracks spend separately from the monotonic lifetime-earned total, so per-source XP resets and `db_earned` goals stay correct. A deferred release slice adds a `contributor` role so plugins can ship their own challenge/quest content (e.g. a drums plugin contributing drums challenges); content stays core-bundled until then.
|
||||||
|
|
||||||
@@ -137,11 +137,11 @@ The visualization slice (cap:6) promotes `visualization` as a core-owned provide
|
|||||||
|
|
||||||
The public command surface is `inspect`, `list-providers`, `select-renderer`, and `clear-renderer`. Selection delegates to the existing picker (`setViz`) so localStorage persistence, WebGL2 gating, and fallback semantics have exactly one implementation. The domain emits `providers-refreshed`, `renderer-changed` (with a `source` of `auto-match`, `user-select`, `fallback`, or `command:<requester>`), `renderer-ready`, and `renderer-failed`.
|
The public command surface is `inspect`, `list-providers`, `select-renderer`, and `clear-renderer`. Selection delegates to the existing picker (`setViz`) so localStorage persistence, WebGL2 gating, and fallback semantics have exactly one implementation. The domain emits `providers-refreshed`, `renderer-changed` (with a `source` of `auto-match`, `user-select`, `fallback`, or `command:<requester>`), `renderer-ready`, and `renderer-failed`.
|
||||||
|
|
||||||
Provider discovery is still the legacy surface — `type: "visualization"` manifests populate the picker and `window.slopsmithViz_*` factory globals carry the renderer contract — and both are registered as compatibility shims (`visualization:type-visualization-manifest`, `visualization:window.slopsmithViz_*`) with hit accounting, so the Inspector shows exactly how much of the domain still rides the bridge. Plugins migrate by declaring a `visualization` provider capability in their manifests; the renderer factory contract (`init`/`draw`/`resize`/`destroy`, `contextType`, `matchesArrangement`) is unchanged.
|
Provider discovery is still the legacy surface — `type: "visualization"` manifests populate the picker and `window.feedBackViz_*` factory globals carry the renderer contract — and both are registered as compatibility shims (`visualization:type-visualization-manifest`, `visualization:window.feedBackViz_*`) with hit accounting, so the Inspector shows exactly how much of the domain still rides the bridge. Plugins migrate by declaring a `visualization` provider capability in their manifests; the renderer factory contract (`init`/`draw`/`resize`/`destroy`, `contextType`, `matchesArrangement`) is unchanged.
|
||||||
|
|
||||||
**Per-instance provider settings (#849).** A provider may declare a `settings` array on its `visualization` capability — generic control descriptors (`{ key, label, type: "toggle" | "range" | "select", default, min/max/step, options }`) the capability-pipelines schema validates on *any* domain (the field lives on the shared `capabilityDeclaration`, not a visualization-only spot — see `docs/plugin-manifest.schema.json`). Descriptors flow through the **generic participant model**: the backend validates them for `/api/plugins` (`plugins/__init__.py`), `static/capabilities.js` normalizes + preserves them on the registered participant (so generic `inspect('visualization')` carries them), and the visualization owner reads them back from the participant by id — no app.js/picker side channel. They surface in the `list-providers` snapshot (each provider's `settings`, deep-frozen) plus a `provider_policy.hasSettings` flag in diagnostics, so a consuming host — splitscreen's per-panel control popover — can render the controls generically without per-plugin hardcoding. The visualization domain's **apply contract**: a provider that declares `settings` MUST implement `applySetting(key, value)` on its renderer instance (the host calls it on the specific per-panel instance, which is inherently per-panel — no canvas→panel resolution, no shared global keys); `getSetting(key)` is optional (the host falls back to the declared `default`). The host owns persistence. **This core slice lands the declarative surface + participant plumbing only** — no bundled provider declares `settings` yet (`highway_3d` still ships the legacy `factory.panelControls` static). The `highway_3d` migration and the splitscreen generic consumer are the remaining #849 follow-ups.
|
**Per-instance provider settings (#849).** A provider may declare a `settings` array on its `visualization` capability — generic control descriptors (`{ key, label, type: "toggle" | "range" | "select", default, min/max/step, options }`) the capability-pipelines schema validates on *any* domain (the field lives on the shared `capabilityDeclaration`, not a visualization-only spot — see `docs/plugin-manifest.schema.json`). Descriptors flow through the **generic participant model**: the backend validates them for `/api/plugins` (`plugins/__init__.py`), `static/capabilities.js` normalizes + preserves them on the registered participant (so generic `inspect('visualization')` carries them), and the visualization owner reads them back from the participant by id — no app.js/picker side channel. They surface in the `list-providers` snapshot (each provider's `settings`, deep-frozen) plus a `provider_policy.hasSettings` flag in diagnostics, so a consuming host — splitscreen's per-panel control popover — can render the controls generically without per-plugin hardcoding. The visualization domain's **apply contract**: a provider that declares `settings` MUST implement `applySetting(key, value)` on its renderer instance (the host calls it on the specific per-panel instance, which is inherently per-panel — no canvas→panel resolution, no shared global keys); `getSetting(key)` is optional (the host falls back to the declared `default`). The host owns persistence. **This core slice lands the declarative surface + participant plumbing only** — no bundled provider declares `settings` yet (`highway_3d` still ships the legacy `factory.panelControls` static). The `highway_3d` migration and the splitscreen generic consumer are the remaining #849 follow-ups.
|
||||||
|
|
||||||
Diagnostics live under `slopsmith.visualization_capability.v1` and contain provider ids/labels/context types, the active renderer id and its selection source, the last auto-match outcome (resolved id + whether any predicate claimed the song), and the last failure (provider id + reason) — never song filenames, titles, or arrangement names. Per-panel selection (splitscreen #90) and per-panel provider settings (#849) are tracked follow-ups; the domain currently models the primary highway surface.
|
Diagnostics live under `feedBack.visualization_capability.v1` and contain provider ids/labels/context types, the active renderer id and its selection source, the last auto-match outcome (resolved id + whether any predicate claimed the song), and the last failure (provider id + reason) — never song filenames, titles, or arrangement names. Per-panel selection (splitscreen #90) and per-panel provider settings (#849) are tracked follow-ups; the domain currently models the primary highway surface.
|
||||||
|
|
||||||
## Note-Detection Domain
|
## Note-Detection Domain
|
||||||
|
|
||||||
@@ -151,7 +151,7 @@ The public command surface is `inspect`, `register-provider`, `unregister-provid
|
|||||||
|
|
||||||
The legacy chart-coupled surface — `highway.setNoteStateProvider(fn)`, the single-global-detector path spec 009 retires — keeps working unchanged and is wrapped for compatibility-shim hit accounting (`note-detection:highway.setNoteStateProvider`). Migrating the chart `note_detect` consumer, Step Mode verify, minigames YIN scoring, and the `setVerifyTarget` bridge onto real bindings — and wiring per-binding tuning contexts into the engine verifier — is the remainder of the spec-009 slice and lands behind the Spec 003 migration gate.
|
The legacy chart-coupled surface — `highway.setNoteStateProvider(fn)`, the single-global-detector path spec 009 retires — keeps working unchanged and is wrapped for compatibility-shim hit accounting (`note-detection:highway.setNoteStateProvider`). Migrating the chart `note_detect` consumer, Step Mode verify, minigames YIN scoring, and the `setVerifyTarget` bridge onto real bindings — and wiring per-binding tuning contexts into the engine verifier — is the remainder of the spec-009 slice and lands behind the Spec 003 migration gate.
|
||||||
|
|
||||||
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.
|
Diagnostics live under `feedBack.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
|
## MIDI-Input Domain
|
||||||
|
|
||||||
@@ -159,11 +159,11 @@ The MIDI-input slice (spec 012, issues #873/#880) promotes `midi-input` as a **c
|
|||||||
|
|
||||||
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.
|
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.
|
Selected input is persisted by `logicalSourceKey` (`feedBack.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.feedBack.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.
|
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.
|
Diagnostics live under `feedBack.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
|
## Capability Roles
|
||||||
|
|
||||||
@@ -185,11 +185,11 @@ Use capability declarations for provider/requester/observer relationships:
|
|||||||
|
|
||||||
Future app-level workflows can then express intent through capability domains instead of hard-coding plugin-private implementation details.
|
Future app-level workflows can then express intent through capability domains instead of hard-coding plugin-private implementation details.
|
||||||
|
|
||||||
Core registers manifest capability declarations from `/api/plugins` before plugin scripts hydrate. Runtime owners can then re-register the same participant with command handlers, event handlers, and current availability state. The merged participant view is visible through `window.slopsmith.capabilities.snapshotDiagnostics()` and `getDiagnostics()`.
|
Core registers manifest capability declarations from `/api/plugins` before plugin scripts hydrate. Runtime owners can then re-register the same participant with command handlers, event handlers, and current availability state. The merged participant view is visible through `window.feedBack.capabilities.snapshotDiagnostics()` and `getDiagnostics()`.
|
||||||
|
|
||||||
Core domains include review metadata in diagnostics:
|
Core domains include review metadata in diagnostics:
|
||||||
|
|
||||||
- `active`: wired to current Slopsmith behavior and expected to work as an integration point.
|
- `active`: wired to current FeedBack behavior and expected to work as an integration point.
|
||||||
- `diagnostic`: support/inspection-only runtime surfaces.
|
- `diagnostic`: support/inspection-only runtime surfaces.
|
||||||
|
|
||||||
PR1 includes only the delivered domains listed in [capability-roadmap.md](capability-roadmap.md): `pipeline`, `diagnostics`, and `library`. The follow-up audio graph/session slice promotes `audio-mix`, `audio-input`, `audio-monitoring`, and a coordinated `stems` surface. The playback slice promotes `playback` as an active transport control plane. The audio-effects slice promotes provider-selected effect-chain planning while leaving physical processor loading to compatible executors such as trusted Desktop native audio or a browser/WASM executor. The visualization slice promotes `visualization` as the highway renderer provider-coordinator, and the note-detection slice (spec 009) promotes `note-detection` as the detection-binding control plane. Backend routes, app UI, settings, and other hardware-facing domains remain documented in the roadmap and safety matrix until their own host workflow/provider slice exists.
|
PR1 includes only the delivered domains listed in [capability-roadmap.md](capability-roadmap.md): `pipeline`, `diagnostics`, and `library`. The follow-up audio graph/session slice promotes `audio-mix`, `audio-input`, `audio-monitoring`, and a coordinated `stems` surface. The playback slice promotes `playback` as an active transport control plane. The audio-effects slice promotes provider-selected effect-chain planning while leaving physical processor loading to compatible executors such as trusted Desktop native audio or a browser/WASM executor. The visualization slice promotes `visualization` as the highway renderer provider-coordinator, and the note-detection slice (spec 009) promotes `note-detection` as the detection-binding control plane. Backend routes, app UI, settings, and other hardware-facing domains remain documented in the roadmap and safety matrix until their own host workflow/provider slice exists.
|
||||||
@@ -201,7 +201,7 @@ Capability metadata is versioned by the `capability-pipelines.v1` standard. Inva
|
|||||||
Requesters should use the public claim/dispatch/release flow instead of mutating another plugin's globals:
|
Requesters should use the public claim/dispatch/release flow instead of mutating another plugin's globals:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
const api = window.slopsmith.capabilities;
|
const api = window.feedBack.capabilities;
|
||||||
const releaseClaim = api.claim({ capability: 'example.plugin-domain', claimId: 'example.automation-active', requester: 'example_requester' });
|
const releaseClaim = api.claim({ capability: 'example.plugin-domain', claimId: 'example.automation-active', requester: 'example_requester' });
|
||||||
await api.dispatch({
|
await api.dispatch({
|
||||||
capability: 'example.plugin-domain',
|
capability: 'example.plugin-domain',
|
||||||
@@ -244,9 +244,9 @@ Dispatch results use explicit outcomes: `handled`, `transformed`, `denied`, `fai
|
|||||||
|
|
||||||
## Deferred Core Adapters
|
## Deferred Core Adapters
|
||||||
|
|
||||||
UI placement and settings contributions are real Slopsmith surfaces, but they are not PR1 capability contracts (visualization is active as of the cap:6 slice; note-detection as of the spec-009 slice). Audio mixer/session domains are active as of the audio graph/session slice, playback is active as of the playback control-plane slice, and audio-effects is active as a provider/route/chain-plan coordinator; plugins should keep using current documented APIs for remaining areas until the corresponding domain PR ships the host workflow, command/event contract, compatibility shims, diagnostics fields, and tests.
|
UI placement and settings contributions are real FeedBack surfaces, but they are not PR1 capability contracts (visualization is active as of the cap:6 slice; note-detection as of the spec-009 slice). Audio mixer/session domains are active as of the audio graph/session slice, playback is active as of the playback control-plane slice, and audio-effects is active as a provider/route/chain-plan coordinator; plugins should keep using current documented APIs for remaining areas until the corresponding domain PR ships the host workflow, command/event contract, compatibility shims, diagnostics fields, and tests.
|
||||||
|
|
||||||
The library provider workflow is the PR1 core adapter and is implemented natively as the `library` capability module. Provider refresh, selection, and sync run through `library` owner commands; backend provider registration remains the way providers enter the library registry, and the browser module turns that registry into provider participants. The app event bus continues to dispatch local `window.slopsmith` events for legacy listeners; playback now mirrors song transport, route, seek, and loop lifecycle into `playback`, and visualization attributes renderer selection/failure, while navigation, note, and route-only surfaces remain outside capability domains until their own slices land.
|
The library provider workflow is the PR1 core adapter and is implemented natively as the `library` capability module. Provider refresh, selection, and sync run through `library` owner commands; backend provider registration remains the way providers enter the library registry, and the browser module turns that registry into provider participants. The app event bus continues to dispatch local `window.feedBack` events for legacy listeners; playback now mirrors song transport, route, seek, and loop lifecycle into `playback`, and visualization attributes renderer selection/failure, while navigation, note, and route-only surfaces remain outside capability domains until their own slices land.
|
||||||
|
|
||||||
The direct `window.highway` object remains the renderer data plane. Per-frame reads such as notes, chords, beats, and renderer hooks should not be moved behind asynchronous capability commands until there is a dedicated chart/render facade.
|
The direct `window.highway` object remains the renderer data plane. Per-frame reads such as notes, chords, beats, and renderer hooks should not be moved behind asynchronous capability commands until there is a dedicated chart/render facade.
|
||||||
|
|
||||||
@@ -254,11 +254,11 @@ The direct `window.highway` object remains the renderer data plane. Per-frame re
|
|||||||
|
|
||||||
Large management surfaces should prefer plugin-owned UI over crowding normal Settings. First-party management plugins can contribute screens and settings panels while core keeps shared services and diagnostics contracts centralized.
|
Large management surfaces should prefer plugin-owned UI over crowding normal Settings. First-party management plugins can contribute screens and settings panels while core keeps shared services and diagnostics contracts centralized.
|
||||||
|
|
||||||
The bundled Capability Inspector plugin is the support surface for the current graph. It reads `window.slopsmith.capabilities.snapshotDiagnostics()`, filters by domain, and summarizes manifest participants, runtime participants, conflicts, unsupported versions, safety classes, expected legacy event surfaces, and compatibility shim hits without rendering raw runtime objects. Domains are grouped in review order: application/library, player/audio runtime, plugin-defined surfaces, then capability runtime. In the all-domains view, each domain starts collapsed with a domain-specific icon plus compact summary badges for participant-lane count, endpoint count, observed links, shimmed links, and status; badge labels live in tooltips/ARIA labels so the header stays scannable. Clicking the domain label expands or collapses the domain, opening the same graph view used by the single-domain filter. The graph places owner details and right-aligned command/event groups on the left, with short owner descriptions bottom-aligned as the final part of that pane. Participant usage is grouped the same way on the right, with observed or shimmed links between border-aligned endpoint ports. In multi-provider domains, links to provider participants use provider-family colors: purple for owner-to-provider command delegation and a lighter violet for provider events. Provider participants, including `library` sources, stay on the right lane and show a provider icon in their header. Headers show role-aware core/non-core origin badges such as Core owner, Core provider, or Non-core participant; owner headers place the origin badge directly after the owner icon, and the built-in local library provider is marked as core-origin. Observer and requester roles are implied by the command/event links rather than separate header badges. Participant cards are shown only when the plugin or runtime source has visible command or event usage for the current graph filter; domains with no such usage show zero participants, and attribution-only shims with no matching endpoint stay out of the lane. Command and event groups can collapse; when collapsed, all links for that side and group converge on the single group port. Hovering a participant, endpoint, or command/event group emphasizes the matching links and dims unrelated links; owner-side labels outside the current focus de-emphasize so the active source endpoints are easy to track. Expanded domain graphs progressively enhance to Cytoscape.js overlays that route bezier links between measured DOM endpoint ports, while keeping the HTML lanes as the fallback and readable data surface. Its Plugins-menu entry is hidden by default; enable **Capability Inspector → Show in Plugins menu** from Settings when reviewing or debugging capability behavior.
|
The bundled Capability Inspector plugin is the support surface for the current graph. It reads `window.feedBack.capabilities.snapshotDiagnostics()`, filters by domain, and summarizes manifest participants, runtime participants, conflicts, unsupported versions, safety classes, expected legacy event surfaces, and compatibility shim hits without rendering raw runtime objects. Domains are grouped in review order: application/library, player/audio runtime, plugin-defined surfaces, then capability runtime. In the all-domains view, each domain starts collapsed with a domain-specific icon plus compact summary badges for participant-lane count, endpoint count, observed links, shimmed links, and status; badge labels live in tooltips/ARIA labels so the header stays scannable. Clicking the domain label expands or collapses the domain, opening the same graph view used by the single-domain filter. The graph places owner details and right-aligned command/event groups on the left, with short owner descriptions bottom-aligned as the final part of that pane. Participant usage is grouped the same way on the right, with observed or shimmed links between border-aligned endpoint ports. In multi-provider domains, links to provider participants use provider-family colors: purple for owner-to-provider command delegation and a lighter violet for provider events. Provider participants, including `library` sources, stay on the right lane and show a provider icon in their header. Headers show role-aware core/non-core origin badges such as Core owner, Core provider, or Non-core participant; owner headers place the origin badge directly after the owner icon, and the built-in local library provider is marked as core-origin. Observer and requester roles are implied by the command/event links rather than separate header badges. Participant cards are shown only when the plugin or runtime source has visible command or event usage for the current graph filter; domains with no such usage show zero participants, and attribution-only shims with no matching endpoint stay out of the lane. Command and event groups can collapse; when collapsed, all links for that side and group converge on the single group port. Hovering a participant, endpoint, or command/event group emphasizes the matching links and dims unrelated links; owner-side labels outside the current focus de-emphasize so the active source endpoints are easy to track. Expanded domain graphs progressively enhance to Cytoscape.js overlays that route bezier links between measured DOM endpoint ports, while keeping the HTML lanes as the fallback and readable data surface. Its Plugins-menu entry is hidden by default; enable **Capability Inspector → Show in Plugins menu** from Settings when reviewing or debugging capability behavior.
|
||||||
|
|
||||||
## Diagnostics Contract
|
## Diagnostics Contract
|
||||||
|
|
||||||
Capability diagnostics use schema `slopsmith.capabilities.diagnostics.v1`. Snapshots are redaction-safe and capped at 64 KB by trimming older `recentDecisions` first while preserving current participants, active or orphaned claims, conflicts, domain review metadata, shim summaries, safety notes, and unsupported-version reports. Server diagnostics bundles include plugin manifest capability metadata, validation warnings, unsupported-version metadata, and compatibility shim summaries.
|
Capability diagnostics use schema `feedBack.capabilities.diagnostics.v1`. Snapshots are redaction-safe and capped at 64 KB by trimming older `recentDecisions` first while preserving current participants, active or orphaned claims, conflicts, domain review metadata, shim summaries, safety notes, and unsupported-version reports. Server diagnostics bundles include plugin manifest capability metadata, validation warnings, unsupported-version metadata, and compatibility shim summaries.
|
||||||
|
|
||||||
Compatibility shim entries include `shimId`, `source`, `capability`, `legacySurface`, `status`, `reason`, and optional hit fields. A shim with `hitCount > 0` means legacy behavior was observed, not merely declared. The `library` domain no longer uses compatibility shims for provider registration or source selection; provider attribution comes from `owner_plugin_id` and runtime provider participants. Future domains should add expected shim entries only in the PR that implements their actual legacy bridge.
|
Compatibility shim entries include `shimId`, `source`, `capability`, `legacySurface`, `status`, `reason`, and optional hit fields. A shim with `hitCount > 0` means legacy behavior was observed, not merely declared. The `library` domain no longer uses compatibility shims for provider registration or source selection; provider attribution comes from `owner_plugin_id` and runtime provider participants. Future domains should add expected shim entries only in the PR that implements their actual legacy bridge.
|
||||||
|
|
||||||
@@ -274,10 +274,10 @@ Future privileged domains must state user value, included and excluded commands,
|
|||||||
|
|
||||||
## Rehydration Pattern
|
## Rehydration Pattern
|
||||||
|
|
||||||
Plugins that wrap shared functions such as `window.playSong` or `window.showScreen` should store wrapper state on a stable `window.__slopsmith...Hooks` object. Re-running the script should replace the implementation object and return before installing another wrapper.
|
Plugins that wrap shared functions such as `window.playSong` or `window.showScreen` should store wrapper state on a stable `window.__feedBack...Hooks` object. Re-running the script should replace the implementation object and return before installing another wrapper.
|
||||||
|
|
||||||
```js
|
```js
|
||||||
const hookState = window.__slopsmithMyPluginHooks || (window.__slopsmithMyPluginHooks = {});
|
const hookState = window.__feedBackMyPluginHooks || (window.__feedBackMyPluginHooks = {});
|
||||||
hookState.impl = { afterPlaySong(filename) { /* current implementation */ } };
|
hookState.impl = { afterPlaySong(filename) { /* current implementation */ } };
|
||||||
if (hookState.installed) return;
|
if (hookState.installed) return;
|
||||||
hookState.installed = true;
|
hookState.installed = true;
|
||||||
@@ -290,7 +290,7 @@ window.playSong = async function(filename, arrangement) {
|
|||||||
|
|
||||||
## Validation Commands
|
## Validation Commands
|
||||||
|
|
||||||
From the `slopsmith/` directory:
|
From the `feedBack/` directory:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node --check static/app.js
|
node --check static/app.js
|
||||||
|
|||||||
+18
-18
@@ -1,6 +1,6 @@
|
|||||||
# Capability Authoring Recipes
|
# Capability Authoring Recipes
|
||||||
|
|
||||||
Use these examples as small manifest fragments when migrating plugin-facing integrations to capability pipelines. The capability model is system-wide; these recipes focus on plugin manifests because core-owned domains are registered by Slopsmith itself. Each example is intentionally complete enough to pass the loader contract in [plugin-manifest.schema.json](plugin-manifest.schema.json).
|
Use these examples as small manifest fragments when migrating plugin-facing integrations to capability pipelines. The capability model is system-wide; these recipes focus on plugin manifests because core-owned domains are registered by FeedBack itself. Each example is intentionally complete enough to pass the loader contract in [plugin-manifest.schema.json](plugin-manifest.schema.json).
|
||||||
|
|
||||||
> **Self-hosted CSS?** If your plugin uses Tailwind classes core doesn't ship (notably arbitrary values like `text-[11px]`), declare a `styles` key and bundle your own preflight-off stylesheet — see [plugin-styles.md](plugin-styles.md). That is separate from the capability-pipeline recipes below.
|
> **Self-hosted CSS?** If your plugin uses Tailwind classes core doesn't ship (notably arbitrary values like `text-[11px]`), declare a `styles` key and bundle your own preflight-off stylesheet — see [plugin-styles.md](plugin-styles.md). That is separate from the capability-pipeline recipes below.
|
||||||
|
|
||||||
@@ -124,7 +124,7 @@ A route-only wrapper that uses the library capability without registering a brow
|
|||||||
|
|
||||||
## Audio Mix Fader Provider
|
## Audio Mix Fader Provider
|
||||||
|
|
||||||
Existing plugins can keep using `window.slopsmith.audio.registerFader(spec)` while migrating. The compatibility bridge records the fader as an `audio-mix` participant. New bundled code should prefer a native participant declaration plus the audio-session helper once available in its integration point.
|
Existing plugins can keep using `window.feedBack.audio.registerFader(spec)` while migrating. The compatibility bridge records the fader as an `audio-mix` participant. New bundled code should prefer a native participant declaration plus the audio-session helper once available in its integration point.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -147,11 +147,11 @@ Existing plugins can keep using `window.slopsmith.audio.registerFader(spec)` whi
|
|||||||
|
|
||||||
Native audio-mix fader providers should register a stable participant id and fader id, return the committed value from every set operation, and settle get/set operations within two seconds. The player mixer displays the committed value rather than the raw requested value. If the fader is temporarily unavailable, keep the participant registered with unavailable/disabled state so the mixer can render a disabled control and diagnostics can explain why it cannot be changed.
|
Native audio-mix fader providers should register a stable participant id and fader id, return the committed value from every set operation, and settle get/set operations within two seconds. The player mixer displays the committed value rather than the raw requested value. If the fader is temporarily unavailable, keep the participant registered with unavailable/disabled state so the mixer can render a disabled control and diagnostics can explain why it cannot be changed.
|
||||||
|
|
||||||
During migration, a plugin may still call `window.slopsmith.audio.registerFader(spec)`. Core maps that legacy fader into a compatibility-backed audio-mix participant and records bridge hits. If a native participant and a legacy fader represent the same logical source, the native participant owns the visible control and the legacy path is reported as compatibility-backed/overshadowed.
|
During migration, a plugin may still call `window.feedBack.audio.registerFader(spec)`. Core maps that legacy fader into a compatibility-backed audio-mix participant and records bridge hits. If a native participant and a legacy fader represent the same logical source, the native participant owns the visible control and the legacy path is reported as compatibility-backed/overshadowed.
|
||||||
|
|
||||||
## Audio Effects Provider
|
## Audio Effects Provider
|
||||||
|
|
||||||
Plugins that can provide guitar/bass processing chains should declare `audio-effects` as a provider and register at runtime with `window.slopsmith.audioEffects.registerProvider(...)`. The provider returns opaque chain plans; it must not expose local filenames, URLs, native preset JSON, VST state blobs, or raw handles through diagnostics or public route state.
|
Plugins that can provide guitar/bass processing chains should declare `audio-effects` as a provider and register at runtime with `window.feedBack.audioEffects.registerProvider(...)`. The provider returns opaque chain plans; it must not expose local filenames, URLs, native preset JSON, VST state blobs, or raw handles through diagnostics or public route state.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -173,7 +173,7 @@ Plugins that can provide guitar/bass processing chains should declare `audio-eff
|
|||||||
```
|
```
|
||||||
|
|
||||||
```js
|
```js
|
||||||
const effects = window.slopsmith && window.slopsmith.audioEffects;
|
const effects = window.feedBack && window.feedBack.audioEffects;
|
||||||
effects.registerProvider({
|
effects.registerProvider({
|
||||||
providerId: 'rig-builder',
|
providerId: 'rig-builder',
|
||||||
pluginId: 'rig_builder',
|
pluginId: 'rig_builder',
|
||||||
@@ -184,7 +184,7 @@ effects.registerProvider({
|
|||||||
'chain.resolve': request => ({
|
'chain.resolve': request => ({
|
||||||
outcome: 'handled',
|
outcome: 'handled',
|
||||||
plan: {
|
plan: {
|
||||||
schema: 'slopsmith.audio_effects.chain_plan.v1',
|
schema: 'feedBack.audio_effects.chain_plan.v1',
|
||||||
planId: 'song-tone-plan',
|
planId: 'song-tone-plan',
|
||||||
routeKey: request.routeKey,
|
routeKey: request.routeKey,
|
||||||
providerId: 'rig-builder',
|
providerId: 'rig-builder',
|
||||||
@@ -203,14 +203,14 @@ effects.registerProvider({
|
|||||||
User-facing controls should dispatch through the domain instead of mutating another plugin's private state:
|
User-facing controls should dispatch through the domain instead of mutating another plugin's private state:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
await window.slopsmith.capabilities.dispatch({
|
await window.feedBack.capabilities.dispatch({
|
||||||
capability: 'audio-effects',
|
capability: 'audio-effects',
|
||||||
command: 'select-chain',
|
command: 'select-chain',
|
||||||
source: 'rig_builder',
|
source: 'rig_builder',
|
||||||
payload: { routeKey: 'desktop-main', providerId: 'rig-builder', authorization: 'user-action' }
|
payload: { routeKey: 'desktop-main', providerId: 'rig-builder', authorization: 'user-action' }
|
||||||
});
|
});
|
||||||
|
|
||||||
const resolved = await window.slopsmith.capabilities.dispatch({
|
const resolved = await window.feedBack.capabilities.dispatch({
|
||||||
capability: 'audio-effects',
|
capability: 'audio-effects',
|
||||||
command: 'resolve-plan',
|
command: 'resolve-plan',
|
||||||
source: 'nam_tone',
|
source: 'nam_tone',
|
||||||
@@ -221,7 +221,7 @@ const resolved = await window.slopsmith.capabilities.dispatch({
|
|||||||
Providers should store public song/tone routing through the host-owned mapping index and keep their own preset or chain rows private. The mapping's `provider_ref` is opaque to core: NAM Tone can use a preset id, Rig Builder can use a chain/preset id, and each provider resolves that reference in `chain.resolve`.
|
Providers should store public song/tone routing through the host-owned mapping index and keep their own preset or chain rows private. The mapping's `provider_ref` is opaque to core: NAM Tone can use a preset id, Rig Builder can use a chain/preset id, and each provider resolves that reference in `chain.resolve`.
|
||||||
|
|
||||||
```js
|
```js
|
||||||
await window.slopsmith.audioEffects.upsertMapping({
|
await window.feedBack.audioEffects.upsertMapping({
|
||||||
song_key: playbackTarget.settingsKey,
|
song_key: playbackTarget.settingsKey,
|
||||||
filename: playbackTarget.filename, // optional migration/debug context
|
filename: playbackTarget.filename, // optional migration/debug context
|
||||||
tone_key: 'Dist',
|
tone_key: 'Dist',
|
||||||
@@ -232,7 +232,7 @@ await window.slopsmith.audioEffects.upsertMapping({
|
|||||||
active: true
|
active: true
|
||||||
});
|
});
|
||||||
|
|
||||||
const mappings = await window.slopsmith.audioEffects.listMappings({
|
const mappings = await window.feedBack.audioEffects.listMappings({
|
||||||
song_key: playbackTarget.settingsKey,
|
song_key: playbackTarget.settingsKey,
|
||||||
tone_key: 'Dist'
|
tone_key: 'Dist'
|
||||||
});
|
});
|
||||||
@@ -243,7 +243,7 @@ Only one mapping is active for a `song_key + tone_key` at a time, but multiple p
|
|||||||
Browser or native executors should declare both provider scope and plan scope. A NAM-only browser executor should not claim Rig Builder plans just because it can load NAM files:
|
Browser or native executors should declare both provider scope and plan scope. A NAM-only browser executor should not claim Rig Builder plans just because it can load NAM files:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
window.slopsmith.audioEffects.registerExecutor({
|
window.feedBack.audioEffects.registerExecutor({
|
||||||
executorId: 'nam-tone-browser-wasm',
|
executorId: 'nam-tone-browser-wasm',
|
||||||
pluginId: 'nam_tone',
|
pluginId: 'nam_tone',
|
||||||
routeKey: 'desktop-main',
|
routeKey: 'desktop-main',
|
||||||
@@ -296,7 +296,7 @@ Plugins that need live instrument input should declare requester/observer intent
|
|||||||
Requesters should list or inspect sources before opening them. `inspect`, `list-sources`, and `select-source` are prompt-free and must not call provider enumeration or open live input. When a requester needs audio, it dispatches `open-source` with a purpose and required channel shape. The requester identity is taken from the dispatch `source` (the authenticated caller) — a payload-supplied `requesterId` is ignored, so a requester cannot spoof another's identity or release a shared session it does not own. Compatible requesters share one open session; each requester later dispatches `close-source`, and the provider is closed only after the last requester releases it.
|
Requesters should list or inspect sources before opening them. `inspect`, `list-sources`, and `select-source` are prompt-free and must not call provider enumeration or open live input. When a requester needs audio, it dispatches `open-source` with a purpose and required channel shape. The requester identity is taken from the dispatch `source` (the authenticated caller) — a payload-supplied `requesterId` is ignored, so a requester cannot spoof another's identity or release a shared session it does not own. Compatible requesters share one open session; each requester later dispatches `close-source`, and the provider is closed only after the last requester releases it.
|
||||||
|
|
||||||
```js
|
```js
|
||||||
const api = window.slopsmith.capabilities;
|
const api = window.feedBack.capabilities;
|
||||||
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'browser:instrument:primary' } });
|
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'browser:instrument:primary' } });
|
||||||
const opened = await api.dispatch({
|
const opened = await api.dispatch({
|
||||||
capability: 'audio-input',
|
capability: 'audio-input',
|
||||||
@@ -436,7 +436,7 @@ Plugins that need to inspect or coordinate song transport should declare `playba
|
|||||||
Fresh audible starts require a user action. Background plugins should call `inspect` first and attach to an existing compatible session; if a plugin needs to offer a play/start action, wire it to a visible user gesture and pass `authorization: "user-action"`.
|
Fresh audible starts require a user action. Background plugins should call `inspect` first and attach to an existing compatible session; if a plugin needs to offer a play/start action, wire it to a visible user gesture and pass `authorization: "user-action"`.
|
||||||
|
|
||||||
```js
|
```js
|
||||||
const api = window.slopsmith.capabilities;
|
const api = window.feedBack.capabilities;
|
||||||
|
|
||||||
const state = await api.dispatch({
|
const state = await api.dispatch({
|
||||||
capability: 'playback',
|
capability: 'playback',
|
||||||
@@ -455,7 +455,7 @@ if (state.status !== 'idle') {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
During migration, legacy uses of `window.playSong`, `song:*` events, `window.slopsmith.seek`, and loop helpers remain available and are recorded as playback bridge hits. Treat bridge hits as migration telemetry: native capability requests should eventually cover normal plugin workflows so unexpected legacy hits disappear from diagnostics.
|
During migration, legacy uses of `window.playSong`, `song:*` events, `window.feedBack.seek`, and loop helpers remain available and are recorded as playback bridge hits. Treat bridge hits as migration telemetry: native capability requests should eventually cover normal plugin workflows so unexpected legacy hits disappear from diagnostics.
|
||||||
|
|
||||||
## Progression Requester And Observer
|
## Progression Requester And Observer
|
||||||
|
|
||||||
@@ -484,7 +484,7 @@ Plugins that report gameplay outcomes or react to player progression (spec 010)
|
|||||||
`buy-item` and `equip-item` require a visible user gesture (`authorization: "user-action"`). Decibels are play-earned only; plugins must not present any purchase path.
|
`buy-item` and `equip-item` require a visible user gesture (`authorization: "user-action"`). Decibels are play-earned only; plugins must not present any purchase path.
|
||||||
|
|
||||||
```js
|
```js
|
||||||
const api = window.slopsmith.capabilities;
|
const api = window.feedBack.capabilities;
|
||||||
|
|
||||||
const result = await api.dispatch({
|
const result = await api.dispatch({
|
||||||
capability: 'progression',
|
capability: 'progression',
|
||||||
@@ -494,14 +494,14 @@ const result = await api.dispatch({
|
|||||||
});
|
});
|
||||||
// result.payload lists challenges/quests completed by this event (toast UX).
|
// result.payload lists challenges/quests completed by this event (toast UX).
|
||||||
|
|
||||||
window.slopsmith.on('progression:quest-completed', (e) => {
|
window.feedBack.on('progression:quest-completed', (e) => {
|
||||||
console.log('quest done:', e.detail.title, '+' + e.detail.reward_db + ' dB');
|
console.log('quest done:', e.detail.title, '+' + e.detail.reward_db + ' dB');
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
## Future Expansion Domains
|
## Future Expansion Domains
|
||||||
|
|
||||||
Some domain names are reserved for expected future contracts, but they are not registered in the runtime graph yet. For example, `ui.player-panels` is documented as a likely panel-host surface, but Slopsmith does not currently expose a capability command for panel contributions. See [capability-roadmap.md](capability-roadmap.md) for the PR1 domain set and deferred-domain checklist.
|
Some domain names are reserved for expected future contracts, but they are not registered in the runtime graph yet. For example, `ui.player-panels` is documented as a likely panel-host surface, but FeedBack does not currently expose a capability command for panel contributions. See [capability-roadmap.md](capability-roadmap.md) for the PR1 domain set and deferred-domain checklist.
|
||||||
|
|
||||||
Plugins should not declare future expansion domains until the corresponding host workflow ships. For current integrations, prefer active domains such as `library`, `playback`, `audio-mix`, `audio-input`, `audio-monitoring`, or `stems` intent matching the recipes above.
|
Plugins should not declare future expansion domains until the corresponding host workflow ships. For current integrations, prefer active domains such as `library`, `playback`, `audio-mix`, `audio-input`, `audio-monitoring`, or `stems` intent matching the recipes above.
|
||||||
|
|
||||||
@@ -537,7 +537,7 @@ the owner is visible in the Capability Inspector.
|
|||||||
Register the action from the plugin's `screen.js`:
|
Register the action from the plugin's `screen.js`:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
window.slopsmith.libraryCardActions.register({
|
window.feedBack.libraryCardActions.register({
|
||||||
id: 'my_card_action.run',
|
id: 'my_card_action.run',
|
||||||
pluginId: 'my_card_action',
|
pluginId: 'my_card_action',
|
||||||
label: 'Do the thing',
|
label: 'Do the thing',
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ The audio graph/session and effects slices promote these domains after PR1:
|
|||||||
|
|
||||||
`core.audio.session` is the runtime coordinator for all four domains. It owns `audio-mix`, `audio-input`, and `audio-monitoring`; for `stems`, it coordinates the active Stems provider without replacing the Stems plugin as the owner of actual stem playback/state.
|
`core.audio.session` is the runtime coordinator for all four domains. It owns `audio-mix`, `audio-input`, and `audio-monitoring`; for `stems`, it coordinates the active Stems provider without replacing the Stems plugin as the owner of actual stem playback/state.
|
||||||
|
|
||||||
The focused audio-mix control-plane slice promotes fader discovery, read/write operations, committed-value events, native-over-legacy duplicate handling, route/analyser inspection, and compatibility removal gates into `audio-mix`. During migration, `window.slopsmith.audio.registerFader(...)` remains available as a compatibility adapter, but the player mixer consumes the audio-mix control plane as its source of truth.
|
The focused audio-mix control-plane slice promotes fader discovery, read/write operations, committed-value events, native-over-legacy duplicate handling, route/analyser inspection, and compatibility removal gates into `audio-mix`. During migration, `window.feedBack.audio.registerFader(...)` remains available as a compatibility adapter, but the player mixer consumes the audio-mix control plane as its source of truth.
|
||||||
|
|
||||||
The focused audio-input control-plane slice promotes source listing, prompt-free selection/inspection, explicit provider enumeration, open/close dispatch, channel-shape compatibility, selected-source persistence, shared requester sessions, and redaction-safe failure diagnostics into `audio-input`. During migration, legacy browser, desktop, or plugin-specific input handoffs should be recorded as `audio-input.legacy-source` bridge hits. Native providers own the visible source when they share a logical source key with a compatibility-backed source; the compatibility source remains diagnostics-only until normal playback shows no unexpected legacy hits.
|
The focused audio-input control-plane slice promotes source listing, prompt-free selection/inspection, explicit provider enumeration, open/close dispatch, channel-shape compatibility, selected-source persistence, shared requester sessions, and redaction-safe failure diagnostics into `audio-input`. During migration, legacy browser, desktop, or plugin-specific input handoffs should be recorded as `audio-input.legacy-source` bridge hits. Native providers own the visible source when they share a logical source key with a compatibility-backed source; the compatibility source remains diagnostics-only until normal playback shows no unexpected legacy hits.
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ The focused audio-effects control-plane slice promotes provider registration, us
|
|||||||
|
|
||||||
The playback slice promotes `playback` from a deferred domain to an active exclusive-owner core domain. It owns transport commands (`start`, `pause`, `resume`, `stop`, `seek`, `set-loop`, `clear-loop`, `inspect`), lifecycle events (`playback:requested`, `playback:loading`, `playback:ready`, `playback:started`, `playback:paused`, `playback:resumed`, `playback:seeking`, `playback:seeked`, `playback:ended`, `playback:stopped`, route events, bridge hits, and loop events), and redaction-safe diagnostics for session, target, timing, route, loop, requester, observer, bridge, and recent outcome state.
|
The playback slice promotes `playback` from a deferred domain to an active exclusive-owner core domain. It owns transport commands (`start`, `pause`, `resume`, `stop`, `seek`, `set-loop`, `clear-loop`, `inspect`), lifecycle events (`playback:requested`, `playback:loading`, `playback:ready`, `playback:started`, `playback:paused`, `playback:resumed`, `playback:seeking`, `playback:seeked`, `playback:ended`, `playback:stopped`, route events, bridge hits, and loop events), and redaction-safe diagnostics for session, target, timing, route, loop, requester, observer, bridge, and recent outcome state.
|
||||||
|
|
||||||
The implementation deliberately keeps raw transport handles in `static/app.js`: the domain host registers a private adapter and receives sanitized snapshots instead of exposing the `<audio>` element, JUCE player, decoded audio buffers, waveform data, or native route handles. Playback targets expose a pseudonymous arrangement-scoped `targetId` plus a hashed per-song `settingsKey` so observers can store local per-song settings without reading raw filenames or paths. Compatibility bridges currently account for `window.playSong`, `window.slopsmith` transport helpers, legacy song events, loop helpers, media snapshots, route switching, and native-route handoff. Fresh audible starts require `authorization: "user-action"`; background requesters can inspect or control only an existing compatible session according to the command conflict policy.
|
The implementation deliberately keeps raw transport handles in `static/app.js`: the domain host registers a private adapter and receives sanitized snapshots instead of exposing the `<audio>` element, JUCE player, decoded audio buffers, waveform data, or native route handles. Playback targets expose a pseudonymous arrangement-scoped `targetId` plus a hashed per-song `settingsKey` so observers can store local per-song settings without reading raw filenames or paths. Compatibility bridges currently account for `window.playSong`, `window.feedBack` transport helpers, legacy song events, loop helpers, media snapshots, route switching, and native-route handoff. Fresh audible starts require `authorization: "user-action"`; background requesters can inspect or control only an existing compatible session according to the command conflict policy.
|
||||||
|
|
||||||
Playback bridge removal gates are: bundled and first-party plugins use native playback dispatch for normal requester/observer workflows; normal play/pause/seek/loop/route smoke runs show no unexpected bridge hits beyond compatibility-only listeners; playback diagnostics distinguish denied, no-target, stale, cancelled, degraded, unavailable, failed, and stopped outcomes; repeated plugin hydration does not duplicate requesters, observers, wrappers, or bridge entries; and exported support snapshots contain no raw song filenames, paths, URLs, media handles, buffers, waveforms, samples, or recordings.
|
Playback bridge removal gates are: bundled and first-party plugins use native playback dispatch for normal requester/observer workflows; normal play/pause/seek/loop/route smoke runs show no unexpected bridge hits beyond compatibility-only listeners; playback diagnostics distinguish denied, no-target, stale, cancelled, degraded, unavailable, failed, and stopped outcomes; repeated plugin hydration does not duplicate requesters, observers, wrappers, or bridge entries; and exported support snapshots contain no raw song filenames, paths, URLs, media handles, buffers, waveforms, samples, or recordings.
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ This is the recommended order for UI/UX capability work only. It excludes audio
|
|||||||
| 5 | Player controls | `ui.player-controls` | Direct player control DOM edits, control popovers, button/slider globals | Ordered player-control regions with stable command buttons, popovers, sliders, disabled states, and contribution teardown | Player controls can be added/removed/reordered without plugins mutating the control bar directly. |
|
| 5 | Player controls | `ui.player-controls` | Direct player control DOM edits, control popovers, button/slider globals | Ordered player-control regions with stable command buttons, popovers, sliders, disabled states, and contribution teardown | Player controls can be added/removed/reordered without plugins mutating the control bar directly. |
|
||||||
| 6 | Player overlays | `ui.player-overlays`, `tours` | Overlay canvases, tour overlays, highway visibility listeners, direct z-index management | Overlay host with anchors, z-order, hit-testing, renderer compatibility flags, visibility events, and cleanup | Fretboard, section map, tours, transpose, step mode, and similar overlays can coexist without private layering rules. |
|
| 6 | Player overlays | `ui.player-overlays`, `tours` | Overlay canvases, tour overlays, highway visibility listeners, direct z-index management | Overlay host with anchors, z-order, hit-testing, renderer compatibility flags, visibility events, and cleanup | Fretboard, section map, tours, transpose, step mode, and similar overlays can coexist without private layering rules. |
|
||||||
| 7 | Player panels | `ui.player-panels` | Splitscreen panel DOM, panel-local highway instances, panel-local shortcuts | Panel host with layout slots, active-panel focus, per-panel renderer selection, per-panel shortcuts, visibility, and teardown | Splitscreen-style panels can be composed through host APIs instead of wrapping playback/screen globals. |
|
| 7 | Player panels | `ui.player-panels` | Splitscreen panel DOM, panel-local highway instances, panel-local shortcuts | Panel host with layout slots, active-panel focus, per-panel renderer selection, per-panel shortcuts, visibility, and teardown | Splitscreen-style panels can be composed through host APIs instead of wrapping playback/screen globals. |
|
||||||
| 8 | Visualization UX | `visualization` | `type: "visualization"`, `window.slopsmithViz_*`, viz picker state, auto-match hooks | Renderer provider registry with picker integration, auto-match ordering, context-type metadata, fallback/revert events, and per-panel selection | Renderer selection and failure recovery are fully attributed in diagnostics; picker options no longer depend on global scans. |
|
| 8 | Visualization UX | `visualization` | `type: "visualization"`, `window.feedBackViz_*`, viz picker state, auto-match hooks | Renderer provider registry with picker integration, auto-match ordering, context-type metadata, fallback/revert events, and per-panel selection | Renderer selection and failure recovery are fully attributed in diagnostics; picker options no longer depend on global scans. |
|
||||||
| 9 | Library and guided UX extensions | `ui.library-card-injection`, `tours` | Library card buttons, tour registration globals, target selectors | Contribution APIs for library card actions and guided-tour steps with applicability, target resolution, and action-result events | Library actions and tours can be inspected, disabled, and tested independently of plugin-private DOM injection. |
|
| 9 | Library and guided UX extensions | `ui.library-card-injection`, `tours` | Library card buttons, tour registration globals, target selectors | Contribution APIs for library card actions and guided-tour steps with applicability, target resolution, and action-result events | Library actions and tours can be inspected, disabled, and tested independently of plugin-private DOM injection. |
|
||||||
| 10 | Theme and polish surfaces | `settings` or candidate `ui.theme` | Global theme settings, direct stylesheet/class mutation | Theme contribution metadata for tokens, selected theme, preview/apply/restore lifecycle, and diagnostics without user secrets | Themes are reversible and attributable, and visual changes do not depend on hidden global state. |
|
| 10 | Theme and polish surfaces | `settings` or candidate `ui.theme` | Global theme settings, direct stylesheet/class mutation | Theme contribution metadata for tokens, selected theme, preview/apply/restore lifecycle, and diagnostics without user secrets | Themes are reversible and attributable, and visual changes do not depend on hidden global state. |
|
||||||
|
|
||||||
@@ -127,7 +127,7 @@ These candidate domains were surfaced by the included plugin inventory but are n
|
|||||||
| `recording` | multi-provider | sensitive | Arm/start/stop capture, take upload/import, capture-source binding, latency metadata, and storage cleanup. | Studio and karaoke workflows need capture/session semantics distinct from raw audio input. |
|
| `recording` | multi-provider | sensitive | Arm/start/stop capture, take upload/import, capture-source binding, latency metadata, and storage cleanup. | Studio and karaoke workflows need capture/session semantics distinct from raw audio input. |
|
||||||
| `practice-session` | multi-provider | safe | Practice session lifecycle, goals, score/progress events, chart segment focus, and journal persistence boundaries. | Practice Journal, Minigames, Guitar Theory, Flappy Bend, and Note Detect imply practice/progression state. |
|
| `practice-session` | multi-provider | safe | Practice session lifecycle, goals, score/progress events, chart segment focus, and journal persistence boundaries. | Practice Journal, Minigames, Guitar Theory, Flappy Bend, and Note Detect imply practice/progression state. |
|
||||||
| `collaboration` | multi-provider | sensitive | Room/session lifecycle, participant identity redaction, shared playback sync, conflict policy, and disconnect recovery. | Multiplayer is a distinct real-time coordination surface. |
|
| `collaboration` | multi-provider | sensitive | Room/session lifecycle, participant identity redaction, shared playback sync, conflict policy, and disconnect recovery. | Multiplayer is a distinct real-time coordination surface. |
|
||||||
| `external-services` | diagnostic or privileged metadata | privileged | Network/download/subprocess integration inventory, endpoint attribution, confirmation policy, and failure diagnostics. | Update Manager, Find More, Sloppak Converter, and media jobs reach outside local Slopsmith state. |
|
| `external-services` | diagnostic or privileged metadata | privileged | Network/download/subprocess integration inventory, endpoint attribution, confirmation policy, and failure diagnostics. | Update Manager, Find More, Sloppak Converter, and media jobs reach outside local FeedBack state. |
|
||||||
|
|
||||||
Candidate domains can also remain as safety metadata on existing domains. For example, `external-services` may be more useful as a cross-cutting review tag than as a dispatchable runtime capability.
|
Candidate domains can also remain as safety metadata on existing domains. For example, `external-services` may be more useful as a cross-cutting review tag than as a dispatchable runtime capability.
|
||||||
|
|
||||||
@@ -141,7 +141,7 @@ PR1 does not add per-domain versioning. The `capability-pipelines.v1` standard v
|
|||||||
- Changing command payloads, return payloads, or dispatch outcomes incompatibly is breaking.
|
- Changing command payloads, return payloads, or dispatch outcomes incompatibly is breaking.
|
||||||
- A breaking change requires either a future `capability-pipelines` version or a clearly new domain name if parallel support is needed.
|
- A breaking change requires either a future `capability-pipelines` version or a clearly new domain name if parallel support is needed.
|
||||||
|
|
||||||
Per-domain versions should wait until Slopsmith has a concrete need for multiple incompatible versions of the same domain to coexist.
|
Per-domain versions should wait until FeedBack has a concrete need for multiple incompatible versions of the same domain to coexist.
|
||||||
|
|
||||||
## Future Domain PR Checklist
|
## Future Domain PR Checklist
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Capability declarations include a safety class so reviewers can decide whether a domain can ship as a normal plugin contract or needs extra enforcement first.
|
Capability declarations include a safety class so reviewers can decide whether a domain can ship as a normal plugin contract or needs extra enforcement first.
|
||||||
|
|
||||||
Core domains also have a review scope. **Active contract** domains are wired to current Slopsmith behavior and should be tested as working integration points. Expected future domains are documented below, but are intentionally not registered in the runtime graph until Slopsmith ships the corresponding host UI or provider workflow.
|
Core domains also have a review scope. **Active contract** domains are wired to current FeedBack behavior and should be tested as working integration points. Expected future domains are documented below, but are intentionally not registered in the runtime graph until FeedBack ships the corresponding host UI or provider workflow.
|
||||||
|
|
||||||
| Domain | Owner Kind | Safety Class | Stable Commands | Provider Operations | Notes |
|
| Domain | Owner Kind | Safety Class | Stable Commands | Provider Operations | Notes |
|
||||||
|--------|------------|--------------|-----------------|---------------------|-------|
|
|--------|------------|--------------|-----------------|---------------------|-------|
|
||||||
@@ -14,10 +14,10 @@ Core domains also have a review scope. **Active contract** domains are wired to
|
|||||||
| audio-monitoring | provider-coordinator | sensitive | inspect, list-providers, register-provider, unregister-provider, select-provider, start, stop, set-direct-monitor | monitoring.start, monitoring.stop, monitoring.status, monitoring.set-direct-monitor | Inspect/list/select/status are prompt-free. Fresh monitoring start requires explicit user action; background requesters may only attach to an active compatible session. Outcomes distinguish handled, stopped, denied, unavailable, degraded, failed, no-owner, no-handler, unsupported-command, incompatible, incompatible-version, provider-selection-required, and user-action-required. Diagnostics redact raw device labels, hardware ids, paths, secrets, live handles, buffers, samples, waveforms, and recordings. |
|
| audio-monitoring | provider-coordinator | sensitive | inspect, list-providers, register-provider, unregister-provider, select-provider, start, stop, set-direct-monitor | monitoring.start, monitoring.stop, monitoring.status, monitoring.set-direct-monitor | Inspect/list/select/status are prompt-free. Fresh monitoring start requires explicit user action; background requesters may only attach to an active compatible session. Outcomes distinguish handled, stopped, denied, unavailable, degraded, failed, no-owner, no-handler, unsupported-command, incompatible, incompatible-version, provider-selection-required, and user-action-required. Diagnostics redact raw device labels, hardware ids, paths, secrets, live handles, buffers, samples, waveforms, and recordings. |
|
||||||
| stems | coordinator plus plugin provider | safe | inspect, mute, restore | stem.get-state, stem.apply-automation, stem.restore-automation | Core coordinates claims/overrides; the active Stems provider owns actual stem state/playback. |
|
| stems | coordinator plus plugin provider | safe | inspect, mute, restore | stem.get-state, stem.apply-automation, stem.restore-automation | Core coordinates claims/overrides; the active Stems provider owns actual stem state/playback. |
|
||||||
| playback | exclusive-owner | safe | inspect, start, pause, resume, stop, seek, set-loop, clear-loop, register-requester, register-observer | none | Core owns the transport control plane while `app.js` keeps raw media handles private. Fresh audible starts require explicit user action. Diagnostics expose pseudonymous targets, sanitized route/timing/loop state, requester/observer summaries, bridge hits, bounded recent outcomes, and no audio elements, native handles, decoded buffers, samples, waveforms, or recordings. |
|
| playback | exclusive-owner | safe | inspect, start, pause, resume, stop, seek, set-loop, clear-loop, register-requester, register-observer | none | Core owns the transport control plane while `app.js` keeps raw media handles private. Fresh audible starts require explicit user action. Diagnostics expose pseudonymous targets, sanitized route/timing/loop state, requester/observer summaries, bridge hits, bounded recent outcomes, and no audio elements, native handles, decoded buffers, samples, waveforms, or recordings. |
|
||||||
| progression | exclusive-owner | safe | inspect, record-event, list-shop, buy-item, equip-item | none | Core owns mastery rank, the challenge/quest engine, the Decibels wallet, and the cosmetics shop (spec 010). `record-event` accepts whitelisted types only (`minigame_run`); `song_completed` is server-derived in `/api/stats` and denied here. `buy-item`/`equip-item` require explicit user action. Decibels are play-earned only — no real-money path exists or may be added. Diagnostics (`slopsmith.progression.diag.v1`) carry content warnings, rank/level/quest counts, and wallet totals; no song filenames or display names. |
|
| progression | exclusive-owner | safe | inspect, record-event, list-shop, buy-item, equip-item | none | Core owns mastery rank, the challenge/quest engine, the Decibels wallet, and the cosmetics shop (spec 010). `record-event` accepts whitelisted types only (`minigame_run`); `song_completed` is server-derived in `/api/stats` and denied here. `buy-item`/`equip-item` require explicit user action. Decibels are play-earned only — no real-money path exists or may be added. Diagnostics (`feedBack.progression.diag.v1`) carry content warnings, rank/level/quest counts, and wallet totals; no song filenames or display names. |
|
||||||
| audio-effects | provider-coordinator | sensitive | inspect, list-providers, register-provider, unregister-provider, select-chain, resolve-plan, inspect-route, bypass, restore, fallback, activate-segment, set-stage-bypass, set-stage-parameter, record-bridge-hit | chain.resolve, chain.inspect, segment.activate, stage.set-bypass, stage.set-parameter, route.bypass, route.restore | Core owns provider selection, route state, chain-plan schema validation, fallback accounting, and diagnostics. Providers propose opaque NAM/IR/VST/utility chain plans; trusted desktop/native code validates and loads processors. Chain selection and route bypass/restore require explicit user action or restored selection. Diagnostics omit raw paths, filenames, URLs, model/IR names, native preset JSON, VST state blobs, handles, callbacks, DOM nodes, audio buffers, samples, and waveforms. |
|
| audio-effects | provider-coordinator | sensitive | inspect, list-providers, register-provider, unregister-provider, select-chain, resolve-plan, inspect-route, bypass, restore, fallback, activate-segment, set-stage-bypass, set-stage-parameter, record-bridge-hit | chain.resolve, chain.inspect, segment.activate, stage.set-bypass, stage.set-parameter, route.bypass, route.restore | Core owns provider selection, route state, chain-plan schema validation, fallback accounting, and diagnostics. Providers propose opaque NAM/IR/VST/utility chain plans; trusted desktop/native code validates and loads processors. Chain selection and route bypass/restore require explicit user action or restored selection. Diagnostics omit raw paths, filenames, URLs, model/IR names, native preset JSON, VST state blobs, handles, callbacks, DOM nodes, audio buffers, samples, and waveforms. |
|
||||||
|
|
||||||
| visualization | provider-coordinator | safe | inspect, list-providers, select-renderer, clear-renderer | renderer.create, renderer.destroy | Highway renderer provider registry, picker-delegated selection, auto-match attribution, and failure fallback. `renderer.create` maps to the legacy `window.slopsmithViz_*` factory `init(canvas, ctx)` call; `renderer.destroy` maps to the factory `destroy()` teardown. Legacy `type: "visualization"` manifests and `window.slopsmithViz_*` globals are accounted compatibility shims. Diagnostics carry provider ids/labels, selection source, last auto-match outcome, and last failure — no song filenames, titles, or arrangement names. |
|
| visualization | provider-coordinator | safe | inspect, list-providers, select-renderer, clear-renderer | renderer.create, renderer.destroy | Highway renderer provider registry, picker-delegated selection, auto-match attribution, and failure fallback. `renderer.create` maps to the legacy `window.feedBackViz_*` factory `init(canvas, ctx)` call; `renderer.destroy` maps to the factory `destroy()` teardown. Legacy `type: "visualization"` manifests and `window.feedBackViz_*` globals are accounted compatibility shims. Diagnostics carry provider ids/labels, selection source, last auto-match outcome, and last failure — no song filenames, titles, or arrangement names. |
|
||||||
|
|
||||||
| 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. |
|
| 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. |
|
||||||
|
|
||||||
@@ -43,6 +43,6 @@ These domains are expected future capability contracts, not current runtime grap
|
|||||||
| 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. |
|
| 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. |
|
| 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.
|
Planned domains should also stay out of the runtime graph until FeedBack ships the corresponding user-facing workflows.
|
||||||
|
|
||||||
When promoting a planned domain, use [capability-review-preflight.md](capability-review-preflight.md) before opening the PR. The preflight captures recurring review requirements for identity, redaction, outcome propagation, diagnostics freshness, schema consistency, and teardown.
|
When promoting a planned domain, use [capability-review-preflight.md](capability-review-preflight.md) before opening the PR. The preflight captures recurring review requirements for identity, redaction, outcome propagation, diagnostics freshness, schema consistency, and teardown.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Slopsmith Diagnostics Bundle — Format Specification
|
# FeedBack Diagnostics Bundle — Format Specification
|
||||||
|
|
||||||
This document is the authoritative reference for the `slopsmith-diag-*.zip`
|
This document is the authoritative reference for the `feedBack-diag-*.zip`
|
||||||
file produced by Settings → Export Diagnostics (slopsmith#166).
|
file produced by Settings → Export Diagnostics (feedBack#166).
|
||||||
|
|
||||||
The bundle is consumed by humans (maintainers reading bug reports) **and**
|
The bundle is consumed by humans (maintainers reading bug reports) **and**
|
||||||
AI agents (auto-triage, code-aware assistants). Every JSON file inside
|
AI agents (auto-triage, code-aware assistants). Every JSON file inside
|
||||||
@@ -15,17 +15,17 @@ version without guessing.
|
|||||||
A diagnostic bundle is a plain ZIP archive. The default filename is:
|
A diagnostic bundle is a plain ZIP archive. The default filename is:
|
||||||
|
|
||||||
```
|
```
|
||||||
slopsmith-diag-<slopsmith-version>-<YYYYMMDD-HHMMSS>.zip
|
feedBack-diag-<feedBack-version>-<YYYYMMDD-HHMMSS>.zip
|
||||||
```
|
```
|
||||||
|
|
||||||
Top-level layout:
|
Top-level layout:
|
||||||
|
|
||||||
```
|
```
|
||||||
slopsmith-diag-0.2.4-20260503-143022.zip
|
feedBack-diag-0.2.4-20260503-143022.zip
|
||||||
├── manifest.json AI-friendly index, schema 1
|
├── manifest.json AI-friendly index, schema 1
|
||||||
├── README.txt Human-friendly: what's in here, how to read
|
├── README.txt Human-friendly: what's in here, how to read
|
||||||
├── system/
|
├── system/
|
||||||
│ ├── version.json slopsmith + python + OS
|
│ ├── version.json feedBack + python + OS
|
||||||
│ ├── env.json allowlisted env vars only (no secrets)
|
│ ├── env.json allowlisted env vars only (no secrets)
|
||||||
│ ├── hardware.json backend hardware (container-limited if Docker)
|
│ ├── hardware.json backend hardware (container-limited if Docker)
|
||||||
│ └── plugins.json loaded + orphan plugins, with git info
|
│ └── plugins.json loaded + orphan plugins, with git info
|
||||||
@@ -53,7 +53,7 @@ logs, console, plugins). Missing sections are not represented in
|
|||||||
{
|
{
|
||||||
"schema": 1, // bundle schema; bump = breaking change
|
"schema": 1, // bundle schema; bump = breaking change
|
||||||
"exported_at": "2026-05-03T14:30:22Z",
|
"exported_at": "2026-05-03T14:30:22Z",
|
||||||
"slopsmith_version": "0.2.4",
|
"feedBack_version": "0.2.4",
|
||||||
"runtime": "docker", // "docker" | "electron" | "bare"
|
"runtime": "docker", // "docker" | "electron" | "bare"
|
||||||
"redacted": true, // were redactions applied?
|
"redacted": true, // were redactions applied?
|
||||||
"files": [
|
"files": [
|
||||||
@@ -94,7 +94,7 @@ Field semantics:
|
|||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
"schema": "system.version.v1",
|
"schema": "system.version.v1",
|
||||||
"slopsmith_version": "0.2.4",
|
"feedBack_version": "0.2.4",
|
||||||
"python": { "version": "3.12.4", "implementation": "CPython", "executable": "/usr/bin/python" },
|
"python": { "version": "3.12.4", "implementation": "CPython", "executable": "/usr/bin/python" },
|
||||||
"os": { "system": "Linux", "release": "6.5.0", "machine": "x86_64" },
|
"os": { "system": "Linux", "release": "6.5.0", "machine": "x86_64" },
|
||||||
"exported_at": "2026-05-03T14:30:22Z"
|
"exported_at": "2026-05-03T14:30:22Z"
|
||||||
@@ -109,13 +109,13 @@ Field semantics:
|
|||||||
"vars": {
|
"vars": {
|
||||||
"LOG_LEVEL": "INFO",
|
"LOG_LEVEL": "INFO",
|
||||||
"LOG_FORMAT": "json",
|
"LOG_FORMAT": "json",
|
||||||
"SLOPSMITH_RUNTIME": "electron"
|
"FEEDBACK_RUNTIME": "electron"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Allowlisted env var keys only (see `ENV_ALLOWLIST` in `lib/diagnostics_bundle.py`):
|
Allowlisted env var keys only (see `ENV_ALLOWLIST` in `lib/diagnostics_bundle.py`):
|
||||||
`LOG_LEVEL`, `LOG_FORMAT`, `LOG_FILE`, `SLOPSMITH_RUNTIME`, `PORT`, `HOST`,
|
`LOG_LEVEL`, `LOG_FORMAT`, `LOG_FILE`, `FEEDBACK_RUNTIME`, `PORT`, `HOST`,
|
||||||
`TZ`, `PYTHONUNBUFFERED`, `DEMUCS_SERVER_URL`. New entries require an
|
`TZ`, `PYTHONUNBUFFERED`, `DEMUCS_SERVER_URL`. New entries require an
|
||||||
allowlist edit; secrets must never be added.
|
allowlist edit; secrets must never be added.
|
||||||
|
|
||||||
@@ -187,7 +187,7 @@ entry explaining why.
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"loaded": false,
|
"loaded": false,
|
||||||
"dir": "broken",
|
"dir": "broken",
|
||||||
"path": "/home/user/.config/slopsmith/plugins/broken"
|
"path": "/home/user/.config/feedBack/plugins/broken"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -223,7 +223,7 @@ appear in `capability_unsupported_versions` and should be treated as
|
|||||||
non-executable runtime intent.
|
non-executable runtime intent.
|
||||||
|
|
||||||
Client-side capability snapshots contributed under `plugins/capabilities/client.json`
|
Client-side capability snapshots contributed under `plugins/capabilities/client.json`
|
||||||
use schema `slopsmith.capabilities.diagnostics.v1`. They include current
|
use schema `feedBack.capabilities.diagnostics.v1`. They include current
|
||||||
pipelines, participants, conflicts, missing providers, user overrides, active
|
pipelines, participants, conflicts, missing providers, user overrides, active
|
||||||
or orphaned claims, claim lifecycle records, compatibility shim hit counts,
|
or orphaned claims, claim lifecycle records, compatibility shim hit counts,
|
||||||
unsupported-version reports, and recent decisions. The runtime caps this
|
unsupported-version reports, and recent decisions. The runtime caps this
|
||||||
@@ -235,7 +235,7 @@ current graph state.
|
|||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
"schema": "logs.server.v1",
|
"schema": "logs.server.v1",
|
||||||
"log_file": "/data/log/slopsmith.log",
|
"log_file": "/data/log/feedBack.log",
|
||||||
"exists": true,
|
"exists": true,
|
||||||
"size_bytes": 8388608,
|
"size_bytes": 8388608,
|
||||||
"tail_bytes": 5242880,
|
"tail_bytes": 5242880,
|
||||||
@@ -341,7 +341,7 @@ serialized as `"[circular]"`.
|
|||||||
`runtime.kind` rules:
|
`runtime.kind` rules:
|
||||||
|
|
||||||
- `"electron"` if `navigator.userAgent` contains `Electron/`. Versions
|
- `"electron"` if `navigator.userAgent` contains `Electron/`. Versions
|
||||||
populated when the desktop launcher exposes `window.slopsmithElectron`
|
populated when the desktop launcher exposes `window.feedBackElectron`
|
||||||
via a preload `contextBridge`.
|
via a preload `contextBridge`.
|
||||||
- `"browser"` otherwise.
|
- `"browser"` otherwise.
|
||||||
|
|
||||||
@@ -367,7 +367,7 @@ typically prefix their keys with their `plugin_id`.
|
|||||||
{
|
{
|
||||||
"schema": "client.ua.v1",
|
"schema": "client.ua.v1",
|
||||||
"userAgent": "...",
|
"userAgent": "...",
|
||||||
"url": "https://slopsmith.local/",
|
"url": "https://feedBack.local/",
|
||||||
"screen": { ... }
|
"screen": { ... }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -406,10 +406,10 @@ dispatch by plugin schema.
|
|||||||
|
|
||||||
Detection precedence (backend):
|
Detection precedence (backend):
|
||||||
|
|
||||||
1. `SLOPSMITH_RUNTIME` env var (`"electron"`/`"docker"`/`"bare"`)
|
1. `FEEDBACK_RUNTIME` env var (`"electron"`/`"docker"`/`"bare"`)
|
||||||
2. `/.dockerenv` exists OR `/proc/1/cgroup` mentions `docker`/
|
2. `/.dockerenv` exists OR `/proc/1/cgroup` mentions `docker`/
|
||||||
`containerd`/`kubepods` → `docker`
|
`containerd`/`kubepods` → `docker`
|
||||||
3. Parent process name matches `electron` or `Slopsmith` → `electron`
|
3. Parent process name matches `electron` or `FeedBack` → `electron`
|
||||||
4. Default: `bare`
|
4. Default: `bare`
|
||||||
|
|
||||||
Detection (frontend): `Electron/` in user agent → `electron`, else
|
Detection (frontend): `Electron/` in user agent → `electron`, else
|
||||||
@@ -430,7 +430,7 @@ between bundles):
|
|||||||
|--------------------|-----------------------------------------------------|
|
|--------------------|-----------------------------------------------------|
|
||||||
| `<DLC_DIR>` | configured DLC root path |
|
| `<DLC_DIR>` | configured DLC root path |
|
||||||
| `<HOME>` | user's home directory |
|
| `<HOME>` | user's home directory |
|
||||||
| `<CONFIG_DIR>` | slopsmith config directory |
|
| `<CONFIG_DIR>` | feedBack config directory |
|
||||||
| `<song:HASH8>` | song filename / basename (8-char salted SHA-256) |
|
| `<song:HASH8>` | song filename / basename (8-char salted SHA-256) |
|
||||||
| `<ip:HASH6>` | IPv4 / IPv6 address |
|
| `<ip:HASH6>` | IPv4 / IPv6 address |
|
||||||
| `<redacted>` | bearer token, `key=`/`token=`/`api_key=` query strings |
|
| `<redacted>` | bearer token, `key=`/`token=`/`api_key=` query strings |
|
||||||
@@ -508,7 +508,7 @@ machine.
|
|||||||
```
|
```
|
||||||
|
|
||||||
Frontend plugins push diagnostics by calling
|
Frontend plugins push diagnostics by calling
|
||||||
`window.slopsmith.diagnostics.contribute(plugin_id, payload)` before the
|
`window.feedBack.diagnostics.contribute(plugin_id, payload)` before the
|
||||||
user clicks Export. The payload is written to `plugins/<id>/client.json`
|
user clicks Export. The payload is written to `plugins/<id>/client.json`
|
||||||
(gated on the same "Plugin diagnostics" toggle as backend plugin files).
|
(gated on the same "Plugin diagnostics" toggle as backend plugin files).
|
||||||
|
|
||||||
|
|||||||
+10
-10
@@ -1,4 +1,4 @@
|
|||||||
# Slopsmith diagnostic sloppaks
|
# FeedBack diagnostic sloppaks
|
||||||
|
|
||||||
Generated, non-copyrighted mini-songs for technique-assessment style
|
Generated, non-copyrighted mini-songs for technique-assessment style
|
||||||
checks. Report-only — they do not change gameplay settings or detection
|
checks. Report-only — they do not change gameplay settings or detection
|
||||||
@@ -6,7 +6,7 @@ thresholds.
|
|||||||
|
|
||||||
## Basic Guitar (POC)
|
## Basic Guitar (POC)
|
||||||
|
|
||||||
**Artifact:** `slopsmith-diagnostic-basic-guitar.sloppak`
|
**Artifact:** `feedBack-diagnostic-basic-guitar.sloppak`
|
||||||
|
|
||||||
**Contents (~55 s):**
|
**Contents (~55 s):**
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ for future Technique Assessment integration).
|
|||||||
|
|
||||||
## Rebuild
|
## Rebuild
|
||||||
|
|
||||||
From the slopsmith repo root (requires `ffmpeg`; the slopsmith Docker image
|
From the feedBack repo root (requires `ffmpeg`; the feedBack Docker image
|
||||||
has `libvorbis`, Homebrew ffmpeg may use the built-in `vorbis` encoder):
|
has `libvorbis`, Homebrew ffmpeg may use the built-in `vorbis` encoder):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -36,14 +36,14 @@ On library scan startup (and periodic rescans), the server copies bundled
|
|||||||
diagnostic sloppaks into the user DLC folder when missing or when the
|
diagnostic sloppaks into the user DLC folder when missing or when the
|
||||||
bundled source is newer:
|
bundled source is newer:
|
||||||
|
|
||||||
`DLC_DIR/diagnostics-builtin/slopsmith-diagnostic-basic-guitar.sloppak`
|
`DLC_DIR/diagnostics-builtin/feedBack-diagnostic-basic-guitar.sloppak`
|
||||||
|
|
||||||
Source: `docs/diagnostics/slopsmith-diagnostic-basic-guitar.sloppak` (next to
|
Source: `docs/diagnostics/feedBack-diagnostic-basic-guitar.sloppak` (next to
|
||||||
`server.py` in dev; must be included in the desktop bundle — see
|
`server.py` in dev; must be included in the desktop bundle — see
|
||||||
`slopsmith-desktop/scripts/bundle-slopsmith.sh`).
|
`feedBack-desktop/scripts/bundle-feedBack.sh`).
|
||||||
|
|
||||||
Unlike `tutorials-builtin/`, `diagnostics-builtin/` **is** included in the
|
Unlike `tutorials-builtin/`, `diagnostics-builtin/` **is** included in the
|
||||||
library scan. Tracks appear under **Slopsmith** /
|
library scan. Tracks appear under **FeedBack** /
|
||||||
**Technique Assessment Diagnostics**.
|
**Technique Assessment Diagnostics**.
|
||||||
|
|
||||||
Existing destination files are not overwritten unless the bundled source
|
Existing destination files are not overwritten unless the bundled source
|
||||||
@@ -55,10 +55,10 @@ are never touched.
|
|||||||
Normally seeding is automatic once a DLC folder is configured. To test a
|
Normally seeding is automatic once a DLC folder is configured. To test a
|
||||||
custom copy or an unreleased build:
|
custom copy or an unreleased build:
|
||||||
|
|
||||||
1. Copy `slopsmith-diagnostic-basic-guitar.sloppak` into your Slopsmith
|
1. Copy `feedBack-diagnostic-basic-guitar.sloppak` into your FeedBack
|
||||||
DLC folder (e.g. `diagnostics-test/` or any scanned path).
|
DLC folder (e.g. `diagnostics-test/` or any scanned path).
|
||||||
2. Restart Slopsmith or trigger a library rescan if the song does not appear.
|
2. Restart FeedBack or trigger a library rescan if the song does not appear.
|
||||||
3. Load **Slopsmith Diagnostic — Basic Guitar**.
|
3. Load **FeedBack Diagnostic — Basic Guitar**.
|
||||||
4. Play the **Diagnostic Guitar** arrangement.
|
4. Play the **Diagnostic Guitar** arrangement.
|
||||||
5. Confirm the 3D highway shows open notes and power-chord gems.
|
5. Confirm the 3D highway shows open notes and power-chord gems.
|
||||||
6. Turn **Detect** on — note_detect should push the chart to the desktop
|
6. Turn **Detect** on — note_detect should push the chart to the desktop
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
"""Build the Slopsmith Diagnostic — Basic Guitar sloppak (POC).
|
"""Build the FeedBack Diagnostic — Basic Guitar sloppak (POC).
|
||||||
|
|
||||||
A short, generated, non-copyrighted mini-song for technique-assessment
|
A short, generated, non-copyrighted mini-song for technique-assessment
|
||||||
style checks: open strings, one fretted note, and repeated E5 power chords.
|
style checks: open strings, one fretted note, and repeated E5 power chords.
|
||||||
Click-track backing only — no external audio.
|
Click-track backing only — no external audio.
|
||||||
|
|
||||||
Run from the slopsmith repo root:
|
Run from the feedBack repo root:
|
||||||
|
|
||||||
python3 docs/diagnostics/build_diagnostic_basic_guitar.py
|
python3 docs/diagnostics/build_diagnostic_basic_guitar.py
|
||||||
|
|
||||||
Output (zip archive):
|
Output (zip archive):
|
||||||
|
|
||||||
docs/diagnostics/slopsmith-diagnostic-basic-guitar.sloppak
|
docs/diagnostics/feedBack-diagnostic-basic-guitar.sloppak
|
||||||
|
|
||||||
Pattern matches docs/benchmarks/note_detect_v1/build_benchmark.py.
|
Pattern matches docs/benchmarks/note_detect_v1/build_benchmark.py.
|
||||||
"""
|
"""
|
||||||
@@ -289,8 +289,8 @@ def build_chart():
|
|||||||
}
|
}
|
||||||
|
|
||||||
manifest = {
|
manifest = {
|
||||||
'title': 'Slopsmith Diagnostic — Basic Guitar',
|
'title': 'FeedBack Diagnostic — Basic Guitar',
|
||||||
'artist': 'Slopsmith',
|
'artist': 'FeedBack',
|
||||||
'album': 'Technique Assessment Diagnostics',
|
'album': 'Technique Assessment Diagnostics',
|
||||||
'year': 2026,
|
'year': 2026,
|
||||||
'duration': round(end_t, 3),
|
'duration': round(end_t, 3),
|
||||||
@@ -405,7 +405,7 @@ def build(output_zip: Path) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def _diagnostic_readme(duration_s: float) -> str:
|
def _diagnostic_readme(duration_s: float) -> str:
|
||||||
return f"""# Slopsmith Diagnostic — Basic Guitar
|
return f"""# FeedBack Diagnostic — Basic Guitar
|
||||||
|
|
||||||
Short generated diagnostic track for technique-assessment style checks.
|
Short generated diagnostic track for technique-assessment style checks.
|
||||||
Non-copyrighted click-track backing only.
|
Non-copyrighted click-track backing only.
|
||||||
@@ -422,7 +422,7 @@ Built by docs/diagnostics/build_diagnostic_basic_guitar.py
|
|||||||
|
|
||||||
def main():
|
def main():
|
||||||
repo_root = Path(__file__).resolve().parents[2]
|
repo_root = Path(__file__).resolve().parents[2]
|
||||||
default_out = Path(__file__).resolve().parent / 'slopsmith-diagnostic-basic-guitar.sloppak'
|
default_out = Path(__file__).resolve().parent / 'feedBack-diagnostic-basic-guitar.sloppak'
|
||||||
out = Path(sys.argv[1]) if len(sys.argv) > 1 else default_out
|
out = Path(sys.argv[1]) if len(sys.argv) > 1 else default_out
|
||||||
if not out.is_absolute():
|
if not out.is_absolute():
|
||||||
out = repo_root / out
|
out = repo_root / out
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,246 @@
|
|||||||
|
# Host Theme Contract — design proposal
|
||||||
|
|
||||||
|
**Status:** proposal (charrette output, 2026-06-29) · **Owner area:** core v3 + plugin UI
|
||||||
|
**Trigger:** a plugin UI feature accidentally "carved itself into a single theme."
|
||||||
|
|
||||||
|
## 1. Problem
|
||||||
|
|
||||||
|
A results-card feature in the `note_detect` plugin (a glow-ring hero button + a
|
||||||
|
gradient-filled accuracy number) was built and visually verified against **only the
|
||||||
|
default skin** ("neon"). On the other skins it broke: on "esports" — a deliberately
|
||||||
|
glow-less, near-monochrome design language — the glow ring and the colour gradient
|
||||||
|
simply **vanished**. The colours adapted (everything used CSS custom-property tokens),
|
||||||
|
but the **visual devices themselves did not port**, because nothing in the system says
|
||||||
|
"this theme does / doesn't do glow rings."
|
||||||
|
|
||||||
|
### Root cause (three findings)
|
||||||
|
|
||||||
|
1. **Themes are design *languages*, not palettes.** neon = glow + animation + gradients;
|
||||||
|
esports = no-glow, square, near-monochrome amber; metal = brushed steel + hard bevels +
|
||||||
|
drop-shadows. Tokens made *colour* portable; they never made a *device* portable.
|
||||||
|
2. **Tokens are named by *device*, not *intent*.** e.g. `--nd-glow-*` holds a glow in neon
|
||||||
|
but a **hard drop-shadow** in metal — the metal skin is already repurposing a
|
||||||
|
device-named slot to express a different language. The cure is to finish that move:
|
||||||
|
name slots by intent, with "off" (`none`) a legal value.
|
||||||
|
3. **No "text-legible-on-accent" role.** White-on-accent was hardcoded in several places;
|
||||||
|
on esports' amber accent that's a contrast failure. And `--nd-accent2` was
|
||||||
|
**double-booked** (gradient-end *and* S-grade colour), so the hero gradient resolved
|
||||||
|
amber→near-white and washed out.
|
||||||
|
|
||||||
|
A process gap compounds it: **verification covered one skin**, so the regression was
|
||||||
|
invisible until a user switched themes. And this recurs ecosystem-wide — other plugins
|
||||||
|
ship their own independent skin systems too.
|
||||||
|
|
||||||
|
## 2. Current state (two disconnected systems)
|
||||||
|
|
||||||
|
| System | What it is | Limits |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Host themes** (`static/v3/theme-core.js`, `html[data-fb-theme]`) | Cosmetic "shop" themes that recolour `fb-*` Tailwind tokens (surfaces/text/borders). | Apply-only & recolour-only. `--fbv-*` vars exist **only while a theme is equipped** (nothing to read in the default state). No read API, no capability signal, no normalized `theme:changed` event. Comment explicitly says it *leaves decorative accents (rings/shadows) at defaults* → **devices are an ownerless gap.** |
|
||||||
|
| **Plugin skins** (e.g. `note_detect` `data-nd-skin`) | Full per-plugin design languages (neon/esports/metal) as CSS-var blocks. | Each plugin reinvents the wheel; disconnected from host themes; a feature can't see both. |
|
||||||
|
|
||||||
|
## 3. Goals / non-goals
|
||||||
|
|
||||||
|
- **Goal:** a feature, authored once, renders correctly in **any** theme — including ones not
|
||||||
|
yet invented — and degrades **intentionally** (neon ring → esports border), never accidentally.
|
||||||
|
- **Goal:** the host owns a canonical contract so plugins consume instead of reinventing.
|
||||||
|
- **Non-goal:** forcing every plugin skin to become a host theme. Skins stay plugin-local but
|
||||||
|
**implement** the contract.
|
||||||
|
- **Non-goal:** backward-compat with pre-v3 hosts. Everything here is additive + feature-detected.
|
||||||
|
|
||||||
|
## 4. The contract — three layers
|
||||||
|
|
||||||
|
### Layer 1 — Semantic colour **roles** (always present)
|
||||||
|
|
||||||
|
The host writes default `--fb-*` role tokens on `:root` **unconditionally** (not only under
|
||||||
|
`[data-fb-theme]`), seeded from the canonical `fb` palette, so `var(--fb-accent, …)` always
|
||||||
|
resolves — themed or not. Roles:
|
||||||
|
|
||||||
|
**Namespace (normative).** The public contract lives under one prefix, **`--fb-*`**, written on
|
||||||
|
`:root` by a host-owned *contract stylesheet* (see §6 / §8) so it is present **themed or not**.
|
||||||
|
The existing `--fbv-*` vars stay **internal plumbing** — `theme-core.js` uses them only to
|
||||||
|
recolour the Tailwind `.bg-fb-*/.text-fb-*/.border-fb-*` utilities under `html[data-fb-theme]`;
|
||||||
|
they are **not** part of this contract and plugins must not read them. (Implementation may seed
|
||||||
|
`--fb-*` from the same source the `--fbv-*` overrides use, so an equipped theme moves both.)
|
||||||
|
|
||||||
|
**Value grammar (normative).** Colour roles are a **space-separated `r g b` triplet** (matching
|
||||||
|
today's `--fbv-*` and the Tailwind utilities), consumed as `rgb(var(--fb-accent))` with optional
|
||||||
|
alpha `rgb(var(--fb-accent) / .5)`. Recipe slots (Layer 2) hold **full CSS values** for their
|
||||||
|
device (a `box-shadow`, a `border` shorthand, a length, a paint), with `none` legal **except**
|
||||||
|
where noted.
|
||||||
|
|
||||||
|
**Normative role tokens** (all `--fb-*`, all always present):
|
||||||
|
|
||||||
|
| Role | Token | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| surface / card / border | `--fb-surface` `--fb-card` `--fb-border` | structural |
|
||||||
|
| text / dim | `--fb-text` `--fb-text-dim` | |
|
||||||
|
| accent / second hue | `--fb-accent` `--fb-accent-2` | `accent-2` is **just a second hue** — never an assumed gradient end |
|
||||||
|
| status | `--fb-good` `--fb-warn` `--fb-bad` | maps onto today's palette `good / mid / low` (mid→warn, low→bad) — implementation aliases both |
|
||||||
|
| **on-fill (new)** | `--fb-on-accent` `--fb-on-good` `--fb-on-warn` `--fb-on-bad` | **Rule: every role used as a fill behind text gets a paired `--fb-on-*`** (fixes white-on-amber). Required + contrast-linted (§6). |
|
||||||
|
| **focus (new)** | `--fb-focus-ring` | focus indicator independent of `accent`, so focus stays visible when `accent ≈ surface` |
|
||||||
|
|
||||||
|
### Layer 2 — Capability **recipes** (intent-named slots; "off" is legal)
|
||||||
|
|
||||||
|
A theme declares its design *language* by filling intent-named slots (all `--fb-*`-prefixed,
|
||||||
|
same namespace as the roles). A feature applies the slot bundle **unconditionally**; it never
|
||||||
|
branches on "is this theme glowy?". Atomic slots (renames-by-intent of today's tokens):
|
||||||
|
`--fb-corner-radius`, `--fb-corner-clip`, `--fb-panel-shadow`, `--fb-text-emph-shadow`,
|
||||||
|
`--fb-panel-texture`, `--fb-motion-decorative` (reduced-motion-gated). For these, `none` is legal.
|
||||||
|
|
||||||
|
Two **composite recipes** carry the load:
|
||||||
|
|
||||||
|
- **EMPHASIS** — how this theme makes a primary action special:
|
||||||
|
`--fb-emph-fill / --fb-emph-border / --fb-emph-halo / --fb-emph-on`.
|
||||||
|
neon → halo (glow ring); esports → border (solid accent); metal → fill + drop-shadow.
|
||||||
|
Any individual slot may be `none` — but a theme **must** emphasise *somehow* (at least one of
|
||||||
|
fill/border/halo non-`none`), so a primary action is never visually flat.
|
||||||
|
- **ACCENT-TEXT** — how this theme fills a big accent number: `--fb-acc-text-fill`
|
||||||
|
(decoupled from `accent-2`). neon/metal → a gradient; esports → a solid accent.
|
||||||
|
**`--fb-acc-text-fill` is the one slot where `none` is illegal** — it is always a valid paint
|
||||||
|
(solid colour or gradient), defaulting to `rgb(var(--fb-accent))`. Reason: the number is
|
||||||
|
rendered with `background-clip: text` + transparent text-fill, so a `none` paint would make
|
||||||
|
the digits **invisible** (transparent fill, nothing to clip) — which would violate the DoD
|
||||||
|
"a device stays legible when its slot resolves to `none`". The feature also feature-detects
|
||||||
|
`background-clip: text` and keeps a solid `color` base (see §5), so the digits are legible
|
||||||
|
even where clip-text is unsupported.
|
||||||
|
|
||||||
|
> These generalize the interim per-skin tokens already shipped in `note_detect`
|
||||||
|
> (`--nd-hero-ring-idle/on`, `--nd-hero-border`, `--nd-acc-fill`).
|
||||||
|
|
||||||
|
### Layer 3 — JS read API + reconciliation
|
||||||
|
|
||||||
|
**The JS API is only for renderers that can't use CSS (canvas / WebGL), never for DOM/CSS
|
||||||
|
consumers** — those use the tokens and slots directly (§5). Critically, it exposes *resolved
|
||||||
|
token values*, **not** theme-style booleans: a `glow:false` flag can't tell a canvas whether to
|
||||||
|
draw a border, a bevel, a drop-shadow, or flat text, so there is **no** `capabilities()` of
|
||||||
|
booleans. On the existing `window.feedBack` bus:
|
||||||
|
|
||||||
|
- `feedBack.theme.get()` → `{ id, isThemed, tokens }` where `tokens` is the **resolved** map of
|
||||||
|
every `--fb-*` role + recipe slot (the computed values, so a canvas reads the actual device,
|
||||||
|
e.g. the gradient stops for `--fb-acc-text-fill`, not a boolean).
|
||||||
|
- `feedBack.theme.prefersReducedMotion()` → boolean (host wraps `matchMedia` once). **This is the
|
||||||
|
single approved JS reduced-motion gate going forward** — existing direct `matchMedia` callers
|
||||||
|
(`venue-mood-fx.js`, `pedal-cables.js`) migrate to it; `--fb-motion-decorative` covers the
|
||||||
|
CSS-authored decorative motion.
|
||||||
|
- `theme:changed` event → `{ id, tokens }`.
|
||||||
|
|
||||||
|
**Lifecycle (normative).** `get()` always returns the **current effective theme synchronously**
|
||||||
|
and is valid at any time — before any theme is applied it returns the default/unthemed roles
|
||||||
|
(which always exist on `:root`). Theme application is async (it follows a `/api/profile` refresh);
|
||||||
|
`theme:changed` fires **only after** the DOM vars/classes are committed, and **once on initial
|
||||||
|
hydration** so a late-mounting plugin isn't stuck on stale state. **Plugin rule:** read `get()`
|
||||||
|
on mount, then subscribe to `theme:changed` — never assume an order between your mount and the
|
||||||
|
first theme apply.
|
||||||
|
|
||||||
|
**Reconciliation rule (ends the two-disconnected-systems problem):** a plugin skin
|
||||||
|
**derives surface/text/border from host tokens** (`--nd-bg: rgb(var(--fb-card))`, etc.) and
|
||||||
|
**owns only its accent + its devices**, selecting the device via the recipe. A host theme then
|
||||||
|
pulls plugin chrome along (one truth for surfaces), while the plugin layers identity on top and
|
||||||
|
never imposes a device the active theme neutralizes.
|
||||||
|
|
||||||
|
**Propagation scope (normative).** The contract is **same-document light-DOM**: `:root` `--fb-*`
|
||||||
|
inheritance and the central focus/motion rules (§6) reach any normal plugin screen. A plugin that
|
||||||
|
renders into a **shadow root or iframe** is responsible for bridging — copy the resolved
|
||||||
|
`get().tokens` into its sub-root and re-subscribe to `theme:changed` (host `:root` vars don't
|
||||||
|
cross those boundaries).
|
||||||
|
|
||||||
|
## 5. Consumption pattern (the rule for feature authors)
|
||||||
|
|
||||||
|
> **A feature may reference a colour *role* or a recipe *slot*. It may never write a raw
|
||||||
|
> device — no literal glow `box-shadow`, no literal `linear-gradient`, no hex.** Devices live
|
||||||
|
> in slots; the theme owns the slots.
|
||||||
|
|
||||||
|
```css
|
||||||
|
.hero-cta {
|
||||||
|
background: var(--fb-emph-fill);
|
||||||
|
border: var(--fb-emph-border);
|
||||||
|
box-shadow: var(--fb-emph-halo); /* neon→ring · esports→none · metal→drop-shadow */
|
||||||
|
color: var(--fb-emph-on); /* never hardcoded #fff again */
|
||||||
|
border-radius: var(--fb-corner-radius);
|
||||||
|
}
|
||||||
|
.accuracy-number {
|
||||||
|
/* Always-legible solid base; survives no-clip-text support too. */
|
||||||
|
color: rgb(var(--fb-accent));
|
||||||
|
}
|
||||||
|
/* Apply the clipped paint ONLY where supported — and --fb-acc-text-fill is
|
||||||
|
guaranteed a real paint (never `none`, per Layer 2), so the digits can't go
|
||||||
|
invisible. */
|
||||||
|
@supports ((background-clip: text) or (-webkit-background-clip: text)) {
|
||||||
|
.accuracy-number {
|
||||||
|
background: var(--fb-acc-text-fill);
|
||||||
|
-webkit-background-clip: text; background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Where the contract physically lives.** A **host-owned static contract stylesheet** (e.g.
|
||||||
|
`static/v3/theme-contract.css`, hand-authored, linked from `static/v3/index.html`) holds the
|
||||||
|
always-present `:root --fb-*` defaults **plus** the two central a11y rules below. It is **not** a
|
||||||
|
Tailwind file, so it never touches the prebuilt `static/tailwind.min.css` artifact the
|
||||||
|
`tailwind-fresh` CI check diffs (and it's independent of `theme-core.js`, which keeps
|
||||||
|
runtime-injecting only the `--fbv-*` utility overrides under `[data-fb-theme]`).
|
||||||
|
|
||||||
|
- **Reduced motion:** `--fb-motion-decorative` is the *only* place CSS decorative animation is
|
||||||
|
named; one central rule in the contract sheet sets it to `none` under
|
||||||
|
`@media (prefers-reduced-motion: reduce)`, so no theme can forget the gate. (JS-driven motion
|
||||||
|
uses `feedBack.theme.prefersReducedMotion()` — §4.3.)
|
||||||
|
- **Focus parity:** one contract-level `:focus-visible { outline: 2px solid rgb(var(--fb-focus-ring)) }`
|
||||||
|
for contract consumers; themes recolour `--fb-focus-ring` but may not author their own focus
|
||||||
|
styling. *Migration:* v3 already ships component-specific focus + reduced-motion rules in
|
||||||
|
`v3.css`; those are reconciled onto the contract token (not magically replaced) as a tracked
|
||||||
|
cleanup — "one rule" describes the end state, not day one.
|
||||||
|
- **On-fill contrast:** every `--fb-on-*` is required and **lintable**
|
||||||
|
(`contrast(on-X, X) ≥ 4.5:1`, 3:1 large) for each fill role (`accent / good / warn / bad`).
|
||||||
|
Contrast is the theme's job, computed once — not re-judged per feature.
|
||||||
|
|
||||||
|
## 7. Verification gate (prevent recurrence)
|
||||||
|
|
||||||
|
- A committed **render-matrix** tool, driven off the runtime skin list, that renders the key
|
||||||
|
surfaces (hero CTA, accent number, **and the canvas share-image card**) across **every skin ×
|
||||||
|
key states** (rest / hover / focus / reduced-motion).
|
||||||
|
- The gate is **computed-style invariant assertions** (deterministic, CI-safe) — e.g. "emphasis
|
||||||
|
present and text legible in each theme" — **not** pixel-snapshot diffing (the animated ring +
|
||||||
|
fonts + AA make snapshots flaky); a contact-sheet montage is the human backstop.
|
||||||
|
- Triggered on the version bump that CSS changes already require; skins enumerated at runtime +
|
||||||
|
a guard test so the matrix can't silently go stale.
|
||||||
|
|
||||||
|
**Definition-of-done for any theme-touching UI change** (the few items that would have caught this):
|
||||||
|
expressed via tokens not hardcoded values · rendered across all skins · **a new visual *device*
|
||||||
|
stays legible when its slot resolves to `none`** · reduced-motion + focus parity · on-accent contrast.
|
||||||
|
|
||||||
|
## 8. Back-compat & rollout
|
||||||
|
|
||||||
|
All additive: the new always-present `--fb-*` tokens (in the contract sheet, §6) + a new
|
||||||
|
`feedBack.theme` namespace + a new event with no current listeners. Existing plugins (those
|
||||||
|
reading `fb-*` Tailwind utility classes, or shipping their own skins) are untouched unless they
|
||||||
|
opt in. On a host too old to ship the contract sheet, a consumer still degrades cleanly: the
|
||||||
|
two-arg fallback `rgb(var(--fb-accent, 224 128 32))` resolves to the literal, and
|
||||||
|
`window.feedBack?.theme?.get?.()` is feature-detected — so older hosts behave exactly as today.
|
||||||
|
|
||||||
|
**Workstream (sub-tasks):**
|
||||||
|
1. **Host minimal surface** — the contract stylesheet's always-present default `--fb-*` tokens + `feedBack.theme.{get, prefersReducedMotion}` (`get().tokens` = resolved values; no boolean `capabilities()`) + `theme:changed`. *(the smallest thing that would have prevented the incident)*
|
||||||
|
2. **note_detect refactor** — rename device tokens by intent (EMPHASIS + ACCENT-TEXT recipes), add `on-accent` + `focus-ring`, derive surfaces from host tokens.
|
||||||
|
3. **Verification gate** — commit the render-matrix + DoD checklist; add the canvas share-card surface.
|
||||||
|
4. **Ecosystem migration guide** — document the contract + the consumption rule for community plugin authors.
|
||||||
|
|
||||||
|
## 9. Cross-apply status (already done)
|
||||||
|
|
||||||
|
- `note_detect` results-card hero + accuracy number — fixed via per-skin device tokens
|
||||||
|
(the Layer-2 prototype) and verified across neon/esports/metal.
|
||||||
|
- The **canvas share-image card** — re-checked across all three skins: **theme-robust**
|
||||||
|
(reads per-skin colour tokens via computed style, draws skin-neutral solid devices). Minor
|
||||||
|
fidelity gap only: it uses flat `--nd-bg` and skips metal's brushed-steel *texture*.
|
||||||
|
|
||||||
|
## 10. Open questions
|
||||||
|
|
||||||
|
- Should plugin skins eventually become *selectable host themes* (one picker), or stay
|
||||||
|
plugin-local forever? (This proposal assumes plugin-local + contract-implementing.)
|
||||||
|
- Component-recipe **bundles** (per named component) are the richer end-state; intent-named
|
||||||
|
slots are the right seed. When/whether to graduate.
|
||||||
|
|
||||||
|
*(Resolved during review and folded into the sections above: the token namespace + value grammar
|
||||||
|
and normative role table (§4.1); the `none`-is-illegal carve-out for `--fb-acc-text-fill` (§4.2);
|
||||||
|
JS exposes resolved tokens, not booleans (§4.3); `theme:changed` lifecycle + shadow/iframe
|
||||||
|
propagation (§4.3); the physical home of the role tokens + central focus/motion rules — a
|
||||||
|
host-owned contract stylesheet outside Tailwind (§6).)*
|
||||||
+11
-11
@@ -13,7 +13,7 @@ Detection quality varies by guitar pickup, audio interface, monitor latency, the
|
|||||||
|
|
||||||
## The benchmark sloppak
|
## The benchmark sloppak
|
||||||
|
|
||||||
The distributable sloppak ships in-tree at [docs/benchmarks/note_detect_v1/note_detect_benchmark_v1.sloppak](benchmarks/note_detect_v1/note_detect_benchmark_v1.sloppak) — drop it directly in your library folder (e.g. `<your-library>/sloppak/`) and it shows up in the library. The file is a zip under the hood but slopsmith's loader (`is_sloppak`) keys off the `.sloppak` suffix, so don't rename. After playing it once it ends up extracted under `static/sloppak_cache/note_detect_benchmark_v1.sloppak/`, which is where the harness reads its `arrangements/lead.json` from. 90 BPM, 8 numbered sections, ~2:20 total:
|
The distributable sloppak ships in-tree at [docs/benchmarks/note_detect_v1/note_detect_benchmark_v1.sloppak](benchmarks/note_detect_v1/note_detect_benchmark_v1.sloppak) — drop it directly in your library folder (e.g. `<your-library>/sloppak/`) and it shows up in the library. The file is a zip under the hood but feedBack's loader (`is_sloppak`) keys off the `.sloppak` suffix, so don't rename. After playing it once it ends up extracted under `static/sloppak_cache/note_detect_benchmark_v1.sloppak/`, which is where the harness reads its `arrangements/lead.json` from. 90 BPM, 8 numbered sections, ~2:20 total:
|
||||||
|
|
||||||
| Section | Notes | Isolates |
|
| Section | Notes | Isolates |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -28,10 +28,10 @@ The distributable sloppak ships in-tree at [docs/benchmarks/note_detect_v1/note_
|
|||||||
|
|
||||||
Every chart note has `sus > 0` — so anything you tune against this benchmark exercises the sustain path, not staccato detection. (If we add a staccato section later, the cleanest split is by section name; don't categorize by `sus` value on the event log — see the "Common pitfalls" section.)
|
Every chart note has `sus > 0` — so anything you tune against this benchmark exercises the sustain path, not staccato detection. (If we add a staccato section later, the cleanest split is by section name; don't categorize by `sus` value on the event log — see the "Common pitfalls" section.)
|
||||||
|
|
||||||
To rebuild after edits to the exercise list, follow the docstring at the top of `build_benchmark.py`. The script writes both an unzipped directory (`.sloppak/`) and a zipped archive (`.sloppak.zip`). The slopsmith library scanner (`lib/sloppak.py::is_sloppak()`) matches on the `.sloppak` suffix, **not** on `.sloppak.zip` — the directory form is usable as-is, but the zip output needs its suffix swapped before it'll be discovered. After regenerating, copy the zip output to the tracked path with the `.sloppak` suffix so it stays a drop-in install. Run from the slopsmith repo root so the relative paths resolve:
|
To rebuild after edits to the exercise list, follow the docstring at the top of `build_benchmark.py`. The script writes both an unzipped directory (`.sloppak/`) and a zipped archive (`.sloppak.zip`). The feedBack library scanner (`lib/sloppak.py::is_sloppak()`) matches on the `.sloppak` suffix, **not** on `.sloppak.zip` — the directory form is usable as-is, but the zip output needs its suffix swapped before it'll be discovered. After regenerating, copy the zip output to the tracked path with the `.sloppak` suffix so it stays a drop-in install. Run from the feedBack repo root so the relative paths resolve:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# From the slopsmith repo root.
|
# From the feedBack repo root.
|
||||||
cp static/sloppak_cache/note_detect_benchmark_v1.sloppak.zip \
|
cp static/sloppak_cache/note_detect_benchmark_v1.sloppak.zip \
|
||||||
docs/benchmarks/note_detect_v1/note_detect_benchmark_v1.sloppak
|
docs/benchmarks/note_detect_v1/note_detect_benchmark_v1.sloppak
|
||||||
```
|
```
|
||||||
@@ -46,7 +46,7 @@ The typical cycle for one tuning hypothesis:
|
|||||||
2. **Arm a recording** from the gear popover next to the Detect button on the player. Arm before pressing Play.
|
2. **Arm a recording** from the gear popover next to the Detect button on the player. Arm before pressing Play.
|
||||||
3. **Play through the benchmark** (or any song) at **1.0× playback speed**. Half-speed playback breaks audio↔chart alignment and produces all-miss garbage — see Pitfalls.
|
3. **Play through the benchmark** (or any song) at **1.0× playback speed**. Half-speed playback breaks audio↔chart alignment and produces all-miss garbage — see Pitfalls.
|
||||||
4. **Auto-save fires on song end.** The WAV lands in `static/note_detect_recordings/note_detect_<slug>_<timestamp>.wav` (bind-mounted, so it's reachable from the host without a copy step).
|
4. **Auto-save fires on song end.** The WAV lands in `static/note_detect_recordings/note_detect_<slug>_<timestamp>.wav` (bind-mounted, so it's reachable from the host without a copy step).
|
||||||
5. **Run the headless harness** with a known config. Paths below assume the note_detect plugin is cloned into `plugins/note_detect/` (see the slopsmith README for the plugin-install flow — note_detect ships as a separate repo):
|
5. **Run the headless harness** with a known config. Paths below assume the note_detect plugin is cloned into `plugins/note_detect/` (see the feedBack README for the plugin-install flow — note_detect ships as a separate repo):
|
||||||
```bash
|
```bash
|
||||||
node plugins/note_detect/tools/harness.js \
|
node plugins/note_detect/tools/harness.js \
|
||||||
--audio static/note_detect_recordings/note_detect_<…>.wav \
|
--audio static/note_detect_recordings/note_detect_<…>.wav \
|
||||||
@@ -162,7 +162,7 @@ The same workflow works on any tuning change — A/V offset sweep, frame-size sw
|
|||||||
|
|
||||||
### "Did my detector change improve things?" — ad hoc
|
### "Did my detector change improve things?" — ad hoc
|
||||||
|
|
||||||
Same recording, same chart, two harness runs. Recipe assumes you're at the slopsmith repo root *and* that the Note Detection plugin is cloned at `plugins/note_detect/` per the README. The detector source lives in that nested plugin repo, which slopsmith's `.gitignore` excludes via `plugins/*/`, so the stash dance has to run **inside** the plugin repo — `git stash` from the slopsmith root would either bail out or, worse, stash unrelated slopsmith edits.
|
Same recording, same chart, two harness runs. Recipe assumes you're at the feedBack repo root *and* that the Note Detection plugin is cloned at `plugins/note_detect/` per the README. The detector source lives in that nested plugin repo, which feedBack's `.gitignore` excludes via `plugins/*/`, so the stash dance has to run **inside** the plugin repo — `git stash` from the feedBack root would either bail out or, worse, stash unrelated feedBack edits.
|
||||||
|
|
||||||
The stash dance below uses **`git stash push -u -m "..."`** to give the stash a known name *and* include untracked files. `-u` matters: if your detector change added a new module or fixture, an untracked-file-blind stash would leave it on disk during the "before" run and contaminate the baseline. The script then asserts a stash was actually created before popping (so a clean worktree doesn't silently pop someone else's WIP), wraps each step in **`set -euo pipefail`** so a failed `git stash pop` (e.g., conflict) aborts before the "after" harness records an invalid result, and uses `trap` to surface any failure with a clear message.
|
The stash dance below uses **`git stash push -u -m "..."`** to give the stash a known name *and* include untracked files. `-u` matters: if your detector change added a new module or fixture, an untracked-file-blind stash would leave it on disk during the "before" run and contaminate the baseline. The script then asserts a stash was actually created before popping (so a clean worktree doesn't silently pop someone else's WIP), wraps each step in **`set -euo pipefail`** so a failed `git stash pop` (e.g., conflict) aborts before the "after" harness records an invalid result, and uses `trap` to surface any failure with a clear message.
|
||||||
|
|
||||||
@@ -172,7 +172,7 @@ PLUGIN_DIR=plugins/note_detect
|
|||||||
HARNESS=$PLUGIN_DIR/tools/harness.js
|
HARNESS=$PLUGIN_DIR/tools/harness.js
|
||||||
STASH_MSG="harness-before-$$"
|
STASH_MSG="harness-before-$$"
|
||||||
trap 'echo "harness recipe aborted — stash may still be in $PLUGIN_DIR (\"git -C $PLUGIN_DIR stash list\")" >&2' ERR
|
trap 'echo "harness recipe aborted — stash may still be in $PLUGIN_DIR (\"git -C $PLUGIN_DIR stash list\")" >&2' ERR
|
||||||
# Stash the detector edits inside the plugin repo, not the slopsmith root.
|
# Stash the detector edits inside the plugin repo, not the feedBack root.
|
||||||
# -u also stashes untracked files (new modules, fixtures) so they don't
|
# -u also stashes untracked files (new modules, fixtures) so they don't
|
||||||
# leak into the "before" baseline. `|| true` only swallows the
|
# leak into the "before" baseline. `|| true` only swallows the
|
||||||
# clean-worktree case, which the next line catches explicitly.
|
# clean-worktree case, which the next line catches explicitly.
|
||||||
@@ -223,9 +223,9 @@ Find the note's `t` in the chart, then grep the event log for entries near that
|
|||||||
|
|
||||||
The Note Detection plugin lives in its own repository — these links go to the canonical source at github.com. If you've cloned the plugin into a local `plugins/note_detect/` next to this repo, the same files are at the equivalent path on disk.
|
The Note Detection plugin lives in its own repository — these links go to the canonical source at github.com. If you've cloned the plugin into a local `plugins/note_detect/` next to this repo, the same files are at the equivalent path on disk.
|
||||||
|
|
||||||
- Plugin source: [`screen.js`](https://github.com/got-feedback/feedback-plugin-notedetect/blob/main/screen.js) — `matchNotes`, `checkMisses`, `_diagTimingErrors` / `_diagTimingErrorsHits`, `getDiagnostic`.
|
- Plugin source: [`screen.js`](https://github.com/got-feedback/feedBack-plugin-notedetect/blob/main/screen.js) — `matchNotes`, `checkMisses`, `_diagTimingErrors` / `_diagTimingErrorsHits`, `getDiagnostic`.
|
||||||
- Routes: [`routes.py`](https://github.com/got-feedback/feedback-plugin-notedetect/blob/main/routes.py) — the `/api/plugins/note_detect/recording` and `/api/plugins/note_detect/live-judgment` endpoints.
|
- Routes: [`routes.py`](https://github.com/got-feedback/feedBack-plugin-notedetect/blob/main/routes.py) — the `/api/plugins/note_detect/recording` and `/api/plugins/note_detect/live-judgment` endpoints.
|
||||||
- Harness: [`tools/harness.js`](https://github.com/got-feedback/feedback-plugin-notedetect/blob/main/tools/harness.js).
|
- Harness: [`tools/harness.js`](https://github.com/got-feedback/feedBack-plugin-notedetect/blob/main/tools/harness.js).
|
||||||
- Regression driver: [`tools/regression.js`](https://github.com/got-feedback/feedback-plugin-notedetect/blob/main/tools/regression.js).
|
- Regression driver: [`tools/regression.js`](https://github.com/got-feedback/feedBack-plugin-notedetect/blob/main/tools/regression.js).
|
||||||
- Benchmark builder: [docs/benchmarks/note_detect_v1/build_benchmark.py](benchmarks/note_detect_v1/build_benchmark.py).
|
- Benchmark builder: [docs/benchmarks/note_detect_v1/build_benchmark.py](benchmarks/note_detect_v1/build_benchmark.py).
|
||||||
- Settings UI: [`settings.html`](https://github.com/got-feedback/feedback-plugin-notedetect/blob/main/settings.html) — A/V auto-calibrate panel, tuning-mode toggle, diagnostic block.
|
- Settings UI: [`settings.html`](https://github.com/got-feedback/feedBack-plugin-notedetect/blob/main/settings.html) — A/V auto-calibrate panel, tuning-mode toggle, diagnostic block.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Plugin Capability Inventory
|
# Plugin Capability Inventory
|
||||||
|
|
||||||
This report inventories the currently included plugins staged in `plugins/` and maps their observed behavior to Slopsmith capability domains. It is intended to inform the capability roadmap and the next migration specs now that PR1, the audio graph/session slice, playback, and audio-effects are active capability surfaces.
|
This report inventories the currently included plugins staged in `plugins/` and maps their observed behavior to FeedBack capability domains. It is intended to inform the capability roadmap and the next migration specs now that PR1, the audio graph/session slice, playback, and audio-effects are active capability surfaces.
|
||||||
|
|
||||||
## Scope And Method
|
## Scope And Method
|
||||||
|
|
||||||
@@ -8,7 +8,7 @@ This report inventories the currently included plugins staged in `plugins/` and
|
|||||||
- Verification pass: the original bundled-plugin scan found 25 plugins with backend `routes.py` and 14 plugins with `settings.html`. First-party plugin repos outside `plugins/` were checked separately from their current manifests and handoff docs.
|
- Verification pass: the original bundled-plugin scan found 25 plugins with backend `routes.py` and 14 plugins with `settings.html`. First-party plugin repos outside `plugins/` were checked separately from their current manifests and handoff docs.
|
||||||
- Most bundled plugin entries below are still inferred/recommended declarations. Current first-party manifests now declare active capability intent for `diagnostics`, `pipeline`, `library`, `audio-mix`, `audio-input`, `audio-monitoring`, `stems`, `playback`, `audio-effects`, `jobs`, and privileged capability inventory surfaces where their repos have already migrated.
|
- Most bundled plugin entries below are still inferred/recommended declarations. Current first-party manifests now declare active capability intent for `diagnostics`, `pipeline`, `library`, `audio-mix`, `audio-input`, `audio-monitoring`, `stems`, `playback`, `audio-effects`, `jobs`, and privileged capability inventory surfaces where their repos have already migrated.
|
||||||
- Manifest fields such as `nav`, `screen`, `settings`, `routes`, and `type: "visualization"` were treated as high-confidence evidence.
|
- Manifest fields such as `nav`, `screen`, `settings`, `routes`, and `type: "visualization"` were treated as high-confidence evidence.
|
||||||
- Code patterns such as `window.slopsmithViz_*`, `window.playSong` wrappers, `window.showScreen` wrappers, `window.registerShortcut`, `window.slopsmithTour.register`, `window.slopsmith.audio.registerFader`, `highway.setNoteStateProvider`, and route/WebSocket handlers were treated as behavior evidence.
|
- Code patterns such as `window.feedBackViz_*`, `window.playSong` wrappers, `window.showScreen` wrappers, `window.registerShortcut`, `window.feedBackTour.register`, `window.feedBack.audio.registerFader`, `highway.setNoteStateProvider`, and route/WebSocket handlers were treated as behavior evidence.
|
||||||
|
|
||||||
## Roadmap Baseline
|
## Roadmap Baseline
|
||||||
|
|
||||||
@@ -80,7 +80,7 @@ The plugin inventory confirms these planned domains are directionally right. The
|
|||||||
| `section_map` | `ui.player-overlays`, `playback` | overlay provider, observer | Planned | High | Highway section overlay behavior. |
|
| `section_map` | `ui.player-overlays`, `playback` | overlay provider, observer | Planned | High | Highway section overlay behavior. |
|
||||||
| `setlist` | `library`, `playback`, `ui.plugin-screens`, `backend.routes` | requester/provider, screen provider, route provider | Library/playback active; UI/routes planned | High | Setlist screen/routes and song selection/playback workflow. |
|
| `setlist` | `library`, `playback`, `ui.plugin-screens`, `backend.routes` | requester/provider, screen provider, route provider | Library/playback active; UI/routes planned | High | Setlist screen/routes and song selection/playback workflow. |
|
||||||
| `sloppak_converter` | `media-import-export`, `jobs`, `library`, `ui.plugin-screens`, `backend.routes`, `ui.library-card-injection` | conversion provider, job provider, route provider | Library active; jobs/UI/routes planned; media/card missing | High | Converter routes, queue UI, library card actions, conversion jobs. |
|
| `sloppak_converter` | `media-import-export`, `jobs`, `library`, `ui.plugin-screens`, `backend.routes`, `ui.library-card-injection` | conversion provider, job provider, route provider | Library active; jobs/UI/routes planned; media/card missing | High | Converter routes, queue UI, library card actions, conversion jobs. |
|
||||||
| `slopscale` | `ui.plugin-screens`, `backend.routes`, `settings`, `visualization` | screen provider, route provider, observer | Planned | High | Routes/settings and 3D highway visualization observation. |
|
| `virtuoso` | `ui.plugin-screens`, `backend.routes`, `settings`, `visualization` | screen provider, route provider, observer | Active | High | Contained practice studio (scale/technique/rhythm drills, workouts, jam backing); borrows the 3D highway visualization. |
|
||||||
| `song_preview` | `playback`, `audio-mix`, `ui.plugin-screens`, `backend.routes`, `settings` | preview provider, route provider, audio participant | Playback/audio-mix active; UI/routes planned | Medium | Preview screen/routes/settings and audio preview behavior. |
|
| `song_preview` | `playback`, `audio-mix`, `ui.plugin-screens`, `backend.routes`, `settings` | preview provider, route provider, audio participant | Playback/audio-mix active; UI/routes planned | Medium | Preview screen/routes/settings and audio preview behavior. |
|
||||||
| `splitscreen` | `ui.player-panels`, `ui.player-overlays`, `visualization`, `playback`, `keyboard-shortcuts`, `settings` | panel provider, observer, shortcut provider | Playback active; UI/visualization planned; shortcuts missing | High | Multi-highway panels, playback/screen wrappers, panel shortcuts/settings. |
|
| `splitscreen` | `ui.player-panels`, `ui.player-overlays`, `visualization`, `playback`, `keyboard-shortcuts`, `settings` | panel provider, observer, shortcut provider | Playback active; UI/visualization planned; shortcuts missing | High | Multi-highway panels, playback/screen wrappers, panel shortcuts/settings. |
|
||||||
| `stem_mixer` | `stems`, `audio-mix`, `ui.plugin-screens`, `backend.routes`, `settings`, `jobs` | stem provider, mixer provider, route provider | Audio active; jobs planned | High | Stems mixer routes/settings and stem/audio mix ownership. |
|
| `stem_mixer` | `stems`, `audio-mix`, `ui.plugin-screens`, `backend.routes`, `settings`, `jobs` | stem provider, mixer provider, route provider | Audio active; jobs planned | High | Stems mixer routes/settings and stem/audio mix ownership. |
|
||||||
@@ -231,11 +231,11 @@ For active domains, command and operation names should follow [capability-domain
|
|||||||
|
|
||||||
## Highway String Colors (data-plane API)
|
## Highway String Colors (data-plane API)
|
||||||
|
|
||||||
User-customizable per-string highway colors (the "Highway String Colors" setting in the 3D Highway plugin's panel) are **not** a capability domain. Consistent with `capability-domains.md` keeping highway-rendering and `visualization` surfaces off the capability graph until a dedicated render-facade slice lands, they are exposed as a synchronous **data-plane** API on `window.slopsmith.highwayColors` plus a change event. Visualization/overlay plugins (custom highways, minigames, fretboard widgets) should read colors from here so their gems/strings match the user's theme.
|
User-customizable per-string highway colors (the "Highway String Colors" setting in the 3D Highway plugin's panel) are **not** a capability domain. Consistent with `capability-domains.md` keeping highway-rendering and `visualization` surfaces off the capability graph until a dedicated render-facade slice lands, they are exposed as a synchronous **data-plane** API on `window.feedBack.highwayColors` plus a change event. Visualization/overlay plugins (custom highways, minigames, fretboard widgets) should read colors from here so their gems/strings match the user's theme.
|
||||||
|
|
||||||
Colors are keyed by **named string slot**, not raw index, so a string keeps its color across arrangements (Low E stays Low E's color on a 6-string guitar, 4-string bass, or 7/8-string, where the extra low strings use the `low7`/`low8` slots). Slots: `highE`, `B`, `G`, `D`, `A`, `lowE`, `low7` (7-string Low B), `low8` (8-string Low F#).
|
Colors are keyed by **named string slot**, not raw index, so a string keeps its color across arrangements (Low E stays Low E's color on a 6-string guitar, 4-string bass, or 7/8-string, where the extra low strings use the `low7`/`low8` slots). Slots: `highE`, `B`, `G`, `D`, `A`, `lowE`, `low7` (7-string Low B), `low8` (8-string Low F#).
|
||||||
|
|
||||||
`window.slopsmith.highwayColors` (`version: 1`):
|
`window.feedBack.highwayColors` (`version: 1`):
|
||||||
|
|
||||||
| Member | Returns | Purpose |
|
| Member | Returns | Purpose |
|
||||||
|--------|---------|---------|
|
|--------|---------|---------|
|
||||||
@@ -250,7 +250,7 @@ Colors are keyed by **named string slot**, not raw index, so a string keeps its
|
|||||||
| `encodeShare(name, map)` / `decodeShare(code)` | `string` / `{name,colors}` | The `SLOPHWY2.` copy/paste share format. |
|
| `encodeShare(name, map)` / `decodeShare(code)` | `string` / `{name,colors}` | The `SLOPHWY2.` copy/paste share format. |
|
||||||
| `onChange(fn)` / `offChange(fn)` | unsubscribe fn | `fn(resolvedMap)` fires on any color change (also on song load when the slot→index mapping shifts). |
|
| `onChange(fn)` / `offChange(fn)` | unsubscribe fn | `fn(resolvedMap)` fires on any color change (also on song load when the slot→index mapping shifts). |
|
||||||
|
|
||||||
The underlying change event is `window.slopsmith.emit('highway:stringColors', …)`; `onChange` wraps it and hands back the resolved map. The raw `window.highway.getStringColors()` data-plane accessor (per-index) remains available for renderers that only need the current applied array. When a `visualization` capability slice eventually lands, this facade is the natural thing to fold into it.
|
The underlying change event is `window.feedBack.emit('highway:stringColors', …)`; `onChange` wraps it and hands back the resolved map. The raw `window.highway.getStringColors()` data-plane accessor (per-index) remains available for renderers that only need the current applied array. When a `visualization` capability slice eventually lands, this facade is the natural thing to fold into it.
|
||||||
|
|
||||||
## Validation Notes
|
## Validation Notes
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://slopsmith.local/contracts/plugin-manifest-capabilities.schema.json",
|
"$id": "https://feedBack.local/contracts/plugin-manifest-capabilities.schema.json",
|
||||||
"title": "Slopsmith Plugin Manifest Capability Contract",
|
"title": "FeedBack Plugin Manifest Capability Contract",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["id", "name"],
|
"required": ["id", "name"],
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
# Plugin styling — the `styles` capability
|
# Plugin styling — the `styles` capability
|
||||||
|
|
||||||
> Building for the redesigned **v3 UI** (`SLOPSMITH_UI=v3` / `/v3`)? v3 uses `fb-*`
|
> Building for the redesigned **v3 UI** (`FEEDBACK_UI=v3` / `/v3`)? v3 uses `fb-*`
|
||||||
> design tokens and a restructured player chrome with a dedicated plugin-control
|
> design tokens and a restructured player chrome with a dedicated plugin-control
|
||||||
> slot. See **[plugin-v3-ui.md](plugin-v3-ui.md)** for the player-chrome contract
|
> slot. See **[plugin-v3-ui.md](plugin-v3-ui.md)** for the player-chrome contract
|
||||||
> plugins must follow in v3.
|
> plugins must follow in v3.
|
||||||
|
|
||||||
Slopsmith serves Tailwind as a **prebuilt** stylesheet
|
FeedBack serves Tailwind as a **prebuilt** stylesheet
|
||||||
(`static/tailwind.min.css`), never the runtime Play CDN. The CDN's on-the-fly
|
(`static/tailwind.min.css`), never the runtime Play CDN. The CDN's on-the-fly
|
||||||
JIT rescanned the DOM on the main thread and dropped ~26% of frames with the 3D
|
JIT rescanned the DOM on the main thread and dropped ~26% of frames with the 3D
|
||||||
highway running (slopsmith-desktop#110). See **constitution Principle II**.
|
highway running (feedBack-desktop#110). See **constitution Principle II**.
|
||||||
|
|
||||||
A prebuilt stylesheet only contains the classes the build scanner saw in **core
|
A prebuilt stylesheet only contains the classes the build scanner saw in **core
|
||||||
source at core build time**. That has a consequence for plugins:
|
source at core build time**. That has a consequence for plugins:
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
# Building plugins for the v3 UI (fee[dB]ack v0.3.0)
|
# Building plugins for the v3 UI (fee[dB]ack v0.3.0)
|
||||||
|
|
||||||
v0.3.0 ("fee[dB]ack") ships a redesigned UI **behind a flag** — `SLOPSMITH_UI=v3`
|
v0.3.0 ("fee[dB]ack") ships a redesigned UI **behind a flag** — `FEEDBACK_UI=v3`
|
||||||
or the `/v3` route. The classic UI (v2) remains the default until 0.3.0 ships, so
|
or the `/v3` route. The classic UI (v2) remains the default until 0.3.0 ships, so
|
||||||
plugins must work in **both**.
|
plugins must work in **both**.
|
||||||
|
|
||||||
The good news: v3 **reuses the same engine** as v2 — same `server.py`, `app.js`,
|
The good news: v3 **reuses the same engine** as v2 — same `server.py`, `app.js`,
|
||||||
`highway.js`, `playSong`, `showScreen`, capability registry, library providers,
|
`highway.js`, `playSong`, `showScreen`, capability registry, library providers,
|
||||||
and the `window.slopsmithViz_<id>` / `setRenderer` visualization contract. So your
|
and the `window.feedBackViz_<id>` / `setRenderer` visualization contract. So your
|
||||||
plugin's **backend, capabilities, library providers, `nav`/`screen`, visualization
|
plugin's **backend, capabilities, library providers, `nav`/`screen`, visualization
|
||||||
renderers, diagnostics, and settings export all work unchanged in v3.** v3 surfaces
|
renderers, diagnostics, and settings export all work unchanged in v3.** v3 surfaces
|
||||||
your `nav` entry in the new sidebar (via `shell.js` `renderPluginNav`) and your
|
your `nav` entry in the new sidebar (via `shell.js` `renderPluginNav`) and your
|
||||||
@@ -34,8 +34,8 @@ So the legacy way of injecting a control breaks in v3 two ways:
|
|||||||
|
|
||||||
The host exposes:
|
The host exposes:
|
||||||
|
|
||||||
- `window.slopsmith.uiVersion === 'v3'` — detect v3 (absent / not `'v3'` in v2).
|
- `window.feedBack.uiVersion === 'v3'` — detect v3 (absent / not `'v3'` in v2).
|
||||||
- `window.slopsmith.ui.playerControlSlot()` — returns a **stable, always-reachable
|
- `window.feedBack.ui.playerControlSlot()` — returns a **stable, always-reachable
|
||||||
container** (the "Plugins" rail popover). In v3, append your control(s) here
|
container** (the "Plugins" rail popover). In v3, append your control(s) here
|
||||||
instead of `#player-controls`.
|
instead of `#player-controls`.
|
||||||
|
|
||||||
@@ -43,9 +43,9 @@ Canonical pattern for any control you inject into the player:
|
|||||||
|
|
||||||
```js
|
```js
|
||||||
function playerSlot() {
|
function playerSlot() {
|
||||||
return (window.slopsmith && window.slopsmith.uiVersion === 'v3'
|
return (window.feedBack && window.feedBack.uiVersion === 'v3'
|
||||||
&& window.slopsmith.ui && typeof window.slopsmith.ui.playerControlSlot === 'function')
|
&& window.feedBack.ui && typeof window.feedBack.ui.playerControlSlot === 'function')
|
||||||
? window.slopsmith.ui.playerControlSlot() : null;
|
? window.feedBack.ui.playerControlSlot() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function injectMyButton() {
|
function injectMyButton() {
|
||||||
@@ -183,7 +183,7 @@ out of the capability graph.
|
|||||||
- [ ] Backend / capabilities / library provider / `nav` + `screen` /
|
- [ ] Backend / capabilities / library provider / `nav` + `screen` /
|
||||||
visualization renderer — **no change needed** (they work in v3 as-is).
|
visualization renderer — **no change needed** (they work in v3 as-is).
|
||||||
- [ ] If you inject a control into the player: detect v3 and mount into
|
- [ ] If you inject a control into the player: detect v3 and mount into
|
||||||
`window.slopsmith.ui.playerControlSlot()`; drop the dead separator /
|
`window.feedBack.ui.playerControlSlot()`; drop the dead separator /
|
||||||
`button:last-child` anchor; guard `contains()` against the actual container.
|
`button:last-child` anchor; guard `contains()` against the actual container.
|
||||||
- [ ] Dropdowns positioned via `getBoundingClientRect()`, not `#player-controls`.
|
- [ ] Dropdowns positioned via `getBoundingClientRect()`, not `#player-controls`.
|
||||||
- [ ] `#player` overlays keep `z-index` ≤ the chrome layers (transport/HUD 20,
|
- [ ] `#player` overlays keep `z-index` ≤ the chrome layers (transport/HUD 20,
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
# Debugging Keyboard Shortcuts
|
# Debugging Keyboard Shortcuts
|
||||||
|
|
||||||
This skill helps you debug keyboard shortcut issues in Slopsmith.
|
This skill helps you debug keyboard shortcut issues in FeedBack.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
1. **Start Slopsmith:**
|
1. **Start FeedBack:**
|
||||||
```bash
|
```bash
|
||||||
cd ~/path/to/slopsmith
|
cd ~/path/to/feedBack
|
||||||
LIBRARY_PATH=/path/to/your/library docker compose up -d
|
LIBRARY_PATH=/path/to/your/library docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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 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.
|
> 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/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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -17,27 +17,27 @@ A sloppak exists in two interchangeable forms:
|
|||||||
| **Directory** | A folder named `something.sloppak/` with the files loose inside | **Authoring** — easy to edit, no zip/unzip cycle |
|
| **Directory** | A folder named `something.sloppak/` with the files loose inside | **Authoring** — easy to edit, no zip/unzip cycle |
|
||||||
| **Zip** | A `something.sloppak` file (zip with the same files inside) | **Distributing** — single file to share |
|
| **Zip** | A `something.sloppak` file (zip with the same files inside) | **Distributing** — single file to share |
|
||||||
|
|
||||||
Slopsmith reads both. You can drop either one straight into your DLC folder and it'll show up in the library.
|
FeedBack reads both. You can drop either one straight into your DLC folder and it'll show up in the library.
|
||||||
|
|
||||||
### Unzipping for editing
|
### Unzipping for editing
|
||||||
|
|
||||||
Slopsmith's converter ships sloppaks in zip form. To edit one, unzip it:
|
FeedBack's converter ships sloppaks in zip form. To edit one, unzip it:
|
||||||
|
|
||||||
- **Windows:** rename `mysong.sloppak` → `mysong.zip`, right-click → Extract All. Then rename the resulting folder back to `mysong.sloppak/` (with the trailing slash / folder form). Or use [7-Zip](https://www.7-zip.org/) and unzip without renaming.
|
- **Windows:** rename `mysong.sloppak` → `mysong.zip`, right-click → Extract All. Then rename the resulting folder back to `mysong.sloppak/` (with the trailing slash / folder form). Or use [7-Zip](https://www.7-zip.org/) and unzip without renaming.
|
||||||
- **macOS:** rename `.sloppak` → `.zip`, double-click. Or use The Unarchiver.
|
- **macOS:** rename `.sloppak` → `.zip`, double-click. Or use The Unarchiver.
|
||||||
- **Linux:** `unzip mysong.sloppak -d mysong.sloppak/`.
|
- **Linux:** `unzip mysong.sloppak -d mysong.sloppak/`.
|
||||||
|
|
||||||
Once you have the directory form, you can edit any file inside and Slopsmith will pick it up — no re-zipping required for your own use.
|
Once you have the directory form, you can edit any file inside and FeedBack will pick it up — no re-zipping required for your own use.
|
||||||
|
|
||||||
### Cache: when changes don't appear
|
### Cache: when changes don't appear
|
||||||
|
|
||||||
The first time Slopsmith opens a zip-form sloppak, it extracts a working copy into its config directory's cache: `${CONFIG_DIR}/sloppak_cache/<safe-id>` (in the standard Docker setup that's inside the `slopsmith-config` volume, mounted at `/config` in the container). The `<safe-id>` is the sloppak filename with each path separator (`/` or `\`) replaced by `__` and each space replaced by `_`. So `My-Song.sloppak` stays `My-Song.sloppak`, and `Artist/My Song.sloppak` becomes `Artist__My_Song.sloppak`.
|
The first time FeedBack opens a zip-form sloppak, it extracts a working copy into its config directory's cache: `${CONFIG_DIR}/sloppak_cache/<safe-id>` (in the standard Docker setup that's inside the `feedBack-config` volume, mounted at `/config` in the container). The `<safe-id>` is the sloppak filename with each path separator (`/` or `\`) replaced by `__` and each space replaced by `_`. So `My-Song.sloppak` stays `My-Song.sloppak`, and `Artist/My Song.sloppak` becomes `Artist__My_Song.sloppak`.
|
||||||
|
|
||||||
You almost never need to touch this cache directly. If you edit the **original zip** in your DLC folder, Slopsmith re-extracts automatically when the zip's modification time or size changes — just save your edits and reload.
|
You almost never need to touch this cache directly. If you edit the **original zip** in your DLC folder, FeedBack re-extracts automatically when the zip's modification time or size changes — just save your edits and reload.
|
||||||
|
|
||||||
If a change still isn't appearing, the simplest reset is to remove the matching cache folder so Slopsmith rebuilds it on the next song load. In a default Docker install that's `docker exec <container> rm -rf /config/sloppak_cache/<safe-id>` (or the equivalent for your setup).
|
If a change still isn't appearing, the simplest reset is to remove the matching cache folder so FeedBack rebuilds it on the next song load. In a default Docker install that's `docker exec <container> rm -rf /config/sloppak_cache/<safe-id>` (or the equivalent for your setup).
|
||||||
|
|
||||||
If you'd rather skip the cache layer entirely, **drop the directory form straight into your DLC folder** — Slopsmith uses it in place and there's nothing to invalidate.
|
If you'd rather skip the cache layer entirely, **drop the directory form straight into your DLC folder** — FeedBack uses it in place and there's nothing to invalidate.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@ The use case: the converted rhythm guitar sounds muddy (Demucs has a tough time
|
|||||||
1. Copy `rhythm_custom.ogg` into the sloppak's `stems/` folder.
|
1. Copy `rhythm_custom.ogg` into the sloppak's `stems/` folder.
|
||||||
2. Open `manifest.yaml` in any text editor (Notepad++, VS Code, BBEdit, gedit — all fine; just **don't use Word**).
|
2. Open `manifest.yaml` in any text editor (Notepad++, VS Code, BBEdit, gedit — all fine; just **don't use Word**).
|
||||||
3. Find the `stems:` block. Two things matter here:
|
3. Find the `stems:` block. Two things matter here:
|
||||||
- **Order:** Slopsmith's base `<audio>` element always plays the **first** stem listed in `stems[]`, regardless of `default:` flags. So if you want your custom stem to be what the player plays out-of-the-box (and what users without the Stems plugin will hear), put it **first**.
|
- **Order:** FeedBack's base `<audio>` element always plays the **first** stem listed in `stems[]`, regardless of `default:` flags. So if you want your custom stem to be what the player plays out-of-the-box (and what users without the Stems plugin will hear), put it **first**.
|
||||||
- **`default:` flags:** consulted by the [Stems plugin](https://github.com/topkoa/slopsmith-plugin-stems) to decide which faders start un-muted. They do **not** affect what the base `<audio>` element plays — that's purely the first-stem rule above.
|
- **`default:` flags:** consulted by the [Stems plugin](https://github.com/topkoa/slopsmith-plugin-stems) to decide which faders start un-muted. They do **not** affect what the base `<audio>` element plays — that's purely the first-stem rule above.
|
||||||
|
|
||||||
Example for a Demucs-split sloppak where you re-recorded the rhythm guitar:
|
Example for a Demucs-split sloppak where you re-recorded the rhythm guitar:
|
||||||
@@ -110,14 +110,14 @@ The use case: the converted rhythm guitar sounds muddy (Demucs has a tough time
|
|||||||
|
|
||||||
### Step 5 — Reload and verify
|
### Step 5 — Reload and verify
|
||||||
|
|
||||||
Reload the song in Slopsmith. The [Stems plugin](https://github.com/topkoa/slopsmith-plugin-stems) will show a fader for `rhythm_custom` next to the others. If you don't see it, check the cache notes in §1.
|
Reload the song in FeedBack. The [Stems plugin](https://github.com/topkoa/slopsmith-plugin-stems) will show a fader for `rhythm_custom` next to the others. If you don't see it, check the cache notes in §1.
|
||||||
|
|
||||||
### Common gotchas
|
### Common gotchas
|
||||||
|
|
||||||
- **Sample-rate mismatch** → choppy/pitched-wrong playback. Re-export from Audacity at exactly the rate the other stems use.
|
- **Sample-rate mismatch** → choppy/pitched-wrong playback. Re-export from Audacity at exactly the rate the other stems use.
|
||||||
- **Mono vs stereo mismatch** is fine for playback but levels can feel different — match what the other stems use if you want consistent behavior in the mixer.
|
- **Mono vs stereo mismatch** is fine for playback but levels can feel different — match what the other stems use if you want consistent behavior in the mixer.
|
||||||
- **Silence padding at the start** of your recording → your stem will play late. Trim it tight in Audacity before exporting.
|
- **Silence padding at the start** of your recording → your stem will play late. Trim it tight in Audacity before exporting.
|
||||||
- **Tabs in `manifest.yaml`** → Slopsmith will refuse to load the song. Use two spaces.
|
- **Tabs in `manifest.yaml`** → FeedBack will refuse to load the song. Use two spaces.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -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`
|
### 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 [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.
|
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 FeedBack's config dir or the metadata DB. See [feedpak spec §9.5](https://github.com/got-feedback/feedpak-spec/blob/main/spec/feedpak-v1.md#95-what-does-not-belong-in-a-feedpak) for the full list.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -252,15 +252,15 @@ If you want to share your modified sloppak with someone else, re-zip it:
|
|||||||
|
|
||||||
1. Open the `mysong.sloppak/` directory.
|
1. Open the `mysong.sloppak/` directory.
|
||||||
2. Select **everything inside** — `manifest.yaml`, `arrangements/`, `stems/`, `lyrics.json`, `cover.jpg`.
|
2. Select **everything inside** — `manifest.yaml`, `arrangements/`, `stems/`, `lyrics.json`, `cover.jpg`.
|
||||||
3. Zip the **contents**, not the parent folder. (If you zip the folder, the zip will have a top-level `mysong.sloppak/` directory inside, which Slopsmith won't parse — the manifest must be at the zip root.)
|
3. Zip the **contents**, not the parent folder. (If you zip the folder, the zip will have a top-level `mysong.sloppak/` directory inside, which FeedBack won't parse — the manifest must be at the zip root.)
|
||||||
4. Rename `mysong.zip` → `mysong.sloppak`.
|
4. Rename `mysong.zip` → `mysong.sloppak`.
|
||||||
|
|
||||||
For your own use, you can skip this entirely — Slopsmith reads the directory form straight from your DLC folder.
|
For your own use, you can skip this entirely — FeedBack reads the directory form straight from your DLC folder.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Out of scope (for now)
|
## Out of scope (for now)
|
||||||
|
|
||||||
- **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).
|
- **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/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).
|
- **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/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/`.
|
- **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/`.
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
The full format specification that used to live here has moved to its own repository and is now
|
The full format specification that used to live here has moved to its own repository and is now
|
||||||
the **authoritative, versioned reference**:
|
the **authoritative, versioned reference**:
|
||||||
|
|
||||||
> **📖 https://github.com/got-feedback/feedback-feedpak-spec**
|
> **📖 https://github.com/got-feedback/feedpak-spec**
|
||||||
> — normative spec ([`spec/feedpak-v1.md`](https://github.com/got-feedback/feedback-feedpak-spec/blob/main/spec/feedpak-v1.md)),
|
> — normative spec ([`spec/feedpak-v1.md`](https://github.com/got-feedback/feedpak-spec/blob/main/spec/feedpak-v1.md)),
|
||||||
> JSON Schemas, examples, and a reference validator.
|
> JSON Schemas, examples, and a reference validator.
|
||||||
|
|
||||||
Update bookmarks to point there. This page is a thin pointer kept at the original path so existing
|
Update bookmarks to point there. This page is a thin pointer kept at the original path so existing
|
||||||
@@ -14,7 +14,7 @@ links keep resolving.
|
|||||||
|
|
||||||
The published format is named **feedpak** (extension `.feedpak`, manifest key `feedpak_version`).
|
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
|
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
|
`.sloppak` extension, `FEEDBACK_*` 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
|
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
|
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.
|
structure under the `.sloppak` name. The internal rename is a separate, later effort.
|
||||||
@@ -45,5 +45,5 @@ concepts to the code that reads and writes them. It is **not** part of the forma
|
|||||||
> **Note on older section references.** Some inline code comments in this repo cite section
|
> **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
|
> 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
|
> 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)
|
> [feedpak spec](https://github.com/got-feedback/feedpak-spec/blob/main/spec/feedpak-v1.md)
|
||||||
> rather than by the old number.
|
> rather than by the old number.
|
||||||
|
|||||||
+3
-3
@@ -7,7 +7,7 @@ import shutil
|
|||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
log = logging.getLogger("slopsmith.lib.audio")
|
log = logging.getLogger("feedBack.lib.audio")
|
||||||
|
|
||||||
# Maximum length of any single decoder-error fragment that we surface to
|
# Maximum length of any single decoder-error fragment that we surface to
|
||||||
# the client. ffmpeg can emit multi-kB build-configuration / version
|
# the client. ffmpeg can emit multi-kB build-configuration / version
|
||||||
@@ -123,7 +123,7 @@ def _scrub_quoted_match(match: re.Match) -> str:
|
|||||||
|
|
||||||
def _bundled_bin_dir() -> Path | None:
|
def _bundled_bin_dir() -> Path | None:
|
||||||
"""Resolve the desktop bundle's resources/bin/ directory if we're
|
"""Resolve the desktop bundle's resources/bin/ directory if we're
|
||||||
running inside one. Layout: resources/slopsmith/lib/audio.py →
|
running inside one. Layout: resources/feedBack/lib/audio.py →
|
||||||
resources/bin/. Gate on vgmstream-cli's presence so we don't
|
resources/bin/. Gate on vgmstream-cli's presence so we don't
|
||||||
misidentify random parent dirs (e.g. Docker's `/bin`, dev
|
misidentify random parent dirs (e.g. Docker's `/bin`, dev
|
||||||
layouts where parents[2] resolves to the repo root) — vgmstream-cli
|
layouts where parents[2] resolves to the repo root) — vgmstream-cli
|
||||||
@@ -284,7 +284,7 @@ def _scrub_paths(text: str, *paths: str) -> str:
|
|||||||
"""Replace absolute filesystem paths in `text` with their basenames.
|
"""Replace absolute filesystem paths in `text` with their basenames.
|
||||||
|
|
||||||
Decoder error strings get joined into the RuntimeError that
|
Decoder error strings get joined into the RuntimeError that
|
||||||
`convert_wem` raises, and slopsmith surfaces that text in the
|
`convert_wem` raises, and feedBack surfaces that text in the
|
||||||
browser as `audio_error`. Leaking install / user / DLC paths to the
|
browser as `audio_error`. Leaking install / user / DLC paths to the
|
||||||
client is a needless info disclosure, so before any decoder error
|
client is a needless info disclosure, so before any decoder error
|
||||||
leaves this module we strip absolute paths down to their final
|
leaves this module we strip absolute paths down to their final
|
||||||
|
|||||||
+24
-24
@@ -130,7 +130,7 @@ ENV_ALLOWLIST = (
|
|||||||
"LOG_LEVEL",
|
"LOG_LEVEL",
|
||||||
"LOG_FORMAT",
|
"LOG_FORMAT",
|
||||||
"LOG_FILE",
|
"LOG_FILE",
|
||||||
"SLOPSMITH_RUNTIME",
|
"FEEDBACK_RUNTIME",
|
||||||
"PORT",
|
"PORT",
|
||||||
"HOST",
|
"HOST",
|
||||||
"TZ",
|
"TZ",
|
||||||
@@ -154,13 +154,13 @@ def _safe_json_dumps(obj) -> str:
|
|||||||
return json.dumps({"error": "unserializable payload"}, indent=2)
|
return json.dumps({"error": "unserializable payload"}, indent=2)
|
||||||
|
|
||||||
|
|
||||||
def _system_version(slopsmith_version: str, redactor=None) -> dict:
|
def _system_version(feedBack_version: str, redactor=None) -> dict:
|
||||||
executable = sys.executable
|
executable = sys.executable
|
||||||
if redactor is not None:
|
if redactor is not None:
|
||||||
executable = redactor.redact_text(executable)
|
executable = redactor.redact_text(executable)
|
||||||
return {
|
return {
|
||||||
"schema": "system.version.v1",
|
"schema": "system.version.v1",
|
||||||
"slopsmith_version": slopsmith_version,
|
"feedBack_version": feedBack_version,
|
||||||
"python": {
|
"python": {
|
||||||
"version": platform.python_version(),
|
"version": platform.python_version(),
|
||||||
"implementation": platform.python_implementation(),
|
"implementation": platform.python_implementation(),
|
||||||
@@ -233,7 +233,7 @@ def _summarize_payload(path: str, parsed) -> dict | None:
|
|||||||
py = parsed.get("python") or {}
|
py = parsed.get("python") or {}
|
||||||
os_ = parsed.get("os") or {}
|
os_ = parsed.get("os") or {}
|
||||||
return {
|
return {
|
||||||
"slopsmith": parsed.get("slopsmith_version"),
|
"feedBack": parsed.get("feedBack_version"),
|
||||||
"python": py.get("version"),
|
"python": py.get("version"),
|
||||||
"os": os_.get("system"),
|
"os": os_.get("system"),
|
||||||
}
|
}
|
||||||
@@ -338,7 +338,7 @@ def _git_info(plugin_dir: Path) -> dict | None:
|
|||||||
"""Return git short SHA + remote URL for a plugin checkout.
|
"""Return git short SHA + remote URL for a plugin checkout.
|
||||||
|
|
||||||
Pure-Python — reads `.git/HEAD` and `.git/config` directly so this
|
Pure-Python — reads `.git/HEAD` and `.git/config` directly so this
|
||||||
works in containers without the `git` binary installed (slopsmith's
|
works in containers without the `git` binary installed (feedBack's
|
||||||
runtime image is minimal). Plugins are gitlinks (see CLAUDE.md);
|
runtime image is minimal). Plugins are gitlinks (see CLAUDE.md);
|
||||||
the SHA is the most reliable "what build is this" identifier.
|
the SHA is the most reliable "what build is this" identifier.
|
||||||
|
|
||||||
@@ -393,7 +393,7 @@ def _system_plugins(loaded_plugins: list[dict], plugins_root: "Path | list[Path]
|
|||||||
show up in the bundle.
|
show up in the bundle.
|
||||||
|
|
||||||
*plugins_root* accepts a single Path, a list of Paths (to cover both
|
*plugins_root* accepts a single Path, a list of Paths (to cover both
|
||||||
the built-in ``plugins/`` directory and ``SLOPSMITH_PLUGINS_DIR``), or
|
the built-in ``plugins/`` directory and ``FEEDBACK_PLUGINS_DIR``), or
|
||||||
None to skip orphan detection entirely.
|
None to skip orphan detection entirely.
|
||||||
|
|
||||||
Plugin directories not in ``LOADED_PLUGINS`` appear in ``orphans``.
|
Plugin directories not in ``LOADED_PLUGINS`` appear in ``orphans``.
|
||||||
@@ -484,7 +484,7 @@ def _system_plugins(loaded_plugins: list[dict], plugins_root: "Path | list[Path]
|
|||||||
# plugin failed to load — common when requirements.txt installs
|
# plugin failed to load — common when requirements.txt installs
|
||||||
# fail in a read-only container). Accepts a single Path, a list of
|
# fail in a read-only container). Accepts a single Path, a list of
|
||||||
# Paths (to cover both the built-in plugins/ dir and
|
# Paths (to cover both the built-in plugins/ dir and
|
||||||
# SLOPSMITH_PLUGINS_DIR), or None.
|
# FEEDBACK_PLUGINS_DIR), or None.
|
||||||
orphans: list[dict] = []
|
orphans: list[dict] = []
|
||||||
if plugins_root is not None:
|
if plugins_root is not None:
|
||||||
roots: list[Path] = plugins_root if isinstance(plugins_root, list) else [plugins_root]
|
roots: list[Path] = plugins_root if isinstance(plugins_root, list) else [plugins_root]
|
||||||
@@ -840,11 +840,11 @@ def _redact_value(value: object, redactor: "Redactor") -> object:
|
|||||||
|
|
||||||
|
|
||||||
README_TEMPLATE = """\
|
README_TEMPLATE = """\
|
||||||
Slopsmith Diagnostics Bundle
|
FeedBack Diagnostics Bundle
|
||||||
============================
|
============================
|
||||||
|
|
||||||
Generated: {exported_at}
|
Generated: {exported_at}
|
||||||
Slopsmith: {slopsmith_version}
|
FeedBack: {feedBack_version}
|
||||||
Runtime: {runtime_kind}
|
Runtime: {runtime_kind}
|
||||||
Redacted: {redacted}
|
Redacted: {redacted}
|
||||||
|
|
||||||
@@ -1005,7 +1005,7 @@ def _build_files_meta(files: dict[str, bytes]) -> list[dict]:
|
|||||||
|
|
||||||
def _assemble_files_and_notes(
|
def _assemble_files_and_notes(
|
||||||
*,
|
*,
|
||||||
slopsmith_version: str,
|
feedBack_version: str,
|
||||||
config_dir: Path,
|
config_dir: Path,
|
||||||
dlc_dir: Path | None,
|
dlc_dir: Path | None,
|
||||||
log_file: Path | None,
|
log_file: Path | None,
|
||||||
@@ -1038,7 +1038,7 @@ def _assemble_files_and_notes(
|
|||||||
if include.get("system", True):
|
if include.get("system", True):
|
||||||
# Pass the redactor so python.executable is redacted when paths
|
# Pass the redactor so python.executable is redacted when paths
|
||||||
# should be hidden (it often lives under $HOME or a per-user venv).
|
# should be hidden (it often lives under $HOME or a per-user venv).
|
||||||
ver_payload = _safe_json_dumps(_system_version(slopsmith_version, redactor=redactor)).encode("utf-8")
|
ver_payload = _safe_json_dumps(_system_version(feedBack_version, redactor=redactor)).encode("utf-8")
|
||||||
files["system/version.json"] = ver_payload
|
files["system/version.json"] = ver_payload
|
||||||
env_payload = _safe_json_dumps(_system_env(redactor=redactor)).encode("utf-8")
|
env_payload = _safe_json_dumps(_system_env(redactor=redactor)).encode("utf-8")
|
||||||
files["system/env.json"] = env_payload
|
files["system/env.json"] = env_payload
|
||||||
@@ -1125,7 +1125,7 @@ def _assemble_files_and_notes(
|
|||||||
files.update(plugin_files)
|
files.update(plugin_files)
|
||||||
|
|
||||||
# Per-plugin client-side contributions from
|
# Per-plugin client-side contributions from
|
||||||
# window.slopsmith.diagnostics.contribute(plugin_id, payload).
|
# window.feedBack.diagnostics.contribute(plugin_id, payload).
|
||||||
# Gated on the same "plugins" toggle as backend plugin diagnostics.
|
# Gated on the same "plugins" toggle as backend plugin diagnostics.
|
||||||
if include.get("plugins", True) and client_contributions and isinstance(client_contributions, dict):
|
if include.get("plugins", True) and client_contributions and isinstance(client_contributions, dict):
|
||||||
# Build the set of actually-loaded plugin IDs so we only accept
|
# Build the set of actually-loaded plugin IDs so we only accept
|
||||||
@@ -1160,7 +1160,7 @@ def _assemble_files_and_notes(
|
|||||||
|
|
||||||
def _make_manifest(
|
def _make_manifest(
|
||||||
*,
|
*,
|
||||||
slopsmith_version: str,
|
feedBack_version: str,
|
||||||
runtime_kind: str,
|
runtime_kind: str,
|
||||||
redact: bool,
|
redact: bool,
|
||||||
files: dict[str, bytes],
|
files: dict[str, bytes],
|
||||||
@@ -1170,7 +1170,7 @@ def _make_manifest(
|
|||||||
return {
|
return {
|
||||||
"schema": BUNDLE_SCHEMA,
|
"schema": BUNDLE_SCHEMA,
|
||||||
"exported_at": _now_iso(),
|
"exported_at": _now_iso(),
|
||||||
"slopsmith_version": slopsmith_version,
|
"feedBack_version": feedBack_version,
|
||||||
"runtime": runtime_kind,
|
"runtime": runtime_kind,
|
||||||
"redacted": redact,
|
"redacted": redact,
|
||||||
"files": _build_files_meta(files),
|
"files": _build_files_meta(files),
|
||||||
@@ -1181,7 +1181,7 @@ def _make_manifest(
|
|||||||
|
|
||||||
def build_bundle(
|
def build_bundle(
|
||||||
*,
|
*,
|
||||||
slopsmith_version: str,
|
feedBack_version: str,
|
||||||
config_dir: Path,
|
config_dir: Path,
|
||||||
dlc_dir: Path | None,
|
dlc_dir: Path | None,
|
||||||
log_file: Path | None,
|
log_file: Path | None,
|
||||||
@@ -1198,7 +1198,7 @@ def build_bundle(
|
|||||||
) -> tuple[bytes, str, dict]:
|
) -> tuple[bytes, str, dict]:
|
||||||
"""Returns (zip_bytes, filename, manifest_dict)."""
|
"""Returns (zip_bytes, filename, manifest_dict)."""
|
||||||
files, notes, runtime_kind, redactor = _assemble_files_and_notes(
|
files, notes, runtime_kind, redactor = _assemble_files_and_notes(
|
||||||
slopsmith_version=slopsmith_version,
|
feedBack_version=feedBack_version,
|
||||||
config_dir=config_dir,
|
config_dir=config_dir,
|
||||||
dlc_dir=dlc_dir,
|
dlc_dir=dlc_dir,
|
||||||
log_file=log_file,
|
log_file=log_file,
|
||||||
@@ -1215,7 +1215,7 @@ def build_bundle(
|
|||||||
)
|
)
|
||||||
|
|
||||||
manifest = _make_manifest(
|
manifest = _make_manifest(
|
||||||
slopsmith_version=slopsmith_version,
|
feedBack_version=feedBack_version,
|
||||||
runtime_kind=runtime_kind,
|
runtime_kind=runtime_kind,
|
||||||
redact=redact,
|
redact=redact,
|
||||||
files=files,
|
files=files,
|
||||||
@@ -1225,7 +1225,7 @@ def build_bundle(
|
|||||||
|
|
||||||
readme = README_TEMPLATE.format(
|
readme = README_TEMPLATE.format(
|
||||||
exported_at=manifest["exported_at"],
|
exported_at=manifest["exported_at"],
|
||||||
slopsmith_version=slopsmith_version,
|
feedBack_version=feedBack_version,
|
||||||
runtime_kind=runtime_kind,
|
runtime_kind=runtime_kind,
|
||||||
redacted=redact,
|
redacted=redact,
|
||||||
)
|
)
|
||||||
@@ -1259,13 +1259,13 @@ def build_bundle(
|
|||||||
for path, payload in sorted(files.items()):
|
for path, payload in sorted(files.items()):
|
||||||
zf.writestr(path, payload)
|
zf.writestr(path, payload)
|
||||||
|
|
||||||
filename = f"slopsmith-diag-{slopsmith_version}-{_now_filename_slug()}.zip"
|
filename = f"feedBack-diag-{feedBack_version}-{_now_filename_slug()}.zip"
|
||||||
return buf.getvalue(), filename, manifest
|
return buf.getvalue(), filename, manifest
|
||||||
|
|
||||||
|
|
||||||
def preview_bundle(
|
def preview_bundle(
|
||||||
*,
|
*,
|
||||||
slopsmith_version: str,
|
feedBack_version: str,
|
||||||
config_dir: Path,
|
config_dir: Path,
|
||||||
dlc_dir: Path | None,
|
dlc_dir: Path | None,
|
||||||
log_file: Path | None,
|
log_file: Path | None,
|
||||||
@@ -1303,7 +1303,7 @@ def preview_bundle(
|
|||||||
for p in loaded_plugins
|
for p in loaded_plugins
|
||||||
]
|
]
|
||||||
files, notes, runtime_kind, redactor = _assemble_files_and_notes(
|
files, notes, runtime_kind, redactor = _assemble_files_and_notes(
|
||||||
slopsmith_version=slopsmith_version,
|
feedBack_version=feedBack_version,
|
||||||
config_dir=config_dir,
|
config_dir=config_dir,
|
||||||
dlc_dir=dlc_dir,
|
dlc_dir=dlc_dir,
|
||||||
log_file=log_file,
|
log_file=log_file,
|
||||||
@@ -1336,7 +1336,7 @@ def preview_bundle(
|
|||||||
if key not in files:
|
if key not in files:
|
||||||
files[key] = _CALLABLE_PREVIEW_PLACEHOLDER
|
files[key] = _CALLABLE_PREVIEW_PLACEHOLDER
|
||||||
# Frontend plugins (those with a screen or script) may call
|
# Frontend plugins (those with a screen or script) may call
|
||||||
# window.slopsmith.diagnostics.contribute() and produce a
|
# window.feedBack.diagnostics.contribute() and produce a
|
||||||
# plugins/<id>/client.json in the real export. Advertise a
|
# plugins/<id>/client.json in the real export. Advertise a
|
||||||
# placeholder so the preview file tree is accurate.
|
# placeholder so the preview file tree is accurate.
|
||||||
if p.get("has_screen") or p.get("has_script"):
|
if p.get("has_screen") or p.get("has_script"):
|
||||||
@@ -1377,14 +1377,14 @@ def preview_bundle(
|
|||||||
}).encode("utf-8")
|
}).encode("utf-8")
|
||||||
|
|
||||||
manifest = _make_manifest(
|
manifest = _make_manifest(
|
||||||
slopsmith_version=slopsmith_version,
|
feedBack_version=feedBack_version,
|
||||||
runtime_kind=runtime_kind,
|
runtime_kind=runtime_kind,
|
||||||
redact=redact,
|
redact=redact,
|
||||||
files=files,
|
files=files,
|
||||||
notes=notes,
|
notes=notes,
|
||||||
redactor=redactor,
|
redactor=redactor,
|
||||||
)
|
)
|
||||||
filename = f"slopsmith-diag-{slopsmith_version}-{_now_filename_slug()}.zip"
|
filename = f"feedBack-diag-{feedBack_version}-{_now_filename_slug()}.zip"
|
||||||
return {
|
return {
|
||||||
"filename": filename,
|
"filename": filename,
|
||||||
"manifest": manifest,
|
"manifest": manifest,
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import platform
|
|||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from env_compat import getenv_compat
|
||||||
|
|
||||||
SCHEMA = "system.hardware.v1"
|
SCHEMA = "system.hardware.v1"
|
||||||
|
|
||||||
|
|
||||||
@@ -41,7 +43,7 @@ def detect_runtime() -> dict:
|
|||||||
nvidia-smi / psutil CPU probes.
|
nvidia-smi / psutil CPU probes.
|
||||||
"""
|
"""
|
||||||
out: dict = {"kind": "bare", "in_docker": False, "in_kubernetes": False}
|
out: dict = {"kind": "bare", "in_docker": False, "in_kubernetes": False}
|
||||||
env_runtime = os.environ.get("SLOPSMITH_RUNTIME", "").strip().lower()
|
env_runtime = (getenv_compat("FEEDBACK_RUNTIME", "") or "").strip().lower()
|
||||||
if env_runtime in ("electron", "docker", "bare"):
|
if env_runtime in ("electron", "docker", "bare"):
|
||||||
out["kind"] = env_runtime
|
out["kind"] = env_runtime
|
||||||
if Path("/.dockerenv").exists():
|
if Path("/.dockerenv").exists():
|
||||||
@@ -65,7 +67,7 @@ def detect_runtime() -> dict:
|
|||||||
import psutil # type: ignore
|
import psutil # type: ignore
|
||||||
|
|
||||||
parent = psutil.Process(os.getppid()).name().lower()
|
parent = psutil.Process(os.getppid()).name().lower()
|
||||||
if "electron" in parent or "slopsmith" in parent:
|
if "electron" in parent or "feedBack" in parent:
|
||||||
out["kind"] = "electron"
|
out["kind"] = "electron"
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ different salts so tokens cannot be cross-correlated between exports.
|
|||||||
Stable token grammar (see docs/diagnostics-bundle-spec.md):
|
Stable token grammar (see docs/diagnostics-bundle-spec.md):
|
||||||
<DLC_DIR> — DLC root path
|
<DLC_DIR> — DLC root path
|
||||||
<HOME> — user's home directory
|
<HOME> — user's home directory
|
||||||
<CONFIG_DIR> — slopsmith config dir
|
<CONFIG_DIR> — feedBack config dir
|
||||||
<song:hash8> — song filename / basename (8 hex chars)
|
<song:hash8> — song filename / basename (8 hex chars)
|
||||||
<ip:hash6> — IPv4 / IPv6 address (6 hex chars)
|
<ip:hash6> — IPv4 / IPv6 address (6 hex chars)
|
||||||
<redacted> — bearer tokens, key=/token= query strings
|
<redacted> — bearer tokens, key=/token= query strings
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
|
|
||||||
log = logging.getLogger("slopsmith.lib.drums")
|
log = logging.getLogger("feedBack.lib.drums")
|
||||||
|
|
||||||
|
|
||||||
# ── Piece vocabulary ──────────────────────────────────────────────────────────
|
# ── Piece vocabulary ──────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""Backward-compatible environment lookup for the slopsmith -> feedBack rename.
|
||||||
|
|
||||||
|
Canonical configuration variables are now ``FEEDBACK_*``. Deployments that
|
||||||
|
predate the rename may still set the old ``SLOPSMITH_*`` names (docker-compose
|
||||||
|
overrides, shell profiles, CI), so we honour those as a fallback. New code
|
||||||
|
should always read the canonical ``FEEDBACK_*`` name and let this shim resolve
|
||||||
|
the legacy alias.
|
||||||
|
|
||||||
|
Flat-importable, no import-time IO or global state (constitution P-V).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
_CANON_PREFIX = "FEEDBACK_"
|
||||||
|
_LEGACY_PREFIX = "SLOPSMITH_"
|
||||||
|
_TRUE_VALUES = {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
|
def getenv_compat(name, default=None):
|
||||||
|
"""``os.environ.get`` with a legacy ``SLOPSMITH_*`` fallback.
|
||||||
|
|
||||||
|
For a canonical ``FEEDBACK_<X>`` name, returns the value of ``FEEDBACK_<X>``
|
||||||
|
if set, else ``SLOPSMITH_<X>`` if set, else ``default``. Names that do not
|
||||||
|
start with ``FEEDBACK_`` behave exactly like ``os.environ.get``.
|
||||||
|
"""
|
||||||
|
value = os.environ.get(name)
|
||||||
|
if value is not None:
|
||||||
|
return value
|
||||||
|
if name.startswith(_CANON_PREFIX):
|
||||||
|
legacy = os.environ.get(_LEGACY_PREFIX + name[len(_CANON_PREFIX):])
|
||||||
|
if legacy is not None:
|
||||||
|
return legacy
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def env_flag_compat(name):
|
||||||
|
"""Parse a conventional boolean env flag, honouring the legacy alias."""
|
||||||
|
return (getenv_compat(name, "") or "").strip().lower() in _TRUE_VALUES
|
||||||
+12
-10
@@ -8,7 +8,9 @@ import sys
|
|||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
log = logging.getLogger("slopsmith.lib.gp2midi")
|
from env_compat import getenv_compat
|
||||||
|
|
||||||
|
log = logging.getLogger("feedBack.lib.gp2midi")
|
||||||
|
|
||||||
import guitarpro
|
import guitarpro
|
||||||
from midiutil import MIDIFile
|
from midiutil import MIDIFile
|
||||||
@@ -152,15 +154,15 @@ def _find_soundfont() -> str | None:
|
|||||||
"""Locate a .sf2 soundfont for MIDI rendering.
|
"""Locate a .sf2 soundfont for MIDI rendering.
|
||||||
|
|
||||||
Precedence:
|
Precedence:
|
||||||
1. ``SLOPSMITH_SOUNDFONT`` env var (user override / desktop-app-supplied)
|
1. ``FEEDBACK_SOUNDFONT`` env var (user override / desktop-app-supplied)
|
||||||
2. Bundled ``<RESOURCESPATH>/soundfonts/*.sf2`` (Electron desktop builds)
|
2. Bundled ``<RESOURCESPATH>/soundfonts/*.sf2`` (Electron desktop builds)
|
||||||
3. Common system locations per OS.
|
3. Common system locations per OS.
|
||||||
"""
|
"""
|
||||||
override = os.environ.get("SLOPSMITH_SOUNDFONT")
|
override = getenv_compat("FEEDBACK_SOUNDFONT")
|
||||||
if override:
|
if override:
|
||||||
if os.path.isfile(override):
|
if os.path.isfile(override):
|
||||||
return override
|
return override
|
||||||
log.warning("SLOPSMITH_SOUNDFONT is set to %r but that file does not exist; falling back to other sources", override)
|
log.warning("FEEDBACK_SOUNDFONT is set to %r but that file does not exist; falling back to other sources", override)
|
||||||
|
|
||||||
resources = os.environ.get("RESOURCESPATH")
|
resources = os.environ.get("RESOURCESPATH")
|
||||||
if resources:
|
if resources:
|
||||||
@@ -187,10 +189,10 @@ def _find_soundfont() -> str | None:
|
|||||||
elif sys.platform == "win32":
|
elif sys.platform == "win32":
|
||||||
appdata = os.environ.get("APPDATA")
|
appdata = os.environ.get("APPDATA")
|
||||||
if appdata:
|
if appdata:
|
||||||
# "Slopsmith" matches slopsmith-desktop's Electron productName
|
# "FeedBack" matches feedBack-desktop's Electron productName
|
||||||
# (app.getPath('userData') resolves to %APPDATA%\Slopsmith on Windows).
|
# (app.getPath('userData') resolves to %APPDATA%\FeedBack on Windows).
|
||||||
for pattern in (
|
for pattern in (
|
||||||
os.path.join(appdata, "Slopsmith", "soundfonts", "*.sf2"),
|
os.path.join(appdata, "FeedBack", "soundfonts", "*.sf2"),
|
||||||
os.path.join(appdata, "SoundFonts", "*.sf2"),
|
os.path.join(appdata, "SoundFonts", "*.sf2"),
|
||||||
):
|
):
|
||||||
candidates += sorted(glob.glob(pattern))
|
candidates += sorted(glob.glob(pattern))
|
||||||
@@ -218,16 +220,16 @@ def _soundfont_install_hint() -> str:
|
|||||||
"or FluidR3_GM from musical-artifacts.com) and either place the .sf2 "
|
"or FluidR3_GM from musical-artifacts.com) and either place the .sf2 "
|
||||||
"file in /usr/local/share/sounds/sf2/ (Intel) or "
|
"file in /usr/local/share/sounds/sf2/ (Intel) or "
|
||||||
"/opt/homebrew/share/sounds/sf2/ (Apple Silicon), or set the "
|
"/opt/homebrew/share/sounds/sf2/ (Apple Silicon), or set the "
|
||||||
"SLOPSMITH_SOUNDFONT environment variable to its full path."
|
"FEEDBACK_SOUNDFONT environment variable to its full path."
|
||||||
)
|
)
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
return (
|
return (
|
||||||
"Download a soundfont (e.g. GeneralUser GS from schristiancollins.com or "
|
"Download a soundfont (e.g. GeneralUser GS from schristiancollins.com or "
|
||||||
"FluidR3_GM from musical-artifacts.com) and either place the .sf2 file in "
|
"FluidR3_GM from musical-artifacts.com) and either place the .sf2 file in "
|
||||||
"%APPDATA%\\Slopsmith\\soundfonts\\ or set the SLOPSMITH_SOUNDFONT "
|
"%APPDATA%\\FeedBack\\soundfonts\\ or set the FEEDBACK_SOUNDFONT "
|
||||||
"environment variable to its full path."
|
"environment variable to its full path."
|
||||||
)
|
)
|
||||||
return "Set SLOPSMITH_SOUNDFONT to the full path of a .sf2 file."
|
return "Set FEEDBACK_SOUNDFONT to the full path of a .sf2 file."
|
||||||
|
|
||||||
|
|
||||||
def _fluidsynth_install_hint() -> str:
|
def _fluidsynth_install_hint() -> str:
|
||||||
|
|||||||
+3
-3
@@ -19,8 +19,8 @@ bar-indexed tempo map, per-beat rhythm durations (dots + tuplets; see
|
|||||||
``_beat_secs`` for the one deliberate double-dot divergence), and
|
``_beat_secs`` for the one deliberate double-dot divergence), and
|
||||||
``_note_midi`` — so the
|
``_note_midi`` — so the
|
||||||
notation beats line up with the RS-XML notes the highway plays (see
|
notation beats line up with the RS-XML notes the highway plays (see
|
||||||
slopsmith#618 for the longer-term goal of sharing the note-building walk
|
feedBack#618 for the longer-term goal of sharing the note-building walk
|
||||||
itself, and slopsmith#261 for the time-signature-denominator pitfalls the
|
itself, and feedBack#261 for the time-signature-denominator pitfalls the
|
||||||
``beat_groups`` emission here exists to avoid re-introducing).
|
``beat_groups`` emission here exists to avoid re-introducing).
|
||||||
|
|
||||||
Where this plugs in: ``gp2rs_gpx.convert_file`` calls
|
Where this plugs in: ``gp2rs_gpx.convert_file`` calls
|
||||||
@@ -43,7 +43,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
import notation as notation_mod
|
import notation as notation_mod
|
||||||
|
|
||||||
log = logging.getLogger("slopsmith.lib.gp2notation")
|
log = logging.getLogger("feedBack.lib.gp2notation")
|
||||||
|
|
||||||
|
|
||||||
# GPX NoteValue string → notation duration denominator (sloppak-spec §5.3:
|
# GPX NoteValue string → notation duration denominator (sloppak-spec §5.3:
|
||||||
|
|||||||
+140
-27
@@ -1,5 +1,6 @@
|
|||||||
"""Convert Guitar Pro files (.gp5/.gp4/.gp3) to arrangement XML."""
|
"""Convert Guitar Pro files (.gp5/.gp4/.gp3) to arrangement XML."""
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
@@ -9,7 +10,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
import guitarpro
|
import guitarpro
|
||||||
|
|
||||||
log = logging.getLogger("slopsmith.lib.gp2rs")
|
log = logging.getLogger("feedBack.lib.gp2rs")
|
||||||
|
|
||||||
_YEAR_RE = re.compile(r"\b(1[89]\d{2}|20\d{2})\b")
|
_YEAR_RE = re.compile(r"\b(1[89]\d{2}|20\d{2})\b")
|
||||||
|
|
||||||
@@ -56,6 +57,8 @@ class RsNote:
|
|||||||
fret: int
|
fret: int
|
||||||
sustain: float = 0.0
|
sustain: float = 0.0
|
||||||
bend: float = 0.0
|
bend: float = 0.0
|
||||||
|
bend_intent: int = 0
|
||||||
|
bend_values: list | None = None
|
||||||
slide_to: int = -1
|
slide_to: int = -1
|
||||||
slide_unpitch_to: int = -1
|
slide_unpitch_to: int = -1
|
||||||
hammer_on: bool = False
|
hammer_on: bool = False
|
||||||
@@ -69,6 +72,9 @@ class RsNote:
|
|||||||
tremolo: bool = False
|
tremolo: bool = False
|
||||||
tap: bool = False
|
tap: bool = False
|
||||||
link_next: bool = False
|
link_next: bool = False
|
||||||
|
# Teaching mark (§6.2.2): fret-hand finger (-1 unset, 0 thumb..4 pinky).
|
||||||
|
# Display only — never used for grading.
|
||||||
|
fret_finger: int = -1
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -191,6 +197,77 @@ def _duration_to_seconds(duration: guitarpro.Duration, tempo: float) -> float:
|
|||||||
return beats * (60.0 / tempo)
|
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 _finger_xml_attrs(n: "RsNote") -> dict:
|
||||||
|
"""Optional teaching-mark XML attribute for a <note>/<chordNote>: `fretFinger`
|
||||||
|
only when set (!= -1). `_parse_note` (lib/song.py) reads it back so a
|
||||||
|
GP-imported fret-hand finger survives import → wire → highway. Display only;
|
||||||
|
never used for grading (§6.2.2)."""
|
||||||
|
if getattr(n, "fret_finger", -1) != -1:
|
||||||
|
return {"fretFinger": str(int(n.fret_finger))}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def _tempo_at_tick(tick: int, tempo_map: list[TempoEvent]) -> float:
|
def _tempo_at_tick(tick: int, tempo_map: list[TempoEvent]) -> float:
|
||||||
"""Get the tempo at a given tick."""
|
"""Get the tempo at a given tick."""
|
||||||
result = tempo_map[0].tempo
|
result = tempo_map[0].tempo
|
||||||
@@ -460,6 +537,19 @@ def _gp_string_to_rs(gp_string: int, num_strings: int) -> int:
|
|||||||
return num_strings - gp_string
|
return num_strings - gp_string
|
||||||
|
|
||||||
|
|
||||||
|
def _gp_finger_to_rs(fingering) -> int:
|
||||||
|
"""Coerce a pyguitarpro ``Fingering`` enum to an RS fret-hand finger int.
|
||||||
|
|
||||||
|
Fingering values are ``unknown=-2, open=-1, thumb=0, index=1, middle=2,
|
||||||
|
annular=3, little=4`` — already the RS finger integers for 0..4. Anything
|
||||||
|
open/unknown/out-of-range collapses to ``-1`` (unset), so we never invent a
|
||||||
|
finger. Teaching mark only (§6.2.2); never used for grading."""
|
||||||
|
val = getattr(fingering, "value", fingering)
|
||||||
|
if not isinstance(val, int) or val < 0 or val > 4:
|
||||||
|
return -1
|
||||||
|
return val
|
||||||
|
|
||||||
|
|
||||||
def _chord_fingers(chord, frets: list[int], num_strings: int) -> list[int]:
|
def _chord_fingers(chord, frets: list[int], num_strings: int) -> list[int]:
|
||||||
"""Per-string fingering for a chord template, in RS string order.
|
"""Per-string fingering for a chord template, in RS string order.
|
||||||
|
|
||||||
@@ -735,12 +825,13 @@ def convert_track(
|
|||||||
# Techniques
|
# Techniques
|
||||||
eff = note.effect
|
eff = note.effect
|
||||||
if eff.bend and eff.bend.points:
|
if eff.bend and eff.bend.points:
|
||||||
# pyguitarpro bend point values are in quarter-tones
|
# `bn` is the peak; `bnv`/`bt` describe the shape over
|
||||||
# (maxValue 12 = 3 whole tones = 6 semitones), so
|
# time (§6.2.1). semitones = value / 2 (maxValue 12 = 6
|
||||||
# semitones = value / 2. The old /100.0 made every bend
|
# semitones); the old /100.0 made every bend round to 0.
|
||||||
# round to 0 (a whole-tone bend is value 4 -> 0.04).
|
peak, intent, curve = _gp_bend_shape(eff.bend, dur)
|
||||||
max_bend = max(p.value for p in eff.bend.points)
|
rn.bend = peak
|
||||||
rn.bend = round(max_bend / 2.0, 1)
|
rn.bend_intent = intent
|
||||||
|
rn.bend_values = curve
|
||||||
|
|
||||||
if eff.hammer:
|
if eff.hammer:
|
||||||
# HO vs PO from pitch direction off the prior note on the
|
# HO vs PO from pitch direction off the prior note on the
|
||||||
@@ -788,6 +879,11 @@ def convert_track(
|
|||||||
if eff.tremoloPicking:
|
if eff.tremoloPicking:
|
||||||
rn.tremolo = True
|
rn.tremolo = True
|
||||||
|
|
||||||
|
# Fret-hand fingering -> fg teaching mark (§6.2.2). Same
|
||||||
|
# Fingering enum + value convention as the chord path.
|
||||||
|
rn.fret_finger = _gp_finger_to_rs(
|
||||||
|
getattr(eff, "leftHandFinger", None))
|
||||||
|
|
||||||
# Whammy / tremolo bar (beat-level dive/raise). RS has no
|
# Whammy / tremolo bar (beat-level dive/raise). RS has no
|
||||||
# whammy attribute, so approximate the pitch movement as an
|
# whammy attribute, so approximate the pitch movement as an
|
||||||
# unpitched slide: a dive slides down, a raise slides up, by
|
# unpitched slide: a dive slides down, a raise slides up, by
|
||||||
@@ -1026,9 +1122,19 @@ def _build_xml(
|
|||||||
|
|
||||||
# Tuning. RS2014 schema names 6 string slots; we always emit those
|
# Tuning. RS2014 schema names 6 string slots; we always emit those
|
||||||
# for compatibility, and emit additional string6+ attributes (up to
|
# for compatibility, and emit additional string6+ attributes (up to
|
||||||
# `len(tuning)-1`) for 7+ string arrangements. Slopsmith parses
|
# `len(tuning)-1`) for 7+ string arrangements. FeedBack parses
|
||||||
# them; the format ignores them.
|
# them; the format ignores them.
|
||||||
|
#
|
||||||
|
# `stringCount` records the AUTHORITATIVE string count (== len(tuning)),
|
||||||
|
# because the 6-slot padding above erases the 4-vs-5-vs-6-string
|
||||||
|
# distinction for standard tunings (a 4-string bass, 5-string bass and
|
||||||
|
# 6-string guitar are otherwise byte-identical, all string0..5 = 0).
|
||||||
|
# parse_arrangement trims `tuning` back to this on read so downstream
|
||||||
|
# string-count derivation (song.arrangement_string_count, the editor's
|
||||||
|
# _stringCountFor) sees the real width instead of guessing. RS2014 and
|
||||||
|
# any other consumer simply ignore the unknown attribute.
|
||||||
tuning_el = ET.SubElement(root, "tuning")
|
tuning_el = ET.SubElement(root, "tuning")
|
||||||
|
tuning_el.set("stringCount", str(len(tuning)))
|
||||||
for i in range(max(6, len(tuning))):
|
for i in range(max(6, len(tuning))):
|
||||||
tuning_el.set(f"string{i}", str(tuning[i] if i < len(tuning) else 0))
|
tuning_el.set(f"string{i}", str(tuning[i] if i < len(tuning) else 0))
|
||||||
ET.SubElement(root, "capo").text = "0"
|
ET.SubElement(root, "capo").text = "0"
|
||||||
@@ -1098,6 +1204,8 @@ def _build_xml(
|
|||||||
"tap": "1" if n.tap else "0",
|
"tap": "1" if n.tap else "0",
|
||||||
"ignore": "0",
|
"ignore": "0",
|
||||||
}
|
}
|
||||||
|
attrs.update(_bend_shape_xml_attrs(n))
|
||||||
|
attrs.update(_finger_xml_attrs(n))
|
||||||
ET.SubElement(notes_el, "note", **attrs)
|
ET.SubElement(notes_el, "note", **attrs)
|
||||||
|
|
||||||
# Chords
|
# Chords
|
||||||
@@ -1108,25 +1216,30 @@ def _build_xml(
|
|||||||
chordId=str(ch.template_idx),
|
chordId=str(ch.template_idx),
|
||||||
highDensity="0", strum="down")
|
highDensity="0", strum="down")
|
||||||
for cn in ch.notes:
|
for cn in ch.notes:
|
||||||
ET.SubElement(chord_el, "chordNote",
|
cn_attrs = {
|
||||||
time=f"{cn.time:.3f}",
|
"time": f"{cn.time:.3f}",
|
||||||
string=str(cn.string),
|
"string": str(cn.string),
|
||||||
fret=str(cn.fret),
|
"fret": str(cn.fret),
|
||||||
sustain=f"{cn.sustain:.3f}",
|
"sustain": f"{cn.sustain:.3f}",
|
||||||
bend=f"{cn.bend:.1f}" if cn.bend else "0",
|
"bend": f"{cn.bend:.1f}" if cn.bend else "0",
|
||||||
hammerOn="1" if cn.hammer_on else "0",
|
"hammerOn": "1" if cn.hammer_on else "0",
|
||||||
pullOff="1" if cn.pull_off else "0",
|
"pullOff": "1" if cn.pull_off else "0",
|
||||||
slideTo=str(cn.slide_to),
|
"slideTo": str(cn.slide_to),
|
||||||
slideUnpitchTo=str(cn.slide_unpitch_to),
|
"slideUnpitchTo": str(cn.slide_unpitch_to),
|
||||||
harmonic="1" if cn.harmonic else "0",
|
"harmonic": "1" if cn.harmonic else "0",
|
||||||
harmonicPinch="1" if cn.harmonic_pinch else "0",
|
"harmonicPinch": "1" if cn.harmonic_pinch else "0",
|
||||||
palmMute="1" if cn.palm_mute else "0",
|
"palmMute": "1" if cn.palm_mute else "0",
|
||||||
mute="1" if cn.mute else "0",
|
"mute": "1" if cn.mute else "0",
|
||||||
vibrato="1" if cn.vibrato else "0",
|
"vibrato": "1" if cn.vibrato else "0",
|
||||||
tremolo="1" if cn.tremolo else "0",
|
"tremolo": "1" if cn.tremolo else "0",
|
||||||
accent="1" if cn.accent else "0",
|
"accent": "1" if cn.accent else "0",
|
||||||
linkNext="1" if cn.link_next else "0",
|
"linkNext": "1" if cn.link_next else "0",
|
||||||
tap="1" if cn.tap else "0", ignore="0")
|
"tap": "1" if cn.tap else "0",
|
||||||
|
"ignore": "0",
|
||||||
|
}
|
||||||
|
cn_attrs.update(_bend_shape_xml_attrs(cn))
|
||||||
|
cn_attrs.update(_finger_xml_attrs(cn))
|
||||||
|
ET.SubElement(chord_el, "chordNote", **cn_attrs)
|
||||||
|
|
||||||
# Anchors
|
# Anchors
|
||||||
anchors_el = ET.SubElement(level, "anchors", count=str(len(anchors)))
|
anchors_el = ET.SubElement(level, "anchors", count=str(len(anchors)))
|
||||||
|
|||||||
+257
-46
@@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
lib/gp2rs_gpx.py — Guitar Pro 6 (.gpx) support shim for gp2rs.
|
lib/gp2rs_gpx.py — Guitar Pro 6 (.gpx) support shim for gp2rs.
|
||||||
|
|
||||||
Drop this file into slopsmith/lib/ alongside gp2rs.py.
|
Drop this file into feedBack/lib/ alongside gp2rs.py.
|
||||||
No third-party dependencies — pure Python stdlib only.
|
No third-party dependencies — pure Python stdlib only.
|
||||||
|
|
||||||
Public API mirrors the two functions that the editor plugin calls:
|
Public API mirrors the two functions that the editor plugin calls:
|
||||||
@@ -20,7 +20,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from safepath import safe_join
|
from safepath import safe_join
|
||||||
|
|
||||||
_log = logging.getLogger("slopsmith.lib.gp2rs_gpx")
|
_log = logging.getLogger("feedBack.lib.gp2rs_gpx")
|
||||||
|
|
||||||
|
|
||||||
def _safe_filename_stem(name: str) -> str:
|
def _safe_filename_stem(name: str) -> str:
|
||||||
@@ -239,12 +239,20 @@ def _gpif_tracks(root: ET.Element) -> list[dict]:
|
|||||||
_bars_by_id = {b.get('id'): b for b in (root.find('Bars') or [])}
|
_bars_by_id = {b.get('id'): b for b in (root.find('Bars') or [])}
|
||||||
_voices_by_id = {v.get('id'): v for v in (root.find('Voices') or [])}
|
_voices_by_id = {v.get('id'): v for v in (root.find('Voices') or [])}
|
||||||
_beats_by_id = {b.get('id'): b for b in (root.find('Beats') or [])}
|
_beats_by_id = {b.get('id'): b for b in (root.find('Beats') or [])}
|
||||||
|
_notes_by_id = {n.get('id'): n for n in (root.find('Notes') or [])}
|
||||||
|
|
||||||
def _note_count_for_raw(raw_idx: int) -> int:
|
def _note_count_for_raw(raw_idx: int) -> int:
|
||||||
# Total note count for the track (sum of notes across all its beats).
|
# Count of notes that ACTUALLY become RS notes for the track. This is
|
||||||
# This is the single source of truth: list_tracks surfaces it as the
|
# the single source of truth: list_tracks surfaces it as the 'notes'
|
||||||
# 'notes' field, and _auto_select_gpx uses (count == 0) to skip empty
|
# field (the importer's per-track preview count) and _auto_select_gpx
|
||||||
# tracks — so the graph is walked once here, not again in list_tracks.
|
# uses (count == 0) to skip empty tracks — so the graph is walked once
|
||||||
|
# here, not again in list_tracks.
|
||||||
|
#
|
||||||
|
# Tie-DESTINATION notes are excluded: a tied note is folded into the
|
||||||
|
# previous note as extended sustain (see the `_note_is_tie` skips in
|
||||||
|
# convert_file), so it never becomes a separate RS note. Counting them
|
||||||
|
# made the preview overstate the result (e.g. 260 shown, 241 imported);
|
||||||
|
# excluding them makes the preview match what the user actually gets.
|
||||||
n = 0
|
n = 0
|
||||||
for mb in _masterbars:
|
for mb in _masterbars:
|
||||||
bar_ids = mb.findtext('Bars', '').split()
|
bar_ids = mb.findtext('Bars', '').split()
|
||||||
@@ -263,9 +271,11 @@ def _gpif_tracks(root: ET.Element) -> list[dict]:
|
|||||||
beat = _beats_by_id.get(bid)
|
beat = _beats_by_id.get(bid)
|
||||||
if beat is None:
|
if beat is None:
|
||||||
continue
|
continue
|
||||||
notes_text = beat.findtext('Notes', '').strip()
|
for nid in beat.findtext('Notes', '').split():
|
||||||
if notes_text:
|
note_el = _notes_by_id.get(nid)
|
||||||
n += len(notes_text.split())
|
if note_el is not None and _note_is_tie(note_el):
|
||||||
|
continue
|
||||||
|
n += 1
|
||||||
return n
|
return n
|
||||||
|
|
||||||
result = []
|
result = []
|
||||||
@@ -457,6 +467,31 @@ _GPIF_FINGER_MAP = {
|
|||||||
'pinky': 4, 'little': 4,
|
'pinky': 4, 'little': 4,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Per-note <LeftFingering> teaching mark (§6.2.2). Unlike the chord-diagram
|
||||||
|
# <Position finger=".."> path above, GPIF stores a single note's fret-hand
|
||||||
|
# finger as a direct <Note> child element with the classical p-i-m-a-c letter
|
||||||
|
# codes (verified against GP8 exports), mapped to the same RS finger integers
|
||||||
|
# (open = -1, thumb = 0, index = 1, middle = 2, annular/ring = 3, little = 4).
|
||||||
|
_GPIF_LEFT_FINGERING_MAP = {
|
||||||
|
'open': -1, 'none': -1, '': -1,
|
||||||
|
'p': 0, 'thumb': 0,
|
||||||
|
'i': 1, 'index': 1,
|
||||||
|
'm': 2, 'middle': 2,
|
||||||
|
'a': 3, 'annular': 3, 'ring': 3,
|
||||||
|
'c': 4, 'little': 4, 'pinky': 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _gpif_left_fingering(note_el) -> int:
|
||||||
|
"""Read a GPIF <Note>'s fret-hand finger (<LeftFingering>) -> RS finger int.
|
||||||
|
|
||||||
|
Returns -1 (unset) when absent or unrecognised — never fabricates a finger.
|
||||||
|
Teaching mark only (§6.2.2); never used for grading."""
|
||||||
|
raw = (note_el.findtext('LeftFingering') or '').strip().lower()
|
||||||
|
if not raw:
|
||||||
|
return -1
|
||||||
|
return _GPIF_LEFT_FINGERING_MAP.get(raw, -1)
|
||||||
|
|
||||||
|
|
||||||
def _rs_string_order(string_pitches: list[int]) -> dict[int, int]:
|
def _rs_string_order(string_pitches: list[int]) -> dict[int, int]:
|
||||||
"""Map each GPIF string index → RS string index (0 = lowest pitch).
|
"""Map each GPIF string index → RS string index (0 = lowest pitch).
|
||||||
@@ -697,6 +732,20 @@ def _note_has_vibrato(note_el: ET.Element, prop_map: dict) -> bool:
|
|||||||
return 'Vibrato' in prop_map or note_el.find('Vibrato') is not None
|
return 'Vibrato' in prop_map or note_el.find('Vibrato') is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _beat_has_tremolo(beat_el: ET.Element) -> bool:
|
||||||
|
"""True if a GP7/GP8 beat carries tremolo picking.
|
||||||
|
|
||||||
|
GPIF encodes tremolo picking as a DIRECT beat-level
|
||||||
|
``<Tremolo>1/8</Tremolo>`` child of ``<Beat>`` (the value is the rate). The
|
||||||
|
RS note model has a single boolean tremolo flag with no rate, so the rate is
|
||||||
|
intentionally ignored — any tremolo-picked beat maps to note tremolo across
|
||||||
|
it. Matched as a direct child (not ``.//``) so it is never confused with the
|
||||||
|
whammy-bar ``VibratoWTremBar`` Property, a separate beat-level effect
|
||||||
|
handled elsewhere.
|
||||||
|
"""
|
||||||
|
return beat_el.find('Tremolo') is not None
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# list_tracks — mirrors gp2rs.list_tracks interface
|
# list_tracks — mirrors gp2rs.list_tracks interface
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -1147,6 +1196,59 @@ def _gpx_bend_scale(root: ET.Element) -> float:
|
|||||||
return 50.0 if peak <= 400 else 2500.0
|
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):
|
def _resolve_pending_slides(rs_notes, rs_chords, pending_slides):
|
||||||
"""Resolve GP slide flags collected during the beat loop into RS slide
|
"""Resolve GP slide flags collected during the beat loop into RS slide
|
||||||
fields, now that every note on each string is known.
|
fields, now that every note on each string is known.
|
||||||
@@ -1280,9 +1382,69 @@ def convert_file(
|
|||||||
track_indices, _piano_merge_map = _find_piano_pairs(track_indices, tracks, names)
|
track_indices, _piano_merge_map = _find_piano_pairs(track_indices, tracks, names)
|
||||||
|
|
||||||
output_files = []
|
output_files = []
|
||||||
# Counts of auto-named guitar/bass arrangements so far, so multiple guitars
|
# All auto-assigned arrangement names handed out so far, so multiple
|
||||||
# get distinct RS roles (Lead, Rhythm, Combo, …) instead of all "Lead".
|
# arrangements get distinct labels (Lead, Rhythm, Combo, Bass, Bass 2, …)
|
||||||
_role_counts: dict[str, int] = {}
|
# and the name-aware and positional guitar paths never collide.
|
||||||
|
_used_arr_names: set[str] = set()
|
||||||
|
|
||||||
|
def _unique_arr_name(base: str) -> str:
|
||||||
|
"""Return `base`, or `base 2`/`base 3`/… if it's already been used."""
|
||||||
|
if base not in _used_arr_names:
|
||||||
|
_used_arr_names.add(base)
|
||||||
|
return base
|
||||||
|
k = 2
|
||||||
|
while f"{base} {k}" in _used_arr_names:
|
||||||
|
k += 1
|
||||||
|
name = f"{base} {k}"
|
||||||
|
_used_arr_names.add(name)
|
||||||
|
return name
|
||||||
|
|
||||||
|
_KEYS_PROGS = set(range(0, 8)) | set(range(16, 24)) | {80, 81, 82, 83}
|
||||||
|
|
||||||
|
def _auto_guitar_hint(track_idx: int):
|
||||||
|
"""For a track that auto-resolves to a guitar arrangement, return its
|
||||||
|
role hint: 'lead', 'rhythm', or '' (unhinted). None when the track is
|
||||||
|
NOT an auto-named guitar (explicitly named, bass, drum, vocal, keys).
|
||||||
|
Mirrors the per-track classification in the conversion loop below."""
|
||||||
|
if track_idx >= len(tracks) or names.get(track_idx):
|
||||||
|
return None
|
||||||
|
t = tracks[track_idx]
|
||||||
|
if t['is_drums'] or _is_vocal_track(t):
|
||||||
|
return None
|
||||||
|
low = t['name'].lower()
|
||||||
|
sp = t['string_pitches']
|
||||||
|
prog = t['midi_program']
|
||||||
|
if (isinstance(prog, int) and 32 <= prog <= 39) or (bool(sp) and max(sp) <= 48) or 'bass' in low:
|
||||||
|
return None # bass
|
||||||
|
if (not sp and prog in _KEYS_PROGS) or any(kw in low for kw in ('piano', 'keys', 'keyboard', 'organ')):
|
||||||
|
return None # keys
|
||||||
|
if 'lead' in low and 'rhythm' not in low:
|
||||||
|
return 'lead'
|
||||||
|
if 'rhythm' in low and 'lead' not in low:
|
||||||
|
return 'rhythm'
|
||||||
|
return '' # guitar, no role hint
|
||||||
|
|
||||||
|
# Two-pass guitar role naming, resolved up front so the per-track loop just
|
||||||
|
# looks names up. Reserve every name-hinted Lead/Rhythm first, THEN fill
|
||||||
|
# unhinted guitars into the remaining canonical roles. A single pass would
|
||||||
|
# let an unhinted guitar that appears BEFORE a hinted one steal its role,
|
||||||
|
# pushing the real Lead/Rhythm to a non-canonical "Rhythm 2" that the
|
||||||
|
# downstream name-based path classification doesn't recognise.
|
||||||
|
_guitar_name_by_idx: dict[int, str] = {}
|
||||||
|
_unhinted_guitars: list[int] = []
|
||||||
|
for _ti in track_indices:
|
||||||
|
hint = _auto_guitar_hint(_ti)
|
||||||
|
if hint is None:
|
||||||
|
continue
|
||||||
|
if hint == 'lead':
|
||||||
|
_guitar_name_by_idx[_ti] = _unique_arr_name('Lead')
|
||||||
|
elif hint == 'rhythm':
|
||||||
|
_guitar_name_by_idx[_ti] = _unique_arr_name('Rhythm')
|
||||||
|
else:
|
||||||
|
_unhinted_guitars.append(_ti)
|
||||||
|
for _ti in _unhinted_guitars:
|
||||||
|
base = next((r for r in ('Lead', 'Rhythm', 'Combo') if r not in _used_arr_names), 'Combo')
|
||||||
|
_guitar_name_by_idx[_ti] = _unique_arr_name(base)
|
||||||
|
|
||||||
for track_idx in track_indices:
|
for track_idx in track_indices:
|
||||||
if track_idx >= len(tracks):
|
if track_idx >= len(tracks):
|
||||||
@@ -1330,16 +1492,12 @@ def convert_file(
|
|||||||
)
|
)
|
||||||
low = track['name'].lower()
|
low = track['name'].lower()
|
||||||
if is_bass or 'bass' in low:
|
if is_bass or 'bass' in low:
|
||||||
_bc = _role_counts.get('bass', 0)
|
arr_name = _unique_arr_name('Bass')
|
||||||
_role_counts['bass'] = _bc + 1
|
|
||||||
arr_name = 'Bass' if _bc == 0 else f'Bass {_bc + 1}'
|
|
||||||
else:
|
else:
|
||||||
# Distinct guitar roles by appearance order so two guitars
|
# Guitar role was resolved up front (two-pass, honoring
|
||||||
# don't both become "Lead": Lead, Rhythm, Combo, then Combo N.
|
# "lead"/"rhythm" in the GP track name so a Rhythm-before-Lead
|
||||||
_gc = _role_counts.get('guitar', 0)
|
# file isn't swapped by positional assignment).
|
||||||
_role_counts['guitar'] = _gc + 1
|
arr_name = _guitar_name_by_idx.get(track_idx) or _unique_arr_name('Lead')
|
||||||
_roles = ('Lead', 'Rhythm', 'Combo')
|
|
||||||
arr_name = _roles[_gc] if _gc < len(_roles) else f'Combo {_gc - 1}'
|
|
||||||
|
|
||||||
# Vocal tracks get their own converter — outputs vocals XML, not notes XML
|
# Vocal tracks get their own converter — outputs vocals XML, not notes XML
|
||||||
if is_vocal:
|
if is_vocal:
|
||||||
@@ -1547,6 +1705,11 @@ def convert_file(
|
|||||||
rn.vibrato = True
|
rn.vibrato = True
|
||||||
if 'LeftHandTapping' in _tp or 'Tapped' in _tp:
|
if 'LeftHandTapping' in _tp or 'Tapped' in _tp:
|
||||||
rn.tap = True
|
rn.tap = True
|
||||||
|
# Fret-hand fingering -> fg teaching mark
|
||||||
|
# (§6.2.2). <LeftFingering> is a direct <Note>
|
||||||
|
# child, not a <Property>, so read it off
|
||||||
|
# note_el rather than the property map.
|
||||||
|
rn.fret_finger = _gpif_left_fingering(note_el)
|
||||||
if 'HarmonicType' in _tp:
|
if 'HarmonicType' in _tp:
|
||||||
_ht = (_tp['HarmonicType'].findtext('HType')
|
_ht = (_tp['HarmonicType'].findtext('HType')
|
||||||
or '').strip().lower()
|
or '').strip().lower()
|
||||||
@@ -1564,21 +1727,21 @@ def convert_file(
|
|||||||
rn.pull_off = True
|
rn.pull_off = True
|
||||||
else:
|
else:
|
||||||
rn.hammer_on = True
|
rn.hammer_on = True
|
||||||
# Bend: peak amount (GPIF bend value → semitones,
|
# Bend: `bn` is the peak; `bnv`/`bt` capture
|
||||||
# scale auto-detected per file in _bend_divisor).
|
# the shape over time (§6.2.1). value/divisor
|
||||||
|
# = semitones (scale auto-detected per file).
|
||||||
if 'Bended' in _tp:
|
if 'Bended' in _tp:
|
||||||
_bv = 0.0
|
# Use the beat duration `dur`, not
|
||||||
for _bk in ('BendDestinationValue',
|
# `rn.sustain` (zeroed for notes <= 0.2s),
|
||||||
'BendMiddleValue', 'BendOriginValue'):
|
# so short bends keep their bnv curve —
|
||||||
_be = _tp.get(_bk)
|
# matching the GP5 path, which maps over
|
||||||
if _be is not None:
|
# the raw note duration.
|
||||||
try:
|
_peak, _intent, _curve = _gpx_bend_shape(
|
||||||
_bv = max(_bv, float(
|
_tp, _bend_divisor, dur)
|
||||||
_be.findtext('Float') or 0))
|
if _peak > 0:
|
||||||
except (ValueError, TypeError):
|
rn.bend = _peak
|
||||||
pass
|
rn.bend_intent = _intent
|
||||||
if _bv > 0:
|
rn.bend_values = _curve
|
||||||
rn.bend = round(_bv / _bend_divisor, 1)
|
|
||||||
# Slide flags: 1/2 = pitched slide to the next
|
# Slide flags: 1/2 = pitched slide to the next
|
||||||
# note; 4 = slide out down, 8 = out up. Resolved
|
# note; 4 = slide out down, 8 = out up. Resolved
|
||||||
# post-loop (needs the next note on the string).
|
# post-loop (needs the next note on the string).
|
||||||
@@ -1613,6 +1776,17 @@ def convert_file(
|
|||||||
for _bn in beat_rs_notes:
|
for _bn in beat_rs_notes:
|
||||||
_bn.vibrato = True
|
_bn.vibrato = True
|
||||||
|
|
||||||
|
# Tremolo picking: GP7/GP8 encodes the rate as a
|
||||||
|
# beat-level <Tremolo>1/8</Tremolo> child. The note
|
||||||
|
# model has a single tremolo flag (no rate), so map
|
||||||
|
# any tremolo-picked beat to note tremolo across it.
|
||||||
|
# Independent of vibrato above — a note can carry
|
||||||
|
# both. (Beat-level <Tremolo>, not the whammy
|
||||||
|
# VibratoWTremBar Property, which is handled above.)
|
||||||
|
if _beat_has_tremolo(beat_el):
|
||||||
|
for _bn in beat_rs_notes:
|
||||||
|
_bn.tremolo = True
|
||||||
|
|
||||||
if len(beat_rs_notes) == 1:
|
if len(beat_rs_notes) == 1:
|
||||||
rs_notes.append(beat_rs_notes[0])
|
rs_notes.append(beat_rs_notes[0])
|
||||||
elif len(beat_rs_notes) > 1:
|
elif len(beat_rs_notes) > 1:
|
||||||
@@ -2209,7 +2383,15 @@ def _auto_select_gpx(tracks: list[dict]) -> tuple[list[int], dict[int, str]]:
|
|||||||
if is_bass:
|
if is_bass:
|
||||||
selected.append((i, 'bass'))
|
selected.append((i, 'bass'))
|
||||||
elif is_guitar:
|
elif is_guitar:
|
||||||
selected.append((i, 'guitar'))
|
# Honor "lead"/"rhythm" in the GP track name so two guitars keep
|
||||||
|
# the author's roles instead of being labelled by appearance order
|
||||||
|
# (which swaps a Rhythm-before-Lead file). Unhinted → positional.
|
||||||
|
if 'lead' in name_l and 'rhythm' not in name_l:
|
||||||
|
selected.append((i, 'guitar_lead'))
|
||||||
|
elif 'rhythm' in name_l and 'lead' not in name_l:
|
||||||
|
selected.append((i, 'guitar_rhythm'))
|
||||||
|
else:
|
||||||
|
selected.append((i, 'guitar'))
|
||||||
elif is_keys:
|
elif is_keys:
|
||||||
selected.append((i, 'keys'))
|
selected.append((i, 'keys'))
|
||||||
|
|
||||||
@@ -2218,19 +2400,48 @@ def _auto_select_gpx(tracks: list[dict]) -> tuple[list[int], dict[int, str]]:
|
|||||||
if not t['is_drums'] and t.get('note_count', 1) > 0:
|
if not t['is_drums'] and t.get('note_count', 1) > 0:
|
||||||
selected.append((i, 'guitar'))
|
selected.append((i, 'guitar'))
|
||||||
|
|
||||||
indices = []
|
|
||||||
name_map = {}
|
name_map = {}
|
||||||
counts: dict[str, int] = {}
|
counts: dict[str, int] = {}
|
||||||
RS_NAMES = {'guitar': ('Lead', 'Rhythm', 'Combo'), 'bass': ('Bass',), 'keys': ('Keys',), 'drums': ('Drums',), 'vocal': ('Vocals',)}
|
RS_NAMES = {'bass': ('Bass',), 'keys': ('Keys',),
|
||||||
|
'drums': ('Drums',), 'vocal': ('Vocals',)}
|
||||||
|
used: set[str] = set()
|
||||||
|
|
||||||
|
def _unique(base: str) -> str:
|
||||||
|
if base not in used:
|
||||||
|
used.add(base)
|
||||||
|
return base
|
||||||
|
k = 2
|
||||||
|
while f"{base} {k}" in used:
|
||||||
|
k += 1
|
||||||
|
used.add(f"{base} {k}")
|
||||||
|
return f"{base} {k}"
|
||||||
|
|
||||||
|
# Two passes so name-hinted Lead/Rhythm guitars reserve their canonical role
|
||||||
|
# BEFORE unhinted guitars are filled in — otherwise an unhinted guitar that
|
||||||
|
# appears before a hinted one steals its role (real Rhythm → "Rhythm 2").
|
||||||
|
# Non-guitar roles are handled in pass 1. `name_map` keys by track index so
|
||||||
|
# this does not affect arrangement (selection) order, computed separately.
|
||||||
|
for idx, role in selected:
|
||||||
|
if role == 'guitar':
|
||||||
|
continue
|
||||||
|
if role == 'guitar_lead':
|
||||||
|
base = 'Lead'
|
||||||
|
elif role == 'guitar_rhythm':
|
||||||
|
base = 'Rhythm'
|
||||||
|
else:
|
||||||
|
counts[role] = counts.get(role, 0) + 1
|
||||||
|
c = counts[role]
|
||||||
|
names_for_role = RS_NAMES.get(role, (role.title(),))
|
||||||
|
base = names_for_role[min(c - 1, len(names_for_role) - 1)]
|
||||||
|
if c > len(names_for_role):
|
||||||
|
base = f"{names_for_role[-1]} {c}"
|
||||||
|
name_map[idx] = _unique(base)
|
||||||
|
|
||||||
for idx, role in selected:
|
for idx, role in selected:
|
||||||
counts[role] = counts.get(role, 0) + 1
|
if role != 'guitar':
|
||||||
c = counts[role]
|
continue
|
||||||
names_for_role = RS_NAMES.get(role, (role.title(),))
|
base = next((r for r in ('Lead', 'Rhythm', 'Combo') if r not in used), 'Combo')
|
||||||
arr_name = names_for_role[min(c - 1, len(names_for_role) - 1)]
|
name_map[idx] = _unique(base)
|
||||||
if c > len(names_for_role):
|
|
||||||
arr_name = f"{names_for_role[-1]} {c}"
|
|
||||||
indices.append(idx)
|
|
||||||
name_map[idx] = arr_name
|
|
||||||
|
|
||||||
|
indices = [idx for idx, _role in selected]
|
||||||
return indices, name_map
|
return indices, name_map
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ lib/gp8_audio_sync.py — Extract embedded audio and sync data from GP8 (.gp) fi
|
|||||||
|
|
||||||
Guitar Pro 8 can embed a backing track (OGG audio) into a .gp file alongside
|
Guitar Pro 8 can embed a backing track (OGG audio) into a .gp file alongside
|
||||||
sync points that map bar positions to exact audio timestamps. This module
|
sync points that map bar positions to exact audio timestamps. This module
|
||||||
extracts both, giving Slopsmith:
|
extracts both, giving FeedBack:
|
||||||
|
|
||||||
1. A real backing track audio file (OGG) — no MIDI synthesis needed
|
1. A real backing track audio file (OGG) — no MIDI synthesis needed
|
||||||
2. A precise audio_offset (seconds) from the FramePadding value
|
2. A precise audio_offset (seconds) from the FramePadding value
|
||||||
@@ -45,7 +45,7 @@ import io
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
_log = logging.getLogger("slopsmith.lib.gp8_audio_sync")
|
_log = logging.getLogger("feedBack.lib.gp8_audio_sync")
|
||||||
|
|
||||||
# GP8 embeds the backing track under Content/Assets/ as OGG *or* one of
|
# GP8 embeds the backing track under Content/Assets/ as OGG *or* one of
|
||||||
# several other formats (MP3 is common — e.g. tracks rendered straight
|
# several other formats (MP3 is common — e.g. tracks rendered straight
|
||||||
|
|||||||
+1
-1
@@ -30,7 +30,7 @@ import zipfile
|
|||||||
import io
|
import io
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
_log = logging.getLogger("slopsmith.lib.gp_autosync")
|
_log = logging.getLogger("feedBack.lib.gp_autosync")
|
||||||
|
|
||||||
# ── Dependency check ──────────────────────────────────────────────────────────
|
# ── Dependency check ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
+11
-11
@@ -1,10 +1,10 @@
|
|||||||
"""Logging configuration for Slopsmith.
|
"""Logging configuration for FeedBack.
|
||||||
|
|
||||||
Call ``configure_logging()`` once at server startup, before any slopsmith
|
Call ``configure_logging()`` once at server startup, before any feedBack
|
||||||
module imports that might emit log records.
|
module imports that might emit log records.
|
||||||
|
|
||||||
Environment variables:
|
Environment variables:
|
||||||
LOG_LEVEL — severity threshold for the ``slopsmith.*`` logger tree
|
LOG_LEVEL — severity threshold for the ``feedBack.*`` logger tree
|
||||||
(default: INFO). Also accepted: DEBUG, WARNING, ERROR.
|
(default: INFO). Also accepted: DEBUG, WARNING, ERROR.
|
||||||
LOG_FORMAT — "json" for structured output (Loki, ELK, Promtail);
|
LOG_FORMAT — "json" for structured output (Loki, ELK, Promtail);
|
||||||
"text" (default) for human-readable coloured console output.
|
"text" (default) for human-readable coloured console output.
|
||||||
@@ -43,7 +43,7 @@ def _add_correlation_id(
|
|||||||
|
|
||||||
|
|
||||||
def configure_logging() -> None:
|
def configure_logging() -> None:
|
||||||
"""Wire up the slopsmith logger hierarchy.
|
"""Wire up the feedBack logger hierarchy.
|
||||||
|
|
||||||
Safe to call multiple times; always reflects the current LOG_LEVEL,
|
Safe to call multiple times; always reflects the current LOG_LEVEL,
|
||||||
LOG_FORMAT, and LOG_FILE environment variables.
|
LOG_FORMAT, and LOG_FILE environment variables.
|
||||||
@@ -52,7 +52,7 @@ def configure_logging() -> None:
|
|||||||
level = getattr(logging, raw_level, None)
|
level = getattr(logging, raw_level, None)
|
||||||
if not isinstance(level, int):
|
if not isinstance(level, int):
|
||||||
sys.stderr.write(
|
sys.stderr.write(
|
||||||
f"[slopsmith] WARNING: unrecognised LOG_LEVEL={raw_level!r};"
|
f"[feedBack] WARNING: unrecognised LOG_LEVEL={raw_level!r};"
|
||||||
" falling back to INFO.\n"
|
" falling back to INFO.\n"
|
||||||
)
|
)
|
||||||
level = logging.INFO
|
level = logging.INFO
|
||||||
@@ -60,7 +60,7 @@ def configure_logging() -> None:
|
|||||||
raw_fmt = os.environ.get("LOG_FORMAT", "text").lower()
|
raw_fmt = os.environ.get("LOG_FORMAT", "text").lower()
|
||||||
if raw_fmt not in ("json", "text"):
|
if raw_fmt not in ("json", "text"):
|
||||||
sys.stderr.write(
|
sys.stderr.write(
|
||||||
f"[slopsmith] WARNING: unrecognised LOG_FORMAT={raw_fmt!r};"
|
f"[feedBack] WARNING: unrecognised LOG_FORMAT={raw_fmt!r};"
|
||||||
" falling back to 'text'.\n"
|
" falling back to 'text'.\n"
|
||||||
)
|
)
|
||||||
raw_fmt = "text"
|
raw_fmt = "text"
|
||||||
@@ -137,17 +137,17 @@ def configure_logging() -> None:
|
|||||||
handlers.append(fh)
|
handlers.append(fh)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
sys.stderr.write(
|
sys.stderr.write(
|
||||||
f"[slopsmith] WARNING: could not open LOG_FILE={log_file!r}: {exc}"
|
f"[feedBack] WARNING: could not open LOG_FILE={log_file!r}: {exc}"
|
||||||
" — continuing with console-only logging.\n"
|
" — continuing with console-only logging.\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
_uvicorn_names = ("uvicorn", "uvicorn.error", "uvicorn.access")
|
_uvicorn_names = ("uvicorn", "uvicorn.error", "uvicorn.access")
|
||||||
all_loggers = [logging.getLogger("slopsmith")] + [
|
all_loggers = [logging.getLogger("feedBack")] + [
|
||||||
logging.getLogger(n) for n in _uvicorn_names
|
logging.getLogger(n) for n in _uvicorn_names
|
||||||
]
|
]
|
||||||
|
|
||||||
# Collect all unique old handlers across every logger *before* any close so
|
# Collect all unique old handlers across every logger *before* any close so
|
||||||
# that a shared handler (slopsmith and uvicorn* were intentionally given the
|
# that a shared handler (feedBack and uvicorn* were intentionally given the
|
||||||
# same objects) isn't closed while still attached to another logger tree.
|
# same objects) isn't closed while still attached to another logger tree.
|
||||||
old_handlers: set[logging.Handler] = set()
|
old_handlers: set[logging.Handler] = set()
|
||||||
for lg in all_loggers:
|
for lg in all_loggers:
|
||||||
@@ -160,8 +160,8 @@ def configure_logging() -> None:
|
|||||||
for h in old_handlers:
|
for h in old_handlers:
|
||||||
h.close()
|
h.close()
|
||||||
|
|
||||||
# Install fresh handlers on the slopsmith root.
|
# Install fresh handlers on the feedBack root.
|
||||||
root = logging.getLogger("slopsmith")
|
root = logging.getLogger("feedBack")
|
||||||
for h in handlers:
|
for h in handlers:
|
||||||
root.addHandler(h)
|
root.addHandler(h)
|
||||||
root.setLevel(level)
|
root.setLevel(level)
|
||||||
|
|||||||
@@ -23,14 +23,14 @@ Engine selection
|
|||||||
Two transcription paths share a common output:
|
Two transcription paths share a common output:
|
||||||
|
|
||||||
* `transcribe_vocals_remote(path, server_url, ...)` — POST the vocal
|
* `transcribe_vocals_remote(path, server_url, ...)` — POST the vocal
|
||||||
stem to the `/align` endpoint on a slopsmith-demucs-server (Byron's
|
stem to the `/align` endpoint on a feedBack-demucs-server (got-feedBack's
|
||||||
reference server already hosts WhisperX alongside Demucs at the same
|
reference server already hosts WhisperX alongside Demucs at the same
|
||||||
URL).
|
URL).
|
||||||
|
|
||||||
* `transcribe_vocals_local(path, ...)` — load WhisperX in-process. Heavy
|
* `transcribe_vocals_local(path, ...)` — load WhisperX in-process. Heavy
|
||||||
(~3 GB of model weights for `large-v2` + the wav2vec2 aligner) and
|
(~3 GB of model weights for `large-v2` + the wav2vec2 aligner) and
|
||||||
slow on CPU. Deferred imports of `whisperx`, `torch`, and `soundfile`
|
slow on CPU. Deferred imports of `whisperx`, `torch`, and `soundfile`
|
||||||
keep the rest of slopsmith free of those dependencies.
|
keep the rest of feedBack free of those dependencies.
|
||||||
|
|
||||||
Callers pick between them based on a `whisperx.server_url` config and
|
Callers pick between them based on a `whisperx.server_url` config and
|
||||||
fall back as appropriate. This module does not read config — both
|
fall back as appropriate. This module does not read config — both
|
||||||
@@ -58,7 +58,7 @@ import logging
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, Optional
|
from typing import Callable, Optional
|
||||||
|
|
||||||
log = logging.getLogger("slopsmith.lib.lyrics_transcribe")
|
log = logging.getLogger("feedBack.lib.lyrics_transcribe")
|
||||||
|
|
||||||
ProgressCB = Optional[Callable[[float, str, str], None]]
|
ProgressCB = Optional[Callable[[float, str, str], None]]
|
||||||
|
|
||||||
@@ -179,7 +179,7 @@ _MIN_WORD_DURATION = 0.05
|
|||||||
|
|
||||||
# Semver for the lyric-transcription artifact contract that gets stamped
|
# Semver for the lyric-transcription artifact contract that gets stamped
|
||||||
# into the sloppak manifest's `lyric_transcription` block alongside the
|
# into the sloppak manifest's `lyric_transcription` block alongside the
|
||||||
# engine + model. Bump per the semantics defined in slopsmith#357 (the
|
# engine + model. Bump per the semantics defined in feedBack#357 (the
|
||||||
# parent `stem_separation` RFC):
|
# parent `stem_separation` RFC):
|
||||||
# * patch — metadata-only or implementation fixes; no regeneration
|
# * patch — metadata-only or implementation fixes; no regeneration
|
||||||
# * minor — backward-compatible additions
|
# * minor — backward-compatible additions
|
||||||
|
|||||||
+1
-1
@@ -24,7 +24,7 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
|
|
||||||
log = logging.getLogger("slopsmith.lib.notation")
|
log = logging.getLogger("feedBack.lib.notation")
|
||||||
|
|
||||||
|
|
||||||
# ── Vocabulary ────────────────────────────────────────────────────────────────
|
# ── Vocabulary ────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
+4
-4
@@ -31,7 +31,7 @@ from tunings import tuning_name
|
|||||||
import sloppak as sloppak_mod
|
import sloppak as sloppak_mod
|
||||||
import loosefolder as loosefolder_mod
|
import loosefolder as loosefolder_mod
|
||||||
|
|
||||||
log = logging.getLogger("slopsmith.scan_worker")
|
log = logging.getLogger("feedBack.scan_worker")
|
||||||
|
|
||||||
|
|
||||||
def _relpath(f: Path, dlc: Path) -> str:
|
def _relpath(f: Path, dlc: Path) -> str:
|
||||||
@@ -53,7 +53,7 @@ def _extract_meta_sloppak(path: Path) -> dict:
|
|||||||
meta["tuning_sort_key"] = sum(offsets)
|
meta["tuning_sort_key"] = sum(offsets)
|
||||||
meta["tuning_offsets"] = " ".join(str(o) for o in offsets)
|
meta["tuning_offsets"] = " ".join(str(o) for o in offsets)
|
||||||
meta["format"] = "sloppak"
|
meta["format"] = "sloppak"
|
||||||
# `extract_meta` already populates `stem_ids` (slopsmith#129);
|
# `extract_meta` already populates `stem_ids` (feedBack#129);
|
||||||
# default to empty for older callers / mocks.
|
# default to empty for older callers / mocks.
|
||||||
meta.setdefault("stem_ids", [])
|
meta.setdefault("stem_ids", [])
|
||||||
# Compute smart names for sloppak arrangements using name-based fallback
|
# Compute smart names for sloppak arrangements using name-based fallback
|
||||||
@@ -109,7 +109,7 @@ def _extract_meta_for_file(path: Path, dlc_root=None) -> dict:
|
|||||||
the root it already resolved; in-process callers can pass the resolver
|
the root it already resolved; in-process callers can pass the resolver
|
||||||
itself (e.g. `_get_dlc_dir`) to keep the lookup lazy.
|
itself (e.g. `_get_dlc_dir`) to keep the lookup lazy.
|
||||||
|
|
||||||
Slopsmith reads only its own `.sloppak` format and loose-folder XML
|
FeedBack reads only its own `.sloppak` format and loose-folder XML
|
||||||
songs. Encrypted/proprietary archive formats are not supported and are
|
songs. Encrypted/proprietary archive formats are not supported and are
|
||||||
silently ignored (empty metadata) rather than decrypted.
|
silently ignored (empty metadata) rather than decrypted.
|
||||||
"""
|
"""
|
||||||
@@ -121,7 +121,7 @@ def _extract_meta_for_file(path: Path, dlc_root=None) -> dict:
|
|||||||
if loosefolder_mod.is_loose_song(path):
|
if loosefolder_mod.is_loose_song(path):
|
||||||
root = dlc_root() if callable(dlc_root) else dlc_root
|
root = dlc_root() if callable(dlc_root) else dlc_root
|
||||||
return _extract_meta_loosefolder(path, root)
|
return _extract_meta_loosefolder(path, root)
|
||||||
# Unknown/unsupported shape — return empty metadata. Slopsmith never
|
# Unknown/unsupported shape — return empty metadata. FeedBack never
|
||||||
# reads encrypted archive formats.
|
# reads encrypted archive formats.
|
||||||
return {
|
return {
|
||||||
"title": "", "artist": "", "album": "", "year": "",
|
"title": "", "artist": "", "album": "", "year": "",
|
||||||
|
|||||||
+46
-4
@@ -21,13 +21,19 @@ import zipfile
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
log = logging.getLogger("slopsmith.lib.sloppak")
|
log = logging.getLogger("feedBack.lib.sloppak")
|
||||||
|
|
||||||
# The feedpak format version this build targets / writes (manifest
|
# The feedpak format version this build targets / writes (manifest
|
||||||
# `feedpak_version`, a semver string per spec §4). Readers tolerate any version
|
# `feedpak_version`, a semver string per spec §4). Readers tolerate any version
|
||||||
# (additive/MINOR compatibility); writers stamp this.
|
# (additive/MINOR compatibility); writers stamp this.
|
||||||
FEEDPAK_VERSION = "1.2.0"
|
FEEDPAK_VERSION = "1.2.0"
|
||||||
|
|
||||||
|
# Package suffixes. The format is byte-identical regardless of suffix; `.feedpak`
|
||||||
|
# is the current write extension, `.sloppak` the legacy one we still read.
|
||||||
|
FEEDPAK_EXT = ".feedpak"
|
||||||
|
SLOPPAK_EXT = ".sloppak"
|
||||||
|
SONG_EXTS = (FEEDPAK_EXT, SLOPPAK_EXT) # accepted on read/discovery
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from jsonc import load_json
|
from jsonc import load_json
|
||||||
@@ -48,8 +54,12 @@ import notation as notation_mod
|
|||||||
# ── Format detection ──────────────────────────────────────────────────────────
|
# ── Format detection ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def is_sloppak(path: Path) -> bool:
|
def is_sloppak(path: Path) -> bool:
|
||||||
"""True if path looks like a sloppak (zip file or directory)."""
|
"""True if path looks like a song package (zip file or directory).
|
||||||
return path.name.lower().endswith(".sloppak")
|
|
||||||
|
Accepts both the current `.feedpak` suffix and the legacy `.sloppak` one —
|
||||||
|
same on-disk format, either form.
|
||||||
|
"""
|
||||||
|
return path.name.lower().endswith(SONG_EXTS)
|
||||||
|
|
||||||
|
|
||||||
# ── Source resolution (zip unpack cache + directory passthrough) ──────────────
|
# ── Source resolution (zip unpack cache + directory passthrough) ──────────────
|
||||||
@@ -357,6 +367,14 @@ class LoadedSloppak:
|
|||||||
# song.arrangements (not to manifest["arrangements"]) — skipped entries are
|
# song.arrangements (not to manifest["arrangements"]) — skipped entries are
|
||||||
# absent so indexing by song.arrangements index is safe.
|
# absent so indexing by song.arrangements index is safe.
|
||||||
arrangement_ids: list[str | None] = field(default_factory=list)
|
arrangement_ids: list[str | None] = field(default_factory=list)
|
||||||
|
# Manifest-relative path to the single full-mix audio file, taken from the
|
||||||
|
# manifest `original_audio:` key (e.g. "original/full.ogg"). This is the
|
||||||
|
# pre-separation mixdown that exists alongside the per-instrument `stems`.
|
||||||
|
# None when the key is absent, points outside source_dir, or the file is
|
||||||
|
# missing on disk. Served to the front-end via the highway WS as
|
||||||
|
# `original_audio_url`; the stems plugin uses it to play the untouched mix
|
||||||
|
# when every stem slider is at unity (and the separate stems otherwise).
|
||||||
|
original_audio: str | None = None
|
||||||
|
|
||||||
|
|
||||||
def load_song(
|
def load_song(
|
||||||
@@ -798,6 +816,29 @@ def load_song(
|
|||||||
}
|
}
|
||||||
|
|
||||||
_fpv = manifest.get("feedpak_version")
|
_fpv = manifest.get("feedpak_version")
|
||||||
|
# Optional full-mix audio — manifest `original_audio:` key. The single
|
||||||
|
# pre-separation mixdown that ships alongside the per-instrument stems.
|
||||||
|
# Same permissive, path-traversal-guarded posture as drum_tab above: a
|
||||||
|
# missing/escaping/absent file simply leaves the full mix unavailable (the
|
||||||
|
# player falls back to the separate stems) rather than aborting the load.
|
||||||
|
# We store the manifest-relative string so server.py can build its URL the
|
||||||
|
# same way it builds stem URLs (via the /api/sloppak/.../file/ endpoint).
|
||||||
|
original_audio_data: str | None = None
|
||||||
|
original_audio_rel = manifest.get("original_audio")
|
||||||
|
if isinstance(original_audio_rel, str) and original_audio_rel.strip():
|
||||||
|
rel = original_audio_rel.strip()
|
||||||
|
try:
|
||||||
|
oa_path = (source_dir / rel).resolve()
|
||||||
|
oa_path.relative_to(source_dir.resolve())
|
||||||
|
except ValueError:
|
||||||
|
log.warning("sloppak: original_audio path %r escapes source_dir — skipped", rel)
|
||||||
|
oa_path = None
|
||||||
|
except OSError as e:
|
||||||
|
log.warning("sloppak: original_audio path resolution failed (%s) — skipped", e)
|
||||||
|
oa_path = None
|
||||||
|
if oa_path is not None and oa_path.is_file():
|
||||||
|
original_audio_data = rel
|
||||||
|
|
||||||
return LoadedSloppak(
|
return LoadedSloppak(
|
||||||
song=song,
|
song=song,
|
||||||
stems=stems,
|
stems=stems,
|
||||||
@@ -811,6 +852,7 @@ def load_song(
|
|||||||
keys=keys_data,
|
keys=keys_data,
|
||||||
notation_by_id=notation_by_id_data,
|
notation_by_id=notation_by_id_data,
|
||||||
arrangement_ids=arrangement_ids_acc,
|
arrangement_ids=arrangement_ids_acc,
|
||||||
|
original_audio=original_audio_data,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -882,6 +924,6 @@ def extract_meta(path: Path) -> dict:
|
|||||||
"arrangements": arrangements,
|
"arrangements": arrangements,
|
||||||
"has_lyrics": has_lyrics,
|
"has_lyrics": has_lyrics,
|
||||||
"stem_count": stem_count,
|
"stem_count": stem_count,
|
||||||
# slopsmith#129: per-stem filter needs the id list, not just count.
|
# feedBack#129: per-stem filter needs the id list, not just count.
|
||||||
"stem_ids": stem_ids,
|
"stem_ids": stem_ids,
|
||||||
}
|
}
|
||||||
|
|||||||
+293
-11
@@ -8,7 +8,7 @@ import logging
|
|||||||
import math
|
import math
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
log = logging.getLogger("slopsmith.lib.song")
|
log = logging.getLogger("feedBack.lib.song")
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -20,6 +20,13 @@ class Note:
|
|||||||
slide_to: int = -1
|
slide_to: int = -1
|
||||||
slide_unpitch_to: int = -1
|
slide_unpitch_to: int = -1
|
||||||
bend: float = 0.0
|
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
|
hammer_on: bool = False
|
||||||
pull_off: bool = False
|
pull_off: bool = False
|
||||||
harmonic: bool = False
|
harmonic: bool = False
|
||||||
@@ -36,6 +43,18 @@ class Note:
|
|||||||
slap: bool = False
|
slap: bool = False
|
||||||
right_hand: int = -1
|
right_hand: int = -1
|
||||||
pick_direction: int = -1
|
pick_direction: int = -1
|
||||||
|
# Teaching marks (§6.2.2, feedpak 1.5.0) — display/teaching only; a grader
|
||||||
|
# MUST NEVER use these to judge whether a note was played correctly.
|
||||||
|
# `fret_finger` is the fret-hand finger (-1 unset, 0 thumb, 1..4
|
||||||
|
# index/middle/ring/pinky — same convention as a chord template's fingers);
|
||||||
|
# `strum_group` is a strum/rake key (>= -1, default -1; notes sharing a value
|
||||||
|
# >= 0 are one gesture, with `pick_direction` giving its direction);
|
||||||
|
# `scale_degree` is the note's pitch class as a chromatic offset 0..11 above
|
||||||
|
# the active key's tonic (default -1, MAY be derived from keys.json). All
|
||||||
|
# three default-omitted on the wire; older readers ignore them.
|
||||||
|
fret_finger: int = -1
|
||||||
|
strum_group: int = -1
|
||||||
|
scale_degree: int = -1
|
||||||
ignore: bool = False
|
ignore: bool = False
|
||||||
|
|
||||||
|
|
||||||
@@ -46,6 +65,17 @@ class ChordTemplate:
|
|||||||
frets: list[int]
|
frets: list[int]
|
||||||
display_name: str = ""
|
display_name: str = ""
|
||||||
arpeggio: bool = False
|
arpeggio: bool = False
|
||||||
|
# Harmony annotation (§6.6) — key-independent voicing type, e.g. "open",
|
||||||
|
# "triad", "shell", "drop2", "barre". Display/teaching only, never grading.
|
||||||
|
voicing: str = ""
|
||||||
|
# Harmony annotation (§6.6) — the CAGED shape the fingering derives from,
|
||||||
|
# one of "C"/"A"/"G"/"E"/"D" ("" = unset). Display/teaching only, never grading.
|
||||||
|
caged: str = ""
|
||||||
|
# Harmony annotation (§6.6) — chromatic semitone offsets 0..11 above the
|
||||||
|
# chord root marking the quality-defining tones (e.g. dom7 -> [4, 10]).
|
||||||
|
# snake_case attr; rides the wire as camelCase "guideTones" (like
|
||||||
|
# display_name -> "displayName"). Display/teaching only, never grading.
|
||||||
|
guide_tones: list = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -54,6 +84,10 @@ class Chord:
|
|||||||
chord_id: int
|
chord_id: int
|
||||||
notes: list[Note] = field(default_factory=list)
|
notes: list[Note] = field(default_factory=list)
|
||||||
high_density: bool = False
|
high_density: bool = False
|
||||||
|
# Harmony annotation (§6.3.1) — key-dependent harmonic function on the chord
|
||||||
|
# INSTANCE: {rn: str, q: str, deg: int 0..11}. All three keys required when
|
||||||
|
# present (see _validate_fn). Display/teaching only, never grading.
|
||||||
|
fn: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -90,10 +124,10 @@ class PhraseLevel:
|
|||||||
"""One difficulty tier's worth of note/chord/anchor/hand-shape data for a
|
"""One difficulty tier's worth of note/chord/anchor/hand-shape data for a
|
||||||
single phrase iteration. the arrangement XML stores these as `<level
|
single phrase iteration. the arrangement XML stores these as `<level
|
||||||
difficulty="N">` blocks that repeat for every difficulty tier the chart
|
difficulty="N">` blocks that repeat for every difficulty tier the chart
|
||||||
author wrote; slopsmith used to collapse them to the phrase's
|
author wrote; feedBack used to collapse them to the phrase's
|
||||||
maxDifficulty and throw the rest away. Keeping them around lets the
|
maxDifficulty and throw the rest away. Keeping them around lets the
|
||||||
highway render a "master difficulty" slider that picks a per-phrase
|
highway render a "master difficulty" slider that picks a per-phrase
|
||||||
difficulty tier at render time (slopsmith#48)."""
|
difficulty tier at render time (feedBack#48)."""
|
||||||
|
|
||||||
difficulty: int
|
difficulty: int
|
||||||
notes: list[Note] = field(default_factory=list)
|
notes: list[Note] = field(default_factory=list)
|
||||||
@@ -141,7 +175,7 @@ class Arrangement:
|
|||||||
# `base`/`changes` drive the highway tone-change markers; `definitions`
|
# `base`/`changes` drive the highway tone-change markers; `definitions`
|
||||||
# feed the Tones plugin gear panel.
|
# feed the Tones plugin gear panel.
|
||||||
tones: dict | None = None
|
tones: dict | None = None
|
||||||
# arrangement XML <arrangementProperties> flags for smart naming (slopsmith feat/arrangement).
|
# arrangement XML <arrangementProperties> flags for smart naming (feedBack feat/arrangement).
|
||||||
# Populated from the XML; default False/0 for sloppak / GP-imported sources.
|
# Populated from the XML; default False/0 for sloppak / GP-imported sources.
|
||||||
path_lead: bool = False
|
path_lead: bool = False
|
||||||
path_rhythm: bool = False
|
path_rhythm: bool = False
|
||||||
@@ -221,6 +255,23 @@ def note_to_wire(n: Note) -> dict:
|
|||||||
out["pkd"] = n.pick_direction
|
out["pkd"] = n.pick_direction
|
||||||
if n.ignore:
|
if n.ignore:
|
||||||
out["ig"] = True
|
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
|
||||||
|
]
|
||||||
|
# Teaching marks (§6.2.2) — default-omitted, mirroring rh/pkd above.
|
||||||
|
if n.fret_finger != -1:
|
||||||
|
out["fg"] = n.fret_finger
|
||||||
|
if n.strum_group != -1:
|
||||||
|
out["ch"] = n.strum_group
|
||||||
|
if n.scale_degree != -1:
|
||||||
|
out["sd"] = n.scale_degree
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -232,12 +283,19 @@ def chord_note_to_wire(cn: Note) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def chord_to_wire(c: Chord) -> dict:
|
def chord_to_wire(c: Chord) -> dict:
|
||||||
return {
|
out = {
|
||||||
"t": round(c.time, 3),
|
"t": round(c.time, 3),
|
||||||
"id": c.chord_id,
|
"id": c.chord_id,
|
||||||
"hd": c.high_density,
|
"hd": c.high_density,
|
||||||
"notes": [chord_note_to_wire(cn) for cn in c.notes],
|
"notes": [chord_note_to_wire(cn) for cn in c.notes],
|
||||||
}
|
}
|
||||||
|
# Harmony function (§6.3.1) — default-omitted, mirroring bend `bnv`. Re-validate
|
||||||
|
# on emit (not just decode) so a directly-constructed Chord can't put a partial
|
||||||
|
# or out-of-range fn on the wire, which would fail the schema's required-keys rule.
|
||||||
|
fn = _validate_fn(c.fn)
|
||||||
|
if fn:
|
||||||
|
out["fn"] = fn
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def anchor_to_wire(a: Anchor) -> dict:
|
def anchor_to_wire(a: Anchor) -> dict:
|
||||||
@@ -254,7 +312,7 @@ def hand_shape_to_wire(h: HandShape) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def chord_template_to_wire(ct: ChordTemplate) -> dict:
|
def chord_template_to_wire(ct: ChordTemplate) -> dict:
|
||||||
return {
|
out = {
|
||||||
"name": ct.name,
|
"name": ct.name,
|
||||||
# ChordTemplate.display_name defaults to "" on the dataclass, but
|
# ChordTemplate.display_name defaults to "" on the dataclass, but
|
||||||
# the spec defaults displayName to name. Fall back here so
|
# the spec defaults displayName to name. Fall back here so
|
||||||
@@ -266,6 +324,40 @@ def chord_template_to_wire(ct: ChordTemplate) -> dict:
|
|||||||
"fingers": list(ct.fingers),
|
"fingers": list(ct.fingers),
|
||||||
"frets": list(ct.frets),
|
"frets": list(ct.frets),
|
||||||
}
|
}
|
||||||
|
# Harmony voicing (§6.6) — default-omitted, only when non-empty.
|
||||||
|
if ct.voicing:
|
||||||
|
out["voicing"] = ct.voicing
|
||||||
|
# CAGED shape + guide tones (§6.6) — default-omitted, mirroring voicing.
|
||||||
|
# Sanitize on EMIT too (not just on decode): a directly-constructed template
|
||||||
|
# must not be able to write a non-enum `caged` or an out-of-range `guideTone`
|
||||||
|
# to the wire (the spec constrains caged to C/A/G/E/D and guideTones to 0..11).
|
||||||
|
_caged = _sanitize_caged(ct.caged)
|
||||||
|
if _caged:
|
||||||
|
out["caged"] = _caged
|
||||||
|
_guide_tones = _sanitize_guide_tones(ct.guide_tones)
|
||||||
|
if _guide_tones:
|
||||||
|
out["guideTones"] = _guide_tones
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# §6.6 CAGED shape enum — the only values accepted off the wire.
|
||||||
|
_CAGED_SHAPES = ("C", "A", "G", "E", "D")
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_caged(val) -> str:
|
||||||
|
"""A wire `caged` is kept only when it is one of the CAGED shape letters;
|
||||||
|
anything else (None, int, list, unknown string) falls back to ""."""
|
||||||
|
return val if isinstance(val, str) and val in _CAGED_SHAPES else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_guide_tones(val) -> list:
|
||||||
|
"""A wire `guideTones` is kept only as the int entries in 0..11; non-list
|
||||||
|
input, non-ints (bool is an int subclass — rejected), and out-of-range
|
||||||
|
values are dropped so a malformed value can't round-trip."""
|
||||||
|
if not isinstance(val, list):
|
||||||
|
return []
|
||||||
|
return [v for v in val
|
||||||
|
if isinstance(v, int) and not isinstance(v, bool) and 0 <= v <= 11]
|
||||||
|
|
||||||
|
|
||||||
def _wire_int_optional(v, default=-1):
|
def _wire_int_optional(v, default=-1):
|
||||||
@@ -283,6 +375,129 @@ def _wire_int_optional(v, default=-1):
|
|||||||
return default
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# Natural-note letter -> pitch class (0 = C). Used to parse a keys.json key
|
||||||
|
# name's tonic for scale-degree derivation (§6.2.2 / §7.7).
|
||||||
|
_KEY_LETTER_PC = {"C": 0, "D": 2, "E": 4, "F": 5, "G": 7, "A": 9, "B": 11}
|
||||||
|
|
||||||
|
|
||||||
|
def key_to_tonic_pc(key) -> int | None:
|
||||||
|
"""Parse a keys.json key name (§7.7) to its tonic pitch class 0..11.
|
||||||
|
|
||||||
|
Reads only the leading note letter plus optional accidentals — e.g. ``"E"``,
|
||||||
|
``"Em"``, ``"A#m"``, ``"Bb"``, ``"F#"`` -> 4, 4, 10, 10, 6. The mode/quality
|
||||||
|
suffix (``m``/``maj``/``min``/scale name) is irrelevant to the tonic and is
|
||||||
|
ignored. Returns ``None`` for anything not starting with a valid note letter,
|
||||||
|
so callers can leave ``sd`` unset rather than guess. Used only for teaching
|
||||||
|
marks; never for grading."""
|
||||||
|
if not isinstance(key, str):
|
||||||
|
return None
|
||||||
|
s = key.strip()
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
pc = _KEY_LETTER_PC.get(s[0].upper())
|
||||||
|
if pc is None:
|
||||||
|
return None
|
||||||
|
# Consume any run of accidentals directly after the letter (``#``/``b``/
|
||||||
|
# unicode ♯/♭); stop at the first non-accidental (start of the mode suffix).
|
||||||
|
for ch in s[1:]:
|
||||||
|
if ch in ("#", "♯"):
|
||||||
|
pc += 1
|
||||||
|
elif ch in ("b", "♭"):
|
||||||
|
pc -= 1
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
return pc % 12
|
||||||
|
|
||||||
|
|
||||||
|
def scale_degree_for_pitch(midi_pitch: int, tonic_pc: int) -> int:
|
||||||
|
"""Chromatic scale degree 0..11 of ``midi_pitch`` above tonic ``tonic_pc``
|
||||||
|
(§6.2.2): the pitch class distance in semitones, 0 = tonic, 7 = fifth.
|
||||||
|
Display/teaching only — MUST NEVER feed a grader."""
|
||||||
|
return (int(midi_pitch) - int(tonic_pc)) % 12
|
||||||
|
|
||||||
|
|
||||||
|
# Open-string base MIDI per string count, index 0 = lowest string. Mirrors
|
||||||
|
# app.js `_TUNING_BASE_MIDI` / highway_3d `_baseOpenStringMidis` so a derived
|
||||||
|
# scale degree agrees with the tuner + open-string labels. `arr.tuning` carries
|
||||||
|
# per-string OFFSETS from standard (not absolute pitch), so the sounding open
|
||||||
|
# pitch is `base + offset (+ capo)` — see `note_pitch_midi`.
|
||||||
|
_TUNING_BASE_MIDI = {
|
||||||
|
4: [28, 33, 38, 43],
|
||||||
|
5: [23, 28, 33, 38, 43],
|
||||||
|
6: [40, 45, 50, 55, 59, 64],
|
||||||
|
7: [35, 40, 45, 50, 55, 59, 64],
|
||||||
|
8: [30, 35, 40, 45, 50, 55, 59, 64],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def base_open_string_midis(string_count: int, is_bass: bool) -> list[int]:
|
||||||
|
"""Standard open-string base MIDI list for an arrangement, index 0 = lowest.
|
||||||
|
|
||||||
|
Mirrors app.js `_tuningOffsetsToFreqs`: a 4/5-string *bass* uses its own low
|
||||||
|
base, while a 4/5-string non-bass (a guitar voicing) borrows the low strings
|
||||||
|
of the 6-string base; 6/7/8 use their own. Unknown counts fall back to the
|
||||||
|
6-string base."""
|
||||||
|
n = int(string_count)
|
||||||
|
if n in (4, 5):
|
||||||
|
return _TUNING_BASE_MIDI[n] if is_bass else _TUNING_BASE_MIDI[6]
|
||||||
|
return _TUNING_BASE_MIDI.get(n, _TUNING_BASE_MIDI[6])
|
||||||
|
|
||||||
|
|
||||||
|
def pitch_from_base(base: list[int], capo: int, tuning: list[int],
|
||||||
|
string: int, fret: int) -> int | None:
|
||||||
|
"""Absolute sounding MIDI for one string+fret, given a precomputed open-string
|
||||||
|
``base`` (from :func:`base_open_string_midis`) and the arrangement's tuning
|
||||||
|
OFFSETS + capo. None when ``string`` has no tuning entry. Single source of the
|
||||||
|
pitch formula so the per-note hot path can hoist ``base`` out of the loop."""
|
||||||
|
if not (0 <= string < len(tuning)) or not base:
|
||||||
|
return None
|
||||||
|
root = base[string] if string < len(base) else base[-1]
|
||||||
|
return root + int(tuning[string]) + int(capo) + int(fret)
|
||||||
|
|
||||||
|
|
||||||
|
def note_pitch_midi(arr: "Arrangement", note: "Note") -> int | None:
|
||||||
|
"""Absolute sounding MIDI pitch of ``note`` on arrangement ``arr``, or None
|
||||||
|
when its string index has no tuning entry.
|
||||||
|
|
||||||
|
Pitch = standard base for the string + the arrangement's per-string tuning
|
||||||
|
OFFSET + capo + fret, matching the client's open-string/tuner math. Used to
|
||||||
|
derive the ``sd`` teaching mark (§6.2.2); display only, never grading.
|
||||||
|
O(notes) via ``arrangement_string_count`` — for a whole arrangement, hoist
|
||||||
|
the base with :func:`base_open_string_midis` and call :func:`pitch_from_base`
|
||||||
|
per note instead."""
|
||||||
|
is_bass = "bass" in (arr.name or "").lower()
|
||||||
|
base = base_open_string_midis(arrangement_string_count(arr), is_bass)
|
||||||
|
return pitch_from_base(base, int(getattr(arr, "capo", 0) or 0),
|
||||||
|
arr.tuning or [], note.string, note.fret)
|
||||||
|
|
||||||
|
|
||||||
def note_from_wire(d: dict, time: float | None = None) -> Note:
|
def note_from_wire(d: dict, time: float | None = None) -> Note:
|
||||||
return Note(
|
return Note(
|
||||||
time=float(d.get("t", time if time is not None else 0.0)),
|
time=float(d.get("t", time if time is not None else 0.0)),
|
||||||
@@ -292,6 +507,8 @@ def note_from_wire(d: dict, time: float | None = None) -> Note:
|
|||||||
slide_to=int(d.get("sl", -1)),
|
slide_to=int(d.get("sl", -1)),
|
||||||
slide_unpitch_to=int(d.get("slu", -1)),
|
slide_unpitch_to=int(d.get("slu", -1)),
|
||||||
bend=float(d.get("bn", 0.0)),
|
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)),
|
hammer_on=bool(d.get("ho", False)),
|
||||||
pull_off=bool(d.get("po", False)),
|
pull_off=bool(d.get("po", False)),
|
||||||
harmonic=bool(d.get("hm", False)),
|
harmonic=bool(d.get("hm", False)),
|
||||||
@@ -310,10 +527,38 @@ def note_from_wire(d: dict, time: float | None = None) -> Note:
|
|||||||
# the XML side's `_int_optional`.
|
# the XML side's `_int_optional`.
|
||||||
right_hand=_wire_int_optional(d.get("rh"), -1),
|
right_hand=_wire_int_optional(d.get("rh"), -1),
|
||||||
pick_direction=_wire_int_optional(d.get("pkd"), -1),
|
pick_direction=_wire_int_optional(d.get("pkd"), -1),
|
||||||
|
# Teaching marks (§6.2.2) — display only, never used for grading.
|
||||||
|
fret_finger=_wire_int_optional(d.get("fg"), -1),
|
||||||
|
strum_group=_wire_int_optional(d.get("ch"), -1),
|
||||||
|
scale_degree=_wire_int_optional(d.get("sd"), -1),
|
||||||
ignore=bool(d.get("ig", False)),
|
ignore=bool(d.get("ig", False)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_fn(raw) -> dict | None:
|
||||||
|
"""Validate an optional chord harmony function (§6.3.1).
|
||||||
|
|
||||||
|
Returns a clean ``{"rn", "q", "deg"}`` dict only when ``raw`` is an object
|
||||||
|
with a non-empty ``rn`` string, a non-empty ``q`` string, and an int ``deg``
|
||||||
|
in 0..11. Any malformed / missing-key / out-of-range input -> ``None`` so a
|
||||||
|
partial fn (which would fail the schema's required-keys rule) never rides the
|
||||||
|
wire. Display/teaching only — MUST NEVER feed a grader. Mirrors the
|
||||||
|
drop-to-default tolerance of `_sanitize_bend_curve`."""
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return None
|
||||||
|
rn = raw.get("rn")
|
||||||
|
q = raw.get("q")
|
||||||
|
deg = raw.get("deg")
|
||||||
|
if not isinstance(rn, str) or not rn.strip():
|
||||||
|
return None
|
||||||
|
if not isinstance(q, str) or not q.strip():
|
||||||
|
return None
|
||||||
|
# bool is an int subclass — reject it so `deg=True` can't pass as 1.
|
||||||
|
if not isinstance(deg, int) or isinstance(deg, bool) or not (0 <= deg <= 11):
|
||||||
|
return None
|
||||||
|
return {"rn": rn.strip(), "q": q.strip(), "deg": deg}
|
||||||
|
|
||||||
|
|
||||||
def chord_from_wire(d: dict) -> Chord:
|
def chord_from_wire(d: dict) -> Chord:
|
||||||
t = float(d.get("t", 0.0))
|
t = float(d.get("t", 0.0))
|
||||||
return Chord(
|
return Chord(
|
||||||
@@ -321,6 +566,7 @@ def chord_from_wire(d: dict) -> Chord:
|
|||||||
chord_id=int(d.get("id", 0)),
|
chord_id=int(d.get("id", 0)),
|
||||||
high_density=bool(d.get("hd", False)),
|
high_density=bool(d.get("hd", False)),
|
||||||
notes=[note_from_wire(cn, time=t) for cn in d.get("notes", [])],
|
notes=[note_from_wire(cn, time=t) for cn in d.get("notes", [])],
|
||||||
|
fn=_validate_fn(d.get("fn")),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -376,7 +622,7 @@ def arrangement_string_count(arr: Arrangement) -> int:
|
|||||||
"""Derive the active arrangement's string count.
|
"""Derive the active arrangement's string count.
|
||||||
|
|
||||||
Used by the server to emit ``stringCount`` in the song_info
|
Used by the server to emit ``stringCount`` in the song_info
|
||||||
WebSocket payload (slopsmith-plugin-3dhighway#7).
|
WebSocket payload (feedBack-plugin-3dhighway#7).
|
||||||
|
|
||||||
The arrangement XML schema always emits 6 ``<tuning>`` slots regardless
|
The arrangement XML schema always emits 6 ``<tuning>`` slots regardless
|
||||||
of instrument (bass charts populate `string0`–`string3` and pad
|
of instrument (bass charts populate `string0`–`string3` and pad
|
||||||
@@ -673,7 +919,11 @@ def arrangement_from_wire(d: dict) -> Arrangement:
|
|||||||
display_name=ct.get("displayName", ct.get("name", "")),
|
display_name=ct.get("displayName", ct.get("name", "")),
|
||||||
arpeggio=bool(ct.get("arp", False)),
|
arpeggio=bool(ct.get("arp", False)),
|
||||||
fingers=list(ct.get("fingers", [-1] * 6)),
|
fingers=list(ct.get("fingers", [-1] * 6)),
|
||||||
frets=list(ct.get("frets", [-1] * 6)))
|
frets=list(ct.get("frets", [-1] * 6)),
|
||||||
|
voicing=(ct.get("voicing")
|
||||||
|
if isinstance(ct.get("voicing"), str) else ""),
|
||||||
|
caged=_sanitize_caged(ct.get("caged")),
|
||||||
|
guide_tones=_sanitize_guide_tones(ct.get("guideTones")))
|
||||||
for ct in d.get("templates", [])
|
for ct in d.get("templates", [])
|
||||||
],
|
],
|
||||||
# `phrases` is optional — absent on single-level sources / older
|
# `phrases` is optional — absent on single-level sources / older
|
||||||
@@ -768,6 +1018,18 @@ def _chord_high_density(elem: ET.Element) -> bool:
|
|||||||
return False
|
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:
|
def _parse_note(n) -> Note:
|
||||||
return Note(
|
return Note(
|
||||||
time=_float(n, "time"),
|
time=_float(n, "time"),
|
||||||
@@ -777,6 +1039,8 @@ def _parse_note(n) -> Note:
|
|||||||
slide_to=_int(n, "slideTo", -1),
|
slide_to=_int(n, "slideTo", -1),
|
||||||
slide_unpitch_to=_int(n, "slideUnpitchTo", -1),
|
slide_unpitch_to=_int(n, "slideUnpitchTo", -1),
|
||||||
bend=_float(n, "bend"),
|
bend=_float(n, "bend"),
|
||||||
|
bend_intent=_int(n, "bendIntent", 0),
|
||||||
|
bend_values=_parse_bend_values(n),
|
||||||
hammer_on=_bool(n, "hammerOn"),
|
hammer_on=_bool(n, "hammerOn"),
|
||||||
pull_off=_bool(n, "pullOff"),
|
pull_off=_bool(n, "pullOff"),
|
||||||
harmonic=_bool(n, "harmonic"),
|
harmonic=_bool(n, "harmonic"),
|
||||||
@@ -793,6 +1057,10 @@ def _parse_note(n) -> Note:
|
|||||||
slap=_bool(n, "slap"),
|
slap=_bool(n, "slap"),
|
||||||
right_hand=_int_optional(n, "rightHand", -1),
|
right_hand=_int_optional(n, "rightHand", -1),
|
||||||
pick_direction=_int_optional(n, "pickDirection", -1),
|
pick_direction=_int_optional(n, "pickDirection", -1),
|
||||||
|
# Teaching mark (§6.2.2): GP import writes `fretFinger`; strum_group /
|
||||||
|
# scale_degree are authored downstream (editor / derived), not in chart
|
||||||
|
# XML, so they have no attribute to read here.
|
||||||
|
fret_finger=_int_optional(n, "fretFinger", -1),
|
||||||
ignore=_bool(n, "ignore"),
|
ignore=_bool(n, "ignore"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -822,6 +1090,20 @@ def parse_arrangement(xml_path: str) -> Arrangement:
|
|||||||
while el.get(f"string{i}") is not None:
|
while el.get(f"string{i}") is not None:
|
||||||
tuning.append(_int(el, f"string{i}"))
|
tuning.append(_int(el, f"string{i}"))
|
||||||
i += 1
|
i += 1
|
||||||
|
# Authoritative string count, written by the GP/RS serializer
|
||||||
|
# (gp2rs._build_xml). The schema pads `<tuning>` to 6 slots, which
|
||||||
|
# erases the 4-vs-5-vs-6-string distinction for standard tunings;
|
||||||
|
# when the real count was recorded, trim the padded tail so
|
||||||
|
# arrangement_string_count / the editor see 4 or 5 instead of 6.
|
||||||
|
# Absent (archive / legacy sources) → leave the 6-slot tuning as-is.
|
||||||
|
sc = el.get("stringCount")
|
||||||
|
if sc is not None:
|
||||||
|
try:
|
||||||
|
n = int(sc)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
n = 0
|
||||||
|
if 1 <= n <= len(tuning):
|
||||||
|
tuning = tuning[:n]
|
||||||
|
|
||||||
# Capo
|
# Capo
|
||||||
capo = 0
|
capo = 0
|
||||||
@@ -1008,7 +1290,7 @@ def parse_arrangement(xml_path: str) -> Arrangement:
|
|||||||
def _collect_from_parsed(parsed, t_start, t_end):
|
def _collect_from_parsed(parsed, t_start, t_end):
|
||||||
"""Append a pre-parsed level's time-clipped slice to the flat
|
"""Append a pre-parsed level's time-clipped slice to the flat
|
||||||
arrangement lists. Used for the max-mastery merge that preserves
|
arrangement lists. Used for the max-mastery merge that preserves
|
||||||
the pre-slopsmith#48 behaviour for existing consumers."""
|
the pre-feedBack#48 behaviour for existing consumers."""
|
||||||
lv_notes, lv_chords, lv_anchors, lv_hand_shapes = _extract_level_slice(
|
lv_notes, lv_chords, lv_anchors, lv_hand_shapes = _extract_level_slice(
|
||||||
parsed, t_start, t_end
|
parsed, t_start, t_end
|
||||||
)
|
)
|
||||||
@@ -1027,7 +1309,7 @@ def parse_arrangement(xml_path: str) -> Arrangement:
|
|||||||
_collect_from_parsed(best, 0.0, float("inf"))
|
_collect_from_parsed(best, 0.0, float("inf"))
|
||||||
|
|
||||||
# Per-phrase difficulty data for the master-difficulty slider
|
# Per-phrase difficulty data for the master-difficulty slider
|
||||||
# (slopsmith#48). Only populated when the XML has multiple levels AND
|
# (feedBack#48). Only populated when the XML has multiple levels AND
|
||||||
# phrase data — left as None for single-level sources so the frontend
|
# phrase data — left as None for single-level sources so the frontend
|
||||||
# knows to disable the slider.
|
# knows to disable the slider.
|
||||||
phrases: list[Phrase] | None = None
|
phrases: list[Phrase] | None = None
|
||||||
@@ -1177,7 +1459,7 @@ def _convert_sng_to_xml(extracted_dir: str):
|
|||||||
"""No-op stub.
|
"""No-op stub.
|
||||||
|
|
||||||
Historically this converted proprietary encrypted ``.notechart`` arrangement
|
Historically this converted proprietary encrypted ``.notechart`` arrangement
|
||||||
files to XML via an external tool. That path has been removed: slopsmith
|
files to XML via an external tool. That path has been removed: feedBack
|
||||||
reads only its own ``.sloppak`` format and loose-folder/GP/MusicXML-derived
|
reads only its own ``.sloppak`` format and loose-folder/GP/MusicXML-derived
|
||||||
arrangement XML, and never decodes or decrypts proprietary archives. Kept
|
arrangement XML, and never decodes or decrypts proprietary archives. Kept
|
||||||
as a no-op so ``load_song`` (which loads plain arrangement XML/JSON from a
|
as a no-op so ``load_song`` (which loads plain arrangement XML/JSON from a
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@ source of truth, so the change survives both incremental and full rescans.
|
|||||||
only the keys present are overwritten, so an edit of just the title can't blank
|
only the keys present are overwritten, so an edit of just the title can't blank
|
||||||
out the artist.
|
out the artist.
|
||||||
|
|
||||||
Only slopsmith's own ``.sloppak`` format (zip- or directory-form) is writable.
|
Only feedBack's own ``.sloppak`` format (zip- or directory-form) is writable.
|
||||||
Unknown / unsupported shapes return False and the caller keeps the DB-only
|
Unknown / unsupported shapes return False and the caller keeps the DB-only
|
||||||
update.
|
update.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"""Regenerate ``static/tailwind.min.css`` over the full installed-plugin set.
|
"""Regenerate ``static/tailwind.min.css`` over the full installed-plugin set.
|
||||||
|
|
||||||
Core's committed (and image-baked) stylesheet is built scanning only the
|
Core's committed (and image-baked) stylesheet is built scanning only the
|
||||||
in-tree plugins. A plugin installed at runtime — into ``SLOPSMITH_PLUGINS_DIR``
|
in-tree plugins. A plugin installed at runtime — into ``FEEDBACK_PLUGINS_DIR``
|
||||||
— ships Tailwind classes the sheet never saw, so it renders unstyled. The
|
— ships Tailwind classes the sheet never saw, so it renders unstyled. The
|
||||||
Play CDN's runtime JIT that used to cover this was removed (slopsmith#411),
|
Play CDN's runtime JIT that used to cover this was removed (feedBack#411),
|
||||||
so we rebuild the sheet ourselves with node + the pinned ``tailwindcss``,
|
so we rebuild the sheet ourselves with node + the pinned ``tailwindcss``,
|
||||||
scanning the baked-in plugins *and* the user plugins dir.
|
scanning the baked-in plugins *and* the user plugins dir.
|
||||||
|
|
||||||
@@ -24,7 +24,9 @@ import tempfile
|
|||||||
import threading
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
log = logging.getLogger("slopsmith.tailwind")
|
from env_compat import getenv_compat
|
||||||
|
|
||||||
|
log = logging.getLogger("feedBack.tailwind")
|
||||||
|
|
||||||
# Pin matches scripts/build-tailwind.sh and the Dockerfile build stage so every
|
# Pin matches scripts/build-tailwind.sh and the Dockerfile build stage so every
|
||||||
# sheet — committed, image-baked, and runtime-regenerated — comes from the same
|
# sheet — committed, image-baked, and runtime-regenerated — comes from the same
|
||||||
@@ -45,7 +47,7 @@ APP_DIR = Path(__file__).resolve().parent.parent
|
|||||||
|
|
||||||
|
|
||||||
def _user_plugins_dir() -> Path | None:
|
def _user_plugins_dir() -> Path | None:
|
||||||
raw = os.environ.get("SLOPSMITH_PLUGINS_DIR", "").strip()
|
raw = (getenv_compat("FEEDBACK_PLUGINS_DIR", "") or "").strip()
|
||||||
if not raw:
|
if not raw:
|
||||||
return None
|
return None
|
||||||
p = Path(raw)
|
p = Path(raw)
|
||||||
|
|||||||
+3
-3
@@ -1,13 +1,13 @@
|
|||||||
"""Tone helpers for sloppak playback.
|
"""Tone helpers for sloppak playback.
|
||||||
|
|
||||||
A slopsmith arrangement may carry a tone block — the initial tone name plus
|
A feedBack arrangement may carry a tone block — the initial tone name plus
|
||||||
in-song tone switches — embedded inline in the arrangement JSON (see
|
in-song tone switches — embedded inline in the arrangement JSON (see
|
||||||
``lib/song.py`` ``arrangement_to_wire`` / the ``tones`` wire key). This module
|
``lib/song.py`` ``arrangement_to_wire`` / the ``tones`` wire key). This module
|
||||||
turns that already-embedded block into the (base, changes) payload the highway
|
turns that already-embedded block into the (base, changes) payload the highway
|
||||||
WebSocket sends to the client.
|
WebSocket sends to the client.
|
||||||
|
|
||||||
The proprietary-archive tone-extraction path (lifting tone definitions out of
|
The proprietary-archive tone-extraction path (lifting tone definitions out of
|
||||||
an unpacked encrypted archive) has been removed. Slopsmith reads tones only
|
an unpacked encrypted archive) has been removed. FeedBack reads tones only
|
||||||
from its own ``.sloppak`` / arrangement JSON; it never reads or decrypts
|
from its own ``.sloppak`` / arrangement JSON; it never reads or decrypts
|
||||||
proprietary archive formats.
|
proprietary archive formats.
|
||||||
"""
|
"""
|
||||||
@@ -18,7 +18,7 @@ import logging
|
|||||||
import math
|
import math
|
||||||
import re
|
import re
|
||||||
|
|
||||||
log = logging.getLogger("slopsmith.lib.tones")
|
log = logging.getLogger("feedBack.lib.tones")
|
||||||
|
|
||||||
|
|
||||||
def tokens(s: str) -> set[str]:
|
def tokens(s: str) -> set[str]:
|
||||||
|
|||||||
+6
-6
@@ -5,7 +5,7 @@ isolated vocals + per-syllable lyric timing (both produced by the
|
|||||||
WhisperX fallback or shipped in the source archive), the /pitch endpoint
|
WhisperX fallback or shipped in the source archive), the /pitch endpoint
|
||||||
runs CREPE over the vocals stem and returns one MIDI note per supplied
|
runs CREPE over the vocals stem and returns one MIDI note per supplied
|
||||||
timing token. The result lands in `<sloppak>/vocal_pitch.json` in the
|
timing token. The result lands in `<sloppak>/vocal_pitch.json` in the
|
||||||
shape the got-feedback/feedback-plugin-lyrics-karaoke renderer
|
shape the got-feedback/feedBack-plugin-lyrics-karaoke renderer
|
||||||
already consumes:
|
already consumes:
|
||||||
|
|
||||||
{"version": 1, "notes": [{"t": float, "d": float, "midi": int}, ...]}
|
{"version": 1, "notes": [{"t": float, "d": float, "midi": int}, ...]}
|
||||||
@@ -23,18 +23,18 @@ runs locally. Adding a local CREPE path here would mean pulling
|
|||||||
`crepe` + `tensorflow` as plugin deps (~500 MB+ on top of the
|
`crepe` + `tensorflow` as plugin deps (~500 MB+ on top of the
|
||||||
existing torch/demucs/whisperx). Deferred until users hit the gap.
|
existing torch/demucs/whisperx). Deferred until users hit the gap.
|
||||||
If you need a local fallback today, install
|
If you need a local fallback today, install
|
||||||
`got-feedback/feedback-plugin-lyrics-karaoke` and let its local
|
`got-feedback/feedBack-plugin-lyrics-karaoke` and let its local
|
||||||
pYIN run when the server isn't reachable.
|
pYIN run when the server isn't reachable.
|
||||||
|
|
||||||
Cache key parity with stem_separation / lyric_transcription
|
Cache key parity with stem_separation / lyric_transcription
|
||||||
───────────────────────────────────────────────────────────
|
───────────────────────────────────────────────────────────
|
||||||
A `pitch_extraction` manifest block mirrors the shape introduced by
|
A `pitch_extraction` manifest block mirrors the shape introduced by
|
||||||
slopsmith#357: `{engine, model, version}`. Today engine is fixed at
|
feedBack#357: `{engine, model, version}`. Today engine is fixed at
|
||||||
`"crepe"` (the server's choice) and model at `"v1"` (server doesn't
|
`"crepe"` (the server's choice) and model at `"v1"` (server doesn't
|
||||||
yet expose the CREPE capacity dial it uses internally; this is the
|
yet expose the CREPE capacity dial it uses internally; this is the
|
||||||
requested value, same caveat as `lyric_transcription.model`). The
|
requested value, same caveat as `lyric_transcription.model`). The
|
||||||
schema version is independent of the upstream CREPE version and bumps
|
schema version is independent of the upstream CREPE version and bumps
|
||||||
per slopsmith's contract:
|
per feedBack's contract:
|
||||||
* patch — metadata-only or implementation fixes
|
* patch — metadata-only or implementation fixes
|
||||||
* minor — backward-compatible additions
|
* minor — backward-compatible additions
|
||||||
* major — output shape / semantics changed; existing
|
* major — output shape / semantics changed; existing
|
||||||
@@ -50,7 +50,7 @@ import math
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, Optional
|
from typing import Callable, Optional
|
||||||
|
|
||||||
log = logging.getLogger("slopsmith.lib.vocal_pitch")
|
log = logging.getLogger("feedBack.lib.vocal_pitch")
|
||||||
|
|
||||||
ProgressCB = Optional[Callable[[float, str, str], None]]
|
ProgressCB = Optional[Callable[[float, str, str], None]]
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@ def extract_pitch_remote(
|
|||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""POST the vocal stem + lyric timings to `{server_url}/pitch`.
|
"""POST the vocal stem + lyric timings to `{server_url}/pitch`.
|
||||||
|
|
||||||
`lyrics` is the same `[{t, d, w}, ...]` list slopsmith writes to
|
`lyrics` is the same `[{t, d, w}, ...]` list feedBack writes to
|
||||||
`lyrics.json`. The endpoint only consumes `t` + `d` (it doesn't
|
`lyrics.json`. The endpoint only consumes `t` + `d` (it doesn't
|
||||||
need the word text), but we pass the full payload through —
|
need the word text), but we pass the full payload through —
|
||||||
slimmer to forward what we already have than to project.
|
slimmer to forward what we already have than to project.
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ import logging
|
|||||||
import struct
|
import struct
|
||||||
import os
|
import os
|
||||||
|
|
||||||
log = logging.getLogger("slopsmith.lib.wem_decode")
|
log = logging.getLogger("feedBack.lib.wem_decode")
|
||||||
|
|
||||||
|
|
||||||
def convert_wem_to_ogg(wem_path: str, output_path: str) -> bool:
|
def convert_wem_to_ogg(wem_path: str, output_path: str) -> bool:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Programmatic entry point for the Slopsmith server.
|
"""Programmatic entry point for the FeedBack server.
|
||||||
|
|
||||||
Using ``uvicorn.run()`` with ``log_config=None`` prevents uvicorn from calling
|
Using ``uvicorn.run()`` with ``log_config=None`` prevents uvicorn from calling
|
||||||
``logging.config.dictConfig(LOGGING_CONFIG)`` during its startup sequence.
|
``logging.config.dictConfig(LOGGING_CONFIG)`` during its startup sequence.
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "slopsmith-browser-tests",
|
"name": "feedBack-browser-tests",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "slopsmith-browser-tests",
|
"name": "feedBack-browser-tests",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "slopsmith-browser-tests",
|
"name": "feedBack-browser-tests",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Browser tests for Slopsmith keyboard shortcuts and JS plugin-API contract tests under tests/js/.",
|
"description": "Browser tests for FeedBack keyboard shortcuts and JS plugin-API contract tests under tests/js/.",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "playwright test",
|
"test": "playwright test",
|
||||||
|
|||||||
+50
-20
@@ -15,7 +15,7 @@ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
|
|||||||
|
|
||||||
from safepath import safe_join
|
from safepath import safe_join
|
||||||
|
|
||||||
log = logging.getLogger("slopsmith.plugins")
|
log = logging.getLogger("feedBack.plugins")
|
||||||
|
|
||||||
|
|
||||||
PLUGINS_DIR = Path(__file__).parent
|
PLUGINS_DIR = Path(__file__).parent
|
||||||
@@ -43,7 +43,7 @@ PLUGINS_LOCK = threading.RLock()
|
|||||||
# registry mutation (the pending seed, _graduate, _mark_failed) re-checks it
|
# registry mutation (the pending seed, _graduate, _mark_failed) re-checks it
|
||||||
# under the lock before touching LOADED_PLUGINS / PENDING_PLUGINS. This keeps a
|
# under the lock before touching LOADED_PLUGINS / PENDING_PLUGINS. This keeps a
|
||||||
# still-running loader from an EARLIER pass — e.g. a "reload plugins" action,
|
# still-running loader from an EARLIER pass — e.g. a "reload plugins" action,
|
||||||
# SLOPSMITH_SYNC_STARTUP hot-reload, or test teardown re-invoking load_plugins()
|
# FEEDBACK_SYNC_STARTUP hot-reload, or test teardown re-invoking load_plugins()
|
||||||
# while the first pass's background install thread is mid-flight — from
|
# while the first pass's background install thread is mid-flight — from
|
||||||
# repopulating or duplicating entries after a NEWER pass has already cleared the
|
# repopulating or duplicating entries after a NEWER pass has already cleared the
|
||||||
# registries. Only the latest pass is allowed to publish.
|
# registries. Only the latest pass is allowed to publish.
|
||||||
@@ -512,7 +512,7 @@ def _capability_warnings(manifest: dict, plugin_id: str) -> tuple[dict, list[dic
|
|||||||
if isinstance(declaration.get("provider_policy"), dict):
|
if isinstance(declaration.get("provider_policy"), dict):
|
||||||
clean["provider_policy"] = declaration["provider_policy"]
|
clean["provider_policy"] = declaration["provider_policy"]
|
||||||
# Declarative per-instance control descriptors a consuming host renders
|
# Declarative per-instance control descriptors a consuming host renders
|
||||||
# generically (slopsmith#849). Domain-agnostic: validated for any
|
# generically (feedBack#849). Domain-agnostic: validated for any
|
||||||
# capability here and surfaced via /api/plugins; each domain defines how
|
# capability here and surfaced via /api/plugins; each domain defines how
|
||||||
# a value is applied (visualization is the first consumer).
|
# a value is applied (visualization is the first consumer).
|
||||||
if clean_settings:
|
if clean_settings:
|
||||||
@@ -567,7 +567,7 @@ def _load_plugin_sibling(plugin_id: str, plugin_dir: Path, name: str):
|
|||||||
import precedence). Mirrors the routes-loading pattern in
|
import precedence). Mirrors the routes-loading pattern in
|
||||||
`load_plugins()` and shares its `sys.modules` cache, so two plugins
|
`load_plugins()` and shares its `sys.modules` cache, so two plugins
|
||||||
that each ship `extractor.py` get distinct cached modules instead
|
that each ship `extractor.py` get distinct cached modules instead
|
||||||
of stomping each other through `sys.path`. See slopsmith#33."""
|
of stomping each other through `sys.path`. See feedBack#33."""
|
||||||
if not isinstance(plugin_id, str) or not plugin_id:
|
if not isinstance(plugin_id, str) or not plugin_id:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"load_sibling: plugin_id must be a non-empty string, got {plugin_id!r}"
|
f"load_sibling: plugin_id must be a non-empty string, got {plugin_id!r}"
|
||||||
@@ -613,7 +613,7 @@ def _load_plugin_sibling(plugin_id: str, plugin_dir: Path, name: str):
|
|||||||
# sys.modules entry — same key load_sibling produces
|
# sys.modules entry — same key load_sibling produces
|
||||||
# `setdefault` is atomic under the GIL so two threads racing to
|
# `setdefault` is atomic under the GIL so two threads racing to
|
||||||
# create the parent can't overwrite each other's registration.
|
# create the parent can't overwrite each other's registration.
|
||||||
# Spotted by codex/Copilot reviews on PRs for slopsmith#33.
|
# Spotted by codex/Copilot reviews on PRs for feedBack#33.
|
||||||
import types
|
import types
|
||||||
new_parent = types.ModuleType(parent_name)
|
new_parent = types.ModuleType(parent_name)
|
||||||
new_parent.__path__ = [str(plugin_dir)]
|
new_parent.__path__ = [str(plugin_dir)]
|
||||||
@@ -641,14 +641,14 @@ def _warn_on_module_collisions(plugin_specs):
|
|||||||
"""Scan top-level importable modules across all plugins about to
|
"""Scan top-level importable modules across all plugins about to
|
||||||
be loaded. Print a warning for any module name shipped by 2+
|
be loaded. Print a warning for any module name shipped by 2+
|
||||||
plugins, since bare `import <name>` from those plugins will hit
|
plugins, since bare `import <name>` from those plugins will hit
|
||||||
the sys.path-based cache and cross-load (slopsmith#33).
|
the sys.path-based cache and cross-load (feedBack#33).
|
||||||
|
|
||||||
Both top-level `.py` files AND top-level packages (directories
|
Both top-level `.py` files AND top-level packages (directories
|
||||||
containing `__init__.py`) are scanned — the same collision
|
containing `__init__.py`) are scanned — the same collision
|
||||||
pattern applies to either, e.g. one plugin's `extractor.py` vs
|
pattern applies to either, e.g. one plugin's `extractor.py` vs
|
||||||
another plugin's `extractor/__init__.py` both produce a shared
|
another plugin's `extractor/__init__.py` both produce a shared
|
||||||
`sys.modules['extractor']` entry. Spotted by codex review on
|
`sys.modules['extractor']` entry. Spotted by codex review on
|
||||||
PR for slopsmith#33.
|
PR for feedBack#33.
|
||||||
|
|
||||||
`routes.py` itself is excluded because the loader already
|
`routes.py` itself is excluded because the loader already
|
||||||
namespaces it as `plugin_{id}_routes`. Top-level dunder files
|
namespaces it as `plugin_{id}_routes`. Top-level dunder files
|
||||||
@@ -663,7 +663,7 @@ def _warn_on_module_collisions(plugin_specs):
|
|||||||
# — that intra-plugin layout is supported by load_sibling
|
# — that intra-plugin layout is supported by load_sibling
|
||||||
# (package form wins, matching CPython precedence) and shouldn't
|
# (package form wins, matching CPython precedence) and shouldn't
|
||||||
# trip a cross-plugin collision warning. Spotted by codex review
|
# trip a cross-plugin collision warning. Spotted by codex review
|
||||||
# on PR for slopsmith#33.
|
# on PR for feedBack#33.
|
||||||
by_name: dict[str, dict[str, set[str]]] = {}
|
by_name: dict[str, dict[str, set[str]]] = {}
|
||||||
for plugin_id, plugin_dir in plugin_specs:
|
for plugin_id, plugin_dir in plugin_specs:
|
||||||
try:
|
try:
|
||||||
@@ -700,7 +700,7 @@ def _warn_on_module_collisions(plugin_specs):
|
|||||||
log.warning(
|
log.warning(
|
||||||
"Module-name collision: %r (%s) is shipped by %d plugins (%s). "
|
"Module-name collision: %r (%s) is shipped by %d plugins (%s). "
|
||||||
"Bare `import %s` may load the wrong file. "
|
"Bare `import %s` may load the wrong file. "
|
||||||
"Migrate to context['load_sibling']('%s') — see CLAUDE.md (slopsmith#33).",
|
"Migrate to context['load_sibling']('%s') — see CLAUDE.md (feedBack#33).",
|
||||||
name, kind_label, len(by_plugin), ids_quoted, name, name,
|
name, kind_label, len(by_plugin), ids_quoted, name, name,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -745,7 +745,7 @@ def _is_valid_tour_manifest(val) -> bool:
|
|||||||
def _normalize_export_paths(settings_field, plugin_id: str) -> list[str]:
|
def _normalize_export_paths(settings_field, plugin_id: str) -> list[str]:
|
||||||
"""Validate and normalize a plugin's `settings.server_files` manifest
|
"""Validate and normalize a plugin's `settings.server_files` manifest
|
||||||
list into clean POSIX-style relpaths suitable for the settings
|
list into clean POSIX-style relpaths suitable for the settings
|
||||||
export/import bundle (slopsmith#113).
|
export/import bundle (feedBack#113).
|
||||||
|
|
||||||
Each entry must be a non-empty string with no absolute prefix and
|
Each entry must be a non-empty string with no absolute prefix and
|
||||||
no `..` segment. A trailing `/` denotes a directory (recurse on
|
no `..` segment. A trailing `/` denotes a directory (recurse on
|
||||||
@@ -1118,7 +1118,7 @@ def load_plugins(app: FastAPI, context: dict, progress_cb=None, route_setup_fn=N
|
|||||||
|
|
||||||
# Collect plugin directories — user plugins first so they override built-in
|
# Collect plugin directories — user plugins first so they override built-in
|
||||||
plugin_dirs = []
|
plugin_dirs = []
|
||||||
user_plugins_dir = os.environ.get("SLOPSMITH_PLUGINS_DIR")
|
user_plugins_dir = os.environ.get("FEEDBACK_PLUGINS_DIR") or os.environ.get("SLOPSMITH_PLUGINS_DIR")
|
||||||
if user_plugins_dir:
|
if user_plugins_dir:
|
||||||
user_path = Path(user_plugins_dir)
|
user_path = Path(user_plugins_dir)
|
||||||
if user_path.is_dir() and user_path != PLUGINS_DIR:
|
if user_path.is_dir() and user_path != PLUGINS_DIR:
|
||||||
@@ -1179,7 +1179,7 @@ def load_plugins(app: FastAPI, context: dict, progress_cb=None, route_setup_fn=N
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Two-pass discovery so we can warn about cross-plugin module-name
|
# Two-pass discovery so we can warn about cross-plugin module-name
|
||||||
# collisions BEFORE any plugin's setup runs (slopsmith#33). The
|
# collisions BEFORE any plugin's setup runs (feedBack#33). The
|
||||||
# first pass collects (plugin_id, plugin_dir, manifest) tuples in
|
# first pass collects (plugin_id, plugin_dir, manifest) tuples in
|
||||||
# load order; the second pass actually executes each plugin's
|
# load order; the second pass actually executes each plugin's
|
||||||
# setup with a per-plugin context.
|
# setup with a per-plugin context.
|
||||||
@@ -1231,7 +1231,7 @@ def load_plugins(app: FastAPI, context: dict, progress_cb=None, route_setup_fn=N
|
|||||||
kept_is_bundled = _is_bundled(kept[1], kept[2]) if kept else False
|
kept_is_bundled = _is_bundled(kept[1], kept[2]) if kept else False
|
||||||
if this_is_bundled and not kept_is_bundled:
|
if this_is_bundled and not kept_is_bundled:
|
||||||
# The incoming copy is the canonical bundled plugin; the
|
# The incoming copy is the canonical bundled plugin; the
|
||||||
# already-kept copy is user-installed (SLOPSMITH_PLUGINS_DIR
|
# already-kept copy is user-installed (FEEDBACK_PLUGINS_DIR
|
||||||
# or cloned directly into plugins/). Bundled always wins —
|
# or cloned directly into plugins/). Bundled always wins —
|
||||||
# evict the user copy and fall through to register the
|
# evict the user copy and fall through to register the
|
||||||
# bundled version instead.
|
# bundled version instead.
|
||||||
@@ -1330,6 +1330,18 @@ def load_plugins(app: FastAPI, context: dict, progress_cb=None, route_setup_fn=N
|
|||||||
_category = manifest.get("category")
|
_category = manifest.get("category")
|
||||||
if not isinstance(_category, str) or not _category:
|
if not isinstance(_category, str) or not _category:
|
||||||
_category = None
|
_category = None
|
||||||
|
# Settings-tab placement (tabbed settings page). When `settings` is a
|
||||||
|
# dict, an optional `category` field names which settings tab the
|
||||||
|
# plugin's panel mounts under (e.g. "graphics", "mic", "progression").
|
||||||
|
# Distinct from the top-level `category` above (which drives Pedalboard
|
||||||
|
# grouping) so the two don't collide. Absent/blank → None → the
|
||||||
|
# frontend falls back to the generic "Plugins" tab.
|
||||||
|
_settings_manifest = manifest.get("settings")
|
||||||
|
_settings_category = None
|
||||||
|
if isinstance(_settings_manifest, dict):
|
||||||
|
_sc = _settings_manifest.get("category")
|
||||||
|
if isinstance(_sc, str) and _sc:
|
||||||
|
_settings_category = _sc
|
||||||
_icon = manifest.get("icon")
|
_icon = manifest.get("icon")
|
||||||
if not isinstance(_icon, str) or not _icon:
|
if not isinstance(_icon, str) or not _icon:
|
||||||
_icon = None
|
_icon = None
|
||||||
@@ -1338,6 +1350,13 @@ def load_plugins(app: FastAPI, context: dict, progress_cb=None, route_setup_fn=N
|
|||||||
_icon = "assets/thumb.png"
|
_icon = "assets/thumb.png"
|
||||||
except OSError:
|
except OSError:
|
||||||
_icon = None
|
_icon = None
|
||||||
|
# Immersive (full-screen) screen opt-in. A plugin that declares a
|
||||||
|
# top-level `"fullscreen": true` gets the whole content area when its
|
||||||
|
# screen is active: the v3 shell hides the topbar and collapses the
|
||||||
|
# sidebar to an icon rail (see static/v3/shell.js + v3.css). For
|
||||||
|
# DAW-style plugin UIs that need the viewport, not a scrolling content
|
||||||
|
# page. Strict `is True` so a stray truthy value can't silently opt in.
|
||||||
|
_fullscreen = manifest.get("fullscreen") is True
|
||||||
return {
|
return {
|
||||||
"id": plugin_id,
|
"id": plugin_id,
|
||||||
"name": manifest.get("name", plugin_id),
|
"name": manifest.get("name", plugin_id),
|
||||||
@@ -1355,6 +1374,10 @@ def load_plugins(app: FastAPI, context: dict, progress_cb=None, route_setup_fn=N
|
|||||||
"has_screen": bool(manifest.get("screen")),
|
"has_screen": bool(manifest.get("screen")),
|
||||||
"has_script": bool(manifest.get("script")),
|
"has_script": bool(manifest.get("script")),
|
||||||
"has_settings": bool(manifest.get("settings")),
|
"has_settings": bool(manifest.get("settings")),
|
||||||
|
"settings_category": _settings_category,
|
||||||
|
# Drives the v3 shell's immersive (full-screen) mode for this
|
||||||
|
# plugin's screen. False unless the manifest declares it explicitly.
|
||||||
|
"fullscreen": _fullscreen,
|
||||||
"has_tour": _is_valid_tour_manifest(manifest.get("tour")),
|
"has_tour": _is_valid_tour_manifest(manifest.get("tour")),
|
||||||
# `styles` is an optional relpath (under the plugin's assets/) to a
|
# `styles` is an optional relpath (under the plugin's assets/) to a
|
||||||
# compiled, preflight-off stylesheet the frontend injects as a
|
# compiled, preflight-off stylesheet the frontend injects as a
|
||||||
@@ -1537,7 +1560,7 @@ def load_plugins(app: FastAPI, context: dict, progress_cb=None, route_setup_fn=N
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Add plugin directory to sys.path so the plugin's bare
|
# Add plugin directory to sys.path so the plugin's bare
|
||||||
# `import sibling` keeps working during the slopsmith#33
|
# `import sibling` keeps working during the feedBack#33
|
||||||
# transition. New plugins should prefer
|
# transition. New plugins should prefer
|
||||||
# `context['load_sibling']('sibling')` instead — see
|
# `context['load_sibling']('sibling')` instead — see
|
||||||
# CLAUDE.md / Plugin System / Backend routes.
|
# CLAUDE.md / Plugin System / Backend routes.
|
||||||
@@ -1556,13 +1579,13 @@ def load_plugins(app: FastAPI, context: dict, progress_cb=None, route_setup_fn=N
|
|||||||
# bijectively encoded by _safe_plugin_id_for_module_name:
|
# bijectively encoded by _safe_plugin_id_for_module_name:
|
||||||
# `_` -> `_5f_`, `.` -> `_2e_`) so two plugins shipping the
|
# `_` -> `_5f_`, `.` -> `_2e_`) so two plugins shipping the
|
||||||
# same filename get distinct cached modules. See
|
# same filename get distinct cached modules. See
|
||||||
# slopsmith#33.
|
# feedBack#33.
|
||||||
plugin_context = dict(context)
|
plugin_context = dict(context)
|
||||||
plugin_context["load_sibling"] = (
|
plugin_context["load_sibling"] = (
|
||||||
lambda name, _pid=plugin_id, _pdir=plugin_dir:
|
lambda name, _pid=plugin_id, _pdir=plugin_dir:
|
||||||
_load_plugin_sibling(_pid, _pdir, name)
|
_load_plugin_sibling(_pid, _pdir, name)
|
||||||
)
|
)
|
||||||
plugin_context["log"] = logging.getLogger(f"slopsmith.plugin.{plugin_id}")
|
plugin_context["log"] = logging.getLogger(f"feedBack.plugin.{plugin_id}")
|
||||||
if callable(plugin_context.get("register_library_provider")):
|
if callable(plugin_context.get("register_library_provider")):
|
||||||
_register_library_provider = plugin_context["register_library_provider"]
|
_register_library_provider = plugin_context["register_library_provider"]
|
||||||
|
|
||||||
@@ -1709,9 +1732,9 @@ def load_plugins(app: FastAPI, context: dict, progress_cb=None, route_setup_fn=N
|
|||||||
# Normalized list of relpaths under CONFIG_DIR that this
|
# Normalized list of relpaths under CONFIG_DIR that this
|
||||||
# plugin opts in to settings export/import. Empty for
|
# plugin opts in to settings export/import. Empty for
|
||||||
# plugins that don't declare `settings.server_files`. See
|
# plugins that don't declare `settings.server_files`. See
|
||||||
# slopsmith#113.
|
# feedBack#113.
|
||||||
"_export_paths": _normalize_export_paths(manifest.get("settings"), plugin_id),
|
"_export_paths": _normalize_export_paths(manifest.get("settings"), plugin_id),
|
||||||
# Diagnostics opt-in (slopsmith#166): same allowlist semantics
|
# Diagnostics opt-in (feedBack#166): same allowlist semantics
|
||||||
# as `_export_paths` but for the troubleshooting bundle.
|
# as `_export_paths` but for the troubleshooting bundle.
|
||||||
"_diagnostics_paths": _normalize_diagnostics_paths(manifest.get("diagnostics"), plugin_id),
|
"_diagnostics_paths": _normalize_diagnostics_paths(manifest.get("diagnostics"), plugin_id),
|
||||||
"_diagnostics_callable_spec": _parse_diagnostics_callable(manifest.get("diagnostics"), plugin_id),
|
"_diagnostics_callable_spec": _parse_diagnostics_callable(manifest.get("diagnostics"), plugin_id),
|
||||||
@@ -1800,7 +1823,7 @@ def load_plugins(app: FastAPI, context: dict, progress_cb=None, route_setup_fn=N
|
|||||||
lambda name, _pid=evicted_id, _pdir=ev_dir:
|
lambda name, _pid=evicted_id, _pdir=ev_dir:
|
||||||
_load_plugin_sibling(_pid, _pdir, name)
|
_load_plugin_sibling(_pid, _pdir, name)
|
||||||
)
|
)
|
||||||
ev_context["log"] = logging.getLogger(f"slopsmith.plugin.{evicted_id}")
|
ev_context["log"] = logging.getLogger(f"feedBack.plugin.{evicted_id}")
|
||||||
if callable(ev_context.get("register_library_provider")):
|
if callable(ev_context.get("register_library_provider")):
|
||||||
_ev_register_library_provider = ev_context["register_library_provider"]
|
_ev_register_library_provider = ev_context["register_library_provider"]
|
||||||
|
|
||||||
@@ -2053,7 +2076,7 @@ def register_plugin_api(app: FastAPI):
|
|||||||
"category": p.get("category") if "category" in p else ((p.get("_manifest") or {}).get("category") or None),
|
"category": p.get("category") if "category" in p else ((p.get("_manifest") or {}).get("category") or None),
|
||||||
"icon": p.get("icon") if "icon" in p else ((p.get("_manifest") or {}).get("icon") or None),
|
"icon": p.get("icon") if "icon" in p else ((p.get("_manifest") or {}).get("icon") or None),
|
||||||
# `bundled` is reserved metadata flagging plugins that
|
# `bundled` is reserved metadata flagging plugins that
|
||||||
# ship with the default container image (slopsmith#160).
|
# ship with the default container image (feedBack#160).
|
||||||
# Surfaced in /api/plugins so the plugin-list UI can
|
# Surfaced in /api/plugins so the plugin-list UI can
|
||||||
# render a "Bundled" badge (lock icon) next to the
|
# render a "Bundled" badge (lock icon) next to the
|
||||||
# plugin name in the settings collapsible.
|
# plugin name in the settings collapsible.
|
||||||
@@ -2067,6 +2090,11 @@ def register_plugin_api(app: FastAPI):
|
|||||||
"has_screen": p["has_screen"],
|
"has_screen": p["has_screen"],
|
||||||
"has_script": p["has_script"],
|
"has_script": p["has_script"],
|
||||||
"has_settings": p["has_settings"],
|
"has_settings": p["has_settings"],
|
||||||
|
# v3 immersive screen opt-in (full-screen plugin UI).
|
||||||
|
"fullscreen": p.get("fullscreen", False),
|
||||||
|
# Settings-tab placement; None when the manifest's `settings`
|
||||||
|
# is absent, a bare string, or omits `category`.
|
||||||
|
"settings_category": p.get("settings_category"),
|
||||||
"has_tour": p.get("has_tour", False),
|
"has_tour": p.get("has_tour", False),
|
||||||
# `.get()` fallbacks keep stubbed test entries (built without
|
# `.get()` fallbacks keep stubbed test entries (built without
|
||||||
# _nav_entry) working — styles is None when unset.
|
# _nav_entry) working — styles is None when unset.
|
||||||
@@ -2115,6 +2143,8 @@ def register_plugin_api(app: FastAPI):
|
|||||||
"has_screen": e.get("has_screen", False),
|
"has_screen": e.get("has_screen", False),
|
||||||
"has_script": e.get("has_script", False),
|
"has_script": e.get("has_script", False),
|
||||||
"has_settings": e.get("has_settings", False),
|
"has_settings": e.get("has_settings", False),
|
||||||
|
"settings_category": e.get("settings_category"),
|
||||||
|
"fullscreen": e.get("fullscreen", False),
|
||||||
"has_tour": e.get("has_tour", False),
|
"has_tour": e.get("has_tour", False),
|
||||||
"has_styles": e.get("has_styles", False),
|
"has_styles": e.get("has_styles", False),
|
||||||
"styles": e.get("styles"),
|
"styles": e.get("styles"),
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"global": [
|
||||||
|
{
|
||||||
|
"id": "first_steps",
|
||||||
|
"title": "First Steps",
|
||||||
|
"description": "Reach Mastery Rank 1 — finish onboarding.",
|
||||||
|
"category": "global",
|
||||||
|
"sourceId": "achievements",
|
||||||
|
"criterion": { "type": "mastery_rank", "tiers": [1] },
|
||||||
|
"tiers": [1]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ascendant",
|
||||||
|
"title": "Ascendant",
|
||||||
|
"description": "Climb the Mastery Rank ladder.",
|
||||||
|
"category": "global",
|
||||||
|
"sourceId": "achievements",
|
||||||
|
"criterion": { "type": "mastery_rank", "tiers": [10, 25, 50] },
|
||||||
|
"tiers": [10, 25, 50],
|
||||||
|
"tier_titles": ["Ascendant I", "Ascendant II", "Ascendant III"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "steady_hands",
|
||||||
|
"title": "Steady Hands",
|
||||||
|
"description": "Make a real advance on many separate days.",
|
||||||
|
"category": "global",
|
||||||
|
"sourceId": "achievements",
|
||||||
|
"criterion": { "type": "growth_streak_days", "tiers": [7, 30, 100] },
|
||||||
|
"tiers": [7, 30, 100],
|
||||||
|
"tier_titles": ["Steady Hands (7)", "Steady Hands (30)", "Steady Hands (100)"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "renaissance",
|
||||||
|
"title": "Renaissance",
|
||||||
|
"description": "Reach Level 10 across several distinct instrument paths.",
|
||||||
|
"category": "global",
|
||||||
|
"sourceId": "achievements",
|
||||||
|
"criterion": { "type": "paths_at_level", "level": 10, "tiers": [2, 3, 5] },
|
||||||
|
"tiers": [2, 3, 5],
|
||||||
|
"tier_titles": ["Renaissance (2)", "Renaissance (3)", "Renaissance (5)"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"per_instrument": [
|
||||||
|
{
|
||||||
|
"id": "path_rank",
|
||||||
|
"title": "{Inst} Mastery",
|
||||||
|
"description": "Climb the {Inst} path: Apprentice, Journeyman, Master.",
|
||||||
|
"sourceId": "achievements",
|
||||||
|
"criterion": { "type": "path_level", "tiers": [10, 25, "max"] },
|
||||||
|
"tier_titles": ["{Inst} Apprentice", "{Inst} Journeyman", "{Inst} Master"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "personal_best",
|
||||||
|
"title": "Personal Best ({Inst})",
|
||||||
|
"description": "Beat your own best accuracy on any {Inst} chart.",
|
||||||
|
"sourceId": "achievements",
|
||||||
|
"criterion": { "type": "personal_best" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "challenger",
|
||||||
|
"title": "Challenger ({Inst})",
|
||||||
|
"description": "Clear a full level-up challenge set on the {Inst} path.",
|
||||||
|
"sourceId": "achievements",
|
||||||
|
"criterion": { "type": "challenge_set_cleared" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
/* Achievements & Feats of Power — plugin styles (plain CSS, no Tailwind build;
|
||||||
|
* P-II: plugins ship their own stylesheet for non-core classes). Mirrors the v3
|
||||||
|
* dark surface tokens used across the Profile page. */
|
||||||
|
|
||||||
|
/* Secondary category pill row inside the Achievements tab (lighter .fb-tab). */
|
||||||
|
.fb-ach-pillrow {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: .4rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.fb-ach-pill {
|
||||||
|
appearance: none;
|
||||||
|
background: rgba(30, 41, 59, .5);
|
||||||
|
border: 1px solid rgba(51, 65, 85, .6);
|
||||||
|
color: #94a3b8;
|
||||||
|
border-radius: 9999px;
|
||||||
|
padding: .35rem .75rem;
|
||||||
|
font-size: .8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color .15s, border-color .15s, background .15s;
|
||||||
|
}
|
||||||
|
.fb-ach-pill:hover { color: #e2e8f0; border-color: rgba(14, 165, 233, .4); }
|
||||||
|
.fb-ach-pill.active { color: #f8fafc; background: rgba(14, 165, 233, .15); border-color: #0ea5e9; }
|
||||||
|
.fb-ach-pill-badge {
|
||||||
|
font-size: .7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #64748b;
|
||||||
|
margin-left: .15rem;
|
||||||
|
}
|
||||||
|
.fb-ach-pill.active .fb-ach-pill-badge { color: #7dd3fc; }
|
||||||
|
|
||||||
|
/* Catalogue list. */
|
||||||
|
.fb-ach-list { display: flex; flex-direction: column; gap: .5rem; }
|
||||||
|
.fb-ach-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: .85rem;
|
||||||
|
padding: .75rem .9rem;
|
||||||
|
border-radius: .6rem;
|
||||||
|
border: 1px solid rgba(51, 65, 85, .5);
|
||||||
|
background: rgba(30, 41, 59, .35);
|
||||||
|
}
|
||||||
|
.fb-ach-item.locked { opacity: .45; filter: grayscale(.6); }
|
||||||
|
.fb-ach-item.earned { border-color: rgba(14, 165, 233, .35); background: rgba(14, 165, 233, .06); }
|
||||||
|
.fb-ach-item-icon { font-size: 1.4rem; line-height: 1.6rem; flex: 0 0 auto; }
|
||||||
|
.fb-ach-item-body { min-width: 0; }
|
||||||
|
.fb-ach-item-title {
|
||||||
|
font-size: .9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #f1f5f9;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .4rem;
|
||||||
|
}
|
||||||
|
.fb-ach-tier {
|
||||||
|
font-size: .65rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #7dd3fc;
|
||||||
|
border: 1px solid rgba(125, 211, 252, .35);
|
||||||
|
border-radius: 9999px;
|
||||||
|
padding: .05rem .4rem;
|
||||||
|
}
|
||||||
|
.fb-ach-item-desc { font-size: .78rem; color: #94a3b8; margin-top: .15rem; }
|
||||||
|
|
||||||
|
/* Feats of Power trophy shelf (profile main page). */
|
||||||
|
.fb-feat-shelf {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: .75rem;
|
||||||
|
}
|
||||||
|
.fb-feat-card {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem .75rem;
|
||||||
|
border-radius: .7rem;
|
||||||
|
border: 1px solid rgba(234, 179, 8, .35);
|
||||||
|
background: linear-gradient(180deg, rgba(234, 179, 8, .1), rgba(30, 41, 59, .3));
|
||||||
|
}
|
||||||
|
.fb-feat-icon { font-size: 1.9rem; }
|
||||||
|
.fb-feat-title { font-size: .85rem; font-weight: 800; color: #fde68a; margin-top: .3rem; }
|
||||||
|
.fb-feat-desc { font-size: .72rem; color: #cbd5e1; margin-top: .25rem; }
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
"""Achievements & Feats of Power — pure evaluation helpers.
|
||||||
|
|
||||||
|
This module holds the side-effect-free core of the engine so it is unit
|
||||||
|
testable (constitution P-V): no IO, no SQLite, no clock. `routes.py` owns the
|
||||||
|
storage/HTTP shell and calls into these functions.
|
||||||
|
|
||||||
|
**Integration law (structural):** Feats are evaluated from *activity counters*
|
||||||
|
only (`evaluate_feats` / `apply_activity`); competency Achievements are recorded
|
||||||
|
from *competency events* the source reports (`report-unlock`). Nothing here ever
|
||||||
|
converts an activity count into a competency unlock or vice versa.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# Counter keys the activity model owns. `*_max` keys take the running maximum;
|
||||||
|
# everything else is a cumulative running total. Kept here (not in routes) so a
|
||||||
|
# test can assert the contract without standing up a DB.
|
||||||
|
MAX_COUNTERS = frozenset({"notes_session_max", "streak_insong_max", "chart_encore_max"})
|
||||||
|
|
||||||
|
|
||||||
|
def tier_index_for(tiers, value):
|
||||||
|
"""Highest 0-based tier index whose threshold is met by ``value``.
|
||||||
|
|
||||||
|
Returns -1 when no tier is reached. Tiers are assumed ascending; we scan
|
||||||
|
all of them rather than short-circuit so an out-of-order catalogue still
|
||||||
|
resolves to the largest satisfied tier.
|
||||||
|
"""
|
||||||
|
idx = -1
|
||||||
|
for i, threshold in enumerate(tiers or []):
|
||||||
|
try:
|
||||||
|
if value >= threshold:
|
||||||
|
idx = i
|
||||||
|
except TypeError:
|
||||||
|
continue
|
||||||
|
return idx
|
||||||
|
|
||||||
|
|
||||||
|
def feat_counter_value(feat, counters):
|
||||||
|
"""Activity-counter value backing a Feat definition (0 when absent)."""
|
||||||
|
key = feat.get("counter")
|
||||||
|
if not key:
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
return int(counters.get(key, 0) or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_feats(feat_defs, counters):
|
||||||
|
"""Map ``feat_id -> highest reached tier index`` for all satisfied Feats.
|
||||||
|
|
||||||
|
A Feat with no tiers, or whose counter hasn't reached tier 0, is omitted.
|
||||||
|
Pure: takes the current counters snapshot, returns a plain dict.
|
||||||
|
"""
|
||||||
|
out = {}
|
||||||
|
for feat in feat_defs or []:
|
||||||
|
fid = feat.get("id")
|
||||||
|
if not fid:
|
||||||
|
continue
|
||||||
|
tiers = feat.get("tiers") or []
|
||||||
|
if not tiers:
|
||||||
|
continue
|
||||||
|
ti = tier_index_for(tiers, feat_counter_value(feat, counters))
|
||||||
|
if ti >= 0:
|
||||||
|
out[fid] = ti
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def apply_activity(counters, delta):
|
||||||
|
"""Return a NEW counters dict after folding in one activity ``delta``.
|
||||||
|
|
||||||
|
Cumulative keys add; ``*_max`` keys keep the running maximum. The caller
|
||||||
|
(routes.py) is responsible for the only stateful bit — the per-chart play
|
||||||
|
count — and passes the post-increment value as ``delta['chart_play_count']``
|
||||||
|
so this function stays pure.
|
||||||
|
|
||||||
|
Recognised delta fields (all optional, default 0):
|
||||||
|
notes -> notes_total (+=)
|
||||||
|
song_done -> songs_done (+=)
|
||||||
|
seconds -> time_total_seconds (+=)
|
||||||
|
session_notes -> notes_session_max (max)
|
||||||
|
in_song_streak -> streak_insong_max (max)
|
||||||
|
chart_play_count -> chart_encore_max (max)
|
||||||
|
"""
|
||||||
|
out = dict(counters or {})
|
||||||
|
|
||||||
|
def _cur(key):
|
||||||
|
try:
|
||||||
|
return int(out.get(key, 0) or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def _int(v):
|
||||||
|
try:
|
||||||
|
return int(v or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
out["notes_total"] = _cur("notes_total") + _int(delta.get("notes"))
|
||||||
|
out["songs_done"] = _cur("songs_done") + _int(delta.get("song_done"))
|
||||||
|
out["time_total_seconds"] = _cur("time_total_seconds") + _int(delta.get("seconds"))
|
||||||
|
out["notes_session_max"] = max(_cur("notes_session_max"), _int(delta.get("session_notes")))
|
||||||
|
out["streak_insong_max"] = max(_cur("streak_insong_max"), _int(delta.get("in_song_streak")))
|
||||||
|
if delta.get("chart_play_count") is not None:
|
||||||
|
out["chart_encore_max"] = max(_cur("chart_encore_max"), _int(delta.get("chart_play_count")))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def consecutive_run_length(dates):
|
||||||
|
"""Longest run of consecutive calendar dates in ``dates`` (ISO 'YYYY-MM-DD').
|
||||||
|
|
||||||
|
Used by the `secret_witching` Feat (practise in the 2–5am window on N
|
||||||
|
consecutive nights). Pure date arithmetic so it's unit-testable; routes.py
|
||||||
|
feeds it the distinct night-dates recorded in `comp_ledger`.
|
||||||
|
"""
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
parsed = []
|
||||||
|
for d in dates or []:
|
||||||
|
try:
|
||||||
|
y, m, dd = (int(x) for x in str(d).split("-"))
|
||||||
|
parsed.append(date(y, m, dd))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
if not parsed:
|
||||||
|
return 0
|
||||||
|
parsed = sorted(set(parsed))
|
||||||
|
best = run = 1
|
||||||
|
for prev, cur in zip(parsed, parsed[1:]):
|
||||||
|
if (cur - prev).days == 1:
|
||||||
|
run += 1
|
||||||
|
best = max(best, run)
|
||||||
|
else:
|
||||||
|
run = 1
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
# ── Data-minimization contract (binding, code-enforced) ──────────────────────
|
||||||
|
# The wall payload key-set is frozen here and asserted by a unit test. The
|
||||||
|
# serializer below is the ONLY way outbound data is built — never dict(row) or
|
||||||
|
# **model — so a stray field cannot leak. Adding a key makes the test go red.
|
||||||
|
WALL_PAYLOAD_KEYS = ("display_name", "player_hash", "achievement_id", "unlocked_at")
|
||||||
|
|
||||||
|
|
||||||
|
def build_wall_payload(display_name, player_hash, achievement_id, unlocked_at):
|
||||||
|
"""Build the EXACT four-field wall payload. ``achievement_id`` must always be
|
||||||
|
a Feat id (the caller only ever invokes this for Feat unlocks — competency
|
||||||
|
never syncs). Explicit literal dict on purpose; do not refactor into a
|
||||||
|
row/model splat."""
|
||||||
|
return {
|
||||||
|
"display_name": display_name,
|
||||||
|
"player_hash": player_hash,
|
||||||
|
"achievement_id": achievement_id,
|
||||||
|
"unlocked_at": unlocked_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def drain_decision(status):
|
||||||
|
"""Dead-letter state machine for one wall-sync attempt (pure).
|
||||||
|
|
||||||
|
``status`` is the HTTP status code, or ``None`` for a network error.
|
||||||
|
Returns one of:
|
||||||
|
'ack' → server accepted; delete the row.
|
||||||
|
'retry' → keep it pending (network error, 429 backoff, or 5xx).
|
||||||
|
'dead' → any other 4xx; move to dead_letter (diagnosable/replayable).
|
||||||
|
A row is NEVER silently dropped — it leaves the queue only on 'ack' (or a
|
||||||
|
user opt-out wiping it).
|
||||||
|
"""
|
||||||
|
if status is None:
|
||||||
|
return "retry"
|
||||||
|
if 200 <= status < 300:
|
||||||
|
return "ack"
|
||||||
|
if status == 429:
|
||||||
|
return "retry"
|
||||||
|
if 400 <= status < 500:
|
||||||
|
return "dead"
|
||||||
|
return "retry" # 5xx — transient server-side, try again later
|
||||||
|
|
||||||
|
|
||||||
|
def diff_unlocks(prev_tiers, new_tiers):
|
||||||
|
"""Feat ids whose tier advanced (incl. first unlock).
|
||||||
|
|
||||||
|
``prev_tiers`` / ``new_tiers`` are ``feat_id -> tier_index`` maps as
|
||||||
|
returned by :func:`evaluate_feats`. Returns the ids that are newly present
|
||||||
|
or moved to a higher tier — i.e. the Feats to record + announce this round.
|
||||||
|
"""
|
||||||
|
out = []
|
||||||
|
for fid, tier in (new_tiers or {}).items():
|
||||||
|
if tier > prev_tiers.get(fid, -1):
|
||||||
|
out.append(fid)
|
||||||
|
return out
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"feats": [
|
||||||
|
{
|
||||||
|
"id": "notes_total",
|
||||||
|
"title": "Note Hunter",
|
||||||
|
"description": "Hit a colossal number of notes across all your practice.",
|
||||||
|
"category": "global",
|
||||||
|
"sourceId": "achievements",
|
||||||
|
"secret": false,
|
||||||
|
"needs_notedetect": true,
|
||||||
|
"counter": "notes_total",
|
||||||
|
"tiers": [100000, 1000000, 10000000],
|
||||||
|
"tier_titles": ["Note Hunter", "Million-Note Maestro", "Ten-Million-Note Titan"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "notes_session",
|
||||||
|
"title": "Marathon",
|
||||||
|
"description": "Hit 25,000 notes in a single sitting.",
|
||||||
|
"category": "global",
|
||||||
|
"sourceId": "achievements",
|
||||||
|
"secret": false,
|
||||||
|
"needs_notedetect": true,
|
||||||
|
"counter": "notes_session_max",
|
||||||
|
"tiers": [25000],
|
||||||
|
"tier_titles": ["Marathon"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "streak_insong",
|
||||||
|
"title": "Untouchable",
|
||||||
|
"description": "Land a huge run of consecutive in-song hits with no miss.",
|
||||||
|
"category": "global",
|
||||||
|
"sourceId": "achievements",
|
||||||
|
"secret": false,
|
||||||
|
"needs_notedetect": true,
|
||||||
|
"counter": "streak_insong_max",
|
||||||
|
"tiers": [1000, 5000],
|
||||||
|
"tier_titles": ["Untouchable", "Truly Untouchable"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "songs_done",
|
||||||
|
"title": "Road Warrior",
|
||||||
|
"description": "Finish a mountain of songs.",
|
||||||
|
"category": "global",
|
||||||
|
"sourceId": "achievements",
|
||||||
|
"secret": false,
|
||||||
|
"needs_notedetect": false,
|
||||||
|
"counter": "songs_done",
|
||||||
|
"tiers": [1000, 5000],
|
||||||
|
"tier_titles": ["Road Warrior", "Road Legend"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "time_total",
|
||||||
|
"title": "Time Served",
|
||||||
|
"description": "Pour hundreds of hours into practice.",
|
||||||
|
"category": "global",
|
||||||
|
"sourceId": "achievements",
|
||||||
|
"secret": false,
|
||||||
|
"needs_notedetect": false,
|
||||||
|
"counter": "time_total_seconds",
|
||||||
|
"tiers": [1800000, 7200000],
|
||||||
|
"tier_titles": ["Time Served (500h)", "Time Served (2,000h)"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "chart_encore",
|
||||||
|
"title": "Encore",
|
||||||
|
"description": "Play the same chart again and again and again.",
|
||||||
|
"category": "global",
|
||||||
|
"sourceId": "achievements",
|
||||||
|
"secret": false,
|
||||||
|
"needs_notedetect": false,
|
||||||
|
"counter": "chart_encore_max",
|
||||||
|
"tiers": [100, 500],
|
||||||
|
"tier_titles": ["Encore", "Standing Ovation"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "secret_witching",
|
||||||
|
"title": "The Witching Hour",
|
||||||
|
"description": "Practise in the dead of night, seven nights running.",
|
||||||
|
"category": "global",
|
||||||
|
"sourceId": "achievements",
|
||||||
|
"secret": true,
|
||||||
|
"needs_notedetect": false,
|
||||||
|
"counter": "witching_nights_run",
|
||||||
|
"tiers": [7],
|
||||||
|
"tier_titles": ["The Witching Hour"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "secret_combo",
|
||||||
|
"title": "Hidden Track",
|
||||||
|
"description": "You found the hidden track.",
|
||||||
|
"category": "global",
|
||||||
|
"sourceId": "achievements",
|
||||||
|
"secret": true,
|
||||||
|
"needs_notedetect": false,
|
||||||
|
"counter": null,
|
||||||
|
"tiers": [],
|
||||||
|
"tier_titles": ["Hidden Track"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"id": "achievements",
|
||||||
|
"name": "Achievements",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"bundled": true,
|
||||||
|
"private": false,
|
||||||
|
"description": "Achievements & Feats of Power — skill milestones on your Profile, plus rare activity Feats.",
|
||||||
|
"script": "screen.js",
|
||||||
|
"styles": "assets/achievements.css",
|
||||||
|
"settings": {
|
||||||
|
"html": "settings.html",
|
||||||
|
"category": "system",
|
||||||
|
"server_files": [
|
||||||
|
"achievements/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"routes": "routes.py"
|
||||||
|
}
|
||||||
@@ -0,0 +1,585 @@
|
|||||||
|
"""Achievements & Feats of Power — local engine (offline).
|
||||||
|
|
||||||
|
State lives under ``<config_dir>/achievements/``:
|
||||||
|
- ``achievements.db`` SQLite — unlocks, activity counters, derived ledger,
|
||||||
|
and the (PR3) wall sync queue.
|
||||||
|
|
||||||
|
Two surfaces, one engine, kept structurally apart (the **integration law**):
|
||||||
|
* **Feats of Power** — activity/volume. The engine OWNS raw activity counters
|
||||||
|
(`counters`), evaluates Feat thresholds, and records Feat unlocks. Feats are
|
||||||
|
the only thing that ever syncs to the public wall.
|
||||||
|
* **Achievements** — demonstrated competency. The engine RECORDS unlocks the
|
||||||
|
source reports (`report-unlock`); it never re-derives them from activity.
|
||||||
|
A baseline catalogue ships here and is driven by the built-in progression
|
||||||
|
system; richer items are contributed by source plugins at runtime.
|
||||||
|
|
||||||
|
Endpoints (all under /api/plugins/achievements/):
|
||||||
|
POST /activity bump activity counters, eval Feats, return newly-unlocked
|
||||||
|
POST /report-unlock idempotent upsert of a competency/feat unlock
|
||||||
|
POST /report-criterion record a (criterion_id, token) pair → distinct count
|
||||||
|
GET /catalog baseline competency defs + earned state
|
||||||
|
GET /earned all earned items (id, cls, category, tier, at)
|
||||||
|
GET /feats earned Feats (for the profile trophy shelf)
|
||||||
|
POST /remove-me wipe synced state (full wall-removal lands in PR2/PR3)
|
||||||
|
|
||||||
|
Pure threshold/criterion math lives in the sibling ``engine.py`` (P-V testable);
|
||||||
|
this module is the SQLite + HTTP shell.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
_lock = threading.Lock()
|
||||||
|
_state = {
|
||||||
|
"db_path": None,
|
||||||
|
"dir": None, # plugin directory (for catalog JSON)
|
||||||
|
"config_dir": None, # CONFIG_DIR (for reading the opt-in setting)
|
||||||
|
"meta_db": None, # MetadataDB (for the profile identity: name + hash)
|
||||||
|
"log": logging.getLogger("feedBack.plugin.achievements"),
|
||||||
|
"engine": None, # sibling engine.py module (pure helpers)
|
||||||
|
"feat_defs": [], # parsed feats.json -> list of feat defs
|
||||||
|
"baseline": {}, # parsed achievements.json
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── SQLite ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _conn():
|
||||||
|
conn = sqlite3.connect(_state["db_path"], timeout=5)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def _init_db():
|
||||||
|
conn = _conn()
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS unlocks (
|
||||||
|
achievement_id TEXT PRIMARY KEY,
|
||||||
|
cls TEXT NOT NULL, -- 'competency' | 'feat'
|
||||||
|
disp_category TEXT, -- global/guitar/bass/...
|
||||||
|
source_id TEXT,
|
||||||
|
tier INTEGER NOT NULL DEFAULT 0,
|
||||||
|
unlocked_at TEXT,
|
||||||
|
synced INTEGER NOT NULL DEFAULT 0
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS counters (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value INTEGER NOT NULL DEFAULT 0
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS comp_ledger (
|
||||||
|
criterion_id TEXT NOT NULL,
|
||||||
|
token TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (criterion_id, token)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS sync_queue (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
kind TEXT NOT NULL, -- 'unlock' | 'remove'
|
||||||
|
payload TEXT NOT NULL,
|
||||||
|
state TEXT NOT NULL DEFAULT 'pending' -- 'pending' | 'dead_letter'
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _now_iso():
|
||||||
|
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||||
|
|
||||||
|
|
||||||
|
def _opted_in():
|
||||||
|
"""True only when the user has opted in (core setting ``achievements_enabled``).
|
||||||
|
|
||||||
|
Read straight from CONFIG_DIR/config.json — the single source of truth the
|
||||||
|
/api/settings endpoint persists. Default OFF on any read failure: nothing
|
||||||
|
leaves the device unless explicitly enabled.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
cfg_path = Path(_state["config_dir"]) / "config.json"
|
||||||
|
cfg = json.loads(cfg_path.read_text(encoding="utf-8"))
|
||||||
|
return bool(cfg.get("achievements_enabled") is True)
|
||||||
|
except (OSError, ValueError, TypeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _identity():
|
||||||
|
"""(display_name, player_hash) from the profile, or (None, None).
|
||||||
|
|
||||||
|
Reused as the wall identity (server.py's documented player_hash). Sync is
|
||||||
|
skipped entirely when either is missing.
|
||||||
|
"""
|
||||||
|
db = _state["meta_db"]
|
||||||
|
if db is None or not hasattr(db, "get_profile"):
|
||||||
|
return None, None
|
||||||
|
try:
|
||||||
|
prof = db.get_profile() or {}
|
||||||
|
return (prof.get("display_name") or None), (prof.get("player_hash") or None)
|
||||||
|
except Exception: # noqa: BLE001 — identity is best-effort; never break a request
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def _enqueue_feat_sync(conn, feat_id, unlocked_at):
|
||||||
|
"""Enqueue a wall-sync POST for a Feat unlock — opt-in gated, identity gated.
|
||||||
|
|
||||||
|
Builds the outbound payload through the SINGLE code-gated serializer
|
||||||
|
(engine.build_wall_payload, exactly four fields). Competency unlocks never
|
||||||
|
reach this path (integration law + data-minimization contract). The drain
|
||||||
|
worker (PR3) POSTs the queued rows; here we only persist intent.
|
||||||
|
"""
|
||||||
|
if not _opted_in():
|
||||||
|
return False
|
||||||
|
display_name, player_hash = _identity()
|
||||||
|
if not display_name or not player_hash:
|
||||||
|
return False
|
||||||
|
payload = _state["engine"].build_wall_payload(display_name, player_hash, feat_id, unlocked_at)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO sync_queue(kind, payload, state) VALUES ('unlock', ?, 'pending')",
|
||||||
|
(json.dumps(payload),),
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ── Wall sync — background drain worker (dead-letter, never drop) ─────────────
|
||||||
|
# Idle unless a wall URL is configured. POSTs pending rows to the hosted
|
||||||
|
# feedback-achievements service; the decision state machine
|
||||||
|
# (engine.drain_decision) is pure + tested. A row leaves the queue only on a
|
||||||
|
# server ack (or a user opt-out wiping it) — never silently dropped.
|
||||||
|
|
||||||
|
# Canonical hosted Feats wall (the got-feedback service). Used by default so the
|
||||||
|
# drain worker targets it out of the box; override via env for self-hosting or a
|
||||||
|
# staging wall. Nothing is ever sent unless the user opted in AND has an identity
|
||||||
|
# (see _enqueue_feat_sync), so a default URL does not publish anything on its own.
|
||||||
|
_DEFAULT_WALL_URL = "https://feedback-achievements.onrender.com"
|
||||||
|
_WALL_URL = (os.environ.get("FEEDBACK_ACHIEVEMENTS_WALL_URL")
|
||||||
|
or os.environ.get("SLOPSMITH_ACHIEVEMENTS_WALL_URL")
|
||||||
|
or _DEFAULT_WALL_URL).rstrip("/")
|
||||||
|
_WALL_TOKEN = os.environ.get("FEEDBACK_ACHIEVEMENTS_CLIENT_TOKEN", "fb-wall-v1")
|
||||||
|
_DRAIN_INTERVAL_S = int(os.environ.get("FEEDBACK_ACHIEVEMENTS_DRAIN_INTERVAL", "30"))
|
||||||
|
_drain_started = False
|
||||||
|
|
||||||
|
|
||||||
|
def _post_to_wall(kind, payload):
|
||||||
|
"""POST one queued item; return the HTTP status code, or None on a network
|
||||||
|
error. Mirrors the lib/lyrics_transcribe outbound pattern (explicit timeout,
|
||||||
|
no raise on non-2xx — the caller's state machine decides)."""
|
||||||
|
import requests # local import: only needed when a wall is configured
|
||||||
|
|
||||||
|
path = "/api/unlock" if kind == "unlock" else "/api/remove"
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
_WALL_URL + path, json=payload,
|
||||||
|
headers={"X-Client-Token": _WALL_TOKEN, "Content-Type": "application/json"},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
return resp.status_code
|
||||||
|
except requests.RequestException:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _drain_once(post_fn=None):
|
||||||
|
"""Process all pending queue rows once. ``post_fn(kind, payload) -> status``
|
||||||
|
is injectable for tests; defaults to the real wall POST."""
|
||||||
|
post_fn = post_fn or _post_to_wall
|
||||||
|
engine = _state["engine"]
|
||||||
|
with _lock:
|
||||||
|
conn = _conn()
|
||||||
|
try:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id, kind, payload FROM sync_queue WHERE state='pending'").fetchall()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
for row in rows:
|
||||||
|
try:
|
||||||
|
payload = json.loads(row["payload"]) if row["payload"] else {}
|
||||||
|
except ValueError:
|
||||||
|
payload = {}
|
||||||
|
status = post_fn(row["kind"], payload)
|
||||||
|
action = engine.drain_decision(status)
|
||||||
|
with _lock:
|
||||||
|
conn = _conn()
|
||||||
|
try:
|
||||||
|
if action == "ack":
|
||||||
|
conn.execute("DELETE FROM sync_queue WHERE id=?", (row["id"],))
|
||||||
|
elif action == "dead":
|
||||||
|
conn.execute("UPDATE sync_queue SET state='dead_letter' WHERE id=?", (row["id"],))
|
||||||
|
# 'retry' → leave it pending for the next pass
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _drain_loop():
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
_drain_once()
|
||||||
|
except Exception as e: # noqa: BLE001 — a worker crash must not kill the thread
|
||||||
|
_state["log"].warning("achievements wall drain error: %s", e)
|
||||||
|
time.sleep(_DRAIN_INTERVAL_S)
|
||||||
|
|
||||||
|
|
||||||
|
def _maybe_start_drain():
|
||||||
|
global _drain_started
|
||||||
|
if _drain_started or not _WALL_URL:
|
||||||
|
return
|
||||||
|
_drain_started = True
|
||||||
|
threading.Thread(target=_drain_loop, name="ach-wall-drain", daemon=True).start()
|
||||||
|
_state["log"].info("achievements wall drain worker started → %s", _WALL_URL)
|
||||||
|
|
||||||
|
|
||||||
|
def _chart_key(chart):
|
||||||
|
"""Stable per-chart counter key — a sha1 digest of the chart id. NOT the
|
||||||
|
builtin hash(), whose str hashing is salted per process (PYTHONHASHSEED), so
|
||||||
|
the same chart would land on a different counter after every restart and the
|
||||||
|
Encore Feat could never accumulate across sessions."""
|
||||||
|
return "chart_plays:" + hashlib.sha1(str(chart).encode("utf-8")).hexdigest()[:16]
|
||||||
|
|
||||||
|
|
||||||
|
def _read_counters(conn):
|
||||||
|
# Excludes the per-chart `chart_plays:*` rows: they are bumped + read
|
||||||
|
# individually via _bump_counter and would otherwise make this aggregate
|
||||||
|
# round-trip O(distinct charts played) on every activity POST.
|
||||||
|
return {
|
||||||
|
row["key"]: int(row["value"])
|
||||||
|
for row in conn.execute("SELECT key, value FROM counters WHERE key NOT LIKE 'chart_plays:%'")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _write_counters(conn, counters):
|
||||||
|
for key, value in counters.items():
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO counters(key, value) VALUES (?, ?) "
|
||||||
|
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
||||||
|
(key, int(value)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _bump_counter(conn, key, delta):
|
||||||
|
"""Increment a counter and return the new value (used for per-chart plays)."""
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO counters(key, value) VALUES (?, ?) "
|
||||||
|
"ON CONFLICT(key) DO UPDATE SET value=value+excluded.value",
|
||||||
|
(key, int(delta)),
|
||||||
|
)
|
||||||
|
row = conn.execute("SELECT value FROM counters WHERE key=?", (key,)).fetchone()
|
||||||
|
return int(row["value"]) if row else int(delta)
|
||||||
|
|
||||||
|
|
||||||
|
def _earned_feat_tiers(conn):
|
||||||
|
return {
|
||||||
|
row["achievement_id"]: int(row["tier"])
|
||||||
|
for row in conn.execute("SELECT achievement_id, tier FROM unlocks WHERE cls='feat'")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _record_unlock(conn, ach_id, cls, disp_category, source_id, tier, at):
|
||||||
|
"""Idempotent upsert; only advances the tier upward. Returns True if changed."""
|
||||||
|
row = conn.execute("SELECT tier FROM unlocks WHERE achievement_id=?", (ach_id,)).fetchone()
|
||||||
|
if row is not None and int(row["tier"]) >= int(tier):
|
||||||
|
return False
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO unlocks(achievement_id, cls, disp_category, source_id, tier, unlocked_at, synced)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, 0)
|
||||||
|
ON CONFLICT(achievement_id) DO UPDATE SET
|
||||||
|
tier=excluded.tier,
|
||||||
|
cls=excluded.cls,
|
||||||
|
disp_category=COALESCE(excluded.disp_category, unlocks.disp_category),
|
||||||
|
source_id=COALESCE(excluded.source_id, unlocks.source_id)
|
||||||
|
""",
|
||||||
|
(ach_id, cls, disp_category, source_id, int(tier), at or _now_iso()),
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ── Catalog loading ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _feat_by_id(fid):
|
||||||
|
for f in _state["feat_defs"]:
|
||||||
|
if f.get("id") == fid:
|
||||||
|
return f
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _load_catalogs():
|
||||||
|
base = Path(_state["dir"])
|
||||||
|
try:
|
||||||
|
feats = json.loads((base / "feats.json").read_text(encoding="utf-8"))
|
||||||
|
_state["feat_defs"] = feats.get("feats", []) if isinstance(feats, dict) else []
|
||||||
|
except (OSError, ValueError) as e:
|
||||||
|
_state["log"].warning("achievements: could not load feats.json: %s", e)
|
||||||
|
_state["feat_defs"] = []
|
||||||
|
try:
|
||||||
|
_state["baseline"] = json.loads((base / "achievements.json").read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError) as e:
|
||||||
|
_state["log"].warning("achievements: could not load achievements.json: %s", e)
|
||||||
|
_state["baseline"] = {}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Request models ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class ActivityIn(BaseModel):
|
||||||
|
notes: int = Field(ge=0, default=0)
|
||||||
|
session_notes: int = Field(ge=0, default=0)
|
||||||
|
in_song_streak: int = Field(ge=0, default=0)
|
||||||
|
song_done: int = Field(ge=0, default=0)
|
||||||
|
seconds: int = Field(ge=0, default=0)
|
||||||
|
chart: str | None = None
|
||||||
|
night_session: bool = False
|
||||||
|
night_date: str | None = None # 'YYYY-MM-DD', frontend supplies (no server clock)
|
||||||
|
|
||||||
|
|
||||||
|
class UnlockIn(BaseModel):
|
||||||
|
id: str
|
||||||
|
kind: str = "achievement" # 'achievement' | 'feat'
|
||||||
|
category: str | None = None # display category (global/guitar/...)
|
||||||
|
sourceId: str | None = None
|
||||||
|
tier: int = Field(ge=0, default=0)
|
||||||
|
at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CriterionIn(BaseModel):
|
||||||
|
criterion_id: str
|
||||||
|
token: str
|
||||||
|
|
||||||
|
|
||||||
|
# ── FastAPI wiring ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def setup(app, context):
|
||||||
|
config_dir = context["config_dir"]
|
||||||
|
base = Path(config_dir) / "achievements"
|
||||||
|
base.mkdir(parents=True, exist_ok=True)
|
||||||
|
_state["db_path"] = str(base / "achievements.db")
|
||||||
|
_state["dir"] = str(Path(__file__).resolve().parent)
|
||||||
|
_state["config_dir"] = str(config_dir)
|
||||||
|
_state["meta_db"] = context.get("meta_db")
|
||||||
|
_state["log"] = context.get("log") or _state["log"]
|
||||||
|
# Pure helpers via the per-plugin sibling loader (constitution P-III), with a
|
||||||
|
# plain-import fallback for pytest / standalone use.
|
||||||
|
load_sibling = context.get("load_sibling")
|
||||||
|
try:
|
||||||
|
_state["engine"] = load_sibling("engine") if load_sibling else __import__("engine")
|
||||||
|
except Exception: # noqa: BLE001 — last-ditch, keep the plugin alive
|
||||||
|
import importlib.util
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
"achievements_engine", str(Path(__file__).resolve().parent / "engine.py"))
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
_state["engine"] = mod
|
||||||
|
_init_db()
|
||||||
|
_load_catalogs()
|
||||||
|
log = _state["log"]
|
||||||
|
|
||||||
|
@app.post("/api/plugins/achievements/activity")
|
||||||
|
def post_activity(body: ActivityIn):
|
||||||
|
engine = _state["engine"]
|
||||||
|
with _lock:
|
||||||
|
conn = _conn()
|
||||||
|
try:
|
||||||
|
# Per-chart play count is the only directly-stateful bit; bump it
|
||||||
|
# first (stable key) so apply_activity() just takes the new max.
|
||||||
|
chart_play_count = None
|
||||||
|
if body.song_done and body.chart:
|
||||||
|
chart_play_count = _bump_counter(conn, _chart_key(body.chart), 1)
|
||||||
|
# Night-window ledger → consecutive-night run. Computed here but
|
||||||
|
# NOT written before the prev snapshot: it is folded into the delta
|
||||||
|
# below so prev_tiers reflects the OLD run and new_tiers the new one
|
||||||
|
# (the same asymmetry chart_encore relies on). Pre-writing it would
|
||||||
|
# make prev already satisfy the Feat, so diff_unlocks would never
|
||||||
|
# see the freshly-earned witching unlock.
|
||||||
|
witching_run = None
|
||||||
|
if body.night_session and body.night_date:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO comp_ledger(criterion_id, token) VALUES ('witching', ?)",
|
||||||
|
(body.night_date,),
|
||||||
|
)
|
||||||
|
nights = [r["token"] for r in conn.execute(
|
||||||
|
"SELECT token FROM comp_ledger WHERE criterion_id='witching'")]
|
||||||
|
witching_run = engine.consecutive_run_length(nights)
|
||||||
|
|
||||||
|
counters = _read_counters(conn)
|
||||||
|
prev_tiers = engine.evaluate_feats(_state["feat_defs"], counters)
|
||||||
|
new_counters = engine.apply_activity(counters, {
|
||||||
|
"notes": body.notes,
|
||||||
|
"session_notes": body.session_notes,
|
||||||
|
"in_song_streak": body.in_song_streak,
|
||||||
|
"song_done": body.song_done,
|
||||||
|
"seconds": body.seconds,
|
||||||
|
"chart_play_count": chart_play_count,
|
||||||
|
})
|
||||||
|
if witching_run is not None:
|
||||||
|
new_counters["witching_nights_run"] = max(
|
||||||
|
int(new_counters.get("witching_nights_run", 0) or 0), witching_run)
|
||||||
|
_write_counters(conn, new_counters)
|
||||||
|
new_tiers = engine.evaluate_feats(_state["feat_defs"], new_counters)
|
||||||
|
fresh = engine.diff_unlocks(prev_tiers, new_tiers)
|
||||||
|
unlocked = []
|
||||||
|
for fid in fresh:
|
||||||
|
f = _feat_by_id(fid) or {}
|
||||||
|
tier = new_tiers[fid]
|
||||||
|
at = _now_iso()
|
||||||
|
if _record_unlock(conn, fid, "feat", f.get("category"), f.get("sourceId"), tier, at):
|
||||||
|
_enqueue_feat_sync(conn, fid, at)
|
||||||
|
unlocked.append(_feat_payload(fid, f, tier))
|
||||||
|
conn.commit()
|
||||||
|
return {"ok": True, "unlocked": unlocked, "counters": new_counters}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
@app.post("/api/plugins/achievements/report-unlock")
|
||||||
|
def post_report_unlock(body: UnlockIn):
|
||||||
|
cls = "feat" if body.kind == "feat" else "competency"
|
||||||
|
at = body.at or _now_iso()
|
||||||
|
with _lock:
|
||||||
|
conn = _conn()
|
||||||
|
try:
|
||||||
|
changed = _record_unlock(
|
||||||
|
conn, body.id, cls, body.category, body.sourceId, body.tier, at)
|
||||||
|
# Only Feats sync; competency never enqueues (integration law +
|
||||||
|
# data-minimization contract).
|
||||||
|
if changed and cls == "feat":
|
||||||
|
_enqueue_feat_sync(conn, body.id, at)
|
||||||
|
conn.commit()
|
||||||
|
return {"ok": True, "changed": changed, "id": body.id, "tier": body.tier}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
@app.post("/api/plugins/achievements/report-criterion")
|
||||||
|
def post_report_criterion(body: CriterionIn):
|
||||||
|
"""Record a distinct (criterion_id, token); return the distinct count.
|
||||||
|
|
||||||
|
Lets a baseline subscriber aggregate multi-event criteria (e.g. the set
|
||||||
|
of distinct days with a real advance → `steady_hands`) without us
|
||||||
|
re-deriving competency from activity. Bookkeeping over events only.
|
||||||
|
"""
|
||||||
|
with _lock:
|
||||||
|
conn = _conn()
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO comp_ledger(criterion_id, token) VALUES (?, ?)",
|
||||||
|
(body.criterion_id, body.token),
|
||||||
|
)
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT COUNT(*) AS n FROM comp_ledger WHERE criterion_id=?",
|
||||||
|
(body.criterion_id,),
|
||||||
|
).fetchone()
|
||||||
|
conn.commit()
|
||||||
|
return {"ok": True, "count": int(row["n"]) if row else 0}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
@app.get("/api/plugins/achievements/catalog")
|
||||||
|
def get_catalog():
|
||||||
|
with _lock:
|
||||||
|
conn = _conn()
|
||||||
|
try:
|
||||||
|
earned = _earned_map(conn)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return {"baseline": _state["baseline"], "earned": earned}
|
||||||
|
|
||||||
|
@app.get("/api/plugins/achievements/earned")
|
||||||
|
def get_earned():
|
||||||
|
with _lock:
|
||||||
|
conn = _conn()
|
||||||
|
try:
|
||||||
|
return {"earned": list(_earned_map(conn).values())}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
@app.get("/api/plugins/achievements/feats")
|
||||||
|
def get_feats():
|
||||||
|
with _lock:
|
||||||
|
conn = _conn()
|
||||||
|
try:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT achievement_id, tier, unlocked_at FROM unlocks WHERE cls='feat'"
|
||||||
|
).fetchall()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
out = []
|
||||||
|
for row in rows:
|
||||||
|
fid = row["achievement_id"]
|
||||||
|
f = _feat_by_id(fid) or {}
|
||||||
|
payload = _feat_payload(fid, f, int(row["tier"]))
|
||||||
|
payload["unlocked_at"] = row["unlocked_at"]
|
||||||
|
out.append(payload)
|
||||||
|
return {"feats": out}
|
||||||
|
|
||||||
|
@app.post("/api/plugins/achievements/remove-me")
|
||||||
|
def post_remove_me():
|
||||||
|
# Local removal works offline: drop the synced flag so nothing re-syncs,
|
||||||
|
# and enqueue a wall removal (drained in PR3). The wall identity
|
||||||
|
# (player_hash) is resolved server-side at drain time, not stored here.
|
||||||
|
_, player_hash = _identity()
|
||||||
|
with _lock:
|
||||||
|
conn = _conn()
|
||||||
|
try:
|
||||||
|
conn.execute("UPDATE unlocks SET synced=0 WHERE cls='feat'")
|
||||||
|
# Enqueue a wall removal only when we have an identity to key it
|
||||||
|
# by; the drain worker (below) POSTs it. Idempotent server-side.
|
||||||
|
if player_hash:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO sync_queue(kind, payload, state) VALUES ('remove', ?, 'pending')",
|
||||||
|
(json.dumps({"player_hash": player_hash}),),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
_maybe_start_drain()
|
||||||
|
log.info("achievements engine ready (%d feats, baseline v%s)",
|
||||||
|
len(_state["feat_defs"]), str(_state["baseline"].get("version", "?")))
|
||||||
|
|
||||||
|
|
||||||
|
def _feat_payload(fid, feat, tier):
|
||||||
|
titles = feat.get("tier_titles") or []
|
||||||
|
title = titles[tier] if 0 <= tier < len(titles) else feat.get("title", fid)
|
||||||
|
return {
|
||||||
|
"id": fid,
|
||||||
|
"cls": "feat",
|
||||||
|
"tier": tier,
|
||||||
|
"title": title,
|
||||||
|
"description": feat.get("description", ""),
|
||||||
|
"category": feat.get("category", "global"),
|
||||||
|
"secret": bool(feat.get("secret", False)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _earned_map(conn):
|
||||||
|
out = {}
|
||||||
|
for row in conn.execute(
|
||||||
|
"SELECT achievement_id, cls, disp_category, tier, unlocked_at FROM unlocks"
|
||||||
|
):
|
||||||
|
out[row["achievement_id"]] = {
|
||||||
|
"id": row["achievement_id"],
|
||||||
|
"cls": row["cls"],
|
||||||
|
"category": row["disp_category"],
|
||||||
|
"tier": int(row["tier"]),
|
||||||
|
"unlocked_at": row["unlocked_at"],
|
||||||
|
}
|
||||||
|
return out
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
/*
|
||||||
|
* Achievements & Feats of Power — frontend engine (vanilla, constitution P-II).
|
||||||
|
*
|
||||||
|
* Renders into the two core Profile mount points (achievements epic):
|
||||||
|
* #v3-profile-feats-slot → earned Feats trophy shelf (hidden-until-earned)
|
||||||
|
* #v3-profile-achievements-mount → full competency catalogue (locked = greyed),
|
||||||
|
* grouped by instrument via a secondary pill row.
|
||||||
|
* Re-injects on every `v3:profile-rendered` (core wipes the mounts each render).
|
||||||
|
*
|
||||||
|
* Also exposes the cross-plugin registration API `window.feedBack.achievements`
|
||||||
|
* (v1) so source plugins (Virtuoso, notedetect, …) contribute competency defs +
|
||||||
|
* report unlocks without us hardcoding their vocabulary. Load-order safe via the
|
||||||
|
* `window.__feedBackAchievementsPending` queue + `achievements:ready` event.
|
||||||
|
*
|
||||||
|
* INTEGRATION LAW: Feats read activity counters only (we POST batched activity on
|
||||||
|
* song:ended); competency Achievements are evaluated from progression EVENTS only.
|
||||||
|
* The two paths never cross.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var API = '/api/plugins/achievements';
|
||||||
|
var bus = window.feedBack;
|
||||||
|
if (!bus) return; // bus must exist (capabilities.js); nothing to attach to.
|
||||||
|
|
||||||
|
var esc = function (s) {
|
||||||
|
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
|
||||||
|
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── State ────────────────────────────────────────────────────────────────
|
||||||
|
var registered = {}; // id -> def (contributed + expanded baseline defs)
|
||||||
|
var earned = {}; // id -> { tier, cls, category }
|
||||||
|
var baseline = null; // /catalog baseline blob
|
||||||
|
var CAT_KEY = 'achievements:profile-cat'; // P-III: plugin localStorage keys prefixed with plugin id
|
||||||
|
var INSTRUMENTS = ['guitar', 'bass', 'drums', 'keys'];
|
||||||
|
|
||||||
|
function progState() {
|
||||||
|
return (window.v3Progression && window.v3Progression.get()) || null;
|
||||||
|
}
|
||||||
|
function notedetectPresent() {
|
||||||
|
return typeof window.createNoteDetector === 'function';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Backend I/O ──────────────────────────────────────────────────────────
|
||||||
|
function fetchJSON(path, opts) {
|
||||||
|
return fetch(API + path, opts).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
|
||||||
|
}
|
||||||
|
function postUnlock(def, tier) {
|
||||||
|
return fetch(API + '/report-unlock', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: def.id, kind: def.kind || 'achievement',
|
||||||
|
category: def.category || 'global', sourceId: def.sourceId || 'achievements',
|
||||||
|
tier: tier || 0,
|
||||||
|
}),
|
||||||
|
}).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
|
||||||
|
}
|
||||||
|
function refreshEarned() {
|
||||||
|
return fetchJSON('/earned').then(function (data) {
|
||||||
|
earned = {};
|
||||||
|
((data && data.earned) || []).forEach(function (e) { earned[e.id] = e; });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tier math (mirrors engine.tier_index_for) ────────────────────────────
|
||||||
|
function tierIndexFor(tiers, value) {
|
||||||
|
var idx = -1;
|
||||||
|
(tiers || []).forEach(function (t, i) { if (value >= t) idx = i; });
|
||||||
|
return idx;
|
||||||
|
}
|
||||||
|
function alreadyEarnedAtLeast(id, tier) {
|
||||||
|
var e = earned[id];
|
||||||
|
return e && e.tier >= tier;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Registration API (v1) ────────────────────────────────────────────────
|
||||||
|
function register(def) {
|
||||||
|
if (!def || !def.id) return;
|
||||||
|
registered[def.id] = {
|
||||||
|
id: def.id, kind: def.kind || 'achievement', category: def.category || 'global',
|
||||||
|
title: def.title || def.id, description: def.description || '',
|
||||||
|
secret: !!def.secret, sourceId: def.sourceId || 'unknown',
|
||||||
|
};
|
||||||
|
scheduleRender();
|
||||||
|
}
|
||||||
|
function registerAll(defs) { (defs || []).forEach(register); }
|
||||||
|
function unlock(id, opts) {
|
||||||
|
var def = registered[id] || { id: id, kind: 'achievement', category: 'global', sourceId: 'unknown' };
|
||||||
|
var tier = (opts && opts.tier) || 0;
|
||||||
|
if (alreadyEarnedAtLeast(id, tier)) return Promise.resolve();
|
||||||
|
return postUnlock(def, tier).then(function () {
|
||||||
|
return refreshEarned().then(function () {
|
||||||
|
// A contributed Feat unlock would enqueue a wall sync here when
|
||||||
|
// opted-in (PR2/PR3); competency never syncs (integration law).
|
||||||
|
scheduleRender();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function progress() { /* accepted, optional — display is greyed/earned, not bars */ }
|
||||||
|
|
||||||
|
var api = { version: 1, register: register, registerAll: registerAll, unlock: unlock, progress: progress };
|
||||||
|
bus.achievements = api;
|
||||||
|
// Drain sources that loaded before us (minigames pending-queue pattern).
|
||||||
|
try { (window.__feedBackAchievementsPending || []).forEach(function (fn) {
|
||||||
|
try { typeof fn === 'function' ? fn(api) : register(fn); } catch (_) { /* noop */ }
|
||||||
|
}); } catch (_) { /* noop */ }
|
||||||
|
window.__feedBackAchievementsPending = null;
|
||||||
|
try { bus.emit && bus.emit('achievements:ready', { version: 1 }); } catch (_) { /* noop */ }
|
||||||
|
|
||||||
|
// ── Baseline competency: evaluate from progression EVENTS only ────────────
|
||||||
|
function expandBaseline() {
|
||||||
|
// Register baseline defs (always present — built-in progression is always
|
||||||
|
// present) so they render greyed even before they're earned. Per-instrument
|
||||||
|
// templates expand across the REAL paths that exist (auto-extends).
|
||||||
|
if (!baseline) return;
|
||||||
|
(baseline.global || []).forEach(function (d) {
|
||||||
|
register({ id: d.id, kind: 'achievement', category: 'global', title: d.title,
|
||||||
|
description: d.description, sourceId: 'achievements' });
|
||||||
|
});
|
||||||
|
var paths = (progState() && progState().paths) || [];
|
||||||
|
var pathIds = paths.length ? paths.map(function (p) { return { id: p.id, name: p.name }; })
|
||||||
|
: INSTRUMENTS.map(function (i) { return { id: i, name: i.charAt(0).toUpperCase() + i.slice(1) }; });
|
||||||
|
(baseline.per_instrument || []).forEach(function (tpl) {
|
||||||
|
pathIds.forEach(function (pi) {
|
||||||
|
var inst = pi.name;
|
||||||
|
register({
|
||||||
|
id: tpl.id + ':' + pi.id, kind: 'achievement', category: pi.id,
|
||||||
|
title: (tpl.title || '').replace(/\{Inst\}/g, inst),
|
||||||
|
description: (tpl.description || '').replace(/\{Inst\}/g, inst),
|
||||||
|
sourceId: 'achievements',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluateBaseline() {
|
||||||
|
var prog = progState();
|
||||||
|
if (!prog || !baseline) return;
|
||||||
|
var defById = {};
|
||||||
|
(baseline.global || []).forEach(function (d) { defById[d.id] = d; });
|
||||||
|
|
||||||
|
// mastery_rank → first_steps / ascendant
|
||||||
|
(baseline.global || []).forEach(function (d) {
|
||||||
|
var crit = d.criterion || {};
|
||||||
|
if (crit.type === 'mastery_rank') {
|
||||||
|
var ti = tierIndexFor(crit.tiers || d.tiers, prog.mastery_rank || 0);
|
||||||
|
if (ti >= 0) baselineUnlock(d.id, 'global', ti);
|
||||||
|
} else if (crit.type === 'paths_at_level') {
|
||||||
|
var n = ((prog.paths) || []).filter(function (p) { return (p.level || 0) >= (crit.level || 10); }).length;
|
||||||
|
var ti2 = tierIndexFor(crit.tiers || d.tiers, n);
|
||||||
|
if (ti2 >= 0) baselineUnlock(d.id, 'global', ti2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// per-instrument path_rank → reach Lv 10/25/max in that path
|
||||||
|
var tpl = (baseline.per_instrument || []).filter(function (t) { return t.id === 'path_rank'; })[0];
|
||||||
|
if (tpl) {
|
||||||
|
((prog.paths) || []).forEach(function (p) {
|
||||||
|
var thresholds = (tpl.criterion && tpl.criterion.tiers) || [10, 25, 'max'];
|
||||||
|
var resolved = thresholds.map(function (t) { return t === 'max' ? (p.max_level || 9999) : t; });
|
||||||
|
var ti = tierIndexFor(resolved, p.level || 0);
|
||||||
|
if (ti >= 0) baselineUnlock('path_rank:' + p.id, p.id, ti);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function baselineUnlock(id, category, tier) {
|
||||||
|
if (alreadyEarnedAtLeast(id, tier)) return;
|
||||||
|
var def = registered[id] || { id: id, kind: 'achievement', category: category, sourceId: 'achievements' };
|
||||||
|
postUnlock(def, tier).then(function () { refreshEarned().then(scheduleRender); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local calendar date 'YYYY-MM-DD' (one source of truth for both the
|
||||||
|
// steady-hands day ledger and the witching-night date below).
|
||||||
|
function localISODate(d) {
|
||||||
|
d = d || new Date();
|
||||||
|
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
// growth-streak + challenger record on real competency events (date ledger /
|
||||||
|
// distinct challenge sets) — kept as bookkeeping over EVENTS, never activity.
|
||||||
|
function recordGrowthDay() {
|
||||||
|
var iso = localISODate();
|
||||||
|
fetchJSON('/report-criterion', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ criterion_id: 'steady_hands_days', token: iso }),
|
||||||
|
}).then(function (res) {
|
||||||
|
if (!res) return;
|
||||||
|
var d = ((baseline && baseline.global) || []).filter(function (x) { return x.id === 'steady_hands'; })[0];
|
||||||
|
var ti = tierIndexFor((d && d.tiers) || [7, 30, 100], res.count || 0);
|
||||||
|
if (ti >= 0) baselineUnlock('steady_hands', 'global', ti);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Activity (Feats): in-memory session counters, flushed on song:ended ───
|
||||||
|
var session = { notesTotal: 0 }; // cumulative across this sitting
|
||||||
|
// `active` gates note counting to an actual song in progress — without it,
|
||||||
|
// note:hit/miss from the tuner or input-calibration would inflate Feats from
|
||||||
|
// non-song input and flush a phantom streak with chart:null.
|
||||||
|
var song = { hits: 0, streak: 0, maxStreak: 0, chart: null, active: false };
|
||||||
|
function resetSong(chart) { song = { hits: 0, streak: 0, maxStreak: 0, chart: chart || null, active: true }; }
|
||||||
|
|
||||||
|
function flushActivity(seconds) {
|
||||||
|
if (!song.active) return; // no active song → nothing to flush (ignore stray events)
|
||||||
|
song.active = false;
|
||||||
|
// No notedetect → song.hits stays 0; notes-based Feats simply don't move
|
||||||
|
// (graceful degradation). song_done / seconds / chart still flow so the
|
||||||
|
// notedetect-free Feats (Road Warrior, Time Served, Encore) progress.
|
||||||
|
var hour = new Date().getHours();
|
||||||
|
var isNight = hour >= 2 && hour < 5;
|
||||||
|
var iso = localISODate();
|
||||||
|
var body = {
|
||||||
|
notes: song.hits,
|
||||||
|
session_notes: session.notesTotal,
|
||||||
|
in_song_streak: song.maxStreak,
|
||||||
|
song_done: 1,
|
||||||
|
seconds: Math.max(0, Math.round(seconds || 0)),
|
||||||
|
chart: song.chart,
|
||||||
|
night_session: isNight,
|
||||||
|
night_date: isNight ? iso : null,
|
||||||
|
};
|
||||||
|
fetchJSON('/activity', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}).then(function (res) {
|
||||||
|
if (res && res.unlocked && res.unlocked.length) {
|
||||||
|
// A Feat just unlocked — refresh the shelf + toast via the bus.
|
||||||
|
fetchFeatsAndRender();
|
||||||
|
res.unlocked.forEach(function (f) {
|
||||||
|
try { bus.emit && bus.emit('achievements:feat-unlocked', f); } catch (_) { /* noop */ }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rendering ────────────────────────────────────────────────────────────
|
||||||
|
var _renderQueued = false;
|
||||||
|
function scheduleRender() {
|
||||||
|
if (_renderQueued) return;
|
||||||
|
_renderQueued = true;
|
||||||
|
(window.requestAnimationFrame || window.setTimeout)(function () { _renderQueued = false; renderAll(); }, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAll() {
|
||||||
|
renderCatalog();
|
||||||
|
fetchFeatsAndRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
function categoriesForDisplay() {
|
||||||
|
var cats = [{ id: 'global', name: 'Global' }];
|
||||||
|
var paths = (progState() && progState().paths) || [];
|
||||||
|
if (paths.length) {
|
||||||
|
paths.forEach(function (p) { cats.push({ id: p.id, name: p.name }); });
|
||||||
|
} else {
|
||||||
|
// Fallback before progression loads: show the known instrument cats
|
||||||
|
// that actually have registered items.
|
||||||
|
INSTRUMENTS.forEach(function (i) {
|
||||||
|
if (Object.keys(registered).some(function (id) { return registered[id].category === i; })) {
|
||||||
|
cats.push({ id: i, name: i.charAt(0).toUpperCase() + i.slice(1) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return cats;
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemsForCategory(catId) {
|
||||||
|
return Object.keys(registered).map(function (id) { return registered[id]; })
|
||||||
|
.filter(function (d) { return (d.category || 'global') === catId; })
|
||||||
|
// Hide un-earned secret items (revealed only once earned).
|
||||||
|
.filter(function (d) { return !d.secret || earned[d.id]; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCatalog() {
|
||||||
|
var mount = document.getElementById('v3-profile-achievements-mount');
|
||||||
|
if (!mount) return;
|
||||||
|
// Hide the core empty-state note now that we own this mount.
|
||||||
|
var emptyNote = document.querySelector('[data-empty-for="v3-profile-achievements-mount"]');
|
||||||
|
if (emptyNote) emptyNote.style.display = 'none';
|
||||||
|
|
||||||
|
var cats = categoriesForDisplay();
|
||||||
|
var saved = null;
|
||||||
|
try { saved = localStorage.getItem(CAT_KEY); } catch (_) { /* noop */ }
|
||||||
|
// Default to the player's primary path (first path), fallback Global.
|
||||||
|
var primary = (progState() && progState().paths && progState().paths[0] && progState().paths[0].id) || 'global';
|
||||||
|
var active = cats.some(function (c) { return c.id === saved; }) ? saved
|
||||||
|
: (cats.some(function (c) { return c.id === primary; }) ? primary : 'global');
|
||||||
|
|
||||||
|
var pills = cats.map(function (c) {
|
||||||
|
var items = itemsForCategory(c.id);
|
||||||
|
var got = items.filter(function (d) { return earned[d.id]; }).length;
|
||||||
|
return '<button type="button" class="fb-ach-pill' + (c.id === active ? ' active' : '') +
|
||||||
|
'" data-cat="' + esc(c.id) + '">' + esc(c.name) +
|
||||||
|
' <span class="fb-ach-pill-badge">' + got + '/' + items.length + '</span></button>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
var items = itemsForCategory(active);
|
||||||
|
var list = items.length ? items.map(function (d) {
|
||||||
|
var got = !!earned[d.id];
|
||||||
|
var tier = got ? (earned[d.id].tier || 0) : -1;
|
||||||
|
return '<div class="fb-ach-item' + (got ? ' earned' : ' locked') + '">' +
|
||||||
|
'<div class="fb-ach-item-icon">' + (got ? '🏅' : '🔒') + '</div>' +
|
||||||
|
'<div class="fb-ach-item-body">' +
|
||||||
|
'<div class="fb-ach-item-title">' + esc(d.title) +
|
||||||
|
(got && tier > 0 ? ' <span class="fb-ach-tier">tier ' + (tier + 1) + '</span>' : '') + '</div>' +
|
||||||
|
'<div class="fb-ach-item-desc">' + esc(d.description) + '</div>' +
|
||||||
|
'</div></div>';
|
||||||
|
}).join('') : '<p class="fb-tabpanel-empty">No achievements in this category yet.</p>';
|
||||||
|
|
||||||
|
mount.innerHTML =
|
||||||
|
'<div class="fb-ach-pillrow">' + pills + '</div>' +
|
||||||
|
'<div class="fb-ach-list">' + list + '</div>';
|
||||||
|
mount.querySelectorAll('[data-cat]').forEach(function (b) {
|
||||||
|
b.addEventListener('click', function () {
|
||||||
|
try { localStorage.setItem(CAT_KEY, b.dataset.cat); } catch (_) { /* noop */ }
|
||||||
|
renderCatalog();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchFeatsAndRender() {
|
||||||
|
return fetchJSON('/feats').then(function (data) { renderFeats((data && data.feats) || []); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFeats(feats) {
|
||||||
|
var slot = document.getElementById('v3-profile-feats-slot');
|
||||||
|
if (!slot) return;
|
||||||
|
if (!feats.length) { slot.innerHTML = ''; return; } // hidden-until-earned
|
||||||
|
var cards = feats.map(function (f) {
|
||||||
|
return '<div class="fb-feat-card" title="' + esc(f.description) + '">' +
|
||||||
|
'<div class="fb-feat-icon">🏆</div>' +
|
||||||
|
'<div class="fb-feat-title">' + esc(f.title) + '</div>' +
|
||||||
|
'<div class="fb-feat-desc">' + esc(f.description) + '</div>' +
|
||||||
|
'</div>';
|
||||||
|
}).join('');
|
||||||
|
// Opt-out users get a subtle wall hint linking to Settings (PR2 wires it).
|
||||||
|
var hint = '';
|
||||||
|
slot.innerHTML =
|
||||||
|
'<div class="bg-fb-card/80 backdrop-blur rounded-xl p-6 border border-fb-border/50">' +
|
||||||
|
'<h3 class="text-lg font-bold text-fb-text mb-3">Feats of Power</h3>' +
|
||||||
|
'<div class="fb-feat-shelf">' + cards + '</div>' + hint +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Boot ─────────────────────────────────────────────────────────────────
|
||||||
|
function init() {
|
||||||
|
fetchJSON('/catalog').then(function (data) {
|
||||||
|
baseline = (data && data.baseline) || {};
|
||||||
|
((data && data.earned) && (earned = {}, Object.keys(data.earned).forEach(function (id) { earned[id] = data.earned[id]; })));
|
||||||
|
expandBaseline();
|
||||||
|
evaluateBaseline();
|
||||||
|
scheduleRender();
|
||||||
|
});
|
||||||
|
// Re-inject on every profile entry (core wipes the mounts each render).
|
||||||
|
document.addEventListener('v3:profile-rendered', function () { renderAll(); });
|
||||||
|
|
||||||
|
// Competency events → re-expand (paths may have appeared) + re-evaluate.
|
||||||
|
['progression:updated', 'progression:rank-changed', 'progression:path-level-up'].forEach(function (ev) {
|
||||||
|
bus.on && bus.on(ev, function () { expandBaseline(); evaluateBaseline(); });
|
||||||
|
});
|
||||||
|
// A real competency advance ticks the growth-streak day ledger.
|
||||||
|
['progression:rank-changed', 'progression:path-level-up', 'progression:challenge-completed'].forEach(function (ev) {
|
||||||
|
bus.on && bus.on(ev, function () { recordGrowthDay(); });
|
||||||
|
});
|
||||||
|
// challenger:<inst> — clearing a full level-up set for a path.
|
||||||
|
bus.on && bus.on('progression:path-level-up', function (e) {
|
||||||
|
var pid = e && e.detail && (e.detail.path_id || e.detail.id);
|
||||||
|
if (pid) baselineUnlock('challenger:' + pid, pid, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Activity (Feats) — in-memory counters, flushed once per song.
|
||||||
|
bus.on && bus.on('song:loading', function (e) {
|
||||||
|
resetSong(e && e.detail && e.detail.filename);
|
||||||
|
});
|
||||||
|
bus.on && bus.on('note:hit', function () {
|
||||||
|
if (!song.active) return; // ignore tuner/calibration note events
|
||||||
|
song.hits++; song.streak++; session.notesTotal++;
|
||||||
|
if (song.streak > song.maxStreak) song.maxStreak = song.streak;
|
||||||
|
});
|
||||||
|
bus.on && bus.on('note:miss', function () { if (song.active) song.streak = 0; });
|
||||||
|
bus.on && bus.on('song:ended', function (e) {
|
||||||
|
flushActivity(e && e.detail && (e.detail.time || e.detail.audioT));
|
||||||
|
});
|
||||||
|
// Song stopped/abandoned without a natural end → mark inactive so stray
|
||||||
|
// note events after it don't accrue against a phantom (chart:null) song.
|
||||||
|
bus.on && bus.on('song:stop', function () { song.active = false; });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', init, { once: true });
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<!-- Achievements plugin — Privacy panel (mounts under the Settings "System" tab
|
||||||
|
via settings.category). Owns the wall opt-in toggle (bound to the core
|
||||||
|
`achievements_enabled` setting) + the self-serve "Remove me from the wall"
|
||||||
|
action. Default OFF — nothing publishes until the user opts in. -->
|
||||||
|
<div class="text-sm text-gray-300 space-y-4" data-ach-privacy>
|
||||||
|
<div>
|
||||||
|
<p>Your <strong>Achievements</strong> and <strong>Feats of Power</strong> live on your
|
||||||
|
<em>Profile</em> page and are local & private by default.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex items-start gap-3 cursor-pointer">
|
||||||
|
<input type="checkbox" id="setting-achievements-enabled"
|
||||||
|
class="mt-1 h-4 w-4 rounded border-gray-600 bg-gray-800 text-sky-500 focus:ring-sky-500">
|
||||||
|
<span>
|
||||||
|
<span class="font-medium text-gray-200">Share my Feats of Power on the public wall</span>
|
||||||
|
<span class="block text-gray-400">Publishes only your display name and the rare
|
||||||
|
<strong>Feats</strong> you earn (activity milestones) — never songs, skills, or scores.
|
||||||
|
You can turn this off and remove yourself at any time.</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button type="button" id="ach-remove-me"
|
||||||
|
class="px-3 py-1.5 rounded-md text-sm border border-red-500/40 text-red-300 hover:bg-red-500/10 transition">
|
||||||
|
Remove me from the wall
|
||||||
|
</button>
|
||||||
|
<span id="ach-remove-status" class="ml-2 text-xs text-gray-400"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var root = document.currentScript && document.currentScript.previousElementSibling;
|
||||||
|
// The panel re-injects on each Settings entry; bind once per element.
|
||||||
|
var toggle = document.getElementById('setting-achievements-enabled');
|
||||||
|
if (!toggle || toggle.dataset.wired === '1') return;
|
||||||
|
toggle.dataset.wired = '1';
|
||||||
|
|
||||||
|
// Hydrate from the authoritative server setting.
|
||||||
|
fetch('/api/settings').then(function (r) { return r.ok ? r.json() : {}; }).then(function (d) {
|
||||||
|
toggle.checked = d && d.achievements_enabled === true;
|
||||||
|
}).catch(function () { /* offline — leave unchecked */ });
|
||||||
|
|
||||||
|
toggle.addEventListener('change', function () {
|
||||||
|
var on = !!toggle.checked;
|
||||||
|
try { localStorage.setItem('achievementsEnabled', on ? '1' : '0'); } catch (_) {}
|
||||||
|
fetch('/api/settings', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ achievements_enabled: on }),
|
||||||
|
}).catch(function () { /* best-effort; revert on failure */ });
|
||||||
|
});
|
||||||
|
|
||||||
|
var removeBtn = document.getElementById('ach-remove-me');
|
||||||
|
var status = document.getElementById('ach-remove-status');
|
||||||
|
if (removeBtn) {
|
||||||
|
removeBtn.addEventListener('click', function () {
|
||||||
|
removeBtn.disabled = true;
|
||||||
|
if (status) status.textContent = 'Removing…';
|
||||||
|
fetch('/api/plugins/achievements/remove-me', { method: 'POST' })
|
||||||
|
.then(function (r) {
|
||||||
|
if (status) status.textContent = r.ok ? 'Removed. Your Feats stay on your Profile.' : 'Could not reach the server — try again.';
|
||||||
|
})
|
||||||
|
.catch(function () { if (status) status.textContent = 'Offline — queued; it will sync when you reconnect.'; })
|
||||||
|
.finally(function () { removeBtn.disabled = false; });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
id: 'library-provider',
|
id: 'library-provider',
|
||||||
selector: '#lib-provider',
|
selector: '#lib-provider',
|
||||||
title: 'Choose a library',
|
title: 'Choose a library',
|
||||||
content: 'Use this menu to switch between your local library and any connected remote libraries. Slopsmith remembers the last library you picked.',
|
content: 'Use this menu to switch between your local library and any connected remote libraries. FeedBack remembers the last library you picked.',
|
||||||
shape: 'spotlight',
|
shape: 'spotlight',
|
||||||
position: 'bottom',
|
position: 'bottom',
|
||||||
waitFor: '#lib-provider'
|
waitFor: '#lib-provider'
|
||||||
@@ -57,20 +57,20 @@
|
|||||||
|
|
||||||
function _register() {
|
function _register() {
|
||||||
try {
|
try {
|
||||||
window.slopsmithTour.register(PLUGIN_ID, { screens: SCREENS, buildSteps: _buildSteps });
|
window.feedBackTour.register(PLUGIN_ID, { screens: SCREENS, buildSteps: _buildSteps });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[app_tour_library] register failed', e);
|
console.warn('[app_tour_library] register failed', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (window.slopsmithTour && typeof window.slopsmithTour.register === 'function') {
|
if (window.feedBackTour && typeof window.feedBackTour.register === 'function') {
|
||||||
_register();
|
_register();
|
||||||
} else {
|
} else {
|
||||||
// Engine inits on DOMContentLoaded after fetching /api/plugins. Plugin
|
// Engine inits on DOMContentLoaded after fetching /api/plugins. Plugin
|
||||||
// scripts can load before or after that handler runs, so poll briefly.
|
// scripts can load before or after that handler runs, so poll briefly.
|
||||||
var deadline = performance.now() + 5000;
|
var deadline = performance.now() + 5000;
|
||||||
var pollId = setInterval(function () {
|
var pollId = setInterval(function () {
|
||||||
if (window.slopsmithTour && typeof window.slopsmithTour.register === 'function') {
|
if (window.feedBackTour && typeof window.feedBackTour.register === 'function') {
|
||||||
clearInterval(pollId);
|
clearInterval(pollId);
|
||||||
_register();
|
_register();
|
||||||
} else if (performance.now() > deadline) {
|
} else if (performance.now() > deadline) {
|
||||||
@@ -93,9 +93,9 @@
|
|||||||
var s = document.createElement('style');
|
var s = document.createElement('style');
|
||||||
s.id = STYLE_ID;
|
s.id = STYLE_ID;
|
||||||
s.textContent =
|
s.textContent =
|
||||||
'body.' + NUDGE_CLASS + ' .slopsmith-tour-menu-btn { bottom: 68px; }' +
|
'body.' + NUDGE_CLASS + ' .feedBack-tour-menu-btn { bottom: 68px; }' +
|
||||||
'body.' + NUDGE_CLASS + ' .slopsmith-tour-menu-popover { bottom: 112px; }' +
|
'body.' + NUDGE_CLASS + ' .feedBack-tour-menu-popover { bottom: 112px; }' +
|
||||||
'body.' + NUDGE_CLASS + ' .slopsmith-tour-prompt { bottom: 112px; }';
|
'body.' + NUDGE_CLASS + ' .feedBack-tour-prompt { bottom: 112px; }';
|
||||||
document.head.appendChild(s);
|
document.head.appendChild(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,8 +109,8 @@
|
|||||||
// Prime from whichever screen is already active.
|
// Prime from whichever screen is already active.
|
||||||
var active = document.querySelector('.screen.active');
|
var active = document.querySelector('.screen.active');
|
||||||
_applyNudge(active ? active.id : null);
|
_applyNudge(active ? active.id : null);
|
||||||
if (window.slopsmith && typeof window.slopsmith.on === 'function') {
|
if (window.feedBack && typeof window.feedBack.on === 'function') {
|
||||||
window.slopsmith.on('screen:changed', function (ev) {
|
window.feedBack.on('screen:changed', function (ev) {
|
||||||
_applyNudge(ev && ev.detail && ev.detail.id);
|
_applyNudge(ev && ev.detail && ev.detail.id);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"tour": [
|
"tour": [
|
||||||
{
|
{
|
||||||
"id": "welcome",
|
"id": "welcome",
|
||||||
"title": "Welcome to Slopsmith",
|
"title": "Welcome to FeedBack",
|
||||||
"content": "This is your library — every song we found in your library folder. Let's take a quick spin through the controls.",
|
"content": "This is your library — every song we found in your library folder. Let's take a quick spin through the controls.",
|
||||||
"shape": "bubble",
|
"shape": "bubble",
|
||||||
"position": "auto"
|
"position": "auto"
|
||||||
|
|||||||
@@ -6,18 +6,18 @@
|
|||||||
|
|
||||||
function _register() {
|
function _register() {
|
||||||
try {
|
try {
|
||||||
window.slopsmithTour.register(PLUGIN_ID, { screens: SCREENS });
|
window.feedBackTour.register(PLUGIN_ID, { screens: SCREENS });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[app_tour_settings] register failed', e);
|
console.warn('[app_tour_settings] register failed', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (window.slopsmithTour && typeof window.slopsmithTour.register === 'function') {
|
if (window.feedBackTour && typeof window.feedBackTour.register === 'function') {
|
||||||
_register();
|
_register();
|
||||||
} else {
|
} else {
|
||||||
var deadline = performance.now() + 5000;
|
var deadline = performance.now() + 5000;
|
||||||
var pollId = setInterval(function () {
|
var pollId = setInterval(function () {
|
||||||
if (window.slopsmithTour && typeof window.slopsmithTour.register === 'function') {
|
if (window.feedBackTour && typeof window.feedBackTour.register === 'function') {
|
||||||
clearInterval(pollId);
|
clearInterval(pollId);
|
||||||
_register();
|
_register();
|
||||||
} else if (performance.now() > deadline) {
|
} else if (performance.now() > deadline) {
|
||||||
@@ -38,9 +38,9 @@
|
|||||||
var s = document.createElement('style');
|
var s = document.createElement('style');
|
||||||
s.id = STYLE_ID;
|
s.id = STYLE_ID;
|
||||||
s.textContent =
|
s.textContent =
|
||||||
'body.' + NUDGE_CLASS + ' .slopsmith-tour-menu-btn { bottom: 68px; }' +
|
'body.' + NUDGE_CLASS + ' .feedBack-tour-menu-btn { bottom: 68px; }' +
|
||||||
'body.' + NUDGE_CLASS + ' .slopsmith-tour-menu-popover { bottom: 112px; }' +
|
'body.' + NUDGE_CLASS + ' .feedBack-tour-menu-popover { bottom: 112px; }' +
|
||||||
'body.' + NUDGE_CLASS + ' .slopsmith-tour-prompt { bottom: 112px; }';
|
'body.' + NUDGE_CLASS + ' .feedBack-tour-prompt { bottom: 112px; }';
|
||||||
document.head.appendChild(s);
|
document.head.appendChild(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,8 +53,8 @@
|
|||||||
_ensureStyle();
|
_ensureStyle();
|
||||||
var active = document.querySelector('.screen.active');
|
var active = document.querySelector('.screen.active');
|
||||||
_applyNudge(active ? active.id : null);
|
_applyNudge(active ? active.id : null);
|
||||||
if (window.slopsmith && typeof window.slopsmith.on === 'function') {
|
if (window.feedBack && typeof window.feedBack.on === 'function') {
|
||||||
window.slopsmith.on('screen:changed', function (ev) {
|
window.feedBack.on('screen:changed', function (ev) {
|
||||||
_applyNudge(ev && ev.detail && ev.detail.id);
|
_applyNudge(ev && ev.detail && ev.detail.id);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"id": "dlc-path",
|
"id": "dlc-path",
|
||||||
"selector": "#dlc-path",
|
"selector": "#dlc-path",
|
||||||
"title": "Library folder",
|
"title": "Library folder",
|
||||||
"content": "Point Slopsmith at your library folder. Songs here become your library. Hit Save after changing.",
|
"content": "Point FeedBack at your library folder. Songs here become your library. Hit Save after changing.",
|
||||||
"shape": "spotlight",
|
"shape": "spotlight",
|
||||||
"position": "bottom"
|
"position": "bottom"
|
||||||
},
|
},
|
||||||
@@ -69,7 +69,7 @@
|
|||||||
"id": "about",
|
"id": "about",
|
||||||
"selector": "#app-version-about",
|
"selector": "#app-version-about",
|
||||||
"title": "About",
|
"title": "About",
|
||||||
"content": "Version, source code, and license. Slopsmith is AGPL-3.0 — if you fork it, the source has to stay open.",
|
"content": "Version, source code, and license. FeedBack is AGPL-3.0 — if you fork it, the source has to stay open.",
|
||||||
"shape": "spotlight",
|
"shape": "spotlight",
|
||||||
"position": "top"
|
"position": "top"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const state = window.__slopsmithCapabilityInspector || (window.__slopsmithCapabilityInspector = {});
|
const state = window.__feedBackCapabilityInspector || (window.__feedBackCapabilityInspector = {});
|
||||||
state.render = render;
|
state.render = render;
|
||||||
if (state.installed) return;
|
if (state.installed) return;
|
||||||
state.installed = true;
|
state.installed = true;
|
||||||
@@ -126,7 +126,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function registry() {
|
function registry() {
|
||||||
return window.slopsmith && window.slopsmith.capabilities;
|
return window.feedBack && window.feedBack.capabilities;
|
||||||
}
|
}
|
||||||
|
|
||||||
function snapshot() {
|
function snapshot() {
|
||||||
@@ -319,7 +319,7 @@
|
|||||||
lifecycle: review.lifecycle || 'plugin-defined',
|
lifecycle: review.lifecycle || 'plugin-defined',
|
||||||
label: review.label || 'Plugin-defined',
|
label: review.label || 'Plugin-defined',
|
||||||
tone: review.tone || 'info',
|
tone: review.tone || 'info',
|
||||||
summary: review.summary || 'Declared by a plugin or test fixture rather than registered as a core Slopsmith domain.',
|
summary: review.summary || 'Declared by a plugin or test fixture rather than registered as a core FeedBack domain.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1087,7 +1087,7 @@
|
|||||||
const groups = new Map();
|
const groups = new Map();
|
||||||
for (const expected of Array.isArray(expectedShims) ? expectedShims : []) {
|
for (const expected of Array.isArray(expectedShims) ? expectedShims : []) {
|
||||||
const surface = String(expected && expected.legacySurface || '');
|
const surface = String(expected && expected.legacySurface || '');
|
||||||
const eventMatch = surface.match(/^window\.slopsmith\.(emit|on):(.+)$/);
|
const eventMatch = surface.match(/^window\.feedBack\.(emit|on):(.+)$/);
|
||||||
const key = eventMatch ? eventMatch[2] : surface;
|
const key = eventMatch ? eventMatch[2] : surface;
|
||||||
const type = eventMatch ? (eventMatch[1] === 'emit' ? 'emit' : 'listener') : 'surface';
|
const type = eventMatch ? (eventMatch[1] === 'emit' ? 'emit' : 'listener') : 'surface';
|
||||||
const entry = groups.get(key) || { group: key, emit: null, listener: null, surfaces: [] };
|
const entry = groups.get(key) || { group: key, emit: null, listener: null, surfaces: [] };
|
||||||
@@ -1461,14 +1461,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function audioSessionSnapshot() {
|
function audioSessionSnapshot() {
|
||||||
const api = window.slopsmith && window.slopsmith.audioSession;
|
const api = window.feedBack && window.feedBack.audioSession;
|
||||||
if (!api || typeof api.snapshot !== 'function') return null;
|
if (!api || typeof api.snapshot !== 'function') return null;
|
||||||
try { return api.snapshot(); }
|
try { return api.snapshot(); }
|
||||||
catch (_) { return null; }
|
catch (_) { return null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function playbackSnapshot() {
|
function playbackSnapshot() {
|
||||||
const api = window.slopsmith && window.slopsmith.playback;
|
const api = window.feedBack && window.feedBack.playback;
|
||||||
if (!api || typeof api.snapshot !== 'function') return null;
|
if (!api || typeof api.snapshot !== 'function') return null;
|
||||||
try { return api.snapshot({ exportMode: 'local-inspector' }); }
|
try { return api.snapshot({ exportMode: 'local-inspector' }); }
|
||||||
catch (_) { return null; }
|
catch (_) { return null; }
|
||||||
@@ -1747,6 +1747,6 @@
|
|||||||
|
|
||||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', install);
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', install);
|
||||||
else install();
|
else install();
|
||||||
window.addEventListener('slopsmith:capabilities:ready', render);
|
window.addEventListener('feedBack:capabilities:ready', render);
|
||||||
window.addEventListener('slopsmith:capabilities:changed', scheduleRender);
|
window.addEventListener('feedBack:capabilities:changed', scheduleRender);
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -0,0 +1,345 @@
|
|||||||
|
# Folder Library — AI Agent Guide
|
||||||
|
|
||||||
|
A FeedBack (fee[dB]ack) plugin that adds a **Folders** nav screen showing your `.sloppak` / `.feedpak` DLC songs grouped by the folder tree on disk. Create, rename, and delete folders (including **nested subfolders**) directly in the UI, move songs by drag-and-drop, and browse with sort and metadata filters.
|
||||||
|
|
||||||
|
> The host app is **FeedBack** (formerly "Slopsmith"). The frontend talks to the host through `window.feedBack`; `window.slopsmith` is a back-compat alias the host still exposes (`window.slopsmith = window.feedBack` in `static/app.js`). New code should prefer `window.feedBack`.
|
||||||
|
|
||||||
|
> ⚠️ **Status — bundled core plugin.** This plugin began as a standalone plugin and is now a bundled core plugin. `screen.js` has been unified into a **single surface factory** driving two entry points: the v3 library Folder view (host chrome — host search `#v3-search`/`#lib-filter`, host filter params, renders into `#lib-folder-tree`) and the classic v2 standalone Folders nav-tab (its own `#fb-search` + toolbar, renders into `#fb-tree`). **Folder search works on both surfaces** — typing in the relevant search box re-renders the tree. **Loose-folder songs** (directories with audio + an arrangement XML) are recognised as songs via the host `loosefolder.is_loose_song` predicate, so they appear in the tree alongside `.sloppak`/`.feedpak` bundles. Folder management, nested subfolders, collapsible folders + expand/collapse-all, drag-and-drop, move-song, sort, filters, and the hover metadata badges are wired on both surfaces; verify against a running build before relying on any of it.
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
plugin.json Plugin manifest — id, name, nav entry, file declarations ("bundled": true core plugin)
|
||||||
|
routes.py FastAPI backend — recursive DLC scan, folder tree + filters, folder/song mutations, two-level cache
|
||||||
|
screen.html Plugin screen content — injected by the host into the plugin div automatically
|
||||||
|
screen.js Frontend logic — recursive folder tree, search, sort, filters, drag-and-drop, modals
|
||||||
|
README.md User-facing docs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
This plugin follows the standard FeedBack plugin pattern (see the repo-root `CLAUDE.md` for the full plugin system reference).
|
||||||
|
|
||||||
|
- **Backend** (`routes.py`) — registers routes under `GET/POST /api/plugins/folder_library/`. Uses `context["get_dlc_dir"]()`, `context["extract_meta"]()`, and `context["log"]`. Scans `<dlc>/sloppak/` if it exists, otherwise `<dlc>/`. Recursively walks the tree and handles create/rename/delete folder and move-song operations on slash-separated folder paths.
|
||||||
|
- **Frontend** (`screen.js`) — plain vanilla JS in an IIFE. Fetches the tree from the backend on screen load, recursively renders collapsible folder sections (any depth) and song rows or cards (grid view). Uses `window.feedBack.on('screen:changed', ...)` (via the `window.slopsmith` alias) to trigger load when the user navigates here. Calls `window.playSong(filename)` on song click with the full relative path from the DLC root.
|
||||||
|
- **No dependencies** — no npm, no build step. Tailwind utility classes available globally from the host; the plugin uses only core-guaranteed utilities and inline styles, so it ships **no** `styles` manifest key.
|
||||||
|
|
||||||
|
## Critical Layout Lessons (Hard-Won)
|
||||||
|
|
||||||
|
These are non-obvious behaviours of the FeedBack desktop app (Electron) that took significant debugging to discover. They still apply unchanged.
|
||||||
|
|
||||||
|
### 1. Do NOT put an outer wrapper div in screen.html
|
||||||
|
The host automatically creates `<div id="plugin-folder_library" class="screen">` and injects `screen.html` content inside it. If you add your own outer div with `class="screen"`, you get a nested screen element which gets `display:none` applied, hiding all content.
|
||||||
|
|
||||||
|
**Wrong:**
|
||||||
|
```html
|
||||||
|
<div id="plugin-folder_library" class="screen">
|
||||||
|
<div>toolbar</div>
|
||||||
|
<div>content</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
```html
|
||||||
|
<!-- no outer wrapper — the host provides it -->
|
||||||
|
<div>toolbar</div>
|
||||||
|
<div>content</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. The .screen CSS class sets display:none by default
|
||||||
|
`.screen { display: none }` and `.screen.active { display: block }`. There is no height set. The screen div gets its height purely from its content. Do not try to set height via CSS classes — use inline styles or JS if needed.
|
||||||
|
|
||||||
|
### 3. The host navbar is position:fixed with z-index:50
|
||||||
|
The navbar sits at `top:0, z-index:50`. Plugin toolbars must use `position:fixed; top:64px; z-index:40` to sit below the navbar. Use a solid `background-color` (not Tailwind bg classes — those may not apply correctly) to prevent content showing through.
|
||||||
|
|
||||||
|
### 4. Content must have padding-top to clear the fixed toolbar
|
||||||
|
Since the toolbar is `position:fixed`, it floats above the content. The content container needs enough `padding-top` (~120px) to ensure the first item isn't hidden behind the toolbar — the host navbar (64px) plus the plugin toolbar height (~56px). Adding more toolbar buttons increases this height, so if content is clipped, increase the padding further.
|
||||||
|
|
||||||
|
### 5. Electron blocks window.prompt() and window.confirm()
|
||||||
|
The desktop app is built on Electron, which throws `Error: prompt() is not supported`. Use a custom inline modal instead. See `_showModal()` in `screen.js` — it returns a Promise and supports both text input and confirm modes.
|
||||||
|
|
||||||
|
### 6. The nav plugin dropdown has z-index:50 and blocks clicks
|
||||||
|
When navigating to a plugin screen via the Plugins dropdown, the dropdown stays open and sits on top of the screen. Call `_closeDropdown()` on screen load to dismiss it. The dropdown element id is `plugin-dropdown`.
|
||||||
|
|
||||||
|
### 7. playSong() expects a relative path from the DLC root
|
||||||
|
`window.playSong()` expects the path relative to the DLC root with forward slashes, e.g. `sloppak/CH/Artist - Title.sloppak`. Not just the filename. The backend builds this in `_meta()` via `"/".join(p.relative_to(dlc).parts)` and returns it as each song's `filename`.
|
||||||
|
|
||||||
|
### 8. FastAPI POST routes need `from fastapi import Request`
|
||||||
|
Routes that receive a JSON body must import `Request` from fastapi explicitly and use `async def route(request: Request)` with `body = await request.json()`. Missing this import crashes the server on plugin load.
|
||||||
|
|
||||||
|
### 9. Plugin id must be consistent everywhere
|
||||||
|
The plugin id (`folder_library`) must match in:
|
||||||
|
- `plugin.json` → `"id"` and `"nav.screen"`
|
||||||
|
- `screen.js` → `PLUGIN_ID` constant and `API` constant (`/api/plugins/folder_library`)
|
||||||
|
- `routes.py` → `APIRouter(prefix="/api/plugins/folder_library")`
|
||||||
|
|
||||||
|
A mismatch in any of these causes silent failures (blank screen, 404 API calls).
|
||||||
|
|
||||||
|
### 10. Use inline styles for grid layout, not Tailwind
|
||||||
|
Tailwind's `grid` and `grid-cols-*` classes may not apply reliably inside the plugin div. Use `element.style.cssText` with explicit `display:grid; grid-template-columns:...` for the grid container.
|
||||||
|
|
||||||
|
## Key Conventions
|
||||||
|
|
||||||
|
- **IIFE + `'use strict'`** — all frontend code wrapped in `(function(){ 'use strict'; ... })();`
|
||||||
|
- **localStorage prefixes** — plugin keys are prefixed `fo:` (e.g. `fo:view`, `fo:sort`, `fo:filters`); host-library-synced filter state uses `fo:lib:`. Open-folder state is tracked by **folder path** (so nested folders each remember their own state).
|
||||||
|
- **Safe storage access** — all `localStorage` reads/writes wrapped in try/catch
|
||||||
|
- **Logging** — backend uses `context["log"]`, never `print()`
|
||||||
|
- **Sibling imports** — use `context["load_sibling"]("name")` not bare `import name` (none needed today; keep this in mind if you add helper modules)
|
||||||
|
|
||||||
|
## Song Formats
|
||||||
|
|
||||||
|
The plugin treats both `.sloppak` and `.feedpak` as songs (`_is_song()` in `routes.py`). `feedpak` is the published name for the same on-disk format the codebase still calls `sloppak` internally — see the repo-root `CLAUDE.md`. Both file form (`.sloppak`/`.feedpak` zip) and directory form (`*.sloppak/` folder) are recognized.
|
||||||
|
|
||||||
|
## Backend Routes
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/api/plugins/folder_library/tree` | Returns the folder tree. Accepts optional filter query params (below) applied server-side. |
|
||||||
|
| POST | `/api/plugins/folder_library/folder/create` | Body: `{name, parent?}` — creates a subfolder; `parent` (slash path) nests it inside an existing folder, omit/empty for top level |
|
||||||
|
| POST | `/api/plugins/folder_library/folder/rename` | Body: `{old, new}` — `old` is a slash path, `new` is a bare name; renames within the same parent |
|
||||||
|
| POST | `/api/plugins/folder_library/folder/delete` | Body: `{name}` (slash path) — moves all songs at any depth to the scan root, then removes the folder |
|
||||||
|
| POST | `/api/plugins/folder_library/song/move` | Body: `{filename, folder}` — moves a song to `folder` (slash path; empty = scan root / "Unsorted") |
|
||||||
|
|
||||||
|
### `/tree` filter query params
|
||||||
|
|
||||||
|
All optional, applied server-side over the cached full tree by `_apply_tree_filters()`. Comma-separated, case-insensitive:
|
||||||
|
|
||||||
|
- `arrangements_has`, `arrangements_lacks` — include/exclude by arrangement name
|
||||||
|
- `stems_has`, `stems_lacks` — include/exclude by stem name
|
||||||
|
- `has_lyrics` — `""` (any), `"1"`, or `"0"`
|
||||||
|
- `tunings` — comma-separated tuning names to include
|
||||||
|
|
||||||
|
The frontend forwards the host library's active filter params here (via `window.feedBackLibFilterParams()` when present, with `window.slopsmithLibFilterParams()` as a legacy fallback) so the Folders view can stay in sync with the main library filters, falling back to its own filter panel state otherwise.
|
||||||
|
|
||||||
|
### Path safety
|
||||||
|
|
||||||
|
`_safe_name()` rejects empty names, leading/trailing whitespace, the characters `\ / : * ? " < > |`, and `.`/`..`. `_safe_path()` applies `_safe_name()` to every slash-separated segment, so traversal (`..`) and absolute paths are rejected before any filesystem op. Always validate user-supplied folder paths through these before touching disk.
|
||||||
|
|
||||||
|
## Tree Shape
|
||||||
|
|
||||||
|
`/tree` returns:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"name": "CH",
|
||||||
|
"path": "CH",
|
||||||
|
"songs": [ /* song objects */ ],
|
||||||
|
"children": [
|
||||||
|
{ "name": "Live", "path": "CH/Live", "songs": [], "children": [] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"root_songs": [ /* songs sitting directly in the scan root — shown as "Unsorted" */ ]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Folder nodes are **recursive**: each has `name`, `path` (slash-separated, relative to the scan root), `songs`, and `children`. The frontend renders any depth — `_findFolderByPath()`, `_countDeep()`, and `_countFoldersDeep()` walk the `children` arrays.
|
||||||
|
|
||||||
|
## Song Metadata Format
|
||||||
|
|
||||||
|
Each song object (built by `_meta()`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"filename": "sloppak/CH/Artist - Title.sloppak",
|
||||||
|
"title": "Title",
|
||||||
|
"artist": "Artist",
|
||||||
|
"album": "Album Name",
|
||||||
|
"duration": 213.5,
|
||||||
|
"year": 1993,
|
||||||
|
"tuning": "E Standard",
|
||||||
|
"added": 1748132400.0,
|
||||||
|
"arrangements": ["Lead", "Rhythm", "Bass"],
|
||||||
|
"stems": ["Drums", "Bass", "Vocals"],
|
||||||
|
"lyrics": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `filename` is the full relative path from the DLC root — pass it directly to `window.playSong()`.
|
||||||
|
- `added` is a Unix timestamp (float, seconds) from `stat().st_mtime` — convert with `new Date(added * 1000)`. Always recomputed fresh (it changes when a file moves), even on a metadata-cache hit.
|
||||||
|
- `arrangements` / `stems` are flat lists of **strings**, even though `extract_meta()` returns them as objects.
|
||||||
|
|
||||||
|
### extract_meta returns arrangements/stems as objects, not strings
|
||||||
|
|
||||||
|
`context["extract_meta"]()` returns arrangements as a list of objects `{index, name, notes}`, not plain strings; stems similarly. `_meta()` normalizes to `.name`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
raw_arr = raw.get("arrangements") or []
|
||||||
|
m["arrangements"] = [
|
||||||
|
a["name"] if isinstance(a, dict) else str(a)
|
||||||
|
for a in raw_arr
|
||||||
|
if (isinstance(a, dict) and "name" in a) or isinstance(a, str)
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
`lyrics` is coerced to a bool from several possible keys (`lyrics`, `hasLyrics`, `has_lyrics`, …). If you add new metadata fields from `extract_meta`, check the raw shape before assuming it's a plain value.
|
||||||
|
|
||||||
|
## Two-Level Cache
|
||||||
|
|
||||||
|
`routes.py` keeps two caches inside `setup()`:
|
||||||
|
|
||||||
|
- **`_meta_cache`** — expensive `extract_meta()` results keyed by absolute POSIX path. **Never cleared.** When files move (rename/delete/move), the keys are rewritten in-place so the warm data survives the operation.
|
||||||
|
- **`_cache`** — the assembled tree structure (`folders` / `root_songs`). Cleared by `_invalidate()` on **every** mutation so the next `/tree` rebuilds it — but the rebuild is fast because `_meta_cache` is still warm.
|
||||||
|
|
||||||
|
`filename` and `added` are deliberately **not** stored in `_meta_cache` (they depend on the file's current location) — they're recomputed on every `_meta()` call and merged onto the cached copy. When you add a mutation route, mirror the existing key-rewrite logic (see `rename_folder`, `delete_folder`, `move_song`) so the metadata cache stays valid.
|
||||||
|
|
||||||
|
## Folder Scan Logic
|
||||||
|
|
||||||
|
`routes.py` scans recursively starting at `<dlc>/sloppak/` (or `<dlc>/` if no `sloppak` subdir exists):
|
||||||
|
- Files/dirs matching `.sloppak` or `.feedpak` → song entries (root-level ones go to `root_songs`, shown as "Unsorted")
|
||||||
|
- Subdirectories → recursive folder nodes with their own `songs` + `children`
|
||||||
|
- Dot-prefixed entries are skipped; empty folders are still included (shown with a 0 count)
|
||||||
|
|
||||||
|
To add more grouping options (by artist, album, etc.), build an alternative projection over the scanned songs rather than the on-disk tree.
|
||||||
|
|
||||||
|
## Library provider (future, not implemented)
|
||||||
|
|
||||||
|
This plugin surfaces folders as a dedicated **view** over the existing library;
|
||||||
|
it does not (yet) register itself as a selectable library **source/provider**.
|
||||||
|
If you want a "Folders" entry to appear in the host's main library-source
|
||||||
|
picker (mapping top-level folder → "artist", subfolder → "album"), implement a
|
||||||
|
provider exposing the source-aware contract (`query_page`, `query_artists`,
|
||||||
|
`query_stats`, `tuning_names`) and register it in `setup()` via
|
||||||
|
`context["register_library_provider"](...)`, unregistering on teardown. (An
|
||||||
|
earlier inert `FolderLibraryProvider` scaffold was removed — it was never wired
|
||||||
|
and only duplicated the scan logic; re-add it only alongside real registration
|
||||||
|
and tests.)
|
||||||
|
|
||||||
|
## View Modes (List / Grid)
|
||||||
|
|
||||||
|
The toolbar has a list/grid toggle. Current view is stored in `localStorage` under `fo:view` (`'list'` or `'grid'`).
|
||||||
|
|
||||||
|
- **List view** — `_songRow()`, rendered inside a `ml-5 space-y-0` div
|
||||||
|
- **Grid view** — `_songCard()`, rendered inside a CSS grid div (`auto-fill, minmax(150px,1fr)`)
|
||||||
|
- Both the folder and unsorted section renderers branch on `_view` to pick the right renderer and container
|
||||||
|
- Album art is fetched via `/api/song/<encoded-path>/art` where each path segment is individually `encodeURIComponent`-encoded. On error the `<img>` is hidden and a placeholder SVG is shown
|
||||||
|
- The collapse/expand toggle restores `display:grid` (not just `display:''`) when reopening a folder in grid mode — always check this when changing toggle logic
|
||||||
|
|
||||||
|
### Lazy folder rendering
|
||||||
|
|
||||||
|
Folders do **not** render their song list on initial load. The folder renderer sets a `_listPopulated` flag and only populates the list the first time a folder is opened, keeping the initial render fast with large libraries. When search is active all folders are forced open and populated immediately (search overrides lazy loading).
|
||||||
|
|
||||||
|
## Sort System
|
||||||
|
|
||||||
|
The toolbar has a sort select (`#fb-sort`) and a direction toggle (`#fb-sort-dir`). State is stored under `fo:sort` and `fo:sortDir`.
|
||||||
|
|
||||||
|
- `_sort` — `'default' | 'title' | 'artist' | 'duration' | 'year' | 'tuning' | 'added'`
|
||||||
|
- `_sortDir` — `'asc' | 'desc'`
|
||||||
|
- `_sortSongs(songs)` returns a sorted copy; direction is applied by reversing after sort. Returns the array unchanged when `_sort === 'default'`.
|
||||||
|
- The sort direction button is dimmed (`opacity: 0.35`) and non-interactive when sort is `'default'`.
|
||||||
|
|
||||||
|
## Filter System
|
||||||
|
|
||||||
|
Client-side filters are stored under `fo:filters` as a JSON object. (The server `/tree` endpoint can also filter — see Backend Routes — used to sync with the host library.)
|
||||||
|
|
||||||
|
### Filter state shape
|
||||||
|
|
||||||
|
```js
|
||||||
|
_filters = {
|
||||||
|
arrangements: { Lead: 'on', Bass: 'exclude', Rhythm: 'off' },
|
||||||
|
stems: { Drums: 'off' },
|
||||||
|
lyrics: 'off', // 'off' | 'on' | 'exclude'
|
||||||
|
tunings: ['E Standard', 'Eb Standard'],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Each arrangement/stem value is `'off' | 'on' | 'exclude'`.
|
||||||
|
|
||||||
|
### Include vs exclude logic
|
||||||
|
|
||||||
|
`_matchFilters(song)` uses **OR logic for includes, AND logic for excludes**:
|
||||||
|
|
||||||
|
- **Include (`'on'`)** — song passes if it has *at least one* selected arrangement/stem. More includes widens the result set.
|
||||||
|
- **Exclude (`'exclude'`)** — each excluded tag independently removes songs that have it. More excludes narrows the result set.
|
||||||
|
|
||||||
|
This matches standard multi-select filter UX (Spotify/library style).
|
||||||
|
|
||||||
|
### Data-driven filter panel
|
||||||
|
|
||||||
|
All filter sections are built from the actual library data — nothing is hardcoded:
|
||||||
|
|
||||||
|
- `_getArrangements()` — unique arrangement names sorted by frequency (most common first), then alphabetically
|
||||||
|
- `_getStems()` — same pattern for stem names
|
||||||
|
- `_getAvailableFilters()` — returns `{ arrangements, stems, lyrics, tuning }` booleans gating the lyrics/tuning sections
|
||||||
|
|
||||||
|
Non-standard arrangement names (e.g. `"Bonus"`) appear as pills automatically — no constants to update. The stems section only appears if at least one song has stems data.
|
||||||
|
|
||||||
|
### Split pill UI
|
||||||
|
|
||||||
|
`_makeSplitPill(label, state, onChange)` renders a two-zone pill:
|
||||||
|
- Left zone (label) — toggles `'off' ↔ 'on'` (include, blue)
|
||||||
|
- Right zone (`✕`) — toggles `'off' ↔ 'exclude'` (exclude, red)
|
||||||
|
|
||||||
|
The filter badge (`#fb-filter-badge`) shows the active filter count via `_activeFilterCount()`.
|
||||||
|
|
||||||
|
## Hover Badges
|
||||||
|
|
||||||
|
Each song row/card has two hidden hover-reveal layers, built once and toggled via CSS `max-height` + `opacity` transitions.
|
||||||
|
|
||||||
|
### `_badge(text, active, type)`
|
||||||
|
|
||||||
|
Renders a single metadata badge. Type controls the inactive colour:
|
||||||
|
|
||||||
|
| type | inactive border | inactive text |
|
||||||
|
|---|---|---|
|
||||||
|
| `'arrangement'` | amber `#92400e` | amber `#fcd34d` |
|
||||||
|
| `'stem'` | violet `#5b21b6` | violet `#c4b5fd` |
|
||||||
|
| `'lyrics'` | rose `#9f1239` | rose `#fda4af` |
|
||||||
|
| `'tuning'` | teal `#0f766e` | teal `#5eead4` |
|
||||||
|
|
||||||
|
Active state is always blue (`#1d4ed8` fill, `#3b82f6` border, white text) regardless of type.
|
||||||
|
|
||||||
|
### `_buildSongBadges(song)`
|
||||||
|
|
||||||
|
Builds the badge row (arrangements, stems, lyrics, tuning), deduplicating within each category. Clicking a badge toggles that filter on/off and re-renders. Returns `null` if the song has no filterable metadata.
|
||||||
|
|
||||||
|
### `_buildSongDateInfo(song)`
|
||||||
|
|
||||||
|
Builds a separate plain-text hover line showing `year · date added` (e.g. `1993 · 24 May 2026`), `#cbd5e1` text. Always shown on hover regardless of filter state.
|
||||||
|
|
||||||
|
### Reveal / hide
|
||||||
|
|
||||||
|
```js
|
||||||
|
_revealBadges(el) // max-height:120px, opacity:1, margin-top:4px
|
||||||
|
_hideBadges(el) // max-height:0, opacity:0, margin-top:0
|
||||||
|
```
|
||||||
|
|
||||||
|
Both badge layers (badges + date-info) are wired to the same `mouseenter`/`mouseleave` events on the row or card element.
|
||||||
|
|
||||||
|
## Drag-and-Drop
|
||||||
|
|
||||||
|
Drag-and-drop uses **pointer events** (mousedown/mousemove/mouseup), not the HTML5 DnD API. HTML5 DnD blocks wheel events and gives unreliable edge positions inside Electron — pointer events give full control.
|
||||||
|
|
||||||
|
- `_makeDraggable(el, song, folderName)` — attaches a `mousedown` listener. A drag goes "live" only after the pointer moves more than `_DRAG_THRESH` (5 px), preventing accidental drags on clicks.
|
||||||
|
- Once live, a ghost `div` follows the cursor. Auto-scroll activates when the pointer is within `_DRAG_ZONE` (150 px) of the viewport top/bottom.
|
||||||
|
- `_makeDropTarget(el, targetFolder)` — sets `data-dropFolder` so an element can receive drops. Both folder headers and song-list containers are drop targets — including **nested** folders (drop onto a subfolder header moves the song there).
|
||||||
|
- `_dragFindTarget(x, y)` — uses `document.elementsFromPoint` to find the topmost element with `data-dropFolder` under the cursor.
|
||||||
|
- **Esc to cancel** — `_onDragKeyDown` calls `_endPointerDrag()` on `Escape`, removing the ghost and clearing state without dropping.
|
||||||
|
- On a successful drop, `_executeDrop()` does an **optimistic UI update** (moves the song in the in-memory tree and re-renders) then calls `/song/move`. On API failure it reloads the full tree.
|
||||||
|
- A one-time `click` capture listener after mouseup suppresses the post-drag click so it doesn't trigger playback.
|
||||||
|
|
||||||
|
## Modal Behaviour
|
||||||
|
|
||||||
|
`_showModal(msg, withInput, defaultVal)` is the custom modal used for all prompts and confirms (Electron blocks `window.prompt()` / `window.confirm()`). It returns a Promise.
|
||||||
|
|
||||||
|
- `_confirm(msg)` — resolves `true` on OK, `null` on cancel
|
||||||
|
- `_prompt(msg, default)` — resolves the trimmed input string on OK, `null` on cancel
|
||||||
|
- **Esc cancels** — resolves with `null`, same as Cancel (applies to rename, delete, create folder/subfolder, move song)
|
||||||
|
- **Enter confirms** — submits, equivalent to OK
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
Implemented since the original release: **nested subfolders** (recursive tree + create-inside-folder), drag-and-drop, sort, advanced filtering, server-side tree filtering synced to the host library, and the warm metadata cache.
|
||||||
|
|
||||||
|
Not yet implemented, in rough priority order:
|
||||||
|
|
||||||
|
- **Auto-play on hover** — with an on/off toggle saved to localStorage.
|
||||||
|
- **Bulk move** — multi-select songs and move them all at once.
|
||||||
|
- **Thumbnail performance** — faster loading and smoother scrolling with large libraries.
|
||||||
|
- **Adjustable thumbnail/row sizes** — user-resizable song cards and list rows.
|
||||||
|
- **Custom themes** — switchable colour schemes.
|
||||||
|
- **Favoriting songs** — likely a new backend route plus a `fo:favorites` localStorage key.
|
||||||
|
- **Editing song metadata** — edit title, artist, album etc. in-plugin; needs new backend write routes.
|
||||||
|
- **Folders as a library source** — register a library provider so a "Folders" entry appears in the host's main library-source picker (see "Library provider (future)" above).
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# Folder Library — FeedBack Plugin
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
A FeedBack (fee[dB]ack) plugin that organizes your `.sloppak` / `.feedpak` DLC songs into a folder tree, grouped by the folders on disk. Browse your whole library visually with album art, nest folders as deep as you like, switch between list and grid layouts, and manage folders without ever leaving the app.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|

|
||||||
|
*Grid view — album art cards with title and artist*
|
||||||
|
|
||||||
|

|
||||||
|
*Live search filters instantly across all folders*
|
||||||
|
|
||||||
|

|
||||||
|
*List view — compact rows with album art thumbnails and duration*
|
||||||
|
|
||||||
|

|
||||||
|
*Create and manage folders directly in the UI*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **Status — migrating to core.** Folder Library is being reworked from a standalone plugin into a bundled core plugin, and several previously-shipped features are not currently wired up in core (see the Roadmap). The list below reflects what works today; if something here is wrong, it's because this rework is still in progress.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **List & Grid views** — toggle between a compact list with thumbnails or a full album art card grid
|
||||||
|
- **Album art** — pulls art automatically for every song in both views
|
||||||
|
- **One-click playback** — click any song to start playing immediately
|
||||||
|
- **Sort options** — sort songs by title, artist, duration, year, tuning, or recently added with an asc/desc toggle
|
||||||
|
- **Advanced filters** — filter by arrangements, stems, lyrics, and tuning with include and exclude support
|
||||||
|
- **Folder management** — create, rename, and delete folders without leaving the plugin
|
||||||
|
- **Nested subfolders** — organize as deep as you want; create a subfolder inside any folder, expand/collapse a whole branch in one click
|
||||||
|
- **Collapsible folders** — expand/collapse individual folders, plus Expand All / Collapse All
|
||||||
|
- **Move songs** — reassign any song to a different folder on the fly; press `Esc` to cancel
|
||||||
|
- **Drag-and-drop** — drag songs between folders (including into nested folders) with smooth auto-scroll; press `Esc` to cancel
|
||||||
|
- **Fast with big libraries** — folder song lists render lazily and metadata is cached so reopening folders is instant
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Folder Library ships bundled with FeedBack as a core plugin (`"bundled": true`), so there's nothing to install — the **Folders** screen appears in the navbar under **Plugins** automatically.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
| Action | How |
|
||||||
|
|--------|-----|
|
||||||
|
| Switch to grid view | Click the grid icon in the toolbar |
|
||||||
|
| Switch to list view | Click the list icon in the toolbar |
|
||||||
|
| Play a song | Click any song row or card |
|
||||||
|
| Sort songs | Use the sort dropdown in the toolbar |
|
||||||
|
| Toggle sort direction | Click the arrow button next to the sort dropdown |
|
||||||
|
| Open filters | Click the filter icon in the toolbar |
|
||||||
|
| Filter by arrangement/stem | Open filters → click a pill to include; click `✕` to exclude |
|
||||||
|
| Clear all filters | Open filters → click "Clear all" |
|
||||||
|
| Create a folder | Click the folder+ icon in the toolbar |
|
||||||
|
| Create a subfolder | Hover a folder header → click the new-subfolder icon |
|
||||||
|
| Rename a folder | Hover the folder header → click the pencil icon |
|
||||||
|
| Delete a folder | Hover the folder header → click the trash icon (songs move up to Unsorted) |
|
||||||
|
| Move a song | Hover the song row → click the folder icon |
|
||||||
|
| Drag a song to a folder | Click and hold a song → drag to a folder header or body (nested folders work too) |
|
||||||
|
| Cancel a drag | Press `Esc` while holding a song |
|
||||||
|
| Cancel a move dialog | Press `Esc` in the move prompt |
|
||||||
|
| Expand / collapse a folder | Click the folder header |
|
||||||
|
| Expand / collapse all subfolders | Use the expand/collapse-children buttons on a folder with subfolders |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
Folder Library started life as a standalone plugin with its own version line, but it's now a **bundled core plugin** that ships with FeedBack. Its changes are tracked alongside the app in the repo-root [CHANGELOG.md](../../CHANGELOG.md), and it versions with the app rather than on its own. The **Features** section above reflects what's in the current build.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
- [ ] Auto play song on hover (with an on/off toggle)
|
||||||
|
- [ ] Bulk move — select multiple songs and move them at once
|
||||||
|
- [ ] Thumbnail performance — faster loading and smoother scrolling with large song libraries
|
||||||
|
- [ ] Adjustable thumbnail and row sizes — resize song cards and list rows to suit your preference
|
||||||
|
- [ ] Custom themes — switch between colour schemes to match your style
|
||||||
|
- [ ] Favoriting songs
|
||||||
|
- [ ] Editing song metadata
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Pull requests are welcome. For major changes please open an issue first to discuss what you'd like to change.
|
||||||
|
|
||||||
|
1. Fork the repo
|
||||||
|
2. Create a feature branch (`git checkout -b feature/your-feature`)
|
||||||
|
3. Commit your changes
|
||||||
|
4. Push to the branch and open a pull request
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"id": "folder_library",
|
||||||
|
"name": "Folder Library",
|
||||||
|
"version": "1.8.0",
|
||||||
|
"bundled": true,
|
||||||
|
"nav": { "label": "Folders", "screen": "plugin-folder_library" },
|
||||||
|
"screen": "screen.html",
|
||||||
|
"script": "screen.js",
|
||||||
|
"routes": "routes.py"
|
||||||
|
}
|
||||||
@@ -0,0 +1,440 @@
|
|||||||
|
"""
|
||||||
|
Folder Library plugin — routes.py
|
||||||
|
|
||||||
|
Surfaces the DLC folder structure as a navigable tree and provides in-app
|
||||||
|
folder management (create / rename / delete) and song moves. Every filesystem
|
||||||
|
mutation is confined to DLC_DIR and validated against path traversal.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
import shutil
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
# ── Pure, testable helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_UNSAFE_NAME_RE = re.compile(r'[\\/:*?"<>|]')
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_name(name: str) -> bool:
|
||||||
|
"""A single path segment is safe: no separators, no traversal dot-names,
|
||||||
|
no surrounding whitespace, no characters illegal across filesystems."""
|
||||||
|
if not name or name.strip() != name:
|
||||||
|
return False
|
||||||
|
if _UNSAFE_NAME_RE.search(name):
|
||||||
|
return False
|
||||||
|
if name in (".", ".."):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_path(path_str: str) -> bool:
|
||||||
|
"""A slash-separated path is safe iff every segment is a safe name."""
|
||||||
|
if not path_str:
|
||||||
|
return False
|
||||||
|
return all(_safe_name(p) for p in path_str.split("/"))
|
||||||
|
|
||||||
|
|
||||||
|
def _is_within(root: Path, candidate: Path) -> bool:
|
||||||
|
"""True iff ``candidate`` resolves to a location inside ``root`` (after
|
||||||
|
normalising ``..`` and symlinks). Containment backstop for file moves so a
|
||||||
|
crafted filename can't escape DLC_DIR even past the segment validator."""
|
||||||
|
try:
|
||||||
|
candidate.resolve().relative_to(root.resolve())
|
||||||
|
return True
|
||||||
|
except (ValueError, OSError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _path_to_dir(root: Path, folder_path: str) -> Path:
|
||||||
|
"""Resolve a slash-separated folder path relative to ``root``."""
|
||||||
|
result = root
|
||||||
|
for part in folder_path.split("/"):
|
||||||
|
result = result / part
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _load_is_loose_song():
|
||||||
|
"""The host's authoritative loose-folder predicate (lib/loosefolder.py),
|
||||||
|
imported lazily so the plugin still loads if it's ever unavailable. A
|
||||||
|
loose-folder song is a directory carrying audio + an arrangement XML rather
|
||||||
|
than a ``.sloppak`` bundle, so the plain suffix check below misses it."""
|
||||||
|
try:
|
||||||
|
from loosefolder import is_loose_song
|
||||||
|
return is_loose_song
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
_IS_LOOSE_SONG = _load_is_loose_song()
|
||||||
|
|
||||||
|
|
||||||
|
def _is_song(p: Path) -> bool:
|
||||||
|
"""A song carrier is a ``.sloppak`` / ``.feedpak`` file or directory-form
|
||||||
|
bundle (extension on the leaf name), or a host-recognised loose-folder song
|
||||||
|
directory — so loose-folder charts surface in the tree like any other song
|
||||||
|
instead of being walked into as if they were ordinary folders."""
|
||||||
|
if p.suffix.lower() in (".sloppak", ".feedpak"):
|
||||||
|
return True
|
||||||
|
if _IS_LOOSE_SONG is not None and p.is_dir():
|
||||||
|
try:
|
||||||
|
return bool(_IS_LOOSE_SONG(p))
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def setup(app, context):
|
||||||
|
log = context["log"]
|
||||||
|
router = APIRouter(prefix="/api/plugins/folder_library")
|
||||||
|
|
||||||
|
# ── Two-level cache ────────────────────────────────────────────────
|
||||||
|
# _meta_cache — expensive extract_meta() results keyed by abs path
|
||||||
|
# (as_posix() string). Never cleared; keys are updated
|
||||||
|
# in-place when files are moved so the data stays valid.
|
||||||
|
# _cache — tree structure ("folders" / "root_songs"). Cleared on
|
||||||
|
# every mutation so the next /tree request rebuilds it —
|
||||||
|
# but that rebuild is now fast because _meta_cache is warm.
|
||||||
|
_cache = {} # "tree" → JSONResponse-ready dict
|
||||||
|
_meta_cache = {} # abs_posix_path → extracted meta (no filename/added)
|
||||||
|
|
||||||
|
def _invalidate():
|
||||||
|
"""Clear the tree structure cache only. _meta_cache is preserved."""
|
||||||
|
_cache.clear()
|
||||||
|
|
||||||
|
def _dlc_root() -> Path | None:
|
||||||
|
try:
|
||||||
|
return Path(context["get_dlc_dir"]())
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _scan_root(dlc: Path) -> Path:
|
||||||
|
sloppak = dlc / "sloppak"
|
||||||
|
return sloppak if sloppak.exists() else dlc
|
||||||
|
|
||||||
|
def _meta(p: Path, dlc: Path) -> dict:
|
||||||
|
# filename and added are always computed fresh — they change when files move.
|
||||||
|
try:
|
||||||
|
filename = "/".join(p.relative_to(dlc).parts)
|
||||||
|
except ValueError:
|
||||||
|
filename = p.name
|
||||||
|
added = None
|
||||||
|
try:
|
||||||
|
added = p.stat().st_mtime
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Return cached extracted metadata if available.
|
||||||
|
cache_key = p.as_posix()
|
||||||
|
if cache_key in _meta_cache:
|
||||||
|
m = dict(_meta_cache[cache_key]) # shallow copy
|
||||||
|
m["filename"] = filename
|
||||||
|
m["added"] = added
|
||||||
|
return m
|
||||||
|
|
||||||
|
# Cache miss — run the expensive extract.
|
||||||
|
m = {"title": None, "artist": None, "album": None, "duration": None,
|
||||||
|
"year": None, "tuning": None, "arrangements": [], "stems": [], "lyrics": False}
|
||||||
|
try:
|
||||||
|
raw = context["extract_meta"](p)
|
||||||
|
if raw:
|
||||||
|
m["title"] = raw.get("title") or raw.get("name")
|
||||||
|
m["artist"] = raw.get("artist") or raw.get("artistName")
|
||||||
|
m["album"] = raw.get("album") or raw.get("albumName")
|
||||||
|
m["duration"] = raw.get("duration")
|
||||||
|
m["year"] = raw.get("year")
|
||||||
|
m["tuning"] = raw.get("tuning")
|
||||||
|
|
||||||
|
# arrangements — objects with a "name" key e.g. [{name:"Lead",...}, ...]
|
||||||
|
raw_arr = raw.get("arrangements") or []
|
||||||
|
if isinstance(raw_arr, (list, tuple)):
|
||||||
|
m["arrangements"] = [
|
||||||
|
a["name"] if isinstance(a, dict) else str(a)
|
||||||
|
for a in raw_arr
|
||||||
|
if (isinstance(a, dict) and "name" in a) or isinstance(a, str)
|
||||||
|
]
|
||||||
|
|
||||||
|
# stems — may also be objects with a "name" key, same as arrangements
|
||||||
|
raw_stems = raw.get("stems") or []
|
||||||
|
for _key in ("stems", "stem_types", "available_stems", "stemTypes"):
|
||||||
|
_v = raw.get(_key)
|
||||||
|
if _v:
|
||||||
|
raw_stems = _v
|
||||||
|
break
|
||||||
|
if isinstance(raw_stems, (list, tuple)):
|
||||||
|
m["stems"] = [
|
||||||
|
a["name"] if isinstance(a, dict) else str(a)
|
||||||
|
for a in raw_stems
|
||||||
|
if (isinstance(a, dict) and "name" in a) or isinstance(a, str)
|
||||||
|
]
|
||||||
|
|
||||||
|
# lyrics — try common key variants
|
||||||
|
for _key in ("lyrics", "hasLyrics", "has_lyrics", "lyric", "hasLyric"):
|
||||||
|
_val = raw.get(_key)
|
||||||
|
if _val is not None:
|
||||||
|
if isinstance(_val, str):
|
||||||
|
m["lyrics"] = _val.lower() not in ("", "false", "no", "0")
|
||||||
|
else:
|
||||||
|
m["lyrics"] = bool(_val)
|
||||||
|
break
|
||||||
|
except Exception as exc:
|
||||||
|
log.debug("meta failed for %s: %s", p.name, exc)
|
||||||
|
if not m["title"]:
|
||||||
|
m["title"] = p.stem
|
||||||
|
|
||||||
|
_meta_cache[cache_key] = m # store without filename/added
|
||||||
|
result = dict(m)
|
||||||
|
result["filename"] = filename
|
||||||
|
result["added"] = added
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _scan_dir(path: Path, root: Path, dlc: Path) -> dict:
|
||||||
|
"""Recursively scan a directory and return a folder node."""
|
||||||
|
songs = []
|
||||||
|
children = []
|
||||||
|
try:
|
||||||
|
for entry in sorted(path.iterdir(), key=lambda p: p.name.lower()):
|
||||||
|
if entry.name.startswith("."):
|
||||||
|
continue
|
||||||
|
if _is_song(entry):
|
||||||
|
songs.append(_meta(entry, dlc))
|
||||||
|
elif entry.is_dir():
|
||||||
|
children.append(_scan_dir(entry, root, dlc))
|
||||||
|
except PermissionError:
|
||||||
|
log.warning("permission denied: %s", path)
|
||||||
|
try:
|
||||||
|
rel = path.relative_to(root)
|
||||||
|
folder_path = "/".join(rel.parts)
|
||||||
|
except ValueError:
|
||||||
|
folder_path = path.name
|
||||||
|
return {
|
||||||
|
"name": path.name,
|
||||||
|
"path": folder_path,
|
||||||
|
"songs": songs,
|
||||||
|
"children": children,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _apply_tree_filters(tree, arrangements_has="", arrangements_lacks="",
|
||||||
|
stems_has="", stems_lacks="", has_lyrics="", tunings=""):
|
||||||
|
"""Filter a cached tree dict by arrangement/stem/lyrics/tuning params.
|
||||||
|
The cache always holds the full unfiltered tree; this is applied per-request."""
|
||||||
|
def _split(s):
|
||||||
|
return [x.strip().lower() for x in s.split(",") if x.strip()] if s else []
|
||||||
|
|
||||||
|
arr_has = _split(arrangements_has)
|
||||||
|
arr_lacks = _split(arrangements_lacks)
|
||||||
|
st_has = _split(stems_has)
|
||||||
|
st_lacks = _split(stems_lacks)
|
||||||
|
tun_set = set(_split(tunings))
|
||||||
|
lyr = None if has_lyrics == "" else (has_lyrics == "1")
|
||||||
|
|
||||||
|
if not any([arr_has, arr_lacks, st_has, st_lacks, tun_set, lyr is not None]):
|
||||||
|
return tree # no filters active — return as-is
|
||||||
|
|
||||||
|
def _song_ok(s):
|
||||||
|
arrs = [a.lower() for a in (s.get("arrangements") or [])]
|
||||||
|
stms = [x.lower() for x in (s.get("stems") or [])]
|
||||||
|
if arr_has and not any(a in arrs for a in arr_has): return False
|
||||||
|
if arr_lacks and any(a in arrs for a in arr_lacks): return False
|
||||||
|
if st_has and not any(x in stms for x in st_has): return False
|
||||||
|
if st_lacks and any(x in stms for x in st_lacks): return False
|
||||||
|
if lyr is not None and bool(s.get("lyrics")) != lyr: return False
|
||||||
|
if tun_set and (s.get("tuning") or "").lower() not in tun_set: return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _filter_node(node):
|
||||||
|
return {
|
||||||
|
"name": node["name"],
|
||||||
|
"path": node["path"],
|
||||||
|
"songs": [s for s in node["songs"] if _song_ok(s)],
|
||||||
|
"children": [_filter_node(c) for c in node.get("children", [])],
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"folders": [_filter_node(f) for f in tree["folders"]],
|
||||||
|
"root_songs": [s for s in tree["root_songs"] if _song_ok(s)],
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.get("/tree")
|
||||||
|
def get_tree(
|
||||||
|
arrangements_has: str = "",
|
||||||
|
arrangements_lacks: str = "",
|
||||||
|
stems_has: str = "",
|
||||||
|
stems_lacks: str = "",
|
||||||
|
has_lyrics: str = "",
|
||||||
|
tunings: str = "",
|
||||||
|
):
|
||||||
|
if "tree" not in _cache:
|
||||||
|
dlc = _dlc_root()
|
||||||
|
if not dlc or not dlc.exists():
|
||||||
|
return JSONResponse({"folders": [], "root_songs": [],
|
||||||
|
"error": "DLC directory not found"})
|
||||||
|
root = _scan_root(dlc)
|
||||||
|
log.info("folder_library: scanning %s", root)
|
||||||
|
folders = []
|
||||||
|
root_songs = []
|
||||||
|
try:
|
||||||
|
for entry in sorted(root.iterdir(), key=lambda p: p.name.lower()):
|
||||||
|
if entry.name.startswith("."):
|
||||||
|
continue
|
||||||
|
if _is_song(entry):
|
||||||
|
root_songs.append(_meta(entry, dlc))
|
||||||
|
elif entry.is_dir():
|
||||||
|
folders.append(_scan_dir(entry, root, dlc))
|
||||||
|
except PermissionError:
|
||||||
|
return JSONResponse({"folders": [], "root_songs": [],
|
||||||
|
"error": "Permission denied"})
|
||||||
|
_cache["tree"] = {"folders": folders, "root_songs": root_songs}
|
||||||
|
|
||||||
|
result = _apply_tree_filters(
|
||||||
|
_cache["tree"], arrangements_has, arrangements_lacks,
|
||||||
|
stems_has, stems_lacks, has_lyrics, tunings,
|
||||||
|
)
|
||||||
|
return JSONResponse(result)
|
||||||
|
|
||||||
|
@router.post("/folder/create")
|
||||||
|
async def create_folder(request: Request):
|
||||||
|
body = await request.json()
|
||||||
|
name = (body.get("name") or "").strip()
|
||||||
|
parent = (body.get("parent") or "").strip()
|
||||||
|
if not _safe_name(name):
|
||||||
|
return JSONResponse({"error": "Invalid folder name"}, status_code=400)
|
||||||
|
if parent and not _safe_path(parent):
|
||||||
|
return JSONResponse({"error": "Invalid parent path"}, status_code=400)
|
||||||
|
dlc = _dlc_root()
|
||||||
|
if not dlc:
|
||||||
|
return JSONResponse({"error": "DLC dir not found"}, status_code=500)
|
||||||
|
root = _scan_root(dlc)
|
||||||
|
parent_dir = _path_to_dir(root, parent) if parent else root
|
||||||
|
if parent and not parent_dir.exists():
|
||||||
|
return JSONResponse({"error": "Parent folder not found"}, status_code=404)
|
||||||
|
target = parent_dir / name
|
||||||
|
if target.exists():
|
||||||
|
return JSONResponse({"error": "Folder already exists"}, status_code=400)
|
||||||
|
try:
|
||||||
|
target.mkdir(parents=False)
|
||||||
|
_invalidate()
|
||||||
|
return JSONResponse({"ok": True})
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse({"error": str(e)}, status_code=500)
|
||||||
|
|
||||||
|
@router.post("/folder/rename")
|
||||||
|
async def rename_folder(request: Request):
|
||||||
|
body = await request.json()
|
||||||
|
old = (body.get("old") or "").strip()
|
||||||
|
new = (body.get("new") or "").strip()
|
||||||
|
if not _safe_path(old) or not _safe_name(new):
|
||||||
|
return JSONResponse({"error": "Invalid folder name"}, status_code=400)
|
||||||
|
dlc = _dlc_root()
|
||||||
|
if not dlc:
|
||||||
|
return JSONResponse({"error": "DLC dir not found"}, status_code=500)
|
||||||
|
root = _scan_root(dlc)
|
||||||
|
src = _path_to_dir(root, old)
|
||||||
|
dst = src.parent / new # rename within the same parent
|
||||||
|
if not src.exists():
|
||||||
|
return JSONResponse({"error": "Folder not found"}, status_code=404)
|
||||||
|
if dst.exists():
|
||||||
|
return JSONResponse({"error": "Name already taken"}, status_code=400)
|
||||||
|
try:
|
||||||
|
# Pre-compute meta cache key updates (keys change because the
|
||||||
|
# folder path changes — all files under src get a new prefix).
|
||||||
|
old_prefix = src.as_posix() + "/"
|
||||||
|
new_prefix = dst.as_posix() + "/"
|
||||||
|
meta_updates = {
|
||||||
|
key: new_prefix + key[len(old_prefix):]
|
||||||
|
for key in list(_meta_cache)
|
||||||
|
if key.startswith(old_prefix)
|
||||||
|
}
|
||||||
|
src.rename(dst)
|
||||||
|
_invalidate()
|
||||||
|
for old_key, new_key in meta_updates.items():
|
||||||
|
if old_key in _meta_cache:
|
||||||
|
_meta_cache[new_key] = _meta_cache.pop(old_key)
|
||||||
|
return JSONResponse({"ok": True})
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse({"error": str(e)}, status_code=500)
|
||||||
|
|
||||||
|
@router.post("/folder/delete")
|
||||||
|
async def delete_folder(request: Request):
|
||||||
|
body = await request.json()
|
||||||
|
name = (body.get("name") or "").strip()
|
||||||
|
if not _safe_path(name):
|
||||||
|
return JSONResponse({"error": "Invalid folder path"}, status_code=400)
|
||||||
|
dlc = _dlc_root()
|
||||||
|
if not dlc:
|
||||||
|
return JSONResponse({"error": "DLC dir not found"}, status_code=500)
|
||||||
|
root = _scan_root(dlc)
|
||||||
|
target = _path_to_dir(root, name)
|
||||||
|
if not target.exists():
|
||||||
|
return JSONResponse({"error": "Folder not found"}, status_code=404)
|
||||||
|
try:
|
||||||
|
# Relocate every song (at any depth) up to the scan root BEFORE
|
||||||
|
# removing the folder. Colliding filenames are de-duplicated so a
|
||||||
|
# name clash never leaves a song behind to be destroyed by rmtree
|
||||||
|
# (the folder is advertised as "moves its songs to Unsorted").
|
||||||
|
for song_path in sorted(target.rglob("*")):
|
||||||
|
if not song_path.exists():
|
||||||
|
continue # a parent song-dir was already relocated
|
||||||
|
if not _is_song(song_path):
|
||||||
|
continue
|
||||||
|
old_key = song_path.as_posix()
|
||||||
|
dest = root / song_path.name
|
||||||
|
if dest.exists():
|
||||||
|
stem, suffix = song_path.stem, song_path.suffix
|
||||||
|
n = 1
|
||||||
|
while dest.exists():
|
||||||
|
dest = root / f"{stem} ({n}){suffix}"
|
||||||
|
n += 1
|
||||||
|
song_path.rename(dest)
|
||||||
|
if old_key in _meta_cache:
|
||||||
|
_meta_cache[dest.as_posix()] = _meta_cache.pop(old_key)
|
||||||
|
shutil.rmtree(target)
|
||||||
|
_invalidate()
|
||||||
|
return JSONResponse({"ok": True})
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse({"error": str(e)}, status_code=500)
|
||||||
|
|
||||||
|
@router.post("/song/move")
|
||||||
|
async def move_song(request: Request):
|
||||||
|
body = await request.json()
|
||||||
|
filename = (body.get("filename") or "").strip()
|
||||||
|
dest_folder = (body.get("folder") or "").strip()
|
||||||
|
# Validate the source path like the folder ops, AND confirm it resolves
|
||||||
|
# inside DLC_DIR — without this a filename such as "../../etc/passwd"
|
||||||
|
# would be renamed (moved) into the served library and become readable.
|
||||||
|
if not filename or not _safe_path(filename):
|
||||||
|
return JSONResponse({"error": "Invalid filename"}, status_code=400)
|
||||||
|
dlc = _dlc_root()
|
||||||
|
if not dlc:
|
||||||
|
return JSONResponse({"error": "DLC dir not found"}, status_code=500)
|
||||||
|
src = dlc / Path(*filename.split("/"))
|
||||||
|
if not _is_within(dlc, src):
|
||||||
|
return JSONResponse({"error": "Invalid filename"}, status_code=400)
|
||||||
|
if not src.exists():
|
||||||
|
return JSONResponse({"error": "Song not found"}, status_code=404)
|
||||||
|
root = _scan_root(dlc)
|
||||||
|
if dest_folder:
|
||||||
|
if not _safe_path(dest_folder):
|
||||||
|
return JSONResponse({"error": "Invalid folder path"}, status_code=400)
|
||||||
|
dst_dir = _path_to_dir(root, dest_folder)
|
||||||
|
if not dst_dir.exists():
|
||||||
|
return JSONResponse({"error": "Destination folder not found"}, status_code=404)
|
||||||
|
else:
|
||||||
|
dst_dir = root
|
||||||
|
dst = dst_dir / src.name
|
||||||
|
if dst.exists():
|
||||||
|
return JSONResponse({"error": "File already exists at destination"}, status_code=400)
|
||||||
|
try:
|
||||||
|
old_key = src.as_posix()
|
||||||
|
src.rename(dst)
|
||||||
|
if old_key in _meta_cache:
|
||||||
|
_meta_cache[dst.as_posix()] = _meta_cache.pop(old_key)
|
||||||
|
_invalidate()
|
||||||
|
return JSONResponse({"ok": True})
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse({"error": str(e)}, status_code=500)
|
||||||
|
|
||||||
|
app.include_router(router)
|
||||||
|
log.info("folder_library routes registered")
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
<!-- Folder Browser — screen.html
|
||||||
|
Slopsmith injects this into a div#plugin-folder_library.screen automatically.
|
||||||
|
Do NOT add an outer wrapper div with class="screen". -->
|
||||||
|
|
||||||
|
<!-- ── toolbar ──────────────────────────────────────────────────────── -->
|
||||||
|
<div class="flex items-center gap-2 px-4 py-3 border-b border-dark-400 flex-wrap"
|
||||||
|
style="position:fixed; top:64px; left:0; right:0; z-index:40; background-color:#0f1117; border-bottom: 1px solid #1f2937;">
|
||||||
|
|
||||||
|
<h2 class="text-base font-semibold text-white mr-1">Folders</h2>
|
||||||
|
|
||||||
|
<!-- search -->
|
||||||
|
<div class="relative flex-1 min-w-40 max-w-xs">
|
||||||
|
<svg class="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-500 pointer-events-none"
|
||||||
|
viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd"
|
||||||
|
d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
|
||||||
|
clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
<input id="fb-search" type="text" placeholder="Search songs…"
|
||||||
|
class="w-full pl-8 pr-3 py-1.5 rounded bg-dark-500 border border-dark-400
|
||||||
|
text-sm text-gray-200 placeholder-gray-500
|
||||||
|
focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- new folder -->
|
||||||
|
<button id="fb-new-folder" title="New parent folder"
|
||||||
|
class="p-1.5 rounded text-gray-400 hover:text-white hover:bg-dark-400 transition-colors">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
|
||||||
|
<path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"/>
|
||||||
|
<path fill-rule="evenodd" d="M10 9a1 1 0 011 1v1h1a1 1 0 110 2h-1v1a1 1 0 11-2 0v-1H8a1 1 0 110-2h1v-1a1 1 0 011-1z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- expand all -->
|
||||||
|
<button id="fb-expand-all" title="Expand all"
|
||||||
|
class="p-1.5 rounded text-gray-400 hover:text-white hover:bg-dark-400 transition-colors">
|
||||||
|
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8"
|
||||||
|
stroke-linecap="round" class="w-4 h-4">
|
||||||
|
<path d="M5 8l5 5 5-5"/>
|
||||||
|
<path d="M5 4l5 5 5-5" opacity=".4"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- collapse all -->
|
||||||
|
<button id="fb-collapse-all" title="Collapse all"
|
||||||
|
class="p-1.5 rounded text-gray-400 hover:text-white hover:bg-dark-400 transition-colors">
|
||||||
|
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8"
|
||||||
|
stroke-linecap="round" class="w-4 h-4">
|
||||||
|
<path d="M5 12l5-5 5 5"/>
|
||||||
|
<path d="M5 16l5-5 5 5" opacity=".4"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- sort -->
|
||||||
|
<select id="fb-sort" title="Sort songs within folders"
|
||||||
|
style="padding:4px 8px; border-radius:6px; border:1px solid #374151;
|
||||||
|
background:#1f2937; color:#d1d5db; font-size:12px; cursor:pointer; outline:none;">
|
||||||
|
<option value="default">Default</option>
|
||||||
|
<option value="title">Title</option>
|
||||||
|
<option value="artist">Artist</option>
|
||||||
|
<option value="duration">Duration</option>
|
||||||
|
<option value="year">Year</option>
|
||||||
|
<option value="tuning">Tuning</option>
|
||||||
|
<option value="added">Recently Added</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- sort direction -->
|
||||||
|
<button id="fb-sort-dir" title="Ascending"
|
||||||
|
class="p-1.5 rounded text-gray-400 hover:text-white hover:bg-dark-400 transition-colors">
|
||||||
|
<svg id="fb-sort-dir-icon" viewBox="0 0 20 20" fill="none" stroke="currentColor"
|
||||||
|
stroke-width="1.8" stroke-linecap="round" class="w-4 h-4">
|
||||||
|
<path d="M5 12l5-5 5 5"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- view toggle -->
|
||||||
|
<button id="fb-view-list" title="List view"
|
||||||
|
class="p-1.5 rounded text-gray-400 hover:text-white hover:bg-dark-400 transition-colors">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
|
||||||
|
<path fill-rule="evenodd"
|
||||||
|
d="M3 4a1 1 0 000 2h14a1 1 0 100-2H3zm0 4a1 1 0 000 2h14a1 1 0 100-2H3zm0 4a1 1 0 000 2h14a1 1 0 100-2H3z"
|
||||||
|
clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button id="fb-view-grid" title="Grid view"
|
||||||
|
class="p-1.5 rounded text-gray-400 hover:text-white hover:bg-dark-400 transition-colors">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
|
||||||
|
<path d="M5 3a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2V5a2 2 0 00-2-2H5zM5 11a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2v-2a2 2 0 00-2-2H5zM11 5a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V5zM11 13a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- filters -->
|
||||||
|
<button id="fb-filter" title="Filters"
|
||||||
|
class="p-1.5 rounded text-gray-400 hover:text-white hover:bg-dark-400 transition-colors"
|
||||||
|
style="position:relative;">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
|
||||||
|
<path fill-rule="evenodd"
|
||||||
|
d="M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z"
|
||||||
|
clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
<span id="fb-filter-badge"
|
||||||
|
style="display:none; position:absolute; top:-2px; right:-2px; min-width:14px; height:14px;
|
||||||
|
padding:0 3px; border-radius:7px; background:#3b82f6; color:#fff;
|
||||||
|
font-size:9px; font-weight:700; line-height:14px; text-align:center;
|
||||||
|
box-sizing:border-box;"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- reload -->
|
||||||
|
<button id="fb-reload" title="Reload"
|
||||||
|
class="p-1.5 rounded text-gray-400 hover:text-white hover:bg-dark-400 transition-colors">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
|
||||||
|
<path fill-rule="evenodd"
|
||||||
|
d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z"
|
||||||
|
clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span id="fb-status" class="text-xs text-gray-500 ml-1"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── tree ─────────────────────────────────────────────────────────── -->
|
||||||
|
<div id="fb-tree" class="px-2 py-2" style="padding-top: 120px;"></div>
|
||||||
|
|
||||||
|
<!-- ── filter backdrop ───────────────────────────────────────────────── -->
|
||||||
|
<div id="fb-filter-backdrop"
|
||||||
|
style="display:none; position:fixed; inset:0; z-index:44;"></div>
|
||||||
|
|
||||||
|
<!-- ── filter panel ──────────────────────────────────────────────────── -->
|
||||||
|
<div id="fb-filter-panel"
|
||||||
|
style="display:none; position:fixed; top:64px; right:0; bottom:0; width:300px;
|
||||||
|
z-index:45; background:#0f1117; border-left:1px solid #1f2937;
|
||||||
|
flex-direction:column; overflow:hidden;"></div>
|
||||||
|
|
||||||
|
<!-- ── custom modal ──────────────────────────────────────────────────── -->
|
||||||
|
<div id="fb-modal" style="display:none; position:fixed; inset:0; z-index:9999;
|
||||||
|
background:rgba(0,0,0,0.6); align-items:center; justify-content:center;">
|
||||||
|
<div style="background:#1e2130; border:1px solid #374151; border-radius:8px;
|
||||||
|
padding:24px; width:360px; max-width:90vw; box-shadow:0 20px 60px rgba(0,0,0,0.5);">
|
||||||
|
<p id="fb-modal-msg" style="color:#e5e7eb; font-size:14px; margin:0 0 16px 0;
|
||||||
|
white-space:pre-wrap; line-height:1.5;"></p>
|
||||||
|
<input id="fb-modal-input" type="text"
|
||||||
|
style="display:none; width:100%; box-sizing:border-box; padding:8px 12px;
|
||||||
|
background:#111827; border:1px solid #374151; border-radius:6px;
|
||||||
|
color:#e5e7eb; font-size:14px; outline:none; margin-bottom:16px;"
|
||||||
|
placeholder=""/>
|
||||||
|
<div style="display:flex; gap:8px; justify-content:flex-end;">
|
||||||
|
<button id="fb-modal-cancel"
|
||||||
|
style="padding:6px 16px; border-radius:6px; border:1px solid #374151;
|
||||||
|
background:transparent; color:#9ca3af; font-size:13px; cursor:pointer;">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button id="fb-modal-ok"
|
||||||
|
style="padding:6px 16px; border-radius:6px; border:none;
|
||||||
|
background:#3b82f6; color:#fff; font-size:13px; cursor:pointer; font-weight:500;">
|
||||||
|
OK
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
This guide tells future AI assistants where each visual element lives in `screen.js`, what controls it, and the gotchas to watch for. The goal is for small polishes (color tweaks, sizing, animation timing, add/remove a label) to land in the right place on the first try without grep spelunking.
|
This guide tells future AI assistants where each visual element lives in `screen.js`, what controls it, and the gotchas to watch for. The goal is for small polishes (color tweaks, sizing, animation timing, add/remove a label) to land in the right place on the first try without grep spelunking.
|
||||||
|
|
||||||
The whole renderer is **one file** — `screen.js`, wrapped in an IIFE, registered as `window.slopsmithViz_highway_3d` (a slopsmith#36 setRenderer factory). No imports beyond Three.js loaded from the vendored `/static/vendor/three/three.module.min.js` (pinned r170; swapped from CDN when bundled into core).
|
The whole renderer is **one file** — `screen.js`, wrapped in an IIFE, registered as `window.feedBackViz_highway_3d` (a feedBack#36 setRenderer factory). No imports beyond Three.js loaded from the vendored `/static/vendor/three/three.module.min.js` (pinned r170; swapped from CDN when bundled into core).
|
||||||
|
|
||||||
**Styling (slopsmith `styles` capability).** This plugin owns its Tailwind CSS: it ships `assets/plugin.css` and declares `"styles": "assets/plugin.css"` in `plugin.json`, so core's prebuilt `static/tailwind.min.css` no longer scans it (it's excluded from core's content globs). The frontend injects `assets/plugin.css` as a `<link>` when the renderer activates. This is the one maintainer-time build step: after you add/change a Tailwind class in `screen.js` or `settings.html`, run `bash build-tailwind.sh` (pinned `tailwindcss@3.4.19`, `corePlugins.preflight=false` — utilities only) and **bump the `version` in `plugin.json`** so the injected `<link>`'s `?v=` cache-buster fetches the fresh file. The generated `assets/plugin.css` is committed; end users never build. See [docs/plugin-styles.md](../../docs/plugin-styles.md).
|
**Styling (feedBack `styles` capability).** This plugin owns its Tailwind CSS: it ships `assets/plugin.css` and declares `"styles": "assets/plugin.css"` in `plugin.json`, so core's prebuilt `static/tailwind.min.css` no longer scans it (it's excluded from core's content globs). The frontend injects `assets/plugin.css` as a `<link>` when the renderer activates. This is the one maintainer-time build step: after you add/change a Tailwind class in `screen.js` or `settings.html`, run `bash build-tailwind.sh` (pinned `tailwindcss@3.4.19`, `corePlugins.preflight=false` — utilities only) and **bump the `version` in `plugin.json`** so the injected `<link>`'s `?v=` cache-buster fetches the fresh file. The generated `assets/plugin.css` is committed; end users never build. See [docs/plugin-styles.md](../../docs/plugin-styles.md).
|
||||||
|
|
||||||
> **Navigation note:** This guide references functions by name and uses the existing banner comments (`/* ── Scene initialisation ─ */`, etc.) as section anchors. Line numbers are deliberately avoided so this stays correct as the file evolves. Use `Grep` for the function name or banner text to jump to a section.
|
> **Navigation note:** This guide references functions by name and uses the existing banner comments (`/* ── Scene initialisation ─ */`, etc.) as section anchors. Line numbers are deliberately avoided so this stays correct as the file evolves. Use `Grep` for the function name or banner text to jump to a section.
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ The file is laid out top-to-bottom as:
|
|||||||
1. **Constants block** — palette (`S_COL`), scale (`SCALE`, `K`), fret/string counts, geometry sizes, camera, fog
|
1. **Constants block** — palette (`S_COL`), scale (`SCALE`, `K`), fret/string counts, geometry sizes, camera, fog
|
||||||
2. **Pure helpers** — `fretX`, `fretMid`, `dZ`, `computeBPM`
|
2. **Pure helpers** — `fretX`, `fretMid`, `dZ`, `computeBPM`
|
||||||
3. **Three.js loader** — `loadThree()` (loads vendored `/static/vendor/three/three.module.min.js`, memoized)
|
3. **Three.js loader** — `loadThree()` (loads vendored `/static/vendor/three/three.module.min.js`, memoized)
|
||||||
4. **Splitscreen helpers** — `_ssActive`, `_ssIsCanvasFocused` (read `window.slopsmithSplitscreen`)
|
4. **Splitscreen helpers** — `_ssActive`, `_ssIsCanvasFocused` (read `window.feedBackSplitscreen`)
|
||||||
5. **`createFactory()`** — the rest of the file is one big closure
|
5. **`createFactory()`** — the rest of the file is one big closure
|
||||||
- Per-instance state (Three.js refs, pools, camera state, lifecycle flags)
|
- Per-instance state (Three.js refs, pools, camera state, lifecycle flags)
|
||||||
- `txtMat()` text-sprite cache, `pool()` factory
|
- `txtMat()` text-sprite cache, `pool()` factory
|
||||||
@@ -63,7 +63,7 @@ Each entry names the function or banner you should grep for, plus key sub-blocks
|
|||||||
|
|
||||||
### Strings
|
### Strings
|
||||||
- **String colors** → `S_COL` array in the top-level constants block. Eight-element vibrant palette; index `s` is the string (0 = high E for guitar). `MAX_RENDER_STRINGS` keys off `S_COL.length`.
|
- **String colors** → `S_COL` array in the top-level constants block. Eight-element vibrant palette; index `s` is the string (0 = high E for guitar). `MAX_RENDER_STRINGS` keys off `S_COL.length`.
|
||||||
- **String count for the active arrangement** → `resolveStringCount(bundle)` (top-level helper). Reads `bundle.stringCount` (slopsmith#93) with a `bass`-name fallback. Don't reintroduce `tuning.length` — see Pitfall #4.
|
- **String count for the active arrangement** → `resolveStringCount(bundle)` (top-level helper). Reads `bundle.stringCount` (feedBack#93) with a `bass`-name fallback. Don't reintroduce `tuning.length` — see Pitfall #4.
|
||||||
- **String thickness / gap / base Y** → `STR_THICK`, `S_BASE`, `S_GAP` constants.
|
- **String thickness / gap / base Y** → `STR_THICK`, `S_BASE`, `S_GAP` constants.
|
||||||
- **String-to-Y mapping (respects invert)** → the `sY(s)` arrow function inside `createFactory()`. Single source of truth for "where on Y is string s."
|
- **String-to-Y mapping (respects invert)** → the `sY(s)` arrow function inside `createFactory()`. Single source of truth for "where on Y is string s."
|
||||||
- **Static string mesh creation** → `buildBoard()`, the `// Thin Line strings (glow layer)` and `// BoxGeometry strings — emissive glow ...` comment blocks. Two layers: low-opacity `Line` for soft glow, `BoxGeometry` mesh per string with its own material clone (kept in `stringLines[]` for live emissive updates).
|
- **Static string mesh creation** → `buildBoard()`, the `// Thin Line strings (glow layer)` and `// BoxGeometry strings — emissive glow ...` comment blocks. Two layers: low-opacity `Line` for soft glow, `BoxGeometry` mesh per string with its own material clone (kept in `stringLines[]` for live emissive updates).
|
||||||
@@ -87,7 +87,7 @@ Each entry names the function or banner you should grep for, plus key sub-blocks
|
|||||||
- **Technique markers** (bend, slide, hammer/pull/tap, accent, tremolo, palm-mute, pinch harmonic) → `// ── Technique labels ──` block in `drawNote()`. Most are small if-blocks using `txtMat(text, color, wide, style)` (cached sprite material; `'technique'` preset in `TXT_STYLES`). Exceptions: a **bend** draws a string-coloured chevron strength stack (`bendChevronMat`, one chevron per half-step), and **hammer-on / pull-off** draw a white ▲/▼ triangle with a string-coloured border (`triMat`) — both pinned to the gem; the bend ribbon's up→hold→down contour is driven by `bendSemisAtTime`.
|
- **Technique markers** (bend, slide, hammer/pull/tap, accent, tremolo, palm-mute, pinch harmonic) → `// ── Technique labels ──` block in `drawNote()`. Most are small if-blocks using `txtMat(text, color, wide, style)` (cached sprite material; `'technique'` preset in `TXT_STYLES`). Exceptions: a **bend** draws a string-coloured chevron strength stack (`bendChevronMat`, one chevron per half-step), and **hammer-on / pull-off** draw a white ▲/▼ triangle with a string-coloured border (`triMat`) — both pinned to the gem; the bend ribbon's up→hold→down contour is driven by `bendSemisAtTime`.
|
||||||
- **Open-string note** → special-cased throughout `drawNote()`: `n.f === 0`. Wider/flatter geometry, "0" label sprite, uses `openX` (the chord's open-string centroid) when supplied.
|
- **Open-string note** → special-cased throughout `drawNote()`: `n.f === 0`. Wider/flatter geometry, "0" label sprite, uses `openX` (the chord's open-string centroid) when supplied.
|
||||||
- **Board projection ("ghost" preview)** → `// ── Board projection ──` block in `drawNote()`. Two meshes per string (`projMeshArr`, `projGlowArr`), one visible per frame for the next note. Linger window `PROJ_WIN`. Gated on the `projectionVisible` setting (BG_DEFAULTS / `h3dBgSetProjectionVisible` / the "Show note preview on the fretboard" checkbox in `settings.html`) — when off, the block is skipped and `update()`'s per-frame `m.visible = false` reset leaves the ghost hidden. **The glow has `renderOrder = -1`** which fights the strings — see Pitfall #6.
|
- **Board projection ("ghost" preview)** → `// ── Board projection ──` block in `drawNote()`. Two meshes per string (`projMeshArr`, `projGlowArr`), one visible per frame for the next note. Linger window `PROJ_WIN`. Gated on the `projectionVisible` setting (BG_DEFAULTS / `h3dBgSetProjectionVisible` / the "Show note preview on the fretboard" checkbox in `settings.html`) — when off, the block is skipped and `update()`'s per-frame `m.visible = false` reset leaves the ghost hidden. **The glow has `renderOrder = -1`** which fights the strings — see Pitfall #6.
|
||||||
- **Note-hit "sizzle" (slopsmith#254)** → `drawNotedetectSizzle()` (called from the `lyricsCtx` block in `draw()`, just before `drawNotedetectLabels()`). For each confirmed hit/active note (`_ndGood` in `drawNote()` pushes `{x, y, z, s, alpha, color}` onto the per-frame `_ndSizzle` array — `alpha` is the provider's clamped fade, `color` an optional palette override), it projects the note's world point through the up-to-date `cam`, sizes the burst from a fretboard-X-axis offset projection (reliable even when the note's rotated flat at the line), and twinkles a few short crackling ellipse-arc segments + tiny dots hugging the note's rectangle — re-randomised every frame, contained to ≲1.4× the note, half white / half the string colour (or the provider's `color` when given). Every dot/arc's `globalAlpha` and `shadowBlur` are scaled by the entry's `alpha`, and the per-element "off-this-frame" probability rises as `alpha` decays, so a struck-note glow visibly thins and fades. Also: `_ndGood` swaps the note's outline to `mGlow[s]` (bright string-tinted, not green). Knobs are inline: arc/dot count, base on-probability, line widths, `shadowBlur`, spread radii. Lives entirely on the 2D overlay layer — no Three.js geometry/disposal.
|
- **Note-hit "sizzle" (feedBack#254)** → `drawNotedetectSizzle()` (called from the `lyricsCtx` block in `draw()`, just before `drawNotedetectLabels()`). For each confirmed hit/active note (`_ndGood` in `drawNote()` pushes `{x, y, z, s, alpha, color}` onto the per-frame `_ndSizzle` array — `alpha` is the provider's clamped fade, `color` an optional palette override), it projects the note's world point through the up-to-date `cam`, sizes the burst from a fretboard-X-axis offset projection (reliable even when the note's rotated flat at the line), and twinkles a few short crackling ellipse-arc segments + tiny dots hugging the note's rectangle — re-randomised every frame, contained to ≲1.4× the note, half white / half the string colour (or the provider's `color` when given). Every dot/arc's `globalAlpha` and `shadowBlur` are scaled by the entry's `alpha`, and the per-element "off-this-frame" probability rises as `alpha` decays, so a struck-note glow visibly thins and fades. Also: `_ndGood` swaps the note's outline to `mGlow[s]` (bright string-tinted, not green). Knobs are inline: arc/dot count, base on-probability, line widths, `shadowBlur`, spread radii. Lives entirely on the 2D overlay layer — no Three.js geometry/disposal.
|
||||||
|
|
||||||
### Chords
|
### Chords
|
||||||
- **Chord rendering loop** → `update()`, `// ── Chords ──` block. Iterates `bundle.chords`, calls `drawNote()` per chord-note, then draws the frame box, name label, and barre indicator.
|
- **Chord rendering loop** → `update()`, `// ── Chords ──` block. Iterates `bundle.chords`, calls `drawNote()` per chord-note, then draws the frame box, name label, and barre indicator.
|
||||||
@@ -108,9 +108,19 @@ Each entry names the function or banner you should grep for, plus key sub-blocks
|
|||||||
- **Beat lines** (downbeats highlighted) → `update()`, `// ── Beat lines ──` block. `mBeatM` (full opacity 0.25) for measure starts, `mBeatQ` (0.07) for other beats.
|
- **Beat lines** (downbeats highlighted) → `update()`, `// ── Beat lines ──` block. `mBeatM` (full opacity 0.25) for measure starts, `mBeatQ` (0.07) for other beats.
|
||||||
- **Section labels** → `update()`, `// ── Section labels ──` block. Cyan (`#00cccc`) sprite at fret 12, above the highest string.
|
- **Section labels** → `update()`, `// ── Section labels ──` block. Cyan (`#00cccc`) sprite at fret 12, above the highest string.
|
||||||
|
|
||||||
|
### Scene colors (two independent axes: Background + Highway)
|
||||||
|
- **Scene-color themes** → `BG_THEMES` table near the top of `createFactory()`. One combined table is the single source of truth, but it drives **two independent axes that share the same id-set**:
|
||||||
|
- **Background axis** — setting key `bgTheme`, setter `window.h3dBgSetBgTheme`, state `bgThemeId`. Owns `clear` (WebGL clear color) + `fog`.
|
||||||
|
- **Highway axis** — setting key `hwTheme`, setter `window.h3dBgSetHwTheme`, state `hwThemeId`. Owns `board` (fretboard/highway-surface plane) + optional `lane`/`laneDim` (the lit lane strip).
|
||||||
|
Any background id can mix with any highway id; picking the same id in both gives the original "matched" look. Per-axis accessors are `_bgBackgroundColors(id)` / `_bgHighwayColors(id)` (both alias `_bgThemeColors`). Both axes default to `'default'` (byte-identical to the original look).
|
||||||
|
- **Applying a theme** → `_applyBgTheme()`. Background half sets clear+fog from `bgThemeId` (skipped under the venue scene); highway half sets the board plane + lane materials (`mLaneOdd`/`mLaneEven`) from `hwThemeId`. Re-run on init, `buildBoard()`, and the settings listener (which fires for **both** `bgTheme` and `hwTheme`), so changing either dropdown retints only its half live.
|
||||||
|
- **Backward-compat migration** → `_bgLoadSettings()`: the first time it loads with no stored `hwTheme` (`_bgHasStored` false), it seeds `hwThemeId` from `bgThemeId` **and persists `hwTheme` once** (a one-time backfill, written without `_bgEmitChange`). So a pre-split single-`bgTheme` pick is byte-identical right after the upgrade, and from then on the two axes are fully independent — changing the Background dropdown never drags the Highway surface, and the settings UI's Highway value can't disagree with what's rendered. settings.html shows the same first-load value via `storedHwTheme == null ? bgTheme : coerceHwTheme(...)`.
|
||||||
|
- **Adding/removing a theme** → edit `BG_THEMES` (the colors) AND `settings.html`'s `SCENE_THEMES` array (the `{id,label}` list — the single source the two dropdowns' `<option>`s and the `VALID_BG_THEMES` validator are both generated from). Keep the two id-sets aligned.
|
||||||
|
|
||||||
### Highway lane (the highlighted strip under active frets)
|
### Highway lane (the highlighted strip under active frets)
|
||||||
- **Lane drawing** → `update()`, `// ── Dynamic highway lane ──` block. `pLane` is a single quad on the fretboard plane; `pLaneDivider` is thin vertical lines at each fret inside the lane. Width keys off the active-fret range; min width ≈ 4 frets.
|
- **Lane drawing** → `update()`, `// ── Dynamic highway lane ──` block. `pLane` is a single quad on the fretboard plane; `pLaneDivider` is thin vertical lines at each fret inside the lane. Width keys off the active-fret range; min width ≈ 4 frets.
|
||||||
- **Lane intensity** → `highwayIntensity` accumulated from upcoming notes (further notes dim it, near notes light it). `_laneTargetColor = 0x4488ff` (set in `initScene()`) is the "lit" color, blended toward from `0x112233`.
|
- **Lane intensity** → `highwayIntensity` accumulated from upcoming notes (further notes dim it, near notes light it).
|
||||||
|
- **Lane color** → the lit quad color is `mLaneOdd.color` (stock `HWY_LANE_STRIPE_ODD_HEX = 0x103B5C`), the dimmer alternating row `mLaneEven.color` (`HWY_LANE_STRIPE_EVEN_HEX = 0x08283C`). These are now **theme-aware**: `_applyBgTheme()` recolors them from the active HIGHWAY theme's optional `lane`/`laneDim` fields, falling back to the stock hexes when a highway theme omits them. (`_laneTargetColor`, set in `initScene()`, is kept in sync with the lit color but has no live consumer today.)
|
||||||
|
|
||||||
### Lyrics & overlays
|
### Lyrics & overlays
|
||||||
- **Lyrics overlay** → `drawLyrics()`. 2D canvas, top centre, semi-transparent rounded background, syllable-level highlighting (current syllable in white, played in muted, upcoming in dim).
|
- **Lyrics overlay** → `drawLyrics()`. 2D canvas, top centre, semi-transparent rounded background, syllable-level highlighting (current syllable in white, played in muted, upcoming in dim).
|
||||||
@@ -131,7 +141,7 @@ Each entry names the function or banner you should grep for, plus key sub-blocks
|
|||||||
|
|
||||||
## The `bundle` object
|
## The `bundle` object
|
||||||
|
|
||||||
Every per-frame renderer call receives a `bundle` from slopsmith core. Fields used by this plugin:
|
Every per-frame renderer call receives a `bundle` from feedBack core. Fields used by this plugin:
|
||||||
|
|
||||||
- `currentTime` — playback time in seconds (drives `dt` for everything)
|
- `currentTime` — playback time in seconds (drives `dt` for everything)
|
||||||
- `notes`, `chords`, `beats`, `sections` — chart arrays (already difficulty-filtered by core)
|
- `notes`, `chords`, `beats`, `sections` — chart arrays (already difficulty-filtered by core)
|
||||||
@@ -141,9 +151,9 @@ Every per-frame renderer call receives a `bundle` from slopsmith core. Fields us
|
|||||||
- `lyricsVisible` — gate for lyrics overlay
|
- `lyricsVisible` — gate for lyrics overlay
|
||||||
- `renderScale` — pixel-ratio multiplier from the user's quality setting
|
- `renderScale` — pixel-ratio multiplier from the user's quality setting
|
||||||
- `songInfo.arrangement` — only field of `songInfo` this plugin reads, used as the bass-name fallback in `resolveStringCount()`
|
- `songInfo.arrangement` — only field of `songInfo` this plugin reads, used as the bass-name fallback in `resolveStringCount()`
|
||||||
- `stringCount` — slopsmith#93; always prefer this over deriving from tuning/arrangement
|
- `stringCount` — feedBack#93; always prefer this over deriving from tuning/arrangement
|
||||||
- `lefty` — display flag consumed by this renderer from `bundle.lefty`. Captured into `_leftyCached` before each frame so `xFret()`, `xFretMid()`, `boardSpanX()`, board geometry, note placement, and the camera shoulder offset mirror the fret axis for left-handed mode. A runtime lefty flip rebuilds board state and mirrors `curX`/`tgtX` plus the lookahead camera X cache so the camera does not drift across the neck.
|
- `lefty` — display flag consumed by this renderer from `bundle.lefty`. Captured into `_leftyCached` before each frame so `xFret()`, `xFretMid()`, `boardSpanX()`, board geometry, note placement, and the camera shoulder offset mirror the fret axis for left-handed mode. A runtime lefty flip rebuilds board state and mirrors `curX`/`tgtX` plus the lookahead camera X cache so the camera does not drift across the neck.
|
||||||
- `getNoteState(note, chartTime)` — slopsmith#254; per-note judgment from a scorer (note_detect). Captured each frame into `_ndGetNoteState` at the top of `update()` and consulted in `drawNote()` AFTER the event-driven `_ndHitMarks`/`_ndMissMarks` lookup AND over the proximity-based `hit` heuristic, both of which it overrides when it has a verdict: `'hit'`/`'active'` → `mGlow[s]` outline (bright string-tinted, *not* green) + `mGlow[s]` body + `mGlow[s]` sustain trail + a queue entry for `drawNotedetectSizzle` (so a held sustain keeps glowing/sparkling as long as the provider keeps returning `'active'`); `'miss'` → `mMissOutline` and `_showHit = false` (suppresses the bright body even if the note is near the line). Called with the note's chart time (`n.t`), which is how note_detect keys its `noteResults` map — *not* `now`. Returns null on cores without the API or songs with no scorer — then the event path / `hit` heuristic drive feedback for older note_detect builds. **notedetect ≥1.13 object verdicts additionally carry `{ points, mult, popKey }`** (game-scoring layer): `points` is the note's awarded score, `mult` the multiplier tier it landed at, and `popKey` a dedup key — chord members all return the chord-level judgment's key so a chord pops once, not once per gem. Consumed by the score-pop spawn in `drawNote()` (see Score FX below); all three are absent on older notedetect builds, so guard with `!== undefined`.
|
- `getNoteState(note, chartTime)` — feedBack#254; per-note judgment from a scorer (note_detect). Captured each frame into `_ndGetNoteState` at the top of `update()` and consulted in `drawNote()` AFTER the event-driven `_ndHitMarks`/`_ndMissMarks` lookup AND over the proximity-based `hit` heuristic, both of which it overrides when it has a verdict: `'hit'`/`'active'` → `mGlow[s]` outline (bright string-tinted, *not* green) + `mGlow[s]` body + `mGlow[s]` sustain trail + a queue entry for `drawNotedetectSizzle` (so a held sustain keeps glowing/sparkling as long as the provider keeps returning `'active'`); `'miss'` → `mMissOutline` and `_showHit = false` (suppresses the bright body even if the note is near the line). Called with the note's chart time (`n.t`), which is how note_detect keys its `noteResults` map — *not* `now`. Returns null on cores without the API or songs with no scorer — then the event path / `hit` heuristic drive feedback for older note_detect builds. **notedetect ≥1.13 object verdicts additionally carry `{ points, mult, popKey }`** (game-scoring layer): `points` is the note's awarded score, `mult` the multiplier tier it landed at, and `popKey` a dedup key — chord members all return the chord-level judgment's key so a chord pops once, not once per gem. Consumed by the score-pop spawn in `drawNote()` (see Score FX below); all three are absent on older notedetect builds, so guard with `!== undefined`.
|
||||||
|
|
||||||
`tuning` and `capo` aren't consumed by this plugin.
|
`tuning` and `capo` aren't consumed by this plugin.
|
||||||
|
|
||||||
@@ -151,11 +161,11 @@ Every per-frame renderer call receives a `bundle` from slopsmith core. Fields us
|
|||||||
|
|
||||||
- **"+N" score pops** → `_fxSpawnPop()` from `drawNote()` (just after the provider verdict-override block), drawn by `drawScoreFx()` (called from the `lyricsCtx` block in `draw()`, right after `drawNotedetectLabels()`). Fixed 24-slot pool (`_fxPops`), deduped per `popKey` via the TTL'd `_fxSeen` map (pruned in `drawScoreFx`). Pops rise/fade over 700 ms; font size scales with the multiplier tier.
|
- **"+N" score pops** → `_fxSpawnPop()` from `drawNote()` (just after the provider verdict-override block), drawn by `drawScoreFx()` (called from the `lyricsCtx` block in `draw()`, right after `drawNotedetectLabels()`). Fixed 24-slot pool (`_fxPops`), deduped per `popKey` via the TTL'd `_fxSeen` map (pruned in `drawScoreFx`). Pops rise/fade over 700 ms; font size scales with the multiplier tier.
|
||||||
- **Session FX** → `notedetect:fx` events (`{ fxType: 'multiplier'|'milestone'|'streakBreak', ... }`). notedetect dispatches each detail object twice in the same task: on `window` (unscoped, first) and as a bubbling CustomEvent from its per-panel instanceRoot (scoped, second). The listener (`_fxOnFx`, bound with the other notedetect listeners) treats element-targeted copies as authoritative — accepted only when their root lives in this panel's container — and **defers the window copy by a task** (`setTimeout 0`): if the element copy (same detail reference) arrived meanwhile it's dropped as a duplicate, otherwise it's the compat fallback for a detector whose root isn't in the DOM. This keeps splitscreen panels from rendering each other's FX even for the first event of a session. Effects: milestone → particle burst from a 4-slot Float32Array pool (`_fxBursts`), multiplier tier-up → expanding ring pulse at the strike-line centre, streak break → brief red wash.
|
- **Session FX** → `notedetect:fx` events (`{ fxType: 'multiplier'|'milestone'|'streakBreak', ... }`). notedetect dispatches each detail object twice in the same task: on `window` (unscoped, first) and as a bubbling CustomEvent from its per-panel instanceRoot (scoped, second). The listener (`_fxOnFx`, bound with the other notedetect listeners) treats element-targeted copies as authoritative — accepted only when their root lives in this panel's container — and **defers the window copy by a task** (`setTimeout 0`): if the element copy (same detail reference) arrived meanwhile it's dropped as a duplicate, otherwise it's the compat fallback for a detector whose root isn't in the DOM. This keeps splitscreen panels from rendering each other's FX even for the first event of a session. Effects: milestone → particle burst from a 4-slot Float32Array pool (`_fxBursts`), multiplier tier-up → expanding ring pulse at the strike-line centre, streak break → brief red wash.
|
||||||
- **Skin palette** → `_fxResolvePalette()` reads `localStorage['slopsmith_notedetect_skin']` (`neon`/`esports`/`metal` → `_FX_PALETTES`) at listener-bind time and on the `notedetect:skin` bus event. The display fonts are document-loaded by notedetect's stylesheet, so the overlay canvas can reference the family names directly.
|
- **Skin palette** → `_fxResolvePalette()` reads `localStorage['feedBack_notedetect_skin']` (`neon`/`esports`/`metal` → `_FX_PALETTES`) at listener-bind time and on the `notedetect:skin` bus event. The display fonts are document-loaded by notedetect's stylesheet, so the overlay canvas can reference the family names directly.
|
||||||
- Everything lives on the 2D overlay layer — no Three.js geometry, no `txtMat()` cache traffic, nothing to dispose; `teardown()` deactivates the pools and removes both listeners.
|
- Everything lives on the 2D overlay layer — no Three.js geometry, no `txtMat()` cache traffic, nothing to dispose; `teardown()` deactivates the pools and removes both listeners.
|
||||||
- **This block is the reference implementation for other renderer plugins** (drum highway, piano, custom highways) that want score pops / session FX: copy the `_fxOnFx` dedup+scoping listener, the `popKey`-keyed seen-map (cleared on backward seek), and the `_FX_PALETTES` skin mapping. The full consumer contract (events, payloads, provider verdict fields, theming variables) is documented in slopsmith-plugin-notedetect's `CLAUDE.md`.
|
- **This block is the reference implementation for other renderer plugins** (drum highway, piano, custom highways) that want score pops / session FX: copy the `_fxOnFx` dedup+scoping listener, the `popKey`-keyed seen-map (cleared on backward seek), and the `_FX_PALETTES` skin mapping. The full consumer contract (events, payloads, provider verdict fields, theming variables) is documented in feedBack-plugin-notedetect's `CLAUDE.md`.
|
||||||
|
|
||||||
If you need a bundle field that isn't here yet, check `_makeBundle()` in `static/highway.js` in the **slopsmith core repo** — this is the plugin repo, `static/highway.js` is not here. The full path in the parent slopsmith checkout is `slopsmith/static/highway.js`.
|
If you need a bundle field that isn't here yet, check `_makeBundle()` in `static/highway.js` in the **feedBack core repo** — this is the plugin repo, `static/highway.js` is not here. The full path in the parent feedBack checkout is `feedBack/static/highway.js`.
|
||||||
|
|
||||||
## Per-string state arrays
|
## Per-string state arrays
|
||||||
|
|
||||||
@@ -185,7 +195,7 @@ If a pool's mesh has per-instance state (its own material clone, its own texture
|
|||||||
1. **Adding a new pool? Reset it.** The reset block at the top of `update()` is easy to miss when adding a new pool elsewhere.
|
1. **Adding a new pool? Reset it.** The reset block at the top of `update()` is easy to miss when adding a new pool elsewhere.
|
||||||
2. **`txtMat()` is cache-keyed by `(style, text, color, wide)`.** Calling it with a numeric `text` works (it's coerced via `String(...)`), but new label content creates a new texture forever. Don't generate dynamic per-frame text (e.g. interpolated values) through `txtMat()` or you'll leak GPU memory. For static labels that change occasionally (chord names, fret numbers), the cache is fine. The `style` arg picks a preset from the `TXT_STYLES` table — see "Tweaking text-sprite styling" below.
|
2. **`txtMat()` is cache-keyed by `(style, text, color, wide)`.** Calling it with a numeric `text` works (it's coerced via `String(...)`), but new label content creates a new texture forever. Don't generate dynamic per-frame text (e.g. interpolated values) through `txtMat()` or you'll leak GPU memory. For static labels that change occasionally (chord names, fret numbers), the cache is fine. The `style` arg picks a preset from the `TXT_STYLES` table — see "Tweaking text-sprite styling" below.
|
||||||
3. **Disposal in `teardown()` matters.** Three.js doesn't garbage-collect GPU resources. Every `material.dispose()`, `geometry.dispose()`, `map.dispose()`, and `ren.dispose()` call there is load-bearing. `teardown()` is called from `init()` (when re-initing), `destroy()` (setRenderer swap or `highway.stop()`), and on init failure.
|
3. **Disposal in `teardown()` matters.** Three.js doesn't garbage-collect GPU resources. Every `material.dispose()`, `geometry.dispose()`, `map.dispose()`, and `ren.dispose()` call there is load-bearing. `teardown()` is called from `init()` (when re-initing), `destroy()` (setRenderer swap or `highway.stop()`), and on init failure.
|
||||||
4. **Don't use `tuning.length` for string count.** `bundle.tuning` (and `arr.tuning` server-side) is always 6 elements even for bass — slopsmith pre-fills the array with zeros for unused strings. Use `bundle.stringCount` (slopsmith#93), with `/bass/i.test(arrangement)` as the only acceptable fallback. There's a comment in `resolveStringCount()` documenting this.
|
4. **Don't use `tuning.length` for string count.** `bundle.tuning` (and `arr.tuning` server-side) is always 6 elements even for bass — feedBack pre-fills the array with zeros for unused strings. Use `bundle.stringCount` (feedBack#93), with `/bass/i.test(arrangement)` as the only acceptable fallback. There's a comment in `resolveStringCount()` documenting this.
|
||||||
5. **lyricsCanvas DOM order.** The 2D overlay canvas is appended to `wrap` AFTER `ren.domElement` and given `z-index:1`. This is the empirically-correct order — earlier versions had it before the WebGL canvas, which broke in splitscreen panels with `position:relative; overflow:hidden`. Don't reorder without testing both modes.
|
5. **lyricsCanvas DOM order.** The 2D overlay canvas is appended to `wrap` AFTER `ren.domElement` and given `z-index:1`. This is the empirically-correct order — earlier versions had it before the WebGL canvas, which broke in splitscreen panels with `position:relative; overflow:hidden`. Don't reorder without testing both modes.
|
||||||
6. **Projection glow `renderOrder = -1`** in `initScene()`. This is a known-suboptimal setting — it forces the glow to draw before the strings in the transparent queue, so the string visibly cuts through the preview. Removing the line lets natural Z-sort layer it correctly. Plus the projection's world-Y matches the string Y, which after perspective projection puts the preview slightly screen-lower than the string; bumping `projY = y + NH * 0.4` recenters it. (Both fixes live on the `fix/preview-stacking` branch.)
|
6. **Projection glow `renderOrder = -1`** in `initScene()`. This is a known-suboptimal setting — it forces the glow to draw before the strings in the transparent queue, so the string visibly cuts through the preview. Removing the line lets natural Z-sort layer it correctly. Plus the projection's world-Y matches the string Y, which after perspective projection puts the preview slightly screen-lower than the string; bumping `projY = y + NH * 0.4` recenters it. (Both fixes live on the `fix/preview-stacking` branch.)
|
||||||
7. **`renderOrder` on transparent objects is sticky.** Three.js sorts the transparent queue by `renderOrder` first, then back-to-front. A stray `m.renderOrder = -1` on something will pull it under everything regardless of Z. When in doubt, leave `renderOrder` at the default 0 and rely on Z position.
|
7. **`renderOrder` on transparent objects is sticky.** Three.js sorts the transparent queue by `renderOrder` first, then back-to-front. A stray `m.renderOrder = -1` on something will pull it under everything regardless of Z. When in doubt, leave `renderOrder` at the default 0 and rely on Z position.
|
||||||
@@ -201,7 +211,7 @@ The eight-color palette `S_COL` is the single source of truth for per-string col
|
|||||||
|
|
||||||
If a planned color-palette feature lands (issue #10), expect it to swap the palette source array but keep this single-array indirection. Anything that hardcodes color today will break that swap; flag it during review.
|
If a planned color-palette feature lands (issue #10), expect it to swap the palette source array but keep this single-array indirection. Anything that hardcodes color today will break that swap; flag it during review.
|
||||||
|
|
||||||
Non-string colors (lane target `0x4488ff`, fret-row label colors `#ffe84d` / `#9ab8cc`, fret-dot color `0x556677`, lyrics box rgba, chord-name gold `#e8d080`, etc.) are scattered as literals — that's intentional for now, since they're scene-wide accents rather than per-string. Pulling them into named constants is fine if you're already in that area.
|
Non-string colors (the stock lane hexes `HWY_LANE_STRIPE_ODD_HEX`/`_EVEN_HEX` — now overridable per Highway theme, see "Scene colors" above; fret-row label colors `#ffe84d` / `#9ab8cc`, fret-dot color `0x556677`, lyrics box rgba, chord-name gold `#e8d080`, etc.) are scattered as literals — that's intentional for now, since they're scene-wide accents rather than per-string. Pulling them into named constants is fine if you're already in that area.
|
||||||
|
|
||||||
## Tweaking text-sprite styling
|
## Tweaking text-sprite styling
|
||||||
|
|
||||||
@@ -231,21 +241,21 @@ Style fields:
|
|||||||
|
|
||||||
## Lifecycle (setRenderer contract)
|
## Lifecycle (setRenderer contract)
|
||||||
|
|
||||||
Per slopsmith#36, the factory returns `{ init, draw, resize, destroy }`:
|
Per feedBack#36, the factory returns `{ init, draw, resize, destroy }`:
|
||||||
|
|
||||||
- **`init(canvas, bundle)`** tears down any prior state, sets `highwayCanvas`, lazily loads Three.js, runs `initScene()`, calls `applySize()` (with a `retrySize` rAF loop fallback if the canvas isn't laid out yet).
|
- **`init(canvas, bundle)`** tears down any prior state, sets `highwayCanvas`, lazily loads Three.js, runs `initScene()`, calls `applySize()` (with a `retrySize` rAF loop fallback if the canvas isn't laid out yet).
|
||||||
- **`draw(bundle)`** is gated on `_isReady`. Re-resolves `nStr` / inverted / renderScale, then `update(bundle) → camUpdate(bundle) → ren.render → 2D overlays`. The `_lastHwW/_lastHwH` check at the top auto-resizes when the splitscreen plugin bypasses `resize()`.
|
- **`draw(bundle)`** is gated on `_isReady`. Re-resolves `nStr` / inverted / renderScale, then `update(bundle) → camUpdate(bundle) → ren.render → 2D overlays`. The `_lastHwW/_lastHwH` check at the top auto-resizes when the splitscreen plugin bypasses `resize()`.
|
||||||
- **`resize(w, h)`** is gated on `_isReady`. Just calls `applySize()`.
|
- **`resize(w, h)`** is gated on `_isReady`. Just calls `applySize()`.
|
||||||
- **`destroy()`** is idempotent. Sets flags, runs `teardown()`, drops `highwayCanvas`. Tolerates being called on an instance that's been destroyed and re-init'd already (resets `_lastHwW/H`, `_diagChord`, etc.).
|
- **`destroy()`** is idempotent. Sets flags, runs `teardown()`, drops `highwayCanvas`. Tolerates being called on an instance that's been destroyed and re-init'd already (resets `_lastHwW/H`, `_diagChord`, etc.).
|
||||||
|
|
||||||
The factory **returns a fresh instance per call**, so splitscreen's per-panel `setRenderer(slopsmithViz_highway_3d())` gets independent state per panel — important because the chord diagram, projection meshes, etc. are all per-instance.
|
The factory **returns a fresh instance per call**, so splitscreen's per-panel `setRenderer(feedBackViz_highway_3d())` gets independent state per panel — important because the chord diagram, projection meshes, etc. are all per-instance.
|
||||||
|
|
||||||
## Branching / PR conventions
|
## Branching / PR conventions
|
||||||
|
|
||||||
- Feature branches off `main`, descriptive name (e.g. `fix/preview-stacking`, `feat/palette-picker`).
|
- Feature branches off `main`, descriptive name (e.g. `fix/preview-stacking`, `feat/palette-picker`).
|
||||||
- PR target: target the contributor's own fork by default unless they ask otherwise; confirm before opening a PR upstream. Run `git remote -v` in this directory to see the remotes that are configured locally.
|
- PR target: target the contributor's own fork by default unless they ask otherwise; confirm before opening a PR upstream. Run `git remote -v` in this directory to see the remotes that are configured locally.
|
||||||
- Commit messages: short imperative subject, optional body explaining *why*. Don't summarize the diff — the diff already does that.
|
- Commit messages: short imperative subject, optional body explaining *why*. Don't summarize the diff — the diff already does that.
|
||||||
- This plugin is bundled **in-tree** at `plugins/highway_3d/` inside the `got-feedback/feedback` repository (not a gitlink/submodule). It ships with the default container image. Changes go through the normal slopsmith PR process — no separate upstream repo to sync.
|
- This plugin is bundled **in-tree** at `plugins/highway_3d/` inside the `got-feedback/feedBack` repository (not a gitlink/submodule). It ships with the default container image. Changes go through the normal feedBack PR process — no separate upstream repo to sync.
|
||||||
|
|
||||||
## When in doubt
|
## When in doubt
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
3D Highway plugin — third-party notices
|
||||||
|
=======================================
|
||||||
|
|
||||||
|
The "Butterchurn (visualizer)" background style (the 'butterchurn' option in
|
||||||
|
the plugin's Background-style setting) bundles the third-party components below
|
||||||
|
under plugins/highway_3d/assets/vendor/. They are lazy-loaded only when a user
|
||||||
|
selects that background style.
|
||||||
|
|
||||||
|
The 3D Highway plugin and the feedBack project are licensed under the GNU Affero
|
||||||
|
General Public License v3.0 (AGPL-3.0-only). The components below are under their
|
||||||
|
own permissive (MIT) licenses, which are compatible with AGPL-3.0.
|
||||||
|
|
||||||
|
- Butterchurn — a WebGL implementation of the MilkDrop visualizer.
|
||||||
|
https://github.com/jberg/butterchurn
|
||||||
|
Copyright (c) Jordan Berg. MIT License.
|
||||||
|
Vendored at: plugins/highway_3d/assets/vendor/butterchurn.min.js
|
||||||
|
|
||||||
|
- butterchurn-presets — MilkDrop preset pack.
|
||||||
|
https://github.com/jberg/butterchurn-presets
|
||||||
|
Presets authored by the MilkDrop community (Geiss, Flexi, Martin, Rovastar,
|
||||||
|
Zylot, Aderrasi, Cope, Eo.S., Unchained, and many others); refer to each
|
||||||
|
preset's embedded attribution.
|
||||||
|
Vendored at: plugins/highway_3d/assets/vendor/butterchurnPresets.min.js
|
||||||
|
|
||||||
|
- Three.js (r170) — loaded at runtime from feedBack core's vendored copy
|
||||||
|
(/static/vendor/three/three.module.min.js). https://threejs.org MIT License.
|
||||||
|
|
||||||
|
Provenance: the Butterchurn background integration originated as the standalone
|
||||||
|
"3D Highway + Butterchurn" plugin and was merged into the bundled 3D Highway
|
||||||
|
renderer as the opt-in 'butterchurn' background style.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# 3D Highway
|
# 3D Highway
|
||||||
|
|
||||||
A 3D note highway visualization for [Slopsmith](https://github.com/got-feedback/feedback) — an alternative to the default 2D highway, with a sense of depth and perspective inspired by stage views in modern rhythm games.
|
A 3D note highway visualization for [FeedBack](https://github.com/got-feedback/feedBack) — an alternative to the default 2D highway, with a sense of depth and perspective inspired by stage views in modern rhythm games.
|
||||||
|
|
||||||
## What you get
|
## What you get
|
||||||
|
|
||||||
@@ -19,15 +19,15 @@ A 3D note highway visualization for [Slopsmith](https://github.com/got-feedback/
|
|||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
3D Highway ships **bundled** with Slopsmith — no separate installation needed. Pick **3D Highway** from the visualization picker in the player.
|
3D Highway ships **bundled** with FeedBack — no separate installation needed. Pick **3D Highway** from the visualization picker in the player.
|
||||||
|
|
||||||
> **Note:** The bundled version is preferred over any user-installed copy with the same plugin ID. If you have an old `slopsmith-plugin-3dhighway` clone on disk (from before 3D Highway was promoted to core), it will be ignored at startup — a warning in the server log names the path of the discarded copy. You can safely delete the stale clone.
|
> **Note:** The bundled version is preferred over any user-installed copy with the same plugin ID. If you have an old `feedBack-plugin-3dhighway` clone on disk (from before 3D Highway was promoted to core), it will be ignored at startup — a warning in the server log names the path of the discarded copy. You can safely delete the stale clone.
|
||||||
>
|
>
|
||||||
> **Fallback:** In the unlikely event that the bundled copy fails to load its routes (e.g., a broken bundled release), Slopsmith will automatically fall back to your user-installed copy and show a yellow "Fallback" badge in the Settings panel. Check the server startup log for the root cause in that case.
|
> **Fallback:** In the unlikely event that the bundled copy fails to load its routes (e.g., a broken bundled release), FeedBack will automatically fall back to your user-installed copy and show a yellow "Fallback" badge in the Settings panel. Check the server startup log for the root cause in that case.
|
||||||
|
|
||||||
## Settings
|
## Settings
|
||||||
|
|
||||||
Most of the visual controls (background style, intensity, audio reactivity, color palette) live on Slopsmith's **Settings** screen under the *3D Highway* section.
|
Most of the visual controls (background style, intensity, audio reactivity, color palette) live on FeedBack's **Settings** screen under the *3D Highway* section.
|
||||||
|
|
||||||
## Contributing / development
|
## Contributing / development
|
||||||
|
|
||||||
@@ -35,4 +35,4 @@ For maintainers and AI assistants working on the codebase, see [`CLAUDE.md`](CLA
|
|||||||
|
|
||||||
### Perf bench (`?h3dbench=1`)
|
### Perf bench (`?h3dbench=1`)
|
||||||
|
|
||||||
Append `?h3dbench=1` to the player URL to enable opt-in `console.log` reporting of `update()` self-time, broken into six segments — `frame` (everything between `pbBeg(0)` at the top of `update()` and `pbEnd(0)` at the bottom; excludes the trailing `pbReportTick()` logging that fires after `pbEnd(0)`), `state` (per-frame state-derivation loop), `next` (next-note-by-string lookahead), `mat` (per-string material writes), `noteDraw` (single-note draw loop), `chordDraw` (chord draw loop). Reported every 5 seconds with p50 / p95 / max per segment and frame count, so before/after numbers on a target chart are reproducible (slopsmith#226). Off-by-default; the bench helpers (`pbBeg` / `pbEnd` / `pbReportTick`) are bound to a shared empty-function literal when the renderer instance is created (each `createHighway()` panel re-checks the flag), so the hot-path call sites are no-ops with negligible overhead (typically JIT-inlined).
|
Append `?h3dbench=1` to the player URL to enable opt-in `console.log` reporting of `update()` self-time, broken into six segments — `frame` (everything between `pbBeg(0)` at the top of `update()` and `pbEnd(0)` at the bottom; excludes the trailing `pbReportTick()` logging that fires after `pbEnd(0)`), `state` (per-frame state-derivation loop), `next` (next-note-by-string lookahead), `mat` (per-string material writes), `noteDraw` (single-note draw loop), `chordDraw` (chord draw loop). Reported every 5 seconds with p50 / p95 / max per segment and frame count, so before/after numbers on a target chart are reproducible (feedBack#226). Off-by-default; the bench helpers (`pbBeg` / `pbEnd` / `pbReportTick`) are bound to a shared empty-function literal when the renderer instance is created (each `createHighway()` panel re-checks the flag), so the hot-path call sites are no-ops with negligible overhead (typically JIT-inlined).
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,47 @@
|
|||||||
|
/*
|
||||||
|
* Slopsmith visualizer — audio feed AudioWorkletProcessor.
|
||||||
|
* Generates Butterchurn's drive signal off the main thread (replaces the
|
||||||
|
* deprecated ScriptProcessor): guitar PCM (gained) + a bass-band song-energy
|
||||||
|
* pulse + a mid-band chart-accent pulse. The main thread pushes the latest
|
||||||
|
* guitar frame and the song/chart/gain scalars via port messages.
|
||||||
|
*/
|
||||||
|
class VizFeedProcessor extends AudioWorkletProcessor {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.frame = null; // latest guitar PCM (Float32Array)
|
||||||
|
this.readIdx = 0;
|
||||||
|
this.song = 0;
|
||||||
|
this.chart = 0;
|
||||||
|
this.gain = 6;
|
||||||
|
this.phase = 0;
|
||||||
|
this.phase2 = 0;
|
||||||
|
this.osc = (2 * Math.PI * 90) / sampleRate; // ~90 Hz → bass band (song)
|
||||||
|
this.osc2 = (2 * Math.PI * 520) / sampleRate; // ~520 Hz → mid band (chart)
|
||||||
|
this.port.onmessage = (e) => {
|
||||||
|
const d = e.data;
|
||||||
|
if (!d) return;
|
||||||
|
if (d.frame) { this.frame = d.frame; this.readIdx = 0; }
|
||||||
|
if (typeof d.song === 'number') this.song = d.song;
|
||||||
|
if (typeof d.chart === 'number') this.chart = d.chart;
|
||||||
|
if (typeof d.gain === 'number') this.gain = d.gain;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
process(inputs, outputs) {
|
||||||
|
const out = outputs[0] && outputs[0][0];
|
||||||
|
if (!out) return true;
|
||||||
|
const f = this.frame, fl = f ? f.length : 0;
|
||||||
|
const TWO_PI = 2 * Math.PI;
|
||||||
|
for (let i = 0; i < out.length; i++) {
|
||||||
|
const g = (fl ? f[this.readIdx % fl] : 0) * this.gain;
|
||||||
|
this.readIdx++;
|
||||||
|
const song = this.song * (0.7 * Math.sin(this.phase) + 0.3 * (Math.random() * 2 - 1)) * 1.4;
|
||||||
|
const chart = this.chart * (0.5 * Math.sin(this.phase2) + 0.5 * (Math.random() * 2 - 1)) * 1.5;
|
||||||
|
this.phase += this.osc; if (this.phase > TWO_PI) this.phase -= TWO_PI;
|
||||||
|
this.phase2 += this.osc2; if (this.phase2 > TWO_PI) this.phase2 -= TWO_PI;
|
||||||
|
const v = g + song + chart;
|
||||||
|
out[i] = v > 1 ? 1 : (v < -1 ? -1 : v);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
registerProcessor('viz-feed', VizFeedProcessor);
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"id": "highway_3d",
|
"id": "highway_3d",
|
||||||
"name": "3D Highway",
|
"name": "3D Highway",
|
||||||
"version": "3.26.0",
|
"version": "3.30.2",
|
||||||
"type": "visualization",
|
"type": "visualization",
|
||||||
"bundled": true,
|
"bundled": true,
|
||||||
"script": "screen.js",
|
"script": "screen.js",
|
||||||
"styles": "assets/plugin.css",
|
"styles": "assets/plugin.css",
|
||||||
"settings": { "html": "settings.html", "server_files": ["plugin_uploads/highway_3d/current.mp4", "plugin_uploads/highway_3d/current.webm"] },
|
"settings": { "html": "settings.html", "category": "graphics", "server_files": ["plugin_uploads/highway_3d/current.mp4", "plugin_uploads/highway_3d/current.webm"] },
|
||||||
"routes": "routes.py",
|
"routes": "routes.py",
|
||||||
"tour": "tour.json"
|
"tour": "tour.json"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""Plugin-registered FastAPI routes for the 3dhighway visualization plugin.
|
"""Plugin-registered FastAPI routes for the 3dhighway visualization plugin.
|
||||||
|
|
||||||
Registered by slopsmith core via plugin.json's "routes" field — the
|
Registered by feedBack core via plugin.json's "routes" field — the
|
||||||
loader at plugins/__init__.py:589–604 imports this module and calls
|
loader at plugins/__init__.py:589–604 imports this module and calls
|
||||||
setup(app, context). context["config_dir"] points at the slopsmith
|
setup(app, context). context["config_dir"] points at the feedBack
|
||||||
data directory; we namespace user uploads under
|
data directory; we namespace user uploads under
|
||||||
{config_dir}/plugin_uploads/highway_3d/.
|
{config_dir}/plugin_uploads/highway_3d/.
|
||||||
|
|
||||||
|
|||||||
+2134
-100
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,15 @@
|
|||||||
<button type="button" onclick="window.hwcSaveTheme?.()" class="bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300">Save as…</button>
|
<button type="button" onclick="window.hwcSaveTheme?.()" class="bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300">Save as…</button>
|
||||||
<button type="button" onclick="window.hwcDeleteTheme?.()" class="bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-400">Delete</button>
|
<button type="button" onclick="window.hwcDeleteTheme?.()" class="bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-400">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- One-click presets: apply a whole named palette to every string at once.
|
||||||
|
Buttons are generated from window.feedBack.highwayColors.presets so the
|
||||||
|
list stays in sync with core (app.js HWC_PRESETS). Each calls
|
||||||
|
applyPreset(id), which persists + applies to both highways and refreshes
|
||||||
|
the pickers below. Older core without the presets API leaves this empty. -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<span class="text-xs text-gray-500 mb-1 block">Quick presets</span>
|
||||||
|
<div id="hwc-presets" class="flex flex-wrap gap-2"></div>
|
||||||
|
</div>
|
||||||
<div id="hwc-pickers" class="mb-3" style="display:grid;grid-template-columns:1fr 1fr;gap:0.5rem;"></div>
|
<div id="hwc-pickers" class="mb-3" style="display:grid;grid-template-columns:1fr 1fr;gap:0.5rem;"></div>
|
||||||
<div class="flex items-center gap-2 mb-2">
|
<div class="flex items-center gap-2 mb-2">
|
||||||
<button type="button" onclick="window.hwcReset?.()" class="bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300">Reset to defaults</button>
|
<button type="button" onclick="window.hwcReset?.()" class="bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300">Reset to defaults</button>
|
||||||
@@ -36,6 +45,33 @@
|
|||||||
if (typeof window.hwcInitSettingsUI === 'function') {
|
if (typeof window.hwcInitSettingsUI === 'function') {
|
||||||
try { window.hwcInitSettingsUI(); } catch (e) { console.warn('[3D-Hwy] hwcInitSettingsUI failed', e); }
|
try { window.hwcInitSettingsUI(); } catch (e) { console.warn('[3D-Hwy] hwcInitSettingsUI failed', e); }
|
||||||
}
|
}
|
||||||
|
// Render the one-click preset buttons from core's preset list.
|
||||||
|
try {
|
||||||
|
const api = window.feedBack && window.feedBack.highwayColors;
|
||||||
|
const host = document.getElementById('hwc-presets');
|
||||||
|
if (api && Array.isArray(api.presets) && host) {
|
||||||
|
host.innerHTML = '';
|
||||||
|
// Slot order low → high for the preview swatch (bass-side first).
|
||||||
|
const order = ['lowE', 'A', 'D', 'G', 'B', 'highE'];
|
||||||
|
for (const p of api.presets) {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'flex items-center gap-2 bg-dark-700 border border-gray-800 rounded-lg px-2 py-1 text-xs text-gray-300';
|
||||||
|
const sw = document.createElement('span');
|
||||||
|
const stops = order.map((k) => p.colors[k]).filter(Boolean).join(',');
|
||||||
|
sw.style.cssText = 'width:2.5rem;height:0.85rem;border-radius:3px;border:1px solid #0006;'
|
||||||
|
+ 'background:linear-gradient(90deg,' + stops + ');';
|
||||||
|
btn.appendChild(sw);
|
||||||
|
const txt = document.createElement('span');
|
||||||
|
txt.textContent = p.label;
|
||||||
|
btn.appendChild(txt);
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
try { api.applyPreset(p.id); } catch (e) { console.warn('[3D-Hwy] applyPreset failed', e); }
|
||||||
|
});
|
||||||
|
host.appendChild(btn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) { console.warn('[3D-Hwy] preset render failed', e); }
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</div>
|
</div>
|
||||||
@@ -52,7 +88,7 @@
|
|||||||
<option value="uniform">Uniform (equal width per fret)</option>
|
<option value="uniform">Uniform (equal width per fret)</option>
|
||||||
<option value="logarithmic">Instrument (logarithmic — decreasing toward bridge)</option>
|
<option value="logarithmic">Instrument (logarithmic — decreasing toward bridge)</option>
|
||||||
</select>
|
</select>
|
||||||
<p class="text-xs text-gray-500 mt-1">The page will reload when changed.</p>
|
<p class="text-xs text-gray-500 mt-1">Applies to the 3D highway immediately.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -88,11 +124,145 @@
|
|||||||
<option value="silhouettes">Silhouettes (parallax)</option>
|
<option value="silhouettes">Silhouettes (parallax)</option>
|
||||||
<option value="lights">Lights (stage glows)</option>
|
<option value="lights">Lights (stage glows)</option>
|
||||||
<option value="geometric">Geometric (rotating shapes)</option>
|
<option value="geometric">Geometric (rotating shapes)</option>
|
||||||
|
<option value="butterchurn">Butterchurn (visualizer)</option>
|
||||||
<option value="image" disabled>Custom image (none uploaded)</option>
|
<option value="image" disabled>Custom image (none uploaded)</option>
|
||||||
<option value="video" disabled>Custom video (none uploaded)</option>
|
<option value="video" disabled>Custom video (none uploaded)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Scene colors — TWO independent axes sharing one palette family:
|
||||||
|
Background (clear + fog) and Highway (board surface + lit lane). Same
|
||||||
|
id-set in both, so any background can mix with any highway; picking the
|
||||||
|
same in both gives the original "matched" look. "Default" is the
|
||||||
|
original look on both. -->
|
||||||
|
<div class="mt-3">
|
||||||
|
<label for="h3d-bg-theme" class="text-xs font-medium text-gray-400 mb-1 block">Background</label>
|
||||||
|
<!-- Options are populated from SCENE_THEMES (the single id+label source)
|
||||||
|
in the hydration script below, so the two dropdowns can't drift. -->
|
||||||
|
<select id="h3d-bg-theme"
|
||||||
|
onchange="window.h3dBgSetBgTheme && window.h3dBgSetBgTheme(this.value)"
|
||||||
|
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300 outline-none">
|
||||||
|
</select>
|
||||||
|
<p class="text-[10px] text-gray-500 mt-1">
|
||||||
|
Tints the background + distance fog. Applies to the 3D highway immediately.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<label for="h3d-hw-theme" class="text-xs font-medium text-gray-400 mb-1 block">Highway (surface + lane)</label>
|
||||||
|
<!-- Options populated from SCENE_THEMES (see below), same source as the
|
||||||
|
Background dropdown so the two stay in lockstep. -->
|
||||||
|
<select id="h3d-hw-theme"
|
||||||
|
onchange="window.h3dBgSetHwTheme && window.h3dBgSetHwTheme(this.value)"
|
||||||
|
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300 outline-none">
|
||||||
|
</select>
|
||||||
|
<p class="text-[10px] text-gray-500 mt-1">
|
||||||
|
Tints the fretboard surface + the lit lane. Applies to the 3D highway immediately.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Butterchurn (visualizer) options. These drive the 'butterchurn'
|
||||||
|
Background style. They used to live in a floating in-canvas panel; the
|
||||||
|
on/off + slider controls now live here in the standard settings UI.
|
||||||
|
The live preset browser (pick / favorite / ban / cycle) stays in-canvas.
|
||||||
|
Persisted in the 'viz3d_settings' localStorage blob (shared with the
|
||||||
|
in-canvas preset controls); window.h3dBcApplySettings() pushes changes
|
||||||
|
to a mounted highway live. -->
|
||||||
|
<div id="h3d-bc-section" class="mt-4">
|
||||||
|
<h3 class="text-sm font-medium text-gray-400 mb-2">Butterchurn (visualizer)</h3>
|
||||||
|
<p class="text-xs text-gray-500 mb-2">Active when Background style is set to <em>Butterchurn (visualizer)</em>.</p>
|
||||||
|
<div class="flex items-center gap-3 mt-3">
|
||||||
|
<label class="flex items-center gap-2 text-sm text-gray-300">
|
||||||
|
<input type="checkbox" id="h3d-bc-enabled" class="accent-accent"> Background on
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<label for="h3d-bc-opacity" class="text-xs font-medium text-gray-400 mb-1 block">Opacity: <span id="h3d-bc-opacity-label">100%</span></label>
|
||||||
|
<input type="range" id="h3d-bc-opacity" min="0" max="100" class="w-full accent-accent">
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 mt-3">
|
||||||
|
<label class="flex items-center gap-2 text-sm text-gray-300">
|
||||||
|
<input type="checkbox" id="h3d-bc-laneDim" class="accent-accent"> Dim behind lane
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<label for="h3d-bc-laneDimStrength" class="text-xs font-medium text-gray-400 mb-1 block">Dim strength</label>
|
||||||
|
<input type="range" id="h3d-bc-laneDimStrength" min="0" max="100" class="w-full accent-accent">
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 mt-3">
|
||||||
|
<label class="flex items-center gap-2 text-sm text-gray-300">
|
||||||
|
<input type="checkbox" id="h3d-bc-chartAccents" class="accent-accent"> Chart accents
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<label for="h3d-bc-chartStrength" class="text-xs font-medium text-gray-400 mb-1 block">Chart strength</label>
|
||||||
|
<input type="range" id="h3d-bc-chartStrength" min="0" max="200" class="w-full accent-accent">
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 mt-3">
|
||||||
|
<label class="flex items-center gap-2 text-sm text-gray-300">
|
||||||
|
<input type="checkbox" id="h3d-bc-colorTint" class="accent-accent"> Color tint
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<label for="h3d-bc-tintStrength" class="text-xs font-medium text-gray-400 mb-1 block">Tint strength</label>
|
||||||
|
<input type="range" id="h3d-bc-tintStrength" min="0" max="100" class="w-full accent-accent">
|
||||||
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<label for="h3d-bc-guitarGain" class="text-xs font-medium text-gray-400 mb-1 block">Guitar gain: <span id="h3d-bc-guitarGain-label">×6</span></label>
|
||||||
|
<input type="range" id="h3d-bc-guitarGain" min="1" max="12" step="0.5" class="w-full accent-accent">
|
||||||
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<label for="h3d-bc-songGain" class="text-xs font-medium text-gray-400 mb-1 block">Song gain: <span id="h3d-bc-songGain-label">×1.8</span></label>
|
||||||
|
<input type="range" id="h3d-bc-songGain" min="0" max="5" step="0.1" class="w-full accent-accent">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
// Self-contained Butterchurn settings wiring. Reads/writes the same
|
||||||
|
// 'viz3d_settings' blob the in-canvas preset controls use (read-modify-
|
||||||
|
// write preserves cyclePool/hold and the favorites/bans lists), then
|
||||||
|
// calls window.h3dBcApplySettings() so a mounted highway applies the
|
||||||
|
// change live. Guarded so it no-ops if the section isn't present.
|
||||||
|
(function () {
|
||||||
|
const BC_LS = 'viz3d_settings';
|
||||||
|
const BC_DEFAULTS = { enabled: true, opacity: 1.0, laneDim: true, laneDimStrength: 0.45, chartAccents: true, colorTint: true, chartStrength: 1.0, tintStrength: 0.65, guitarGain: 6, songGain: 1.8 };
|
||||||
|
function readBlob() { let s = {}; try { s = JSON.parse(localStorage.getItem(BC_LS) || '{}') || {}; } catch (e) {} return s; }
|
||||||
|
function patch(p) {
|
||||||
|
const s = readBlob();
|
||||||
|
Object.assign(s, p);
|
||||||
|
try { localStorage.setItem(BC_LS, JSON.stringify(s)); } catch (e) {}
|
||||||
|
try { if (typeof window.h3dBcApplySettings === 'function') window.h3dBcApplySettings(); } catch (e) {}
|
||||||
|
}
|
||||||
|
const s = Object.assign({}, BC_DEFAULTS, readBlob());
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
const en = $('h3d-bc-enabled');
|
||||||
|
if (!en) return;
|
||||||
|
const op = $('h3d-bc-opacity'), opv = $('h3d-bc-opacity-label');
|
||||||
|
const ld = $('h3d-bc-laneDim'), lds = $('h3d-bc-laneDimStrength');
|
||||||
|
const ca = $('h3d-bc-chartAccents'), cs = $('h3d-bc-chartStrength');
|
||||||
|
const ct = $('h3d-bc-colorTint'), ts = $('h3d-bc-tintStrength');
|
||||||
|
const gg = $('h3d-bc-guitarGain'), ggv = $('h3d-bc-guitarGain-label');
|
||||||
|
const sg = $('h3d-bc-songGain'), sgv = $('h3d-bc-songGain-label');
|
||||||
|
// Hydrate from saved settings.
|
||||||
|
en.checked = !!s.enabled;
|
||||||
|
op.value = Math.round((s.opacity != null ? s.opacity : 1) * 100); if (opv) opv.textContent = op.value + '%';
|
||||||
|
ld.checked = !!s.laneDim; lds.value = Math.round((s.laneDimStrength != null ? s.laneDimStrength : 0.45) * 100);
|
||||||
|
ca.checked = !!s.chartAccents; cs.value = Math.round((s.chartStrength != null ? s.chartStrength : 1) * 100);
|
||||||
|
ct.checked = !!s.colorTint; ts.value = Math.round((s.tintStrength != null ? s.tintStrength : 0.65) * 100);
|
||||||
|
gg.value = s.guitarGain != null ? s.guitarGain : 6; if (ggv) ggv.textContent = '×' + gg.value;
|
||||||
|
sg.value = s.songGain != null ? s.songGain : 1.8; if (sgv) sgv.textContent = '×' + sg.value;
|
||||||
|
// Wire changes (read-modify-write + live apply).
|
||||||
|
en.addEventListener('change', () => patch({ enabled: en.checked }));
|
||||||
|
op.addEventListener('input', () => { if (opv) opv.textContent = op.value + '%'; patch({ opacity: op.value / 100 }); });
|
||||||
|
ld.addEventListener('change', () => patch({ laneDim: ld.checked }));
|
||||||
|
lds.addEventListener('input', () => patch({ laneDimStrength: lds.value / 100 }));
|
||||||
|
ca.addEventListener('change', () => patch({ chartAccents: ca.checked }));
|
||||||
|
cs.addEventListener('input', () => patch({ chartStrength: cs.value / 100 }));
|
||||||
|
ct.addEventListener('change', () => patch({ colorTint: ct.checked }));
|
||||||
|
ts.addEventListener('input', () => patch({ tintStrength: ts.value / 100 }));
|
||||||
|
gg.addEventListener('input', () => { if (ggv) ggv.textContent = '×' + gg.value; patch({ guitarGain: parseFloat(gg.value) }); });
|
||||||
|
sg.addEventListener('input', () => { if (sgv) sgv.textContent = '×' + sg.value; patch({ songGain: parseFloat(sg.value) }); });
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
<!-- Intensity -->
|
<!-- Intensity -->
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<label for="h3d-bg-intensity" class="text-xs font-medium text-gray-400 mb-1 block">
|
<label for="h3d-bg-intensity" class="text-xs font-medium text-gray-400 mb-1 block">
|
||||||
@@ -147,7 +317,7 @@
|
|||||||
<p class="text-xs text-gray-500 mb-2">
|
<p class="text-xs text-gray-500 mb-2">
|
||||||
Upload an MP4 or WebM (≤50 MB). Plays muted, looped
|
Upload an MP4 or WebM (≤50 MB). Plays muted, looped
|
||||||
in the fog band when the style above is set to
|
in the fog band when the style above is set to
|
||||||
<em>Custom video</em>. Bytes stay on the slopsmith server,
|
<em>Custom video</em>. Bytes stay on the feedBack server,
|
||||||
not in the browser.
|
not in the browser.
|
||||||
</p>
|
</p>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -407,6 +577,80 @@
|
|||||||
<span>Glowy</span>
|
<span>Glowy</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<label for="h3d-hitfx" class="text-xs font-medium text-gray-400 mb-1 block">
|
||||||
|
Hit feedback intensity: <span id="h3d-hitfx-label">0.70</span>
|
||||||
|
</label>
|
||||||
|
<input type="range" id="h3d-hitfx" min="0" max="1" step="0.05" value="0.70"
|
||||||
|
oninput="document.getElementById('h3d-hitfx-label').textContent = parseFloat(this.value).toFixed(2); window.h3dBgSetHitFx && window.h3dBgSetHitFx(this.value)"
|
||||||
|
onchange="window.h3dBgSetHitFx && window.h3dBgSetHitFx(this.value)"
|
||||||
|
class="w-full accent-accent">
|
||||||
|
<p class="text-[10px] text-gray-500 mt-1">
|
||||||
|
How much "juice" a nailed note gets — the strike-line flash and the
|
||||||
|
spark burst at the hit line. <em>0</em> = colour verdict only (no sparks).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 flex items-start justify-between gap-3">
|
||||||
|
<label for="h3d-sparks" class="text-xs font-medium text-gray-400">
|
||||||
|
Hit sparks
|
||||||
|
<span class="block text-[10px] text-gray-500 font-normal">The particle burst that pops off a note the instant it's detected as a hit. Turn off for a calmer highway — the strike-line flash and colour verdict stay.</span>
|
||||||
|
</label>
|
||||||
|
<input type="checkbox" id="h3d-sparks" checked
|
||||||
|
onchange="window.h3dBgSetSparks && window.h3dBgSetSparks(this.checked)"
|
||||||
|
class="accent-accent mt-0.5">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 flex items-start justify-between gap-3">
|
||||||
|
<label for="h3d-cinematic" class="text-xs font-medium text-gray-400">
|
||||||
|
Cinematic lighting
|
||||||
|
<span class="block text-[10px] text-gray-500 font-normal">Darker stage so the glowing notes pop against it. Turn off for the brighter, flatter look.</span>
|
||||||
|
</label>
|
||||||
|
<input type="checkbox" id="h3d-cinematic" checked
|
||||||
|
onchange="window.h3dBgSetCinematic && window.h3dBgSetCinematic(this.checked)"
|
||||||
|
class="accent-accent mt-0.5">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 flex items-start justify-between gap-3">
|
||||||
|
<label for="h3d-streakfx" class="text-xs font-medium text-gray-400">
|
||||||
|
Streak feedback
|
||||||
|
<span class="block text-[10px] text-gray-500 font-normal">A clean run quietly "heats up" — bigger sparks the longer you stay accurate. Eases back on a miss.</span>
|
||||||
|
</label>
|
||||||
|
<input type="checkbox" id="h3d-streakfx" checked
|
||||||
|
onchange="window.h3dBgSetStreakFx && window.h3dBgSetStreakFx(this.checked)"
|
||||||
|
class="accent-accent mt-0.5">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 flex items-start justify-between gap-3">
|
||||||
|
<label for="h3d-verdictmarks" class="text-xs font-medium text-gray-400">
|
||||||
|
Accessible verdict marks (✓ / ✗)
|
||||||
|
<span class="block text-[10px] text-gray-500 font-normal">Adds a shape mark to each hit/miss so the result doesn't rely on the green/red colour pair alone.</span>
|
||||||
|
</label>
|
||||||
|
<input type="checkbox" id="h3d-verdictmarks" checked
|
||||||
|
onchange="window.h3dBgSetVerdictMarks && window.h3dBgSetVerdictMarks(this.checked)"
|
||||||
|
class="accent-accent mt-0.5">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 flex items-start justify-between gap-3">
|
||||||
|
<label for="h3d-bloom" class="text-xs font-medium text-gray-400">
|
||||||
|
Glow bloom
|
||||||
|
<span class="block text-[10px] text-gray-500 font-normal">Real light-bleed around the glowing notes and hit flash (higher fidelity). Turns itself off in split-screen. If your machine struggles, turn this off first.</span>
|
||||||
|
</label>
|
||||||
|
<input type="checkbox" id="h3d-bloom" checked
|
||||||
|
onchange="window.h3dBgSetBloom && window.h3dBgSetBloom(this.checked)"
|
||||||
|
class="accent-accent mt-0.5">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 flex items-start justify-between gap-3">
|
||||||
|
<label for="h3d-timingfx" class="text-xs font-medium text-gray-400">
|
||||||
|
Timing feedback
|
||||||
|
<span class="block text-[10px] text-gray-500 font-normal">Colours a hit by your timing — on-time green, a touch <span style="color:#35d6ff">early (cyan)</span> or <span style="color:#ffb84d">late (amber)</span> — so you can feel where you sit in the beat.</span>
|
||||||
|
</label>
|
||||||
|
<input type="checkbox" id="h3d-timingfx" checked
|
||||||
|
onchange="window.h3dBgSetTimingFx && window.h3dBgSetTimingFx(this.checked)"
|
||||||
|
class="accent-accent mt-0.5">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
@@ -712,8 +956,27 @@
|
|||||||
no-selection / NaN state. -->
|
no-selection / NaN state. -->
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
const DEFAULTS = { style: 'particles', intensity: 0.5, reactive: true, palette: 'default', showFretOnNote: true, fretNumberGhostScope: 'chords', cameraSmoothing: 0.5, zoomSmoothing: 0.5, tiltSmoothing: 0.5, cameraLockLow: false, cameraLockZoom: 0.5, cameraMode: 'lookahead', nutHeadstockVisible: true, tuningLabelsVisible: true, nutColor: '#f5f3f0', headstockColor: '#d4b48a', textSize: 0.5, vibrancy: 0.85, glow: 0.25, customImageDataUrl: '', customImageName: '', customVideoName: '', chordDiagramVisible: true, chordDiagramSize: 0.5, chordDiagramPosition: 'tl', fretColumnMarkerCadence: 1, projectionVisible: true, inlayLabelsVisible: false, sectionLabelsOnHighway: false, sectionHudVisible: false, sectionHudPosition: 'tr', sectionHudSize: 0.5, toneHudVisible: false, toneHudPosition: 'tl', toneHudSize: 0.5, fpsVisible: false, fretDividersVisible: true, slideArrowApproachVisible: true, slideArrowNeckVisible: true, slideArrowChainPreviewVisible: true };
|
const DEFAULTS = { style: 'particles', intensity: 0.5, reactive: true, palette: 'default', bgTheme: 'default', hwTheme: 'default', showFretOnNote: true, fretNumberGhostScope: 'chords', cameraSmoothing: 0.5, zoomSmoothing: 0.5, tiltSmoothing: 0.5, cameraLockLow: false, cameraLockZoom: 0.5, cameraMode: 'lookahead', nutHeadstockVisible: true, tuningLabelsVisible: true, nutColor: '#f5f3f0', headstockColor: '#d4b48a', textSize: 0.5, vibrancy: 0.85, glow: 0.25, customImageDataUrl: '', customImageName: '', customVideoName: '', chordDiagramVisible: true, chordDiagramSize: 0.5, chordDiagramPosition: 'tl', fretColumnMarkerCadence: 1, projectionVisible: true, inlayLabelsVisible: false, sectionLabelsOnHighway: false, sectionHudVisible: false, sectionHudPosition: 'tr', sectionHudSize: 0.5, toneHudVisible: false, toneHudPosition: 'tl', toneHudSize: 0.5, fpsVisible: false, fretDividersVisible: true, slideArrowApproachVisible: true, slideArrowNeckVisible: true, slideArrowChainPreviewVisible: true };
|
||||||
const VALID_STYLES = new Set(['off', 'particles', 'silhouettes', 'lights', 'geometric', 'image', 'video']);
|
const VALID_STYLES = new Set(['off', 'particles', 'silhouettes', 'lights', 'geometric', 'butterchurn', 'image', 'video']);
|
||||||
|
// Scene color themes — the single id+label source for BOTH the
|
||||||
|
// Background and Highway dropdowns AND the validator below. The
|
||||||
|
// <option>s are generated from this list (see hydration), so the two
|
||||||
|
// dropdowns can't drift from each other or from VALID_BG_THEMES.
|
||||||
|
// Mirror these ids with screen.js BG_THEMES (the color table).
|
||||||
|
const SCENE_THEMES = [
|
||||||
|
{ id: 'default', label: 'Default (blue-black)' },
|
||||||
|
{ id: 'midnight', label: 'Midnight (deep blue)' },
|
||||||
|
{ id: 'charcoal', label: 'Charcoal (neutral gray)' },
|
||||||
|
{ id: 'deeppurple', label: 'Deep purple' },
|
||||||
|
{ id: 'forest', label: 'Forest (dark green)' },
|
||||||
|
{ id: 'warmslate', label: 'Warm Slate (espresso)' },
|
||||||
|
{ id: 'deepfocus', label: 'Deep Focus (near-black)' },
|
||||||
|
{ id: 'deepsea', label: 'Deep Sea (dark teal)' },
|
||||||
|
{ id: 'cathode', label: 'Cathode (amber CRT)' },
|
||||||
|
{ id: 'cathodegreen', label: 'Cathode Green (green CRT)' },
|
||||||
|
{ id: 'hearth', label: 'Hearth (warm red)' },
|
||||||
|
];
|
||||||
|
const VALID_BG_THEMES = new Set(SCENE_THEMES.map((t) => t.id));
|
||||||
const VALID_CAMERA_MODES = new Set(['steady', 'lookahead']);
|
const VALID_CAMERA_MODES = new Set(['steady', 'lookahead']);
|
||||||
// Chord diagram is top-only (bl/br removed).
|
// Chord diagram is top-only (bl/br removed).
|
||||||
const VALID_CHORD_DIAG_POSITIONS = new Set(['tl', 'tr']);
|
const VALID_CHORD_DIAG_POSITIONS = new Set(['tl', 'tr']);
|
||||||
@@ -725,7 +988,7 @@
|
|||||||
// localStorage values are stored as UTF-16 (two bytes per
|
// localStorage values are stored as UTF-16 (two bytes per
|
||||||
// character), so the on-disk footprint is ~2.67× the raw
|
// character), so the on-disk footprint is ~2.67× the raw
|
||||||
// file size. localStorage quotas are typically 5 MB per
|
// file size. localStorage quotas are typically 5 MB per
|
||||||
// origin and slopsmith already uses some of that for other
|
// origin and feedBack already uses some of that for other
|
||||||
// settings, so a 1.5 MB raw limit (≈4 MB on disk) leaves
|
// settings, so a 1.5 MB raw limit (≈4 MB on disk) leaves
|
||||||
// safe headroom; the read-back verification below catches
|
// safe headroom; the read-back verification below catches
|
||||||
// remaining edge cases where the write still gets refused.
|
// remaining edge cases where the write still gets refused.
|
||||||
@@ -771,6 +1034,9 @@
|
|||||||
const VIDEO_UPLOAD_URL = '/api/plugins/highway_3d/files';
|
const VIDEO_UPLOAD_URL = '/api/plugins/highway_3d/files';
|
||||||
|
|
||||||
function coerceStyle(v) { return VALID_STYLES.has(v) ? v : DEFAULTS.style; }
|
function coerceStyle(v) { return VALID_STYLES.has(v) ? v : DEFAULTS.style; }
|
||||||
|
function coerceBgTheme(v) { return VALID_BG_THEMES.has(v) ? v : DEFAULTS.bgTheme; }
|
||||||
|
// Highway axis shares the same valid id-set as the background axis.
|
||||||
|
function coerceHwTheme(v) { return VALID_BG_THEMES.has(v) ? v : DEFAULTS.hwTheme; }
|
||||||
function coerceCameraMode(v) {
|
function coerceCameraMode(v) {
|
||||||
if (v === 'classic') v = 'steady';
|
if (v === 'classic') v = 'steady';
|
||||||
return VALID_CAMERA_MODES.has(v) ? v : DEFAULTS.cameraMode;
|
return VALID_CAMERA_MODES.has(v) ? v : DEFAULTS.cameraMode;
|
||||||
@@ -812,7 +1078,7 @@
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
let storedStyle = null, storedI = null, storedR = null, storedFretOnNote = null, storedFretNumberGhostScope = null;
|
let storedStyle = null, storedBgTheme = null, storedHwTheme = null, storedI = null, storedR = null, storedFretOnNote = null, storedFretNumberGhostScope = null;
|
||||||
let storedCameraSmoothing = null;
|
let storedCameraSmoothing = null;
|
||||||
let storedCameraMode = null;
|
let storedCameraMode = null;
|
||||||
let storedCustomImageDataUrl = null, storedCustomImageName = null;
|
let storedCustomImageDataUrl = null, storedCustomImageName = null;
|
||||||
@@ -848,6 +1114,8 @@
|
|||||||
let storedSlideArrowChainPreviewVisible = null;
|
let storedSlideArrowChainPreviewVisible = null;
|
||||||
try {
|
try {
|
||||||
storedStyle = localStorage.getItem('h3d_bg_style');
|
storedStyle = localStorage.getItem('h3d_bg_style');
|
||||||
|
storedBgTheme = localStorage.getItem('h3d_bg_bgTheme');
|
||||||
|
storedHwTheme = localStorage.getItem('h3d_bg_hwTheme');
|
||||||
storedI = localStorage.getItem('h3d_bg_intensity');
|
storedI = localStorage.getItem('h3d_bg_intensity');
|
||||||
storedR = localStorage.getItem('h3d_bg_reactive');
|
storedR = localStorage.getItem('h3d_bg_reactive');
|
||||||
storedFretOnNote = localStorage.getItem('h3d_bg_showFretOnNote');
|
storedFretOnNote = localStorage.getItem('h3d_bg_showFretOnNote');
|
||||||
@@ -889,6 +1157,15 @@
|
|||||||
} catch (_) { /* storage blocked */ }
|
} catch (_) { /* storage blocked */ }
|
||||||
|
|
||||||
const style = coerceStyle(storedStyle);
|
const style = coerceStyle(storedStyle);
|
||||||
|
const bgTheme = coerceBgTheme(storedBgTheme);
|
||||||
|
// Highway axis. BACKWARD-COMPAT: when hwTheme was never written
|
||||||
|
// (pre-split installs, or the panel opened before the highway has
|
||||||
|
// mounted and run its one-time backfill), show the background pick —
|
||||||
|
// exactly the value screen.js will persist on first load — so the
|
||||||
|
// dropdown matches what's rendered. Once hwTheme is stored (which the
|
||||||
|
// renderer's backfill or any explicit pick does) it reads
|
||||||
|
// independently and the two axes no longer track each other.
|
||||||
|
const hwTheme = (storedHwTheme == null) ? bgTheme : coerceHwTheme(storedHwTheme);
|
||||||
const intensity = coerceIntensity(storedI);
|
const intensity = coerceIntensity(storedI);
|
||||||
const reactive = coerceBool(storedR, DEFAULTS.reactive);
|
const reactive = coerceBool(storedR, DEFAULTS.reactive);
|
||||||
const showFretOnNote = coerceBool(storedFretOnNote, DEFAULTS.showFretOnNote);
|
const showFretOnNote = coerceBool(storedFretOnNote, DEFAULTS.showFretOnNote);
|
||||||
@@ -959,6 +1236,20 @@
|
|||||||
const customVideoName = (typeof storedCustomVideoName === 'string') ? storedCustomVideoName : DEFAULTS.customVideoName;
|
const customVideoName = (typeof storedCustomVideoName === 'string') ? storedCustomVideoName : DEFAULTS.customVideoName;
|
||||||
|
|
||||||
const sel = document.getElementById('h3d-bg-style');
|
const sel = document.getElementById('h3d-bg-style');
|
||||||
|
const bgThemeSel = document.getElementById('h3d-bg-theme');
|
||||||
|
const hwThemeSel = document.getElementById('h3d-hw-theme');
|
||||||
|
// Populate both scene-theme dropdowns from the single SCENE_THEMES
|
||||||
|
// source so they can't drift. (Values are selected further below.)
|
||||||
|
for (const selEl of [bgThemeSel, hwThemeSel]) {
|
||||||
|
if (!selEl) continue;
|
||||||
|
selEl.innerHTML = '';
|
||||||
|
for (const t of SCENE_THEMES) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = t.id;
|
||||||
|
opt.textContent = t.label;
|
||||||
|
selEl.appendChild(opt);
|
||||||
|
}
|
||||||
|
}
|
||||||
const sli = document.getElementById('h3d-bg-intensity');
|
const sli = document.getElementById('h3d-bg-intensity');
|
||||||
const lbl = document.getElementById('h3d-bg-intensity-label');
|
const lbl = document.getElementById('h3d-bg-intensity-label');
|
||||||
const rea = document.getElementById('h3d-bg-reactive');
|
const rea = document.getElementById('h3d-bg-reactive');
|
||||||
@@ -1082,6 +1373,35 @@
|
|||||||
if (thsi) thsi.value = String(toneHudSize);
|
if (thsi) thsi.value = String(toneHudSize);
|
||||||
if (thslbl) thslbl.textContent = toneHudSize.toFixed(2);
|
if (thslbl) thslbl.textContent = toneHudSize.toFixed(2);
|
||||||
|
|
||||||
|
// Hit-feedback "juice" controls — hydrate from saved state so the
|
||||||
|
// panel reflects persistence on reopen (the renderer already reads
|
||||||
|
// these via _bgReadSetting; without this the controls always showed
|
||||||
|
// their default markup, misrepresenting a saved non-default). Reads
|
||||||
|
// h3d_bg_* directly; defaults mirror BG_DEFAULTS (all bools on,
|
||||||
|
// hitFx 0.70) and the _bgCoerceBool 'true'/'1' vs 'false'/'0' rules.
|
||||||
|
try {
|
||||||
|
const _bgBool = (k, def) => {
|
||||||
|
const v = localStorage.getItem('h3d_bg_' + k);
|
||||||
|
return v == null ? def : !(v === 'false' || v === '0');
|
||||||
|
};
|
||||||
|
const _setChk = (id, on) => { const el = document.getElementById(id); if (el) el.checked = on; };
|
||||||
|
_setChk('h3d-sparks', _bgBool('sparks', true));
|
||||||
|
_setChk('h3d-cinematic', _bgBool('cinematic', true));
|
||||||
|
_setChk('h3d-streakfx', _bgBool('streakFx', true));
|
||||||
|
_setChk('h3d-verdictmarks', _bgBool('verdictMarks', true));
|
||||||
|
_setChk('h3d-bloom', _bgBool('bloom', true));
|
||||||
|
_setChk('h3d-timingfx', _bgBool('timingFx', true));
|
||||||
|
const _hf = document.getElementById('h3d-hitfx');
|
||||||
|
if (_hf) {
|
||||||
|
let v = parseFloat(localStorage.getItem('h3d_bg_hitFx'));
|
||||||
|
if (!isFinite(v)) v = 0.70;
|
||||||
|
v = Math.max(0, Math.min(1, v));
|
||||||
|
_hf.value = String(v);
|
||||||
|
const _hfl = document.getElementById('h3d-hitfx-label');
|
||||||
|
if (_hfl) _hfl.textContent = v.toFixed(2);
|
||||||
|
}
|
||||||
|
} catch (_) { /* storage blocked — controls keep their default markup */ }
|
||||||
|
|
||||||
// (3D Highway palette picker removed — string colors are now set
|
// (3D Highway palette picker removed — string colors are now set
|
||||||
// via the core "Highway String Colors" UI above, which drives both
|
// via the core "Highway String Colors" UI above, which drives both
|
||||||
// the 2D and 3D highways. The bg-settings 'palette' key still exists
|
// the 2D and 3D highways. The bg-settings 'palette' key still exists
|
||||||
@@ -1200,6 +1520,8 @@
|
|||||||
sel.value = style;
|
sel.value = style;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (bgThemeSel) bgThemeSel.value = bgTheme;
|
||||||
|
if (hwThemeSel) hwThemeSel.value = hwTheme;
|
||||||
|
|
||||||
if (fileInput) {
|
if (fileInput) {
|
||||||
fileInput.addEventListener('change', function () {
|
fileInput.addEventListener('change', function () {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Tailwind build config for the 3D Highway plugin's OWN stylesheet.
|
* Tailwind build config for the 3D Highway plugin's OWN stylesheet.
|
||||||
*
|
*
|
||||||
* Slopsmith serves Tailwind as a prebuilt stylesheet and core only scans core
|
* FeedBack serves Tailwind as a prebuilt stylesheet and core only scans core
|
||||||
* source at build time (constitution Principle II — no Play CDN / runtime JIT).
|
* source at build time (constitution Principle II — no Play CDN / runtime JIT).
|
||||||
* This plugin owns its utilities so it styles correctly even when core's build
|
* This plugin owns its utilities so it styles correctly even when core's build
|
||||||
* didn't scan it (it's excluded from core's content globs). It uses arbitrary
|
* didn't scan it (it's excluded from core's content globs). It uses arbitrary
|
||||||
|
|||||||
@@ -15,10 +15,10 @@
|
|||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
window.slopsmith = window.slopsmith || {};
|
window.feedBack = window.feedBack || {};
|
||||||
if (window.slopsmithInputSetup && window.slopsmithInputSetup.version === 1) return;
|
if (window.feedBackInputSetup && window.feedBackInputSetup.version === 1) return;
|
||||||
|
|
||||||
const capabilities = window.slopsmith.capabilities;
|
const capabilities = window.feedBack.capabilities;
|
||||||
const DONE_KEY = (inst) => `input_setup.done.${inst}`;
|
const DONE_KEY = (inst) => `input_setup.done.${inst}`;
|
||||||
const INSTRUMENTS = {
|
const INSTRUMENTS = {
|
||||||
guitar: { label: 'Guitar', mode: 'audio' },
|
guitar: { label: 'Guitar', mode: 'audio' },
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
|
|
||||||
// The Web-MIDI source provider now ships built-in with the core midi-input
|
// 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
|
// domain (static/capabilities/midi-input.js), so input_setup is a pure
|
||||||
// consumer — it just discovers/selects/opens through `window.slopsmith.midiInput`.
|
// consumer — it just discovers/selects/opens through `window.feedBack.midiInput`.
|
||||||
|
|
||||||
// ── audio-input helper (guitar/bass device context) ─────────────────────
|
// ── audio-input helper (guitar/bass device context) ─────────────────────
|
||||||
async function _audioSources() {
|
async function _audioSources() {
|
||||||
@@ -51,15 +51,16 @@
|
|||||||
sources = sources.filter((s) => s
|
sources = sources.filter((s) => s
|
||||||
&& !/midi/i.test(String(s.providerId || ''))
|
&& !/midi/i.test(String(s.providerId || ''))
|
||||||
&& !/^midi-input/i.test(String(s.label || '')));
|
&& !/^midi-input/i.test(String(s.label || '')));
|
||||||
// De-dupe by display label — the desktop engine enumerates the same
|
// No label de-dupe here. The audio-input capability already
|
||||||
// device under several driver types, so the same name can repeat.
|
// collapses exact duplicates by logicalSourceKey
|
||||||
const seen = new Set();
|
// (_visibleInputSources), so nothing it returns shares a key. A
|
||||||
sources = sources.filter((s) => {
|
// device that enumerates under several driver types (ASIO / Windows
|
||||||
const key = String(s.label || '').toLowerCase();
|
// Audio / DirectSound) has a DISTINCT key per type and is now
|
||||||
if (seen.has(key)) return false;
|
// labelled with its driver type (e.g. "Focusrite (ASIO)") — each is
|
||||||
seen.add(key);
|
// a real, separately-selectable input the user must be able to see.
|
||||||
return true;
|
// The old bare-label collapse also kept whichever variant sorted
|
||||||
});
|
// first, which could silently drop the one that was actually
|
||||||
|
// `selected` below.
|
||||||
const selected = sources.find((s) => s && s.selected) || null;
|
const selected = sources.find((s) => s && s.selected) || null;
|
||||||
return { sources, selected };
|
return { sources, selected };
|
||||||
} catch (_) { return { sources: [], selected: null }; }
|
} catch (_) { return { sources: [], selected: null }; }
|
||||||
@@ -163,10 +164,25 @@
|
|||||||
|
|
||||||
host.querySelector('[data-is-cal]').addEventListener('click', () => {
|
host.querySelector('[data-is-cal]').addEventListener('click', () => {
|
||||||
if (hasDetector) {
|
if (hasDetector) {
|
||||||
|
// Hide our own full-screen overlay while note_detect's
|
||||||
|
// Calibration Wizard runs on top. That wizard goes
|
||||||
|
// transparent (pointer-events:none) when it minimizes to
|
||||||
|
// expose the Tuner; if our overlay stayed up it would show
|
||||||
|
// through — covering the tuner with the still-mounted
|
||||||
|
// "select your input" card and looking like a second
|
||||||
|
// wizard at the input step. Restore it on done/cancel
|
||||||
|
// (one of which always fires when that wizard closes).
|
||||||
|
const ov = document.getElementById('input-setup-overlay');
|
||||||
|
const prevDisplay = ov ? ov.style.display : '';
|
||||||
|
if (ov) ov.style.display = 'none';
|
||||||
|
const restore = () => {
|
||||||
|
const o = document.getElementById('input-setup-overlay');
|
||||||
|
if (o) o.style.display = prevDisplay;
|
||||||
|
};
|
||||||
window.noteDetect.launchCalibration({
|
window.noteDetect.launchCalibration({
|
||||||
instrument: inst,
|
instrument: inst,
|
||||||
onDone: () => advance(inst, true),
|
onDone: () => { restore(); advance(inst, true); },
|
||||||
onCancel: () => { /* stay on this panel; user can skip or retry */ },
|
onCancel: () => { restore(); /* stay on this panel; user can skip or retry */ },
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
advance(inst, true);
|
advance(inst, true);
|
||||||
@@ -176,7 +192,7 @@
|
|||||||
|
|
||||||
// Keys/drums: pick a MIDI device via midi-input and confirm a live hit.
|
// Keys/drums: pick a MIDI device via midi-input and confirm a live hit.
|
||||||
async function renderMidiPanel(inst) {
|
async function renderMidiPanel(inst) {
|
||||||
const mi = window.slopsmith.midiInput;
|
const mi = window.feedBack.midiInput;
|
||||||
// Availability is the midi-input DOMAIN being present, not the
|
// Availability is the midi-input DOMAIN being present, not the
|
||||||
// Web-MIDI browser API — the domain coordinates providers (the
|
// Web-MIDI browser API — the domain coordinates providers (the
|
||||||
// built-in Web-MIDI one, plus any native/desktop adapter), so
|
// built-in Web-MIDI one, plus any native/desktop adapter), so
|
||||||
@@ -249,7 +265,7 @@
|
|||||||
// Show every source the midi-input domain surfaces — not just
|
// Show every source the midi-input domain surfaces — not just
|
||||||
// the built-in Web-MIDI provider — so a native/desktop MIDI
|
// the built-in Web-MIDI provider — so a native/desktop MIDI
|
||||||
// adapter registered with the domain is selectable too.
|
// adapter registered with the domain is selectable too.
|
||||||
const sources = window.slopsmith.midiInput.listSources() || [];
|
const sources = window.feedBack.midiInput.listSources() || [];
|
||||||
if (!sources.length) { testEl && (testEl.textContent = ''); wrap.classList.remove('hidden'); select.innerHTML = '<option>No MIDI devices found</option>'; select.disabled = true; return; }
|
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');
|
wrap.classList.remove('hidden');
|
||||||
select.disabled = false;
|
select.disabled = false;
|
||||||
@@ -327,7 +343,7 @@
|
|||||||
return _runWizard({ host, instruments: instruments || [] }).then((r) => { overlay.remove(); return r; });
|
return _runWizard({ host, instruments: instruments || [] }).then((r) => { overlay.remove(); return r; });
|
||||||
}
|
}
|
||||||
|
|
||||||
window.slopsmithInputSetup = {
|
window.feedBackInputSetup = {
|
||||||
version: 1,
|
version: 1,
|
||||||
mount,
|
mount,
|
||||||
launch,
|
launch,
|
||||||
|
|||||||
+12
-12
@@ -1,16 +1,16 @@
|
|||||||
# slopsmith-plugin-minigames
|
# feedBack-plugin-minigames
|
||||||
|
|
||||||
The minigame framework for [Slopsmith](https://github.com/got-feedback/feedback).
|
The minigame framework for [FeedBack](https://github.com/got-feedback/feedBack).
|
||||||
|
|
||||||
This plugin provides:
|
This plugin provides:
|
||||||
|
|
||||||
- A **Minigames hub** screen that discovers every installed minigame plugin and lists them as tiles with leaderboards.
|
- A **Minigames hub** screen that discovers every installed minigame plugin and lists them as tiles with leaderboards.
|
||||||
- A **shared profile** (XP, level, unlocks, totals) that aggregates runs across every minigame.
|
- A **shared profile** (XP, level, unlocks, totals) that aggregates runs across every minigame.
|
||||||
- A JS **SDK** exposed at `window.slopsmithMinigames` that minigame plugins use to access scoring, HUD primitives, run persistence, and a scheduler — so individual minigames do not need their own DSP or backend.
|
- A JS **SDK** exposed at `window.feedBackMinigames` that minigame plugins use to access scoring, HUD primitives, run persistence, and a scheduler — so individual minigames do not need their own DSP or backend.
|
||||||
|
|
||||||
## Writing a minigame
|
## Writing a minigame
|
||||||
|
|
||||||
A minigame is a standard Slopsmith plugin that:
|
A minigame is a standard FeedBack plugin that:
|
||||||
|
|
||||||
1. Adds a `minigame` block to its `plugin.json`:
|
1. Adds a `minigame` block to its `plugin.json`:
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ A minigame is a standard Slopsmith plugin that:
|
|||||||
2. On script load, registers itself with the SDK using the safe late-binding
|
2. On script load, registers itself with the SDK using the safe late-binding
|
||||||
pattern (minigame plugins may load before the SDK; the pending queue
|
pattern (minigame plugins may load before the SDK; the pending queue
|
||||||
handles both orderings — the SDK drains it on init, and the
|
handles both orderings — the SDK drains it on init, and the
|
||||||
`slopsmith-minigames-ready` event is an alternative for plugins that prefer
|
`feedBack-minigames-ready` event is an alternative for plugins that prefer
|
||||||
event-driven registration):
|
event-driven registration):
|
||||||
|
|
||||||
> **Important:** `spec.id` must exactly match the `id` field in `plugin.json`.
|
> **Important:** `spec.id` must exactly match the `id` field in `plugin.json`.
|
||||||
@@ -51,20 +51,20 @@ A minigame is a standard Slopsmith plugin that:
|
|||||||
stop: () => { /* tear down */ },
|
stop: () => { /* tear down */ },
|
||||||
};
|
};
|
||||||
|
|
||||||
if (window.slopsmithMinigames) {
|
if (window.feedBackMinigames) {
|
||||||
window.slopsmithMinigames.register(spec);
|
window.feedBackMinigames.register(spec);
|
||||||
} else {
|
} else {
|
||||||
(window.__slopsmithMinigamesPending = window.__slopsmithMinigamesPending || []).push(spec);
|
(window.__feedBackMinigamesPending = window.__feedBackMinigamesPending || []).push(spec);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Calls `window.slopsmithMinigames.end({ score, durationMs, modifiers, meta })` when the run ends.
|
3. Calls `window.feedBackMinigames.end({ score, durationMs, modifiers, meta })` when the run ends.
|
||||||
|
|
||||||
See [`slopsmith-plugin-flappy-bend`](https://github.com/got-feedback/feedback-plugin-flappy-bend) for a working example.
|
See [`feedBack-plugin-flappy-bend`](https://github.com/got-feedback/feedBack-plugin-flappy-bend) for a working example.
|
||||||
|
|
||||||
## SDK reference
|
## SDK reference
|
||||||
|
|
||||||
`window.slopsmithMinigames` exposes:
|
`window.feedBackMinigames` exposes:
|
||||||
|
|
||||||
- `register(spec)` — declare a minigame
|
- `register(spec)` — declare a minigame
|
||||||
- `start(gameId, opts)` / `end(result)` — lifecycle
|
- `start(gameId, opts)` / `end(result)` — lifecycle
|
||||||
@@ -77,4 +77,4 @@ See [`slopsmith-plugin-flappy-bend`](https://github.com/got-feedback/feedback-pl
|
|||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
- `slopsmith-plugin-notedetect` >= 1.10.0 — required for discrete/chord scoring modes (continuous mode is self-contained).
|
- `feedBack-plugin-notedetect` >= 1.10.0 — required for discrete/chord scoring modes (continuous mode is self-contained).
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "minigames",
|
"id": "minigames",
|
||||||
"name": "Minigames",
|
"name": "Minigames",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"bundled": true,
|
"bundled": true,
|
||||||
"private": false,
|
"private": false,
|
||||||
"screen": "screen.html",
|
"screen": "screen.html",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user