mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 08:54:30 +00:00
Compare commits
52
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
932c0b28ac | ||
|
|
1cf3dcba67 | ||
|
|
d3037b903e | ||
|
|
fdbab2a9e1 | ||
|
|
a8ad02739a | ||
|
|
ea25cfe541 | ||
|
|
37aedd4251 | ||
|
|
32127bc70b | ||
|
|
a07edd9971 | ||
|
|
5d0229fc82 | ||
|
|
820a18648a | ||
|
|
187d0bb978 | ||
|
|
5145710a8a | ||
|
|
fe8d30ce3e | ||
|
|
530995dd02 | ||
|
|
4dc5936712 | ||
|
|
f79efe2516 | ||
|
|
63eb7a4ffc | ||
|
|
a0867f8bfd | ||
|
|
9f35fedeef | ||
|
|
73e3fe2226 | ||
|
|
e518910baa | ||
|
|
4195b73877 | ||
|
|
3fc077cbc1 | ||
|
|
ea22791984 | ||
|
|
f182bd0ab7 | ||
|
|
9b793c5dbd | ||
|
|
0f1006972b | ||
|
|
6ee5da3d8b | ||
|
|
a858617d71 | ||
|
|
351b273ab5 | ||
|
|
e33df9a720 | ||
|
|
b8382139ca | ||
|
|
587fbbea81 | ||
|
|
e64378da78 | ||
|
|
e8db65afcb | ||
|
|
293dc86d83 | ||
|
|
c557742174 | ||
|
|
23735ef910 | ||
|
|
1183f100ee | ||
|
|
9b71d8ddcb | ||
|
|
d5e184d6e0 | ||
|
|
a72c0d2e17 | ||
|
|
8f800e07b1 | ||
|
|
21997f4b5c | ||
|
|
36aeea67ac | ||
|
|
a7a93e9bef | ||
|
|
a39e03559d | ||
|
|
c7fb074111 | ||
|
|
fb06e288e1 | ||
|
|
313348a1ff | ||
|
|
21b8a6cd49 |
@@ -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
|
||||||
@@ -35,6 +36,8 @@ plugins/minigames/__pycache__/
|
|||||||
!plugins/tuner/
|
!plugins/tuner/
|
||||||
!plugins/tuner/**
|
!plugins/tuner/**
|
||||||
plugins/tuner/__pycache__/
|
plugins/tuner/__pycache__/
|
||||||
|
!plugins/input_setup/
|
||||||
|
!plugins/input_setup/**
|
||||||
node_modules/
|
node_modules/
|
||||||
test-results/
|
test-results/
|
||||||
playwright-report/
|
playwright-report/
|
||||||
@@ -46,3 +49,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,10 +97,12 @@ 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`, `docs/sloppak-spec.md`) is the preferred
|
format (`lib/sloppak.py`; specified at
|
||||||
|
[got-feedback/feedback-feedpak-spec](https://github.com/got-feedback/feedback-feedpak-spec),
|
||||||
|
published as feedpak — same format) is the preferred
|
||||||
format and the home for new features; loose-folder XML charts
|
format and the home for new features; loose-folder XML charts
|
||||||
(`lib/loosefolder.py`) are also discovered and played as a first-class
|
(`lib/loosefolder.py`) are also discovered and played as a first-class
|
||||||
format. Both must keep playing across releases.
|
format. Both must keep playing across releases.
|
||||||
@@ -143,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`)
|
||||||
@@ -169,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
|
||||||
@@ -186,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.
|
||||||
|
|
||||||
@@ -217,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
|
||||||
|
|||||||
+44
-32
@@ -8,45 +8,57 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- **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`.
|
- **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.
|
||||||
- **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`.
|
- **"Song Editor" promoted to a first-class v3 sidebar item.** The editor
|
||||||
- **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.
|
plugin (`id: editor`) now gets its own dedicated sidebar entry — under the
|
||||||
- **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.
|
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.
|
- **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.
|
||||||
- **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).
|
- **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.
|
||||||
- **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).
|
- **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.
|
||||||
|
|
||||||
### 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.
|
||||||
@@ -55,21 +67,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.
|
||||||
@@ -77,17 +89,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`).
|
||||||
@@ -96,8 +108,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
|
||||||
|
|
||||||
@@ -530,9 +530,15 @@ cover.jpg Album art (optional)
|
|||||||
lyrics.json Syllable-level lyrics (optional)
|
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/feedBack-plugin-stems) provides live stem mixing for sloppak songs.
|
||||||
|
|
||||||
**Full developer reference:** [docs/sloppak-spec.md](docs/sloppak-spec.md) — manifest schema, arrangement wire format, and how to extend the format with new data types (drum tab, key/scale annotations, etc.).
|
**Full developer reference:** the authoritative format spec now lives in its own repo —
|
||||||
|
[got-feedback/feedpak-spec](https://github.com/got-feedback/feedpak-spec)
|
||||||
|
([`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
|
||||||
|
tab, key/scale annotations, etc.). Published as **feedpak**; this codebase still uses the legacy
|
||||||
|
**sloppak** name internally — same on-disk format. [docs/sloppak-spec.md](docs/sloppak-spec.md) is
|
||||||
|
a local pointer + code map.
|
||||||
|
|
||||||
**Key code:**
|
**Key code:**
|
||||||
- `lib/sloppak.py` — format detection, zip/directory resolution, metadata extraction, song loading
|
- `lib/sloppak.py` — format detection, zip/directory resolution, metadata extraction, song loading
|
||||||
@@ -542,9 +548,9 @@ Sloppak is the preferred format for new features. The [Stems plugin](https://git
|
|||||||
## 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
|
||||||
- **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.
|
||||||
|
|
||||||
@@ -577,8 +583,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.
|
||||||
@@ -588,14 +594,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*
|
||||||
|
|
||||||
@@ -615,7 +621,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,44 @@
|
|||||||
|
|
||||||
| 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` |
|
| [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` |
|
||||||
| [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` |
|
| [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` |
|
||||||
| [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
|
||||||
|
|
||||||
|
|||||||
@@ -22,5 +22,5 @@ too sharp / too flat / not played).
|
|||||||
- **[Implementation Plan](docs/NOTE_FAILURE_PLAN.md)** — 7 phases from
|
- **[Implementation Plan](docs/NOTE_FAILURE_PLAN.md)** — 7 phases from
|
||||||
detection foundation through section grading and polish
|
detection foundation through section grading and polish
|
||||||
- **Note Detection Plugin Plan** — see the
|
- **Note Detection Plugin Plan** — see the
|
||||||
[slopsmith-plugin-notedetect](https://github.com/topkoa/slopsmith-plugin-notedetect)
|
[feedBack-plugin-notedetect](https://github.com/topkoa/feedBack-plugin-notedetect)
|
||||||
repository (Phase 0 foundation)
|
repository (Phase 0 foundation)
|
||||||
|
|||||||
+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
|
||||||
|
|||||||
+34
-22
@@ -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,19 @@ 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
|
||||||
|
|
||||||
|
The MIDI-input slice (spec 012, issues #873/#880) promotes `midi-input` as a **core-owned** provider-coordinator implemented by [static/capabilities/midi-input.js](../static/capabilities/midi-input.js) — the MIDI analog of `audio-input`. It is deliberately separate from `audio-input` (whose source/`open` contract is audio-frame-centric: channel shapes, sample buffers) because MIDI carries discrete messages, not audio; and it is **not** owned by any feature plugin, so the device-access boundary outlives the input-setup wizard (exactly as `audio-input` is `core.audio.session`-owned). Consumers — the `input_setup` onboarding wizard, the `piano`/keys and `drums` plugins, and (as a follow-up, #881) note-detection's Web-MIDI provider — converge here on ONE device-access boundary: one permission prompt, one source list, one redaction boundary, retiring private per-plugin `navigator.requestMIDIAccess()` calls.
|
||||||
|
|
||||||
|
Native providers register source summaries with `providerId`, a stable `sourceId`, a derived redaction-safe `logicalSourceKey` (`providerId::sourceId`), `kind: "midi"`, a label, and `availability`. The public command surface is `inspect`, `list-sources`, `discover`, `select-source`, `open-source`, and `close-source`; provider operations are `source.enumerate`, `source.describe`, `source.open`, and `source.close`. `inspect`, `list-sources`, and `select-source` are prompt-free and never request MIDI access. Unlike audio (where `getUserMedia` gates labels and `open-source` is the prompt), Web-MIDI's `requestMIDIAccess()` gates the whole input list, so **`discover` is the permission boundary** and records `denied`/`unavailable` outcomes; `open-source` then attaches a shared listener session and never re-prompts.
|
||||||
|
|
||||||
|
Selected input is persisted by `logicalSourceKey` (`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.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
@@ -173,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.
|
||||||
@@ -189,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',
|
||||||
@@ -232,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.
|
||||||
|
|
||||||
@@ -242,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.
|
||||||
|
|
||||||
@@ -262,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;
|
||||||
@@ -278,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. |
|
||||||
|
|
||||||
@@ -108,7 +108,7 @@ These domains are planned but should stay out of the runtime graph until a host
|
|||||||
| `ui.player-overlays` | exclusive-owner | safe | Overlay contributions layered over player or highway surfaces. | Overlay placement and z-order rules that coexist with legacy overlays. |
|
| `ui.player-overlays` | exclusive-owner | safe | Overlay contributions layered over player or highway surfaces. | Overlay placement and z-order rules that coexist with legacy overlays. |
|
||||||
| `plugins` | exclusive-owner | privileged | Plugin enable/disable/install/update workflows. | Visible user confirmation, rollback, and disabled-handler enforcement. |
|
| `plugins` | exclusive-owner | privileged | Plugin enable/disable/install/update workflows. | Visible user confirmation, rollback, and disabled-handler enforcement. |
|
||||||
| `jobs` | multi-provider | privileged | Long-running jobs, cancellation, status, failures. | Scheduling limits, cancellation semantics, and user-visible failures. |
|
| `jobs` | multi-provider | privileged | Long-running jobs, cancellation, status, failures. | Scheduling limits, cancellation semantics, and user-visible failures. |
|
||||||
| `midi-control` | multi-provider | sensitive | MIDI device providers and control mappings. | Device consent and redacted diagnostics. |
|
| `midi-control` | multi-provider | sensitive | MIDI control mappings only (CC/pitchbend/note → action routing), consuming `midi-input` for device access. Device discovery/selection/open is split out to the delivered `midi-input` domain (spec 012). | A concrete mapping/routing workflow on top of the `midi-input` device plane (#882). |
|
||||||
| `audio-input` | multi-provider | sensitive | Audio input device providers, source selection, open/close lifecycle, shared sessions, and redacted failure diagnostics. | Promoted by the audio graph/session slice and implemented by the audio-input control-plane slice. |
|
| `audio-input` | multi-provider | sensitive | Audio input device providers, source selection, open/close lifecycle, shared sessions, and redacted failure diagnostics. | Promoted by the audio graph/session slice and implemented by the audio-input control-plane slice. |
|
||||||
| `tempo-clock` | multi-provider | safe | Tempo/clock provider registration and consumers. | A concrete tempo source and consumer workflow. |
|
| `tempo-clock` | multi-provider | safe | Tempo/clock provider registration and consumers. | A concrete tempo source and consumer workflow. |
|
||||||
|
|
||||||
@@ -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,13 +14,15 @@ 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. |
|
||||||
|
|
||||||
|
| midi-input | provider-coordinator | sensitive | inspect, list-sources, discover, select-source, open-source, close-source | source.enumerate, source.describe, source.open, source.close | Core-owned MIDI device control plane (spec 012), the MIDI analog of `audio-input`. Inspect/list/select are prompt-free; `discover` is the Web-MIDI permission boundary (`requestMIDIAccess()` gates the whole input list) and records denied/unavailable outcomes; `open-source` attaches a shared listener session and never re-prompts. Selection persists by redaction-safe `logicalSourceKey`. Diagnostics redact device labels and never include raw MIDI messages or live handles. |
|
||||||
|
|
||||||
Privileged commands are roadmap-only until they have: a visible user confirmation path, diagnostics redaction rules, failure recovery, and tests that prove disabled or incompatible participants cannot execute handlers.
|
Privileged commands are roadmap-only until they have: a visible user confirmation path, diagnostics redaction rules, failure recovery, and tests that prove disabled or incompatible participants cannot execute handlers.
|
||||||
|
|
||||||
## Expected Future Domains
|
## Expected Future Domains
|
||||||
@@ -38,9 +40,9 @@ These domains are expected future capability contracts, not current runtime grap
|
|||||||
| ui.player-overlays | exclusive-owner | safe | register-contribution, mount, unmount, set-visible, reorder-by-policy, inspect | Needs overlay placement rules that coexist with legacy highway overlays. |
|
| ui.player-overlays | exclusive-owner | safe | register-contribution, mount, unmount, set-visible, reorder-by-policy, inspect | Needs overlay placement rules that coexist with legacy highway overlays. |
|
||||||
| plugins | exclusive-owner | privileged | enable, disable, install-missing, update, inspect | Needs explicit user confirmation for writes/install/update. |
|
| plugins | exclusive-owner | privileged | enable, disable, install-missing, update, inspect | Needs explicit user confirmation for writes/install/update. |
|
||||||
| jobs | multi-provider | privileged | register, inspect, cancel | Needs scheduling limits, cancellation semantics, and user-visible failures. |
|
| jobs | multi-provider | privileged | register, inspect, cancel | Needs scheduling limits, cancellation semantics, and user-visible failures. |
|
||||||
| midi-control | multi-provider | sensitive | register, inspect | Needs device consent and redacted diagnostics. |
|
| midi-control | multi-provider | sensitive | list-mappings, get-mapping, set-mapping, delete-mapping, activate-mapping, inspect | Mappings ONLY — CC/pitchbend/note → semantic action routing (spec 013). Device discovery/selection/open is NOT this domain's job: it consumes the delivered `midi-input` domain for device access. Needs a concrete mapping consumer (the MIDI control plugin / drums learn-mode) + redacted diagnostics (no raw MIDI streams) before promotion. |
|
||||||
| tempo-clock | multi-provider | safe | register, inspect | Needs a concrete provider and consumer workflow. |
|
| 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.
|
||||||
|
|
||||||
@@ -177,7 +177,7 @@ entry explaining why.
|
|||||||
"capability_validation_warnings": [],
|
"capability_validation_warnings": [],
|
||||||
"capability_unsupported_versions": [],
|
"capability_unsupported_versions": [],
|
||||||
"compatibility_shims": [],
|
"compatibility_shims": [],
|
||||||
"git": { "sha": "abc123d", "remote": "https://github.com/topkoa/slopsmith-plugin-stems.git" }
|
"git": { "sha": "abc123d", "remote": "https://github.com/topkoa/feedBack-plugin-stems.git" }
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"orphans": [
|
"orphans": [
|
||||||
@@ -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
|
||||||
|
|||||||
+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 [sloppak-spec.md](sloppak-spec.md). This document is the **how-do-I-actually-edit-mine** companion.
|
> For the format **schema** (what every field means, how the wire format works, how to extend the format with new data types), see the authoritative [feedpak spec](https://github.com/got-feedback/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,8 +72,8 @@ 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/feedBack-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/feedBack-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 [sloppak-spec.md §5.7](sloppak-spec.md#57-dont-break-the-manifest-contract) for the full list.
|
Don't add per-machine settings (audio device picks, MIDI port IDs), UI state, or your own play counts. The sloppak holds the song's authored data — anything that varies by user or machine lives in 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 [sloppak-spec.md §4.2](sloppak-spec.md#42-writing-python-server-side).
|
- **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 [sloppak-spec.md §3](sloppak-spec.md#3-arrangement-json--the-wire-format), but for any real chart edit you want the [Arrangement Editor plugin](https://github.com/got-feedback/feedback-plugin-editor).
|
- **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/`.
|
||||||
|
|||||||
+31
-921
@@ -1,939 +1,49 @@
|
|||||||
# Sloppak Format — Developer Guide
|
# Sloppak / feedpak Format — moved
|
||||||
|
|
||||||
Sloppak is Slopsmith's open, hand-editable song format. This guide is for developers who want to **read**, **write**, or **extend** the format — including adding new data types like drum tabs, vocal pitches, lighting cues, key/scale annotations, or anything else a future visualization plugin might need.
|
The full format specification that used to live here has moved to its own repository and is now
|
||||||
|
the **authoritative, versioned reference**:
|
||||||
|
|
||||||
> If you're a **user** wanting to modify an existing sloppak — record your own rhythm stem, fix metadata, swap cover art, replace a Demucs split — see [sloppak-hand-editing.md](sloppak-hand-editing.md). That guide is the practical, step-by-step companion to this developer reference.
|
> **📖 https://github.com/got-feedback/feedpak-spec**
|
||||||
|
> — 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.
|
||||||
|
|
||||||
The authoritative format reference lives in code (`lib/sloppak.py`, `lib/song.py`); this doc explains the why, the how, and the conventions you should follow when adding to it.
|
Update bookmarks to point there. This page is a thin pointer kept at the original path so existing
|
||||||
|
links keep resolving.
|
||||||
|
|
||||||
---
|
## Naming: `sloppak` here, `feedpak` in the spec
|
||||||
|
|
||||||
## 1. Format at a glance
|
The published format is named **feedpak** (extension `.feedpak`, manifest key `feedpak_version`).
|
||||||
|
This codebase still uses the legacy **sloppak** name internally — `lib/sloppak.py`, the
|
||||||
|
`.sloppak` extension, `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
|
||||||
|
spec says `feedpak` / `feedpak_version`, the packs this server reads and writes today are the same
|
||||||
|
structure under the `.sloppak` name. The internal rename is a separate, later effort.
|
||||||
|
|
||||||
A sloppak exists in **two interchangeable forms**:
|
## Hand-editing a pack
|
||||||
|
|
||||||
| Form | What it is | Used for |
|
For the practical "how do I edit my own pack" walkthrough (record your own stem, fix metadata,
|
||||||
|---|---|---|
|
swap cover art, replace a stem split), see the companion guide that stays in this repo:
|
||||||
| **Directory** | A folder named `*.sloppak/` containing the files below | Authoring, hand editing, plugin development |
|
[sloppak-hand-editing.md](sloppak-hand-editing.md).
|
||||||
| **Zip archive** | A `.sloppak` file (zip with the same files inside) | Distribution |
|
|
||||||
|
|
||||||
Both forms hold identical contents. Slopsmith resolves either transparently — zip files are unpacked to a cache the first time they're opened (see `resolve_source_dir()` in [lib/sloppak.py](../lib/sloppak.py)).
|
## Where the format maps to code (this repo)
|
||||||
|
|
||||||
### Directory layout
|
The spec is implementation-independent; this table is the feedback-specific bridge from format
|
||||||
|
concepts to the code that reads and writes them. It is **not** part of the format.
|
||||||
```
|
|
||||||
my-song.sloppak/
|
|
||||||
├── manifest.yaml # Required — all metadata + file index
|
|
||||||
├── arrangements/
|
|
||||||
│ ├── lead.json # One JSON per playable arrangement
|
|
||||||
│ ├── rhythm.json
|
|
||||||
│ └── bass.json
|
|
||||||
├── stems/
|
|
||||||
│ ├── full.ogg # Mixed audio (initial single-stem output; may be absent after stem splitting)
|
|
||||||
│ ├── guitar.ogg # Optional individual stems
|
|
||||||
│ ├── bass.ogg
|
|
||||||
│ ├── drums.ogg
|
|
||||||
│ ├── vocals.ogg
|
|
||||||
│ └── other.ogg
|
|
||||||
├── lyrics.json # Optional — syllable-level lyrics
|
|
||||||
└── cover.jpg # Optional — album art
|
|
||||||
```
|
|
||||||
|
|
||||||
Three rules to remember:
|
|
||||||
|
|
||||||
1. **`manifest.yaml` is the index.** Nothing inside the sloppak is auto-discovered — every file path is listed in the manifest. This makes the format predictable: no scanning, no guessing. (One historical exception: the cover-art handler in `server.py` falls back to `cover.jpg` when `manifest.cover` is missing. New code should not add similar filename fallbacks.)
|
|
||||||
2. **Filenames in `manifest.yaml` are POSIX paths**, relative to the sloppak root (forward slashes, no leading `/`).
|
|
||||||
3. **YAML for the manifest, JSON for everything else.** YAML is hand-editable for users; JSON is fast-parsed and easy to round-trip in code.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. `manifest.yaml` reference
|
|
||||||
|
|
||||||
Minimal valid manifest:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
title: "Black Hole Sun"
|
|
||||||
artist: "Soundgarden"
|
|
||||||
duration: 320.5
|
|
||||||
arrangements:
|
|
||||||
- id: lead
|
|
||||||
name: Lead
|
|
||||||
file: arrangements/lead.json
|
|
||||||
tuning: [0, 0, 0, 0, 0, 0]
|
|
||||||
capo: 0
|
|
||||||
stems:
|
|
||||||
- id: full
|
|
||||||
file: stems/full.ogg
|
|
||||||
default: true
|
|
||||||
```
|
|
||||||
|
|
||||||
Full set of currently-recognized top-level keys:
|
|
||||||
|
|
||||||
| Key | Type | Required | Description |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `title` | string | yes | Song title |
|
|
||||||
| `artist` | string | yes | Artist name |
|
|
||||||
| `album` | string | no | Album |
|
|
||||||
| `year` | int | no | Release year |
|
|
||||||
| `duration` | float | yes | Song length in seconds |
|
|
||||||
| `arrangements` | list | yes | Playable arrangements (see §2.1) |
|
|
||||||
| `stems` | list | yes | Audio stems (see §2.2) |
|
|
||||||
| `stem_separation` | object | no | Structured metadata when stems were produced by an automated separation engine (currently `demucs`). Shape: `{engine, model, version}`. See §2.2 for fields + semver semantics per [slopsmith#357](https://github.com/got-feedback/feedback/issues/357). Omitted for single-stem sloppaks (`stems: [{id: full, ...}]`) and for hand-edited / user-recorded stems |
|
|
||||||
| `lyrics` | string | no | Path to lyrics JSON |
|
|
||||||
| `lyrics_source` | string | no | Where the lyrics came from: `xml` (vocals XML from the chart source), `whisperx` (auto-transcribed), or `user` (hand-edited). Absent on legacy sloppaks — readers should treat missing as `xml` |
|
|
||||||
| `lyric_transcription` | object | no | Structured metadata when lyrics came from an automated engine (currently `whisperx`). Same shape as the parent `stem_separation` block defined by [slopsmith#357](https://github.com/got-feedback/feedback/issues/357) — see §2.3 for fields and semver semantics. Omitted for authored lyrics (`xml`/`user`) |
|
|
||||||
| `vocal_pitch` | string | no | Path to per-syllable pitch JSON (`{"version": 1, "notes": [{t, d, midi}, ...]}`). Consumed by [slopsmith-plugin-lyrics-karaoke](https://github.com/got-feedback/feedback-plugin-lyrics-karaoke) to render karaoke note bars. See §2.4 |
|
|
||||||
| `pitch_extraction` | object | no | Structured metadata when pitch was extracted by an automated engine (currently `crepe` via the demucs server's `/pitch` endpoint). Same shape as `stem_separation` / `lyric_transcription`. Omitted for hand-edited pitch tracks |
|
|
||||||
| `cover` | string | no | Path to cover image |
|
|
||||||
| `preview` | string | no | Path to a short preview audio clip (OGG) at the sloppak root. Populated when the source carries a separate short browser-preview clip (decoded to `preview.ogg`); absent otherwise. Consumed by [`slopsmith-plugin-song-preview`](https://github.com/got-feedback/feedback-plugin-song-preview) for hover-to-listen previews in the library |
|
|
||||||
| `song_timeline` | string | no | Path to a `song_timeline.json` file carrying song-wide beats and sections (see §5.3). When present, its data takes priority over any beats/sections embedded in arrangement JSONs. Older readers ignore the key and fall back to reading beats/sections from the first arrangement JSON as before |
|
|
||||||
| `drum_tab` | string | no | Path to `drum_tab.json` — per-piece drum hits (see §5.3). Implemented end-to-end as of slopsmith#344 |
|
|
||||||
|
|
||||||
Unknown keys are **silently ignored** by the loader. This is deliberate — it's the extensibility hook (see §5).
|
|
||||||
|
|
||||||
### 2.1. `arrangements[]`
|
|
||||||
|
|
||||||
Each entry describes one playable arrangement and points at its JSON file:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
arrangements:
|
|
||||||
- id: lead # filesystem-safe stable ID, used for filenames
|
|
||||||
name: Lead # display name (Lead/Rhythm/Bass/Combo are sorted first)
|
|
||||||
file: arrangements/lead.json
|
|
||||||
tuning: [0, 0, 0, 0, 0, 0] # six semitone offsets from E A D G B E
|
|
||||||
capo: 0
|
|
||||||
centOffset: 0.0 # optional float, cents; default 0.0
|
|
||||||
```
|
|
||||||
|
|
||||||
- `tuning` is a list of semitone offsets from standard `E2 A2 D2 G3 B3 E4`. **Six elements is the standard six-string convention** and the only length `lib/tunings.py` produces friendly names for; 5- and 7-string content is accepted by the loader and falls through to a numeric label. For bass, the four bass strings are at indices 0–3; the other two slots are `0`. Consumers should not hard-code `len(tuning) == 6`.
|
|
||||||
- `name` controls the sort order in the UI: `Lead > Combo > Rhythm > Bass > everything else`.
|
|
||||||
- `centOffset` is a pitch-shift value in cents. Commonly `-1200.0` for extended-range bass arrangements tuned one octave down; small non-zero values for songs mastered at a non-A440 reference pitch (e.g. A443 ≈ +11.8 cents). Absent / `0.0` means no shift. Exposed to plugins via `getSongInfo().centOffset`.
|
|
||||||
- Manifest-level `tuning`, `capo`, and `centOffset` **override** anything embedded in the arrangement JSON. The arrangement JSON's own values are fallbacks.
|
|
||||||
- `notation` (optional string) — path to a `notation_<id>.json` file carrying standard musical notation data for this arrangement (see §5.3). When present, the loader surfaces it on `LoadedSloppak.notation_by_id[id]` and the highway WS streams `notation_info` + `notation_measures` messages. The `file:` key may be omitted when `notation:` is present — the loader creates a stub arrangement so the notation file can be the sole data source.
|
|
||||||
|
|
||||||
### 2.2. `stems[]`
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
stems:
|
|
||||||
- id: full
|
|
||||||
file: stems/full.ogg
|
|
||||||
default: true # plays by default when the song opens
|
|
||||||
- id: guitar
|
|
||||||
file: stems/guitar.ogg
|
|
||||||
default: true
|
|
||||||
- id: drums
|
|
||||||
file: stems/drums.ogg
|
|
||||||
default: false
|
|
||||||
```
|
|
||||||
|
|
||||||
- `id` is referenced by the Stems plugin and any other consumer; keep it stable.
|
|
||||||
- `default` accepts `true`/`false`, or strings (`"on"`/`"off"`/`"true"`/etc.) for hand-edited manifests.
|
|
||||||
- A freshly converted sloppak from `lib/sloppak_convert.py` starts with a single `{id: full, file: stems/full.ogg, ...}` entry. After stem-splitting (Demucs), `full.ogg` is removed and the manifest is rewritten with per-instrument entries (`guitar`, `bass`, `drums`, `vocals`, `other`). The format requires only that `stems` is non-empty — there's no specific filename or id that must always be present.
|
|
||||||
|
|
||||||
When stems were produced by an automated separation engine (Demucs), an optional `stem_separation` block records which engine + model produced them. Per [slopsmith#357](https://github.com/got-feedback/feedback/issues/357):
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
stem_separation:
|
|
||||||
engine: demucs # stable engine id; only `demucs` today
|
|
||||||
model: htdemucs_6s # specific model name (htdemucs_6s / htdemucs_ft / htdemucs / mdx_extra / ...)
|
|
||||||
version: 1.0.0 # semver for slopsmith's stem-artifact contract
|
|
||||||
```
|
|
||||||
|
|
||||||
Fields:
|
|
||||||
- `engine` — stable identifier for the separation engine. Currently always `demucs`. New engines (e.g. a hypothetical `spleeter`) would get their own stable id.
|
|
||||||
- `model` — the engine-specific model id used for this split. For Demucs this is the `-n` flag value.
|
|
||||||
- `version` — semver for Slopsmith's stem-artifact contract (independent of upstream Demucs / model versions). Bump per the same semantics #357 defines: patch = metadata-only fixes, minor = backward-compatible additions, major = stem set / packing / post-processing changed and existing splits should be regenerated.
|
|
||||||
|
|
||||||
Omitted for single-stem sloppaks (`stems: [{id: full, ...}]` — no automated separation ran) and for hand-edited / user-recorded stems. The RFC reserves a separate `stem_authoring` sibling block for the hand-edit case; that's deferred to a follow-up.
|
|
||||||
|
|
||||||
A remote Demucs server can use this block as part of a cache key so that changing the model or major version naturally produces a cache miss. Local plugin jobs should preserve this metadata in job state and in any copied/downloaded manifests.
|
|
||||||
|
|
||||||
### 2.3. `lyrics`
|
|
||||||
|
|
||||||
If present, points at a JSON file containing a flat list of syllable objects:
|
|
||||||
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{"t": 12.34, "d": 0.18, "w": "Hel"},
|
|
||||||
{"t": 12.52, "d": 0.22, "w": "lo-"},
|
|
||||||
{"t": 13.10, "d": 0.30, "w": "world"}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
| Field | Meaning |
|
|
||||||
|---|---|
|
|
||||||
| `t` | Time in seconds |
|
|
||||||
| `d` | Duration in seconds |
|
|
||||||
| `w` | Syllable text. Trailing `-` joins to the next syllable as one word; trailing `+` marks the last syllable of a line (renderer wraps after it). Both are suffixes on a real syllable — not standalone entries. See `static/highway.js` for the rendering: `raw.endsWith('+')` flags end-of-line, and `sylText` strips the trailing marker before drawing |
|
|
||||||
|
|
||||||
When lyrics are present, the optional top-level `lyrics_source` key records where they came from. The assembler sets it to `xml` when the lyrics were parsed from the source chart's vocals XML; the WhisperX auto-transcription fallback (`scripts/transcribe_lyrics.py`, or `--auto-lyrics` on the split scripts) sets it to `whisperx`. Hand-edited lyrics should bump it to `user` so UI consumers can render a different badge (or no badge) than for machine-generated lyrics. The key is absent on sloppaks produced before this field existed — readers should treat missing as `xml` for backward compatibility.
|
|
||||||
|
|
||||||
When `lyrics_source` is `whisperx` (or any future automated engine), an optional `lyric_transcription` block records which engine + model produced the file. Shape mirrors the parent `stem_separation` RFC ([slopsmith#357](https://github.com/got-feedback/feedback/issues/357)):
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
lyric_transcription:
|
|
||||||
engine: whisperx # stable engine id
|
|
||||||
model: medium # the WhisperX model size that ran (tiny/base/small/medium/large-v2/large-v3)
|
|
||||||
version: 1.0.0 # semver for slopsmith's lyric-transcription artifact contract
|
|
||||||
```
|
|
||||||
|
|
||||||
Fields:
|
|
||||||
- `engine` — stable identifier for the transcription engine; currently always `whisperx`.
|
|
||||||
- `model` — the engine-specific model id used for this transcription.
|
|
||||||
- `version` — semver for Slopsmith's lyric-transcription artifact contract (independent of upstream Whisper / WhisperX versions). Bump per the same semantics #357 defines for stems: patch = metadata-only fixes, minor = backward-compatible additions, major = output shape changed and existing transcriptions should be regenerated.
|
|
||||||
|
|
||||||
Omitted for authored lyrics (`xml` / `user`). A remote WhisperX server can use this block as part of a cache key the same way #357 envisions for stems — caches should miss whenever any of the three fields change, ensuring stale transcriptions don't get returned after a model bump.
|
|
||||||
|
|
||||||
### 2.4. `vocal_pitch`
|
|
||||||
|
|
||||||
If present, points at a JSON file holding per-syllable pitch data — the karaoke companion to `lyrics`. Consumed by [slopsmith-plugin-lyrics-karaoke](https://github.com/got-feedback/feedback-plugin-lyrics-karaoke) to render karaoke-style note bars over the lyric text. Shape:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"version": 1,
|
|
||||||
"notes": [
|
|
||||||
{"t": 12.34, "d": 0.40, "midi": 64},
|
|
||||||
{"t": 12.78, "d": 0.55, "midi": 67}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Field | Meaning |
|
|
||||||
|---|---|
|
|
||||||
| `version` | Schema version of this `vocal_pitch.json` file (currently the integer `1`). Bump on a breaking change to the `notes` entry shape. This is *not* the same as the top-level `pitch_extraction.version` block below, which is a semver string used as a cache-key for the extractor engine |
|
|
||||||
| `notes` | List of pitch entries, one per syllable that the extractor could lock onto. `t` + `d` mirror the matching `lyrics.json` entry; `midi` is the MIDI note number (60 = middle C). Syllables the extractor couldn't pitch (silent / sub-confidence) are omitted from this list — it may be shorter than `lyrics.json` |
|
|
||||||
|
|
||||||
When pitch came from an automated engine (the demucs server's `/pitch` endpoint, which runs CREPE), the optional top-level `pitch_extraction` block records which engine + model produced the file. Same shape and semver-string semantics as `stem_separation` / `lyric_transcription` — distinct from the in-file integer `version` field above:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
pitch_extraction:
|
|
||||||
engine: crepe
|
|
||||||
model: v1
|
|
||||||
version: 1.0.0
|
|
||||||
```
|
|
||||||
|
|
||||||
Omitted for hand-edited pitch tracks. As with the other two automated-artifact blocks, a remote pitch server can use this for cache-key invalidation.
|
|
||||||
|
|
||||||
The sloppak assembler runs pitch extraction automatically when `pitch_extraction.enabled` is set in its config AND a server URL is configured (either `pitch_extraction.server_url` or the shared `demucs_server_url`) AND the sloppak has lyrics + a `stems/vocals.ogg` after the split pass — either because `_maybe_transcribe_lyrics` just produced them via WhisperX OR because they were already on disk (from the source chart's vocals XML, hand-authoring, or an earlier build). Pitch is *not* coupled to `whisperx.enabled` — setting `pitch_extraction.enabled=true` alone (with WhisperX off) is enough to retro-generate pitch over any existing on-disk lyrics. Sloppaks built before this field existed simply don't carry it — readers should treat missing `vocal_pitch` as "no pitch data, fall back to whatever the karaoke plugin's local-extraction path produces (if any)".
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Arrangement JSON — the wire format
|
|
||||||
|
|
||||||
Arrangement JSON files use the **wire format** produced by `arrangement_to_wire()` — the on-disk representation of a complete arrangement. Slopsmith's `/ws/highway/{filename}` endpoint transports similar data as a sequence of typed messages (`notes`, `chords`, `anchors`, `chord_templates`, `phrases`, …) rather than as one identical top-level JSON object. In practice, the WebSocket stream reuses the same per-object field names where applicable, but it should not be treated as a byte-for-byte match for `arrangements/*.json`.
|
|
||||||
|
|
||||||
The authoritative serializer/deserializer is in [lib/song.py](../lib/song.py):
|
|
||||||
|
|
||||||
- `arrangement_to_wire(arr) → dict` — write
|
|
||||||
- `arrangement_from_wire(dict) → Arrangement` — read
|
|
||||||
|
|
||||||
### 3.1. Top-level shape
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "Lead",
|
|
||||||
"tuning": [0, 0, 0, 0, 0, 0],
|
|
||||||
"capo": 0,
|
|
||||||
"centOffset": 0.0, /* optional, float cents, default 0.0 */
|
|
||||||
"notes": [ /* see 3.2 */ ],
|
|
||||||
"chords": [ /* see 3.3 */ ],
|
|
||||||
"anchors": [ /* see 3.4 */ ],
|
|
||||||
"handshapes": [ /* see 3.5 */ ],
|
|
||||||
"templates": [ /* see 3.6 */ ],
|
|
||||||
"phrases": [ /* optional, see 3.7 */ ],
|
|
||||||
"tones": { /* optional, see 3.9 */ },
|
|
||||||
"beats": [ /* see 3.8, only on first arrangement */ ],
|
|
||||||
"sections": [ /* see 3.8, only on first arrangement */ ]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`beats` and `sections` are **song-level** but live on the first arrangement's JSON for legacy reasons — `lib/sloppak.py` hoists them to the `Song` object on load. If you author multiple arrangements, only put them in one file. **New sloppaks should use `song_timeline.json` instead** (see §2 and §5.3) — when the manifest carries a `song_timeline:` key pointing at a schema-valid file, its beats/sections **replace** whatever the arrangement JSONs loaded (the override is applied after arrangement loading, so a valid `song_timeline.json` always wins). Arrangement-JSON beats/sections remain supported for backward compatibility with all existing sloppaks and are the fallback when the file is absent or invalid.
|
|
||||||
|
|
||||||
### 3.2. Notes
|
|
||||||
|
|
||||||
Field names are short on purpose — these get streamed thousands of times per song. Don't expand them.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"t": 12.345, // time (s)
|
|
||||||
"s": 2, // string (0 = lowest)
|
|
||||||
"f": 7, // fret (0 = open, 24 = max)
|
|
||||||
"sus": 0.5, // sustain (s, 0 = none)
|
|
||||||
"sl": 9, // pitched slide-to fret (-1 = no slide)
|
|
||||||
"slu": -1, // unpitched slide-to fret (-1 = no slide)
|
|
||||||
"bn": 1.0, // bend amount in semitones
|
|
||||||
"ho": false, // hammer-on
|
|
||||||
"po": false, // pull-off
|
|
||||||
"hm": false, // natural harmonic
|
|
||||||
"hp": false, // pinch harmonic
|
|
||||||
"pm": false, // palm mute
|
|
||||||
"mt": false, // string mute
|
|
||||||
"vb": false, // vibrato
|
|
||||||
"tr": false, // tremolo
|
|
||||||
"ac": false, // accent
|
|
||||||
"tp": false, // tap
|
|
||||||
"ln": false, // link-next (chord linking metadata; renderers may ignore — runtime linking is derived from proximity)
|
|
||||||
"fhm": false, // fret-hand mute
|
|
||||||
"plk": false, // pluck (pop, bass)
|
|
||||||
"slp": false, // slap (bass)
|
|
||||||
"rh": -1, // right-hand fingering (-1 = unset)
|
|
||||||
"pkd": -1, // pick direction (-1 = unset, 0 = down, 1 = up)
|
|
||||||
"ig": false // ignore (chart-author flag — note is rendered but not scored / sequenced)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Default values: numbers → `0` or `-1` (slides / `rh` / `pkd`), bools → `false`. Omit fields equal to their default if you're authoring by hand — the parser fills them in. **Encoders should default-omit the newer technique keys** (`ln`, `fhm`, `plk`, `slp`, `rh`, `pkd`, `ig`) — the highway streams notes thousands of times per song, so trimming the common case keeps the WebSocket payload tight. The pre-existing keys are still emitted unconditionally to preserve the legacy wire contract.
|
|
||||||
|
|
||||||
### 3.3. Chords
|
|
||||||
|
|
||||||
A chord groups note-shaped objects under a single time:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"t": 30.0,
|
|
||||||
"id": 12, // index into templates[]
|
|
||||||
"hd": false, // high-density flag
|
|
||||||
"notes": [
|
|
||||||
{"s": 0, "f": 3, "sus": 0.0, ...},
|
|
||||||
{"s": 1, "f": 5, "sus": 0.0, ...}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Chord notes use the same field set as standalone notes, **except `t` is omitted** (the chord carries the time). The fingering / shape lookup is `chord.id → templates[id]`.
|
|
||||||
|
|
||||||
### 3.4. Anchors
|
|
||||||
|
|
||||||
Where the fretting hand sits. Drives the highway zoom box.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"time": 12.0, "fret": 5, "width": 4}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.5. Hand shapes
|
|
||||||
|
|
||||||
Spans during which a chord shape is held:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"chord_id": 12, "start_time": 30.0, "end_time": 31.5, "arp": false}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `chord_id` (`int`, default `0`) — index into `templates[]`; identifies which chord template the span is holding.
|
|
||||||
- `start_time` (`float`, default `0.0`) — start of the span in seconds.
|
|
||||||
- `end_time` (`float`, default `0.0`) — end of the span in seconds.
|
|
||||||
- `arp` (`bool`, default `false`, allowed values `true`/`false`) — whether this hand shape should be treated as an arpeggio span rather than a fully-strummed chord hold.
|
|
||||||
|
|
||||||
### 3.6. Chord templates
|
|
||||||
|
|
||||||
Named shapes referenced by `chord.id` and `handshape.chord_id`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "Em7",
|
|
||||||
"displayName": "Em7",
|
|
||||||
"arp": false,
|
|
||||||
"fingers": [-1, 2, 1, -1, -1, -1],
|
|
||||||
"frets": [ 0, 2, 2, 0, 0, 0]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `name` (`string`, default `""`) — canonical template name used by the parser / authoring data.
|
|
||||||
- `displayName` (`string`, default `name`) — label shown in the UI; source XML may use this for display-specific variants such as `-arp`.
|
|
||||||
- `arp` (`bool`, default `false`, allowed values `true`/`false`) — whether the template is flagged as arpeggiated. Parsed from explicit XML attributes (`arpeggio` / `arp`, any common casing) or inferred from `displayName` markers such as `-arp`.
|
|
||||||
- `fingers` (`int[6]`, default `[-1, -1, -1, -1, -1, -1]`) — fretting-hand finger numbers, lowest string first. `-1` = unused string, `0` = open string / no fretting finger, `1..4` = index/middle/ring/pinky.
|
|
||||||
- `frets` (`int[6]`, default `[-1, -1, -1, -1, -1, -1]`) — fret numbers, lowest string first. `-1` = unused string, `0` = open string, positive values = fretted note.
|
|
||||||
|
|
||||||
### 3.7. Phrases (optional, multi-difficulty data)
|
|
||||||
|
|
||||||
Sources that carry per-phrase difficulty ladders (phrase-aware arrangement XML) include this. GP imports and legacy sloppaks omit it:
|
|
||||||
|
|
||||||
```json
|
|
||||||
"phrases": [
|
|
||||||
{
|
|
||||||
"start_time": 0.0,
|
|
||||||
"end_time": 12.5,
|
|
||||||
"max_difficulty": 4,
|
|
||||||
"levels": [
|
|
||||||
{ "difficulty": 0, "notes": [...], "chords": [...], "anchors": [...], "handshapes": [...] },
|
|
||||||
{ "difficulty": 1, "notes": [...], "chords": [...], "anchors": [...], "handshapes": [...] },
|
|
||||||
...
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
If you're writing a converter that doesn't have multi-difficulty data, **omit the `phrases` key entirely** (don't emit `"phrases": []`). A missing key signals "no ladder, disable the master-difficulty slider"; an empty list is the same in current code but reads ambiguously.
|
|
||||||
|
|
||||||
### 3.8. Beats and sections
|
|
||||||
|
|
||||||
```json
|
|
||||||
"beats": [{"time": 0.5, "measure": 1}, {"time": 1.0, "measure": -1}, ...],
|
|
||||||
"sections": [{"name": "verse", "number": 1, "time": 12.5}, ...]
|
|
||||||
```
|
|
||||||
|
|
||||||
`measure: -1` = sub-beat (not a downbeat). Section `name` follows the usual song-structure conventions (`intro`, `verse`, `chorus`, `bridge`, `solo`, `outro`, …).
|
|
||||||
|
|
||||||
### 3.9. Tones (optional)
|
|
||||||
|
|
||||||
`tones` carries the arrangement's guitar tones — the amp/pedal/cabinet gear and the in-song tone switches. It's populated when the source chart carries tone data (`lib/tones.py`); a sloppak authored from scratch may omit it entirely.
|
|
||||||
|
|
||||||
```json
|
|
||||||
"tones": {
|
|
||||||
"base": "Clean Rhythm",
|
|
||||||
"changes": [
|
|
||||||
{"t": 12.5, "name": "Lead Drive"},
|
|
||||||
{"t": 48.0, "name": "Clean Rhythm"}
|
|
||||||
],
|
|
||||||
"definitions": [
|
|
||||||
{
|
|
||||||
"Name": "Clean Rhythm",
|
|
||||||
"Key": "Tone_A",
|
|
||||||
"GearList": { /* raw gear blocks: Amp, PrePedal1-4, … */ }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `base` (string) — the tone in effect before the first change.
|
|
||||||
- `changes` (list, time-sorted) — `{"t": seconds, "name": str}` tone switches. The highway draws a marker at each. Omit when the arrangement never switches tone.
|
|
||||||
- `definitions` (list) — the **raw tone objects** (`Name`, `Key`, `GearList`), copied verbatim from the source chart's tone manifest. The Tones plugin parses these into the rendered signal chain (it owns the gear-name/image map, so the data is stored unparsed here).
|
|
||||||
|
|
||||||
All three sub-keys are individually optional; an arrangement with none of them simply omits `tones`. Readers that don't know about tones ignore the key (the loader preserves it verbatim).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Reading and writing sloppaks programmatically
|
|
||||||
|
|
||||||
### 4.1. Reading (Python, server-side)
|
|
||||||
|
|
||||||
```python
|
|
||||||
from pathlib import Path
|
|
||||||
from sloppak import load_song, load_manifest
|
|
||||||
|
|
||||||
# Quick metadata only (parses manifest, skips arrangement JSONs)
|
|
||||||
manifest = load_manifest(Path("song.sloppak"))
|
|
||||||
|
|
||||||
# Full song load (manifest + all arrangements + lyrics)
|
|
||||||
loaded = load_song("song.sloppak", dlc_root=Path("/dlc"), unpack_cache_root=Path("/cache"))
|
|
||||||
print(loaded.song.title, len(loaded.song.arrangements))
|
|
||||||
print(loaded.stems) # [{"id": "full", "file": "stems/full.ogg", "default": True}]
|
|
||||||
print(loaded.manifest) # raw dict — read your custom keys here
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.2. Writing (Python, server-side)
|
|
||||||
|
|
||||||
There's no general-purpose writer in `lib/` yet. The current writer lives in [lib/sloppak_convert.py](../lib/sloppak_convert.py) inside the sloppak assembly function — it's the single source of truth for "how a sloppak gets built." If you need to write sloppaks from a new source, copy the structure of that function:
|
|
||||||
|
|
||||||
1. Build a `work_dir/` in temp.
|
|
||||||
2. Write `arrangements/{id}.json` per arrangement using `arrangement_to_wire()`.
|
|
||||||
3. Encode audio to OGG into `stems/`.
|
|
||||||
4. Optionally write `lyrics.json`, `cover.jpg`.
|
|
||||||
5. Compose the `manifest` dict and dump as YAML with `yaml.safe_dump(manifest, sort_keys=False, allow_unicode=True)`.
|
|
||||||
6. Either `shutil.copytree(work_dir, out)` for directory form, or `_zip_dir(work_dir, out)` for zip form.
|
|
||||||
|
|
||||||
Always use `yaml.safe_dump` (not `yaml.dump`) and pass `sort_keys=False` so the human-readable order is preserved.
|
|
||||||
|
|
||||||
### 4.3. Reading (JavaScript, plugin-side)
|
|
||||||
|
|
||||||
Plugins typically don't read the sloppak file directly — they consume the `/ws/highway/{filename}` WebSocket stream (see `CLAUDE.md` for the message protocol), which produces the same shapes. If you specifically need raw manifest access from the browser, expose it through a custom backend route in your plugin's `routes.py` and fetch it.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Extending the format — adding new data
|
|
||||||
|
|
||||||
Sloppak is designed to be extended without breaking older readers. The conventions below come from how `lyrics`, `stems`, and the optional `phrases` ladder were each added.
|
|
||||||
|
|
||||||
### 5.1. The golden rule: **manifest opt-in, file off to the side**
|
|
||||||
|
|
||||||
New data types should follow this pattern:
|
|
||||||
|
|
||||||
1. **Drop a new file** alongside the standard ones (e.g., `drums.json`, `keys.json`, `lighting.json`).
|
|
||||||
2. **Add a manifest key** that *points at* that file (e.g., `drum_tab: drums.json`).
|
|
||||||
3. **Make consumers gate on the manifest key**: if the key is absent, do nothing. Never auto-discover by filename — that breaks the "manifest is the index" rule.
|
|
||||||
|
|
||||||
So a sloppak with drum tabs would look like:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# manifest.yaml
|
|
||||||
title: "Song"
|
|
||||||
artist: "Band"
|
|
||||||
duration: 240.0
|
|
||||||
arrangements: [...]
|
|
||||||
stems: [...]
|
|
||||||
drum_tab: drum_tab.json # ← new key
|
|
||||||
```
|
|
||||||
|
|
||||||
```
|
|
||||||
my-song.sloppak/
|
|
||||||
├── manifest.yaml
|
|
||||||
├── arrangements/...
|
|
||||||
├── stems/...
|
|
||||||
└── drum_tab.json # ← new file
|
|
||||||
```
|
|
||||||
|
|
||||||
Older Slopsmith readers ignore the unknown `drum_tab` key (the loader uses `manifest.get("drum_tab")` / unknown keys pass through). Your plugin checks for it and renders accordingly. **Zero coordination needed with core.**
|
|
||||||
|
|
||||||
### 5.2. Naming conventions for new keys and files
|
|
||||||
|
|
||||||
- **Manifest keys**: `snake_case`, descriptive, singular when the value is one thing (`lyrics`, `cover`, `drum_tab`), plural when it's a list (`stems`, `arrangements`).
|
|
||||||
- **File names**: lowercase, hyphenated or underscored, JSON for structured data, OGG for audio, JPG/PNG for images.
|
|
||||||
- **Inside JSON**: short field names for hot-path data that gets streamed thousands of times (`t`, `s`, `f` — see §3.2). Long names are fine for one-off metadata.
|
|
||||||
- **Time fields**: always `t` or `time` (not `start`, not `timestamp`) — and always **seconds as floats**, not ms or ticks. Be consistent with the existing wire format.
|
|
||||||
- **Indexes / IDs**: stable, filesystem-safe, lowercase. Don't reuse a source format's internal numeric IDs unless you have to.
|
|
||||||
|
|
||||||
### 5.3. Worked examples for the kinds of additions you mentioned
|
|
||||||
|
|
||||||
#### Drum tab
|
|
||||||
|
|
||||||
`drum_tab.json` carries per-piece hits authored on top of the song's audio.
|
|
||||||
Implemented end-to-end as of slopsmith#344 (drums-from-scratch): the loader
|
|
||||||
in `lib/sloppak.py` parses it, `lib/drums.py` defines the canonical piece-id
|
|
||||||
vocabulary, and `/ws/highway/{filename}` streams it as `drum_tab` + chunked
|
|
||||||
`drum_hits` messages.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"version": 1,
|
|
||||||
"name": "Drums",
|
|
||||||
"kit": [
|
|
||||||
{"id": "kick", "name": "Kick"},
|
|
||||||
{"id": "snare", "name": "Snare"},
|
|
||||||
{"id": "hh_closed", "name": "Hi-hat (closed)"},
|
|
||||||
{"id": "hh_open", "name": "Hi-hat (open)"},
|
|
||||||
{"id": "crash_r", "name": "Crash (right)"},
|
|
||||||
{"id": "ride", "name": "Ride"}
|
|
||||||
],
|
|
||||||
"hits": [
|
|
||||||
{"t": 0.500, "p": "kick", "v": 110},
|
|
||||||
{"t": 0.750, "p": "snare", "v": 92},
|
|
||||||
{"t": 0.750, "p": "hh_closed", "v": 70},
|
|
||||||
{"t": 1.000, "p": "snare", "v": 60, "g": true},
|
|
||||||
{"t": 1.250, "p": "snare", "v": 105, "f": true},
|
|
||||||
{"t": 4.000, "p": "crash_r", "v": 120, "k": 0.080}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Manifest:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
drum_tab: drum_tab.json
|
|
||||||
```
|
|
||||||
|
|
||||||
##### Hit fields
|
|
||||||
|
|
||||||
| key | type | meaning |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `t` | float seconds | hit time, required, monotonic in `hits[]` |
|
|
||||||
| `p` | string | piece-id from the closed list below; required |
|
|
||||||
| `v` | int 1-127 | velocity (default 100) |
|
|
||||||
| `g` | bool | ghost note (renders smaller / outline-only) |
|
|
||||||
| `f` | bool | flam (renders a small leading ghost glyph 30 ms early) |
|
|
||||||
| `k` | float seconds | cymbal-choke tail duration (renders a fade-out) |
|
|
||||||
|
|
||||||
##### Canonical piece-id vocabulary
|
|
||||||
|
|
||||||
A closed list lives in `lib/drums.py::PIECES`. Open/closed hi-hat are
|
|
||||||
**distinct piece-ids**, not articulation flags — hit detection must reject
|
|
||||||
a closed-hat strike on an open-hat note, which it can only do if the
|
|
||||||
articulation is part of the piece-id.
|
|
||||||
|
|
||||||
| piece-id | category | default GM MIDI | default shape |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| `kick` | kick | 35, 36 | bar (full-width across all non-kick lanes) |
|
|
||||||
| `snare` | drum | 38, 40 | rectangle |
|
|
||||||
| `snare_xstick` | drum | 37 | hatched rectangle |
|
|
||||||
| `tom_hi` | drum | 50, 48 | rectangle |
|
|
||||||
| `tom_mid` | drum | 47, 45 | rectangle |
|
|
||||||
| `tom_low` | drum | 43 | rectangle |
|
|
||||||
| `tom_floor` | drum | 41 | rectangle |
|
|
||||||
| `hh_closed` | cymbal | 42 | filled circle |
|
|
||||||
| `hh_open` | cymbal | 46 | ring (outline) circle |
|
|
||||||
| `hh_pedal` | cymbal | 44 | small circle with × |
|
|
||||||
| `stack` | cymbal | 30 | jagged circle (no GM standard — reuses 30 from extended-percussion range) |
|
|
||||||
| `crash_l` | cymbal | 49 | circle |
|
|
||||||
| `crash_r` | cymbal | 57 | circle |
|
|
||||||
| `splash` | cymbal | 55 | small circle |
|
|
||||||
| `china` | cymbal | 52 | jagged circle |
|
|
||||||
| `ride` | cymbal | 51, 59 | circle |
|
|
||||||
| `ride_bell` | cymbal | 53 | circle with centre dot |
|
|
||||||
| `bell` | cymbal | 80 | circle with centre dot (no GM standard — reuses "Mute Triangle") |
|
|
||||||
|
|
||||||
Unknown piece-ids round-trip through the loader (forward-compat); the
|
|
||||||
client just renders them as a default rectangle.
|
|
||||||
|
|
||||||
##### Wire format
|
|
||||||
|
|
||||||
Streamed as two highway-WS message types:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "type": "drum_tab", "version": 1, "name": "Drums",
|
|
||||||
"kit": [{"id": "kick", "name": "Kick"}, ...], "total": 1234 }
|
|
||||||
```
|
|
||||||
|
|
||||||
…followed by one or more chunks of 500 hits:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "type": "drum_hits", "data": [{"t": 0.5, "p": "kick", "v": 110}, ...],
|
|
||||||
"total": 1234 }
|
|
||||||
```
|
|
||||||
|
|
||||||
##### Design notes
|
|
||||||
|
|
||||||
- `kit[]` is the legend — fixed metadata, separated from hot-path data.
|
|
||||||
- `hits[]` uses short field names because this list can be thousands long.
|
|
||||||
- `v` defaults to 100; ghost / flam / choke flags are all optional.
|
|
||||||
- Older sloppaks whose drums are encoded as guitar notes (`midi = string*24 + fret`) still play — the drums plugin keeps a legacy decoder that reads the standard `notes` stream and synthesises `drum_hits` from it.
|
|
||||||
|
|
||||||
#### Song timeline (beats and sections as a top-level file)
|
|
||||||
|
|
||||||
`song_timeline.json` moves song-wide beats and sections out of the first
|
|
||||||
arrangement JSON and into a dedicated file. Implemented in `lib/sloppak.py`
|
|
||||||
alongside the notation format: the loader reads the manifest's optional
|
|
||||||
`song_timeline:` key, validates the file, and populates `Song.beats` /
|
|
||||||
`Song.sections` from it, taking priority over any beats/sections embedded
|
|
||||||
in arrangement JSONs.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"version": 1,
|
|
||||||
"beats": [
|
|
||||||
{"time": 0.500, "measure": 1},
|
|
||||||
{"time": 1.000, "measure": -1},
|
|
||||||
{"time": 1.500, "measure": -1},
|
|
||||||
{"time": 2.000, "measure": 2}
|
|
||||||
],
|
|
||||||
"sections": [
|
|
||||||
{"name": "intro", "number": 1, "time": 0.0},
|
|
||||||
{"name": "verse", "number": 1, "time": 16.0},
|
|
||||||
{"name": "chorus", "number": 1, "time": 32.0}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Manifest:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
song_timeline: song_timeline.json
|
|
||||||
```
|
|
||||||
|
|
||||||
| Field in `beats[]` | Type | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| `time` | float seconds | Beat timestamp. Matches the existing arrangement-JSON wire convention |
|
|
||||||
| `measure` | int | 1-based downbeat number. `-1` = sub-beat (not a downbeat) |
|
|
||||||
|
|
||||||
| Field in `sections[]` | Type | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| `name` | string | song-structure convention: `intro`, `verse`, `chorus`, `bridge`, `solo`, `outro`, … |
|
|
||||||
| `number` | int | Section repeat number |
|
|
||||||
| `time` | float seconds | Section start |
|
|
||||||
|
|
||||||
**Backward compatibility.** Sloppaks without `song_timeline:` continue to
|
|
||||||
work — the loader falls through to reading beats/sections from the first
|
|
||||||
arrangement JSON exactly as before. No migration is needed.
|
|
||||||
|
|
||||||
**New sloppaks** should put beats/sections here and leave arrangement JSONs
|
|
||||||
free of timeline data. This is especially important for notation-only
|
|
||||||
arrangements (see below) where there may be no arrangement JSON at all.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### Notation format (standard musical notation per arrangement)
|
|
||||||
|
|
||||||
The notation format promotes keys, piano, violin, and any other
|
|
||||||
staff-notation instrument to first-class status with their own data
|
|
||||||
structure, separate from the guitar wire format. Implemented in
|
|
||||||
`lib/sloppak.py` and `lib/notation.py`; the highway WS streams
|
|
||||||
`notation_info` + `notation_measures` messages when notation data is
|
|
||||||
present for the active arrangement.
|
|
||||||
|
|
||||||
**Architecture: per-arrangement, not song-wide.** Unlike `drum_tab`
|
|
||||||
(one drum track per song, top-level manifest key), notation is
|
|
||||||
per-instrument. A song could carry both `notation_keys.json` and
|
|
||||||
`notation_violin.json`. The manifest key lives on the **arrangement
|
|
||||||
entry**, not at the top level.
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
arrangements:
|
|
||||||
- id: keys
|
|
||||||
name: Keys
|
|
||||||
type: piano
|
|
||||||
notation: notation_keys.json # per-arrangement sub-key
|
|
||||||
# file: is optional when notation: is present
|
|
||||||
```
|
|
||||||
|
|
||||||
```text
|
|
||||||
my-song.sloppak/
|
|
||||||
├── manifest.yaml
|
|
||||||
├── song_timeline.json
|
|
||||||
├── notation_keys.json
|
|
||||||
└── stems/
|
|
||||||
└── full.ogg
|
|
||||||
```
|
|
||||||
|
|
||||||
**`notation_<id>.json` — file schema:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"version": 1,
|
|
||||||
"instrument": "piano",
|
|
||||||
"staves": [
|
|
||||||
{"id": "rh", "clef": "G2", "label": "Right Hand"},
|
|
||||||
{"id": "lh", "clef": "F4", "label": "Left Hand"}
|
|
||||||
],
|
|
||||||
"measures": [
|
|
||||||
{
|
|
||||||
"idx": 1,
|
|
||||||
"t": 0.0,
|
|
||||||
"ts": [4, 4],
|
|
||||||
"ks": 0,
|
|
||||||
"tempo": 120.0,
|
|
||||||
"staves": {
|
|
||||||
"rh": {
|
|
||||||
"voices": [{"v": 1, "beats": [
|
|
||||||
{"t": 0.000, "dur": 4, "notes": [{"midi": 64}]},
|
|
||||||
{"t": 0.500, "dur": 4, "notes": [{"midi": 67}]}
|
|
||||||
]}]
|
|
||||||
},
|
|
||||||
"lh": {
|
|
||||||
"voices": [{"v": 1, "beats": [
|
|
||||||
{"t": 0.000, "dur": 1, "notes": [{"midi": 52}, {"midi": 60}]}
|
|
||||||
]}]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Top-level fields:**
|
|
||||||
|
|
||||||
| Field | Type | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| `version` | int | Always `1`. Bump on breaking schema change |
|
|
||||||
| `instrument` | string | Mirrors arrangement `type`: `piano`, `violin`, `guitar`, etc. Makes the file self-describing |
|
|
||||||
| `rights` | string | Optional copyright / rights text (MusicXML `<rights>`). Omit when absent |
|
|
||||||
| `lyricist` | string | Optional lyricist credit (MusicXML `<creator type="lyricist">`). Omit when absent |
|
|
||||||
| `arranger` | string | Optional arranger credit (MusicXML `<creator type="arranger">`). Omit when absent |
|
|
||||||
| `staves` | list | Static staff definitions. Each has `id` (stable, referenced by `measures[].staves` keys), `clef` (see below), and optional `label` |
|
|
||||||
| `measures` | list | Ordered measure data — the hot path |
|
|
||||||
|
|
||||||
**Clef vocabulary** (defined in `lib/notation.py::CLEFS`):
|
|
||||||
|
|
||||||
| Value | Meaning |
|
|
||||||
|---|---|
|
|
||||||
| `G2` | Treble clef — guitar, violin, flute, piano RH |
|
|
||||||
| `F4` | Bass clef — bass guitar, cello, piano LH |
|
|
||||||
| `C3` | Alto clef — viola |
|
|
||||||
| `C4` | Tenor clef — cello upper register, trombone |
|
|
||||||
| `neutral` | Unpitched / percussion staff |
|
|
||||||
|
|
||||||
**Measure fields:**
|
|
||||||
|
|
||||||
| Field | Type | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| `idx` | int | 1-based measure number |
|
|
||||||
| `t` | float | Time in seconds at measure downbeat |
|
|
||||||
| `ts` | int[2] | Time signature `[numerator, denominator]`. Omit if unchanged |
|
|
||||||
| `beat_groups` | int[] | Beat grouping for compound and irregular meters, as a list of integers. Each integer is the count of time-signature denominator units in that primary beat group. The sum must equal the time-signature numerator. E.g. 6/8 → `[3, 3]`; 9/8 → `[3, 3, 3]`; 7/8 → `[2, 2, 3]`; 5/8 → `[2, 3]` or `[3, 2]`. Omit for simple meters (2/4, 3/4, 4/4) where grouping is unambiguous. Renderers translate this to their own beam-grouping API at render time — this field is renderer-agnostic. |
|
|
||||||
| `ks` | int | Key signature: semitones from C, −7 to +7 (negative = flats, positive = sharps). Omit if unchanged |
|
|
||||||
| `tempo` | float | BPM. Omit if unchanged |
|
|
||||||
| `pickup` | bool | `true` when this measure is an anacrusis (pickup / upbeat) shorter than the time signature implies (MusicXML `implicit="yes"`). Renderers suppress the measure number and start counting from the next full measure. Omit when false |
|
|
||||||
| `staves` | object | Keyed by staff `id`. Each staff has optional `clef` (omit if unchanged) and `voices` |
|
|
||||||
|
|
||||||
**Beat fields** (inside `staves → voices → beats`):
|
|
||||||
|
|
||||||
| Field | Default | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| `t` | required | Time in seconds |
|
|
||||||
| `dur` | required | Duration denominator: `1`=whole, `2`=half, `4`=quarter, `8`=eighth, `16`=sixteenth, `32`=thirty-second |
|
|
||||||
| `dot` | omit | Augmentation dots: `1`=dotted, `2`=double-dotted |
|
|
||||||
| `rest` | omit | `true` if this beat is a rest; `notes` is omitted |
|
|
||||||
| `tu` | omit | Tuplet: `[numerator, denominator]`, e.g. `[3, 2]` for triplet |
|
|
||||||
| `beat_pos` | omit | Exact position within the measure as a rational `[numerator, denominator]` pair, where the denominator is the time-signature denominator. E.g. beat 2 in 6/8 (the second dotted quarter) = `[3, 8]`. Avoids floating-point imprecision when deriving beat position from tempo and absolute time. Omit if not set by the importer. Renderers that do not recognise this field derive position from `t` and the tempo map as before. |
|
|
||||||
| `notes` | omit | List of note objects (omit for rests) |
|
|
||||||
| `dyn` | omit | Dynamic: `ppp`, `pp`, `p`, `mp`, `mf`, `f`, `ff`, `fff` |
|
|
||||||
| `slr` | omit | Slur start |
|
|
||||||
| `slre` | omit | Slur end |
|
|
||||||
| `grace` | omit | Grace-note beat, typed: `"a"` = acciaccatura (slashed, steals time from the previous note; MusicXML `<grace slash="yes">`), `"p"` = appoggiatura (unslashed, steals time from the following note; `<grace>`). The beat's `dur` is the grace note's written duration. Vocabulary in `lib/notation.py::GRACE_TYPES` |
|
|
||||||
| `arp` | omit | `true` when the beat's chord is arpeggiated (rolled; MusicXML `<arpeggiate>`) |
|
|
||||||
| `ferm` | omit | `true` when the beat carries a fermata (MusicXML `<fermata>`) |
|
|
||||||
| `spd` / `sph` / `spu` | omit | Sustain pedal: pedal **d**own / **h**old-through-this-beat / **u**p. This is the only pedal encoding — there is deliberately no separate `ped` field. MusicXML mapping: `<pedal type="start">` → `spd`, `<pedal type="change">` → `spu` + `spd` on the same beat (re-pedal), `<pedal type="stop">` → `spu`; beats inside an active pedal span carry `sph` |
|
|
||||||
| Additional beat effects | omit | `cre`, `dec`, `vib`, `vibw`, `fade`, `pm`, `lr`, `slap`, `pop`, `tap`, `su`, `sd`, `rasg`, `golpe`, `wah`, `txt`, `chrd` — all optional, omit when absent |
|
|
||||||
|
|
||||||
**Note fields** (inside `beats → notes`):
|
|
||||||
|
|
||||||
| Field | Default | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| `midi` | required | MIDI pitch 0–127. Unambiguous — no string/fret/tuning indirection |
|
|
||||||
| `tied` | omit | Tied from the previous beat |
|
|
||||||
| `acc` | omit | Accidental override: `null`/omit = derive from key sig; `0` = force natural (♮); `−2`/`−1`/`1`/`2` = double-flat/flat/sharp/double-sharp |
|
|
||||||
| `stem` | omit | Force stem direction: `"up"` or `"down"` (MusicXML `<stem>`). Omit to let the renderer decide. Vocabulary in `lib/notation.py::STEM_DIRECTIONS` |
|
|
||||||
| Additional note effects | omit | `stc`, `ten`, `ac`, `hac`, `vib`, `vibw`, `dead`, `ghost`, `fng`, `rfng`, `str`, `harm`, `bend`, `slide`, `trill`, `ho`, `po`, `tp`, `barre` — all optional |
|
|
||||||
|
|
||||||
**Wire format.** `song_info` carries `has_notation: bool`. Notation data
|
|
||||||
is streamed as two highway-WS message types after `sections`, before `anchors`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"type": "notation_info", "version": 1, "instrument": "piano",
|
|
||||||
"staves": [...], "total": 64}
|
|
||||||
```
|
|
||||||
|
|
||||||
…followed by one or more chunks of 32 measures:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"type": "notation_measures", "data": [...], "total": 64}
|
|
||||||
```
|
|
||||||
|
|
||||||
`total` is the measure count across **all** chunks. Clients accumulate `data` arrays until the accumulated measure count reaches `total` (an individual chunk's `data.length` says nothing — every full chunk of a multi-chunk stream is shorter than `total`). The `anchors` frame that follows the notation block is a secondary end-of-block signal.
|
|
||||||
|
|
||||||
**`lib/notation.py`** is the vocabulary library: `SCHEMA_VERSION`, `CLEFS`, `DURATIONS`, `validate_notation()`, `measure_to_wire()`, `measures_to_wire()`.
|
|
||||||
|
|
||||||
**Legacy fallback.** Sloppaks that carry keys as guitar wire format (Clone Hero converted content) continue to work — the notation plugin checks for the `notation` key on the arrangement entry. When absent, it falls back to decoding guitar wire format notes via `midi = s * 24 + f`.
|
|
||||||
|
|
||||||
**v1 non-features (accepted limitations).** The following are deliberately
|
|
||||||
out of schema v1; they ship, if ever, as **additive v1.x patches** (new
|
|
||||||
optional fields old consumers ignore — the permissive validator passes
|
|
||||||
unknown fields through by design):
|
|
||||||
|
|
||||||
- Microtonal pitch (anything finer than the ±2 semitone `acc` vocabulary).
|
|
||||||
- Figured bass.
|
|
||||||
- Mid-measure key-signature, time-signature, or clef changes (all three are
|
|
||||||
measure-granular in v1).
|
|
||||||
- Ottava lines (`ott`), repeat/volta barline semantics (`barline`),
|
|
||||||
ornaments beyond trills (mordents, turns), tremolo (`trem`), and notated
|
|
||||||
glissando lines (`glis`).
|
|
||||||
|
|
||||||
Importers MUST drop these source features with a logged warning rather than
|
|
||||||
approximate them into wrong notation; renderers MUST NOT invent semantics
|
|
||||||
for field names from this list before a v1.x patch specifies them.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### Key / scale annotations (for theory-aware visualizations)
|
|
||||||
|
|
||||||
`keys.json` mirroring the `sections[]` shape:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"version": 1,
|
|
||||||
"events": [
|
|
||||||
{"t": 0.0, "key": "Em", "scale": "natural_minor"},
|
|
||||||
{"t": 64.5, "key": "G", "scale": "major"},
|
|
||||||
{"t": 142.0, "key": "Em", "scale": "natural_minor"}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Manifest:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
keys: keys.json
|
|
||||||
```
|
|
||||||
|
|
||||||
Each entry implicitly applies until the next event. Same model as `sections[]`.
|
|
||||||
|
|
||||||
#### Vocal pitch contour (a different shape, a different key)
|
|
||||||
|
|
||||||
The canonical `vocal_pitch` key + file (defined in §2.4) is the
|
|
||||||
per-syllable note format consumed by the karaoke plugin —
|
|
||||||
`{version: 1, notes: [{t, d, midi}]}`. If you want to ship a finer-
|
|
||||||
grained pitch *contour* (one sample every 20 ms, Hz instead of MIDI),
|
|
||||||
that's a different shape and should ride on its own manifest key so
|
|
||||||
the two don't collide:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
vocal_pitch_contour: vocal_pitch_contour.json
|
|
||||||
```
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"version": 1,
|
|
||||||
"samples": [
|
|
||||||
{"t": 0.000, "hz": 220.5},
|
|
||||||
{"t": 0.020, "hz": 222.1}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Per §5.1, manifest keys are cheap — reach for a new one when the
|
|
||||||
schema diverges, don't overload an existing key with a second shape.
|
|
||||||
|
|
||||||
### 5.4. `version` field — always include it
|
|
||||||
|
|
||||||
Every new file should have `"version": 1` at the top. It's free insurance: when you change the schema later, `version: 2` consumers can branch on it. Old consumers without that branch ignore the file (or fall back gracefully).
|
|
||||||
|
|
||||||
### 5.5. Stay backward-compatible
|
|
||||||
|
|
||||||
If you change a field that already shipped:
|
|
||||||
|
|
||||||
- **Adding fields** is always safe (older readers ignore them).
|
|
||||||
- **Removing fields** breaks older readers. Don't.
|
|
||||||
- **Repurposing fields** (changing meaning or units) is the worst — bump `version` and branch.
|
|
||||||
|
|
||||||
If you're tempted to remove or repurpose: leave the old field, add a new one, and sunset the old one over a release or two.
|
|
||||||
|
|
||||||
### 5.6. When to put data inside an arrangement vs. its own file
|
|
||||||
|
|
||||||
- **Inside arrangement JSON** (`arrangements/lead.json`):
|
|
||||||
- Data that is *per-arrangement* and *per-instrument* (notes, chords, anchors, hand-shapes — guitar specifics).
|
|
||||||
- Data that meaningfully differs between Lead and Rhythm versions of the same song.
|
|
||||||
- **Its own file** (and pointed-at via manifest key):
|
|
||||||
- Data that is *song-wide* (lyrics, beats, sections, tempo map, drum tab, lighting, key/scale changes).
|
|
||||||
- Data that may be authored or generated independently of the playable arrangement (a stem split, an AI-generated drum tab).
|
|
||||||
|
|
||||||
Beats and sections historically lived inside the first arrangement JSON (early arrangement XML put them there). The `song_timeline.json` file (see §5.3) is the correct home for new sloppaks — the loader reads it first and it takes priority. New song-wide data should always be its own file.
|
|
||||||
|
|
||||||
### 5.7. Don't break the manifest contract
|
|
||||||
|
|
||||||
A few things that should *not* end up in `manifest.yaml`:
|
|
||||||
|
|
||||||
- **Per-machine settings** (DMX universes, IPs, output device picks) — those go in `${CONFIG_DIR}/...json`, not the sloppak.
|
|
||||||
- **UI state** (last zoom level, panel sizes) — `localStorage` only.
|
|
||||||
- **User progress / play counts** — Slopsmith stores these in its metadata DB, not in the sloppak.
|
|
||||||
|
|
||||||
The sloppak holds **the song's authored data**. Anything that varies by user or by machine is out.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Quick reference — file types you'll touch
|
|
||||||
|
|
||||||
| File | Format | Schema lives in | Authority |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `manifest.yaml` | YAML | `lib/sloppak.py` (`load_manifest`, `extract_meta`) | This doc + the loader |
|
|
||||||
| `arrangements/*.json` | JSON | `lib/song.py` (`arrangement_to_wire`, `arrangement_from_wire`) | The wire-format functions |
|
|
||||||
| `lyrics.json` | JSON (flat list) | `lib/sloppak.py` (passed through to `Song.lyrics`) | This doc §2.3 |
|
|
||||||
| `song_timeline.json` | JSON | `lib/sloppak.py` (loader) | This doc §5.3 |
|
|
||||||
| `notation_<id>.json` | JSON | `lib/notation.py` (`validate_notation`, `measures_to_wire`) | This doc §5.3 |
|
|
||||||
| `stems/*.ogg` | OGG Vorbis | — | Convention: `q:a 5` for size/quality balance |
|
|
||||||
| `cover.jpg` | JPEG | — | Convention: square, 500–1500 px on a side |
|
|
||||||
| Your new file | JSON (preferred) | Your plugin's spec doc | You |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Testing your extension
|
|
||||||
|
|
||||||
If you add a new file type or manifest key:
|
|
||||||
|
|
||||||
1. **Round-trip test**: write a sample, load it, write it back, compare. Add to `tests/test_sloppak.py`.
|
|
||||||
2. **Backward-compat test**: load a sloppak that *doesn't* have your new key — your code must not crash, and the song must still play.
|
|
||||||
3. **Hand-edit test**: open the directory form in a text editor, change a field by hand, reload Slopsmith. The format is meant to be hand-editable; your additions should preserve that.
|
|
||||||
4. **Both forms**: test with both the directory form and the zipped form. The unpack cache is invalidated based on mtime and size, so you can repackage and reload without restarting the server.
|
|
||||||
|
|
||||||
The full pytest suite (`pytest`) must stay green before any PR.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Where to look in the code
|
|
||||||
|
|
||||||
| For… | Read |
|
| For… | Read |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Format detection, source resolution, zip unpacking | [lib/sloppak.py](../lib/sloppak.py) |
|
| Format detection, source resolution, zip unpacking | [lib/sloppak.py](../lib/sloppak.py) |
|
||||||
| Data classes (`Note`, `Chord`, `Arrangement`, `Song`, `Phrase`) | [lib/song.py](../lib/song.py) |
|
| Data classes (`Note`, `Chord`, `Arrangement`, `Song`, `Phrase`) | [lib/song.py](../lib/song.py) |
|
||||||
| Wire-format helpers (`*_to_wire` / `*_from_wire`) | [lib/song.py](../lib/song.py) |
|
| Wire-format helpers (`*_to_wire` / `*_from_wire`) | [lib/song.py](../lib/song.py) |
|
||||||
| The reference sloppak writer | [lib/sloppak_convert.py](../lib/sloppak_convert.py) |
|
| The reference pack writer (assembly pipeline) | [lib/sloppak_convert.py](../lib/sloppak_convert.py) |
|
||||||
| Drum tab vocabulary and wire helpers | [lib/drums.py](../lib/drums.py) |
|
| Drum-tab vocabulary and wire helpers | [lib/drums.py](../lib/drums.py) |
|
||||||
| Notation vocabulary and wire helpers | [lib/notation.py](../lib/notation.py) |
|
| Notation vocabulary and wire helpers | [lib/notation.py](../lib/notation.py) |
|
||||||
| Live streaming over WebSocket (consumes the same shapes) | `server.py` (`/ws/highway/{filename}`) |
|
| Live streaming over WebSocket (consumes the same shapes) | `server.py` (`/ws/highway/{filename}`) |
|
||||||
| The plugin system (where new viz consumers go) | [CLAUDE.md](../CLAUDE.md) — Plugin System section |
|
| The plugin system (where new visualization consumers go) | [CLAUDE.md](../CLAUDE.md) |
|
||||||
| Tests | [tests/test_sloppak.py](../tests/test_sloppak.py), [tests/test_sloppak_convert.py](../tests/test_sloppak_convert.py) |
|
| Tests | [tests/test_sloppak.py](../tests/test_sloppak.py), [tests/test_sloppak_convert.py](../tests/test_sloppak_convert.py) |
|
||||||
|
|
||||||
|
> **Note on older section references.** Some inline code comments in this repo cite section
|
||||||
|
> numbers from the previous version of this document (e.g. "sloppak-spec §5.3"). The external spec
|
||||||
|
> renumbered its sections, so those citations are approximate — find the topic by name in the
|
||||||
|
> [feedpak spec](https://github.com/got-feedback/feedpak-spec/blob/main/spec/feedpak-v1.md)
|
||||||
|
> 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:
|
||||||
|
|||||||
+7
-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:
|
||||||
@@ -522,6 +522,10 @@ def attach_notation_to_sloppak(sloppak_dir: str | Path, arr_id: str, payload: di
|
|||||||
json.dumps(payload, separators=(",", ":")), encoding="utf-8"
|
json.dumps(payload, separators=(",", ":")), encoding="utf-8"
|
||||||
)
|
)
|
||||||
entry["notation"] = filename
|
entry["notation"] = filename
|
||||||
|
# Stamp the format version while we're rewriting the manifest (spec §4),
|
||||||
|
# without downgrading an existing (possibly higher) declared version.
|
||||||
|
from sloppak import FEEDPAK_VERSION
|
||||||
|
manifest.setdefault("feedpak_version", FEEDPAK_VERSION)
|
||||||
manifest_path.write_text(
|
manifest_path.write_text(
|
||||||
yaml.safe_dump(manifest, sort_keys=False, allow_unicode=True),
|
yaml.safe_dump(manifest, sort_keys=False, allow_unicode=True),
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
|
|||||||
+212
-32
@@ -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,69 @@ 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]:
|
||||||
|
"""Per-string fingering for a chord template, in RS string order.
|
||||||
|
|
||||||
|
pyguitarpro exposes the chord-diagram voicing on ``beat.effect.chord``:
|
||||||
|
``chord.strings`` is a per-string fret list indexed 0 = highest string
|
||||||
|
(GP string 1), -1 = unplayed; ``chord.fingerings`` is the parallel list
|
||||||
|
of :class:`guitarpro.Fingering` enums (``open=-1, thumb=0, index=1,
|
||||||
|
middle=2, annular=3, little=4`` — already the RS finger integers). The
|
||||||
|
fingerings list may carry one trailing extra entry, so we only read the
|
||||||
|
first ``len(strings)`` of it.
|
||||||
|
|
||||||
|
Returns a list the same width as ``frets`` (RS string index 0 = low).
|
||||||
|
Only strings that are actually played in this template (``frets[rs] >= 0``)
|
||||||
|
get a finger; everything else stays -1. A chord without a populated
|
||||||
|
voicing yields all -1, so diagram-less charts are unchanged.
|
||||||
|
"""
|
||||||
|
fingers = [-1] * len(frets)
|
||||||
|
strings = getattr(chord, "strings", None) or []
|
||||||
|
fingerings = getattr(chord, "fingerings", None) or []
|
||||||
|
for i, fret in enumerate(strings):
|
||||||
|
if fret is None or fret < 0:
|
||||||
|
continue # string not part of the voicing
|
||||||
|
rs = _gp_string_to_rs(i + 1, num_strings)
|
||||||
|
if not (0 <= rs < len(frets)) or frets[rs] < 0:
|
||||||
|
continue
|
||||||
|
if i < len(fingerings):
|
||||||
|
val = getattr(fingerings[i], "value", fingerings[i])
|
||||||
|
fingers[rs] = val if isinstance(val, int) else -1
|
||||||
|
return fingers
|
||||||
|
|
||||||
|
|
||||||
|
def _chord_diagram_frets(chord, num_strings: int, width: int) -> list[int]:
|
||||||
|
"""RS-string-ordered absolute frets of the chord DIAGRAM voicing, padded to
|
||||||
|
``width`` with -1.
|
||||||
|
|
||||||
|
Used to confirm the diagram describes the voicing actually played before
|
||||||
|
enriching a template — mirrors the GP8 exact fret-pattern guard. pyguitarpro
|
||||||
|
stores absolute frets in ``chord.strings`` (``firstFret`` is display-only),
|
||||||
|
so the result compares directly against the played ``frets``."""
|
||||||
|
out = [-1] * width
|
||||||
|
strings = getattr(chord, "strings", None) or []
|
||||||
|
for i, fret in enumerate(strings):
|
||||||
|
if fret is None or fret < 0:
|
||||||
|
continue
|
||||||
|
rs = _gp_string_to_rs(i + 1, num_strings)
|
||||||
|
if 0 <= rs < width:
|
||||||
|
out[rs] = fret
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _is_bass_track(track: guitarpro.Track) -> bool:
|
def _is_bass_track(track: guitarpro.Track) -> bool:
|
||||||
"""Detect whether a GP track is a bass.
|
"""Detect whether a GP track is a bass.
|
||||||
|
|
||||||
@@ -685,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
|
||||||
@@ -738,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
|
||||||
@@ -828,17 +974,44 @@ def convert_track(
|
|||||||
fret_key = tuple(frets)
|
fret_key = tuple(frets)
|
||||||
|
|
||||||
if fret_key not in chord_template_map:
|
if fret_key not in chord_template_map:
|
||||||
# Try to get chord name from GP
|
|
||||||
chord_name = ""
|
|
||||||
if beat.effect and beat.effect.chord:
|
|
||||||
chord_name = beat.effect.chord.name or ""
|
|
||||||
idx = len(chord_templates)
|
idx = len(chord_templates)
|
||||||
chord_templates.append(ChordTemplate(
|
chord_templates.append(ChordTemplate(
|
||||||
name=chord_name,
|
name="",
|
||||||
frets=list(frets),
|
frets=list(frets),
|
||||||
fingers=[-1] * width,
|
fingers=[-1] * width,
|
||||||
))
|
))
|
||||||
chord_template_map[fret_key] = idx
|
chord_template_map[fret_key] = idx
|
||||||
|
else:
|
||||||
|
idx = chord_template_map[fret_key]
|
||||||
|
|
||||||
|
# Enrich the template from the GP chord diagram attached to
|
||||||
|
# this beat — but ONLY when the diagram describes the voicing
|
||||||
|
# actually played (same width-normalized fret pattern). A
|
||||||
|
# mismatched chord label/diagram would otherwise mis-name /
|
||||||
|
# finger the played template, and the back-fill would spread
|
||||||
|
# it to other strums of the same played pattern. Mirrors the
|
||||||
|
# GP8 exact fret-pattern guard.
|
||||||
|
#
|
||||||
|
# Name and fingers back-fill INDEPENDENTLY: a name-only first
|
||||||
|
# annotation must not block a later beat that carries fingers
|
||||||
|
# (and vice versa). Back-fill any still-blank field so the
|
||||||
|
# data attaches regardless of which strum carries it.
|
||||||
|
if beat.effect and beat.effect.chord:
|
||||||
|
gpc = beat.effect.chord
|
||||||
|
# Compare over the FULL string span (played width vs the
|
||||||
|
# track's string count) so a diagram that frets an
|
||||||
|
# extended string the played voicing doesn't use counts
|
||||||
|
# as a mismatch instead of being silently trimmed.
|
||||||
|
_w = max(len(frets), num_strings)
|
||||||
|
_played = frets + [-1] * (_w - len(frets))
|
||||||
|
if _chord_diagram_frets(gpc, num_strings, _w) == _played:
|
||||||
|
ct = chord_templates[idx]
|
||||||
|
if not ct.name and gpc.name:
|
||||||
|
ct.name = gpc.name
|
||||||
|
if all(f < 0 for f in ct.fingers):
|
||||||
|
fingers = _chord_fingers(gpc, frets, num_strings)
|
||||||
|
if any(f >= 0 for f in fingers):
|
||||||
|
ct.fingers = fingers
|
||||||
|
|
||||||
rs_chords.append(RsChord(
|
rs_chords.append(RsChord(
|
||||||
time=t,
|
time=t,
|
||||||
@@ -949,7 +1122,7 @@ 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.
|
||||||
tuning_el = ET.SubElement(root, "tuning")
|
tuning_el = ET.SubElement(root, "tuning")
|
||||||
for i in range(max(6, len(tuning))):
|
for i in range(max(6, len(tuning))):
|
||||||
@@ -1021,6 +1194,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
|
||||||
@@ -1031,25 +1206,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)))
|
||||||
|
|||||||
+220
-17
@@ -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:
|
||||||
@@ -445,6 +445,118 @@ def _gp6_element_variation_to_midi(element: int, variation: int) -> int | None:
|
|||||||
return _ART_TO_MIDI.get(art_id, art_id)
|
return _ART_TO_MIDI.get(art_id, art_id)
|
||||||
|
|
||||||
|
|
||||||
|
# GPIF chord-diagram <Position finger="..."> names → RS finger integers,
|
||||||
|
# matching the editor (E1) + gp2rs/pyguitarpro convention:
|
||||||
|
# open/unused = -1, thumb = 0, index = 1, middle = 2, ring = 3, pinky = 4.
|
||||||
|
_GPIF_FINGER_MAP = {
|
||||||
|
'none': -1, 'open': -1, '': -1,
|
||||||
|
'thumb': 0,
|
||||||
|
'index': 1,
|
||||||
|
'middle': 2,
|
||||||
|
'ring': 3, 'annular': 3,
|
||||||
|
'pinky': 4, 'little': 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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]:
|
||||||
|
"""Map each GPIF string index → RS string index (0 = lowest pitch).
|
||||||
|
|
||||||
|
Mirrors the per-note transform in ``convert_file`` (sort GPIF string
|
||||||
|
indices by open pitch ascending, tiebreak on index, use the rank), so a
|
||||||
|
chord diagram's string indices land on the same RS strings as the played
|
||||||
|
notes regardless of format direction (GP6 .gpx high→low, GP8 .gp low→high).
|
||||||
|
"""
|
||||||
|
order = sorted(range(len(string_pitches)),
|
||||||
|
key=lambda i: (string_pitches[i], i))
|
||||||
|
return {gp: rs for rs, gp in enumerate(order)}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_chord_diagrams(track_el, string_pitches: list[int]) -> dict:
|
||||||
|
"""Map fret-pattern tuple → ``{'name', 'fingers'}`` from a track's diagrams.
|
||||||
|
|
||||||
|
GP7/GP8 GPIF stores authored chord diagrams per track under
|
||||||
|
``Properties/Property[@name="DiagramCollection"]/Items/Item``. Each Item
|
||||||
|
carries the chord name (its ``name`` attribute) and a ``<Diagram>`` with
|
||||||
|
per-string ``<Fret string=.. fret=..>`` plus
|
||||||
|
``<Fingering><Position finger=.. string=..></Fingering>``. Diagram string
|
||||||
|
indices share the positional space of note ``String`` indices, so they go
|
||||||
|
through the same pitch-rank transform; ``<Fret fret>`` is the absolute fret
|
||||||
|
(``baseFret`` is display-only and not applied).
|
||||||
|
|
||||||
|
Keying by fret pattern (width-normalised to ≥6, exactly like the template
|
||||||
|
build site) keeps the join key consistent with GP5 + the editor's
|
||||||
|
preserve-by-fret-key (E0). Returns ``{}`` when there are no diagrams or no
|
||||||
|
string tuning (orientation/width would be undefined).
|
||||||
|
"""
|
||||||
|
diagrams: dict[tuple, dict] = {}
|
||||||
|
if track_el is None or not string_pitches:
|
||||||
|
return diagrams
|
||||||
|
gp_to_rs = _rs_string_order(string_pitches)
|
||||||
|
for item in track_el.findall(
|
||||||
|
'.//Property[@name="DiagramCollection"]/Items/Item'):
|
||||||
|
diag = item.find('Diagram')
|
||||||
|
if diag is None:
|
||||||
|
continue
|
||||||
|
rs_frets: dict[int, int] = {}
|
||||||
|
for fr in diag.findall('Fret'):
|
||||||
|
try:
|
||||||
|
gp = int(fr.get('string'))
|
||||||
|
fret = int(fr.get('fret'))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if fret < 0:
|
||||||
|
continue
|
||||||
|
rs = gp_to_rs.get(gp)
|
||||||
|
if rs is not None:
|
||||||
|
rs_frets[rs] = fret
|
||||||
|
if not rs_frets:
|
||||||
|
continue
|
||||||
|
width = max(6, max(rs_frets) + 1)
|
||||||
|
frets = [-1] * width
|
||||||
|
fingers = [-1] * width
|
||||||
|
for rs, fret in rs_frets.items():
|
||||||
|
frets[rs] = fret
|
||||||
|
for pos in diag.findall('Fingering/Position'):
|
||||||
|
try:
|
||||||
|
gp = int(pos.get('string'))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
rs = gp_to_rs.get(gp)
|
||||||
|
if rs is None or not (0 <= rs < width) or frets[rs] < 0:
|
||||||
|
continue
|
||||||
|
fname = (pos.get('finger') or '').strip().lower()
|
||||||
|
fingers[rs] = _GPIF_FINGER_MAP.get(fname, -1)
|
||||||
|
# First diagram wins for a given voicing (stable, deterministic).
|
||||||
|
diagrams.setdefault(tuple(frets),
|
||||||
|
{'name': item.get('name', '') or '', 'fingers': fingers})
|
||||||
|
return diagrams
|
||||||
|
|
||||||
|
|
||||||
def _gpx_percussion_midis(track_el) -> list[int]:
|
def _gpx_percussion_midis(track_el) -> list[int]:
|
||||||
"""Flatten a drumKit ``InstrumentSet``'s articulations into a list of GM
|
"""Flatten a drumKit ``InstrumentSet``'s articulations into a list of GM
|
||||||
``OutputMidiNumber``s, positionally indexed to match a note's
|
``OutputMidiNumber``s, positionally indexed to match a note's
|
||||||
@@ -610,6 +722,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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -1060,6 +1186,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.
|
||||||
@@ -1277,6 +1456,10 @@ def convert_file(
|
|||||||
rs_chords: list[RsChord] = []
|
rs_chords: list[RsChord] = []
|
||||||
chord_templates: list[ChordTemplate] = []
|
chord_templates: list[ChordTemplate] = []
|
||||||
chord_template_map: dict[tuple, int] = {}
|
chord_template_map: dict[tuple, int] = {}
|
||||||
|
# Authored chord diagrams (name + per-string fingering) for this track,
|
||||||
|
# keyed by fret pattern so they enrich matching played voicings.
|
||||||
|
chord_diagram_map = _parse_chord_diagrams(
|
||||||
|
track.get('_el'), track['string_pitches'])
|
||||||
beats_out: list[RsBeat] = []
|
beats_out: list[RsBeat] = []
|
||||||
sections: list[RsSection] = []
|
sections: list[RsSection] = []
|
||||||
section_counts: dict[str, int] = {}
|
section_counts: dict[str, int] = {}
|
||||||
@@ -1456,6 +1639,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()
|
||||||
@@ -1473,21 +1661,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).
|
||||||
@@ -1522,6 +1710,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:
|
||||||
@@ -1533,8 +1732,12 @@ def convert_file(
|
|||||||
fkey = tuple(frets_t)
|
fkey = tuple(frets_t)
|
||||||
if fkey not in chord_template_map:
|
if fkey not in chord_template_map:
|
||||||
chord_template_map[fkey] = len(chord_templates)
|
chord_template_map[fkey] = len(chord_templates)
|
||||||
|
_diag = chord_diagram_map.get(fkey)
|
||||||
chord_templates.append(ChordTemplate(
|
chord_templates.append(ChordTemplate(
|
||||||
name='', frets=list(frets_t), fingers=[-1] * width,
|
name=(_diag['name'] if _diag else ''),
|
||||||
|
frets=list(frets_t),
|
||||||
|
fingers=(list(_diag['fingers']) if _diag
|
||||||
|
else [-1] * width),
|
||||||
))
|
))
|
||||||
rs_chords.append(RsChord(
|
rs_chords.append(RsChord(
|
||||||
time=t,
|
time=t,
|
||||||
|
|||||||
@@ -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": "",
|
||||||
|
|||||||
+242
-7
@@ -15,13 +15,25 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
import shutil
|
import shutil
|
||||||
import threading
|
import threading
|
||||||
import zipfile
|
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
|
||||||
|
# `feedpak_version`, a semver string per spec §4). Readers tolerate any version
|
||||||
|
# (additive/MINOR compatibility); writers stamp this.
|
||||||
|
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
|
||||||
|
|
||||||
@@ -33,6 +45,7 @@ from song import (
|
|||||||
Arrangement,
|
Arrangement,
|
||||||
arrangement_from_wire,
|
arrangement_from_wire,
|
||||||
_finite_float,
|
_finite_float,
|
||||||
|
sanitize_tempos,
|
||||||
)
|
)
|
||||||
import drums as drums_mod
|
import drums as drums_mod
|
||||||
import notation as notation_mod
|
import notation as notation_mod
|
||||||
@@ -41,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) ──────────────
|
||||||
@@ -54,6 +71,27 @@ def is_sloppak(path: Path) -> bool:
|
|||||||
_source_cache: dict[str, tuple[Path, float, int]] = {}
|
_source_cache: dict[str, tuple[Path, float, int]] = {}
|
||||||
_source_lock = threading.Lock()
|
_source_lock = threading.Lock()
|
||||||
|
|
||||||
|
# Full-archive unpacks (zip form) are expensive — they write every stem to
|
||||||
|
# disk. Cap how many run at once so a burst (e.g. many plays queued, or a stray
|
||||||
|
# caller looping the library) can't saturate disk/CPU, and serialize per-file so
|
||||||
|
# two callers never rmtree + re-extract the same dest simultaneously (which
|
||||||
|
# would corrupt the half-written dir the other is reading).
|
||||||
|
_UNPACK_MAX_CONCURRENCY = 2
|
||||||
|
_unpack_semaphore = threading.BoundedSemaphore(_UNPACK_MAX_CONCURRENCY)
|
||||||
|
_unpack_locks: dict[str, threading.Lock] = {}
|
||||||
|
_unpack_locks_guard = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _unpack_lock_for(filename: str) -> threading.Lock:
|
||||||
|
"""Return a stable per-file lock so concurrent unpacks of the same sloppak
|
||||||
|
serialize instead of racing on the same destination dir."""
|
||||||
|
with _unpack_locks_guard:
|
||||||
|
lk = _unpack_locks.get(filename)
|
||||||
|
if lk is None:
|
||||||
|
lk = threading.Lock()
|
||||||
|
_unpack_locks[filename] = lk
|
||||||
|
return lk
|
||||||
|
|
||||||
|
|
||||||
def _unpack_zip(zip_path: Path, dest: Path) -> None:
|
def _unpack_zip(zip_path: Path, dest: Path) -> None:
|
||||||
"""Extract a sloppak zip archive into dest, replacing any previous contents.
|
"""Extract a sloppak zip archive into dest, replacing any previous contents.
|
||||||
@@ -126,10 +164,26 @@ def resolve_source_dir(
|
|||||||
if path.is_dir():
|
if path.is_dir():
|
||||||
resolved = path
|
resolved = path
|
||||||
else:
|
else:
|
||||||
# Zip form — unpack to the cache.
|
# Zip form — unpack to the cache. Serialize per-file (so concurrent
|
||||||
|
# callers don't rmtree + re-extract the same dest at once) and cap
|
||||||
|
# global unpack concurrency (so a burst can't saturate disk/CPU).
|
||||||
dest = unpack_cache_root / _safe_id(filename)
|
dest = unpack_cache_root / _safe_id(filename)
|
||||||
_unpack_zip(path, dest)
|
with _unpack_lock_for(filename):
|
||||||
resolved = dest
|
# Re-check the cache inside the per-file lock — a prior holder may
|
||||||
|
# have just finished unpacking this exact (mtime, size).
|
||||||
|
with _source_lock:
|
||||||
|
cached = _source_cache.get(filename)
|
||||||
|
if (
|
||||||
|
cached
|
||||||
|
and cached[1] == mtime
|
||||||
|
and cached[2] == size
|
||||||
|
and cached[0].exists()
|
||||||
|
):
|
||||||
|
resolved = cached[0]
|
||||||
|
else:
|
||||||
|
with _unpack_semaphore:
|
||||||
|
_unpack_zip(path, dest)
|
||||||
|
resolved = dest
|
||||||
|
|
||||||
with _source_lock:
|
with _source_lock:
|
||||||
_source_cache[filename] = (resolved, mtime, size)
|
_source_cache[filename] = (resolved, mtime, size)
|
||||||
@@ -179,6 +233,97 @@ def load_manifest(path: Path) -> dict:
|
|||||||
return _read_manifest_from_zip(path)
|
return _read_manifest_from_zip(path)
|
||||||
|
|
||||||
|
|
||||||
|
_COVER_MEDIA_TYPES = {
|
||||||
|
".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
||||||
|
".png": "image/png", ".webp": "image/webp",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _cover_media_type(name: str) -> str:
|
||||||
|
return _COVER_MEDIA_TYPES.get(Path(name).suffix.lower(), "image/jpeg")
|
||||||
|
|
||||||
|
|
||||||
|
def read_cover_bytes(
|
||||||
|
path: Path, manifest: dict | None = None
|
||||||
|
) -> tuple[bytes, str] | None:
|
||||||
|
"""Return ``(image_bytes, media_type)`` for a sloppak's cover, or ``None``.
|
||||||
|
|
||||||
|
Reads ONLY the cover image. For a zipped sloppak this opens the single
|
||||||
|
cover member rather than unpacking the whole archive (stems included), so
|
||||||
|
serving album art on the library grid never triggers a full extraction —
|
||||||
|
the dominant cost behind slow cover loading on scroll.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if manifest is None:
|
||||||
|
manifest = load_manifest(path)
|
||||||
|
except Exception:
|
||||||
|
manifest = {}
|
||||||
|
cover_rel = str((manifest or {}).get("cover") or "cover.jpg")
|
||||||
|
|
||||||
|
if path.is_dir():
|
||||||
|
# Directory form — read the file, guarding against escape.
|
||||||
|
cover_path = (path / cover_rel).resolve()
|
||||||
|
try:
|
||||||
|
cover_path.relative_to(path.resolve())
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if cover_path.is_file():
|
||||||
|
try:
|
||||||
|
return cover_path.read_bytes(), _cover_media_type(cover_path.name)
|
||||||
|
except OSError as e:
|
||||||
|
log.warning("sloppak: failed to read cover %r: %s", cover_path, e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Zip form — read just the cover member, no unpack. Normalize the manifest
|
||||||
|
# name the way the filesystem would (collapse './' and 'a/../b', backslash →
|
||||||
|
# slash) so a non-canonical-but-valid cover like './cover.jpg' still resolves
|
||||||
|
# to the archive member 'cover.jpg' — matching the old unpack-then-resolve
|
||||||
|
# behavior — and reject zip-slip escape before opening.
|
||||||
|
_zip_root = Path("/_root").resolve()
|
||||||
|
safe = safe_join(_zip_root, cover_rel)
|
||||||
|
# `safe is None` → escape; `safe == _zip_root` → a degenerate name like "."
|
||||||
|
# or "subdir/.." that collapses to the root (member would be "."). Reject
|
||||||
|
# both, mirroring _unpack_zip's degenerate-root guard.
|
||||||
|
if safe is None or safe == _zip_root:
|
||||||
|
log.warning("sloppak: rejected unsafe cover name %r in %r", cover_rel, path)
|
||||||
|
return None
|
||||||
|
member = safe.relative_to(_zip_root).as_posix()
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(str(path), "r") as zf:
|
||||||
|
try:
|
||||||
|
data = zf.read(member)
|
||||||
|
except KeyError:
|
||||||
|
return None
|
||||||
|
return data, _cover_media_type(member)
|
||||||
|
except (OSError, zipfile.BadZipFile, RuntimeError) as e:
|
||||||
|
log.warning("sloppak: failed to read cover from zip %r: %s", path, e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_time_signatures(events) -> list[dict]:
|
||||||
|
"""Clean a time-signature event list (``[{time, ts:[num, den]}]``): keep
|
||||||
|
entries with a finite non-bool ``time`` and a ``ts`` of two integers >= 1,
|
||||||
|
sorted by time. Non-list / all-invalid input -> ``[]``."""
|
||||||
|
out: list[dict] = []
|
||||||
|
if isinstance(events, list):
|
||||||
|
for ev in events:
|
||||||
|
if not isinstance(ev, dict):
|
||||||
|
continue
|
||||||
|
t = ev.get("time")
|
||||||
|
ts = ev.get("ts")
|
||||||
|
if (not isinstance(t, (int, float)) or isinstance(t, bool)
|
||||||
|
or not math.isfinite(t)):
|
||||||
|
continue
|
||||||
|
if not isinstance(ts, list) or len(ts) != 2:
|
||||||
|
continue
|
||||||
|
if not all(isinstance(x, int) and not isinstance(x, bool) and x >= 1
|
||||||
|
for x in ts):
|
||||||
|
continue
|
||||||
|
out.append({"time": float(t), "ts": [int(ts[0]), int(ts[1])]})
|
||||||
|
out.sort(key=lambda e: e["time"])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class LoadedSloppak:
|
class LoadedSloppak:
|
||||||
"""Result of loading a sloppak: the Song object plus stem descriptors."""
|
"""Result of loading a sloppak: the Song object plus stem descriptors."""
|
||||||
@@ -186,6 +331,9 @@ class LoadedSloppak:
|
|||||||
stems: list[dict] # [{"id": str, "file": str, "default": bool}]
|
stems: list[dict] # [{"id": str, "file": str, "default": bool}]
|
||||||
source_dir: Path
|
source_dir: Path
|
||||||
manifest: dict
|
manifest: dict
|
||||||
|
# The pack's declared format version (manifest `feedpak_version`, a semver
|
||||||
|
# string per spec §4). None when absent (legacy / pre-versioning packs).
|
||||||
|
feedpak_version: str | None = None
|
||||||
# Parsed `drum_tab.json` payload when the manifest carries a `drum_tab:`
|
# Parsed `drum_tab.json` payload when the manifest carries a `drum_tab:`
|
||||||
# key pointing at a readable, schema-valid file. None otherwise (older
|
# key pointing at a readable, schema-valid file. None otherwise (older
|
||||||
# sloppaks, sloppaks without drums, sloppaks whose drum tab failed to
|
# sloppaks, sloppaks without drums, sloppaks whose drum tab failed to
|
||||||
@@ -197,6 +345,18 @@ class LoadedSloppak:
|
|||||||
# When present, its beats/sections take priority over any beats/sections
|
# When present, its beats/sections take priority over any beats/sections
|
||||||
# embedded in the arrangement JSONs.
|
# embedded in the arrangement JSONs.
|
||||||
song_timeline: dict | None = None
|
song_timeline: dict | None = None
|
||||||
|
# Parsed `keys.json` payload (manifest `keys:` key) — a song-level,
|
||||||
|
# instrument-independent key/scale-change track (spec §7.7). None when
|
||||||
|
# absent / unreadable / malformed. Streamed over the highway WS as a
|
||||||
|
# `keys` message; consumers (renderers, plugins) read it from there.
|
||||||
|
keys: dict | None = None
|
||||||
|
# Sanitized song-level tempo + time-signature maps from `song_timeline.json`
|
||||||
|
# (feedpak 1.2.0). `tempos`: [{time, bpm}]; `time_signatures`: [{time, ts}].
|
||||||
|
# None when absent/empty. Streamed over the highway WS (`tempos` /
|
||||||
|
# `time_signatures` messages); a per-chart arrangement `tempos` overrides
|
||||||
|
# `tempos` for that chart (spec §6.10).
|
||||||
|
tempos: list | None = None
|
||||||
|
time_signatures: list | None = None
|
||||||
# Maps arrangement id → validated notation payload. None when no
|
# Maps arrangement id → validated notation payload. None when no
|
||||||
# arrangement passed schema validation; a non-empty dict only when at least
|
# arrangement passed schema validation; a non-empty dict only when at least
|
||||||
# one arrangement carried a `notation:` sub-key whose file loaded and passed
|
# one arrangement carried a `notation:` sub-key whose file loaded and passed
|
||||||
@@ -405,6 +565,8 @@ def load_song(
|
|||||||
# already loaded onto the song object — song_timeline is the authoritative
|
# already loaded onto the song object — song_timeline is the authoritative
|
||||||
# source for timeline data in sloppaks that carry it.
|
# source for timeline data in sloppaks that carry it.
|
||||||
song_timeline_data: dict | None = None
|
song_timeline_data: dict | None = None
|
||||||
|
tempos_data: list | None = None
|
||||||
|
time_sigs_data: list | None = None
|
||||||
song_timeline_rel = manifest.get("song_timeline")
|
song_timeline_rel = manifest.get("song_timeline")
|
||||||
if isinstance(song_timeline_rel, str) and song_timeline_rel:
|
if isinstance(song_timeline_rel, str) and song_timeline_rel:
|
||||||
try:
|
try:
|
||||||
@@ -487,6 +649,13 @@ def load_song(
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
song_timeline_data = raw
|
song_timeline_data = raw
|
||||||
|
# tempos / time_signatures (feedpak 1.2.0) are independent of the
|
||||||
|
# beats/sections validation above — all are optional — so load them
|
||||||
|
# whenever the payload parsed to a dict.
|
||||||
|
if isinstance(raw, dict):
|
||||||
|
tempos_data = sanitize_tempos(raw.get("tempos")) or None
|
||||||
|
time_sigs_data = _sanitize_time_signatures(
|
||||||
|
raw.get("time_signatures")) or None
|
||||||
|
|
||||||
# Optional shared lyrics file. Same safety posture as the drum_tab
|
# Optional shared lyrics file. Same safety posture as the drum_tab
|
||||||
# loader above: constrain the manifest-declared path to source_dir
|
# loader above: constrain the manifest-declared path to source_dir
|
||||||
@@ -577,13 +746,79 @@ def load_song(
|
|||||||
default_on = bool(default_val)
|
default_on = bool(default_val)
|
||||||
stems.append({"id": sid, "file": sfile, "default": default_on})
|
stems.append({"id": sid, "file": sfile, "default": default_on})
|
||||||
|
|
||||||
|
# Optional keys.json — song-level, instrument-independent key/scale track
|
||||||
|
# (manifest `keys:` key, spec §7.7). Permissive like the other side-files:
|
||||||
|
# missing / unreadable / malformed -> None, never fatal. Stored as a
|
||||||
|
# sanitized {version, events:[{t, key, scale?}]} (finite t, non-empty string
|
||||||
|
# key, sorted) so the highway WS can stream it without re-validating.
|
||||||
|
keys_data: dict | None = None
|
||||||
|
keys_rel = manifest.get("keys")
|
||||||
|
if isinstance(keys_rel, str) and keys_rel:
|
||||||
|
try:
|
||||||
|
k_path = (source_dir / keys_rel).resolve()
|
||||||
|
k_path.relative_to(source_dir.resolve())
|
||||||
|
except ValueError:
|
||||||
|
log.warning("sloppak: keys path %r escapes source_dir — skipped", keys_rel)
|
||||||
|
k_path = None
|
||||||
|
except OSError as e:
|
||||||
|
log.warning("sloppak: keys path resolution failed (%s) — skipped", e)
|
||||||
|
k_path = None
|
||||||
|
if k_path is not None and k_path.exists():
|
||||||
|
try:
|
||||||
|
raw = json.loads(k_path.read_text(encoding="utf-8"))
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("sloppak: failed to parse keys %r: %s", keys_rel, e)
|
||||||
|
raw = None
|
||||||
|
if raw is not None and not isinstance(raw, dict):
|
||||||
|
log.warning("sloppak: keys %r ignored — expected dict, got %s",
|
||||||
|
keys_rel, type(raw).__name__)
|
||||||
|
elif isinstance(raw, dict):
|
||||||
|
if not isinstance(raw.get("events"), list):
|
||||||
|
log.warning("sloppak: keys %r ignored — 'events' must be a list", keys_rel)
|
||||||
|
else:
|
||||||
|
clean_events: list[dict] = []
|
||||||
|
for ev in raw["events"]:
|
||||||
|
if not isinstance(ev, dict):
|
||||||
|
continue
|
||||||
|
# Drop events with a missing / non-numeric / non-finite
|
||||||
|
# time rather than silently rewriting them to 0.0 — a
|
||||||
|
# bad `t` makes the whole event meaningless.
|
||||||
|
t = ev.get("t")
|
||||||
|
if (not isinstance(t, (int, float)) or isinstance(t, bool)
|
||||||
|
or not math.isfinite(t)):
|
||||||
|
continue
|
||||||
|
t = float(t)
|
||||||
|
key = ev.get("key")
|
||||||
|
if not isinstance(key, str) or not key:
|
||||||
|
continue
|
||||||
|
entry = {"t": t, "key": key}
|
||||||
|
scale = ev.get("scale")
|
||||||
|
if isinstance(scale, str) and scale:
|
||||||
|
entry["scale"] = scale
|
||||||
|
clean_events.append(entry)
|
||||||
|
clean_events.sort(key=lambda e: e["t"])
|
||||||
|
# int only — a float version (incl. NaN/Inf, which json.loads
|
||||||
|
# accepts) would raise on int(); default rather than abort the
|
||||||
|
# load of an optional side-file.
|
||||||
|
_ver = raw.get("version")
|
||||||
|
keys_data = {
|
||||||
|
"version": _ver if isinstance(_ver, int)
|
||||||
|
and not isinstance(_ver, bool) else 1,
|
||||||
|
"events": clean_events,
|
||||||
|
}
|
||||||
|
|
||||||
|
_fpv = manifest.get("feedpak_version")
|
||||||
return LoadedSloppak(
|
return LoadedSloppak(
|
||||||
song=song,
|
song=song,
|
||||||
stems=stems,
|
stems=stems,
|
||||||
source_dir=source_dir,
|
source_dir=source_dir,
|
||||||
manifest=manifest,
|
manifest=manifest,
|
||||||
|
feedpak_version=_fpv if isinstance(_fpv, str) and _fpv else None,
|
||||||
drum_tab=drum_tab_data,
|
drum_tab=drum_tab_data,
|
||||||
song_timeline=song_timeline_data,
|
song_timeline=song_timeline_data,
|
||||||
|
tempos=tempos_data,
|
||||||
|
time_signatures=time_sigs_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,
|
||||||
)
|
)
|
||||||
@@ -657,6 +892,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,
|
||||||
}
|
}
|
||||||
|
|||||||
+311
-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
|
||||||
@@ -151,6 +185,10 @@ class Arrangement:
|
|||||||
# RS2014 custom song pitch-shift field (cents). Commonly -1200.0 (one octave
|
# RS2014 custom song pitch-shift field (cents). Commonly -1200.0 (one octave
|
||||||
# down) for extended-range bass arrangements. 0.0 when absent or zero.
|
# down) for extended-range bass arrangements. 0.0 when absent or zero.
|
||||||
cent_offset: float = 0.0
|
cent_offset: float = 0.0
|
||||||
|
# Per-chart tempo override (§6.10): [{time, bpm}]. None when the chart
|
||||||
|
# follows the song-level tempo; when present a Reader uses it for this
|
||||||
|
# chart and ignores the song-level tempo.
|
||||||
|
tempos: list | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -217,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
|
||||||
|
|
||||||
|
|
||||||
@@ -228,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:
|
||||||
@@ -250,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
|
||||||
@@ -262,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):
|
||||||
@@ -279,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)),
|
||||||
@@ -288,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)),
|
||||||
@@ -306,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(
|
||||||
@@ -317,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")),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -372,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
|
||||||
@@ -585,6 +835,29 @@ def _finite_float(value, default: float = 0.0) -> float:
|
|||||||
return v if math.isfinite(v) else default
|
return v if math.isfinite(v) else default
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_tempos(events) -> list[dict]:
|
||||||
|
"""Clean a tempo-event list (``[{time, bpm}]``): keep entries with a finite
|
||||||
|
non-bool ``time`` and a finite ``bpm > 0``, coerced to float and sorted by
|
||||||
|
time. Non-list / all-invalid input -> ``[]``. Shared by the per-chart
|
||||||
|
arrangement ``tempos`` (§6.10) and the song-level ``song_timeline.tempos``."""
|
||||||
|
out: list[dict] = []
|
||||||
|
if isinstance(events, list):
|
||||||
|
for ev in events:
|
||||||
|
if not isinstance(ev, dict):
|
||||||
|
continue
|
||||||
|
t = ev.get("time")
|
||||||
|
bpm = ev.get("bpm")
|
||||||
|
if (not isinstance(t, (int, float)) or isinstance(t, bool)
|
||||||
|
or not math.isfinite(t)):
|
||||||
|
continue
|
||||||
|
if (not isinstance(bpm, (int, float)) or isinstance(bpm, bool)
|
||||||
|
or not math.isfinite(bpm) or bpm <= 0):
|
||||||
|
continue
|
||||||
|
out.append({"time": float(t), "bpm": float(bpm)})
|
||||||
|
out.sort(key=lambda e: e["time"])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def arrangement_to_wire(arr: Arrangement) -> dict:
|
def arrangement_to_wire(arr: Arrangement) -> dict:
|
||||||
"""Serialize an Arrangement into a JSON-ready dict matching the wire format."""
|
"""Serialize an Arrangement into a JSON-ready dict matching the wire format."""
|
||||||
out = {
|
out = {
|
||||||
@@ -612,6 +885,10 @@ def arrangement_to_wire(arr: Arrangement) -> dict:
|
|||||||
# "no tones".
|
# "no tones".
|
||||||
if arr.tones:
|
if arr.tones:
|
||||||
out["tones"] = arr.tones
|
out["tones"] = arr.tones
|
||||||
|
# Per-chart tempo override (§6.10) — additive; omit when the chart follows
|
||||||
|
# the song-level tempo (empty/None).
|
||||||
|
if arr.tempos:
|
||||||
|
out["tempos"] = list(arr.tempos)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -622,6 +899,7 @@ def arrangement_from_wire(d: dict) -> Arrangement:
|
|||||||
tuning=list(d.get("tuning", [0] * 6)),
|
tuning=list(d.get("tuning", [0] * 6)),
|
||||||
capo=int(d.get("capo", 0)),
|
capo=int(d.get("capo", 0)),
|
||||||
cent_offset=_finite_float(d.get("centOffset", 0.0)),
|
cent_offset=_finite_float(d.get("centOffset", 0.0)),
|
||||||
|
tempos=(sanitize_tempos(d.get("tempos")) or None),
|
||||||
notes=[note_from_wire(n) for n in d.get("notes", [])],
|
notes=[note_from_wire(n) for n in d.get("notes", [])],
|
||||||
chords=[chord_from_wire(c) for c in d.get("chords", [])],
|
chords=[chord_from_wire(c) for c in d.get("chords", [])],
|
||||||
anchors=[
|
anchors=[
|
||||||
@@ -641,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
|
||||||
@@ -736,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"),
|
||||||
@@ -745,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"),
|
||||||
@@ -761,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"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -976,7 +1276,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
|
||||||
)
|
)
|
||||||
@@ -995,7 +1295,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
|
||||||
@@ -1145,7 +1445,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
|
||||||
|
|||||||
+10
-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.
|
||||||
"""
|
"""
|
||||||
@@ -49,6 +49,15 @@ def _apply_to_sloppak_manifest(manifest: dict, fields: dict) -> bool:
|
|||||||
if "year" in fields:
|
if "year" in fields:
|
||||||
manifest["year"] = _coerce_year(fields["year"])
|
manifest["year"] = _coerce_year(fields["year"])
|
||||||
dirty = True
|
dirty = True
|
||||||
|
# Opportunistically declare the format version (spec §4) when we're already
|
||||||
|
# rewriting because a metadata field was supplied. Gated on `dirty` (i.e. a
|
||||||
|
# field was given) so this never forces a *standalone* rewrite with no fields
|
||||||
|
# passed, and `not in` so an existing (possibly higher) version is preserved,
|
||||||
|
# never downgraded. NB `dirty` here means "a field was supplied" — a
|
||||||
|
# supplied-but-identical value already triggers a rewrite (pre-existing).
|
||||||
|
if dirty and "feedpak_version" not in manifest:
|
||||||
|
from sloppak import FEEDPAK_VERSION
|
||||||
|
manifest["feedpak_version"] = FEEDPAK_VERSION
|
||||||
return dirty
|
return dirty
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
+22
-70
@@ -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
|
||||||
@@ -934,16 +934,6 @@ def _install_requirements(plugin_dir: Path, plugin_id: str):
|
|||||||
if not req_file.exists():
|
if not req_file.exists():
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Packaged/distributed hosts (e.g. slopsmith-desktop) set
|
|
||||||
# SLOPSMITH_SKIP_PLUGIN_INSTALL to skip the blocking startup pip install:
|
|
||||||
# heavy optional deps (torch/whisperx/demucs) would otherwise download for
|
|
||||||
# minutes and hang the backend past the host app's readiness window. The
|
|
||||||
# plugin still loads and degrades gracefully when its optional deps are
|
|
||||||
# absent.
|
|
||||||
if os.environ.get("SLOPSMITH_SKIP_PLUGIN_INSTALL", "").strip().lower() not in ("", "0", "false", "no"):
|
|
||||||
log.info("Skipping requirement install for plugin %r (SLOPSMITH_SKIP_PLUGIN_INSTALL set)", plugin_id)
|
|
||||||
return True
|
|
||||||
|
|
||||||
_PIP_TARGET.mkdir(parents=True, exist_ok=True)
|
_PIP_TARGET.mkdir(parents=True, exist_ok=True)
|
||||||
pip_target = str(_PIP_TARGET)
|
pip_target = str(_PIP_TARGET)
|
||||||
|
|
||||||
@@ -956,32 +946,9 @@ def _install_requirements(plugin_dir: Path, plugin_id: str):
|
|||||||
# (PYTHONHASHSEED), so the marker would never match on restart and
|
# (PYTHONHASHSEED), so the marker would never match on restart and
|
||||||
# pip would re-resolve every plugin's requirements on every boot.
|
# pip would re-resolve every plugin's requirements on every boot.
|
||||||
marker = _PIP_TARGET / f".installed_{plugin_id}"
|
marker = _PIP_TARGET / f".installed_{plugin_id}"
|
||||||
fail_marker = _PIP_TARGET / f".failed_{plugin_id}"
|
|
||||||
req_hash = hashlib.sha256(req_file.read_bytes()).hexdigest()
|
req_hash = hashlib.sha256(req_file.read_bytes()).hexdigest()
|
||||||
|
if marker.exists() and marker.read_text().strip() == req_hash:
|
||||||
def _marker_matches(m):
|
|
||||||
# Tolerate an unreadable/transiently-broken marker (permissions, I/O):
|
|
||||||
# treat it as "no match" and fall through to a normal install attempt
|
|
||||||
# rather than letting read_text() raise out of this function.
|
|
||||||
try:
|
|
||||||
return m.exists() and m.read_text().strip() == req_hash
|
|
||||||
except OSError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if _marker_matches(marker):
|
|
||||||
return True # Already installed, same requirements
|
return True # Already installed, same requirements
|
||||||
# A previous install of these exact requirements already failed. Don't
|
|
||||||
# re-attempt on every boot: that re-blocks startup for the full pip timeout
|
|
||||||
# each launch. Retry only when requirements.txt changes (new hash) or the
|
|
||||||
# .failed_ marker is cleared.
|
|
||||||
if _marker_matches(fail_marker):
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _record_failure():
|
|
||||||
try:
|
|
||||||
fail_marker.write_text(req_hash)
|
|
||||||
except OSError:
|
|
||||||
pass # read-only target: nothing to persist
|
|
||||||
|
|
||||||
log.info("Installing requirements for plugin %r (this can take a while for large deps)...", plugin_id)
|
log.info("Installing requirements for plugin %r (this can take a while for large deps)...", plugin_id)
|
||||||
try:
|
try:
|
||||||
@@ -993,20 +960,7 @@ def _install_requirements(plugin_dir: Path, plugin_id: str):
|
|||||||
capture_output=True, text=True, timeout=1800,
|
capture_output=True, text=True, timeout=1800,
|
||||||
)
|
)
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
# Persisting markers is best-effort and must NOT fall through to the
|
marker.write_text(req_hash)
|
||||||
# outer `except` (which would call _record_failure() and make a
|
|
||||||
# SUCCESSFUL install look like a sticky failure). The two writes are
|
|
||||||
# independent: clearing a stale .failed_ marker must still happen
|
|
||||||
# even if writing the success marker fails — otherwise a real
|
|
||||||
# success would stay recorded as a failure on the next boot.
|
|
||||||
try:
|
|
||||||
marker.write_text(req_hash)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
fail_marker.unlink() # clear any stale failure record
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
log.info("Requirements installed for plugin %r", plugin_id)
|
log.info("Requirements installed for plugin %r", plugin_id)
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
@@ -1020,7 +974,6 @@ def _install_requirements(plugin_dir: Path, plugin_id: str):
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
log.warning("Plugin %r: failed to install requirements: %s", plugin_id, result.stderr[:300])
|
log.warning("Plugin %r: failed to install requirements: %s", plugin_id, result.stderr[:300])
|
||||||
_record_failure()
|
|
||||||
return False
|
return False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
err_lower = str(e).lower()
|
err_lower = str(e).lower()
|
||||||
@@ -1033,7 +986,6 @@ def _install_requirements(plugin_dir: Path, plugin_id: str):
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
log.warning("Plugin %r: error installing requirements: %s", plugin_id, e)
|
log.warning("Plugin %r: error installing requirements: %s", plugin_id, e)
|
||||||
_record_failure()
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -1166,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:
|
||||||
@@ -1227,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.
|
||||||
@@ -1279,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.
|
||||||
@@ -1585,7 +1537,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.
|
||||||
@@ -1604,13 +1556,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"]
|
||||||
|
|
||||||
@@ -1757,9 +1709,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),
|
||||||
@@ -1848,7 +1800,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"]
|
||||||
|
|
||||||
@@ -2101,7 +2053,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.
|
||||||
|
|||||||
@@ -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);
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -131,7 +131,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 +141,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 +151,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 +185,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.
|
||||||
@@ -231,21 +231,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
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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/.
|
||||||
|
|
||||||
|
|||||||
+341
-80
@@ -2,7 +2,7 @@
|
|||||||
// Visual layer from joel's prototype (vibrant palette, glowing strings,
|
// Visual layer from joel's prototype (vibrant palette, glowing strings,
|
||||||
// fret heat, dynamic lane, chord frame-boxes, per-note connector labels,
|
// fret heat, dynamic lane, chord frame-boxes, per-note connector labels,
|
||||||
// board projection, outline+core note meshes) adapted into the
|
// board projection, outline+core note meshes) adapted into the
|
||||||
// slopsmithViz setRenderer contract (slopsmith#36) so it works in the
|
// feedBackViz setRenderer contract (feedBack#36) so it works in the
|
||||||
// main player and per-panel in splitscreen without any architectural
|
// main player and per-panel in splitscreen without any architectural
|
||||||
// changes.
|
// changes.
|
||||||
|
|
||||||
@@ -27,12 +27,12 @@
|
|||||||
// high E=purple); Neon pushes saturation harder; Pastel desaturates
|
// high E=purple); Neon pushes saturation harder; Pastel desaturates
|
||||||
// for long-session comfort; Colorblind (high contrast) is derived from
|
// for long-session comfort; Colorblind (high contrast) is derived from
|
||||||
// the chart format's built-in colorblind-mode palette, but this preset
|
// the chart format's built-in colorblind-mode palette, but this preset
|
||||||
// intentionally keeps some entries tuned for slopsmith rather than
|
// intentionally keeps some entries tuned for feedBack rather than
|
||||||
// reproducing every original hex value verbatim. The chart-format base
|
// reproducing every original hex value verbatim. The chart-format base
|
||||||
// values came from community reverse-engineering of the original chart
|
// values came from community reverse-engineering of the original chart
|
||||||
// files; do not treat the tuned values below as the exact original
|
// files; do not treat the tuned values below as the exact original
|
||||||
// palette.
|
// palette.
|
||||||
// In slopsmith's index convention s=0 is the low E (thickest) and
|
// In feedBack's index convention s=0 is the low E (thickest) and
|
||||||
// s=5 is the high E (thinnest), matching the chart format's native string
|
// s=5 is the high E (thinnest), matching the chart format's native string
|
||||||
// indexing. Per-index ordering is preserved across all palettes so
|
// indexing. Per-index ordering is preserved across all palettes so
|
||||||
// switching between them never reassigns a string to a different
|
// switching between them never reassigns a string to a different
|
||||||
@@ -128,10 +128,10 @@
|
|||||||
const MAX_RENDER_STRINGS = S_COL.length;
|
const MAX_RENDER_STRINGS = S_COL.length;
|
||||||
|
|
||||||
// Resolve the string count for the active arrangement. Prefer
|
// Resolve the string count for the active arrangement. Prefer
|
||||||
// bundle.stringCount (exposed by slopsmith core since #93 — derived
|
// bundle.stringCount (exposed by feedBack core since #93 — derived
|
||||||
// from notes/chords/tuning, so it works for 5-string bass, 7- and
|
// from notes/chords/tuning, so it works for 5-string bass, 7- and
|
||||||
// 8-string guitar, etc.). Fall back to arrangement-name detection
|
// 8-string guitar, etc.). Fall back to arrangement-name detection
|
||||||
// for older slopsmith cores that don't emit the field. Clamp to the
|
// for older feedBack cores that don't emit the field. Clamp to the
|
||||||
// palette size so a malformed bundle or a 12-string chart doesn't
|
// palette size so a malformed bundle or a 12-string chart doesn't
|
||||||
// index past the per-string material arrays.
|
// index past the per-string material arrays.
|
||||||
function resolveStringCount(bundle) {
|
function resolveStringCount(bundle) {
|
||||||
@@ -231,7 +231,7 @@
|
|||||||
const AHEAD = 3.0;
|
const AHEAD = 3.0;
|
||||||
const BEHIND = 0.5;
|
const BEHIND = 0.5;
|
||||||
// How long a note/chord-frame stays renderable past the hit line while a
|
// How long a note/chord-frame stays renderable past the hit line while a
|
||||||
// note-state provider (slopsmith#254) is attached. The provider's
|
// note-state provider (feedBack#254) is attached. The provider's
|
||||||
// hit/miss verdict is asynchronous — the engine-side verifier reports it
|
// hit/miss verdict is asynchronous — the engine-side verifier reports it
|
||||||
// ~0.35-0.5 s after the line — so the default ~50 ms note linger /
|
// ~0.35-0.5 s after the line — so the default ~50 ms note linger /
|
||||||
// ~0.48 s chord linger lapses before the tint can apply. Drives both
|
// ~0.48 s chord linger lapses before the tint can apply. Drives both
|
||||||
@@ -664,7 +664,7 @@
|
|||||||
/** Arpeggio rim accent and lane tint. */
|
/** Arpeggio rim accent and lane tint. */
|
||||||
const ARPEGGIO_RIM_BLUE_HEX = 0x454BB6;
|
const ARPEGGIO_RIM_BLUE_HEX = 0x454BB6;
|
||||||
/** Post-hit chord-frame rim tints driven by the note-state provider
|
/** Post-hit chord-frame rim tints driven by the note-state provider
|
||||||
* (slopsmith#254). Applied only to the teal frame during the linger
|
* (feedBack#254). Applied only to the teal frame during the linger
|
||||||
* fade (chDt <= 0) when a scorer is attached.
|
* fade (chDt <= 0) when a scorer is attached.
|
||||||
* Matches the gem hit/miss colours so chord frame and note body
|
* Matches the gem hit/miss colours so chord frame and note body
|
||||||
* give a consistent signal:
|
* give a consistent signal:
|
||||||
@@ -754,7 +754,16 @@
|
|||||||
if (localStorage.getItem('highway_3d.fretSpacing') === m) return;
|
if (localStorage.getItem('highway_3d.fretSpacing') === m) return;
|
||||||
localStorage.setItem('highway_3d.fretSpacing', m);
|
localStorage.setItem('highway_3d.fretSpacing', m);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
location.reload();
|
// Apply live rather than reloading the page — a full page reload
|
||||||
|
// reboots the SPA to the home screen (index.html's `.screen.active`),
|
||||||
|
// ejecting the user from Settings. Rebind the module-scope flag so
|
||||||
|
// panels mounted later this session pick up the new mode, recompute
|
||||||
|
// the fretX-derived scalars, then broadcast a change so every mounted
|
||||||
|
// panel rebuilds its board. Same live-update path as every other
|
||||||
|
// 3D-highway setting.
|
||||||
|
_h3dFretUniform = (m !== 'logarithmic');
|
||||||
|
_recomputeFretSpacingDerived();
|
||||||
|
_bgEmitChange('fretSpacing');
|
||||||
};
|
};
|
||||||
|
|
||||||
const fretMid = f => (f <= 0 ? -2 * K : (fretX(f - 1) + fretX(f)) / 2);
|
const fretMid = f => (f <= 0 ? -2 * K : (fretX(f - 1) + fretX(f)) / 2);
|
||||||
@@ -767,7 +776,10 @@
|
|||||||
}
|
}
|
||||||
/** Reference column (~mid board): prior fixed K-based sprites matched this neighborhood. */
|
/** Reference column (~mid board): prior fixed K-based sprites matched this neighborhood. */
|
||||||
const FRET_LABEL_SCALE_REF_FRET = 5;
|
const FRET_LABEL_SCALE_REF_FRET = 5;
|
||||||
const _fretLabelScaleRefW = Math.max(1e-8, fretColumnWorldW(FRET_LABEL_SCALE_REF_FRET));
|
// `let` (not `const`): recomputed by _recomputeFretSpacingDerived when the
|
||||||
|
// user flips Uniform/Logarithmic at runtime so label scaling tracks the
|
||||||
|
// new geometry without a page reload.
|
||||||
|
let _fretLabelScaleRefW = Math.max(1e-8, fretColumnWorldW(FRET_LABEL_SCALE_REF_FRET));
|
||||||
function fretLabelScaleForFret(f) {
|
function fretLabelScaleForFret(f) {
|
||||||
const w = fretColumnWorldW(f);
|
const w = fretColumnWorldW(f);
|
||||||
const m = w / _fretLabelScaleRefW;
|
const m = w / _fretLabelScaleRefW;
|
||||||
@@ -823,7 +835,19 @@
|
|||||||
// World-units-per-fret near mid-neck. Used by the camera-X hysteresis
|
// World-units-per-fret near mid-neck. Used by the camera-X hysteresis
|
||||||
// gate (issue #34) to convert a fret-equivalent dead zone into world
|
// gate (issue #34) to convert a fret-equivalent dead zone into world
|
||||||
// units. Pure function of SCALE — hoist out of update()'s hot path.
|
// units. Pure function of SCALE — hoist out of update()'s hot path.
|
||||||
const FRET_WIDTH_MID = fretX(7) - fretX(6);
|
// `let` (not `const`): recomputed alongside _fretLabelScaleRefW when the
|
||||||
|
// fret-spacing mode flips at runtime — see _recomputeFretSpacingDerived.
|
||||||
|
let FRET_WIDTH_MID = fretX(7) - fretX(6);
|
||||||
|
|
||||||
|
// Recompute the fretX-derived scalars baked at module init. Called from
|
||||||
|
// h3dSetFretSpacing after _h3dFretUniform flips so label scaling and the
|
||||||
|
// camera hysteresis threshold track the newly chosen spacing — the live
|
||||||
|
// alternative to the old location.reload(), which ejected the user from
|
||||||
|
// Settings back to the home screen.
|
||||||
|
function _recomputeFretSpacingDerived() {
|
||||||
|
_fretLabelScaleRefW = Math.max(1e-8, fretColumnWorldW(FRET_LABEL_SCALE_REF_FRET));
|
||||||
|
FRET_WIDTH_MID = fretX(7) - fretX(6);
|
||||||
|
}
|
||||||
|
|
||||||
function computeBPM(beats, t) {
|
function computeBPM(beats, t) {
|
||||||
if (!beats || beats.length < 2) return 120;
|
if (!beats || beats.length < 2) return 120;
|
||||||
@@ -898,7 +922,7 @@
|
|||||||
* ====================================================================== */
|
* ====================================================================== */
|
||||||
|
|
||||||
function _ssActive() {
|
function _ssActive() {
|
||||||
const ss = window.slopsmithSplitscreen;
|
const ss = window.feedBackSplitscreen;
|
||||||
if (!ss || typeof ss.isActive !== 'function' || !ss.isActive()) return false;
|
if (!ss || typeof ss.isActive !== 'function' || !ss.isActive()) return false;
|
||||||
return typeof ss.isCanvasFocused === 'function'
|
return typeof ss.isCanvasFocused === 'function'
|
||||||
&& typeof ss.onFocusChange === 'function'
|
&& typeof ss.onFocusChange === 'function'
|
||||||
@@ -906,7 +930,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function _ssIsCanvasFocused(highwayCanvas) {
|
function _ssIsCanvasFocused(highwayCanvas) {
|
||||||
const ss = window.slopsmithSplitscreen;
|
const ss = window.feedBackSplitscreen;
|
||||||
if (!_ssActive()) return true;
|
if (!_ssActive()) return true;
|
||||||
return !!(ss && typeof ss.isCanvasFocused === 'function' &&
|
return !!(ss && typeof ss.isCanvasFocused === 'function' &&
|
||||||
ss.isCanvasFocused(highwayCanvas));
|
ss.isCanvasFocused(highwayCanvas));
|
||||||
@@ -917,7 +941,7 @@
|
|||||||
*
|
*
|
||||||
* Audio-reactive ambient scenery in the fog band beyond the highway.
|
* Audio-reactive ambient scenery in the fog band beyond the highway.
|
||||||
* Module-level singletons share an AudioContext + AnalyserNode tap on
|
* Module-level singletons share an AudioContext + AnalyserNode tap on
|
||||||
* the slopsmith core <audio id="audio"> element across all panel
|
* the feedBack core <audio id="audio"> element across all panel
|
||||||
* instances; per-panel settings live in localStorage with a global
|
* instances; per-panel settings live in localStorage with a global
|
||||||
* fallback so settings.html drives a single default while per-panel
|
* fallback so settings.html drives a single default while per-panel
|
||||||
* overrides (h3d_bg_panel<idx>_*) can be set for splitscreen layouts.
|
* overrides (h3d_bg_panel<idx>_*) can be set for splitscreen layouts.
|
||||||
@@ -957,7 +981,7 @@
|
|||||||
const key = `${outcome}:${status}:${reason}`;
|
const key = `${outcome}:${status}:${reason}`;
|
||||||
if (_bgBridgeKeys.get(bridgeId) === key) return;
|
if (_bgBridgeKeys.get(bridgeId) === key) return;
|
||||||
_bgBridgeKeys.set(bridgeId, key);
|
_bgBridgeKeys.set(bridgeId, key);
|
||||||
const session = window.slopsmith && window.slopsmith.audioSession;
|
const session = window.feedBack && window.feedBack.audioSession;
|
||||||
if (!session || typeof session.recordBridgeHit !== 'function') return;
|
if (!session || typeof session.recordBridgeHit !== 'function') return;
|
||||||
try {
|
try {
|
||||||
session.recordBridgeHit({
|
session.recordBridgeHit({
|
||||||
@@ -974,14 +998,14 @@
|
|||||||
|
|
||||||
function _bgGetAnalyser() {
|
function _bgGetAnalyser() {
|
||||||
// Prefer the stems plugin's side-chain analyser when a sloppak is
|
// Prefer the stems plugin's side-chain analyser when a sloppak is
|
||||||
// loaded. As of slopsmith-plugin-stems 0.5.0 (sample-locked playback)
|
// loaded. As of feedBack-plugin-stems 0.5.0 (sample-locked playback)
|
||||||
// the #audio element is a silent virtual transport on sloppaks, so
|
// the #audio element is a silent virtual transport on sloppaks, so
|
||||||
// tapping it sees only silence; the stems mix is exposed at
|
// tapping it sees only silence; the stems mix is exposed at
|
||||||
// window.slopsmith.stems.getAnalyser() instead. The stems plugin
|
// window.feedBack.stems.getAnalyser() instead. The stems plugin
|
||||||
// creates and destroys that AnalyserNode per song, so we re-check
|
// creates and destroys that AnalyserNode per song, so we re-check
|
||||||
// each call and key the cache on its identity — when the node
|
// each call and key the cache on its identity — when the node
|
||||||
// changes (song switch), the cache is replaced automatically.
|
// changes (song switch), the cache is replaced automatically.
|
||||||
const stemsApi = window.slopsmith && window.slopsmith.stems;
|
const stemsApi = window.feedBack && window.feedBack.stems;
|
||||||
const stemsAnalyser = (stemsApi && typeof stemsApi.getAnalyser === 'function')
|
const stemsAnalyser = (stemsApi && typeof stemsApi.getAnalyser === 'function')
|
||||||
? stemsApi.getAnalyser() : null;
|
? stemsApi.getAnalyser() : null;
|
||||||
if (stemsAnalyser) {
|
if (stemsAnalyser) {
|
||||||
@@ -999,7 +1023,7 @@
|
|||||||
freq: new Uint8Array(Math.max(BG_FREQ_BINS, stemsAnalyser.frequencyBinCount)),
|
freq: new Uint8Array(Math.max(BG_FREQ_BINS, stemsAnalyser.frequencyBinCount)),
|
||||||
source: 'stems',
|
source: 'stems',
|
||||||
};
|
};
|
||||||
_bgRecordAudioBridge('audio-mix.analyser', 'window.slopsmith.stems.getAnalyser', 'handled', '', 'stems');
|
_bgRecordAudioBridge('audio-mix.analyser', 'window.feedBack.stems.getAnalyser', 'handled', '', 'stems');
|
||||||
}
|
}
|
||||||
return _bgAudio;
|
return _bgAudio;
|
||||||
}
|
}
|
||||||
@@ -1328,7 +1352,7 @@
|
|||||||
const FRET_NUMBER_GHOST_SCOPE_IDS = ['chords', 'all'];
|
const FRET_NUMBER_GHOST_SCOPE_IDS = ['chords', 'all'];
|
||||||
|
|
||||||
function _bgPanelKey(canvas) {
|
function _bgPanelKey(canvas) {
|
||||||
const ss = window.slopsmithSplitscreen;
|
const ss = window.feedBackSplitscreen;
|
||||||
const idx = (ss && typeof ss.panelIndexFor === 'function') ? ss.panelIndexFor(canvas) : null;
|
const idx = (ss && typeof ss.panelIndexFor === 'function') ? ss.panelIndexFor(canvas) : null;
|
||||||
return (idx == null) ? 'main' : 'panel' + idx;
|
return (idx == null) ? 'main' : 'panel' + idx;
|
||||||
}
|
}
|
||||||
@@ -2300,7 +2324,7 @@
|
|||||||
// never sees a tainted canvas. Setting
|
// never sees a tainted canvas. Setting
|
||||||
// `crossOrigin = "anonymous"` would also strip
|
// `crossOrigin = "anonymous"` would also strip
|
||||||
// cookies from the fetch, which would 401 against
|
// cookies from the fetch, which would 401 against
|
||||||
// any cookie-protected slopsmith deployment. If
|
// any cookie-protected feedBack deployment. If
|
||||||
// this ever needs to fetch cross-origin, switch
|
// this ever needs to fetch cross-origin, switch
|
||||||
// to `use-credentials` AND have the server send
|
// to `use-credentials` AND have the server send
|
||||||
// the matching CORS headers.
|
// the matching CORS headers.
|
||||||
@@ -2446,7 +2470,7 @@
|
|||||||
let _nextInstanceId = 0;
|
let _nextInstanceId = 0;
|
||||||
|
|
||||||
/* ======================================================================
|
/* ======================================================================
|
||||||
* Factory — slopsmith#36 setRenderer contract
|
* Factory — feedBack#36 setRenderer contract
|
||||||
* ====================================================================== */
|
* ====================================================================== */
|
||||||
|
|
||||||
function createFactory() {
|
function createFactory() {
|
||||||
@@ -2455,8 +2479,8 @@
|
|||||||
// ── Per-instance Three.js state ───────────────────────────────────
|
// ── Per-instance Three.js state ───────────────────────────────────
|
||||||
let scene = null, cam = null, ren = null;
|
let scene = null, cam = null, ren = null;
|
||||||
let wrap = null;
|
let wrap = null;
|
||||||
// highway:visibility listener (slopsmith#246). Hides the .h3d-wrap
|
// highway:visibility listener (feedBack#246). Hides the .h3d-wrap
|
||||||
// overlay when slopsmith's canvas is display:none'd (splitscreen
|
// overlay when feedBack's canvas is display:none'd (splitscreen
|
||||||
// case). Without this, the wrap is a *sibling* of #highway so
|
// case). Without this, the wrap is a *sibling* of #highway so
|
||||||
// hiding #highway leaves the WebGL scene painting full-screen.
|
// hiding #highway leaves the WebGL scene painting full-screen.
|
||||||
// Bound in initScene after wrap creation, unbound in destroy().
|
// Bound in initScene after wrap creation, unbound in destroy().
|
||||||
@@ -2534,6 +2558,13 @@
|
|||||||
let _drawRecentByString = null;
|
let _drawRecentByString = null;
|
||||||
/** Snapshotted in update() — drawNote() is a sibling of update(), not nested in its closure. */
|
/** Snapshotted in update() — drawNote() is a sibling of update(), not nested in its closure. */
|
||||||
let _drawChordTemplates = null;
|
let _drawChordTemplates = null;
|
||||||
|
/** Teaching marks sd/ch overlay pref (§6.2.2), mirrored from the 2D
|
||||||
|
* highway's `teachingMarksVisible` bundle flag. */
|
||||||
|
let _drawTeachingMarks = false;
|
||||||
|
/** Fret-hand finger (fg) hint pref, mirrored from the 2D highway's
|
||||||
|
* `fingerHintsVisible` bundle flag — default on (shown unless an explicit
|
||||||
|
* false), hideable independently of the sd/ch overlays. */
|
||||||
|
let _showFingerHints = true;
|
||||||
let _laneTargetColor = null;
|
let _laneTargetColor = null;
|
||||||
let _renderScale = 1;
|
let _renderScale = 1;
|
||||||
let lyricsCanvas = null, lyricsCtx = null;
|
let lyricsCanvas = null, lyricsCtx = null;
|
||||||
@@ -2672,6 +2703,14 @@
|
|||||||
// that CSS-box drift and re-frame, instead of the user having to
|
// that CSS-box drift and re-frame, instead of the user having to
|
||||||
// un/re-maximize the window.
|
// un/re-maximize the window.
|
||||||
let _appliedW = 0, _appliedH = 0;
|
let _appliedW = 0, _appliedH = 0;
|
||||||
|
// True once applySize() has pinned the .h3d-wrap overlay to the
|
||||||
|
// highway canvas's offset box. Stays false while the canvas has no
|
||||||
|
// layout yet (init() can run before #highway has a real box, where
|
||||||
|
// applySize falls back to the parent-panel size and only sets the
|
||||||
|
// wrap height). The rAF loop re-pins once the canvas lays out even
|
||||||
|
// when the logical render size is unchanged — otherwise the overlay
|
||||||
|
// would stay at top:0;left:0;right:0 and expose a strip of #highway.
|
||||||
|
let _wrapPinned = false;
|
||||||
let mBeatM = null, mBeatQ = null;
|
let mBeatM = null, mBeatQ = null;
|
||||||
let txtCache = {};
|
let txtCache = {};
|
||||||
// Cloned sprite materials cached on individual sprite instances
|
// Cloned sprite materials cached on individual sprite instances
|
||||||
@@ -2803,9 +2842,9 @@
|
|||||||
|
|
||||||
// Notedetect feedback (issue #9). Per-panel mark queues populated
|
// Notedetect feedback (issue #9). Per-panel mark queues populated
|
||||||
// by two event sources: (a) legacy `notedetect:hit` /
|
// by two event sources: (a) legacy `notedetect:hit` /
|
||||||
// `notedetect:miss` window CustomEvents, and (b) Slopsmith
|
// `notedetect:miss` window CustomEvents, and (b) FeedBack
|
||||||
// event-bus `note:hit` / `note:miss` events (subscribed in
|
// event-bus `note:hit` / `note:miss` events (subscribed in
|
||||||
// initScene() when window.slopsmith exposes both `on` and `off`).
|
// initScene() when window.feedBack exposes both `on` and `off`).
|
||||||
// Both sources feed the same _ndPushMark() helper which dedupes
|
// Both sources feed the same _ndPushMark() helper which dedupes
|
||||||
// dual emissions. drawNote looks up its (s, f, t) against these
|
// dual emissions. drawNote looks up its (s, f, t) against these
|
||||||
// arrays each frame and swaps the outline material when a match
|
// arrays each frame and swaps the outline material when a match
|
||||||
@@ -2862,7 +2901,7 @@
|
|||||||
// no longer reads it — pruning lives once per frame so
|
// no longer reads it — pruning lives once per frame so
|
||||||
// drawNote's hot path is just the bounded (s, f, t) match.
|
// drawNote's hot path is just the bounded (s, f, t) match.
|
||||||
let _ndFrameNowMs = 0;
|
let _ndFrameNowMs = 0;
|
||||||
// slopsmith#254 — core's per-note judgment provider, captured
|
// feedBack#254 — core's per-note judgment provider, captured
|
||||||
// from `bundle.getNoteState` at the top of each update(). When
|
// from `bundle.getNoteState` at the top of each update(). When
|
||||||
// present it's authoritative over the event-driven marks above:
|
// present it's authoritative over the event-driven marks above:
|
||||||
// 'hit'/'active' → bright string-tinted outline (mGlow[s]) +
|
// 'hit'/'active' → bright string-tinted outline (mGlow[s]) +
|
||||||
@@ -2873,7 +2912,7 @@
|
|||||||
// with no scorer registered. Older note_detect builds that only
|
// with no scorer registered. Older note_detect builds that only
|
||||||
// emit notedetect:hit/miss events still work via _ndHitMarks.
|
// emit notedetect:hit/miss events still work via _ndHitMarks.
|
||||||
let _ndGetNoteState = null;
|
let _ndGetNoteState = null;
|
||||||
let _ndHasProvider = false; // true iff a note-state provider is registered (slopsmith#254)
|
let _ndHasProvider = false; // true iff a note-state provider is registered (feedBack#254)
|
||||||
// Sustain verdict latch — persists a provider's hit/miss verdict for the
|
// Sustain verdict latch — persists a provider's hit/miss verdict for the
|
||||||
// full duration of a sustained note. Once hitGlowDuration expires the
|
// full duration of a sustained note. Once hitGlowDuration expires the
|
||||||
// provider stops returning state; the latch re-injects the last verdict
|
// provider stops returning state; the latch re-injects the last verdict
|
||||||
@@ -2937,7 +2976,7 @@
|
|||||||
let _fxPalette = _FX_PALETTES.neon;
|
let _fxPalette = _FX_PALETTES.neon;
|
||||||
function _fxResolvePalette() {
|
function _fxResolvePalette() {
|
||||||
let skin = null;
|
let skin = null;
|
||||||
try { skin = localStorage.getItem('slopsmith_notedetect_skin'); } catch (e) {}
|
try { skin = localStorage.getItem('feedBack_notedetect_skin'); } catch (e) {}
|
||||||
_fxPalette = _FX_PALETTES[skin] || _FX_PALETTES.neon;
|
_fxPalette = _FX_PALETTES[skin] || _FX_PALETTES.neon;
|
||||||
}
|
}
|
||||||
function _fxSpawnPop(popKey, points, mult, x, y, z) {
|
function _fxSpawnPop(popKey, points, mult, x, y, z) {
|
||||||
@@ -3017,6 +3056,7 @@
|
|||||||
let gPMXLines = null, pMuteXLines = null; // PM X lines combined geometry (8 segs as quads)
|
let gPMXLines = null, pMuteXLines = null; // PM X lines combined geometry (8 segs as quads)
|
||||||
let gFHXLines = null, pFHXLines = null; // FH X lines combined geometry
|
let gFHXLines = null, pFHXLines = null; // FH X lines combined geometry
|
||||||
let pNoteFretLabel, pConnectorLine, pDropLine, pTapChevron, pAccentHalo;
|
let pNoteFretLabel, pConnectorLine, pDropLine, pTapChevron, pAccentHalo;
|
||||||
|
let pTeachMarkLbl; // teaching marks fg/sd label sprites (§6.2.2)
|
||||||
let pHaloBar = null, gHaloBar = null; // gradient halo bar geometry — replaces per-shell pChordAccentHalo
|
let pHaloBar = null, gHaloBar = null; // gradient halo bar geometry — replaces per-shell pChordAccentHalo
|
||||||
let gArpBracket = null; // shared 1×1×1 box geometry for pArpBracket; built once, disposed in teardown
|
let gArpBracket = null; // shared 1×1×1 box geometry for pArpBracket; built once, disposed in teardown
|
||||||
let pSusRibbon = null, pSusRibbonOl = null;
|
let pSusRibbon = null, pSusRibbonOl = null;
|
||||||
@@ -3340,7 +3380,7 @@
|
|||||||
|
|
||||||
function _unsubscribeFocus() {
|
function _unsubscribeFocus() {
|
||||||
if (!_focusSubscribed) return;
|
if (!_focusSubscribed) return;
|
||||||
const ss = window.slopsmithSplitscreen;
|
const ss = window.feedBackSplitscreen;
|
||||||
if (ss && typeof ss.offFocusChange === 'function') ss.offFocusChange(_onFocusChange);
|
if (ss && typeof ss.offFocusChange === 'function') ss.offFocusChange(_onFocusChange);
|
||||||
_focusSubscribed = false;
|
_focusSubscribed = false;
|
||||||
}
|
}
|
||||||
@@ -4047,7 +4087,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Object pool ────────────────────────────────────────────────────
|
// ── Object pool ────────────────────────────────────────────────────
|
||||||
// ── Opt-in perf bench harness (slopsmith#226) ──────────────────────
|
// ── Opt-in perf bench harness (feedBack#226) ──────────────────────
|
||||||
// Enable with `?h3dbench=1` on the player URL. Aggregates per-segment
|
// Enable with `?h3dbench=1` on the player URL. Aggregates per-segment
|
||||||
// timings of update() into a console.log every _PB_REPORT_MS.
|
// timings of update() into a console.log every _PB_REPORT_MS.
|
||||||
//
|
//
|
||||||
@@ -5033,21 +5073,21 @@
|
|||||||
wrap.setAttribute('data-h3d-primary', '');
|
wrap.setAttribute('data-h3d-primary', '');
|
||||||
highwayCanvas.parentNode.insertBefore(wrap, highwayCanvas.nextSibling);
|
highwayCanvas.parentNode.insertBefore(wrap, highwayCanvas.nextSibling);
|
||||||
|
|
||||||
// Subscribe to highway:visibility (slopsmith#246) so the
|
// Subscribe to highway:visibility (feedBack#246) so the
|
||||||
// .h3d-wrap overlay hides in sync with the slopsmith canvas.
|
// .h3d-wrap overlay hides in sync with the feedBack canvas.
|
||||||
// The wrap is a sibling of #highway, so display:none on
|
// The wrap is a sibling of #highway, so display:none on
|
||||||
// #highway leaves us painting full-screen otherwise.
|
// #highway leaves us painting full-screen otherwise.
|
||||||
// Guarded lazy bind: tolerate hosts that don't yet expose
|
// Guarded lazy bind: tolerate hosts that don't yet expose
|
||||||
// slopsmith.on/off (older slopsmith versions, headless
|
// feedBack.on/off (older feedBack versions, headless
|
||||||
// tests).
|
// tests).
|
||||||
if (window.slopsmith
|
if (window.feedBack
|
||||||
&& typeof window.slopsmith.on === 'function'
|
&& typeof window.feedBack.on === 'function'
|
||||||
&& typeof window.slopsmith.off === 'function') {
|
&& typeof window.feedBack.off === 'function') {
|
||||||
_visibilityHandler = (e) => {
|
_visibilityHandler = (e) => {
|
||||||
if (!wrap) return;
|
if (!wrap) return;
|
||||||
// Filter by canvas identity (splitscreen-safe).
|
// Filter by canvas identity (splitscreen-safe).
|
||||||
// Each createHighway() instance emits its own
|
// Each createHighway() instance emits its own
|
||||||
// visibility events on the shared slopsmith bus —
|
// visibility events on the shared feedBack bus —
|
||||||
// without this gate, one hidden panel would also
|
// without this gate, one hidden panel would also
|
||||||
// hide every other panel's 3D overlay.
|
// hide every other panel's 3D overlay.
|
||||||
if (!e || !e.detail || e.detail.canvas !== highwayCanvas) return;
|
if (!e || !e.detail || e.detail.canvas !== highwayCanvas) return;
|
||||||
@@ -5055,7 +5095,7 @@
|
|||||||
wrap.style.display = v === false ? 'none' : '';
|
wrap.style.display = v === false ? 'none' : '';
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
window.slopsmith.on('highway:visibility', _visibilityHandler);
|
window.feedBack.on('highway:visibility', _visibilityHandler);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_visibilityHandler = null;
|
_visibilityHandler = null;
|
||||||
}
|
}
|
||||||
@@ -5076,7 +5116,7 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
window.slopsmith.on('highway:canvas-replaced', _canvasReplacedHandler);
|
window.feedBack.on('highway:canvas-replaced', _canvasReplacedHandler);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_canvasReplacedHandler = null;
|
_canvasReplacedHandler = null;
|
||||||
}
|
}
|
||||||
@@ -6095,6 +6135,14 @@
|
|||||||
_nfl.material.depthTest = false;
|
_nfl.material.depthTest = false;
|
||||||
return _nfl;
|
return _nfl;
|
||||||
});
|
});
|
||||||
|
// Teaching marks fg/sd labels (§6.2.2). One pool, two get()s per note
|
||||||
|
// (finger + degree); the texture is swapped per draw via material.map.
|
||||||
|
pTeachMarkLbl = pool(lblG, () => {
|
||||||
|
const _tml = new T.Sprite(txtMat('0', '#7fd1ff', false, 'teachMark').clone());
|
||||||
|
_tml.material.fog = false;
|
||||||
|
_tml.material.depthTest = false;
|
||||||
|
return _tml;
|
||||||
|
});
|
||||||
pConnectorLine = pool(noteG, () => new T.Line(
|
pConnectorLine = pool(noteG, () => new T.Line(
|
||||||
new T.BufferGeometry().setFromPoints([new T.Vector3(0, 0, 0), new T.Vector3(0, 1, 0)]),
|
new T.BufferGeometry().setFromPoints([new T.Vector3(0, 0, 0), new T.Vector3(0, 1, 0)]),
|
||||||
new T.LineBasicMaterial({ color: 0xaaaaaa, transparent: true, opacity: 0.5, depthTest: false }),
|
new T.LineBasicMaterial({ color: 0xaaaaaa, transparent: true, opacity: 0.5, depthTest: false }),
|
||||||
@@ -6122,7 +6170,7 @@
|
|||||||
return _sp;
|
return _sp;
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Pre-warm pools (slopsmith#226) ─────────────────────────────
|
// ── Pre-warm pools (feedBack#226) ─────────────────────────────
|
||||||
// Dense 7/8-string charts can outrun the lazy-grow path in the
|
// Dense 7/8-string charts can outrun the lazy-grow path in the
|
||||||
// first 1-2s of playback, stalling those frames with `new T.Mesh`
|
// first 1-2s of playback, stalling those frames with `new T.Mesh`
|
||||||
// allocations *and* growing noteG forever (the pool only hides on
|
// allocations *and* growing noteG forever (the pool only hides on
|
||||||
@@ -6153,6 +6201,7 @@
|
|||||||
pSusRailBloom.warm(_WARM_CHORD);
|
pSusRailBloom.warm(_WARM_CHORD);
|
||||||
pTechPlane.warm(_WARM_CHORD);
|
pTechPlane.warm(_WARM_CHORD);
|
||||||
pNoteFretLabel.warm(_WARM_NOTE);
|
pNoteFretLabel.warm(_WARM_NOTE);
|
||||||
|
pTeachMarkLbl.warm(_WARM_NOTE);
|
||||||
pChordFrameFill.warm(_WARM_CHORD);
|
pChordFrameFill.warm(_WARM_CHORD);
|
||||||
pChordBox.warm(_WARM_CHORD);
|
pChordBox.warm(_WARM_CHORD);
|
||||||
pChordLbl.warm(_WARM_CHORD);
|
pChordLbl.warm(_WARM_CHORD);
|
||||||
@@ -6197,6 +6246,15 @@
|
|||||||
scene.add(bgGroup);
|
scene.add(bgGroup);
|
||||||
_bgMountStyle();
|
_bgMountStyle();
|
||||||
_bgListener = (changedKey) => {
|
_bgListener = (changedKey) => {
|
||||||
|
if (changedKey === 'fretSpacing') {
|
||||||
|
// _h3dFretUniform + the fretX-derived scalars were already
|
||||||
|
// updated globally in h3dSetFretSpacing. Rebuild this
|
||||||
|
// panel's static board geometry (fret wires, lanes, inlays)
|
||||||
|
// so it re-lays-out for the new spacing; per-frame note
|
||||||
|
// geometry reads fretX live and needs no rebuild.
|
||||||
|
if (fretG) buildBoard();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (changedKey === 'inlayLabelsVisible') {
|
if (changedKey === 'inlayLabelsVisible') {
|
||||||
_bgLoadSettings();
|
_bgLoadSettings();
|
||||||
// Flip visibility on the already-built sprites; no
|
// Flip visibility on the already-built sprites; no
|
||||||
@@ -6401,13 +6459,13 @@
|
|||||||
_ndOnMiss = (e) => { _ndMissMarks = _ndPushMark(_ndMissMarks, e.detail); };
|
_ndOnMiss = (e) => { _ndMissMarks = _ndPushMark(_ndMissMarks, e.detail); };
|
||||||
window.addEventListener('notedetect:hit', _ndOnHit);
|
window.addEventListener('notedetect:hit', _ndOnHit);
|
||||||
window.addEventListener('notedetect:miss', _ndOnMiss);
|
window.addEventListener('notedetect:miss', _ndOnMiss);
|
||||||
if (window.slopsmith &&
|
if (window.feedBack &&
|
||||||
typeof window.slopsmith.on === 'function' &&
|
typeof window.feedBack.on === 'function' &&
|
||||||
typeof window.slopsmith.off === 'function') {
|
typeof window.feedBack.off === 'function') {
|
||||||
_ndOnBusHit = (e) => { _ndHitMarks = _ndPushMark(_ndHitMarks, e.detail); };
|
_ndOnBusHit = (e) => { _ndHitMarks = _ndPushMark(_ndHitMarks, e.detail); };
|
||||||
_ndOnBusMiss = (e) => { _ndMissMarks = _ndPushMark(_ndMissMarks, e.detail); };
|
_ndOnBusMiss = (e) => { _ndMissMarks = _ndPushMark(_ndMissMarks, e.detail); };
|
||||||
window.slopsmith.on('note:hit', _ndOnBusHit);
|
window.feedBack.on('note:hit', _ndOnBusHit);
|
||||||
window.slopsmith.on('note:miss', _ndOnBusMiss);
|
window.feedBack.on('note:miss', _ndOnBusMiss);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Score FX (notedetect ≥1.13). notedetect dispatches each fx
|
// Score FX (notedetect ≥1.13). notedetect dispatches each fx
|
||||||
@@ -6441,10 +6499,10 @@
|
|||||||
}, 0);
|
}, 0);
|
||||||
};
|
};
|
||||||
window.addEventListener('notedetect:fx', _fxOnFx);
|
window.addEventListener('notedetect:fx', _fxOnFx);
|
||||||
if (window.slopsmith && typeof window.slopsmith.on === 'function'
|
if (window.feedBack && typeof window.feedBack.on === 'function'
|
||||||
&& typeof window.slopsmith.off === 'function') {
|
&& typeof window.feedBack.off === 'function') {
|
||||||
_fxOnSkin = () => _fxResolvePalette();
|
_fxOnSkin = () => _fxResolvePalette();
|
||||||
window.slopsmith.on('notedetect:skin', _fxOnSkin);
|
window.feedBack.on('notedetect:skin', _fxOnSkin);
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -8257,7 +8315,7 @@
|
|||||||
function smoothNow(bundle) {
|
function smoothNow(bundle) {
|
||||||
const raw = bundle.currentTime;
|
const raw = bundle.currentTime;
|
||||||
const p = performance.now();
|
const p = performance.now();
|
||||||
// Host pause signal (slopsmith core's bundle.isPlaying): when the
|
// Host pause signal (feedBack core's bundle.isPlaying): when the
|
||||||
// chart clock isn't advancing (paused / stalled / mid-seek), don't
|
// chart clock isn't advancing (paused / stalled / mid-seek), don't
|
||||||
// extrapolate forward against a frozen audio sample — that creeps
|
// extrapolate forward against a frozen audio sample — that creeps
|
||||||
// the highway ahead by up to the interp cap and then snaps back
|
// the highway ahead by up to the interp cap and then snaps back
|
||||||
@@ -8353,6 +8411,7 @@
|
|||||||
if (pMuteXLines) pMuteXLines.reset();
|
if (pMuteXLines) pMuteXLines.reset();
|
||||||
if (pFHXLines) pFHXLines.reset();
|
if (pFHXLines) pFHXLines.reset();
|
||||||
pNoteFretLabel.reset(); pConnectorLine.reset(); pDropLine.reset();
|
pNoteFretLabel.reset(); pConnectorLine.reset(); pDropLine.reset();
|
||||||
|
pTeachMarkLbl.reset();
|
||||||
pFretColMarker.reset(); pSusRail.reset(); pSusRailBloom.reset(); pTechPlane.reset();
|
pFretColMarker.reset(); pSusRail.reset(); pSusRailBloom.reset(); pTechPlane.reset();
|
||||||
// Clear per-frame queues in-place (avoid reallocating the array object).
|
// Clear per-frame queues in-place (avoid reallocating the array object).
|
||||||
_ndLabels.length = 0;
|
_ndLabels.length = 0;
|
||||||
@@ -8378,7 +8437,7 @@
|
|||||||
if (_ndMissMarks[_pi].expiresAt <= _ndFrameNowMs) _ndMissMarks.splice(_pi, 1);
|
if (_ndMissMarks[_pi].expiresAt <= _ndFrameNowMs) _ndMissMarks.splice(_pi, 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// slopsmith#254 — capture core's per-note judgment provider for
|
// feedBack#254 — capture core's per-note judgment provider for
|
||||||
// this frame's drawNote() calls (held-sustain glow + lit gems).
|
// this frame's drawNote() calls (held-sustain glow + lit gems).
|
||||||
// bundle.getNoteState is ALWAYS present (the core stub returns
|
// bundle.getNoteState is ALWAYS present (the core stub returns
|
||||||
// null when no provider is registered), so its existence isn't
|
// null when no provider is registered), so its existence isn't
|
||||||
@@ -8816,6 +8875,9 @@
|
|||||||
|
|
||||||
_drawNextByString = nextNoteByString;
|
_drawNextByString = nextNoteByString;
|
||||||
_drawChordTemplates = bundle.chordTemplates ?? null;
|
_drawChordTemplates = bundle.chordTemplates ?? null;
|
||||||
|
_drawTeachingMarks = !!bundle.teachingMarksVisible;
|
||||||
|
// Default on: only an explicit false (older bundles omit the flag) hides fg.
|
||||||
|
_showFingerHints = bundle.fingerHintsVisible !== false;
|
||||||
|
|
||||||
// ── Recent-past event per string (for _nextAnyT deadline) ─────
|
// ── Recent-past event per string (for _nextAnyT deadline) ─────
|
||||||
// Once a note/chord passes `now` it leaves _drawNextByString,
|
// Once a note/chord passes `now` it leaves _drawNextByString,
|
||||||
@@ -9078,10 +9140,10 @@
|
|||||||
{
|
{
|
||||||
const si = bundle.songInfo;
|
const si = bundle.songInfo;
|
||||||
// bundle.songInfo has no filename field (the WS song_info message
|
// bundle.songInfo has no filename field (the WS song_info message
|
||||||
// never includes it). Use window.slopsmith.currentSong.filename
|
// never includes it). Use window.feedBack.currentSong.filename
|
||||||
// — set by highway.js from the WS URL — combined with the
|
// — set by highway.js from the WS URL — combined with the
|
||||||
// arrangement index as a reliable per-song-arrangement key.
|
// arrangement index as a reliable per-song-arrangement key.
|
||||||
const currentSong = window.slopsmith && window.slopsmith.currentSong;
|
const currentSong = window.feedBack && window.feedBack.currentSong;
|
||||||
const key = currentSong ? currentSong.filename + '\0' + (si ? (si.arrangement_index ?? '') : '') : null;
|
const key = currentSong ? currentSong.filename + '\0' + (si ? (si.arrangement_index ?? '') : '') : null;
|
||||||
if (key !== null && key !== _songKey) {
|
if (key !== null && key !== _songKey) {
|
||||||
_songKey = key;
|
_songKey = key;
|
||||||
@@ -9667,7 +9729,7 @@
|
|||||||
// lingering past that point.
|
// lingering past that point.
|
||||||
chordTailHoldS = Math.min(CHORD_HWY_LINGER_S, Math.max(cjNext.t - ch.t, 1e-3));
|
chordTailHoldS = Math.min(CHORD_HWY_LINGER_S, Math.max(cjNext.t - ch.t, 1e-3));
|
||||||
}
|
}
|
||||||
// slopsmith#254 — engine verdicts land ~0.4 s after the
|
// feedBack#254 — engine verdicts land ~0.4 s after the
|
||||||
// chord crosses; on a fast different-voicing sequence
|
// chord crosses; on a fast different-voicing sequence
|
||||||
// the clip above can shrink the rim's draw life below
|
// the clip above can shrink the rim's draw life below
|
||||||
// that, so the green/red latch is set but the rim isn't
|
// that, so the green/red latch is set but the rim isn't
|
||||||
@@ -9823,6 +9885,19 @@
|
|||||||
// so Object.assign leaves a stale `true` from a previous
|
// so Object.assign leaves a stale `true` from a previous
|
||||||
// muted chord note untouched. Reset it explicitly here.
|
// muted chord note untouched. Reset it explicitly here.
|
||||||
_scrChordNote.fhm = cn.fhm || false;
|
_scrChordNote.fhm = cn.fhm || false;
|
||||||
|
// Same stale-scratch hazard for the bend shape:
|
||||||
|
// `bnv`/`bt` are omit-when-default on the wire, so a
|
||||||
|
// chord note without them would otherwise inherit the
|
||||||
|
// previous note's curve (and bendSemisAtTime would
|
||||||
|
// apply the wrong contour). Reset explicitly.
|
||||||
|
_scrChordNote.bnv = Array.isArray(cn.bnv) ? cn.bnv : undefined;
|
||||||
|
_scrChordNote.bt = cn.bt || 0;
|
||||||
|
// Same stale-scratch hazard for the teaching marks
|
||||||
|
// (§6.2.2): fg/sd are omit-when-default on the wire,
|
||||||
|
// so a chord note without them must reset to -1 or it
|
||||||
|
// inherits the previous note's finger/degree label.
|
||||||
|
_scrChordNote.fg = Number.isInteger(cn.fg) ? cn.fg : -1;
|
||||||
|
_scrChordNote.sd = Number.isInteger(cn.sd) ? cn.sd : -1;
|
||||||
drawNote(
|
drawNote(
|
||||||
_scrChordNote,
|
_scrChordNote,
|
||||||
now,
|
now,
|
||||||
@@ -9967,7 +10042,7 @@
|
|||||||
// Used for the mute X lines so hit/miss feedback only shows on
|
// Used for the mute X lines so hit/miss feedback only shows on
|
||||||
// the outer borders of the framebox, not inside the X pattern.
|
// the outer borders of the framebox, not inside the X pattern.
|
||||||
const baseRimHex = rimHex;
|
const baseRimHex = rimHex;
|
||||||
// slopsmith#254 — once the chord crosses the hit
|
// feedBack#254 — once the chord crosses the hit
|
||||||
// line, tint the teal frame by the note-state
|
// line, tint the teal frame by the note-state
|
||||||
// provider verdict: green on a clean grab, red on a
|
// provider verdict: green on a clean grab, red on a
|
||||||
// miss. The verdict is async (the engine verifier
|
// miss. The verdict is async (the engine verifier
|
||||||
@@ -10262,6 +10337,40 @@
|
|||||||
lbl.scale.set(lblWS, lblHS, 1);
|
lbl.scale.set(lblWS, lblHS, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Harmony annotations (§6.3.1 / §6.6) — the chord's
|
||||||
|
// function (fn.rn Roman numeral) and template voicing,
|
||||||
|
// stacked above the chord name. Gated by the
|
||||||
|
// teaching-marks opt-in (mirrors the 2D overlay). Display
|
||||||
|
// only — never grading.
|
||||||
|
if (_drawTeachingMarks && firstInShapeRun && !chordWireHighDensity(ch)) {
|
||||||
|
const _tmpl = bundle.chordTemplates?.[ch.id];
|
||||||
|
const _h = chordHarmonyLabels(ch.fn, _tmpl?.voicing, _tmpl?.caged, _tmpl?.guideTones);
|
||||||
|
if (_h.rn || _h.voicing || _h.caged || _h.guideTones) {
|
||||||
|
const hlW = 24 * K * _textSizeMul;
|
||||||
|
const hlH = 9 * K * _textSizeMul;
|
||||||
|
const frameLeft = cx - width / 2;
|
||||||
|
const baseX = frameLeft - hlW / 2 + NW * 0.94;
|
||||||
|
const opacity = Math.min(1, 0.3 + fade * 0.7) * chordTailMul;
|
||||||
|
// Start one chord-name-height above the name and
|
||||||
|
// stack upward so labels never overlap the gems.
|
||||||
|
let hy = yMaxF + hlH * 1.6;
|
||||||
|
const _drawHarmony = (text, colorHex) => {
|
||||||
|
if (!text) return;
|
||||||
|
const s = pChordLbl.get();
|
||||||
|
const m = txtMat(text, colorHex, true, 'chord');
|
||||||
|
if (s.material.map !== m.map) { s.material.map = m.map; s.material.needsUpdate = true; }
|
||||||
|
s.material.opacity = opacity;
|
||||||
|
s.position.set(baseX, hy, z);
|
||||||
|
s.scale.set(hlW, hlH, 1);
|
||||||
|
hy += hlH;
|
||||||
|
};
|
||||||
|
_drawHarmony(_h.rn, '#ffcc66'); // sd teaching color
|
||||||
|
_drawHarmony(_h.voicing, '#7fd1ff'); // fg teaching color
|
||||||
|
_drawHarmony(_h.caged, '#a0ffa0'); // CAGED shape teaching color
|
||||||
|
_drawHarmony(_h.guideTones, '#d0a0ff'); // guide-tone teaching color
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Shape-based barre detection for the 3D indicator.
|
// Shape-based barre detection for the 3D indicator.
|
||||||
// Drives off chord notes alone — independent of label
|
// Drives off chord notes alone — independent of label
|
||||||
// availability, so charts whose chordTemplates lack a
|
// availability, so charts whose chordTemplates lack a
|
||||||
@@ -11309,15 +11418,71 @@
|
|||||||
return visualIdx >= (nStr - 1) * 0.5 ? -1 : 1;
|
return visualIdx >= (nStr - 1) * 0.5 ? -1 : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Teaching marks (§6.2.2) — display only, never grading. Pure label
|
||||||
|
// helpers, mirroring static/highway.js so the two highways agree;
|
||||||
|
// node-tested via tests/js/highway_teaching_marks.test.js.
|
||||||
|
function teachingFingerLabel(fg) {
|
||||||
|
// fret-hand finger: '' when unset/out of range; 0 -> 'T' (thumb),
|
||||||
|
// 1..4 -> '1'..'4'.
|
||||||
|
if (!Number.isInteger(fg) || fg < 0 || fg > 4) return '';
|
||||||
|
return fg === 0 ? 'T' : String(fg);
|
||||||
|
}
|
||||||
|
function teachingDegreeLabel(sd) {
|
||||||
|
// scale degree: chromatic 0..11 above the active key tonic; '' when
|
||||||
|
// unset/out of range.
|
||||||
|
if (!Number.isInteger(sd) || sd < 0 || sd > 11) return '';
|
||||||
|
return String(sd);
|
||||||
|
}
|
||||||
|
/** Harmony annotations (§6.3.1 / §6.6): display labels for a chord's
|
||||||
|
* function (instance `fn.rn` Roman numeral) and template `voicing`,
|
||||||
|
* `caged` shape, and `guideTones`. '' for each when absent/malformed;
|
||||||
|
* `caged`/`guideTones` come back pre-formatted ("CAGED: E" / "gt 4,10").
|
||||||
|
* Pure; shared with the 2D highway and node-tested. Display only — never
|
||||||
|
* grading. */
|
||||||
|
function chordHarmonyLabels(fn, voicing, caged, guideTones) {
|
||||||
|
const rn = (fn && typeof fn.rn === 'string') ? fn.rn.trim() : '';
|
||||||
|
const vc = (typeof voicing === 'string') ? voicing.trim() : '';
|
||||||
|
const cg = (typeof caged === 'string' && /^[CAGED]$/.test(caged.trim()))
|
||||||
|
? 'CAGED: ' + caged.trim() : '';
|
||||||
|
const gt = Array.isArray(guideTones)
|
||||||
|
? guideTones.filter(n => Number.isInteger(n) && n >= 0 && n <= 11) : [];
|
||||||
|
return { rn, voicing: vc, caged: cg, guideTones: gt.length ? 'gt ' + gt.join(',') : '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function bnvSampleAt(bnv, t) {
|
||||||
|
// Linear interpolation of a bend curve [{t, v}] (§6.2.1; t is
|
||||||
|
// seconds from the note onset) at elapsed time t. Clamps to the
|
||||||
|
// endpoints; returns 0 for an empty/invalid curve.
|
||||||
|
if (!Array.isArray(bnv) || bnv.length === 0) return 0;
|
||||||
|
if (t <= bnv[0].t) return bnv[0].v;
|
||||||
|
const last = bnv[bnv.length - 1];
|
||||||
|
if (t >= last.t) return last.v;
|
||||||
|
for (let i = 1; i < bnv.length; i++) {
|
||||||
|
const a = bnv[i - 1], b = bnv[i];
|
||||||
|
if (t <= b.t) {
|
||||||
|
const span = b.t - a.t;
|
||||||
|
return span > 0 ? a.v + (b.v - a.v) * ((t - a.t) / span) : b.v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return last.v;
|
||||||
|
}
|
||||||
|
|
||||||
function bendSemisAtTime(n, chartTime) {
|
function bendSemisAtTime(n, chartTime) {
|
||||||
|
if (!(n?.sus > 0)) return 0;
|
||||||
|
// When the note carries an authoritative bend curve (§6.2.1),
|
||||||
|
// sample its real shape at the elapsed time so the gem's Y gesture
|
||||||
|
// and sustain ribbon follow the actual bend (pre-bend, round-trip,
|
||||||
|
// release, …). Negative samples clamp to 0 (upward-only Y offset).
|
||||||
|
if (Array.isArray(n.bnv) && n.bnv.length) {
|
||||||
|
return Math.max(0, bnvSampleAt(n.bnv, chartTime - n.t));
|
||||||
|
}
|
||||||
const bn = Number(n?.bn) || 0;
|
const bn = Number(n?.bn) || 0;
|
||||||
if (!(bn > 0) || !(n?.sus > 0)) return 0;
|
if (!(bn > 0)) return 0;
|
||||||
const p = Math.max(0, Math.min(1, (chartTime - n.t) / Math.max(n.sus, 1e-6)));
|
const p = Math.max(0, Math.min(1, (chartTime - n.t) / Math.max(n.sus, 1e-6)));
|
||||||
// rise → hold → release: ramp up over the first ~35 %, hold, then
|
// Fallback: synthesize rise → hold → release from the scalar peak.
|
||||||
// release back down over the last ~30 %. Depicts the bend gesture
|
// Ramp up over the first ~35 %, hold, then release over the last
|
||||||
// (up and back down) rather than a monotone climb that only ever
|
// ~30 % — the bend gesture rather than a monotone climb. Drives both
|
||||||
// showed the bend going up. Drives both the sustain ribbon's Y
|
// the sustain ribbon's Y contour and the gem's techniqueYNow offset.
|
||||||
// contour and the gem's techniqueYNow offset.
|
|
||||||
const RISE = BEND_ENV_RISE_FRAC, REL = BEND_ENV_RELEASE_FRAC;
|
const RISE = BEND_ENV_RISE_FRAC, REL = BEND_ENV_RELEASE_FRAC;
|
||||||
let env;
|
let env;
|
||||||
if (p < RISE) env = p / RISE;
|
if (p < RISE) env = p / RISE;
|
||||||
@@ -11558,7 +11723,7 @@
|
|||||||
const effectiveProjWin = _rawGap > 0 ? Math.min(0.6, Math.max(0.05, _rawGap)) : 0.6;
|
const effectiveProjWin = _rawGap > 0 ? Math.min(0.6, Math.max(0.05, _rawGap)) : 0.6;
|
||||||
const projFactorG = Math.max(0, Math.min(1, 1 - Math.max(dt, 0) / effectiveProjWin));
|
const projFactorG = Math.max(0, Math.min(1, 1 - Math.max(dt, 0) / effectiveProjWin));
|
||||||
const inGhostWin = n.f > 0 && isNextOnString && dt > -ghostHold && dt < effectiveProjWin && projFactorG > 0.001;
|
const inGhostWin = n.f > 0 && isNextOnString && dt > -ghostHold && dt < effectiveProjWin && projFactorG > 0.001;
|
||||||
// slopsmith#254 — query the provider once per note, before both !skipBody
|
// feedBack#254 — query the provider once per note, before both !skipBody
|
||||||
// blocks, so _showHit can be a const and _ndGood is available for the
|
// blocks, so _showHit can be a const and _ndGood is available for the
|
||||||
// sustain trail (which renders even when skipBody=true for slide targets).
|
// sustain trail (which renders even when skipBody=true for slide targets).
|
||||||
let _ndGood = false; // true when provider confirms hit/active
|
let _ndGood = false; // true when provider confirms hit/active
|
||||||
@@ -11733,7 +11898,7 @@
|
|||||||
const rimXY = n.ac ? ACCENT_RIM_XY_SCALE_MUL : 1;
|
const rimXY = n.ac ? ACCENT_RIM_XY_SCALE_MUL : 1;
|
||||||
const rimZ = n.ac ? ACCENT_RIM_Z_SCALE_MUL : 1;
|
const rimZ = n.ac ? ACCENT_RIM_Z_SCALE_MUL : 1;
|
||||||
|
|
||||||
// slopsmith#254 — apply outline + lateral face-fill overrides from provider verdict.
|
// feedBack#254 — apply outline + lateral face-fill overrides from provider verdict.
|
||||||
// hit/active → green outline (mHitBright[s]) + green lateral faces;
|
// hit/active → green outline (mHitBright[s]) + green lateral faces;
|
||||||
// miss → magenta-red outline (mMissOutline) + dark lateral faces; front/back stay transparent.
|
// miss → magenta-red outline (mMissOutline) + dark lateral faces; front/back stay transparent.
|
||||||
if (_ndCs) {
|
if (_ndCs) {
|
||||||
@@ -11917,6 +12082,7 @@
|
|||||||
const ribbonSusTrail = !!(
|
const ribbonSusTrail = !!(
|
||||||
(slideSt && n.f > 0 && (n.sus || 0) > 1e-4)
|
(slideSt && n.f > 0 && (n.sus || 0) > 1e-4)
|
||||||
|| (Number(n.bn) > 0)
|
|| (Number(n.bn) > 0)
|
||||||
|
|| (Array.isArray(n.bnv) && n.bnv.length > 0)
|
||||||
|| n.tr
|
|| n.tr
|
||||||
|| hasTechniqueVibrato
|
|| hasTechniqueVibrato
|
||||||
);
|
);
|
||||||
@@ -12087,11 +12253,17 @@
|
|||||||
arrow.material.opacity = 1;
|
arrow.material.opacity = 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (n.bn > 0) {
|
// Derive the peak from bn OR the bnv curve: a note may carry an
|
||||||
|
// authoritative curve with bn left at 0 (bn SHOULD be the peak
|
||||||
|
// whenever bnv exists — this is the robustness fallback).
|
||||||
|
const _bnvPeak = (Array.isArray(n.bnv) && n.bnv.length)
|
||||||
|
? n.bnv.reduce((m, p) => Math.max(m, Number(p.v) || 0), 0) : 0;
|
||||||
|
const _bendPeak = Math.max(Number(n.bn) || 0, _bnvPeak);
|
||||||
|
if (_bendPeak > 0) {
|
||||||
// Bend chevron stack — PlaneGeometry mesh so it tilts with
|
// Bend chevron stack — PlaneGeometry mesh so it tilts with
|
||||||
// the gem (approachRot). Fixed world size so it perspective-
|
// the gem (approachRot). Fixed world size so it perspective-
|
||||||
// shrinks naturally without distFactor compensation.
|
// shrinks naturally without distFactor compensation.
|
||||||
const steps = Math.max(1, Math.min(4, Math.round(n.bn)));
|
const steps = Math.max(1, Math.min(4, Math.round(_bendPeak)));
|
||||||
const bendSm = bendChevronMat(steps, activePalette[s] || 0xffffff);
|
const bendSm = bendChevronMat(steps, activePalette[s] || 0xffffff);
|
||||||
const l = pTechPlane.get();
|
const l = pTechPlane.get();
|
||||||
l.material = _spriteMat2MeshMat(l, bendSm);
|
l.material = _spriteMat2MeshMat(l, bendSm);
|
||||||
@@ -12221,6 +12393,35 @@
|
|||||||
fretLabel.scale.set(flS, flS, 1);
|
fretLabel.scale.set(flS, flS, 1);
|
||||||
fretLabel.material.opacity = alpha;
|
fretLabel.material.opacity = alpha;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Teaching marks (§6.2.2) — display only, never grading. The
|
||||||
|
// fret-hand finger (fg) renders by default to the right of the
|
||||||
|
// fret label (hideable via the finger-hints toggle); the scale
|
||||||
|
// degree (sd) is opt-in (mirrors the 2D `teachingMarksVisible`
|
||||||
|
// toggle) and renders to the left.
|
||||||
|
if (alpha > 0 && n.f > 0) {
|
||||||
|
const _tmS = 5.0 * K * _textSizeMul * fretLabelScaleForFret(n.f);
|
||||||
|
const _drawTeachMark = (text, colorHex, dx, cacheKey) => {
|
||||||
|
if (!text) return;
|
||||||
|
const spr = pTeachMarkLbl.get();
|
||||||
|
const m = txtMat(text, colorHex, false, cacheKey);
|
||||||
|
if (spr.material.map !== m.map) {
|
||||||
|
spr.material.map = m.map;
|
||||||
|
spr.material.needsUpdate = true;
|
||||||
|
}
|
||||||
|
spr.position.set(x + dx, labelY, noteZ);
|
||||||
|
spr.renderOrder = renderOrderForLayerAtZ(noteZ,
|
||||||
|
_isArpNote ? 'ARP_NOTE_FRET_LABEL' : 'NOTE_FRET_LABEL');
|
||||||
|
spr.scale.set(_tmS, _tmS, 1);
|
||||||
|
spr.material.opacity = alpha;
|
||||||
|
};
|
||||||
|
if (_showFingerHints) {
|
||||||
|
_drawTeachMark(teachingFingerLabel(n.fg), '#7fd1ff', NW * 0.95, 'teachFg');
|
||||||
|
}
|
||||||
|
if (_drawTeachingMarks) {
|
||||||
|
_drawTeachMark(teachingDegreeLabel(n.sd), '#ffcc66', -NW * 0.95, 'teachSd');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -12748,7 +12949,55 @@
|
|||||||
const baseDPR = _ssActive() ? Math.min(devicePixelRatio, 1.25) : Math.min(devicePixelRatio, 2);
|
const baseDPR = _ssActive() ? Math.min(devicePixelRatio, 1.25) : Math.min(devicePixelRatio, 2);
|
||||||
ren.setPixelRatio(_renderScale * baseDPR);
|
ren.setPixelRatio(_renderScale * baseDPR);
|
||||||
ren.setSize(w, h);
|
ren.setSize(w, h);
|
||||||
wrap.style.height = h + 'px';
|
// Pin the overlay to #highway's exact box so it fully covers the
|
||||||
|
// canvas. The wrap is anchored to top:0/left:0/right:0 of its
|
||||||
|
// offset parent, which only lines up with #highway when the
|
||||||
|
// canvas sits at the parent's origin. The v3 player can place
|
||||||
|
// chrome above the canvas, shifting the wrap up so its lower edge
|
||||||
|
// falls short of #highway — leaving a strip of the canvas exposed
|
||||||
|
// (the reported gap, where the previous renderer's frame showed
|
||||||
|
// through). The wrap is a sibling of highwayCanvas, so they share
|
||||||
|
// an offset parent; tracking the canvas's box keeps the overlay
|
||||||
|
// flush in single-player and splitscreen alike.
|
||||||
|
//
|
||||||
|
// Derive the box from the SAME getBoundingClientRect measurements
|
||||||
|
// that drive ren.setSize(w, h) — NOT integer offsetTop/Width — so
|
||||||
|
// the overlay matches the renderer exactly. Under browser zoom or
|
||||||
|
// fractional flex layouts the canvas lands on sub-pixel bounds;
|
||||||
|
// offsetWidth/Top round to whole pixels and would leave the wrap up
|
||||||
|
// to 1px short of (or shifted from) the canvas, reopening the
|
||||||
|
// exposed edge strip. Position is taken relative to the containing
|
||||||
|
// block's padding edge (clientTop/Left strip the parent's border),
|
||||||
|
// which is what `top`/`left` resolve against for the absolutely
|
||||||
|
// positioned wrap. Guarded on a laid-out canvas (offsetWidth/Height
|
||||||
|
// > 0); otherwise fall back to the static top:0/left:0/right:0.
|
||||||
|
if (highwayCanvas && highwayCanvas.offsetWidth > 0 && highwayCanvas.offsetHeight > 0) {
|
||||||
|
const _pinParent = wrap.offsetParent || highwayCanvas.parentNode;
|
||||||
|
const _cr = highwayCanvas.getBoundingClientRect();
|
||||||
|
const _pr = _pinParent ? _pinParent.getBoundingClientRect() : { top: 0, left: 0 };
|
||||||
|
const _pbTop = _pinParent ? _pinParent.clientTop : 0;
|
||||||
|
const _pbLeft = _pinParent ? _pinParent.clientLeft : 0;
|
||||||
|
wrap.style.top = (_cr.top - _pr.top - _pbTop) + 'px';
|
||||||
|
wrap.style.left = (_cr.left - _pr.left - _pbLeft) + 'px';
|
||||||
|
wrap.style.right = 'auto';
|
||||||
|
wrap.style.width = _cr.width + 'px';
|
||||||
|
wrap.style.height = _cr.height + 'px';
|
||||||
|
_wrapPinned = true;
|
||||||
|
} else {
|
||||||
|
// Canvas not laid out (e.g. init ran before #highway had a real
|
||||||
|
// box, or a panel hide/show where canvasSize() falls back to the
|
||||||
|
// parent panel). Reset to the static anchor — if we had pinned
|
||||||
|
// before, the old top/left/right:auto/width would otherwise stay
|
||||||
|
// and the wrap would reappear at a stale horizontal position on
|
||||||
|
// the next show. Leave _wrapPinned false so the rAF loop re-pins
|
||||||
|
// once the canvas materializes again.
|
||||||
|
wrap.style.top = '0';
|
||||||
|
wrap.style.left = '0';
|
||||||
|
wrap.style.right = '0';
|
||||||
|
wrap.style.width = 'auto';
|
||||||
|
wrap.style.height = h + 'px';
|
||||||
|
_wrapPinned = false;
|
||||||
|
}
|
||||||
if (lyricsCanvas) { lyricsCanvas.width = w; lyricsCanvas.height = h; }
|
if (lyricsCanvas) { lyricsCanvas.width = w; lyricsCanvas.height = h; }
|
||||||
_diagRenderCache.clear();
|
_diagRenderCache.clear();
|
||||||
cam.aspect = w / h;
|
cam.aspect = w / h;
|
||||||
@@ -12770,15 +13019,15 @@
|
|||||||
if (_ndOnHit) { window.removeEventListener('notedetect:hit', _ndOnHit); _ndOnHit = null; }
|
if (_ndOnHit) { window.removeEventListener('notedetect:hit', _ndOnHit); _ndOnHit = null; }
|
||||||
if (_ndOnMiss) { window.removeEventListener('notedetect:miss', _ndOnMiss); _ndOnMiss = null; }
|
if (_ndOnMiss) { window.removeEventListener('notedetect:miss', _ndOnMiss); _ndOnMiss = null; }
|
||||||
if (_fxOnFx) { window.removeEventListener('notedetect:fx', _fxOnFx); _fxOnFx = null; }
|
if (_fxOnFx) { window.removeEventListener('notedetect:fx', _fxOnFx); _fxOnFx = null; }
|
||||||
if (window.slopsmith && typeof window.slopsmith.off === 'function') {
|
if (window.feedBack && typeof window.feedBack.off === 'function') {
|
||||||
if (_fxOnSkin) { try { window.slopsmith.off('notedetect:skin', _fxOnSkin); } catch (e) {} _fxOnSkin = null; }
|
if (_fxOnSkin) { try { window.feedBack.off('notedetect:skin', _fxOnSkin); } catch (e) {} _fxOnSkin = null; }
|
||||||
if (_ndOnBusHit) window.slopsmith.off('note:hit', _ndOnBusHit);
|
if (_ndOnBusHit) window.feedBack.off('note:hit', _ndOnBusHit);
|
||||||
if (_ndOnBusMiss) window.slopsmith.off('note:miss', _ndOnBusMiss);
|
if (_ndOnBusMiss) window.feedBack.off('note:miss', _ndOnBusMiss);
|
||||||
if (_visibilityHandler) {
|
if (_visibilityHandler) {
|
||||||
try { window.slopsmith.off('highway:visibility', _visibilityHandler); } catch (e) {}
|
try { window.feedBack.off('highway:visibility', _visibilityHandler); } catch (e) {}
|
||||||
}
|
}
|
||||||
if (_canvasReplacedHandler) {
|
if (_canvasReplacedHandler) {
|
||||||
try { window.slopsmith.off('highway:canvas-replaced', _canvasReplacedHandler); } catch (e) {}
|
try { window.feedBack.off('highway:canvas-replaced', _canvasReplacedHandler); } catch (e) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ndOnBusHit = _ndOnBusMiss = null;
|
_ndOnBusHit = _ndOnBusMiss = null;
|
||||||
@@ -12934,7 +13183,7 @@
|
|||||||
_renderScale = 1;
|
_renderScale = 1;
|
||||||
mBeatM = mBeatQ = null;
|
mBeatM = mBeatQ = null;
|
||||||
pNote = pNoteEdge = pSus = pSusOutline = pSusRibbon = pSusRibbonOl = pLbl = pBeat = pSec = null;
|
pNote = pNoteEdge = pSus = pSusOutline = pSusRibbon = pSusRibbonOl = pLbl = pBeat = pSec = null;
|
||||||
pFretLbl = pLane = pLaneDivider = pGhostFretLbl = pChordBox = pChordFrameFill = pChordLbl = pBarreLine = pArpBracket = pNoteFretLabel = pConnectorLine = pDropLine = pTapChevron = pAccentHalo = pHaloBar = pPMXFill = pFHXFill = pMuteXLines = pFHXLines = null;
|
pFretLbl = pLane = pLaneDivider = pGhostFretLbl = pChordBox = pChordFrameFill = pChordLbl = pBarreLine = pArpBracket = pNoteFretLabel = pConnectorLine = pDropLine = pTapChevron = pAccentHalo = pHaloBar = pPMXFill = pFHXFill = pMuteXLines = pFHXLines = pTeachMarkLbl = null;
|
||||||
if (gPMXFill) { gPMXFill.dispose(); gPMXFill = null; }
|
if (gPMXFill) { gPMXFill.dispose(); gPMXFill = null; }
|
||||||
if (gFHXFill) { gFHXFill.dispose(); gFHXFill = null; }
|
if (gFHXFill) { gFHXFill.dispose(); gFHXFill = null; }
|
||||||
if (gPMXLines) { gPMXLines.dispose(); gPMXLines = null; }
|
if (gPMXLines) { gPMXLines.dispose(); gPMXLines = null; }
|
||||||
@@ -13012,11 +13261,11 @@
|
|||||||
_bgReactiveOptOut = !!(bundle && bundle.bgReactive === false);
|
_bgReactiveOptOut = !!(bundle && bundle.bgReactive === false);
|
||||||
|
|
||||||
if (_ssActive()) {
|
if (_ssActive()) {
|
||||||
window.slopsmithSplitscreen.onFocusChange(_onFocusChange);
|
window.feedBackSplitscreen.onFocusChange(_onFocusChange);
|
||||||
_focusSubscribed = true;
|
_focusSubscribed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Async-ready contract (slopsmith#36 readyPromise). Resolves
|
// Async-ready contract (feedBack#36 readyPromise). Resolves
|
||||||
// when Three.js loaded + scene initialised (_isReady = true).
|
// when Three.js loaded + scene initialised (_isReady = true).
|
||||||
// Rejects on any async failure so highway.js can revert.
|
// Rejects on any async failure so highway.js can revert.
|
||||||
let _resolveReady, _rejectReady;
|
let _resolveReady, _rejectReady;
|
||||||
@@ -13127,6 +13376,17 @@
|
|||||||
} else if (box.w > 0 && box.h > 0 &&
|
} else if (box.w > 0 && box.h > 0 &&
|
||||||
(Math.abs(box.w - _appliedW) > 1 || Math.abs(box.h - _appliedH) > 1)) {
|
(Math.abs(box.w - _appliedW) > 1 || Math.abs(box.h - _appliedH) > 1)) {
|
||||||
applySize(box.w, box.h);
|
applySize(box.w, box.h);
|
||||||
|
} else if (!_wrapPinned && box.w > 0 && box.h > 0 &&
|
||||||
|
highwayCanvas.offsetWidth > 0 && highwayCanvas.offsetHeight > 0) {
|
||||||
|
// 3. The overlay pin couldn't be applied at init because
|
||||||
|
// #highway had no layout yet (offsetWidth/Height === 0),
|
||||||
|
// so applySize() only set the wrap height. The canvas has
|
||||||
|
// now laid out but to the same logical size, so neither
|
||||||
|
// drift branch above fires — re-run applySize to pin the
|
||||||
|
// wrap to the canvas box now that its offsets are real.
|
||||||
|
// Otherwise the overlay stays at top:0;left:0;right:0 and
|
||||||
|
// a strip of #highway is exposed on first load / split.
|
||||||
|
applySize(box.w, box.h);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
update(bundle);
|
update(bundle);
|
||||||
@@ -13304,17 +13564,18 @@
|
|||||||
_destroyed = true; _isReady = false; _diagChord = null; _diagPrev = null; _diagLastKey = null; _diagRenderCache.clear();
|
_destroyed = true; _isReady = false; _diagChord = null; _diagPrev = null; _diagLastKey = null; _diagRenderCache.clear();
|
||||||
_lastHwW = 0; _lastHwH = 0;
|
_lastHwW = 0; _lastHwH = 0;
|
||||||
_appliedW = 0; _appliedH = 0;
|
_appliedW = 0; _appliedH = 0;
|
||||||
|
_wrapPinned = false;
|
||||||
_unsubscribeFocus(); teardown();
|
_unsubscribeFocus(); teardown();
|
||||||
highwayCanvas = null;
|
highwayCanvas = null;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
window.slopsmithViz_highway_3d = createFactory;
|
window.feedBackViz_highway_3d = createFactory;
|
||||||
// Per-panel control descriptors (splitscreen). The palette selector was
|
// Per-panel control descriptors (splitscreen). The palette selector was
|
||||||
// removed — per-string colors are set via the core "Highway String Colors"
|
// removed — per-string colors are set via the core "Highway String Colors"
|
||||||
// UI, which drives both highways by named string.
|
// UI, which drives both highways by named string.
|
||||||
window.slopsmithViz_highway_3d.panelControls = [
|
window.feedBackViz_highway_3d.panelControls = [
|
||||||
{
|
{
|
||||||
key: 'cameraSmoothing',
|
key: 'cameraSmoothing',
|
||||||
label: 'Camera smoothing (X-pan)',
|
label: 'Camera smoothing (X-pan)',
|
||||||
@@ -13357,8 +13618,8 @@
|
|||||||
// are matched by the piano plugin instead.
|
// are matched by the piano plugin instead.
|
||||||
// _canRun3D() in app.js still gates Auto from
|
// _canRun3D() in app.js still gates Auto from
|
||||||
// picking us on machines without WebGL2.
|
// picking us on machines without WebGL2.
|
||||||
window.slopsmithViz_highway_3d.contextType = 'webgl2';
|
window.feedBackViz_highway_3d.contextType = 'webgl2';
|
||||||
window.slopsmithViz_highway_3d.__test = {
|
window.feedBackViz_highway_3d.__test = {
|
||||||
getAnalyserForBridgeTest: _bgGetAnalyser,
|
getAnalyserForBridgeTest: _bgGetAnalyser,
|
||||||
readBandsForBridgeTest: _bgReadBands,
|
readBandsForBridgeTest: _bgReadBands,
|
||||||
resetAnalyserBridgeForTest() { _bgBridgeKeys.clear(); _bgAudio = null; _bgAudioCore = null; _bgAudioFailedAt = 0; },
|
resetAnalyserBridgeForTest() { _bgBridgeKeys.clear(); _bgAudio = null; _bgAudioCore = null; _bgAudioFailedAt = 0; },
|
||||||
@@ -13369,12 +13630,12 @@
|
|||||||
// sloppaks). Word boundaries (\b) keep us from accidentally matching
|
// sloppaks). Word boundaries (\b) keep us from accidentally matching
|
||||||
// arrangements that merely contain these as substrings (e.g. a
|
// arrangements that merely contain these as substrings (e.g. a
|
||||||
// "BasslineKeys" arrangement would otherwise match `bass`).
|
// "BasslineKeys" arrangement would otherwise match `bass`).
|
||||||
window.slopsmithViz_highway_3d.matchesArrangement = function (songInfo) {
|
window.feedBackViz_highway_3d.matchesArrangement = function (songInfo) {
|
||||||
const arr = (songInfo && songInfo.arrangement) || '';
|
const arr = (songInfo && songInfo.arrangement) || '';
|
||||||
return /\b(?:lead|rhythm|bass|combo|guitar)\b/i.test(arr);
|
return /\b(?:lead|rhythm|bass|combo|guitar)\b/i.test(arr);
|
||||||
};
|
};
|
||||||
|
|
||||||
// No imperative register() call needed: slopsmith#272 introduced the
|
// No imperative register() call needed: feedBack#272 introduced the
|
||||||
// consolidated tour menu, which discovers this plugin's tour automatically
|
// consolidated tour menu, which discovers this plugin's tour automatically
|
||||||
// via /api/plugins (has_tour:true from plugin.json's tour field) and
|
// via /api/plugins (has_tour:true from plugin.json's tour field) and
|
||||||
// gates relevance on whether highway_3d is the active viz. A register()
|
// gates relevance on whether highway_3d is the active viz. A register()
|
||||||
|
|||||||
@@ -52,7 +52,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>
|
||||||
@@ -147,7 +147,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">
|
||||||
@@ -725,7 +725,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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"id": "input_setup",
|
||||||
|
"name": "Input Setup",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"bundled": true,
|
||||||
|
"private": false,
|
||||||
|
"standards": ["capability-pipelines.v1", "plugin-runtime-idempotent.v1"],
|
||||||
|
"script": "screen.js",
|
||||||
|
"settings": { "html": "settings.html" },
|
||||||
|
"description": "Per-instrument input-device selection and calibration, used during onboarding and re-launchable from Settings.",
|
||||||
|
"category": "practice",
|
||||||
|
"capabilities": {
|
||||||
|
"input-calibration": {
|
||||||
|
"roles": ["owner"],
|
||||||
|
"commands": ["run", "status", "inspect"],
|
||||||
|
"events": ["calibration-started", "calibration-done", "calibration-skipped"],
|
||||||
|
"kind": "command",
|
||||||
|
"mode": "active",
|
||||||
|
"compatibility": "none",
|
||||||
|
"ownership": "exclusive-owner",
|
||||||
|
"safety": "safe",
|
||||||
|
"description": "Owns the per-instrument input-setup wizard workflow; onboarding and Settings dispatch through the runtime.",
|
||||||
|
"version": 1
|
||||||
|
},
|
||||||
|
"audio-input": {
|
||||||
|
"roles": ["requester"],
|
||||||
|
"requests": ["list-sources", "select-source", "open-source"],
|
||||||
|
"mode": "active",
|
||||||
|
"compatibility": "degrade-noop",
|
||||||
|
"ownership": "requester-only",
|
||||||
|
"safety": "sensitive",
|
||||||
|
"description": "Picks the guitar/bass audio input device through the core audio-input domain.",
|
||||||
|
"version": 1
|
||||||
|
},
|
||||||
|
"midi-input": {
|
||||||
|
"roles": ["requester"],
|
||||||
|
"requests": ["discover", "list-sources", "select-source", "open-source", "close-source"],
|
||||||
|
"mode": "active",
|
||||||
|
"compatibility": "degrade-noop",
|
||||||
|
"ownership": "requester-only",
|
||||||
|
"safety": "sensitive",
|
||||||
|
"description": "Picks the keys/drums MIDI device through the core midi-input domain (Web-MIDI provider ships built-in with the domain).",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
/*
|
||||||
|
* input_setup — per-instrument input-device selection & calibration.
|
||||||
|
*
|
||||||
|
* Bundled core plugin (constitution P-II vanilla JS). It:
|
||||||
|
* 1. supplies a Web-MIDI source provider to the core `midi-input` domain;
|
||||||
|
* 2. owns the `input-calibration` capability domain (run / status / inspect);
|
||||||
|
* 3. renders the onboarding input-setup wizard (one pass per instrument):
|
||||||
|
* - guitar/bass → pick via `audio-input`, then launch note_detect's
|
||||||
|
* Calibration Wizard (note-detection is a deferred surface — JS API);
|
||||||
|
* - keys/drums → pick via `midi-input`, then a live "play a note /
|
||||||
|
* hit a pad" confirmation.
|
||||||
|
*
|
||||||
|
* Idempotent (plugin-runtime-idempotent.v1): re-hydration is a no-op.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
window.feedBack = window.feedBack || {};
|
||||||
|
if (window.feedBackInputSetup && window.feedBackInputSetup.version === 1) return;
|
||||||
|
|
||||||
|
const capabilities = window.feedBack.capabilities;
|
||||||
|
const DONE_KEY = (inst) => `input_setup.done.${inst}`;
|
||||||
|
const INSTRUMENTS = {
|
||||||
|
guitar: { label: 'Guitar', mode: 'audio' },
|
||||||
|
bass: { label: 'Bass', mode: 'audio' },
|
||||||
|
keys: { label: 'Keys / Piano', mode: 'midi' },
|
||||||
|
piano: { label: 'Keys / Piano', mode: 'midi' },
|
||||||
|
drums: { label: 'Drums', mode: 'midi' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
|
||||||
|
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||||
|
|
||||||
|
function _isDone(inst) { try { return window.localStorage.getItem(DONE_KEY(inst)) === '1'; } catch (_) { return false; } }
|
||||||
|
function _markDone(inst, v) { try { if (v) window.localStorage.setItem(DONE_KEY(inst), '1'); else window.localStorage.removeItem(DONE_KEY(inst)); } catch (_) { /* private mode */ } }
|
||||||
|
|
||||||
|
// The Web-MIDI source provider now ships built-in with the core midi-input
|
||||||
|
// domain (static/capabilities/midi-input.js), so input_setup is a pure
|
||||||
|
// consumer — it just discovers/selects/opens through `window.feedBack.midiInput`.
|
||||||
|
|
||||||
|
// ── audio-input helper (guitar/bass device context) ─────────────────────
|
||||||
|
async function _audioSources() {
|
||||||
|
if (!capabilities || typeof capabilities.command !== 'function') return { sources: [], selected: null };
|
||||||
|
try {
|
||||||
|
const r = await capabilities.command('audio-input', 'list-sources', { requester: 'input_setup' });
|
||||||
|
const p = (r && r.payload) || {};
|
||||||
|
let sources = Array.isArray(p.sources) ? p.sources : [];
|
||||||
|
// Exclude MIDI devices some plugins export into audio-input
|
||||||
|
// (e.g. keys-highway-3d's pseudonymized 'midi-input-N'): they aren't
|
||||||
|
// audio inputs and the cryptic labels confuse this guitar/bass picker.
|
||||||
|
sources = sources.filter((s) => s
|
||||||
|
&& !/midi/i.test(String(s.providerId || ''))
|
||||||
|
&& !/^midi-input/i.test(String(s.label || '')));
|
||||||
|
// De-dupe by display label — the desktop engine enumerates the same
|
||||||
|
// device under several driver types, so the same name can repeat.
|
||||||
|
const seen = new Set();
|
||||||
|
sources = sources.filter((s) => {
|
||||||
|
const key = String(s.label || '').toLowerCase();
|
||||||
|
if (seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
const selected = sources.find((s) => s && s.selected) || null;
|
||||||
|
return { sources, selected };
|
||||||
|
} catch (_) { return { sources: [], selected: null }; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Wizard UI ────────────────────────────────────────────────────────────
|
||||||
|
// Renders sequential per-instrument panels into `host`. Resolves the
|
||||||
|
// returned promise to { completed:[...], skipped:[...] } when finished.
|
||||||
|
function _runWizard(opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
const instruments = (Array.isArray(opts.instruments) ? opts.instruments : [])
|
||||||
|
.map((i) => String(i).toLowerCase()).filter((i) => INSTRUMENTS[i]);
|
||||||
|
// De-dupe keys/piano (same MIDI flow under one label).
|
||||||
|
const seen = new Set();
|
||||||
|
const queue = instruments.filter((i) => { const k = INSTRUMENTS[i].label; if (seen.has(k)) return false; seen.add(k); return true; });
|
||||||
|
|
||||||
|
const completed = [];
|
||||||
|
const skipped = [];
|
||||||
|
let idx = 0;
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const host = opts.host;
|
||||||
|
if (!host) { resolve({ completed, skipped }); return; }
|
||||||
|
|
||||||
|
function finish() {
|
||||||
|
_emitOwner('calibration-done', { completed: completed.slice(), skipped: skipped.slice() });
|
||||||
|
if (typeof opts.onComplete === 'function') { try { opts.onComplete({ completed, skipped }); } catch (_) {} }
|
||||||
|
resolve({ completed, skipped });
|
||||||
|
}
|
||||||
|
// Per-panel teardown run on EVERY exit (Continue or the generic "Skip
|
||||||
|
// for now"), so an opened MIDI session/listener never leaks past the
|
||||||
|
// panel that opened it.
|
||||||
|
let _activeCleanup = null;
|
||||||
|
function next() {
|
||||||
|
if (idx >= queue.length) { finish(); return; }
|
||||||
|
renderPanel(queue[idx]);
|
||||||
|
}
|
||||||
|
function advance(inst, didComplete) {
|
||||||
|
if (_activeCleanup) { try { _activeCleanup(); } catch (_) {} _activeCleanup = null; }
|
||||||
|
if (didComplete) { _markDone(inst, true); if (!completed.includes(inst)) completed.push(inst); }
|
||||||
|
else { if (!skipped.includes(inst)) skipped.push(inst); }
|
||||||
|
idx += 1;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
function shell(inst, bodyHtml, footHtml) {
|
||||||
|
const meta = INSTRUMENTS[inst];
|
||||||
|
host.innerHTML =
|
||||||
|
'<div class="space-y-4">' +
|
||||||
|
'<div><div class="text-xs uppercase tracking-wider text-fb-textDim">Input setup — step ' + (idx + 1) + ' of ' + queue.length + '</div>' +
|
||||||
|
'<h3 class="text-lg font-bold text-fb-text mt-0.5">Set up your ' + esc(meta.label) + '</h3></div>' +
|
||||||
|
'<div data-is-body>' + bodyHtml + '</div>' +
|
||||||
|
'<div class="flex justify-between items-center pt-1">' +
|
||||||
|
'<button type="button" data-is-skip class="text-sm text-fb-textDim hover:text-fb-text">Skip for now</button>' +
|
||||||
|
'<div data-is-foot>' + (footHtml || '') + '</div></div></div>';
|
||||||
|
host.querySelector('[data-is-skip]').addEventListener('click', () => advance(inst, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── per-instrument panels ───────────────────────────────────────
|
||||||
|
async function renderPanel(inst) {
|
||||||
|
const meta = INSTRUMENTS[inst];
|
||||||
|
if (meta.mode === 'audio') return renderAudioPanel(inst);
|
||||||
|
return renderMidiPanel(inst);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guitar/bass: show the audio source (audio-input) and launch the
|
||||||
|
// note_detect Calibration Wizard for the deep work.
|
||||||
|
async function renderAudioPanel(inst) {
|
||||||
|
const { sources, selected } = await _audioSources();
|
||||||
|
const opts2 = sources.map((s) =>
|
||||||
|
'<option value="' + esc(s.logicalSourceKey || s.sourceId || '') + '"' + (s.selected ? ' selected' : '') + '>' + esc(s.label || 'Input') + '</option>').join('');
|
||||||
|
const hasDetector = !!(window.noteDetect && typeof window.noteDetect.launchCalibration === 'function');
|
||||||
|
const body =
|
||||||
|
'<p class="text-sm text-fb-textDim">Pick your audio input, then run the calibration to set levels, channel and latency.</p>' +
|
||||||
|
(sources.length
|
||||||
|
? '<label class="block text-xs uppercase tracking-wider text-fb-textDim mt-3 mb-1">Audio input</label>' +
|
||||||
|
'<select data-is-audio class="w-full bg-gray-800/50 border border-gray-700 rounded-md px-2 py-1.5 text-sm text-fb-text outline-none">' + opts2 + '</select>'
|
||||||
|
: '<p class="text-sm text-fb-accent mt-2">No audio input detected yet — plug in your interface, or skip and set this up later.</p>') +
|
||||||
|
(hasDetector ? '' : '<p class="text-xs text-fb-textDim mt-3">The note detector isn’t loaded here — you can calibrate later from the player.</p>');
|
||||||
|
const foot =
|
||||||
|
'<button type="button" data-is-cal class="bg-fb-primary hover:bg-fb-primaryHi text-white px-5 py-2 rounded-md font-medium">' +
|
||||||
|
(hasDetector ? 'Calibrate' : 'Continue') + '</button>';
|
||||||
|
shell(inst, body, foot);
|
||||||
|
|
||||||
|
const sel = host.querySelector('[data-is-audio]');
|
||||||
|
const commitAudio = (key) => {
|
||||||
|
if (!capabilities || !key) return;
|
||||||
|
capabilities.command('audio-input', 'select-source', { requester: 'input_setup', payload: { logicalSourceKey: key } }).catch(() => {});
|
||||||
|
};
|
||||||
|
if (sel) {
|
||||||
|
sel.addEventListener('change', () => commitAudio(sel.value));
|
||||||
|
// The <select> shows its first option by default, but no `change`
|
||||||
|
// fires for that implicit pick — so on a first run with nothing yet
|
||||||
|
// selected, audio-input would calibrate against the wrong/no source.
|
||||||
|
// Commit the shown option up-front so the displayed device is the
|
||||||
|
// one calibrated (idempotent if it was already selected).
|
||||||
|
if (!selected) commitAudio(sel.value);
|
||||||
|
}
|
||||||
|
// Tell the tuner tables / note_detect which instrument this is.
|
||||||
|
try { fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ instrument: inst }) }); } catch (_) {}
|
||||||
|
|
||||||
|
host.querySelector('[data-is-cal]').addEventListener('click', () => {
|
||||||
|
if (hasDetector) {
|
||||||
|
window.noteDetect.launchCalibration({
|
||||||
|
instrument: inst,
|
||||||
|
onDone: () => advance(inst, true),
|
||||||
|
onCancel: () => { /* stay on this panel; user can skip or retry */ },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
advance(inst, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keys/drums: pick a MIDI device via midi-input and confirm a live hit.
|
||||||
|
async function renderMidiPanel(inst) {
|
||||||
|
const mi = window.feedBack.midiInput;
|
||||||
|
// Availability is the midi-input DOMAIN being present, not the
|
||||||
|
// Web-MIDI browser API — the domain coordinates providers (the
|
||||||
|
// built-in Web-MIDI one, plus any native/desktop adapter), so
|
||||||
|
// gating on navigator.requestMIDIAccess would hide a usable
|
||||||
|
// non-Web-MIDI provider before discover() is ever called.
|
||||||
|
const midiAvailable = !!(mi && mi.version === 1);
|
||||||
|
if (!midiAvailable) {
|
||||||
|
shell(inst,
|
||||||
|
'<p class="text-sm text-fb-accent">MIDI input isn’t available here. Connect a MIDI keyboard/e-kit in a supported environment, or skip for now.</p>',
|
||||||
|
'<button type="button" data-is-skip2 class="bg-fb-primary hover:bg-fb-primaryHi text-white px-5 py-2 rounded-md font-medium">Continue</button>');
|
||||||
|
host.querySelector('[data-is-skip2]').addEventListener('click', () => advance(inst, false));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const verb = inst === 'drums' ? 'hit a pad' : 'play a note';
|
||||||
|
shell(inst,
|
||||||
|
'<p class="text-sm text-fb-textDim">Connect your MIDI device, pick it below, then ' + verb + ' to confirm it’s working.</p>' +
|
||||||
|
'<div class="mt-3 flex items-center gap-2">' +
|
||||||
|
'<button type="button" data-is-scan class="text-sm text-fb-primary hover:text-fb-primaryHi">Scan for MIDI devices</button></div>' +
|
||||||
|
'<div data-is-midi-wrap class="hidden mt-2">' +
|
||||||
|
'<select data-is-midi class="w-full bg-gray-800/50 border border-gray-700 rounded-md px-2 py-1.5 text-sm text-fb-text outline-none"></select>' +
|
||||||
|
'<p data-is-test class="text-sm text-fb-textDim mt-2">Waiting for input…</p></div>',
|
||||||
|
'<button type="button" data-is-next disabled class="bg-fb-primary disabled:opacity-40 text-white px-5 py-2 rounded-md font-medium">Continue</button>');
|
||||||
|
|
||||||
|
const wrap = host.querySelector('[data-is-midi-wrap]');
|
||||||
|
const select = host.querySelector('[data-is-midi]');
|
||||||
|
const testEl = host.querySelector('[data-is-test]');
|
||||||
|
const nextBtn = host.querySelector('[data-is-next]');
|
||||||
|
let activeKey = null;
|
||||||
|
let listener = null;
|
||||||
|
let activeHandle = null;
|
||||||
|
let openSeq = 0;
|
||||||
|
|
||||||
|
async function openSelected() {
|
||||||
|
// Tear down the previous device + RESET the confirmation
|
||||||
|
// state, so a hit on a prior device can't leave Continue
|
||||||
|
// enabled for a newly-selected device that hasn't been heard.
|
||||||
|
const myGen = ++openSeq;
|
||||||
|
if (activeHandle && listener) { try { activeHandle.removeListener(listener); } catch (_) {} }
|
||||||
|
if (activeKey) { try { mi.close({ requester: 'input_setup', logicalSourceKey: activeKey }); } catch (_) {} }
|
||||||
|
activeHandle = null;
|
||||||
|
listener = null;
|
||||||
|
nextBtn.disabled = true;
|
||||||
|
_markDone(inst, false);
|
||||||
|
// Capture the requested key in a local: a newer openSelected()
|
||||||
|
// overwrites the shared `activeKey`, so comparing it after the
|
||||||
|
// awaits would let a stale open bind the wrong device.
|
||||||
|
const requestedKey = select.value;
|
||||||
|
activeKey = requestedKey;
|
||||||
|
if (!requestedKey) { testEl.textContent = ''; return; }
|
||||||
|
testEl.textContent = 'Waiting for input…';
|
||||||
|
await mi.select(requestedKey);
|
||||||
|
const res = await mi.open({ requester: 'input_setup', logicalSourceKey: requestedKey });
|
||||||
|
// Discard a stale open if a newer openSelected() superseded us.
|
||||||
|
if (myGen !== openSeq) { try { if (res) mi.close({ requester: 'input_setup', logicalSourceKey: requestedKey }); } catch (_) {} return; }
|
||||||
|
if (!res || !res.handle) { testEl.textContent = 'Could not open this device.'; activeKey = null; return; }
|
||||||
|
activeHandle = res.handle;
|
||||||
|
listener = (data) => {
|
||||||
|
// 0x90 = note-on (any channel); velocity > 0.
|
||||||
|
if (data && (data[0] & 0xf0) === 0x90 && data[2] > 0) {
|
||||||
|
testEl.innerHTML = '<span class="text-fb-primary font-semibold">✓ Got it</span> — device is working.';
|
||||||
|
nextBtn.disabled = false;
|
||||||
|
_markDone(inst, true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
activeHandle.addListener(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
host.querySelector('[data-is-scan]').addEventListener('click', async () => {
|
||||||
|
await mi.discover();
|
||||||
|
// Show every source the midi-input domain surfaces — not just
|
||||||
|
// the built-in Web-MIDI provider — so a native/desktop MIDI
|
||||||
|
// adapter registered with the domain is selectable too.
|
||||||
|
const sources = window.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; }
|
||||||
|
wrap.classList.remove('hidden');
|
||||||
|
select.disabled = false;
|
||||||
|
select.innerHTML = sources.map((s) => '<option value="' + esc(s.logicalSourceKey) + '"' + (s.selected ? ' selected' : '') + '>' + esc(s.label) + '</option>').join('');
|
||||||
|
openSelected();
|
||||||
|
});
|
||||||
|
select.addEventListener('change', openSelected);
|
||||||
|
// Close the open session/listener on ANY exit (Continue or the
|
||||||
|
// generic Skip), so a scanned+selected device doesn't keep its
|
||||||
|
// Web-MIDI input live after the panel advances.
|
||||||
|
_activeCleanup = () => {
|
||||||
|
if (activeHandle && listener) { try { activeHandle.removeListener(listener); } catch (_) {} }
|
||||||
|
if (activeKey) { try { mi.close({ requester: 'input_setup', logicalSourceKey: activeKey }); } catch (_) {} }
|
||||||
|
activeHandle = null; listener = null; activeKey = null;
|
||||||
|
};
|
||||||
|
nextBtn.addEventListener('click', () => advance(inst, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
_emitOwner('calibration-started', { instruments: queue.slice() });
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _emitOwner(event, detail) {
|
||||||
|
try { capabilities && capabilities.emitEvent && capabilities.emitEvent('input-calibration', event, detail || {}); } catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── input-calibration owner domain ───────────────────────────────────────
|
||||||
|
function _statusPayload(instruments) {
|
||||||
|
const list = (Array.isArray(instruments) && instruments.length ? instruments : Object.keys(INSTRUMENTS))
|
||||||
|
.map((i) => String(i).toLowerCase());
|
||||||
|
const status = {};
|
||||||
|
list.forEach((i) => { if (INSTRUMENTS[i]) status[i] = _isDone(i) ? 'done' : 'needs-setup'; });
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (capabilities && typeof capabilities.registerOwner === 'function') {
|
||||||
|
capabilities.registerOwner('input-calibration', {
|
||||||
|
pluginId: 'input_setup',
|
||||||
|
kind: 'command',
|
||||||
|
safety: 'safe',
|
||||||
|
commands: ['run', 'status', 'inspect'],
|
||||||
|
events: ['calibration-started', 'calibration-done', 'calibration-skipped'],
|
||||||
|
description: 'Per-instrument input-setup wizard workflow (audio via audio-input + note_detect; MIDI via midi-input).',
|
||||||
|
handlers: {
|
||||||
|
inspect: () => ({ outcome: 'handled', payload: { available: true, status: _statusPayload() } }),
|
||||||
|
status: (ctx) => ({ outcome: 'handled', payload: { status: _statusPayload((ctx.payload || {}).instruments) } }),
|
||||||
|
// `run` is fire-and-launch: an interactive wizard far exceeds the
|
||||||
|
// ~250ms handler timeout, so it starts the overlay and returns
|
||||||
|
// immediately. Completion is signaled by the `calibration-done`
|
||||||
|
// event (mirrors audio-monitoring `start`). A second `run` while
|
||||||
|
// one is open is a no-op (single overlay).
|
||||||
|
run: (ctx) => {
|
||||||
|
const instruments = ((ctx.payload || {}).instruments) || [];
|
||||||
|
if (!document.getElementById('input-setup-overlay')) launch(instruments);
|
||||||
|
return { outcome: 'handled', payload: { started: true, instruments } };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── public surface (onboarding + Settings re-entry) ──────────────────────
|
||||||
|
function mount(container, options) {
|
||||||
|
options = options || {};
|
||||||
|
return _runWizard({ host: container, instruments: options.instruments || [], onComplete: options.onComplete, onSkip: options.onSkip });
|
||||||
|
}
|
||||||
|
|
||||||
|
function launch(instruments) {
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.id = 'input-setup-overlay';
|
||||||
|
overlay.className = 'fixed inset-0 z-[210] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4';
|
||||||
|
overlay.innerHTML = '<div class="bg-fb-card rounded-xl border border-fb-border/50 w-full max-w-lg p-6" data-is-host></div>';
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
const host = overlay.querySelector('[data-is-host]');
|
||||||
|
return _runWizard({ host, instruments: instruments || [] }).then((r) => { overlay.remove(); return r; });
|
||||||
|
}
|
||||||
|
|
||||||
|
window.feedBackInputSetup = {
|
||||||
|
version: 1,
|
||||||
|
mount,
|
||||||
|
launch,
|
||||||
|
status: (instruments) => _statusPayload(instruments),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Settings-panel re-entry (settings.html "Set up input devices" button).
|
||||||
|
// Re-runs the wizard for the player's selected instrument paths, falling
|
||||||
|
// back to all instruments when progression isn't available.
|
||||||
|
window._inputSetupRelaunch = async function () {
|
||||||
|
let instruments = [];
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/progression');
|
||||||
|
if (r.ok) {
|
||||||
|
const d = await r.json();
|
||||||
|
const paths = Array.isArray(d.paths) ? d.paths : [];
|
||||||
|
instruments = paths.map((p) => (typeof p === 'string' ? p : (p && p.id))).filter(Boolean);
|
||||||
|
}
|
||||||
|
} catch (_) { /* offline — fall back below */ }
|
||||||
|
if (!instruments.length) instruments = ['guitar', 'bass', 'keys', 'drums'];
|
||||||
|
launch(instruments);
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<div class="space-y-4 py-2">
|
||||||
|
<div class="bg-dark-900/50 p-3 rounded-xl border border-gray-800/50">
|
||||||
|
<h3 class="text-sm font-medium text-gray-200">Input devices & calibration</h3>
|
||||||
|
<p class="text-[11px] text-gray-500 mt-1">
|
||||||
|
Re-run the input setup wizard for your instrument paths — pick your audio
|
||||||
|
input or MIDI device and confirm it's working. Guitar/bass also opens the
|
||||||
|
calibration wizard.
|
||||||
|
</p>
|
||||||
|
<button type="button"
|
||||||
|
onclick="window._inputSetupRelaunch && window._inputSetupRelaunch()"
|
||||||
|
class="mt-3 px-4 py-2 bg-accent hover:bg-accent-light text-white text-sm font-medium rounded-lg">
|
||||||
|
Set up input devices
|
||||||
|
</button>
|
||||||
|
<p id="input-setup-settings-status" class="text-[11px] text-gray-500 mt-2"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
+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).
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ _state = {
|
|||||||
"db_path": None,
|
"db_path": None,
|
||||||
"profile_path": None,
|
"profile_path": None,
|
||||||
"plugins_dir_resolver": None,
|
"plugins_dir_resolver": None,
|
||||||
"log": logging.getLogger("slopsmith.plugin.minigames"),
|
"log": logging.getLogger("feedBack.plugin.minigames"),
|
||||||
# fee[dB]ack v0.3.0 unified XP: when running inside core these point at the
|
# fee[dB]ack v0.3.0 unified XP: when running inside core these point at the
|
||||||
# single core XP store (server.py plugin_context). XP then flows to ONE
|
# single core XP store (server.py plugin_context). XP then flows to ONE
|
||||||
# store the profile badge reads. Absent when the plugin runs standalone,
|
# store the profile badge reads. Absent when the plugin runs standalone,
|
||||||
@@ -282,7 +282,7 @@ def _list_minigame_plugins(force_refresh: bool = False) -> list:
|
|||||||
"version": data.get("version"),
|
"version": data.get("version"),
|
||||||
}
|
}
|
||||||
# Deduplicate by plugin_id: first entry wins (resolver returns
|
# Deduplicate by plugin_id: first entry wins (resolver returns
|
||||||
# SLOPSMITH_PLUGINS_DIR before the bundled siblings, so an explicit
|
# FEEDBACK_PLUGINS_DIR before the bundled siblings, so an explicit
|
||||||
# override takes precedence over the in-tree snapshot — same winner
|
# override takes precedence over the in-tree snapshot — same winner
|
||||||
# selection as the core plugin loader).
|
# selection as the core plugin loader).
|
||||||
if plugin_id not in seen_ids:
|
if plugin_id not in seen_ids:
|
||||||
@@ -328,7 +328,7 @@ def setup(app, context):
|
|||||||
|
|
||||||
# The plugin loader doesn't currently expose a list-other-plugins helper,
|
# The plugin loader doesn't currently expose a list-other-plugins helper,
|
||||||
# so derive the plugin directories from environment + conventions:
|
# so derive the plugin directories from environment + conventions:
|
||||||
# 1. SLOPSMITH_PLUGINS_DIR env var (explicit override)
|
# 1. FEEDBACK_PLUGINS_DIR env var (explicit override)
|
||||||
# 2. The directory that contains this plugin (plugin_self.parent) —
|
# 2. The directory that contains this plugin (plugin_self.parent) —
|
||||||
# covers the common case where all plugins live in one flat dir.
|
# covers the common case where all plugins live in one flat dir.
|
||||||
# 3. plugin_self.parent.parent / "plugins" — covers the layout where
|
# 3. plugin_self.parent.parent / "plugins" — covers the layout where
|
||||||
@@ -336,7 +336,7 @@ def setup(app, context):
|
|||||||
# Duplicates are removed via a seen-set keyed on resolved paths.
|
# Duplicates are removed via a seen-set keyed on resolved paths.
|
||||||
def _resolve_plugin_dirs():
|
def _resolve_plugin_dirs():
|
||||||
roots = []
|
roots = []
|
||||||
env_dir = os.environ.get("SLOPSMITH_PLUGINS_DIR")
|
env_dir = os.environ.get("FEEDBACK_PLUGINS_DIR") or os.environ.get("SLOPSMITH_PLUGINS_DIR")
|
||||||
if env_dir:
|
if env_dir:
|
||||||
roots.append(Path(env_dir))
|
roots.append(Path(env_dir))
|
||||||
# Built-in plugins/ next to server.py (one level above this file's
|
# Built-in plugins/ next to server.py (one level above this file's
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- In-game container — only visible while a minigame is running.
|
<!-- In-game container — only visible while a minigame is running.
|
||||||
z-[60] sits above the Slopsmith navbar (z-50) so the game owns the
|
z-[60] sits above the FeedBack navbar (z-50) so the game owns the
|
||||||
viewport during a run; the stage's own Quit button is the exit. -->
|
viewport during a run; the stage's own Quit button is the exit. -->
|
||||||
<div id="mg-stage" class="hidden fixed inset-0 z-[60] bg-fb-bg/95 flex flex-col"
|
<div id="mg-stage" class="hidden fixed inset-0 z-[60] bg-fb-bg/95 flex flex-col"
|
||||||
role="region" aria-labelledby="mg-stage-title">
|
role="region" aria-labelledby="mg-stage-title">
|
||||||
|
|||||||
+23
-23
@@ -1,20 +1,20 @@
|
|||||||
// slopsmith-plugin-minigames — SDK + hub controller.
|
// feedBack-plugin-minigames — SDK + hub controller.
|
||||||
//
|
//
|
||||||
// This file does two things:
|
// This file does two things:
|
||||||
// 1) Publishes window.slopsmithMinigames — the SDK that individual
|
// 1) Publishes window.feedBackMinigames — the SDK that individual
|
||||||
// minigame plugins call (register, start/end, scoring, ui, persistence).
|
// minigame plugins call (register, start/end, scoring, ui, persistence).
|
||||||
// 2) Mounts a hub UI in screen.html that lists every registered minigame
|
// 2) Mounts a hub UI in screen.html that lists every registered minigame
|
||||||
// and the shared profile/leaderboards.
|
// and the shared profile/leaderboards.
|
||||||
//
|
//
|
||||||
// Plugin load order is alphabetical, so minigame plugins (e.g. flappy_bend)
|
// Plugin load order is alphabetical, so minigame plugins (e.g. flappy_bend)
|
||||||
// load BEFORE this script. They should register via a tiny shim that queues
|
// load BEFORE this script. They should register via a tiny shim that queues
|
||||||
// to `window.__slopsmithMinigamesPending` if the SDK isn't up yet — we drain
|
// to `window.__feedBackMinigamesPending` if the SDK isn't up yet — we drain
|
||||||
// the queue on init and also fire `slopsmith-minigames-ready` once ready.
|
// the queue on init and also fire `feedBack-minigames-ready` once ready.
|
||||||
|
|
||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
if (window.slopsmithMinigames && window.slopsmithMinigames.__alive) {
|
if (window.feedBackMinigames && window.feedBackMinigames.__alive) {
|
||||||
return; // hot-reload guard
|
return; // hot-reload guard
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,7 +128,7 @@
|
|||||||
const yinD = new Float32Array(yinHalfN);
|
const yinD = new Float32Array(yinHalfN);
|
||||||
const yinCmnd = new Float32Array(yinHalfN);
|
const yinCmnd = new Float32Array(yinHalfN);
|
||||||
let ringWrite = 0;
|
let ringWrite = 0;
|
||||||
// Desktop-engine bridge path. On slopsmith-desktop the native JUCE engine
|
// Desktop-engine bridge path. On feedBack-desktop the native JUCE engine
|
||||||
// owns the input device (often an exclusive ASIO device the browser's
|
// owns the input device (often an exclusive ASIO device the browser's
|
||||||
// getUserMedia can't see), so a renderer getUserMedia stream lands on the
|
// getUserMedia can't see), so a renderer getUserMedia stream lands on the
|
||||||
// wrong/silent Windows-default device. When the bridge is present we pull
|
// wrong/silent Windows-default device. When the bridge is present we pull
|
||||||
@@ -347,7 +347,7 @@
|
|||||||
// Prefer the desktop engine bridge (correct, user-configured input device);
|
// Prefer the desktop engine bridge (correct, user-configured input device);
|
||||||
// fall back to getUserMedia on the web build or a downlevel addon.
|
// fall back to getUserMedia on the web build or a downlevel addon.
|
||||||
function start() {
|
function start() {
|
||||||
const audio = window.slopsmithDesktop && window.slopsmithDesktop.audio;
|
const audio = window.feedBackDesktop && window.feedBackDesktop.audio;
|
||||||
if (audio && typeof audio.getRawAudioFrame === 'function') {
|
if (audio && typeof audio.getRawAudioFrame === 'function') {
|
||||||
startBridge(audio);
|
startBridge(audio);
|
||||||
} else {
|
} else {
|
||||||
@@ -360,7 +360,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── scoring.createDiscrete / createChord ──────────────────────────────
|
// ── scoring.createDiscrete / createChord ──────────────────────────────
|
||||||
// Both wrap window.createNoteDetector from slopsmith-plugin-notedetect.
|
// Both wrap window.createNoteDetector from feedBack-plugin-notedetect.
|
||||||
// For v1 they are thin event re-emitters — minigames using them must
|
// For v1 they are thin event re-emitters — minigames using them must
|
||||||
// run alongside a chart (createNoteDetector needs a highway). Chart-free
|
// run alongside a chart (createNoteDetector needs a highway). Chart-free
|
||||||
// discrete scoring is out of scope until the scoring-core extraction
|
// discrete scoring is out of scope until the scoring-core extraction
|
||||||
@@ -369,7 +369,7 @@
|
|||||||
const handlers = { hit: [], miss: [], end: [] };
|
const handlers = { hit: [], miss: [], end: [] };
|
||||||
const fn = window.createNoteDetector;
|
const fn = window.createNoteDetector;
|
||||||
if (typeof fn !== 'function') {
|
if (typeof fn !== 'function') {
|
||||||
console.warn('[minigames] window.createNoteDetector unavailable — install slopsmith-plugin-notedetect for discrete/chord scoring.');
|
console.warn('[minigames] window.createNoteDetector unavailable — install feedBack-plugin-notedetect for discrete/chord scoring.');
|
||||||
let _unavailStopped = false;
|
let _unavailStopped = false;
|
||||||
return {
|
return {
|
||||||
on(event, cb) { (handlers[event] || (handlers[event] = [])).push(cb); return this; },
|
on(event, cb) { (handlers[event] || (handlers[event] = [])).push(cb); return this; },
|
||||||
@@ -736,8 +736,8 @@
|
|||||||
container,
|
container,
|
||||||
modifiers,
|
modifiers,
|
||||||
// Convenience pass-through for the SDK so games don't have to
|
// Convenience pass-through for the SDK so games don't have to
|
||||||
// touch window.slopsmithMinigames inside their start handler.
|
// touch window.feedBackMinigames inside their start handler.
|
||||||
sdk: window.slopsmithMinigames,
|
sdk: window.feedBackMinigames,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[minigames] minigame start() threw:', e);
|
console.error('[minigames] minigame start() threw:', e);
|
||||||
@@ -895,7 +895,7 @@
|
|||||||
tile.setAttribute('aria-label', title);
|
tile.setAttribute('aria-label', title);
|
||||||
const stats = perGame[spec.id] || { runs: 0, best_score: 0 };
|
const stats = perGame[spec.id] || { runs: 0, best_score: 0 };
|
||||||
// Thumbnails are served via the minigame plugin's own asset route
|
// Thumbnails are served via the minigame plugin's own asset route
|
||||||
// (the Slopsmith plugin loader only serves manifest-declared files,
|
// (the FeedBack plugin loader only serves manifest-declared files,
|
||||||
// so each minigame that ships extra assets must expose /assets/).
|
// so each minigame that ships extra assets must expose /assets/).
|
||||||
// Thumbnails are served by the minigame plugin's own /assets/ route;
|
// Thumbnails are served by the minigame plugin's own /assets/ route;
|
||||||
// not every plugin ships one, so fall back to the placeholder on 404.
|
// not every plugin ships one, so fall back to the placeholder on 404.
|
||||||
@@ -957,15 +957,15 @@
|
|||||||
listRegistered: () => Array.from(registered.values()),
|
listRegistered: () => Array.from(registered.values()),
|
||||||
};
|
};
|
||||||
|
|
||||||
window.slopsmithMinigames = sdk;
|
window.feedBackMinigames = sdk;
|
||||||
// Drain queue of plugins that loaded before us.
|
// Drain queue of plugins that loaded before us.
|
||||||
(window.__slopsmithMinigamesPending || []).forEach(register);
|
(window.__feedBackMinigamesPending || []).forEach(register);
|
||||||
window.__slopsmithMinigamesPending = null;
|
window.__feedBackMinigamesPending = null;
|
||||||
window.dispatchEvent(new CustomEvent('slopsmith-minigames-ready'));
|
window.dispatchEvent(new CustomEvent('feedBack-minigames-ready'));
|
||||||
|
|
||||||
// ── Wire hub render to screen lifecycle ───────────────────────────────
|
// ── Wire hub render to screen lifecycle ───────────────────────────────
|
||||||
// Slopsmith mounts plugin screens with id "plugin-<plugin_id>" and
|
// FeedBack mounts plugin screens with id "plugin-<plugin_id>" and
|
||||||
// routes there via showScreen() / window.slopsmith.navigate().
|
// routes there via showScreen() / window.feedBack.navigate().
|
||||||
const SCREEN_ID = `plugin-${PLUGIN_ID}`;
|
const SCREEN_ID = `plugin-${PLUGIN_ID}`;
|
||||||
// Non-scoring teardown: called when navigation happens mid-run so that
|
// Non-scoring teardown: called when navigation happens mid-run so that
|
||||||
// microphone streams, timers, and stage DOM are cleaned up without submitting
|
// microphone streams, timers, and stage DOM are cleaned up without submitting
|
||||||
@@ -993,8 +993,8 @@
|
|||||||
console.info('[minigames] active session torn down (reason=' + reason + ')');
|
console.info('[minigames] active session torn down (reason=' + reason + ')');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (window.slopsmith && typeof window.slopsmith.on === 'function') {
|
if (window.feedBack && typeof window.feedBack.on === 'function') {
|
||||||
window.slopsmith.on('screen:changed', (e) => {
|
window.feedBack.on('screen:changed', (e) => {
|
||||||
const id = e && e.detail && e.detail.id;
|
const id = e && e.detail && e.detail.id;
|
||||||
if (id === SCREEN_ID) {
|
if (id === SCREEN_ID) {
|
||||||
renderHub();
|
renderHub();
|
||||||
@@ -1035,8 +1035,8 @@
|
|||||||
function installNavLink() {
|
function installNavLink() {
|
||||||
const navigateToHub = (e) => {
|
const navigateToHub = (e) => {
|
||||||
if (e) e.preventDefault();
|
if (e) e.preventDefault();
|
||||||
if (window.slopsmith && typeof window.slopsmith.navigate === 'function') {
|
if (window.feedBack && typeof window.feedBack.navigate === 'function') {
|
||||||
window.slopsmith.navigate(SCREEN_ID);
|
window.feedBack.navigate(SCREEN_ID);
|
||||||
} else if (typeof window.showScreen === 'function') {
|
} else if (typeof window.showScreen === 'function') {
|
||||||
window.showScreen(SCREEN_ID);
|
window.showScreen(SCREEN_ID);
|
||||||
}
|
}
|
||||||
@@ -1078,7 +1078,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
installNavLink();
|
installNavLink();
|
||||||
// The slopsmith plugin loader rebuilds the dropdown when plugins
|
// The feedBack plugin loader rebuilds the dropdown when plugins
|
||||||
// hot-reload — re-install on a short delay then settle.
|
// hot-reload — re-install on a short delay then settle.
|
||||||
setTimeout(installNavLink, 250);
|
setTimeout(installNavLink, 250);
|
||||||
setTimeout(installNavLink, 1500);
|
setTimeout(installNavLink, 1500);
|
||||||
|
|||||||
@@ -22,14 +22,14 @@
|
|||||||
if (!btn || !status) return;
|
if (!btn || !status) return;
|
||||||
btn.addEventListener('click', async () => {
|
btn.addEventListener('click', async () => {
|
||||||
if (!confirm('Wipe all minigame XP, unlocks, and run history? This cannot be undone.')) return;
|
if (!confirm('Wipe all minigame XP, unlocks, and run history? This cannot be undone.')) return;
|
||||||
if (!window.slopsmithMinigames?.resetProfile) {
|
if (!window.feedBackMinigames?.resetProfile) {
|
||||||
status.textContent = 'Minigames SDK not loaded — reload the page and try again.';
|
status.textContent = 'Minigames SDK not loaded — reload the page and try again.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
status.textContent = 'Wiping…';
|
status.textContent = 'Wiping…';
|
||||||
try {
|
try {
|
||||||
await window.slopsmithMinigames.resetProfile();
|
await window.feedBackMinigames.resetProfile();
|
||||||
status.textContent = 'Profile reset.';
|
status.textContent = 'Profile reset.';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
status.textContent = 'Failed: ' + String(e?.message || e);
|
status.textContent = 'Failed: ' + String(e?.message || e);
|
||||||
|
|||||||
+14
-14
@@ -1,22 +1,22 @@
|
|||||||
# Slopsmith Tuner Plugin
|
# FeedBack Tuner Plugin
|
||||||
|
|
||||||
<img width="290" height="362" alt="grafik" src="https://github.com/user-attachments/assets/879440e9-b680-481b-9091-ddfa73319078" />
|
<img width="290" height="362" alt="grafik" src="https://github.com/user-attachments/assets/879440e9-b680-481b-9091-ddfa73319078" />
|
||||||
|
|
||||||
|
|
||||||
A real-time guitar and bass tuner plugin for [Slopsmith](https://github.com/got-feedback/feedback).
|
A real-time guitar and bass tuner plugin for [FeedBack](https://github.com/got-feedback/feedBack).
|
||||||
|
|
||||||
This plugin adds a floating "Tuner" button to the Slopsmith interface, providing a high-accuracy chromatic tuner with support for multiple presets, custom tunings, and automatic song tuning detection.
|
This plugin adds a floating "Tuner" button to the FeedBack interface, providing a high-accuracy chromatic tuner with support for multiple presets, custom tunings, and automatic song tuning detection.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Real-time Pitch Detection**: Uses the YIN algorithm for robust and accurate frequency tracking.
|
- **Real-time Pitch Detection**: Uses the YIN algorithm for robust and accurate frequency tracking.
|
||||||
- **Multiple Presets**: Includes common guitar and bass tunings (Standard, Drop D, DADGAD, Open G, etc.).
|
- **Multiple Presets**: Includes common guitar and bass tunings (Standard, Drop D, DADGAD, Open G, etc.).
|
||||||
- **Automatic Song Tuning**: Detects and selects the correct tuning for the currently playing song in the Slopsmith player.
|
- **Automatic Song Tuning**: Detects and selects the correct tuning for the currently playing song in the FeedBack player.
|
||||||
- **Manual & Auto Tracking**: Automatically estimates the closest string or allows manual selection for focused tuning.
|
- **Manual & Auto Tracking**: Automatically estimates the closest string or allows manual selection for focused tuning.
|
||||||
- **Visual Feedback**: Large cents-deviation gauge, frequency display, and color-coded indicators.
|
- **Visual Feedback**: Large cents-deviation gauge, frequency display, and color-coded indicators.
|
||||||
- **Custom Tunings**: Add your own tunings via note names (e.g., E2, A2) or Hz frequencies in the settings.
|
- **Custom Tunings**: Add your own tunings via note names (e.g., E2, A2) or Hz frequencies in the settings.
|
||||||
- **Audio Device Selection**: Choose specific input devices and channels (Mono, Left, Right) for professional interfaces.
|
- **Audio Device Selection**: Choose specific input devices and channels (Mono, Left, Right) for professional interfaces.
|
||||||
- **Themable UI**: Styled with Tailwind CSS to match your Slopsmith theme.
|
- **Themable UI**: Styled with Tailwind CSS to match your FeedBack theme.
|
||||||
- **Visualizations**: Pick from different visualizations to suit your needs (Currently: Default, Strobe, Analogue Gauge, Mace Fx III, and Toilet Tuner)
|
- **Visualizations**: Pick from different visualizations to suit your needs (Currently: Default, Strobe, Analogue Gauge, Mace Fx III, and Toilet Tuner)
|
||||||
|
|
||||||
## Available Visualizations
|
## Available Visualizations
|
||||||
@@ -34,18 +34,18 @@ This plugin adds a floating "Tuner" button to the Slopsmith interface, providing
|
|||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
### Download a Release
|
### Download a Release
|
||||||
1. Download one of the [Releases](https://github.com/OmikronApex/slopsmith-plugin-tuner/releases)
|
1. Download one of the [Releases](https://github.com/OmikronApex/feedBack-plugin-tuner/releases)
|
||||||
2. Extract it to your plugins folder
|
2. Extract it to your plugins folder
|
||||||
3. Restart Slopsmith
|
3. Restart FeedBack
|
||||||
|
|
||||||
### Update Manager
|
### Update Manager
|
||||||
The plugin is listed in the official plugin repository, so it can also be installed directly via the [Update Manager](https://github.com/masc0t/slopsmith-update-manager)
|
The plugin is listed in the official plugin repository, so it can also be installed directly via the [Update Manager](https://github.com/masc0t/feedBack-update-manager)
|
||||||
|
|
||||||
### Git
|
### Git
|
||||||
```bash
|
```bash
|
||||||
cd /path/to/slopsmith/plugins
|
cd /path/to/feedBack/plugins
|
||||||
git clone https://github.com/OmikronApex/slopsmith-plugin-tuner.git tuner
|
git clone https://github.com/OmikronApex/feedBack-plugin-tuner.git tuner
|
||||||
# Restart Slopsmith (or restart your docker container)
|
# Restart FeedBack (or restart your docker container)
|
||||||
docker compose restart
|
docker compose restart
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ Click the ⚙️ icon in the tuner window to access:
|
|||||||
|
|
||||||
|
|
||||||
### Plugin Manager
|
### Plugin Manager
|
||||||
Access advanced settings via the Slopsmith Plugin Manager (Settings -> Plugins -> Tuner):
|
Access advanced settings via the FeedBack Plugin Manager (Settings -> Plugins -> Tuner):
|
||||||
- **Floating Button**: Toggle the visibility of the tuner button on the main interface.
|
- **Floating Button**: Toggle the visibility of the tuner button on the main interface.
|
||||||
- **Tuning Visibility**: Toggle which built-in tunings appear in your menu.
|
- **Tuning Visibility**: Toggle which built-in tunings appear in your menu.
|
||||||
- **Custom Tunings**: Define your own tuning presets by entering a name and a list of notes/frequencies.
|
- **Custom Tunings**: Define your own tuning presets by entering a name and a list of notes/frequencies.
|
||||||
@@ -82,7 +82,7 @@ Access advanced settings via the Slopsmith Plugin Manager (Settings -> Plugins -
|
|||||||
## Changelog
|
## Changelog
|
||||||
|
|
||||||
### [1.3.1] - 2026-06-04
|
### [1.3.1] - 2026-06-04
|
||||||
- JUCE bridge audio input: when running inside Slopsmith Desktop the tuner taps the engine's raw audio stream (`getRawAudioFrame`) and runs its own tuning-optimised YIN over it, falling back to the browser microphone pipeline otherwise.
|
- JUCE bridge audio input: when running inside FeedBack Desktop the tuner taps the engine's raw audio stream (`getRawAudioFrame`) and runs its own tuning-optimised YIN over it, falling back to the browser microphone pipeline otherwise.
|
||||||
- Fixed octave-low / sub-harmonic pitch errors (canonical YIN absolute-threshold selection) and added octave-aware nearest-string matching.
|
- Fixed octave-low / sub-harmonic pitch errors (canonical YIN absolute-threshold selection) and added octave-aware nearest-string matching.
|
||||||
- "Free Tune" is now remembered as your last tuning, so it persists across sessions instead of resetting to a preset each time.
|
- "Free Tune" is now remembered as your last tuning, so it persists across sessions instead of resetting to a preset each time.
|
||||||
- Relocated visualization SVG assets to `visualization/assets/`, served via the dedicated `/api/plugins/tuner/viz-assets/` route (supersedes the 1.3.0 note about the root `assets/` directory).
|
- Relocated visualization SVG assets to `visualization/assets/`, served via the dedicated `/api/plugins/tuner/viz-assets/` route (supersedes the 1.3.0 note about the root `assets/` directory).
|
||||||
@@ -93,7 +93,7 @@ Access advanced settings via the Slopsmith Plugin Manager (Settings -> Plugins -
|
|||||||
- Added CHEF MT-3 visualization: inspired by the BOSS TU-3, featuring a 90° curved glass gauge arc, 51 tick marks, red 7-segment display, and rubber mode/brightness buttons.
|
- Added CHEF MT-3 visualization: inspired by the BOSS TU-3, featuring a 90° curved glass gauge arc, 51 tick marks, red 7-segment display, and rubber mode/brightness buttons.
|
||||||
- Refactored `screen.js` into focused modules: audio pipeline extracted to `utils/audio.js`, UI layer extracted to `utils/ui.js` (shared-state factory pattern). `screen.js` reduced from ~1060 to ~300 lines.
|
- Refactored `screen.js` into focused modules: audio pipeline extracted to `utils/audio.js`, UI layer extracted to `utils/ui.js` (shared-state factory pattern). `screen.js` reduced from ~1060 to ~300 lines.
|
||||||
- Normalised `DEFAULT_TUNINGS` keys to instrument keys (`guitar-6`, `bass-4`, etc.) — removes the internal group-name lookup table.
|
- Normalised `DEFAULT_TUNINGS` keys to instrument keys (`guitar-6`, `bass-4`, etc.) — removes the internal group-name lookup table.
|
||||||
- Added plugin stylesheet (`assets/plugin.css`) via the Slopsmith styles contract, ensuring arbitrary Tailwind classes render correctly for runtime-installed users.
|
- Added plugin stylesheet (`assets/plugin.css`) via the FeedBack styles contract, ensuring arbitrary Tailwind classes render correctly for runtime-installed users.
|
||||||
- Moved SVG assets (`Bathroom.svg`, `Plunger.svg`, `Toiletbowl.svg`) to the root `assets/` directory; removed the now-redundant custom asset route from `routes.py`.
|
- Moved SVG assets (`Bathroom.svg`, `Plunger.svg`, `Toiletbowl.svg`) to the root `assets/` directory; removed the now-redundant custom asset route from `routes.py`.
|
||||||
- Moved Toilet Tuner to the end of the visualization picker list.
|
- Moved Toilet Tuner to the end of the visualization picker list.
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ def _migrate_custom_tuning(name: str, value) -> dict:
|
|||||||
def setup(app: FastAPI, context: dict):
|
def setup(app: FastAPI, context: dict):
|
||||||
config_dir = Path(context["config_dir"])
|
config_dir = Path(context["config_dir"])
|
||||||
config_file = config_dir / "tuner.json"
|
config_file = config_dir / "tuner.json"
|
||||||
log = context.get("log") or logging.getLogger("slopsmith.plugin.tuner")
|
log = context.get("log") or logging.getLogger("feedBack.plugin.tuner")
|
||||||
|
|
||||||
def _read() -> dict:
|
def _read() -> dict:
|
||||||
defaults = {
|
defaults = {
|
||||||
|
|||||||
+25
-25
@@ -1,7 +1,7 @@
|
|||||||
// Guitar/Bass Tuner Plugin for Slopsmith
|
// Guitar/Bass Tuner Plugin for FeedBack
|
||||||
(function() {
|
(function() {
|
||||||
'use strict';
|
'use strict';
|
||||||
const _TUNER_STORAGE_KEY = 'slopsmith_tuner_settings';
|
const _TUNER_STORAGE_KEY = 'feedBack_tuner_settings';
|
||||||
|
|
||||||
// ── Player sync state ─────────────────────────────────────────────
|
// ── Player sync state ─────────────────────────────────────────────
|
||||||
let _onScreenChanged = null;
|
let _onScreenChanged = null;
|
||||||
@@ -104,18 +104,18 @@
|
|||||||
|
|
||||||
function _tuningIdentityKey(songInfo) {
|
function _tuningIdentityKey(songInfo) {
|
||||||
if (!songInfo || !Array.isArray(songInfo.tuning) || !songInfo.tuning.length) return null;
|
if (!songInfo || !Array.isArray(songInfo.tuning) || !songInfo.tuning.length) return null;
|
||||||
const ctx = (typeof window.slopsmith?.songTuningContext === 'function')
|
const ctx = (typeof window.feedBack?.songTuningContext === 'function')
|
||||||
? window.slopsmith.songTuningContext(songInfo)
|
? window.feedBack.songTuningContext(songInfo)
|
||||||
: {
|
: {
|
||||||
stringCount: songInfo.stringCount,
|
stringCount: songInfo.stringCount,
|
||||||
arrangement: songInfo.arrangement,
|
arrangement: songInfo.arrangement,
|
||||||
arrangement_smart_name: songInfo.arrangement_smart_name,
|
arrangement_smart_name: songInfo.arrangement_smart_name,
|
||||||
};
|
};
|
||||||
const isBass = (typeof window.slopsmith?.isBassArrangement === 'function')
|
const isBass = (typeof window.feedBack?.isBassArrangement === 'function')
|
||||||
? window.slopsmith.isBassArrangement(ctx)
|
? window.feedBack.isBassArrangement(ctx)
|
||||||
: (songInfo.arrangement || '').toLowerCase().includes('bass');
|
: (songInfo.arrangement || '').toLowerCase().includes('bass');
|
||||||
const sc = (typeof window.slopsmith?.effectiveStringCount === 'function')
|
const sc = (typeof window.feedBack?.effectiveStringCount === 'function')
|
||||||
? window.slopsmith.effectiveStringCount(songInfo.tuning, ctx)
|
? window.feedBack.effectiveStringCount(songInfo.tuning, ctx)
|
||||||
: (songInfo.stringCount || songInfo.tuning.length);
|
: (songInfo.stringCount || songInfo.tuning.length);
|
||||||
if (!sc || sc <= 0) return null;
|
if (!sc || sc <= 0) return null;
|
||||||
const offsets = songInfo.tuning.slice(0, sc);
|
const offsets = songInfo.tuning.slice(0, sc);
|
||||||
@@ -125,7 +125,7 @@
|
|||||||
|
|
||||||
function _autoOpenSessionKey(songInfo) {
|
function _autoOpenSessionKey(songInfo) {
|
||||||
if (!songInfo) return '';
|
if (!songInfo) return '';
|
||||||
const cur = window.slopsmith?.currentSong;
|
const cur = window.feedBack?.currentSong;
|
||||||
const filename = (cur && cur.filename) || songInfo.filename || songInfo.title || 'unknown';
|
const filename = (cur && cur.filename) || songInfo.filename || songInfo.title || 'unknown';
|
||||||
const arr = (cur && cur.arrangementIndex != null)
|
const arr = (cur && cur.arrangementIndex != null)
|
||||||
? cur.arrangementIndex
|
? cur.arrangementIndex
|
||||||
@@ -142,7 +142,7 @@
|
|||||||
async function _maybeAutoOpenOnTuningChange() {
|
async function _maybeAutoOpenOnTuningChange() {
|
||||||
if (!document.getElementById('player')?.classList.contains('active')) return;
|
if (!document.getElementById('player')?.classList.contains('active')) return;
|
||||||
|
|
||||||
const songInfo = window.highway?.getSongInfo?.() || window.slopsmith?.currentSong;
|
const songInfo = window.highway?.getSongInfo?.() || window.feedBack?.currentSong;
|
||||||
if (!songInfo) return;
|
if (!songInfo) return;
|
||||||
|
|
||||||
const tuningKey = _tuningIdentityKey(songInfo);
|
const tuningKey = _tuningIdentityKey(songInfo);
|
||||||
@@ -181,11 +181,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function _installAutoOpenListeners() {
|
function _installAutoOpenListeners() {
|
||||||
if (_onAutoOpenSongLoading || !window.slopsmith?.on) return;
|
if (_onAutoOpenSongLoading || !window.feedBack?.on) return;
|
||||||
_onAutoOpenSongLoading = _onAutoOpenSongLoadingHandler;
|
_onAutoOpenSongLoading = _onAutoOpenSongLoadingHandler;
|
||||||
_onAutoOpenSongReady = () => { _maybeAutoOpenOnTuningChange(); };
|
_onAutoOpenSongReady = () => { _maybeAutoOpenOnTuningChange(); };
|
||||||
window.slopsmith.on('song:loading', _onAutoOpenSongLoading);
|
window.feedBack.on('song:loading', _onAutoOpenSongLoading);
|
||||||
window.slopsmith.on('song:ready', _onAutoOpenSongReady);
|
window.feedBack.on('song:ready', _onAutoOpenSongReady);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Player sync helpers ───────────────────────────────────────────
|
// ── Player sync helpers ───────────────────────────────────────────
|
||||||
@@ -196,18 +196,18 @@
|
|||||||
|| (onPlayer && songInfo?.tuning?.length);
|
|| (onPlayer && songInfo?.tuning?.length);
|
||||||
if (songInfo?.tuning?.length && wantCurrent) {
|
if (songInfo?.tuning?.length && wantCurrent) {
|
||||||
_state.selectedTuningName = '_current';
|
_state.selectedTuningName = '_current';
|
||||||
const ctx = (typeof window.slopsmith?.songTuningContext === 'function')
|
const ctx = (typeof window.feedBack?.songTuningContext === 'function')
|
||||||
? window.slopsmith.songTuningContext(songInfo)
|
? window.feedBack.songTuningContext(songInfo)
|
||||||
: {
|
: {
|
||||||
stringCount: songInfo.stringCount,
|
stringCount: songInfo.stringCount,
|
||||||
arrangement: songInfo.arrangement,
|
arrangement: songInfo.arrangement,
|
||||||
arrangement_smart_name: songInfo.arrangement_smart_name,
|
arrangement_smart_name: songInfo.arrangement_smart_name,
|
||||||
};
|
};
|
||||||
const isBass = (typeof window.slopsmith?.isBassArrangement === 'function')
|
const isBass = (typeof window.feedBack?.isBassArrangement === 'function')
|
||||||
? window.slopsmith.isBassArrangement(ctx)
|
? window.feedBack.isBassArrangement(ctx)
|
||||||
: (songInfo.arrangement || '').toLowerCase().includes('bass');
|
: (songInfo.arrangement || '').toLowerCase().includes('bass');
|
||||||
const sc = (typeof window.slopsmith?.effectiveStringCount === 'function')
|
const sc = (typeof window.feedBack?.effectiveStringCount === 'function')
|
||||||
? window.slopsmith.effectiveStringCount(songInfo.tuning, ctx)
|
? window.feedBack.effectiveStringCount(songInfo.tuning, ctx)
|
||||||
: (songInfo.stringCount || songInfo.tuning.length);
|
: (songInfo.stringCount || songInfo.tuning.length);
|
||||||
_state.currentSongOffsets = songInfo.tuning.slice(0, sc);
|
_state.currentSongOffsets = songInfo.tuning.slice(0, sc);
|
||||||
_state.currentSongIsBass = isBass;
|
_state.currentSongIsBass = isBass;
|
||||||
@@ -378,14 +378,14 @@
|
|||||||
_outsideClickClose = () => { if (_state.enabled) disable(); };
|
_outsideClickClose = () => { if (_state.enabled) disable(); };
|
||||||
setTimeout(() => { if (_outsideClickClose) document.addEventListener('click', _outsideClickClose, { once: true }); }, 0);
|
setTimeout(() => { if (_outsideClickClose) document.addEventListener('click', _outsideClickClose, { once: true }); }, 0);
|
||||||
|
|
||||||
if (window.slopsmith && !_onScreenChanged) {
|
if (window.feedBack && !_onScreenChanged) {
|
||||||
_onScreenChanged = () => { disable(); };
|
_onScreenChanged = () => { disable(); };
|
||||||
_onSongReady = () => {
|
_onSongReady = () => {
|
||||||
_tunerUIApi.renderTuningOptions();
|
_tunerUIApi.renderTuningOptions();
|
||||||
if (_state.selectedTuningName === '_current') _syncCurrentTuning();
|
if (_state.selectedTuningName === '_current') _syncCurrentTuning();
|
||||||
};
|
};
|
||||||
window.slopsmith.on('screen:changed', _onScreenChanged);
|
window.feedBack.on('screen:changed', _onScreenChanged);
|
||||||
window.slopsmith.on('song:ready', _onSongReady);
|
window.feedBack.on('song:ready', _onSongReady);
|
||||||
}
|
}
|
||||||
|
|
||||||
_state.uiContainer?.querySelector('.tuner-mic-error')?.remove();
|
_state.uiContainer?.querySelector('.tuner-mic-error')?.remove();
|
||||||
@@ -413,8 +413,8 @@
|
|||||||
if (_outsideClickClose) { document.removeEventListener('click', _outsideClickClose); _outsideClickClose = null; }
|
if (_outsideClickClose) { document.removeEventListener('click', _outsideClickClose); _outsideClickClose = null; }
|
||||||
if (_state.activeViz) { _state.activeViz.destroy(); _state.activeViz = null; }
|
if (_state.activeViz) { _state.activeViz.destroy(); _state.activeViz = null; }
|
||||||
if (_state.uiContainer) { _state.uiContainer.classList.add('hidden'); _state.uiContainer.classList.remove('flex'); }
|
if (_state.uiContainer) { _state.uiContainer.classList.add('hidden'); _state.uiContainer.classList.remove('flex'); }
|
||||||
if (_onScreenChanged) { window.slopsmith?.off('screen:changed', _onScreenChanged); _onScreenChanged = null; }
|
if (_onScreenChanged) { window.feedBack?.off('screen:changed', _onScreenChanged); _onScreenChanged = null; }
|
||||||
if (_onSongReady) { window.slopsmith?.off('song:ready', _onSongReady); _onSongReady = null; }
|
if (_onSongReady) { window.feedBack?.off('song:ready', _onSongReady); _onSongReady = null; }
|
||||||
if (window._tunerAudio) window._tunerAudio.stop();
|
if (window._tunerAudio) window._tunerAudio.stop();
|
||||||
if (_state.vizContainer) _state.vizContainer.innerHTML = '';
|
if (_state.vizContainer) _state.vizContainer.innerHTML = '';
|
||||||
if (window.tuner?.updateButtons) window.tuner.updateButtons();
|
if (window.tuner?.updateButtons) window.tuner.updateButtons();
|
||||||
@@ -426,7 +426,7 @@
|
|||||||
).catch(e => console.warn('Tuner: badge audio resume failed:', e && e.message ? e.message : e));
|
).catch(e => console.warn('Tuner: badge audio resume failed:', e && e.message ? e.message : e));
|
||||||
}
|
}
|
||||||
if (wasEnabled && onPlayer) {
|
if (wasEnabled && onPlayer) {
|
||||||
const songInfo = window.highway?.getSongInfo?.() || window.slopsmith?.currentSong;
|
const songInfo = window.highway?.getSongInfo?.() || window.feedBack?.currentSong;
|
||||||
if (songInfo) _autoOpenDismissedSessionKey = _autoOpenSessionKey(songInfo);
|
if (songInfo) _autoOpenDismissedSessionKey = _autoOpenSessionKey(songInfo);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
if (window.slopsmithDesktop && window.slopsmithDesktop.isDesktop) {
|
if (window.feedBackDesktop && window.feedBackDesktop.isDesktop) {
|
||||||
document.currentScript.insertAdjacentHTML('beforebegin', `
|
document.currentScript.insertAdjacentHTML('beforebegin', `
|
||||||
<div class="flex items-center justify-between bg-dark-900/50 p-3 rounded-xl border border-gray-800/50">
|
<div class="flex items-center justify-between bg-dark-900/50 p-3 rounded-xl border border-gray-800/50">
|
||||||
<div>
|
<div>
|
||||||
@@ -129,7 +129,7 @@
|
|||||||
body: JSON.stringify(config)
|
body: JSON.stringify(config)
|
||||||
});
|
});
|
||||||
if (window._tunerReloadConfig) window._tunerReloadConfig();
|
if (window._tunerReloadConfig) window._tunerReloadConfig();
|
||||||
if (opts && opts.tuningsChanged) window.slopsmith?.emit('tunings:updated');
|
if (opts && opts.tuningsChanged) window.feedBack?.emit('tunings:updated');
|
||||||
} catch (e) { console.error('Tuner settings: save failed', e); }
|
} catch (e) { console.error('Tuner settings: save failed', e); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -86,7 +86,7 @@
|
|||||||
|
|
||||||
async function _tryBridgeStart(audioInputMode, myGen) {
|
async function _tryBridgeStart(audioInputMode, myGen) {
|
||||||
if (audioInputMode === 'browser') return false;
|
if (audioInputMode === 'browser') return false;
|
||||||
var desktop = (typeof window !== 'undefined') ? window.slopsmithDesktop : null;
|
var desktop = (typeof window !== 'undefined') ? window.feedBackDesktop : null;
|
||||||
if (!desktop || !desktop.isDesktop || !desktop.audio
|
if (!desktop || !desktop.isDesktop || !desktop.audio
|
||||||
|| typeof desktop.audio.isAvailable !== 'function') return false;
|
|| typeof desktop.audio.isAvailable !== 'function') return false;
|
||||||
|
|
||||||
|
|||||||
+21
-21
@@ -149,7 +149,7 @@ window._tunerUI = function(state, actions) {
|
|||||||
if (state.tuningSelect) state.tuningSelect.value = name;
|
if (state.tuningSelect) state.tuningSelect.value = name;
|
||||||
renderStringNotes();
|
renderStringNotes();
|
||||||
actions.saveConfig();
|
actions.saveConfig();
|
||||||
window.slopsmith?.emit('tunings:updated');
|
window.feedBack?.emit('tunings:updated');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Tuner: Failed to save custom tuning', e);
|
console.error('Tuner: Failed to save custom tuning', e);
|
||||||
}
|
}
|
||||||
@@ -204,18 +204,18 @@ window._tunerUI = function(state, actions) {
|
|||||||
if (isPlayer && typeof window.highway?.getSongInfo === 'function') {
|
if (isPlayer && typeof window.highway?.getSongInfo === 'function') {
|
||||||
const info = window.highway.getSongInfo();
|
const info = window.highway.getSongInfo();
|
||||||
if (info && info.tuning) {
|
if (info && info.tuning) {
|
||||||
const ctx = (typeof window.slopsmith?.songTuningContext === 'function')
|
const ctx = (typeof window.feedBack?.songTuningContext === 'function')
|
||||||
? window.slopsmith.songTuningContext(info)
|
? window.feedBack.songTuningContext(info)
|
||||||
: {
|
: {
|
||||||
stringCount: info.stringCount,
|
stringCount: info.stringCount,
|
||||||
arrangement: info.arrangement,
|
arrangement: info.arrangement,
|
||||||
arrangement_smart_name: info.arrangement_smart_name,
|
arrangement_smart_name: info.arrangement_smart_name,
|
||||||
};
|
};
|
||||||
const isBass = (typeof window.slopsmith?.isBassArrangement === 'function')
|
const isBass = (typeof window.feedBack?.isBassArrangement === 'function')
|
||||||
? window.slopsmith.isBassArrangement(ctx)
|
? window.feedBack.isBassArrangement(ctx)
|
||||||
: (info.arrangement || '').toLowerCase().includes('bass');
|
: (info.arrangement || '').toLowerCase().includes('bass');
|
||||||
const sc = (typeof window.slopsmith?.effectiveStringCount === 'function')
|
const sc = (typeof window.feedBack?.effectiveStringCount === 'function')
|
||||||
? window.slopsmith.effectiveStringCount(info.tuning, ctx)
|
? window.feedBack.effectiveStringCount(info.tuning, ctx)
|
||||||
: (info.stringCount || info.tuning.length);
|
: (info.stringCount || info.tuning.length);
|
||||||
const sliced = info.tuning.slice(0, sc);
|
const sliced = info.tuning.slice(0, sc);
|
||||||
const freqs = window._tunerUtils.offsetsToFreqs(sliced, isBass);
|
const freqs = window._tunerUtils.offsetsToFreqs(sliced, isBass);
|
||||||
@@ -340,8 +340,8 @@ window._tunerUI = function(state, actions) {
|
|||||||
_lastAutoTargetFreq = null;
|
_lastAutoTargetFreq = null;
|
||||||
if (state.activeViz) state.activeViz.update(null, 0, 0, vizMode, null, referencePitch);
|
if (state.activeViz) state.activeViz.update(null, 0, 0, vizMode, null, referencePitch);
|
||||||
_syncStringHighlight(state.manualTargetFreq);
|
_syncStringHighlight(state.manualTargetFreq);
|
||||||
if (window.slopsmith && window.slopsmith.emit) {
|
if (window.feedBack && window.feedBack.emit) {
|
||||||
window.slopsmith.emit('tuner:frame', { note: null, cents: 0, freq: 0, hasSignal: false });
|
window.feedBack.emit('tuner:frame', { note: null, cents: 0, freq: 0, hasSignal: false });
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -384,8 +384,8 @@ window._tunerUI = function(state, actions) {
|
|||||||
if (state.activeViz) state.activeViz.update(note, cents, displayFreq, vizMode, targetFreq, referencePitch, state.useFlats);
|
if (state.activeViz) state.activeViz.update(note, cents, displayFreq, vizMode, targetFreq, referencePitch, state.useFlats);
|
||||||
if (state.freeTune) _syncStringHighlight(null);
|
if (state.freeTune) _syncStringHighlight(null);
|
||||||
else _syncActiveStringFromFreq(targetFreq, isManual);
|
else _syncActiveStringFromFreq(targetFreq, isManual);
|
||||||
if (window.slopsmith && window.slopsmith.emit) {
|
if (window.feedBack && window.feedBack.emit) {
|
||||||
window.slopsmith.emit('tuner:frame', { note, cents, freq: displayFreq, hasSignal: true });
|
window.feedBack.emit('tuner:frame', { note, cents, freq: displayFreq, hasSignal: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -393,7 +393,7 @@ window._tunerUI = function(state, actions) {
|
|||||||
const btn = document.getElementById('tuner-toggle-btn');
|
const btn = document.getElementById('tuner-toggle-btn');
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
const isPlayer = document.querySelector('.screen.active')?.id === 'player';
|
const isPlayer = document.querySelector('.screen.active')?.id === 'player';
|
||||||
if (!state.showFloatingButton || isPlayer || window.slopsmith?.isPlaying) {
|
if (!state.showFloatingButton || isPlayer || window.feedBack?.isPlaying) {
|
||||||
btn.classList.add('hidden');
|
btn.classList.add('hidden');
|
||||||
} else {
|
} else {
|
||||||
btn.classList.remove('hidden');
|
btn.classList.remove('hidden');
|
||||||
@@ -687,16 +687,16 @@ window._tunerUI = function(state, actions) {
|
|||||||
};
|
};
|
||||||
const handleStop = () => updateFloatingButtonVisibility();
|
const handleStop = () => updateFloatingButtonVisibility();
|
||||||
|
|
||||||
if (window.slopsmith) {
|
if (window.feedBack) {
|
||||||
window.slopsmith.on('song:play', handlePlay);
|
window.feedBack.on('song:play', handlePlay);
|
||||||
window.slopsmith.on('song:pause', handleStop);
|
window.feedBack.on('song:pause', handleStop);
|
||||||
window.slopsmith.on('song:ended', handleStop);
|
window.feedBack.on('song:ended', handleStop);
|
||||||
window.slopsmith.on('screen:changed', (e) => {
|
window.feedBack.on('screen:changed', (e) => {
|
||||||
if (e.detail.id === 'player') { handlePlay(); injectPlayerButton(); }
|
if (e.detail.id === 'player') { handlePlay(); injectPlayerButton(); }
|
||||||
else handleStop();
|
else handleStop();
|
||||||
});
|
});
|
||||||
|
|
||||||
if (window.slopsmith.isPlaying || document.querySelector('.screen.active')?.id === 'player') {
|
if (window.feedBack.isPlaying || document.querySelector('.screen.active')?.id === 'player') {
|
||||||
handlePlay();
|
handlePlay();
|
||||||
if (document.querySelector('.screen.active')?.id === 'player') injectPlayerButton();
|
if (document.querySelector('.screen.active')?.id === 'player') injectPlayerButton();
|
||||||
} else {
|
} else {
|
||||||
@@ -710,10 +710,10 @@ window._tunerUI = function(state, actions) {
|
|||||||
// popover). The legacy `button:last-child` anchor resolves to a NESTED
|
// popover). The legacy `button:last-child` anchor resolves to a NESTED
|
||||||
// transport button in v3 and would throw on insertBefore; the slot is
|
// transport button in v3 and would throw on insertBefore; the slot is
|
||||||
// always present in v3, so that anchor is only used in the classic UI.
|
// always present in v3, so that anchor is only used in the classic UI.
|
||||||
const isV3 = !!(window.slopsmith && window.slopsmith.uiVersion === 'v3');
|
const isV3 = !!(window.feedBack && window.feedBack.uiVersion === 'v3');
|
||||||
let slot = null;
|
let slot = null;
|
||||||
if (isV3 && window.slopsmith.ui && typeof window.slopsmith.ui.playerControlSlot === 'function') {
|
if (isV3 && window.feedBack.ui && typeof window.feedBack.ui.playerControlSlot === 'function') {
|
||||||
try { const _s = window.slopsmith.ui.playerControlSlot(); if (_s instanceof Element) slot = _s; }
|
try { const _s = window.feedBack.ui.playerControlSlot(); if (_s instanceof Element) slot = _s; }
|
||||||
catch (_e) { /* host slot API failure → fall back to legacy container */ }
|
catch (_e) { /* host slot API failure → fall back to legacy container */ }
|
||||||
}
|
}
|
||||||
const controls = slot || document.getElementById('player-controls');
|
const controls = slot || document.getElementById('player-controls');
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Analogue gauge tuner visualization for the Slopsmith tuner plugin.
|
* Analogue gauge tuner visualization for the FeedBack tuner plugin.
|
||||||
*
|
*
|
||||||
* Contract: window['_tunerViz_analogue-gauge'](container) → { update(note, cents, freq), destroy() }
|
* Contract: window['_tunerViz_analogue-gauge'](container) → { update(note, cents, freq), destroy() }
|
||||||
* - note: string | null (null = no signal)
|
* - note: string | null (null = no signal)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* CHEF MT-3 tuner visualization for the Slopsmith tuner plugin.
|
* CHEF MT-3 tuner visualization for the FeedBack tuner plugin.
|
||||||
*
|
*
|
||||||
* Inspired by classic chromatic pedal tuners:
|
* Inspired by classic chromatic pedal tuners:
|
||||||
* - Shiny black rectangular panel with chamfered edges and corner screws
|
* - Shiny black rectangular panel with chamfered edges and corner screws
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Default (gauge) tuner visualization for the Slopsmith tuner plugin.
|
* Default (gauge) tuner visualization for the FeedBack tuner plugin.
|
||||||
*
|
*
|
||||||
* Contract: window._tunerViz_default(container) → { update(note, cents, freq), destroy() }
|
* Contract: window._tunerViz_default(container) → { update(note, cents, freq), destroy() }
|
||||||
* - note: string | null (null = no signal)
|
* - note: string | null (null = no signal)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Mace Fx III style tuner visualization for the Slopsmith tuner plugin.
|
* Mace Fx III style tuner visualization for the FeedBack tuner plugin.
|
||||||
*
|
*
|
||||||
* Inspired by hardware rack tuner displays:
|
* Inspired by hardware rack tuner displays:
|
||||||
* - Dark navy LCD background
|
* - Dark navy LCD background
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Strobe tuner visualization for the Slopsmith tuner plugin.
|
* Strobe tuner visualization for the FeedBack tuner plugin.
|
||||||
*
|
*
|
||||||
* Contract: window._tunerViz_strobe(container) → { update(note, cents, freq), destroy() }
|
* Contract: window._tunerViz_strobe(container) → { update(note, cents, freq), destroy() }
|
||||||
* - note: string | null (null = no signal)
|
* - note: string | null (null = no signal)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Toilet Tuner visualization for the Slopsmith tuner plugin.
|
* Toilet Tuner visualization for the FeedBack tuner plugin.
|
||||||
*
|
*
|
||||||
* Bathroom scene background; plunger slides left/right over the bowl based on
|
* Bathroom scene background; plunger slides left/right over the bowl based on
|
||||||
* cents deviation; dips into bowl when in tune (±2 cents); wall calendar shows
|
* cents deviation; dips into bowl when in tune (±2 cents); wall calendar shows
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ from notation_lift import ( # noqa: E402
|
|||||||
split_hands,
|
split_hands,
|
||||||
)
|
)
|
||||||
|
|
||||||
log = logging.getLogger("slopsmith.scripts.lift_keys_notation")
|
log = logging.getLogger("feedBack.scripts.lift_keys_notation")
|
||||||
|
|
||||||
_SAFE_ID_RE = re.compile(r"[A-Za-z0-9_-]+")
|
_SAFE_ID_RE = re.compile(r"[A-Za-z0-9_-]+")
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Slopsmith — FastAPI backend serving highway viewer + library."""
|
"""FeedBack — FastAPI backend serving highway viewer + library."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import bisect
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
@@ -14,9 +15,10 @@ from pathlib import Path
|
|||||||
from typing import Any, ClassVar
|
from typing import Any, ClassVar
|
||||||
|
|
||||||
from logging_setup import configure_logging
|
from logging_setup import configure_logging
|
||||||
|
from env_compat import getenv_compat
|
||||||
configure_logging()
|
configure_logging()
|
||||||
|
|
||||||
log = logging.getLogger("slopsmith.server")
|
log = logging.getLogger("feedBack.server")
|
||||||
|
|
||||||
from fastapi import Body, FastAPI, WebSocket, WebSocketDisconnect, UploadFile, File, HTTPException, Query
|
from fastapi import Body, FastAPI, WebSocket, WebSocketDisconnect, UploadFile, File, HTTPException, Query
|
||||||
from fastapi.concurrency import run_in_threadpool
|
from fastapi.concurrency import run_in_threadpool
|
||||||
@@ -27,13 +29,17 @@ from safepath import safe_join
|
|||||||
from song import (
|
from song import (
|
||||||
anchor_to_wire,
|
anchor_to_wire,
|
||||||
arrangement_string_count,
|
arrangement_string_count,
|
||||||
|
base_open_string_midis,
|
||||||
compute_smart_names,
|
compute_smart_names,
|
||||||
chord_template_to_wire,
|
chord_template_to_wire,
|
||||||
chord_to_wire,
|
chord_to_wire,
|
||||||
hand_shape_to_wire,
|
hand_shape_to_wire,
|
||||||
|
key_to_tonic_pc,
|
||||||
load_song,
|
load_song,
|
||||||
note_to_wire,
|
note_to_wire,
|
||||||
phrase_to_wire,
|
phrase_to_wire,
|
||||||
|
pitch_from_base,
|
||||||
|
scale_degree_for_pitch,
|
||||||
)
|
)
|
||||||
from audio import find_wem_files, convert_wem
|
from audio import find_wem_files, convert_wem
|
||||||
from tunings import tuning_name, DEFAULT_TUNINGS, DEFAULT_REFERENCE_PITCH, apply_reference_pitch
|
from tunings import tuning_name, DEFAULT_TUNINGS, DEFAULT_REFERENCE_PITCH, apply_reference_pitch
|
||||||
@@ -62,7 +68,7 @@ import xml.etree.ElementTree as ET
|
|||||||
import structlog
|
import structlog
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
|
|
||||||
app = FastAPI(title="Slopsmith")
|
app = FastAPI(title="FeedBack")
|
||||||
|
|
||||||
# Plugins that maintain session stores can register a cleanup callback here.
|
# Plugins that maintain session stores can register a cleanup callback here.
|
||||||
# The demo-mode janitor calls every registered hook once per hour so stale
|
# The demo-mode janitor calls every registered hook once per hour so stale
|
||||||
@@ -225,17 +231,17 @@ _DEMO_BLOCKED: list[tuple[str, re.Pattern]] = [
|
|||||||
|
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def _demo_mode_guard(request: Request, call_next):
|
async def _demo_mode_guard(request: Request, call_next):
|
||||||
if os.environ.get("SLOPSMITH_DEMO_MODE") == "1":
|
if getenv_compat("FEEDBACK_DEMO_MODE") or getenv_compat("FEEDBACK_DEMO_MODE") == "1":
|
||||||
path = request.url.path
|
path = request.url.path
|
||||||
for method, pattern in _DEMO_BLOCKED:
|
for method, pattern in _DEMO_BLOCKED:
|
||||||
if request.method == method and pattern.match(path):
|
if request.method == method and pattern.match(path):
|
||||||
return JSONResponse({"error": "demo mode: read-only"}, status_code=403)
|
return JSONResponse({"error": "demo mode: read-only"}, status_code=403)
|
||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
if request.method == "GET" and path == "/" and "slopsmith_demo_session" not in request.cookies:
|
if request.method == "GET" and path == "/" and "feedBack_demo_session" not in request.cookies:
|
||||||
forwarded_proto = (request.headers.get("x-forwarded-proto") or "").split(",")[0].strip()
|
forwarded_proto = (request.headers.get("x-forwarded-proto") or "").split(",")[0].strip()
|
||||||
is_secure = request.url.scheme == "https" or forwarded_proto.lower() == "https"
|
is_secure = request.url.scheme == "https" or forwarded_proto.lower() == "https"
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
"slopsmith_demo_session", str(uuid.uuid4()),
|
"feedBack_demo_session", str(uuid.uuid4()),
|
||||||
max_age=86400, httponly=True, samesite="lax",
|
max_age=86400, httponly=True, samesite="lax",
|
||||||
secure=is_secure,
|
secure=is_secure,
|
||||||
)
|
)
|
||||||
@@ -270,11 +276,11 @@ SLOPPAK_CACHE_DIR = CONFIG_DIR / "sloppak_cache"
|
|||||||
|
|
||||||
|
|
||||||
def _env_flag(name: str) -> bool:
|
def _env_flag(name: str) -> bool:
|
||||||
"""Parse a conventional boolean env flag."""
|
"""Parse a conventional boolean env flag (honours legacy SLOPSMITH_* alias)."""
|
||||||
return os.environ.get(name, "").strip().lower() in {"1", "true", "yes", "on"}
|
return (getenv_compat(name, "") or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
# Canonical Tuning-filter grouping key (slopsmith#867). tuning_name collapses
|
# Canonical Tuning-filter grouping key (feedBack#867). tuning_name collapses
|
||||||
# every non-standard tuning to "Custom Tuning"; for those rows we key on the
|
# every non-standard tuning to "Custom Tuning"; for those rows we key on the
|
||||||
# raw offsets so distinct customs stay distinct, while named tunings keep
|
# raw offsets so distinct customs stay distinct, while named tunings keep
|
||||||
# grouping by name (stable across the offsets-column migration). Used by both
|
# grouping by name (stable across the offsets-column migration). Used by both
|
||||||
@@ -386,14 +392,14 @@ class MetadataDB:
|
|||||||
for ddl in (
|
for ddl in (
|
||||||
"ALTER TABLE songs ADD COLUMN format TEXT DEFAULT 'archive'",
|
"ALTER TABLE songs ADD COLUMN format TEXT DEFAULT 'archive'",
|
||||||
"ALTER TABLE songs ADD COLUMN stem_count INTEGER DEFAULT 0",
|
"ALTER TABLE songs ADD COLUMN stem_count INTEGER DEFAULT 0",
|
||||||
# slopsmith#129: per-stem filter needs the id list, not just count.
|
# feedBack#129: per-stem filter needs the id list, not just count.
|
||||||
"ALTER TABLE songs ADD COLUMN stem_ids TEXT DEFAULT '[]'",
|
"ALTER TABLE songs ADD COLUMN stem_ids TEXT DEFAULT '[]'",
|
||||||
# slopsmith#69 + #22: denormalized canonical tuning name + numeric
|
# feedBack#69 + #22: denormalized canonical tuning name + numeric
|
||||||
# sort key (sum of offsets). The existing `tuning` text column
|
# sort key (sum of offsets). The existing `tuning` text column
|
||||||
# stays — these are caches, repopulated on rescan.
|
# stays — these are caches, repopulated on rescan.
|
||||||
"ALTER TABLE songs ADD COLUMN tuning_name TEXT DEFAULT ''",
|
"ALTER TABLE songs ADD COLUMN tuning_name TEXT DEFAULT ''",
|
||||||
"ALTER TABLE songs ADD COLUMN tuning_sort_key INTEGER DEFAULT 0",
|
"ALTER TABLE songs ADD COLUMN tuning_sort_key INTEGER DEFAULT 0",
|
||||||
# slopsmith#867: raw per-string offsets (space-joined ints) so the
|
# feedBack#867: raw per-string offsets (space-joined ints) so the
|
||||||
# v3 client can render target notes and the Tuning filter can keep
|
# v3 client can render target notes and the Tuning filter can keep
|
||||||
# distinct custom tunings distinct (tuning_name collapses them all
|
# distinct custom tunings distinct (tuning_name collapses them all
|
||||||
# to "Custom Tuning"). Cache; repopulated on rescan.
|
# to "Custom Tuning"). Cache; repopulated on rescan.
|
||||||
@@ -575,6 +581,88 @@ class MetadataDB:
|
|||||||
self.conn.execute("INSERT OR IGNORE INTO wallet (id) VALUES (1)")
|
self.conn.execute("INSERT OR IGNORE INTO wallet (id) VALUES (1)")
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
|
# One-time repair of pre-fix rows written under URL-encoded filenames
|
||||||
|
# (idempotent: a no-op once every row is canonical).
|
||||||
|
self._migrate_decode_stat_filenames()
|
||||||
|
|
||||||
|
def _song_exists(self, filename: str) -> bool:
|
||||||
|
return self.conn.execute(
|
||||||
|
"SELECT 1 FROM songs WHERE filename = ?", (filename,)).fetchone() is not None
|
||||||
|
|
||||||
|
def _canonical_song_filename(self, filename: str) -> str:
|
||||||
|
"""Map a (possibly URL-encoded) filename to the `songs` library key.
|
||||||
|
|
||||||
|
The recorder relays encodeURIComponent'd names ('/'→'%2F', ' '→'%20'),
|
||||||
|
but `songs` keys on the decoded on-disk path. Decoding is LIBRARY-AWARE so
|
||||||
|
a real filename that legitimately contains literal %XX is never corrupted:
|
||||||
|
prefer the form that already exists in `songs`, and decode only when the
|
||||||
|
decoded form resolves to a real song. When NEITHER form is in the library
|
||||||
|
(e.g. a play recorded before the library scan finishes) keep the stored
|
||||||
|
name unchanged — the next-startup migration canonicalizes it once the song
|
||||||
|
is scanned, rather than risk corrupting a real %XX name now."""
|
||||||
|
if not isinstance(filename, str):
|
||||||
|
return filename
|
||||||
|
if self._song_exists(filename):
|
||||||
|
return filename # already a real library key (may contain %)
|
||||||
|
from urllib.parse import unquote
|
||||||
|
decoded = unquote(filename)
|
||||||
|
if decoded != filename and self._song_exists(decoded):
|
||||||
|
return decoded # encoded → real library key
|
||||||
|
return filename # neither in library: leave as-is (heals on migrate)
|
||||||
|
|
||||||
|
def _migrate_decode_stat_filenames(self):
|
||||||
|
"""Rewrite URL-encoded song_stats.filename rows to the decoded
|
||||||
|
library-path key (the form `songs` uses). Pre-fix, the recorder stored
|
||||||
|
encodeURIComponent'd names, so every recorded best was invisible to the
|
||||||
|
reads that filter on `filename IN (SELECT filename FROM songs)`. Merge on
|
||||||
|
collision — two encoded rows decoding to the same name, or an encoded row
|
||||||
|
meeting an already-decoded one — with the same best=max / plays=sum /
|
||||||
|
last-wins semantics as song_score.merge_stats, so the (filename,
|
||||||
|
arrangement) primary key is never violated.
|
||||||
|
|
||||||
|
Library-aware via the shared _canonical_song_filename rule: only decode a
|
||||||
|
row when the decoded form is a real song, so a correctly-stored name
|
||||||
|
containing literal %XX is never rewritten, and dead-song/orphan rows
|
||||||
|
(neither form in the library) are left exactly as-is."""
|
||||||
|
cols = self._STATS_COLS
|
||||||
|
with self._lock:
|
||||||
|
rows = [dict(zip(cols, r)) for r in self.conn.execute(
|
||||||
|
"SELECT " + ", ".join(cols) + " FROM song_stats").fetchall()]
|
||||||
|
canon = self._canonical_song_filename
|
||||||
|
if all(canon(r["filename"]) == r["filename"] for r in rows):
|
||||||
|
return # every row already canonical (or an untouchable orphan)
|
||||||
|
merged: dict = {}
|
||||||
|
for r in rows:
|
||||||
|
key = (canon(r["filename"]), int(r["arrangement"]))
|
||||||
|
cur = merged.get(key)
|
||||||
|
if cur is None:
|
||||||
|
merged[key] = dict(r, filename=key[0], arrangement=key[1])
|
||||||
|
continue
|
||||||
|
# Most-recently-updated row wins the "last_*"/position fields.
|
||||||
|
def _stamp(x):
|
||||||
|
return str(x.get("updated_at") or x.get("last_played_at") or "")
|
||||||
|
newer = r if _stamp(r) >= _stamp(cur) else cur
|
||||||
|
merged[key] = {
|
||||||
|
"filename": key[0], "arrangement": key[1],
|
||||||
|
"plays": (cur["plays"] or 0) + (r["plays"] or 0),
|
||||||
|
"best_score": max(cur["best_score"] or 0, r["best_score"] or 0),
|
||||||
|
"best_accuracy": max(cur["best_accuracy"] or 0.0, r["best_accuracy"] or 0.0),
|
||||||
|
"last_score": newer["last_score"], "last_accuracy": newer["last_accuracy"],
|
||||||
|
"last_position": newer["last_position"],
|
||||||
|
"last_played_at": newer["last_played_at"], "updated_at": newer["updated_at"],
|
||||||
|
}
|
||||||
|
# Atomic swap: clear and reinsert the canonicalized set in one txn.
|
||||||
|
try:
|
||||||
|
self.conn.execute("DELETE FROM song_stats")
|
||||||
|
self.conn.executemany(
|
||||||
|
"INSERT INTO song_stats (" + ", ".join(cols) + ") VALUES ("
|
||||||
|
+ ", ".join("?" * len(cols)) + ")",
|
||||||
|
[tuple(m[c] for c in cols) for m in merged.values()],
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
except Exception:
|
||||||
|
self.conn.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
def is_favorite(self, filename: str) -> bool:
|
def is_favorite(self, filename: str) -> bool:
|
||||||
return self.conn.execute("SELECT 1 FROM favorites WHERE filename = ?", (filename,)).fetchone() is not None
|
return self.conn.execute("SELECT 1 FROM favorites WHERE filename = ?", (filename,)).fetchone() is not None
|
||||||
@@ -1175,6 +1263,25 @@ class MetadataDB:
|
|||||||
).fetchall()
|
).fetchall()
|
||||||
return {r[0]: r[1] for r in rows if r[2] and r[2] > 0}
|
return {r[0]: r[1] for r in rows if r[2] and r[2] > 0}
|
||||||
|
|
||||||
|
def top_stats(self, limit: int = 5) -> list[dict]:
|
||||||
|
"""Top scored songs (best score first) for the profile 'Your best
|
||||||
|
scores' panel. Aggregated per-song across arrangements (best score,
|
||||||
|
best accuracy, total plays), only SCORED songs (plays > 0), dead songs
|
||||||
|
skipped. Mirrors best_accuracy_map's grouping; enriched with metadata
|
||||||
|
by the /api/stats/top route."""
|
||||||
|
limit = max(1, min(50, int(limit)))
|
||||||
|
rows = self.conn.execute(
|
||||||
|
"SELECT filename, MAX(best_score), MAX(best_accuracy), SUM(plays) "
|
||||||
|
"FROM song_stats WHERE 1=1 " + self._existing_song_filter() + # skip dead songs
|
||||||
|
"GROUP BY filename HAVING SUM(plays) > 0 "
|
||||||
|
"ORDER BY MAX(best_score) DESC, MAX(best_accuracy) DESC LIMIT ?",
|
||||||
|
(limit,),
|
||||||
|
).fetchall()
|
||||||
|
return [
|
||||||
|
{"filename": r[0], "best_score": r[1], "best_accuracy": r[2], "plays": r[3]}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
# ── Playlists ─────────────────────────────────────────────────────────--
|
# ── Playlists ─────────────────────────────────────────────────────────--
|
||||||
SAVED_KEY = "saved_for_later"
|
SAVED_KEY = "saved_for_later"
|
||||||
|
|
||||||
@@ -1476,7 +1583,7 @@ class MetadataDB:
|
|||||||
# Manifest-allowed filter values. Whitelisted before binding so a
|
# Manifest-allowed filter values. Whitelisted before binding so a
|
||||||
# malformed query string can't push arbitrary text through to SQL —
|
# malformed query string can't push arbitrary text through to SQL —
|
||||||
# parameters are bound, but capping the input space is still cheap
|
# parameters are bound, but capping the input space is still cheap
|
||||||
# defense-in-depth (see slopsmith#129).
|
# defense-in-depth (see feedBack#129).
|
||||||
_ALLOWED_ARRANGEMENT_NAMES = {"Lead", "Rhythm", "Bass", "Combo"}
|
_ALLOWED_ARRANGEMENT_NAMES = {"Lead", "Rhythm", "Bass", "Combo"}
|
||||||
# Per-smart-type list of (sql_op, sql_param) pairs appended to the SQL
|
# Per-smart-type list of (sql_op, sql_param) pairs appended to the SQL
|
||||||
# name-fallback branch (key-absent smart_name). Covers legacy raw names
|
# name-fallback branch (key-absent smart_name). Covers legacy raw names
|
||||||
@@ -1516,7 +1623,7 @@ class MetadataDB:
|
|||||||
naming_mode: str = "legacy") -> tuple[str, list]:
|
naming_mode: str = "legacy") -> tuple[str, list]:
|
||||||
"""Shared WHERE-clause builder for query_page / query_artists /
|
"""Shared WHERE-clause builder for query_page / query_artists /
|
||||||
query_stats. Returns (where_sql, params). Leading 'WHERE' is
|
query_stats. Returns (where_sql, params). Leading 'WHERE' is
|
||||||
included so callers paste it directly. See slopsmith#129/#69.
|
included so callers paste it directly. See feedBack#129/#69.
|
||||||
"""
|
"""
|
||||||
where = "WHERE title != ''"
|
where = "WHERE title != ''"
|
||||||
params: list = []
|
params: list = []
|
||||||
@@ -1698,7 +1805,7 @@ class MetadataDB:
|
|||||||
"title": "title COLLATE NOCASE", "title-desc": "title COLLATE NOCASE DESC",
|
"title": "title COLLATE NOCASE", "title-desc": "title COLLATE NOCASE DESC",
|
||||||
"recent": "mtime DESC",
|
"recent": "mtime DESC",
|
||||||
# Tuning sort uses musical distance from E Standard
|
# Tuning sort uses musical distance from E Standard
|
||||||
# (slopsmith#22 — was alphabetical). `tuning_sort_key` is
|
# (feedBack#22 — was alphabetical). `tuning_sort_key` is
|
||||||
# the sum of per-string offsets, so |sort_key| is the
|
# the sum of per-string offsets, so |sort_key| is the
|
||||||
# magnitude of the down/up-tune. ABS ascending puts E
|
# magnitude of the down/up-tune. ABS ascending puts E
|
||||||
# Standard (0) first, then ±2 (Drop D, F Standard), then
|
# Standard (0) first, then ±2 (Drop D, F Standard), then
|
||||||
@@ -1726,7 +1833,7 @@ class MetadataDB:
|
|||||||
"COALESCE(tuning_sort_key, 0) ASC, "
|
"COALESCE(tuning_sort_key, 0) ASC, "
|
||||||
"COALESCE(tuning_name, '') COLLATE NOCASE"
|
"COALESCE(tuning_name, '') COLLATE NOCASE"
|
||||||
),
|
),
|
||||||
# Year sort (slopsmith#128). Empty-year rows pushed to the
|
# Year sort (feedBack#128). Empty-year rows pushed to the
|
||||||
# bottom for both directions; otherwise CAST so '2010' >
|
# bottom for both directions; otherwise CAST so '2010' >
|
||||||
# '2005' rather than alphabetic.
|
# '2005' rather than alphabetic.
|
||||||
"year": "(year = '') ASC, CAST(year AS INTEGER) ASC",
|
"year": "(year = '') ASC, CAST(year AS INTEGER) ASC",
|
||||||
@@ -2844,7 +2951,7 @@ _scan_status = dict(_SCAN_STATUS_INIT)
|
|||||||
_STARTUP_STATUS_INIT = {
|
_STARTUP_STATUS_INIT = {
|
||||||
"running": True,
|
"running": True,
|
||||||
"phase": "booting",
|
"phase": "booting",
|
||||||
"message": "Starting Slopsmith server...",
|
"message": "Starting FeedBack server...",
|
||||||
"current_plugin": "",
|
"current_plugin": "",
|
||||||
"loaded": 0,
|
"loaded": 0,
|
||||||
"total": 0,
|
"total": 0,
|
||||||
@@ -2928,13 +3035,13 @@ def _make_scan_executor():
|
|||||||
mp_ctx = multiprocessing.get_context("spawn")
|
mp_ctx = multiprocessing.get_context("spawn")
|
||||||
# Default to one worker per core so CPU-bound metadata parsing uses the
|
# Default to one worker per core so CPU-bound metadata parsing uses the
|
||||||
# whole machine (the point of moving to processes).
|
# whole machine (the point of moving to processes).
|
||||||
# SLOPSMITH_MAX_SCAN_WORKERS (set by the Desktop launcher to cap memory
|
# FEEDBACK_MAX_SCAN_WORKERS (set by the Desktop launcher to cap memory
|
||||||
# usage on low-RAM machines — e.g. 8 GB M2 MacBook Air) takes priority;
|
# usage on low-RAM machines — e.g. 8 GB M2 MacBook Air) takes priority;
|
||||||
# SCAN_MAX_WORKERS is a legacy override for Docker/bare installs.
|
# SCAN_MAX_WORKERS is a legacy override for Docker/bare installs.
|
||||||
# A malformed override falls back to the core count rather than crashing.
|
# A malformed override falls back to the core count rather than crashing.
|
||||||
try:
|
try:
|
||||||
max_workers = int(
|
max_workers = int(
|
||||||
os.environ.get("SLOPSMITH_MAX_SCAN_WORKERS")
|
getenv_compat("FEEDBACK_MAX_SCAN_WORKERS")
|
||||||
or os.environ.get("SCAN_MAX_WORKERS")
|
or os.environ.get("SCAN_MAX_WORKERS")
|
||||||
or (os.cpu_count() or 1)
|
or (os.cpu_count() or 1)
|
||||||
)
|
)
|
||||||
@@ -2954,14 +3061,14 @@ def _make_scan_executor():
|
|||||||
_BUILTIN_DIAGNOSTIC_SUBDIR = "diagnostics-builtin"
|
_BUILTIN_DIAGNOSTIC_SUBDIR = "diagnostics-builtin"
|
||||||
_BUILTIN_DIAGNOSTIC_SOURCES: list[tuple[str, str]] = [
|
_BUILTIN_DIAGNOSTIC_SOURCES: list[tuple[str, str]] = [
|
||||||
(
|
(
|
||||||
"slopsmith-diagnostic-basic-guitar.sloppak",
|
"feedBack-diagnostic-basic-guitar.sloppak",
|
||||||
"docs/diagnostics/slopsmith-diagnostic-basic-guitar.sloppak",
|
"docs/diagnostics/feedBack-diagnostic-basic-guitar.sloppak",
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def _slopsmith_server_root() -> Path:
|
def _feedBack_server_root() -> Path:
|
||||||
"""Directory containing server.py (repo root in dev; resources/slopsmith when bundled)."""
|
"""Directory containing server.py (repo root in dev; resources/feedBack when bundled)."""
|
||||||
return Path(__file__).resolve().parent
|
return Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
|
||||||
@@ -2973,7 +3080,7 @@ def _builtin_diagnostic_filename() -> str:
|
|||||||
|
|
||||||
# Progression content (spec 010): bundled JSON under data/progression/ (paths,
|
# Progression content (spec 010): bundled JSON under data/progression/ (paths,
|
||||||
# quest pools, shop catalog). Loaded lazily-once; invalid entries are logged
|
# quest pools, shop catalog). Loaded lazily-once; invalid entries are logged
|
||||||
# warnings, never fatal. SLOPSMITH_PROGRESSION_DATA overrides the root (tests).
|
# warnings, never fatal. FEEDBACK_PROGRESSION_DATA overrides the root (tests).
|
||||||
_progression_content: dict | None = None
|
_progression_content: dict | None = None
|
||||||
_progression_content_lock = threading.Lock()
|
_progression_content_lock = threading.Lock()
|
||||||
|
|
||||||
@@ -2984,8 +3091,8 @@ def _get_progression_content() -> dict:
|
|||||||
with _progression_content_lock:
|
with _progression_content_lock:
|
||||||
if _progression_content is None:
|
if _progression_content is None:
|
||||||
import progression as progression_mod
|
import progression as progression_mod
|
||||||
root = os.environ.get("SLOPSMITH_PROGRESSION_DATA") or (
|
root = getenv_compat("FEEDBACK_PROGRESSION_DATA") or (
|
||||||
_slopsmith_server_root() / "data" / "progression"
|
_feedBack_server_root() / "data" / "progression"
|
||||||
)
|
)
|
||||||
content, warnings = progression_mod.load_content(root)
|
content, warnings = progression_mod.load_content(root)
|
||||||
for warning in warnings:
|
for warning in warnings:
|
||||||
@@ -3009,7 +3116,7 @@ def _seed_builtin_diagnostic_sloppaks(dlc: Path | None = None) -> None:
|
|||||||
log.debug("Builtin diagnostic seed: no DLC folder configured, skipping")
|
log.debug("Builtin diagnostic seed: no DLC folder configured, skipping")
|
||||||
return
|
return
|
||||||
|
|
||||||
root = _slopsmith_server_root()
|
root = _feedBack_server_root()
|
||||||
dest_dir = dlc / _BUILTIN_DIAGNOSTIC_SUBDIR
|
dest_dir = dlc / _BUILTIN_DIAGNOSTIC_SUBDIR
|
||||||
# Refuse a symlinked seed directory: mkdir(exist_ok=True) would accept
|
# Refuse a symlinked seed directory: mkdir(exist_ok=True) would accept
|
||||||
# it and copies would land at the link target, outside the DLC tree.
|
# it and copies would land at the link target, outside the DLC tree.
|
||||||
@@ -3124,8 +3231,10 @@ def _background_scan():
|
|||||||
# path for playback.
|
# path for playback.
|
||||||
def _is_excluded_from_library(p: Path) -> bool:
|
def _is_excluded_from_library(p: Path) -> bool:
|
||||||
return "tutorials-builtin" in p.parts or "minigames-builtin" in p.parts
|
return "tutorials-builtin" in p.parts or "minigames-builtin" in p.parts
|
||||||
# Sloppaks: match both file (zip) and directory form by suffix.
|
# Sloppaks: match both file (zip) and directory form, across both the
|
||||||
sloppaks = [f for f in sorted(dlc.rglob("*.sloppak"))
|
# `.feedpak` and legacy `.sloppak` suffixes.
|
||||||
|
_cands = sorted(p for ext in sloppak_mod.SONG_EXTS for p in dlc.rglob(f"*{ext}"))
|
||||||
|
sloppaks = [f for f in _cands
|
||||||
if sloppak_mod.is_sloppak(f)
|
if sloppak_mod.is_sloppak(f)
|
||||||
and not _is_excluded_from_library(f)]
|
and not _is_excluded_from_library(f)]
|
||||||
|
|
||||||
@@ -3143,7 +3252,7 @@ def _background_scan():
|
|||||||
if _is_excluded_from_library(wem):
|
if _is_excluded_from_library(wem):
|
||||||
continue
|
continue
|
||||||
d = wem.parent
|
d = wem.parent
|
||||||
if d in sloppak_dirs or d.name.lower().endswith(".sloppak"):
|
if d in sloppak_dirs or d.name.lower().endswith(sloppak_mod.SONG_EXTS):
|
||||||
continue
|
continue
|
||||||
if d not in seen_loose and loosefolder_mod.is_loose_song(d):
|
if d not in seen_loose and loosefolder_mod.is_loose_song(d):
|
||||||
loose_songs.append(d)
|
loose_songs.append(d)
|
||||||
@@ -3309,7 +3418,7 @@ async def startup_events():
|
|||||||
# phase so any frontend startup waiter that observes the lifespan also
|
# phase so any frontend startup waiter that observes the lifespan also
|
||||||
# unblocks cleanly (the SSE/poll client treats only `complete` and
|
# unblocks cleanly (the SSE/poll client treats only `complete` and
|
||||||
# `error` as terminal when `running` becomes false).
|
# `error` as terminal when `running` becomes false).
|
||||||
if _env_flag("SLOPSMITH_SKIP_STARTUP_TASKS"):
|
if _env_flag("FEEDBACK_SKIP_STARTUP_TASKS"):
|
||||||
log.info("[startup] Skipping plugin load and background scan")
|
log.info("[startup] Skipping plugin load and background scan")
|
||||||
# Tests pop `server` from sys.modules across runs, but the `plugins`
|
# Tests pop `server` from sys.modules across runs, but the `plugins`
|
||||||
# module is not reloaded — so LOADED_PLUGINS can carry stale entries
|
# module is not reloaded — so LOADED_PLUGINS can carry stale entries
|
||||||
@@ -3324,7 +3433,7 @@ async def startup_events():
|
|||||||
_set_startup_status(
|
_set_startup_status(
|
||||||
running=False,
|
running=False,
|
||||||
phase="complete",
|
phase="complete",
|
||||||
message="Startup tasks skipped (SLOPSMITH_SKIP_STARTUP_TASKS).",
|
message="Startup tasks skipped (FEEDBACK_SKIP_STARTUP_TASKS).",
|
||||||
error=None,
|
error=None,
|
||||||
current_plugin="",
|
current_plugin="",
|
||||||
loaded=0,
|
loaded=0,
|
||||||
@@ -3380,7 +3489,7 @@ async def startup_events():
|
|||||||
|
|
||||||
# Load plugins asynchronously so HTTP routes and the desktop window can
|
# Load plugins asynchronously so HTTP routes and the desktop window can
|
||||||
# come up immediately while heavy plugin imports/install steps continue.
|
# come up immediately while heavy plugin imports/install steps continue.
|
||||||
_sync_mode = os.environ.get("SLOPSMITH_SYNC_STARTUP", "").lower() in {"1", "true", "yes", "on"}
|
_sync_mode = getenv_compat("FEEDBACK_SYNC_STARTUP", "").lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
def _load_plugins_background():
|
def _load_plugins_background():
|
||||||
try:
|
try:
|
||||||
@@ -3569,7 +3678,7 @@ async def startup_events():
|
|||||||
route_setup_fn=_route_setup_on_main)
|
route_setup_fn=_route_setup_on_main)
|
||||||
# Self-heal a freshly recreated container: its filesystem reset to
|
# Self-heal a freshly recreated container: its filesystem reset to
|
||||||
# the image-baked sheet (in-tree plugins only), but a mounted
|
# the image-baked sheet (in-tree plugins only), but a mounted
|
||||||
# SLOPSMITH_PLUGINS_DIR may carry user-installed plugins whose
|
# FEEDBACK_PLUGINS_DIR may carry user-installed plugins whose
|
||||||
# classes aren't in it. Run in its OWN daemon thread so the startup
|
# classes aren't in it. Run in its OWN daemon thread so the startup
|
||||||
# status can flip to "complete" immediately rather than waiting on
|
# status can flip to "complete" immediately rather than waiting on
|
||||||
# the (up to 120s) Tailwind subprocess. No-op when there are no user
|
# the (up to 120s) Tailwind subprocess. No-op when there are no user
|
||||||
@@ -3615,7 +3724,7 @@ async def startup_events():
|
|||||||
threading.Thread(target=_load_plugins_background, daemon=True).start()
|
threading.Thread(target=_load_plugins_background, daemon=True).start()
|
||||||
|
|
||||||
global _DEMO_JANITOR_STARTED, _DEMO_JANITOR_THREAD
|
global _DEMO_JANITOR_STARTED, _DEMO_JANITOR_THREAD
|
||||||
if os.environ.get("SLOPSMITH_DEMO_MODE") == "1" and not _DEMO_JANITOR_STARTED:
|
if getenv_compat("FEEDBACK_DEMO_MODE") or getenv_compat("FEEDBACK_DEMO_MODE") == "1" and not _DEMO_JANITOR_STARTED:
|
||||||
_DEMO_JANITOR_STARTED = True
|
_DEMO_JANITOR_STARTED = True
|
||||||
_DEMO_JANITOR_STOP.clear()
|
_DEMO_JANITOR_STOP.clear()
|
||||||
def _janitor():
|
def _janitor():
|
||||||
@@ -3722,7 +3831,7 @@ def get_version():
|
|||||||
version = version_file.read_text().strip()
|
version = version_file.read_text().strip()
|
||||||
except (OSError, UnicodeDecodeError):
|
except (OSError, UnicodeDecodeError):
|
||||||
pass
|
pass
|
||||||
default_source_url = "https://github.com/got-feedback/feedback"
|
default_source_url = "https://github.com/got-feedback/feedBack"
|
||||||
# APP_SOURCE_URL / APP_LICENSE_URL flow straight into <a href> in the UI,
|
# APP_SOURCE_URL / APP_LICENSE_URL flow straight into <a href> in the UI,
|
||||||
# so validate with urllib.parse rather than a bare prefix check — a prefix
|
# so validate with urllib.parse rather than a bare prefix check — a prefix
|
||||||
# check accepts malformed values like "https://" (no host) which produce
|
# check accepts malformed values like "https://" (no host) which produce
|
||||||
@@ -3827,7 +3936,7 @@ def trigger_full_rescan():
|
|||||||
|
|
||||||
# ── Song upload ───────────────────────────────────────────────────────────────
|
# ── Song upload ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
_ALLOWED_SONG_EXTS = {".sloppak"}
|
_ALLOWED_SONG_EXTS = set(sloppak_mod.SONG_EXTS)
|
||||||
_MAX_UPLOAD_BYTES = 1024 * 1024 * 1024 # 1 GB — covers sloppaks bundled with stems
|
_MAX_UPLOAD_BYTES = 1024 * 1024 * 1024 # 1 GB — covers sloppaks bundled with stems
|
||||||
# Per-request batch cap. Lets a user drop a whole album of sloppaks at once
|
# Per-request batch cap. Lets a user drop a whole album of sloppaks at once
|
||||||
# without giving a hostile client a 1000-file DoS surface via Starlette's
|
# without giving a hostile client a 1000-file DoS surface via Starlette's
|
||||||
@@ -4058,7 +4167,7 @@ async def _save_uploaded_song(upload: UploadFile, dlc: Path, overwrite: bool) ->
|
|||||||
suffix = Path(base).suffix.lower()
|
suffix = Path(base).suffix.lower()
|
||||||
if suffix not in _ALLOWED_SONG_EXTS:
|
if suffix not in _ALLOWED_SONG_EXTS:
|
||||||
return {"status": "error", "filename": base,
|
return {"status": "error", "filename": base,
|
||||||
"error": "Only .sloppak files are accepted"}
|
"error": "Only .feedpak files are accepted"}
|
||||||
|
|
||||||
dest = dlc / base
|
dest = dlc / base
|
||||||
if dest.exists():
|
if dest.exists():
|
||||||
@@ -4116,10 +4225,10 @@ async def _save_uploaded_song(upload: UploadFile, dlc: Path, overwrite: bool) ->
|
|||||||
if bytes_read == 0:
|
if bytes_read == 0:
|
||||||
error_result = {"status": "error", "filename": base,
|
error_result = {"status": "error", "filename": base,
|
||||||
"error": "Empty upload — file is 0 bytes"}
|
"error": "Empty upload — file is 0 bytes"}
|
||||||
elif suffix == ".sloppak":
|
elif suffix in _ALLOWED_SONG_EXTS:
|
||||||
if head[:2] != b"PK":
|
if head[:2] != b"PK":
|
||||||
error_result = {"status": "error", "filename": base,
|
error_result = {"status": "error", "filename": base,
|
||||||
"error": "Not a valid sloppak file (expected zip archive)"}
|
"error": "Not a valid feedpak file (expected zip archive)"}
|
||||||
else:
|
else:
|
||||||
# ZIP magic alone admits any renamed zip — verify the sloppak
|
# ZIP magic alone admits any renamed zip — verify the sloppak
|
||||||
# loader can actually parse a manifest.yaml inside. Without
|
# loader can actually parse a manifest.yaml inside. Without
|
||||||
@@ -4406,7 +4515,7 @@ async def list_tuning_names(provider: str = "local"):
|
|||||||
"""Distinct tuning names present in the library, with per-tuning
|
"""Distinct tuning names present in the library, with per-tuning
|
||||||
counts. Powers the tuning multi-select. Sorted by `tuning_sort_key`
|
counts. Powers the tuning multi-select. Sorted by `tuning_sort_key`
|
||||||
so names appear in the same musical order the sort uses
|
so names appear in the same musical order the sort uses
|
||||||
(slopsmith#22) — E Standard first, then nearest neighbors."""
|
(feedBack#22) — E Standard first, then nearest neighbors."""
|
||||||
library_provider = _get_library_provider(provider)
|
library_provider = _get_library_provider(provider)
|
||||||
_require_library_provider_capability(library_provider, "library.read")
|
_require_library_provider_capability(library_provider, "library.read")
|
||||||
return await _call_library_provider_async(library_provider, "tuning_names")
|
return await _call_library_provider_async(library_provider, "tuning_names")
|
||||||
@@ -4852,6 +4961,10 @@ def api_record_stats(data: dict):
|
|||||||
filename = _clean_str(data.get("filename"))
|
filename = _clean_str(data.get("filename"))
|
||||||
if not filename:
|
if not filename:
|
||||||
return JSONResponse({"error": "filename required"}, status_code=400)
|
return JSONResponse({"error": "filename required"}, status_code=400)
|
||||||
|
# The recorder hands us URL-encoded filenames; canonicalize to the library
|
||||||
|
# key so stored rows line up with `songs` (and so the arrangement-count bound
|
||||||
|
# below resolves the real song). See MetadataDB._canonical_song_filename.
|
||||||
|
filename = meta_db._canonical_song_filename(filename)
|
||||||
arr_raw = data.get("arrangement", 0)
|
arr_raw = data.get("arrangement", 0)
|
||||||
if arr_raw is None:
|
if arr_raw is None:
|
||||||
arrangement = 0
|
arrangement = 0
|
||||||
@@ -5006,6 +5119,28 @@ def api_stats_best():
|
|||||||
return meta_db.best_accuracy_map()
|
return meta_db.best_accuracy_map()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/stats/top")
|
||||||
|
def api_top_stats(limit: int = 5):
|
||||||
|
"""Top scored songs (best first), joined to song metadata, for the profile
|
||||||
|
'Your best scores' panel (defined before the {filename} catch-all)."""
|
||||||
|
from urllib.parse import quote
|
||||||
|
out = []
|
||||||
|
for r in meta_db.top_stats(limit):
|
||||||
|
meta = meta_db.conn.execute(
|
||||||
|
"SELECT title, artist, tuning_name FROM songs WHERE filename = ?",
|
||||||
|
(r["filename"],),
|
||||||
|
).fetchone()
|
||||||
|
title, artist, tuning_name = meta if meta else (None, None, None)
|
||||||
|
out.append({
|
||||||
|
**r,
|
||||||
|
"title": title or r["filename"],
|
||||||
|
"artist": artist or "",
|
||||||
|
"tuning_name": tuning_name or "",
|
||||||
|
"art_url": f"/api/song/{quote(r['filename'])}/art",
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/stats/{filename:path}")
|
@app.get("/api/stats/{filename:path}")
|
||||||
def api_song_stats(filename: str):
|
def api_song_stats(filename: str):
|
||||||
return meta_db.get_song_stats(filename)
|
return meta_db.get_song_stats(filename)
|
||||||
@@ -5315,8 +5450,9 @@ def save_settings(data: dict):
|
|||||||
else:
|
else:
|
||||||
if Path(dlc_path).is_dir():
|
if Path(dlc_path).is_dir():
|
||||||
updates["dlc_dir"] = dlc_path
|
updates["dlc_dir"] = dlc_path
|
||||||
count = sum(1 for f in Path(dlc_path).iterdir() if f.suffix == ".sloppak")
|
count = sum(1 for f in Path(dlc_path).iterdir()
|
||||||
messages.append(f"DLC folder: {count} sloppak files found")
|
if f.suffix.lower() in sloppak_mod.SONG_EXTS)
|
||||||
|
messages.append(f"DLC folder: {count} song files found")
|
||||||
else:
|
else:
|
||||||
return {"error": f"DLC directory not found: {dlc_path}"}
|
return {"error": f"DLC directory not found: {dlc_path}"}
|
||||||
|
|
||||||
@@ -5438,7 +5574,7 @@ def save_settings(data: dict):
|
|||||||
return {"message": ". ".join(messages) if messages else "Settings saved"}
|
return {"message": ". ".join(messages) if messages else "Settings saved"}
|
||||||
|
|
||||||
|
|
||||||
# ── Settings export/import (slopsmith#113) ───────────────────────────────────
|
# ── Settings export/import (feedBack#113) ───────────────────────────────────
|
||||||
|
|
||||||
# Bumped only when the bundle JSON shape changes incompatibly. Importer
|
# Bumped only when the bundle JSON shape changes incompatibly. Importer
|
||||||
# refuses anything but this exact value — version mismatches are warned
|
# refuses anything but this exact value — version mismatches are warned
|
||||||
@@ -5810,7 +5946,7 @@ def _atomic_write_file(target: Path, payload: bytes):
|
|||||||
def export_settings():
|
def export_settings():
|
||||||
"""Build a settings bundle covering server config + opted-in plugin
|
"""Build a settings bundle covering server config + opted-in plugin
|
||||||
server-side files. Frontend layers in `local_storage` before
|
server-side files. Frontend layers in `local_storage` before
|
||||||
triggering the download. See slopsmith#113."""
|
triggering the download. See feedBack#113."""
|
||||||
import datetime
|
import datetime
|
||||||
from plugins import LOADED_PLUGINS, PLUGINS_LOCK
|
from plugins import LOADED_PLUGINS, PLUGINS_LOCK
|
||||||
|
|
||||||
@@ -5833,11 +5969,11 @@ def export_settings():
|
|||||||
bundle = {
|
bundle = {
|
||||||
"schema": SETTINGS_BUNDLE_SCHEMA,
|
"schema": SETTINGS_BUNDLE_SCHEMA,
|
||||||
"exported_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
"exported_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
"slopsmith_version": _running_version(),
|
"feedBack_version": _running_version(),
|
||||||
"server_config": server_config,
|
"server_config": server_config,
|
||||||
"plugin_server_configs": plugin_blocks,
|
"plugin_server_configs": plugin_blocks,
|
||||||
}
|
}
|
||||||
filename = f"slopsmith-settings-{now.strftime('%Y-%m-%d')}.json"
|
filename = f"feedBack-settings-{now.strftime('%Y-%m-%d')}.json"
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
bundle,
|
bundle,
|
||||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
@@ -5849,7 +5985,7 @@ def import_settings(bundle: dict):
|
|||||||
"""Apply a previously exported settings bundle. Validates the entire
|
"""Apply a previously exported settings bundle. Validates the entire
|
||||||
bundle in phase 1 (no disk writes); only on full success does
|
bundle in phase 1 (no disk writes); only on full success does
|
||||||
phase 2 commit each file via temp+rename. The frontend reads
|
phase 2 commit each file via temp+rename. The frontend reads
|
||||||
`local_storage` itself — server ignores it. See slopsmith#113."""
|
`local_storage` itself — server ignores it. See feedBack#113."""
|
||||||
from plugins import LOADED_PLUGINS, PLUGINS_LOCK
|
from plugins import LOADED_PLUGINS, PLUGINS_LOCK
|
||||||
|
|
||||||
if not isinstance(bundle, dict):
|
if not isinstance(bundle, dict):
|
||||||
@@ -5887,7 +6023,7 @@ def import_settings(bundle: dict):
|
|||||||
)
|
)
|
||||||
|
|
||||||
warnings: list[str] = []
|
warnings: list[str] = []
|
||||||
bundle_version = bundle.get("slopsmith_version")
|
bundle_version = bundle.get("feedBack_version")
|
||||||
running = _running_version()
|
running = _running_version()
|
||||||
if bundle_version and bundle_version != running:
|
if bundle_version and bundle_version != running:
|
||||||
warnings.append(
|
warnings.append(
|
||||||
@@ -6020,7 +6156,7 @@ def import_settings(bundle: dict):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# ── Diagnostic bundle export (slopsmith#166) ──────────────────────────
|
# ── Diagnostic bundle export (feedBack#166) ──────────────────────────
|
||||||
#
|
#
|
||||||
# One-click "Export Diagnostics" in Settings produces a redacted zip
|
# One-click "Export Diagnostics" in Settings produces a redacted zip
|
||||||
# combining server logs, system info, hardware (CPU/GPU/RAM), plugin
|
# combining server logs, system info, hardware (CPU/GPU/RAM), plugin
|
||||||
@@ -6044,11 +6180,11 @@ def _diag_plugins_roots() -> list[Path]:
|
|||||||
"""Return all plugin root directories for orphan scanning.
|
"""Return all plugin root directories for orphan scanning.
|
||||||
|
|
||||||
Includes both the built-in ``plugins/`` directory and
|
Includes both the built-in ``plugins/`` directory and
|
||||||
``SLOPSMITH_PLUGINS_DIR`` when set, so user-installed plugins and
|
``FEEDBACK_PLUGINS_DIR`` when set, so user-installed plugins and
|
||||||
orphans in the external dir are reflected in the bundle.
|
orphans in the external dir are reflected in the bundle.
|
||||||
"""
|
"""
|
||||||
roots: list[Path] = []
|
roots: list[Path] = []
|
||||||
user_dir = os.environ.get("SLOPSMITH_PLUGINS_DIR", "").strip()
|
user_dir = getenv_compat("FEEDBACK_PLUGINS_DIR", "").strip()
|
||||||
if user_dir:
|
if user_dir:
|
||||||
p = Path(user_dir)
|
p = Path(user_dir)
|
||||||
if p.is_dir():
|
if p.is_dir():
|
||||||
@@ -6228,7 +6364,7 @@ def export_diagnostics(payload: dict = Body(default_factory=dict)):
|
|||||||
)
|
)
|
||||||
|
|
||||||
zip_bytes, filename, _manifest = _diag_build(
|
zip_bytes, filename, _manifest = _diag_build(
|
||||||
slopsmith_version=_running_version(),
|
feedBack_version=_running_version(),
|
||||||
config_dir=CONFIG_DIR,
|
config_dir=CONFIG_DIR,
|
||||||
dlc_dir=_get_dlc_dir(),
|
dlc_dir=_get_dlc_dir(),
|
||||||
log_file=_diag_log_file(),
|
log_file=_diag_log_file(),
|
||||||
@@ -6274,7 +6410,7 @@ def preview_diagnostics(
|
|||||||
with PLUGINS_LOCK:
|
with PLUGINS_LOCK:
|
||||||
plugins_snapshot = list(LOADED_PLUGINS)
|
plugins_snapshot = list(LOADED_PLUGINS)
|
||||||
return _diag_preview(
|
return _diag_preview(
|
||||||
slopsmith_version=_running_version(),
|
feedBack_version=_running_version(),
|
||||||
config_dir=CONFIG_DIR,
|
config_dir=CONFIG_DIR,
|
||||||
dlc_dir=_get_dlc_dir(),
|
dlc_dir=_get_dlc_dir(),
|
||||||
log_file=_diag_log_file(),
|
log_file=_diag_log_file(),
|
||||||
@@ -6298,13 +6434,72 @@ def diagnostics_hardware():
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _if_none_match_hits(header: str | None, etag: str) -> bool:
|
||||||
|
"""True if an If-None-Match header matches `etag` (weak comparison).
|
||||||
|
|
||||||
|
Handles the `*` wildcard and comma-separated lists, and ignores a weak
|
||||||
|
`W/` prefix on either side — the standard semantics for a conditional GET.
|
||||||
|
"""
|
||||||
|
if not header:
|
||||||
|
return False
|
||||||
|
bare = etag.removeprefix("W/")
|
||||||
|
for tok in header.split(","):
|
||||||
|
t = tok.strip()
|
||||||
|
if t == "*" or t.removeprefix("W/") == bare:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# Album art is served with a strong validator (an ETag on the sloppak byte
|
||||||
|
# path; FileResponse's own ETag/Last-Modified on the file paths) and revalidated
|
||||||
|
# with `no-cache`. That keeps re-scroll cheap — a conditional GET returns a
|
||||||
|
# bodyless 304 — without ever serving a stale cover. A long `immutable` max-age
|
||||||
|
# was rejected: the frontend's `?v=<mtime>` buster is only second-resolution, so
|
||||||
|
# a same-second cover rewrite would keep the URL and pin the old bytes for the
|
||||||
|
# cache lifetime. Validation cost is negligible for a localhost backend.
|
||||||
|
_ART_CACHE_HEADERS = {"Cache-Control": "no-cache"}
|
||||||
|
|
||||||
|
|
||||||
|
def _art_etag(path: Path) -> str | None:
|
||||||
|
"""Strong validator for an art file: nanosecond mtime + size (so a
|
||||||
|
same-second rewrite still changes it). None if the file can't be stat'd."""
|
||||||
|
try:
|
||||||
|
st = path.stat()
|
||||||
|
return f'"{st.st_mtime_ns}-{st.st_size}"'
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _art_conditional(etag: str | None, request: Request | None):
|
||||||
|
"""Return (headers, not_modified) for an art response. `not_modified` is
|
||||||
|
True when the client's If-None-Match already matches `etag` → caller should
|
||||||
|
return a bodyless 304. Starlette's FileResponse emits an ETag but does NOT
|
||||||
|
itself evaluate If-None-Match, so every art path routes through here to get
|
||||||
|
real conditional handling."""
|
||||||
|
headers = dict(_ART_CACHE_HEADERS)
|
||||||
|
if etag:
|
||||||
|
headers["ETag"] = etag
|
||||||
|
inm = request.headers.get("if-none-match") if request is not None else None
|
||||||
|
return headers, bool(etag) and _if_none_match_hits(inm, etag)
|
||||||
|
|
||||||
|
|
||||||
|
def _file_art_response(path: Path, media_type: str, request: Request | None):
|
||||||
|
"""FileResponse for an on-disk art file, with no-cache + ETag and a bodyless
|
||||||
|
304 when the client's validator still matches."""
|
||||||
|
headers, not_modified = _art_conditional(_art_etag(path), request)
|
||||||
|
if not_modified:
|
||||||
|
return Response(status_code=304, headers=headers)
|
||||||
|
return FileResponse(str(path), media_type=media_type, headers=headers)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/song/{filename:path}/art")
|
@app.get("/api/song/{filename:path}/art")
|
||||||
async def get_song_art(filename: str):
|
async def get_song_art(filename: str, request: Request = None):
|
||||||
"""Serve album art for a song.
|
"""Serve album art for a song.
|
||||||
|
|
||||||
Dispatches by format and returns the appropriate media type:
|
Dispatches by format and returns the appropriate media type:
|
||||||
- Sloppak: serves `cover.jpg` (or manifest-declared cover) from
|
- Sloppak: serves `cover.jpg` (or manifest-declared cover) read directly
|
||||||
the source dir as JPEG/PNG/WebP.
|
from the package (the single cover member for zip-form sloppaks — no
|
||||||
|
full unpack) as JPEG/PNG/WebP.
|
||||||
- Loose folder: serves the discovered art file directly as
|
- Loose folder: serves the discovered art file directly as
|
||||||
JPEG/PNG/WebP.
|
JPEG/PNG/WebP.
|
||||||
"""
|
"""
|
||||||
@@ -6318,27 +6513,29 @@ async def get_song_art(filename: str):
|
|||||||
if not song_path.exists():
|
if not song_path.exists():
|
||||||
return JSONResponse({"error": "not found"}, 404)
|
return JSONResponse({"error": "not found"}, 404)
|
||||||
|
|
||||||
# Sloppak path: pull cover.jpg from the source dir (manifest-declared or default).
|
# Sloppak path: read the cover (manifest-declared or default) straight from
|
||||||
|
# the package. For a zip-form sloppak this opens just the cover member —
|
||||||
|
# NOT the whole archive — so the library grid never triggers a full unpack
|
||||||
|
# of stems just to paint a thumbnail.
|
||||||
if sloppak_mod.is_sloppak(song_path):
|
if sloppak_mod.is_sloppak(song_path):
|
||||||
|
# Read the cover (cheap — single member, no full unpack) and validate by
|
||||||
|
# its CONTENT. A stat-based ETag would be wrong for directory-form
|
||||||
|
# sloppaks: editing cover.jpg in place changes the file's mtime, not the
|
||||||
|
# directory's, so a dir-stat ETag could emit a stale 304. Content hashing
|
||||||
|
# is correct for both dir- and zip-form. Raw byte Response lacks
|
||||||
|
# FileResponse's validators, so we attach the ETag + honor If-None-Match.
|
||||||
try:
|
try:
|
||||||
src = sloppak_mod.resolve_source_dir(filename, dlc, SLOPPAK_CACHE_DIR)
|
art = await asyncio.to_thread(sloppak_mod.read_cover_bytes, song_path)
|
||||||
manifest = sloppak_mod.load_manifest(song_path)
|
|
||||||
cover_rel = str(manifest.get("cover") or "cover.jpg")
|
|
||||||
cover_path = (src / cover_rel).resolve()
|
|
||||||
# Prevent escape and fall back to default name if missing.
|
|
||||||
try:
|
|
||||||
cover_path.relative_to(src.resolve())
|
|
||||||
except ValueError:
|
|
||||||
return JSONResponse({"error": "forbidden"}, 403)
|
|
||||||
if cover_path.exists() and cover_path.is_file():
|
|
||||||
mt = {
|
|
||||||
".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
|
||||||
".png": "image/png", ".webp": "image/webp",
|
|
||||||
}.get(cover_path.suffix.lower(), "image/jpeg")
|
|
||||||
return FileResponse(str(cover_path), media_type=mt)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
art = None
|
||||||
return JSONResponse({"error": "no art"}, 404)
|
if art is None:
|
||||||
|
return JSONResponse({"error": "no art"}, 404)
|
||||||
|
data, mt = art
|
||||||
|
etag = f'"{hashlib.sha1(data).hexdigest()}"'
|
||||||
|
headers, not_modified = _art_conditional(etag, request)
|
||||||
|
if not_modified:
|
||||||
|
return Response(status_code=304, headers=headers)
|
||||||
|
return Response(content=data, media_type=mt, headers=headers)
|
||||||
|
|
||||||
# Loose folder path: serve art file directly.
|
# Loose folder path: serve art file directly.
|
||||||
# song_path is already validated against DLC_DIR by _resolve_dlc_path.
|
# song_path is already validated against DLC_DIR by _resolve_dlc_path.
|
||||||
@@ -6358,7 +6555,7 @@ async def get_song_art(filename: str):
|
|||||||
".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
||||||
".png": "image/png", ".webp": "image/webp",
|
".png": "image/png", ".webp": "image/webp",
|
||||||
}.get(art_resolved.suffix.lower(), "image/jpeg")
|
}.get(art_resolved.suffix.lower(), "image/jpeg")
|
||||||
return FileResponse(str(art_resolved), media_type=mt)
|
return _file_art_response(art_resolved, mt, request)
|
||||||
return JSONResponse({"error": "no art"}, 404)
|
return JSONResponse({"error": "no art"}, 404)
|
||||||
|
|
||||||
# Custom art uploaded via /art/upload is cached as PNG under ART_CACHE_DIR;
|
# Custom art uploaded via /art/upload is cached as PNG under ART_CACHE_DIR;
|
||||||
@@ -6367,7 +6564,7 @@ async def get_song_art(filename: str):
|
|||||||
safe_name = filename.replace("/", "_").replace(" ", "_")
|
safe_name = filename.replace("/", "_").replace(" ", "_")
|
||||||
cached = art_cache / f"{safe_name}.png"
|
cached = art_cache / f"{safe_name}.png"
|
||||||
if cached.exists():
|
if cached.exists():
|
||||||
return FileResponse(str(cached), media_type="image/png")
|
return _file_art_response(cached, "image/png", request)
|
||||||
|
|
||||||
return JSONResponse({"error": "no art"}, 404)
|
return JSONResponse({"error": "no art"}, 404)
|
||||||
|
|
||||||
@@ -6880,7 +7077,7 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
|
|||||||
"audio_error": audio_error,
|
"audio_error": audio_error,
|
||||||
"tuning": arr.tuning,
|
"tuning": arr.tuning,
|
||||||
# Number of strings on the active arrangement
|
# Number of strings on the active arrangement
|
||||||
# (slopsmith-plugin-3dhighway#7). arrangement XML / archive sources
|
# (feedBack-plugin-3dhighway#7). arrangement XML / archive sources
|
||||||
# always emit `tuning` as length 6 with zero-padding for
|
# always emit `tuning` as length 6 with zero-padding for
|
||||||
# unused string slots, so `len(arr.tuning)` is unreliable
|
# unused string slots, so `len(arr.tuning)` is unreliable
|
||||||
# there; sloppak / GP-imported sources may instead carry
|
# there; sloppak / GP-imported sources may instead carry
|
||||||
@@ -6915,6 +7112,11 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
|
|||||||
and _notation_arr_id is not None
|
and _notation_arr_id is not None
|
||||||
and _notation_arr_id in loaded_slop.notation_by_id
|
and _notation_arr_id in loaded_slop.notation_by_id
|
||||||
),
|
),
|
||||||
|
# Song-level key/scale track presence (keys.json, spec §7.7) so a
|
||||||
|
# consumer can light up a key/scale display without parsing the pack.
|
||||||
|
"has_keys": bool(
|
||||||
|
is_slop and loaded_slop is not None and loaded_slop.keys is not None
|
||||||
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
# Send drum_tab when the sloppak ships one (manifest `drum_tab:` key,
|
# Send drum_tab when the sloppak ships one (manifest `drum_tab:` key,
|
||||||
@@ -6955,6 +7157,31 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
|
|||||||
sections = [{"name": s.name, "time": s.start_time} for s in song.sections]
|
sections = [{"name": s.name, "time": s.start_time} for s in song.sections]
|
||||||
await websocket.send_json({"type": "sections", "data": sections})
|
await websocket.send_json({"type": "sections", "data": sections})
|
||||||
|
|
||||||
|
# Send the song-level key/scale track (keys.json, spec §7.7) when the
|
||||||
|
# sloppak ships one. Consumers read it from the WS rather than the file,
|
||||||
|
# like drum_tab/beats/sections. The loader already sanitized the events
|
||||||
|
# (finite t, non-empty string key, sorted), so this is a direct send.
|
||||||
|
if is_slop and loaded_slop is not None and loaded_slop.keys is not None:
|
||||||
|
await websocket.send_json({
|
||||||
|
"type": "keys",
|
||||||
|
"version": int(loaded_slop.keys.get("version", 1)),
|
||||||
|
"data": loaded_slop.keys.get("events") or [],
|
||||||
|
})
|
||||||
|
|
||||||
|
# Song-level tempo + time-signature maps (song_timeline, feedpak 1.2.0),
|
||||||
|
# plus the per-chart tempo override (§6.10): the active arrangement's own
|
||||||
|
# `tempos` wins over the song-level map for this chart. Both are
|
||||||
|
# pre-sanitized by the loader / arrangement_from_wire, so they stream
|
||||||
|
# directly. Consumers read these rather than the file.
|
||||||
|
_song_tempos = loaded_slop.tempos if (is_slop and loaded_slop is not None) else None
|
||||||
|
_tempos_out = getattr(arr, "tempos", None) or _song_tempos
|
||||||
|
if _tempos_out:
|
||||||
|
await websocket.send_json({"type": "tempos", "data": _tempos_out})
|
||||||
|
_time_sigs = (loaded_slop.time_signatures
|
||||||
|
if (is_slop and loaded_slop is not None) else None)
|
||||||
|
if _time_sigs:
|
||||||
|
await websocket.send_json({"type": "time_signatures", "data": _time_sigs})
|
||||||
|
|
||||||
# Send notation data when the sloppak ships it for the active arrangement.
|
# Send notation data when the sloppak ships it for the active arrangement.
|
||||||
# Slots after sections (cursor sync depends on beats, which precede sections)
|
# Slots after sections (cursor sync depends on beats, which precede sections)
|
||||||
# and before anchors — per docs/sloppak-spec.md §5.3.
|
# and before anchors — per docs/sloppak-spec.md §5.3.
|
||||||
@@ -7271,8 +7498,48 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
|
|||||||
"data": [],
|
"data": [],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Teaching mark sd (§6.2.2): derive each note's scale degree from the
|
||||||
|
# active key (keys.json §7.7) + its sounding pitch (tuning[string] +
|
||||||
|
# fret), only when the author didn't author one. Display/teaching only —
|
||||||
|
# NEVER feeds grading. Notes whose string/fret has no tuning entry, or
|
||||||
|
# that have no active key, or whose key name is unparseable, stay unset.
|
||||||
|
_key_events = (
|
||||||
|
(loaded_slop.keys.get("events") or [])
|
||||||
|
if (is_slop and loaded_slop is not None and loaded_slop.keys is not None)
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
_key_times = [e["t"] for e in _key_events]
|
||||||
|
_key_tonics = [key_to_tonic_pc(e.get("key")) for e in _key_events]
|
||||||
|
_tuning = arr.tuning or []
|
||||||
|
# Hoist the open-string base out of the per-note loop: arr.tuning holds
|
||||||
|
# per-string OFFSETS from standard, so the sounding pitch is
|
||||||
|
# base[string] + offset + capo + fret (matches the tuner / open-string
|
||||||
|
# labels). arrangement_string_count is O(notes), so compute once here.
|
||||||
|
_base = base_open_string_midis(
|
||||||
|
arrangement_string_count(arr), "bass" in (arr.name or "").lower())
|
||||||
|
_capo = int(getattr(arr, "capo", 0) or 0)
|
||||||
|
|
||||||
|
def _fill_scale_degree(wire: dict, n, t: float) -> None:
|
||||||
|
# Author-provided sd wins — note_to_wire already emitted it.
|
||||||
|
if "sd" in wire or not _key_times:
|
||||||
|
return
|
||||||
|
idx = bisect.bisect_right(_key_times, t) - 1
|
||||||
|
if idx < 0:
|
||||||
|
return
|
||||||
|
tonic = _key_tonics[idx]
|
||||||
|
if tonic is None:
|
||||||
|
return
|
||||||
|
midi = pitch_from_base(_base, _capo, _tuning, n.string, n.fret)
|
||||||
|
if midi is None:
|
||||||
|
return
|
||||||
|
wire["sd"] = scale_degree_for_pitch(midi, tonic)
|
||||||
|
|
||||||
# Send notes in chunks
|
# Send notes in chunks
|
||||||
notes = [note_to_wire(n) for n in arr.notes]
|
notes = []
|
||||||
|
for n in arr.notes:
|
||||||
|
w = note_to_wire(n)
|
||||||
|
_fill_scale_degree(w, n, n.time)
|
||||||
|
notes.append(w)
|
||||||
# Send in chunks of 500
|
# Send in chunks of 500
|
||||||
for i in range(0, len(notes), 500):
|
for i in range(0, len(notes), 500):
|
||||||
await websocket.send_json({
|
await websocket.send_json({
|
||||||
@@ -7282,7 +7549,12 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
|
|||||||
})
|
})
|
||||||
|
|
||||||
# Send chords
|
# Send chords
|
||||||
chords = [chord_to_wire(c) for c in arr.chords]
|
chords = []
|
||||||
|
for c in arr.chords:
|
||||||
|
cw = chord_to_wire(c)
|
||||||
|
for cn, cnw in zip(c.notes, cw.get("notes", [])):
|
||||||
|
_fill_scale_degree(cnw, cn, c.time)
|
||||||
|
chords.append(cw)
|
||||||
for i in range(0, len(chords), 500):
|
for i in range(0, len(chords), 500):
|
||||||
await websocket.send_json({
|
await websocket.send_json({
|
||||||
"type": "chords",
|
"type": "chords",
|
||||||
@@ -7299,7 +7571,7 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
|
|||||||
})
|
})
|
||||||
|
|
||||||
# Per-phrase difficulty data for the master-difficulty slider
|
# Per-phrase difficulty data for the master-difficulty slider
|
||||||
# (slopsmith#48). Only sent when the source chart had multiple
|
# (feedBack#48). Only sent when the source chart had multiple
|
||||||
# `<level>` tiers — single-level charts (GP converter, older
|
# `<level>` tiers — single-level charts (GP converter, older
|
||||||
# sloppaks without phrase data) produce arr.phrases=None, and the
|
# sloppaks without phrase data) produce arr.phrases=None, and the
|
||||||
# frontend treats the missing message as "slider disabled".
|
# frontend treats the missing message as "slider disabled".
|
||||||
@@ -7410,9 +7682,9 @@ app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
|||||||
def index():
|
def index():
|
||||||
# fee[dB]ack v0.3.0: the v3 shell is now the DEFAULT at `/`. The classic v2
|
# fee[dB]ack v0.3.0: the v3 shell is now the DEFAULT at `/`. The classic v2
|
||||||
# UI remains fully available as a fallback — opt back in with
|
# UI remains fully available as a fallback — opt back in with
|
||||||
# SLOPSMITH_UI=v2 (or =legacy), or hit the dedicated /v2 route below (which
|
# FEEDBACK_UI=v2 (or =legacy), or hit the dedicated /v2 route below (which
|
||||||
# serves it regardless of the env var).
|
# serves it regardless of the env var).
|
||||||
if os.environ.get("SLOPSMITH_UI") in ("v2", "legacy"):
|
if getenv_compat("FEEDBACK_UI") or getenv_compat("FEEDBACK_UI") in ("v2", "legacy"):
|
||||||
return FileResponse(str(STATIC_DIR / "index.html"))
|
return FileResponse(str(STATIC_DIR / "index.html"))
|
||||||
return FileResponse(str(STATIC_DIR / "v3" / "index.html"))
|
return FileResponse(str(STATIC_DIR / "v3" / "index.html"))
|
||||||
|
|
||||||
@@ -7427,5 +7699,5 @@ def index_v3():
|
|||||||
@app.get("/v2")
|
@app.get("/v2")
|
||||||
def index_v2():
|
def index_v2():
|
||||||
# Always serve the classic v2 UI, independent of the env var, so the
|
# Always serve the classic v2 UI, independent of the env var, so the
|
||||||
# fallback is reachable without flipping SLOPSMITH_UI.
|
# fallback is reachable without flipping FEEDBACK_UI.
|
||||||
return FileResponse(str(STATIC_DIR / "index.html"))
|
return FileResponse(str(STATIC_DIR / "index.html"))
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# Spec 012 — MIDI-Input Control-Plane Capability Domain
|
||||||
|
|
||||||
|
**Status:** active (control-plane slice) · **Issues:** #873 (impl), #880 (this spec) · **Base:** `release/v0.3.0`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
`midi-input` is a **core-owned provider-coordinator** capability domain for MIDI
|
||||||
|
device discovery, selection, and open/close session lifecycle — the MIDI analog
|
||||||
|
of `audio-input` (spec 006). It gives every MIDI consumer in FeedBack (the
|
||||||
|
`input_setup` onboarding wizard, the `piano`/keys and `drums` plugins, and — as
|
||||||
|
a follow-up — note-detection's Web-MIDI provider) **one device-access boundary**:
|
||||||
|
one permission prompt, one source list, one redaction boundary.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
Today each MIDI consumer calls `navigator.requestMIDIAccess()` privately
|
||||||
|
(piano, drums, plugin-midi, note-detection's `midi` provider kind), so there is
|
||||||
|
no shared source list, no single permission prompt, and no common redaction of
|
||||||
|
device labels. The onboarding input-setup step (#874/#876/#877) needs a single
|
||||||
|
governed surface to pick and verify a MIDI device per instrument.
|
||||||
|
|
||||||
|
## Why not reuse `audio-input`
|
||||||
|
|
||||||
|
`audio-input`'s source/`source.open` contract is audio-frame-centric:
|
||||||
|
`channelSummary`/`channelCount`/`channelShape`, `requiredChannelShape`, and
|
||||||
|
redaction keyed to audio handles/buffers/samples. MIDI carries discrete messages
|
||||||
|
and has no channel shape. Folding MIDI in would overload the audio contract and
|
||||||
|
its redaction boundary. A sibling domain keeps both contracts clean and lets
|
||||||
|
each evolve independently — the same reasoning that made `audio-input` and
|
||||||
|
`audio-monitoring` siblings rather than one domain.
|
||||||
|
|
||||||
|
## Why core-owned (not plugin-owned)
|
||||||
|
|
||||||
|
An input control plane outlives any one feature; `audio-input` is
|
||||||
|
`core.audio.session`-owned, not owned by a feature plugin. If `input_setup`
|
||||||
|
owned `midi-input`, the domain's lifetime would be coupled to the wizard, and
|
||||||
|
migrating ownership later (every consumer, persistence key, diagnostics schema
|
||||||
|
references the owner) is costly. The domain is `core.midi-input`.
|
||||||
|
|
||||||
|
## Contract
|
||||||
|
|
||||||
|
- **Owner:** `core.midi-input`, kind `provider-coordinator`, safety `sensitive`.
|
||||||
|
- **Public commands:** `inspect`, `list-sources`, `discover`, `select-source`,
|
||||||
|
`open-source`, `close-source`.
|
||||||
|
- **Provider operations:** `source.enumerate`, `source.describe`, `source.open`,
|
||||||
|
`source.close`.
|
||||||
|
- **Events:** `provider-registered`, `provider-unregistered`,
|
||||||
|
`availability-changed`, `sources-changed`, `source-selected`, `source-opened`,
|
||||||
|
`source-closed`.
|
||||||
|
|
||||||
|
### Sources & identity
|
||||||
|
|
||||||
|
Providers register source summaries with `providerId`, a stable `sourceId`, a
|
||||||
|
derived **redaction-safe** `logicalSourceKey` (`providerId::sourceId`),
|
||||||
|
`kind: "midi"`, a label, and `availability`. Persistence and diagnostics use the
|
||||||
|
`logicalSourceKey`, never the human device label.
|
||||||
|
|
||||||
|
### Permission model (Web-MIDI nuance)
|
||||||
|
|
||||||
|
`requestMIDIAccess()` gates the **whole input list**, so **`discover` is the
|
||||||
|
permission boundary** (not `open-source`, as it is for audio). `inspect` /
|
||||||
|
`list-sources` / `select-source` are **prompt-free** and never request access.
|
||||||
|
`discover` records `denied` / `unavailable` outcomes; `open-source` attaches a
|
||||||
|
shared listener session to an already-discovered source and never re-prompts.
|
||||||
|
|
||||||
|
### Sessions
|
||||||
|
|
||||||
|
One shared open session per source across requesters (refcounted); the provider
|
||||||
|
receives `source.close` only after the last requester releases. Live MIDI
|
||||||
|
message delivery (for the "play a note / hit a pad" calibration check) is exposed
|
||||||
|
to in-page consumers via the public `window.feedBack.midiInput` session handle
|
||||||
|
**only** — never as raw capability events or in diagnostics.
|
||||||
|
|
||||||
|
### Persistence & redaction
|
||||||
|
|
||||||
|
Selected source persists under `feedBack.midiInput.selectedLogicalSourceKey`.
|
||||||
|
Diagnostics (`feedBack.midi_input.diagnostics.v1`) carry provider ids, source
|
||||||
|
ids/keys/kinds/availability, the selected key, and open-session keys; device
|
||||||
|
**labels are redacted** and **no raw MIDI messages** are ever included.
|
||||||
|
|
||||||
|
## Split from `midi-control`
|
||||||
|
|
||||||
|
The reserved `midi-control` domain is narrowed to **control mappings only**
|
||||||
|
(CC/pitchbend/note → action routing) and will consume `midi-input` for device
|
||||||
|
access. This spec carves out the device control plane so `midi-control` can stay
|
||||||
|
mappings-only (#882).
|
||||||
|
|
||||||
|
## Consumers (separate issues)
|
||||||
|
|
||||||
|
- `input_setup` onboarding wizard — keys/drums device pick + verify (#876/#877).
|
||||||
|
- `piano` / `drums` plugins — consume `midi-input` instead of private
|
||||||
|
`requestMIDIAccess()` (via the sub-flow issues; legacy retired through bridges).
|
||||||
|
- note-detection's Web-MIDI provider migrates onto `midi-input` (#881).
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
- Owner registers; appears in the Capability Inspector with the commands above.
|
||||||
|
- `discover` is the only command that triggers `requestMIDIAccess()`;
|
||||||
|
`inspect`/`list-sources`/`select-source` never prompt.
|
||||||
|
- Selection persists across reload by `logicalSourceKey`.
|
||||||
|
- Diagnostics contain no device labels or raw MIDI messages.
|
||||||
|
- A consumer can `discover` → `select-source` → `open-source` → receive live
|
||||||
|
note-on for the calibration check → `close-source` (session refcount releases).
|
||||||
|
|
||||||
|
## Out of scope (follow-ups)
|
||||||
|
|
||||||
|
- `midi-control` mapping/routing domain (#882).
|
||||||
|
- note-detection provider migration onto `midi-input` (#881).
|
||||||
|
- Retiring per-plugin `requestMIDIAccess()` in piano/drums via compatibility
|
||||||
|
bridges (tracked with the sub-flow issues).
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# Spec 013 — `midi-control` Mappings Domain (the midi-input/midi-control split)
|
||||||
|
|
||||||
|
**Status:** documented future contract (RESERVED — not in the runtime graph) ·
|
||||||
|
**Issue:** #882 · **Depends on:** spec 012 (`midi-input`, delivered) · **Base:** `feedback/main`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
`midi-control` is the planned sibling of `midi-input`: it owns **MIDI control
|
||||||
|
mappings** — routing CC / pitchbend / note messages to *semantic actions* (drum
|
||||||
|
lane, transport command, effect parameter, etc.) — and **consumes `midi-input`**
|
||||||
|
for device access. It does **not** discover, select, or open devices; that is
|
||||||
|
`midi-input`'s job (spec 012, delivered).
|
||||||
|
|
||||||
|
This spec records the **split** so the boundary is unambiguous and the contract
|
||||||
|
is ready for whoever builds the runtime slice. Per project governance
|
||||||
|
(`docs/capability-safety-matrix.md`, `docs/capability-roadmap.md`), a future
|
||||||
|
domain stays **documentation-only until a PR ships its host workflow, a concrete
|
||||||
|
consumer, and tests** — so `midi-control` remains `RESERVED` in
|
||||||
|
`static/capabilities.js` `RESERVED_FUTURE_DOMAINS` until then. This spec does not
|
||||||
|
register a runtime domain.
|
||||||
|
|
||||||
|
## Why split it out
|
||||||
|
|
||||||
|
Before `midi-input` existed, "MIDI" meant two conflated concerns: getting bytes
|
||||||
|
from a device, and mapping those bytes to actions. The reserved `midi-control`
|
||||||
|
entry originally covered both. With `midi-input` delivered as the device control
|
||||||
|
plane, `midi-control` is narrowed to **mappings only** — mirroring how
|
||||||
|
`audio-input` (devices) is separate from `audio-effects`/`audio-mix` (what you do
|
||||||
|
with the signal). Keeping them separate prevents a future god-domain and lets the
|
||||||
|
device plane stabilize independently of mapping semantics.
|
||||||
|
|
||||||
|
## Boundary (normative)
|
||||||
|
|
||||||
|
- **`midi-input` owns:** device discovery (`discover`), source list, selection,
|
||||||
|
open/close sessions, the Web-MIDI permission boundary, redacted device
|
||||||
|
diagnostics. The raw MIDI message stream is delivered to in-page consumers via
|
||||||
|
its session handle.
|
||||||
|
- **`midi-control` will own:** named mappings from MIDI events (note / CC /
|
||||||
|
pitchbend, optionally channel-scoped) to semantic actions, mapping persistence,
|
||||||
|
active-mapping selection, and "learn" capture. It **consumes** a `midi-input`
|
||||||
|
session for the live stream; it never calls `requestMIDIAccess` or enumerates
|
||||||
|
devices.
|
||||||
|
|
||||||
|
## Proposed contract (for the future implementation slice)
|
||||||
|
|
||||||
|
- **Owner:** `core.midi-control` (or a first-party MIDI-control plugin),
|
||||||
|
`multi-provider`, safety `sensitive`.
|
||||||
|
- **Commands:** `list-mappings`, `get-mapping`, `set-mapping`, `delete-mapping`,
|
||||||
|
`activate-mapping`, `inspect`.
|
||||||
|
- **Mapping shape (sketch):** `{ id, label, trigger: { type: 'note'|'cc'|'pitchbend',
|
||||||
|
number?, channel? }, action: { domain?, command?|actionId, params? } }`.
|
||||||
|
- **Learn mode:** open a `midi-input` session, capture the next matching event,
|
||||||
|
and bind it to the pending action (the per-plugin "learn" UIs in drums today
|
||||||
|
are the reference behaviour to generalise).
|
||||||
|
- **Diagnostics:** `feedBack.midi_control.diagnostics.v1` — mapping summaries +
|
||||||
|
bounded recent activations; **no raw MIDI streams, no device labels**.
|
||||||
|
|
||||||
|
## Intended consumers (promotion trigger)
|
||||||
|
|
||||||
|
The domain should be promoted out of RESERVED when a concrete consumer needs
|
||||||
|
shared mappings, e.g.:
|
||||||
|
- the generic **MIDI control plugin** (`feedback-plugin-midi`) — today an ad-hoc
|
||||||
|
event→action mapper; the canonical first adopter.
|
||||||
|
- **drums** note→lane mapping + "learn mode" (`feedback-plugin-drums`,
|
||||||
|
`feedback-plugin-drum-highway-3d`) — currently per-plugin; could adopt
|
||||||
|
`midi-control` to share mapping logic once the contract is proven.
|
||||||
|
|
||||||
|
Until such a consumer-driven slice exists (with host workflow + tests), this
|
||||||
|
remains a documented contract only.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Any runtime registration / handlers (governance: no premature domain).
|
||||||
|
- Migrating the drums/keys per-plugin mapping now — deferred to the consumer slice.
|
||||||
|
- The device plane — owned by `midi-input` (spec 012, done).
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user