Commit Graph
160 Commits
Author SHA1 Message Date
a222b45c02 refactor(app): carve the viz layer out of app.js — and delete the loader seam (R3a) (#880)
static/js/viz.js (770 lines) — the viz picker, renderer selection, Auto-match,
the WebGL2 probe, the 3D-promotion nag, the notation hints. Bodies VERBATIM.
app.js 11,603 → 10,857.

THE SEAM IS GONE. #878's plugin-loader needed configurePluginLoader({
populateVizPicker }) purely because _populateVizPicker lived in app.js and
importing app.js would have closed a cycle. viz.js is a LEAF — it imports NOTHING
— so plugin-loader now imports _populateVizPicker straight from it. The _host
object, the configure function, its loud-default guard, and the wiring line in
app.js are all deleted. The second carve simplifies the first.

  app.js -> { plugin-loader, viz }
  plugin-loader -> viz
  viz -> (nothing)

NOT A PURE MOVE — one listener block had to be SPLIT. app.js had a single
top-level `if (window.feedBack) { … }` registering four handlers, and only two
were viz. song:loaded / arrangement:changed / song:ready (the mastery slider)
stay in app.js and now call the imported _autoMatchViz / _maybeShowNotationViewHint.
The viz:reverted handler MOVES, because it REASSIGNS _cancelPendingAutoLabel and
an imported binding is read-only — `_cancelPendingAutoLabel = null` would throw if
the listener stayed behind while the state moved.

ORDER CHECKED, NOT ASSUMED: viz.js's song:ready listener now registers BEFORE
app.js's own (imports evaluate first). Safe — _pendingPromotionNag is only ever
set inside _populateVizPicker, which runs at boot/plugin-refresh, never from
inside the other song:ready handler, so the two are independent.

VERIFIED — the listeners are the risk here, so they were DRIVEN, not just booted.
A/B against origin/main in two browsers:
  * viz picker: 6 options (auto|default|venue|drum_highway_3d|keys_highway_3d|
    highway_3d), selected highway_3d, Auto label — IDENTICAL. This alone proves
    plugin-loader's direct import of viz.js works.
  * emit('viz:reverted') -> picker resets to default, localStorage resets to
    default, the warning logs — IDENTICAL. The MOVED listener fires.
  * emit('song:ready') -> mastery slider enables, no throw — IDENTICAL. The SPLIT
    listener still does both halves.
  * plugin screens, module injections, 37 capability participants — IDENTICAL.
  * zero console/page errors on both.

pytest 2396, node 1038/1038, ESLint 0, tailwind-fresh clean. no-cycle re-bitten on
the 3-module graph (viz -> plugin-loader fails).

Codex preflight raised a [P2] claiming viz.js's top-level bus guards would be
false because "app.js only creates the event bus later" — FALSE POSITIVE. app.js
does not create the bus; capabilities.js does, from its own <script type="module">
at index.html:122, and module scripts execute in document order, so the bus exists
long before app.js's import graph evaluates. Instrumented the setter: by viz.js's
turn `window.feedBack.on` is already a function, and the viz:reverted listener is
provably attached (firing it resets the picker). The ordering is also enforced by
test_app_shell_loads_capability_registry_before_app_runtime.

Harnesses: 5 tests retargeted to viz.js across legacy_shim_hits, venue_scene_3d,
venue_viz (each SPLIT — their non-viz tests still read app.js).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 18:41:52 +02:00
5b904706d0 feat(audio): loopback feeder mode + static no-cache — all app audio under exclusive/ASIO (#877)
* feat(audio): route feedpak full-mix natively under exclusive output

Song playback runs through the renderer, which WASAPI-exclusive (and
ASIO) output silences. Route single-mix feedpaks (stem-less
original_audio packs AND single-stem packs) onto the engine's backing
transport when the output device type is exclusive-style, and migrate
back to HTML5 when it isn't. Extends /api/audio-local-path to resolve
/api/sloppak/.../file/... URLs via the same containment guards as
serve_sloppak_file. Multi-stem packs stay on the WebAudio path
(Phase 2). Includes [feedpak-route] transition-gated diagnostics
logging.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(audio): renderer-bus feeder — mix renderer song audio into engine output (Phase 2)

Under exclusive-style output the native backing transport (Phase 1, #824)
carries loose /audio/ songs and feedpak full-mixes, but not the stems
plugin's multi-stem WebAudio graph or tracks JUCE rejected. The feeder taps
the renderer-side master with an AudioWorklet, re-points the owning
AudioContext at a null sink so it keeps rendering without a device, and
pushes ~10 ms chunks over IPC into the desktop engine's renderer bus
(feedBack-desktop#90 follow-up). Inert in the Docker sphere and in shared
mode. Validated by the fix12 tester spike: null-sink rendering works,
clocks hold, no overflow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(diag): --debug ASIO routing diagnostics in static bundle

Gated on window.feedBackDesktop.audio.debugEnabled() (desktop --debug);
inert in the Docker sphere and normal desktop runs.

- [asio-diag] getCurrentDevice= full device object on outputType change
  (catches ASIO drivers reporting a non-'ASIO' type name)
- [asio-diag] renderer-bus: full feeder decision vector, change-gated
  (running/exclusive/stems/juceMode/elementSong/want/mode)
- [asio-diag] setSink: every sink flip with ctx state + rate

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(audio): loopback feeder mode — all app audio under exclusive/ASIO

Tester-confirmed (2026-07-11 log): song previews and other
plugin-private audio bypass the per-surface feeder taps and leak to the
default WASAPI device under ASIO output. Also confirmed: the element
capture path poisons itself when highway_3d already owns #audio's
one-shot MediaElementSource (InvalidStateError with _elCtx assigned
pre-throw → TypeError every later tick).

- New preferred mode 'loopback': one getDisplayMedia frame-audio capture
  (desktop main answers with the app's own frame) covers song, previews,
  and UI sounds for the whole exclusive session — engages even with no
  song loaded. Local playback silenced via suppressLocalAudioPlayback,
  page-mute IPC fallback otherwise.
- Sticky fallback to the existing stems/element surface modes when
  capture is unavailable (old desktop main, denied, Docker sphere).
- Element capture: assign module state only after the whole chain
  succeeds; close the context on failure — collision now retries clean.
- Failed engage now disables the bus and tears down loopback (no more
  bus-enabled-with-no-producer stranding).
- Tests: 12 (5 new — loopback engage/preference/mute-fallback/sticky
  fallback, collision retry).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(audio): loopback feeder mode — all app audio under exclusive/ASIO

Tester-confirmed (2026-07-11 log): song previews and other
plugin-private audio bypass the per-surface feeder taps and leak to the
default WASAPI device under ASIO output. Also confirmed: the element
capture path poisons itself when highway_3d already owns #audio's
one-shot MediaElementSource (InvalidStateError with _elCtx assigned
pre-throw → TypeError every later tick).

- New preferred mode 'loopback': one getDisplayMedia frame-audio capture
  (desktop main answers with the app's own frame) covers song, previews,
  and UI sounds for the whole exclusive session — engages even with no
  song loaded. Local playback silenced via suppressLocalAudioPlayback,
  page-mute IPC fallback otherwise.
- Sticky fallback to the existing stems/element surface modes when
  capture is unavailable (old desktop main, denied, Docker sphere).
- Element capture: assign module state only after the whole chain
  succeeds; close the context on failure — collision now retries clean.
- Failed engage now disables the bus and tears down loopback (no more
  bus-enabled-with-no-producer stranding).
- Tests: 12 (5 new — loopback engage/preference/mute-fallback/sticky
  fallback, collision retry).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(audio): close loopback capture context on teardown (release tap worklet)

The loopback context was reused across engages (_lbCtx || new), but teardown
only stopped the stream + deactivated the tap — never closing the context or
detaching the worklet node. Each exclusive<->shared switch orphaned a live
tap worklet on the long-lived context. Use a fresh context per session and
close it on disengage. Adds a test asserting the context is closed on teardown.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(diag): install-time + uncaught-error diagnostics for the reroute chain

2026-07-11 tester log showed the routing watcher and renderer-bus feeder
never installed (zero [feedpak-route]/[renderer-bus] lines) plus an
uncaught SyntaxError with no source location — nothing in the log said
why. New:

- global error/unhandledrejection tap logging message + filename:line:col
  (error events carry the location even for parse errors in other scripts)
- explicit install / NOT-installed lines for watcher and feeder (incl.
  loopback capability probe)
- DOMException detail (name/message/stack head) in the feeder retry warn
  — the console-message forward stringified it to [object DOMException]

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(static): force conditional revalidation on /static (Cache-Control: no-cache)

Without Cache-Control Chromium's heuristic freshness (10% of file age)
serves /static/app.js from disk cache for hours-to-days without
revalidating. Desktop consequence: a new build's window ran the previous
build's app.js — the 2026-07-11 ASIO investigation traced 'routing
watcher never installed' + a stems module-plugin SyntaxError to exactly
this (stale loader predating scriptType support). no-cache keeps caching
but revalidates via ETag — unchanged files still cost only a 304.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(diag): gate install-time + uncaught-error [asio-diag] lines on --debug

The error tap and install lines from the previous diag commit were
unconditional. Now: error/rejection taps check _asioDiagEnabled() at
event time; install lines log deferred once the async debugEnabled()
resolves true. The NOT-installed anomaly lines stay bridge-gated
(window.feedBackDesktop present) instead — a broken bridge can't deliver
the debug flag, they fire at most once, and only in the broken state
they exist to witness. Docker sphere: fully silent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Byron Gamatos <xasiklas@gmail.com>
2026-07-11 18:22:25 +02:00
38772f604a refactor(app): carve the plugin loader out of app.js into static/js/ (R3a) (#878)
The first carve, and deliberately the riskiest: app.js IS the plugin loader (the
R0 host rails), so it goes first while the module graph is still one edge deep.

static/js/plugin-loader.js (829 lines) — bodies VERBATIM. app.js 12,217 → 11,439.
Core's first `static/js/` module, exactly as constitution II anticipates.

CLOSURE (measured with acorn, not regex — brace-matching stripped source drifted):
the block at app.js:11246-12031 is contiguous and self-contained. It needs only
TWO things from the rest of app.js, and exports only TWO:
  exports: loadPlugins (the window contract), bootstrapPluginsAndUi (boot)
  inbound: window.showScreen — already the public host contract (constitution II),
           so it is called through `window`, not re-coupled as an import
           _populateVizPicker — injected via configurePluginLoader()

WHY A SEAM, NOT AN IMPORT. plugin-loader must not import app.js: app.js imports
it, so that would close a cycle. I checked whether _populateVizPicker could just
move into the module instead (which would delete the seam entirely) — it drags 9
further symbols (_canRun3D, _autoMatchViz, _showPromotionNag, …), i.e. a whole
viz cluster. That is its own carve, so the seam stays.

THE SEAM'S DEFAULT IS LOUD, ON PURPOSE. A no-op stub is the classic silent
failure for this pattern (see the editor's setHostHooks trap, hit twice): drop the
wiring call and the loader keeps working while the viz picker quietly stops
refreshing — no test, no boot check says a word. The default now console.errors,
so the smoke harness catches it. VERIFIED BY BITE TEST: removing
configurePluginLoader() from app.js surfaces
"[plugin-loader] host seam not configured" at boot. The seam IS exercised on the
plugin-startup path, so an unwired hook cannot pass silently.

no-cycle is now LIVE on core's own graph for the first time. eslint.config.js
gains `static/app.js` + `static/js/**` to the module block — app.js now `import`s,
so parsing it as a script would be a syntax error. VERIFIED BY BITE TEST: making
plugin-loader import app.js back fails with "Dependency cycle detected".

HARNESSES (the R3a note said budget one conversion per carve — it was five):
retargeted capability_inspector_nav, plugin_hydration_wipe,
plugin_loader_script_type, plugin_style_injection, legacy_shim_hits (SPLIT — one
test needs the loader, one still needs app.js) + test_plugin_runtime_idempotence.
legacy_shim_hits was missed by a symbol-name grep because it greps for a code
STRING; only the failing run found it. test_capability_events' NEGATIVE asserts
now span app.js + the loader — carving code out of app.js would otherwise make
them vacuous instead of failing.

VERIFIED: A/B against origin/main in two browsers — mounted plugin screens, 14
loaded plugin scripts, the 3 module plugins injected as <script type="module">,
37 capability participants, 14 shims, window.loadPlugins: IDENTICAL, zero
console/page errors on both. /static/js/plugin-loader.js serves 200; R0 rails
intact (src/main.js 200, conditional GET 304, script_type passthrough).
pytest 2396, node 1032/1032, ESLint 0, Codex 0.

Codex preflight caught a REAL [P1] first pass: static/js/plugin-loader.js was
untracked, so a checkout would have served an app.js importing a nonexistent
module — a failed static import kills the whole module and every window handler
with it. Now tracked.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 18:18:00 +02:00
92c86f5393 refactor(ui): load app.js as an ES module (R3a) (#876)
One attribute. #871/#872/#874/#875 exist to make this line safe.

app.js's 385 top-level `function` declarations stop being implicit `window`
properties: 87 stay reachable via the explicit contract (#874's Object.assign
block + the 47 pre-existing `window.X = X` assignments), and 298 become
module-private. Verified NO unexposed name is read from outside app.js.

Strict mode (modules are always strict) checked ahead of the flip: app.js parses
clean as `sourceType: module` (no octal, dup params, `with`), and has no implicit
globals, no `eval`/`new Function`, no top-level `this`. `registerShortcut` is
called bare at 15 top-level sites but is assigned at `window.registerShortcut`
(app.js:10387) before its first call (10648), and a bare identifier in a module
still resolves through the global object — verified `typeof
window.registerShortcut === 'function'` in the browser.

HARD GATE — app.js IS the plugin loader:
  - /api/plugins script_type passthrough: editor/stems/studio = "module"
  - /api/plugins/stems/src/main.js -> 200; conditional GET -> 304 (live-edit ETag)
  - deep graph: stems/src/transport.js, editor/src/state.js -> 200
  - window.loadPlugins present; 5 plugin screens mount; the 3 migrated plugins
    injected as <script type="module">
  - 37 capability participants, 14 compatibility shims, bus + capabilities v1

Every one of the shell's 336 inline handlers resolves on window under module
scope, and the A-Z rail / pagination execute 6/6 with no ReferenceError. A/B
against origin/main: the ONLY unresolvable handler is `editorToggleStemMixer`,
which is equally broken on main (a dead handler in the editor plugin — not
defined anywhere in its source; pre-existing, flagged separately).

Codex preflight raised a [P1] claiming restartCurrentSong / requestExitSong /
editRegionInEditor / returnToEditorFromHighway would ReferenceError — FALSE
POSITIVE. It scanned only #874's new Object.assign block and missed app.js's 47
scattered `window.X = X` assignments; all four are at app.js:7086/7204/8492/8511
and all four resolve as `function` in the browser with app.js loaded as a module.

pytest 2396, node 1032/1032, ESLint 0 errors, tailwind-fresh clean.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 17:38:33 +02:00
c223ace419 refactor(ui): load the capabilities as ES modules (R3a) (#875)
ship-ci / ci (push) Waiting to run
The 12 capability <script> tags become type="module". No JS changes — the
capability scripts already self-register on the window.feedBack bus,
version-negotiate (`capabilities.version !== 1` → bail), and self-guard for
idempotency. They never import or call app.js; it is pure pub/sub.

Verified they export nothing by name: no top-level declaration in
capabilities.js or capabilities/*.js is read by any other script, so losing
global scope costs nothing.

This is the first REAL exercise of the ordering fix from #872. A module defers to
after HTML parse, so the capabilities now execute AFTER the document is parsed —
while app.js still calls `window.feedBack.on(...)` at its top level. That only
works because #872 put every classic script into the same deferred queue, where
document order IS execution order: capabilities.js (line 122) still runs before
app.js (line 1237). Had app.js stayed a plain classic script it would have run
during parse, hit a bare `{}`, and died on `.on is not a function`.

A/B against origin/main, 11 probes — capabilities.version, registered
participants (37), compatibility shims (14), the bus, workingTuning, theme,
setViz/showScreen/playSong, mounted plugin screens: IDENTICAL, zero console/page
errors on both. 12 module tags served and executed; pytest 2396, node 1032/1032,
ESLint 0, Codex 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 17:15:38 +02:00
ff7e855e35 refactor(app): make app.js's window contract explicit — 66 names (R3a) (#874)
app.js is a classic script, so each of its 385 top-level `function foo()` decls
is implicitly a property of `window`. As an ES module it will not be — module
scope is not global scope — and every name reached from outside this file would
silently vanish. This adds the explicit `window.*` assignments BEFORE the flip.

Provably a NO-OP: all 66 are top-level function declarations, so while app.js is
still a classic script `Object.assign(window, {...})` only re-assigns what
`window` already has. That is what makes it safe to land on its own, ahead of
the flip that needs it.

The consumers are wider than the inline handlers in index.html:
  - inline on*= handlers in static/v3/index.html
  - on*= handlers app.js BUILDS inside template literals (goFavPage,
    updatePlugin, hideScanBanner, ...) — they resolve against window at CLICK
    time, but live in a JS string, so scanning the HTML alone never finds them
  - static/v3/*.js (showScreen alone has 17 consumers), capabilities
  - feedback-desktop and the external plugin repos — easy to miss, they live in
    other repos and no core test covers them
  - capabilities/visualization.js reads window.setViz behind a `typeof` guard,
    so losing it DEGRADES IN SILENCE rather than throwing

Constitution II names window.playSong / window.showScreen / window.feedBack as
the public extension contract, so this is an obligation, not a convenience.

FOUR names are invisible to every static tool. app.js:2156-2157 picks the
handler NAME at runtime —
    const letterFn = favoritesOnly ? 'filterFavTreeLetter' : 'filterTreeLetter';
— and interpolates it into `onclick="${letterFn}('A')"`. The names exist only
inside string literals, so ESLint, no-undef, and any grep for `onclick="fn` all
miss them. They are the library A-Z rail and its pagination: drop one and those
buttons throw at click time and nowhere else.

New tests/js/window_contract.test.js scrapes the HTML's handlers AND app.js's
template-literal handlers, and pins the 4 runtime-composed names by hand.
Verified to BITE: dropping showScreen, goTreePage, or setViz each fails it with
the right message.

On-device: 28 A-Z rail buttons render with their real onclick sources
(filterTreeLetter('A'), ...) and 8/8 execute with no ReferenceError; all 66
names resolve on window in the browser.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 17:09:18 +02:00
4b4c156fce refactor(ui): defer every classic script, keep boot() on DOMContentLoaded (R3a) (#872)
Puts every external `<script>` in the v3 shell into the deferred queue, and
keeps each script's boot() firing at DOMContentLoaded exactly as it does today.
Behaviourally a no-op; it is what makes the ES-module flips safe.

WHY. `type="module"` defers execution to after HTML parse. Classic-`defer` and
module scripts share ONE "execute after parsing" list and run in DOCUMENT ORDER,
but a plain classic script runs DURING parse — ahead of all of them. So the
moment capabilities.js becomes a module while app.js is still plain, app.js runs
FIRST, and its 11 top-level `window.feedBack.on(...)` calls (app.js:6245-6722)
hit a bare `{}` — `_ensureFeedBackEventBus()` (capabilities.js:33), which
attaches .on/.emit/.off, would not have run yet. TypeError, app.js dies
mid-parse. Deferring everything now keeps document order == execution order
through the rest of the migration.

THE CATCH (Codex preflight caught this — a real ordering change). 22 scripts
guard their boot with `if (document.readyState === 'loading')`. A deferred
script runs at readyState 'interactive', so that test is FALSE and the else-branch
fires boot() immediately, at the script's position in document order — instead of
at DOMContentLoaded, after every script has evaluated.

That matters far more than one call site: a scan of the shell's scripts found
**43 forward references** where a script's boot() reads a global that a LATER
script defines (shell.js -> profile.js's window.v3Onboarding, songs.js ->
settings.js's window._confirmDialog, badges.js -> songs.js's
window.displayTuningName, ...). Every one of them resolves today only because
all boots happen at DOMContentLoaded. So the guards now treat 'interactive' as
not-ready (`!== 'complete'`), restoring that exactly.

Codex's specific finding (first-run onboarding silently skipped) did NOT
reproduce — shell.js's boot() awaits /api/profile, and that yield lets the
remaining deferred scripts run first. But the race it described is real, the
guard is silent when it fails (`&& window.v3Onboarding`), and the other 42
forward refs have no such await protecting them. Fixed at the root rather than
at the one site.

VERIFIED. A/B against origin/main on a fresh profile, 13 probes (onboarding
overlay, v3Onboarding/v3Songs/v3Profile/fbNotify/v3Badges/uiPrompt/showScreen,
bus, capabilities.version, createHighway, plugin scripts, mounted screens):
IDENTICAL, zero console/page errors on both. pytest 2396, node 1028/1028,
ESLint 0 errors, Codex 0.

New guard: test_every_external_script_defers_so_document_order_is_execution_order
fails if any external tag is plain classic — verified to fail on a single
reverted tag, so it actually bites.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 16:57:04 +02:00
9d0bf95716 refactor(ui)!: remove the classic v2 shell — v3 is the only UI (R3a) (#871)
* refactor(ui)!: remove the classic v2 shell — v3 is the only UI (R3a)

Deletes `static/index.html`, the `/v2` route, and the `FEEDBACK_UI` v2/legacy
opt-out. `/` and `/v3` both serve `static/v3/index.html`, which has been the
default since 0.3.0.

This is step 0 of the core-frontend ES-module migration (R3a). Both shells load
the same `static/app.js`, so every later step of that migration — exposing the
window contract, the `defer` ordering fix, the `type="module"` flips — would
otherwise have to be made and verified twice. Removing the fallback now halves
that surface before any of it is touched.

Incidentally fixes a latent bug in `index()`: its guard read
`if getenv_compat("FEEDBACK_UI") or getenv_compat("FEEDBACK_UI") in ("v2", "legacy")`,
whose left operand is truthy for *any* non-empty value — so `FEEDBACK_UI=v3`
actually served the **v2** shell.

- `static/tailwind.min.css` regenerated: the content globs scanned the deleted
  file, so v2-only utility classes are now purged (CI's tailwind-fresh job
  rebuilds and diffs it).
- Constitution amended to 1.3.0 — Principle II's frontend file list now names
  `static/v3/index.html`.
- Tests: 4 suites read the v2 shell (3 via a constructed `path.join` that a
  literal grep misses). Their v2 halves are paired duplicates of v3 tests that
  stay, so they are dropped; `alpha_warning_banner` and the capability-registry
  script-order test retarget to `static/v3/index.html`.

BREAKING CHANGE: `FEEDBACK_UI=v2` / `=legacy` and the `/v2` route are gone.
Unset the variable and use `/`. No chart, settings, or plugin data changes, and
no plugin API changes — v3 reuses the same engine.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: drop the stale '/ is v2' plugin-verification guidance (CodeRabbit)

The v3-only rewrite updated the intro paragraphs but left three lines that
still instructed plugin authors to verify in 'both / (v2) and /v3' — now the
same shell. Historical 'in v2 it was X' contrasts are kept: they still orient
authors whose plugins also ship to users on older cores.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 16:33:03 +02:00
Byron GamatosandGitHub f00ba2217d Revert "feat(audio): loopback feeder mode — all app audio under exclusive/ASIO (#865)" (#867)
This reverts commit 9a58a55fe8.
2026-07-11 14:56:35 +02:00
9a58a55fe8 feat(audio): loopback feeder mode — all app audio under exclusive/ASIO (#865)
* feat(audio): loopback feeder mode — all app audio under exclusive/ASIO

Tester-confirmed (2026-07-11 log): song previews and other
plugin-private audio bypass the per-surface feeder taps and leak to the
default WASAPI device under ASIO output. Also confirmed: the element
capture path poisons itself when highway_3d already owns #audio's
one-shot MediaElementSource (InvalidStateError with _elCtx assigned
pre-throw → TypeError every later tick).

- New preferred mode 'loopback': one getDisplayMedia frame-audio capture
  (desktop main answers with the app's own frame) covers song, previews,
  and UI sounds for the whole exclusive session — engages even with no
  song loaded. Local playback silenced via suppressLocalAudioPlayback,
  page-mute IPC fallback otherwise.
- Sticky fallback to the existing stems/element surface modes when
  capture is unavailable (old desktop main, denied, Docker sphere).
- Element capture: assign module state only after the whole chain
  succeeds; close the context on failure — collision now retries clean.
- Failed engage now disables the bus and tears down loopback (no more
  bus-enabled-with-no-producer stranding).
- Tests: 12 (5 new — loopback engage/preference/mute-fallback/sticky
  fallback, collision retry).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(audio): close loopback capture context on teardown (release tap worklet)

The loopback context was reused across engages (_lbCtx || new), but teardown
only stopped the stream + deactivated the tap — never closing the context or
detaching the worklet node. Each exclusive<->shared switch orphaned a live
tap worklet on the long-lived context. Use a fresh context per session and
close it on disengage. Adds a test asserting the context is closed on teardown.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Byron Gamatos <xasiklas@gmail.com>
2026-07-11 14:54:40 +02:00
76159c16cd feat(diag): --debug ASIO routing diagnostics in static bundle (#852)
* feat(audio): route feedpak full-mix natively under exclusive output

Song playback runs through the renderer, which WASAPI-exclusive (and
ASIO) output silences. Route single-mix feedpaks (stem-less
original_audio packs AND single-stem packs) onto the engine's backing
transport when the output device type is exclusive-style, and migrate
back to HTML5 when it isn't. Extends /api/audio-local-path to resolve
/api/sloppak/.../file/... URLs via the same containment guards as
serve_sloppak_file. Multi-stem packs stay on the WebAudio path
(Phase 2). Includes [feedpak-route] transition-gated diagnostics
logging.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(audio): renderer-bus feeder — mix renderer song audio into engine output (Phase 2)

Under exclusive-style output the native backing transport (Phase 1, #824)
carries loose /audio/ songs and feedpak full-mixes, but not the stems
plugin's multi-stem WebAudio graph or tracks JUCE rejected. The feeder taps
the renderer-side master with an AudioWorklet, re-points the owning
AudioContext at a null sink so it keeps rendering without a device, and
pushes ~10 ms chunks over IPC into the desktop engine's renderer bus
(feedBack-desktop#90 follow-up). Inert in the Docker sphere and in shared
mode. Validated by the fix12 tester spike: null-sink rendering works,
clocks hold, no overflow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(diag): --debug ASIO routing diagnostics in static bundle

Gated on window.feedBackDesktop.audio.debugEnabled() (desktop --debug);
inert in the Docker sphere and normal desktop runs.

- [asio-diag] getCurrentDevice= full device object on outputType change
  (catches ASIO drivers reporting a non-'ASIO' type name)
- [asio-diag] renderer-bus: full feeder decision vector, change-gated
  (running/exclusive/stems/juceMode/elementSong/want/mode)
- [asio-diag] setSink: every sink flip with ctx state + rate

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 00:42:41 +02:00
2281cac438 refactor(highway): lift 79 per-instance closure vars into hwState (R3c H lift) (#849)
* refactor(highway): lift 79 per-instance closure vars into `hwState` (R3c H lift)

Collapses createHighway()'s 79 mutable closure `let`s into one per-instance
`hwState` object. Scope-resolved rewrite via acorn + eslint-scope: 1059 edits
(1057 references + 79 defs - the deleted `let canvas, ctx, ws`), with the four
names shadowed in inner scopes (chartTime/ctx/notes/chordTemplates) resolved
correctly so only closure-bound refs move. Enables the later module split:
extracted renderer/ws modules close over `hwState` as a factory arg, so
multi-panel plugins (highway_3d, note_detect, splitscreen) don't share one
highway's state.

Container is `hwState`, NOT `H` — `H` is already canvas height (70 uses). The
frame-time gate caught that collision instantly (0 draws, `H._drawHooks is not
iterable` in the shared draw-hook path).

PERF (the whole risk): identical to the pre-lift baseline. Draw p50 2.1-2.2 ms,
p95 2.7-3.0 ms (pre-lift 2.7-3.2), measured on the Arcturus feedpak, headless.
Each closure-slot read became a `hwState.<slot>` monomorphic property load; the
hot loop pays nothing. On-device: Byron confirmed the 2D highway plays smoothly.

Tests: the ~30 highway JS suites brace-extract functions/patterns from the
source; their state references + the monotonic-clock vm sandbox now use
`hwState.<slot>` (the const _CHART_MAX_INTERP_MS etc. stay top-level, not
lifted). node --test: 1030/1030 green. Two self-inflicted over-replacements
caught and reverted (`_lefty` is a prefix of the 3D-local `_leftyCached`;
`STRING_COLORS` a suffix of `DEFAULT_STRING_COLORS`) — substring replaces on the
brace-extract regexes need word care.

Transformer saved at ~/.local/share/feedback-editor/highway-h-lift.mjs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(highway): pin the setNoteStateProvider assertion to hwState._noteStateProvider (CodeRabbit)

The [^}]* form matched an unqualified _noteStateProvider =, so a regression to
closure-level state could still pass. Require the hwState-qualified assignment.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 23:19:20 +02:00
1c1a0e0268 feat(audio): renderer-bus feeder — song audio into engine output under exclusive mode (Phase 2) (#828)
ship-ci / ci (push) Waiting to run
* feat(audio): route feedpak full-mix natively under exclusive output

Song playback runs through the renderer, which WASAPI-exclusive (and
ASIO) output silences. Route single-mix feedpaks (stem-less
original_audio packs AND single-stem packs) onto the engine's backing
transport when the output device type is exclusive-style, and migrate
back to HTML5 when it isn't. Extends /api/audio-local-path to resolve
/api/sloppak/.../file/... URLs via the same containment guards as
serve_sloppak_file. Multi-stem packs stay on the WebAudio path
(Phase 2). Includes [feedpak-route] transition-gated diagnostics
logging.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(audio): renderer-bus feeder — mix renderer song audio into engine output (Phase 2)

Under exclusive-style output the native backing transport (Phase 1, #824)
carries loose /audio/ songs and feedpak full-mixes, but not the stems
plugin's multi-stem WebAudio graph or tracks JUCE rejected. The feeder taps
the renderer-side master with an AudioWorklet, re-points the owning
AudioContext at a null sink so it keeps rendering without a device, and
pushes ~10 ms chunks over IPC into the desktop engine's renderer bus
(feedBack-desktop#90 follow-up). Inert in the Docker sphere and in shared
mode. Validated by the fix12 tester spike: null-sink rendering works,
clocks hold, no overflow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 22:51:09 +02:00
1b3178037b feat(audio): route feedpak full-mix natively under exclusive output (#824)
Song playback runs through the renderer, which WASAPI-exclusive (and
ASIO) output silences. Route single-mix feedpaks (stem-less
original_audio packs AND single-stem packs) onto the engine's backing
transport when the output device type is exclusive-style, and migrate
back to HTML5 when it isn't. Extends /api/audio-local-path to resolve
/api/sloppak/.../file/... URLs via the same containment guards as
serve_sloppak_file. Multi-stem packs stay on the WebAudio path
(Phase 2). Includes [feedpak-route] transition-gated diagnostics
logging.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 22:18:00 +02:00
845255e404 fix(audio-effects): accept pre-rebrand chain plan schema as alias (#816)
* fix(playback): throttle legacy bridge-hit recording; emit loop-set for manual A/B

window.feedBack.getLoop() is a read surface plugins legitimately poll
(note_detect HUD ticked it at ~30 Hz), but every call recorded a
playback.loop-api bridge hit: compat-shim bookkeeping, a
playback:bridge-hit event, and a diagnostics snapshot rebuild +
stringify per call — real main-thread cost and a saturated hitCount in
the capability inspector, even with no song playing.

- _recordPlaybackBridge now throttles per bridgeId|surface (5 s window).
  Bridge hits are a 'surface still in use' signal, not a call counter.
- setLoopEnd() (manual A/B buttons) now emits the same loop-set
  transport event as setLoop(), so event-driven consumers no longer
  need to poll getLoop() to see button-armed loops.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(changelog): note loop-api bridge throttle fix

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(audio-effects): accept pre-rebrand chain plan schema as alias

The rebrand renamed PLAN_SCHEMA to 'feedBack.audio_effects.chain_plan.v1'
but shipped plugin bundles (rig_builder <= 2.9.x) still send the
slopsmith-era id, so _validatePlan rejected every plan and providers fell
back to their heavyweight legacy load paths (full chain rebuild per poll
cycle — audible as continuous distortion during songs). Accept the old id
as an explicit alias.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 22:17:57 +02:00
Byron GamatosandGitHub 950e348357 R0: module-migration rails (src/ serving, live-edit cache, scriptType loading, governance) (#812)
ship-ci / ci (push) Has been cancelled
Host enablement for the plugin ES-module migration: sandboxed /api/plugins/{id}/src/ serving, no-cache+weak-ETag/304 live-edit caching on src/+screen.js+assets, scriptType:module loader injection + scriptType/minHost manifest passthrough; constitution v1.2.0 + module playbook + signed size-exemptions register + maintainer/CI-only ESLint gate; rerunnable perf-baseline harness. Reviewed by Codex (local), Copilot, and CodeRabbit.
2026-07-08 10:14:40 +02:00
a18a818e8b fix(playback): throttle legacy bridge-hit recording; emit loop-set for manual A/B (#811)
ship-ci / ci (push) Waiting to run
* fix(playback): throttle legacy bridge-hit recording; emit loop-set for manual A/B

window.feedBack.getLoop() is a read surface plugins legitimately poll
(note_detect HUD ticked it at ~30 Hz), but every call recorded a
playback.loop-api bridge hit: compat-shim bookkeeping, a
playback:bridge-hit event, and a diagnostics snapshot rebuild +
stringify per call — real main-thread cost and a saturated hitCount in
the capability inspector, even with no song playing.

- _recordPlaybackBridge now throttles per bridgeId|surface (5 s window).
  Bridge hits are a 'surface still in use' signal, not a call counter.
- setLoopEnd() (manual A/B buttons) now emits the same loop-set
  transport event as setLoop(), so event-driven consumers no longer
  need to poll getLoop() to see button-armed loops.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(changelog): note loop-api bridge throttle fix

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 00:00:58 +02:00
fadaa154e9 feat(library): sort and badge by personal difficulty rating (#810)
* feat(library): sort and badge by personal difficulty rating

Adds sort=difficulty/difficulty-desc to the library API (correlated
subquery over song_user_meta.user_difficulty, unrated songs pushed to
the bottom either direction, same pattern as the existing mastery
sort) and surfaces the rating as a badge on library cards in both the
v2 grid/tree views and the v3 grid. The rating itself already existed
(song_user_meta) — this just makes it sortable and visible, so it's
no longer only readable in the per-song edit drawer.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(library): escape difficulty badge, wire tree view, add changelog+tests

- Wrap song.user_difficulty in esc() at both badge call sites
  (static/app.js ~2082 and ~2283) for XSS-consistency with the
  sibling tuning badge, which already uses esc().
- server.py: query_artists (the classic tree view's data source, used
  by /api/library/artists) never batch-attached user_difficulty the
  way query_page does for the grid, so the tree-view difficulty badge
  added in 75673c3 was unreachable dead code (song.user_difficulty was
  always undefined there). Now attaches it via the existing
  user_meta_map() helper, same pattern as query_page.
- Add an [Unreleased] CHANGELOG.md entry for the difficulty sort +
  badge feature, matching the repo's existing entry format.
- Add tests/test_library_filters.py::test_difficulty_sort_pushes_unrated_to_bottom
  asserting unrated songs sort to the bottom in both sort=difficulty
  and sort=difficulty-desc directions, and
  ::test_tree_view_songs_carry_user_difficulty covering the
  query_artists fix above.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(library): chunk user_meta_map + rebuild stale tailwind css

Address review-bot findings on the difficulty sort/badge:
- user_meta_map now chunks filenames into 400-row batches (like
  overrides_map) before the IN (...) query. query_artists (tree view)
  passes every song across up to 50 artists, which could push the
  placeholder count past SQLite's older variable limit; query_page's
  small pages are unaffected. (CodeRabbit: Stability & Availability)
- Rebuild static/tailwind.min.css: the ◆N difficulty badge introduced
  bg-blue-900/30 + text-blue-300, which were never compiled into the
  committed stylesheet, failing the tailwind-fresh CI gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-07 23:57:20 +02:00
topkoa 92e78be62d Fix v3 library Filters drawer crash on saved prefs; rename Stems label
applySavedPrefs() rebuilt state.filters without the `genre` key, so with
saved prefs restored from localStorage state.filters.genre was undefined.
Clicking Filters ran renderDrawer(), which indexes f.genre.includes(g)
whenever the library has >=1 genre -> TypeError, renderDrawer aborts, and
openDrawer never removes translate-x-full. The drawer stayed off-screen so
the menu appeared dead. Only triggered for users with saved prefs AND a
non-empty genre list, matching the intermittent report.

Carry genre: [] alongside the other session-only facets (mastery, match),
mirroring the default and clear-all shapes which already include it.

Also rename the visible "Stems (sloppak)" drawer label to "Stems (feedpak)"
to match the public format name used elsewhere in the UI.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-06 19:50:34 -04:00
69c8ad4e0c fix(settings): don't let a stale DLC path block saving the Demucs server address (#795)
ship-ci / ci (push) Waiting to run
The v3 Settings "Save" button posts dlc_dir together with demucs_server_url,
default_arrangement and av_offset_ms in one request. POST /api/settings
validated dlc_dir first and early-returned "DLC directory not found" before it
ever processed demucs_server_url, so on a machine whose DLC path doesn't resolve
(fresh install, unplugged/network drive, a path carried over from another
machine) setting the Demucs server address silently failed — reported in
got-feedBack/feedBack-demucs-server#3 (macOS 07-05 nightly).

- server: a non-resolving dlc_dir is now recorded as a warning and skipped
  rather than aborting the whole POST, so the co-submitted keys still persist.
  The bad path is surfaced via a new additive `warnings` field and folded into
  `message` so the settings status line still shows it.
- client (v3): the Demucs input now autosaves on blur/enter via a single-key
  persistSetting POST, like every other v3 setting, so it never depends on the
  coupled Save button.
- tests: cover the decoupling and the unchanged happy path.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 20:35:51 +02:00
021ee55f2a Allow .feedpak files in library when uploading (#770)
Signed-off-by: Rob Sassack <rsassack25@gmail.com>
Co-authored-by: Byron Gamatos <xasiklas@gmail.com>
2026-07-06 10:05:27 +02:00
1f621e5fe5 perf(highway): draw the held-sustain glow without ctx.shadowBlur (#729)
shadowBlur cost scales with the blurred device-pixel area. The lit-sustain
trail can span half the canvas, the canvas is DPR-scaled (4x pixels on a
2x Mac), and the blur ran on every frame exactly while a sustain is HELD
— i.e. at the moment the player most notices a hitch. Sustain-heavy songs
(e.g. fingerpicked acoustic charts) hit this constantly.

Replace the blur with three inflated low-alpha fills of the same trail
quad: reads as the same soft shimmering glow (the shimmer LUT still
drives per-frame flicker, feedBack#254 intent preserved) at a flat,
area-independent cost. Also drops the now-dead shadowBlur reset in the
crackle pass; no shadowBlur uses remain in highway.js.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Byron Gamatos <xasiklas@gmail.com>
2026-07-06 09:49:06 +02:00
612b1f2e0d feat(v3): choose handedness in the instrument selector + onboarding (give lefties a break) (#793)
ship-ci / ci (push) Waiting to run
* feat(v3): add a handedness (left-handed) choice to the instrument selector + onboarding

Left-handed players could already mirror the highway, but only via a buried
Settings toggle they had to find AFTER setup -- so a lefty went through the tour,
the tuner and calibration all right-handed first (community callout).

Add a "Handedness: Right / Left" row to the v3 instrument badge popover, alongside
Instrument / Strings / Tuning (all player-orientation choices). It writes the same
lefty preference -- highway.setLefty when a live highway exists (flips it
immediately + persists), else the 'lefty' localStorage key the highway reads on
init -- and keeps the Settings "Left-handed" checkbox in sync. The first-run
tour's "Choose your instrument" step, which runs before the tuner/audio-
calibration steps, now calls it out so lefties flip it up front.

Frontend-only, additive. Full core JS suite green (938). Tests:
tests/js/badges_handedness.test.js.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UR2Cr7GEu3yMY7SrfxH6c1

* docs: split the spliced Handedness/Colorblind CHANGELOG entries

A rebase pasted the Handedness bullet over the Colorblind preset entry's bold
lead, merging two unrelated Added entries into one run-on bullet. Restore them
as two separate bullets.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-05 23:50:30 +02:00
ChrisBeWithYouandGitHub 4f6dc233f1 feat(player): seed editor region handoff state (#762)
Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
2026-07-05 23:37:39 +02:00
ChrisBeWithYouandGitHub 5be70939e4 feat(v3 library): searchable Cover Art Archive picker in Change-cover (#783)
The cover picker only offered CAA covers from a song's MATCHED release, so an
unmatched song (the city-pop pile) got nothing but Current/Pack/Upload/URL. Add
a search box: GET /api/song/{fn}/art/cover-search?q= searches MusicBrainz
release-groups and returns each album's CAA front-250 thumb; the picker renders
them as pickable tiles (same apply→/art/url path; covers with no CAA art
self-hide). Pre-filled from the song's artist + album/title (romaji fallback), so
a blank-artist pack pre-fills "Junko Yagami …". Reuses the throttled _mb_http_get.
2026-07-05 23:36:49 +02:00
ChrisBeWithYouandGitHub 6aaa2dcf47 feat(v3 library): batch→popup handoff + English-base romaji (metadata-curation capstone) (#782)
* feat(v3 library): click a "No match" badge to fix it — batch → popup handoff

Connects the two halves: the "No match" badge (the unmatched pile) now opens the
Fix-metadata popup for that song in one click, instead of right-click → menu.
The resting badge becomes interactive (pointer-events-auto + hover), carrying a
data-meta-fix hook; wireCards opens window.__fbFixMatch(playTarget) on click and
stops propagation so it doesn't also play the card. Batch tile states stay
non-interactive. Loop becomes: Unmatched filter → see the pile → click one →
fix it. tailwind.min.css regenerated for the badge's hover classes.

* feat(v3 library): show the author's romaji, not blank/native script (English base)

Two changes so an English-speaking base never sees a blank name or native script:

- Filename romaji fallback: a blank-artist CDLC pack ("Artist_Title_v1_p") shows
  nothing useful (artist blank; title = the raw filename), and a match fills it
  with kanji/kana. query_page + pack_fields now surface the author's own romaji
  parsed from the filename ("Junko Yagami — BAY CITY") when the pack has no
  artist of its own — display-only, keyset-safe (raw title stashed for the
  cursor), a real pack artist or a user override still wins.
- Smart adopt: "Use these values" now KEEPS the readable romaji name + title the
  card already shows and takes only album/year/genre (+ art via the pin) from the
  match, so identifying a Japanese song gives "Junko Yagami — BAY CITY — FULL MOON"
  with the right cover, never native script.

Tests: romaji fallback fires for a blank-artist CDLC pack (grid + pack_fields
agree) and is left alone when the pack has a real artist.
2026-07-05 23:35:58 +02:00
de002cdc24 feat(highway): add "Colorblind (deuteranope)" string-color preset (#788)
Adds a one-click preset to the shared "Highway String Colors" picker,
next to the existing Okabe-Ito "Colorblind-friendly" preset. Contributed
by a deuteranopic player who found the Okabe-Ito set still hard to
separate: it retunes the six main strings and keeps that set's 7/8-string
colors. Applies to both the 2D and 3D highways via the shared picker,
which writes the slot->hex map the renderers already consume.

Additive frontend-only change to HWC_PRESETS in static/app.js; the picker
UI and both highways pick it up automatically (the preset list is
generated from HWC_PRESETS and applied by id). All 20 highway
string-color JS tests pass; app.js syntax-checks clean.


Claude-Session: https://claude.ai/code/session_01UR2Cr7GEu3yMY7SrfxH6c1

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 21:13:13 +02:00
3100d68a45 feat(v3 library): genre field in the Fix-metadata popup Details tab (#780)
Adds Genre as a fifth Details field (edit / lock / revert / Yours-Pack
provenance), backed by the existing override store. To make it actually useful,
the genre FILTER and FACET now resolve the per-song override (effective genre =
override else scanned pack genre) — guarded so the common no-override case stays
on the plain indexed column — so a corrected/added genre is immediately
browsable. Genre stays a library-only overlay: it is NOT a write-to-file field
(split WRITE_FIELDS = the four file-safe fields from the five DETAIL_FIELDS), so
Write to file leaves the genre override in place and the copy says so. The
Match→Details bridge also carries a candidate's first genre.

Tests: effective-genre facet + filter, and that a value-less lock doesn't invent
an effective genre.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 21:12:55 +02:00
6397a959a4 feat(library): Match→Details "use these values" bridge (#779)
Connects the popup's two tabs. A match only improves the underlying canon+art;
by design it never silently re-titles the grid. This adds the explicit opt-in
path: each Match candidate (search or Identify-by-audio) gets a "Use these
values →" action that copies its title/artist/album/year into the Details tab
as pending (unsaved) inputs and lands you there for review — pinning the match
too so the art/canon follow. You then Save (overlay) or Write to file. Queue-
review candidates are unchanged (they still accept/pin on click).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 21:11:14 +02:00
5f499a8a3a feat(v3 library): "Write to file" in the Fix-metadata popup (#778)
* feat(library): "Write to file" in the Fix-metadata popup's Details tab

Completes the confirmed edit model: Save keeps edits as a reversible display
overlay (files untouched); "Write to file" bakes the shown title/artist/album/
year into the pack itself via the existing POST /api/song/{fn}/meta (writes the
manifest, re-stats, coalesces a rescan). On a real file write the now-redundant
override values are cleared (locks kept) and the tab re-renders, so the fields
read from the file as "Pack". Loose-folder / unwritable packs fall back to a
DB-only update and say so (may revert on a full rescan). Secondary button next
to Save; touches only the four file-safe fields, the rest of the pack verbatim.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(library): mirror server year coercion in Write-to-file grid sync

update_song_meta coerces a non-numeric/empty year to "" before persisting,
but writeToFile optimistically set song.year to the raw typed text — so the
library card flashed e.g. "abcd" until the next natural refresh. Apply the
same integer coercion client-side so the in-memory song matches what was
written.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-05 21:10:34 +02:00
af1170cec3 feat(v3 library): "Fix metadata" popup — per-song override + lock, cover picker, MusicBrainz + AcoustID (#777)
* feat(library): metadata override + lock store, enforced by enrichment (popup slices 1–2)

Backend foundation for the Fix-metadata popup. Not yet surfaced in the UI (the
display + 3-tab popup are the next slices); no PR until it's user-visible.

Slice 1 — the store:
- `song_field_override(filename, field, value, locked)` table: a reversible
  DISPLAY overlay (never written to the pack), filename-keyed so it survives a
  rescan (never purged by delete_missing) and is dropped only with the song.
- DB methods (partial upsert that drops empty+unlocked rows; batch map) +
  `GET`/`PUT /api/song/{fn}/overrides` (field allowlist title/artist/album/
  year/genre; clearing rides PUT since DELETE /api/song/{path} shadows sub-
  routes; PUT demo-blocked).

Slice 2 — locks respected by enrichment:
- The auto-matcher composes a per-song `_compose_lock_filter` onto the global
  apply-filter, so a match still applies IDENTITY (mbid/release → art) but never
  re-canonicalizes a LOCKED display field.
- Gap-fill (write-to-file) skips locked album/year/genre — writing the matched
  value would be exactly the clobber the lock exists to prevent.
- Review/manual picks bypass the filter (an explicit confirm overrides a lock).

Tests: store semantics + rescan-survival + API; the lock filter + reader; an
auto-match leaving a locked field un-canonicalized; gap-fill excluding locked
keys.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(library): show per-song overrides in the grid (popup slice 3)

The grid now displays the user's per-song title/artist/album/year override
in place of the pack value ("grid shows only overrides") — a matched
MusicBrainz canon never silently re-titles a card; canon stays in the
Details drawer + art. Overlaid in Python over the visible window, keyset-safe
like the P4 artist-alias re-label: the seek still runs on the raw column, and
the one overridable keyset column (title) stashes its raw value for the cursor
so paging never skips/dupes. The private stash is dropped from the payload.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(library): 3-tab Fix-metadata popup — Details / Cover art / Match (slice 4)

Turns the thin single-song fix-match modal into the Plex-style metadata
editor reached from a card's "Fix metadata…" menu:

- Details tab: type + lock the displayed title/artist/album/year. Values
  ride the reversible override store (GET/PUT /api/song/{fn}/overrides); each
  field sits on its pack value (Yours/Pack provenance + revert-to-pack), a lock
  pins it against auto-match, and Save repaints the grid via library:changed
  (slice-3 overlay). This is the real tool for the blank-artist city-pop pile
  MusicBrainz can't surface — you just type the right title.
- Cover art tab: hands off to the shared image picker (its own modal); the
  pick refreshes the thumbnail everywhere.
- Match tab: the existing MusicBrainz search + candidate/pick flow, refactored
  into shared body/footer helpers (the queue-review flow is untouched).

Backend: GET /overrides now also returns the pack baseline so the Details tab
can pre-fill + show provenance. tailwind.min.css regenerated (build-tailwind.sh)
for the popup's new utility classes.

Identify-by-audio (AcoustID) is deferred: it lives in unmerged PR #759, off
main — the Match tab gains the button once #759 lands and this branch rebases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(library): wire "Identify by audio" in the tabbed popup's Match tab

The AcoustID Identify button (#759) merged in referencing an out-of-scope
`panel` in the wiring — a leftover from the pre-popup fix-match modal that my
tab refactor renamed to `root`. Under strict mode that threw, so the handler
never attached and the button did nothing. Scope it to `root` (the tab body),
which is where the search-results area it renders into lives.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(library): make "Identify by audio" outcomes unmistakable

An empty AcoustID result read the same as a broken button. Each state now says
plainly which outcome it is — ✓ fingerprinted-but-no-match vs no-audio vs off vs
unavailable — and, in the popup, points at the manual fallback (Search, or set
the album in Details + cover in Cover art by hand). A ✓ marks the states that
actually ran, so "worked, found nothing" no longer looks like a failure.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 21:09:38 +02:00
3e036e3db6 feat(v3 library): persistent "no match" badge + Unmatched quick filter (#781)
* feat(v3 library): persistent "no match" badge + Unmatched quick filter

The Refresh-Metadata batch (#764) shows a transient per-tile "no match" only
while a pass runs, so the unmatched pile goes quiet at rest. Two additions make
it visible + reachable:

- Persistent per-card "No match" badge: query_page now marks each row
  `unmatched` (a cheap failed-set membership like favs/estd), and enrichBadge
  paints a subtle resting marker for those cards — tracked in a `_unmatched` set
  so a batch tile clearing falls back to it instead of wiping it. A live batch
  tile still wins while a pass runs.
- "Unmatched" toolbar toggle (local-only): one click applies the same filter as
  the drawer's Match → Unmatched (match_state='failed'), so the no-match pile is
  a click away right after a batch. Re-queries + reflects active state.

Test: query_page flags a failed row + the match=unmatched filter returns it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(v3 library): repaint persistent no-match badge after metadata tile-clear

_clearMetaTiles removed every .v3-meta-tile node — including the new
persistent 'No match' resting badge, which derives from _unmatched rather
than _metaTile. A metadata rescan's tile-clear therefore dropped the badge
until the next scroll re-rendered the card. Repaint it from _unmatched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-05 21:00:30 +02:00
92dc321fdf feat(gp_autosync): piecewise time-warp helpers + refine_sync onset pass (#787)
* feat(gp_autosync): piecewise time-warp helpers + refine_sync onset pass

auto_sync computes per-bar sync points but consumers only ever applied the
scalar bar-1 audio_offset, so any tempo drift between the recording and the
tab's authored tempo accumulated over the song. Add the librosa-free helpers
needed to apply the full piecewise mapping:

- bar_start_times(gp_path): per-bar score times sharing auto_sync's axis
  (GPIF bar-resolution map, GP3/4/5 per-tick integration)
- build_warp_anchors(points, bar_starts): monotonic (score, audio) anchors
- warp_time(t, anchors): piecewise-linear map with edge-slope extrapolation
- warp_song_times(song, warp): retime a lib.song.Song in place (notes,
  sustains, chords, beats, sections, anchors, handshapes, phrase levels,
  tone changes, tempo overrides)
- gp_has_expandable_repeats(gp_path): detects GP3/4/5 repeat/volta/direction
  markup whose playback expansion auto_sync's as-written points cannot map

Also implement refine_sync() — the editor's refine-sync endpoint has imported
it since the snapshot but it never existed in lib, so the Refine button 500'd.
It densifies the DTW points to every Nth bar and re-times each with a local
onset phase sweep (radius clamped under half a beat to avoid one-beat locks,
short scoring grid + median residual snap against the first beats). Synthetic
click-track validation: ~13ms mean / ~40ms max error from ±180ms coarse input
across 117-123 BPM recordings of a 120 BPM tab.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* review: Copilot round 1 — normalize bar_start_times GP3/4/5 parse failures to ValueError, document ImportError

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 20:08:19 +02:00
c7aa5a10b0 fix(v3): recycle library grid cards on scroll instead of rebuilding the window (#742)
The virtualized v3 Songs grid rebuilt its entire visible window
(grid.innerHTML = _renderCardsRange(...) + a full wireCards pass) every
time it slid by one row. Each row-boundary crossing was therefore a heavy
synchronous frame — reparse ~60 cards, re-attach hundreds of listeners,
reflow — that stalled the main thread and buffered held-arrow key-repeats,
flushing them in a burst. Testers saw the library "go super fast for a
second then slow down," skipping "every so many scrolls," up or down, at
the same spots each time. It hitched scrolling back up over already-loaded
songs too, because the cost was DOM teardown, not fetching.

renderWindow() now reconciles the window in place: it reuses the card
nodes that stay on-screen and builds only the row that enters/leaves
(~6 nodes per slide instead of ~60). Nodes are keyed by absolute index
(data-idx) with a real-vs-skeleton + select-mode signature (data-sig) so
hole-fills after a page fetch and select-mode toggles still rebuild
exactly the nodes that changed. wireCards()'s existing data-wired guard
then wires only the freshly-built nodes, so per-slide listener churn drops
with it. Everything keyed off data-fn (favorites, ⋮ menu, right-click,
selection, accuracy badges, A–Z rail) is unaffected.

Follow-up to the stage-2 virtualized grid (#636 item 3). Frontend-only.

Tests: tests/js/v3_songs_window_recycle.test.js — window stays [start,end)
contiguous and in-window node identity is reused across a down-then-up
scroll; select-mode toggle and a rail-seek jump rebuild correctly.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-05 00:19:46 +02:00
fa2d12222a feat(v3 library): "Refresh Metadata" button + per-view re-match + filename-artist seed (#764)
Adds a media-server-style "Refresh Metadata" control to the Songs toolbar
(beside "⟳ Refresh") — the metadata counterpart to a file scan.

- Re-matches the songs currently SHOWN (the visible grid window) against
  MusicBrainz: a per-view refresh that's visible even on an already-matched
  library. The button doubles as Stop while a pass runs; a batch progress bar
  + per-tile queued→working→done badges show what's happening. User-pinned
  `manual` matches are never re-matched.
- Backend: POST /api/enrichment/{cancel,states,rematch}; /status gains
  total/matched/current/cancelling; a cooperative cancel Event is checked
  between songs in the match + art phases so Stop halts without waiting for
  the whole queue. `states` is read-only (open); `cancel`/`rematch` are
  demo-blocked.
- Matcher: when a pack's `artist` field is blank (common in community
  charts), derive artist/title from the CDLC `Artist_Song-Title` filename
  convention as a SEARCH SEED so text matching can identify it — the displayed
  values still come only from the confirmed MusicBrainz match, nothing
  estimated is shown as author-set. Rescues blank-artist packs that otherwise
  always failed.

Tests: enrichment_states_for, the three new routes, cancel-halts-a-pass,
kick-clears-stale-cancel, filename parse, blank-artist seeding, and
present-artist-not-overridden.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 00:18:18 +02:00
73c5ab149e feat(enrichment): AcoustID audio-fingerprint identification (opt-in) (#759)
* feat(enrichment): AcoustID audio-fingerprint identification (opt-in)

Text search can only guess the version; the definitive fix is content-based —
fingerprint the actual audio with Chromaprint (fpcalc) and look it up on
AcoustID, which maps the fingerprint to the EXACT MusicBrainz recording (the
approach Lidarr uses). Sidesteps the studio-vs-live ambiguity entirely.

- lib/acoustid_match.py: pure response parsing + config gating (unit-tested);
  normalizes AcoustID hits into the same candidate shape as mb_match so the
  review UI + editor Match popup render fingerprint and text hits identically.
- server.py: _fpcalc (Chromaprint subprocess), _acoustid_lookup (throttled,
  offline-guarded HTTP), _identify_by_fingerprint (also available to the
  library-enrichment pipeline), and POST /api/enrichment/identify (upload the
  master audio → candidates).
- Fully OPT-IN and graceful: absent the fpcalc binary or an ACOUSTID_API_KEY
  the whole path is a no-op / 503 and the text matcher runs unchanged.

Requires (both optional): the `fpcalc` (Chromaprint) binary on PATH/$FPCALC,
and a free AcoustID application key in $ACOUSTID_API_KEY. Pure parsing/gating
is unit-tested; the fpcalc + live-lookup path needs those two to exercise.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(enrichment): make AcoustID self-serve — opt-in toggle + API key in settings

Fingerprinting was env-var only (ACOUSTID_API_KEY), so only an operator could
enable it. Add two core settings so a user can turn it on themselves:
  - acoustid_enabled (bool, default OFF — opt-in)
  - acoustid_api_key (string, ≤128 chars, trimmed; env var stays a fallback)

_acoustid_available()/_acoustid_lookup() now resolve (enabled, key) from
settings via _acoustid_settings(). /api/enrichment/identify distinguishes
"not set up" (412 needs_setup — the UI nudges the user to enable it) from
"set up but fpcalc/network missing" (503) so the client never fakes a match.

Verified: default off; POST round-trips + trims; 412 vs 503 gating; bad
types/over-length rejected. acoustid_match unit tests green (8/8).

* fix(enrichment): POST the AcoustID lookup instead of GET

A Chromaprint fingerprint is multi-KB (a 3.5-min track ≈ 3.5k chars), so
sending it as a GET query param overflows the request URL for longer songs and
fails spuriously. AcoustID accepts the same params form-encoded — POST them.

* fix(acoustid): space-separate the lookup `meta` (was silently dropping metadata)

The meta value was `+`-joined ("recordings+releasegroups+compress"). Sent over
the wire the literal `+` percent-encodes to %2B, which AcoustID does NOT split
into flags — so every hit came back with an empty `recordings` array and the
parser produced zero candidates (a fingerprint match that resolved to nothing).
AcoustID wants the flags space-separated. Verified against real fingerprints:
`+`-joined → 0 recordings; space-joined → 28, resolving Highway to Hell and
Living After Midnight to their canonical studio albums as the top hit.

* feat(acoustid): resolve the canonical original album + year from the fingerprint

AcoustID hits resolved the right recording but a weak album/blank year: the
album picker took the first studio-typed group (a later comp/soundtrack typed
"Album" could win) and the year took an arbitrary release (often a reissue).
Request the `releases` meta (which carries per-release dates) and use them to
(1) pick the EARLIEST original studio album among the groups and (2) fill the
year from that album's earliest release. Verified against real fingerprints:
Smoke on the Water → Machine Head (1972) not a later comp; Highway to Hell →
1979; Living After Midnight → British Steel (1980). +2 unit tests.

* feat(acoustid): per-song "Identify by audio" for the library metadata tooling

Add POST /api/enrichment/identify/{filename} — fingerprints an EXISTING library
song's own master audio (resolves the sloppak's original_audio or a loose
folder's audio), the library counterpart to the upload-based /identify used by
the editor. Wire an "Identify by audio" action into the match-review / Fix-match
modal: it renders fingerprint hits in the same candidate list and pins the pick
via the existing /review/{f}/pick. Shared _acoustid_gate() (412 needs_setup /
503) for both endpoints; 404 when a pack has no full mix. Both identify routes
added to the demo-mode block list (they spend fpcalc + the AcoustID budget) —
fixes a pre-existing miss on the upload route.

* fix(acoustid): regenerate stale tailwind CSS + cap identify upload

- static/tailwind.min.css was stale vs a fresh rebuild (ci/tailwind-fresh red);
  regenerated with the pinned tailwindcss@3.4.19 (byte-stable).
- /api/enrichment/identify read the whole multipart upload into memory before
  writing it; stream it to the temp file with a 256 MB cap (413 over) so an
  oversized upload can't balloon RAM. fpcalc reads from the temp file anyway.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(acoustid): pre-parse upload guard + settings UI to enable it

- /api/enrichment/identify is now async: a pre-parse Content-Length check +
  request.form(max_part_size=…) reject an oversized body BEFORE Starlette spools
  the multipart to temp disk (mirrors the song-upload endpoint), and the blocking
  fpcalc subprocess + AcoustID HTTP run off the event loop via run_in_executor.
- The v3 Metadata-matching settings card gains an 'Identify by audio' opt-in
  toggle (acoustid_enabled, default OFF) + an AcoustID key input
  (acoustid_api_key), wired in match-review.js — so the advertised feature is
  reachable from the UI instead of only via a manual settings POST. Reuses
  existing classes only; committed tailwind.min.css stays fresh.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 00:17:43 +02:00
a86abadb14 settings: add host instrument profiles (#753)
* settings: add host instrument profiles

Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>

* settings: add instrument pathway selection

Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>

* fix(settings): profile-aware saves/resets/switch, provider tunings, bass-5

Five regressions from the instrument-profiles rework:

1. save_settings canonicalized profiles on EVERY save -> empty/unrelated POST
   froze default profiles into config.json (broke
   test_empty_post_preserves_all_existing_keys). Gate on the save touching
   instrument settings; GET already virtualizes profiles.
2. pathway is profile-mirrored, so the Gameplay reset (flat-key delete) was a
   no-op. reset_settings now resets pathway inside the persisted profiles too.
3. Per-profile tuning validation rejected provider/custom tunings (tuner
   plugin, /api/tunings). _valid_tuning_for_key now accepts a name unknown to
   every built-in table while still rejecting a built-in misapplied to the
   wrong key.
4. First-migration overwrote an explicit active_instrument_profile with the
   legacy-inferred one, so a fresh-config switch to 'bass' was lost. Use
   setdefault so an explicit request wins.
5. Pre-existing test_instrument_fields_persist used bass-5 + 'Drop D' (a
   4-string tuning). Updated to the valid 'Drop A'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(settings): partial-merge instrument_profiles; clamp tuning on string-count switch

Two partial-update follow-ups:
- save_settings normalized a POSTed instrument_profiles by FILLING every omitted
  profile with defaults and replacing wholesale, so a one-profile update reset
  the others. Validate each PROVIDED profile individually and merge the partial
  over the persisted set inside the lock — /api/settings is partial-merge.
- the string-count picker posted only string_count, so the backend silently
  reset a now-invalid tuning to Standard while the UI kept the old value
  (settings/tuner desync). Clamp + post the valid tuning too, mirroring the
  instrument-switch path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 00:16:42 +02:00
68e29a8b6e fix(plugins): don't treat transient absence from /api/plugins as uninstall (#741)
* fix(plugins): don't treat transient absence from /api/plugins as uninstall

The backend clears its plugin registry at the start of load_plugins()
and repopulates it incrementally while HTTP stays up, so every backend
restart (desktop: Audio Quality soundfont switch, LAN toggle, update
restart) serves a window of partial — even empty — /api/plugins
responses. loadPlugins() treated absence from the current response as
an uninstall, with three destructive consequences for still-loaded
plugins:

1. Their settings-panel and screen DOM were wiped while their
   _loadedPluginScripts entry survived, so the NEXT refetch failed the
   DOM-existence check and re-evaluated the plugin's screen.js
   mid-session. For the desktop audio_engine plugin that re-ran init()
   against the surviving native audio chain and exactly duplicated
   every VST/NAM/IR stage (the alpha testers' "chain duplicates after
   leaving the Audio menu" / blown-out gain reports).
2. _reconcilePluginStyles dropped their stylesheet, leaving them
   visible but unstyled until they reappeared.
3. The stale-contribution sweep unmounted their UI contributions and
   unregistered their capability participant with no re-registration
   path (plugin scripts don't re-run thanks to the loadedScripts
   guard).

Absence is now a non-signal everywhere in loadPlugins: the DOM wipe and
style reconcile are scoped to plugins the response actually names, and
the absence sweep is removed. Present plugins still fully re-sync via
_registerLegacyPluginUiContributions each round; failed plugins are
present in the response and still cleaned up; nav is rebuilt from the
response so genuinely uninstalled plugins drop out of it, and their
(un-unloadable) already-evaluated scripts keep their DOM until reload.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: update idempotence contract to the absence-is-not-uninstall invariant

The removed-plugin sweep contract pinned the old behavior this branch
deletes; pin the new invariant instead (no absence sweep + respondedIds
scoping on the DOM/style reconcilers).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:45:30 +02:00
d2b2a7e9f7 fix(tests): re-green the JS suite — 18 stale source-shape tests + 1 real seek-reason violation (#740)
main's JS suite has been red since the recent v3-library and player
refactors landed. 17 of 18 failures were test harnesses/regexes that
went stale behind real, intentional code changes; one was a genuine
contract violation in the code.

Code fix:
- session-resume seek passed 'resume' as its _audioSeek reason; the
  documented contract (enforced by song_seek.test.js) requires
  multi-word kebab-case. Renamed to 'session-resume' — no consumer
  string-matches specific reasons, so this is rename-safe.

Test updates (each pins the CURRENT contract):
- highway_colors_facade: inject HWC_PRESETS + applyHighwayStringPreset
  (new preset feature); lock presets/applyPreset into the surface test
- loop_api: stub _updateEditRegionBtn (new edit-region UI hook)
- song_close: sandbox gets window.feedBack.playQueue; assert a real
  close abandons the queue (the new queue-aware behavior)
- v3_keep_practicing: the shelf moved from client-side /api/stats/recent
  dedupe+gating to the server-side practice-suggestions recommender —
  tests now pin that (fetch, arrangement-aware card click, Promise.all)
- v3_songs_tuning: card row variable renamed song → shown (grouped cards)
- live_guitar_tone_source: accept literal ’ where &rsquo; drifted in copy
- legacy_shim_hits: normalize CRLF before fixed-width region() slicing
  (Windows-only failure; char windows shrank by one char per line)

Suite: 987/987 locally (Windows), previously 968/987 (and 18 red on CI).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:35:07 +02:00
OmikronApexandClaude Fable 5 336132e049 fix(v3): keep shuffle toggle size stable across states
Off state had a 1px border, on state none — toggling grew/shrank the
button 2px and shifted the row. On state now carries a same-color
border (border-fb-primary, already in the prebuilt CSS).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:10:16 +02:00
OmikronApexandClaude Fable 5 a2f43009f7 fix(v3): match shuffle icon height to Play all button
w-4 icon (16px) vs text-sm line-height (20px) made the shuffle button
4px shorter than its neighbor at equal py-2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:08:54 +02:00
OmikronApexandClaude Fable 5 425f72b33f feat(v3): playlist shuffle toggle
Crossing-arrows toggle next to Play all / Play album on the playlist
detail page. When on, playQueue.start Fisher-Yates-shuffles the queue
once at start — on a copy, so the stored playlist order is untouched —
swapping per-slot album arrangements in lockstep so each slot keeps its
pinned arrangement (#685 contract preserved). Prev-less queue semantics
are unchanged: auto-advance simply walks the shuffled order.

Preference is global, persisted as localStorage v3PlaylistShuffle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 13:07:47 +02:00
9d6fdfe232 feat(v3): use PNG logo in sidebar nav instead of the text wordmark (#734)
Replaces the fee[dB]ack text wordmark in the #v3-brand sidebar header with the exported PNG logo (static/v3/brand/feedback-logo-light.png, 664x165). Sized width:100% + height:auto so it fits the 256px sidebar's content width (~208px inside the p-6). Updated both the no-JS fallback (index.html) and the shell.js boot render. Inline style avoids introducing a new Tailwind utility (constitution P-II).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 11:39:27 +02:00
be9e965001 v3 library: artist pages — in-your-library view, similar-in-library, links-only web links (#731)
* v3 library: artist pages — in-your-library view, similar-in-library, links-only web

The greenlit artist-pages feature. Every page renders from LOCAL data;
an optional, opt-in external-links strip is the only network surface.

Server:
- artist_enrichment table (mb_artist_id PK, url_rels JSON, genres JSON,
  fetched_at) — never purged; one row per matched MusicBrainz artist.
- GET /api/artist/{name}/page — all-local: canonical name (+ raw alias
  variants), song/album/mastered counts, album list, similar-in-library
  (top artists by shared genre, self excluded, empty is fine), and the
  artist's MB id when any matched/manual song carries one. THE DENOMINATOR
  LAW: "N mastered" counts songs you OWN (best_accuracy >= 0.9 across the
  artist's library songs), never a global discography — a stored score for
  a song no longer in the library does not count.
- GET /api/artist/{name}/links — lazy, cached-forever: returns the cached
  row, else (external links enabled + network + a known MB artist id) ONE
  throttled artist lookup (inc=url-rels+genres+tags), whitelisted into
  {official, tour, video, social[], wikipedia}. Every URL passes the same
  http(s) scheme gate as art redirects, so a hostile javascript:/data:/file:
  can never reach an href. POST .../links/refresh re-fetches. Offline /
  no-mbid / links-disabled → empty, no error. Both routes demo-blocked.
- Settings keys artist_pages_enabled (default ON — local-only) and
  artist_external_links (default OFF — opt-in per the dev-chat thread).

Frontend (static/v3/songs.js): an in-place sub-render mirroring openAlbum()
with a "← Song Library" back + scroll restore. 2x2 album-art mosaic header
(borrows the playlist-cover renderer), canonical name + "also shown as"
variants + a Matched·MusicBrainz pill when known; stats strip that omits
the mastered segment at zero (invitational, never "0 mastered"); Play all /
Shuffle (playQueue) + Save as smart playlist (collections rule {artist});
album rail → openAlbum; song list via the artist filter + wireCards;
"Similar in your library" chips → open that artist; and the external-links
row under an "On the web · opens your browser" divider, each link
target=_blank rel=noopener noreferrer with its domain shown — rendered only
when external links are on AND links exist. Empty modules hide.

Entry points: card ⋮ "Go to artist", the grid card artist line, and a
"View artist page" link in the Details drawer — all via
window.__fbOpenArtistPage.

Tests: tests/test_artist_page.py — page counts/albums/alias folding, the
denominator law (owned-only, best-across-arrangements), similar ranking +
empty, mb-id only from matched rows, links whitelist + scheme gate (a
javascript: and an ftp:// URL both rejected), disabled-by-default no
network, cache-no-second-fetch, refresh, demo block. 21 pass (35 with
artist_alias). node --check clean; tailwind rebuilt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN

* fix(v3 artist pages): unfiltered album view + select-mode row guard on artist page

Artist-page album click no longer applies the global library filters: openAlbum
gains an ignoreFilters option that builds the /api/library request scoped only to
artist+album (no drawer/genre/tuning/search params), so the album view and
Play-album match the artist page's full-shelf counts. The normal albums-view
click path is unchanged (ignoreFilters defaults off).

Select-mode row clicks on the artist page now toggle selection instead of playing.
Extracted the grid/tree capture-phase select guard into a shared bindSelectGuard()
and attach it to the persistent artist-page host too. Each host is bound once at
shell build; innerHTML re-renders reuse the same element, so there is no
double-binding.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-03 08:52:20 +02:00
64a499975e v3 library: multi-candidate cover picker — select from a populated list (#732)
* v3 library: multi-candidate cover picker — select from list, media-server style

Auto-best already ships; this adds the "pick from a populated list" surface
Christian asked for, as ONE reusable component (song covers now; album/artist
art reuse the same picker later).

Server:
- GET /api/song/{filename}/art/candidates — assembles WITHOUT hoarding: the
  Current image + its provenance, Pack art when present, and Cover Art Archive
  candidates for the matched release (and any release ids stored on a review
  row's candidates). New _caa_release_index() fetches the CAA release INDEX
  json (image list + types + thumb sizes) through the existing throttle +
  offline gate, cached as caa_index_{id}.json beside the covers (indexes are
  stable). Capped at 12; fetched on demand. Demo-blocked (it spends the rate
  budget) and offline → instant tiles only, no error.

Frontend: new static/v3/image-picker.js — window.__fbOpenImagePicker({filename,
title}), a body-appended singleton modal (match-review anatomy: overlay, focus
trap, Esc). Current image + provenance badge on the left; a tile grid on the
right whose instant tiles — Current, Pack original, Upload, Paste URL — work
immediately even offline, while CAA candidates load behind ONE /art/candidates
fetch with skeleton tiles + a "the source is rate-limited" caption. The fetch
is tied to an AbortController and cancelled when the modal closes.

Applying a pick reuses EXISTING routes so there's no new write path and the
design's key trick holds: a chosen cover POSTs to …/art/url (the override
lane — never evicted by the art-cache LRU, survives a re-match); "Pack
original" DELETEs the override; Upload POSTs …/art/upload (GIF stays
upload-only + local-only). Silent-on-success; the drawer/card art refreshes
via the existing cache-buster.

Entry points: the Details drawer art click (the old direct file dialog is now
the Upload tile) and a card ⋮ "Change cover…" action.

Tests: tests/test_art_candidates.py (matched row lists index images; review
row pulls in candidate releases; unmatched/offline → instant tiles only;
index cached, no second fetch; demo blocked) over a fake index seam.
30 pass with test_art_layer green (same seams). node --check clean; tailwind
rebuilt for the new file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN

* fix(v3 cover picker): uiPrompt over window.prompt, visible-only focus trap, gate CAA to matched rows, index-cache lock, abort-on-reopen; +traversal tests

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-03 08:49:44 +02:00
8c7cde5d5c v3 library: first-hour polish — zero-states, match progress, provenance, alias search (#730)
* v3 library: first-hour polish — zero-states, match progress, provenance, alias search

Six launch-eve fixes for a brand-new user's first hour with a fresh,
being-matched library. Each is small and reuses shipped idioms.

- Invitational repertoire meter: with no practice data yet, the home meter
  no longer reads "0 of N mastered" (debt framing) — it shows an empty bar
  with "grows as you master songs". A count of 0 read as failure on day one.
- "Start here" starter shelf: growth_edge_suggestions() distinguishes two
  empties — attempts exist but all mastered (honest empty shelf) vs nothing
  attempted yet (day one) → new starter_suggestions() returns up to 8
  approachable songs (90–480s, shortest first) flagged starter:true, and the
  client renders a "Start here" shelf instead of a blank home.
- Library-visible match progress: while the background pass runs, a quiet
  "Matching your library — X of Y" line sits by the review chip (5s poll,
  single guarded interval, cleared the moment the pass stops — no leak,
  no toast, silent completion).
- One-time transparency toast: the first time an install is seen matching a
  real library, one fbNotify names what's contacted (MusicBrainz / Cover Art
  Archive), that results are stored locally, that files aren't changed
  without you, and where the switch is. localStorage-gated, wrapped so a
  blocked notifier can't break the chip.
- Empty-library dead-end card: a genuinely empty local library (no songs, no
  query/filter) shows "Your library is empty" + drop-files hint + Open
  Settings, instead of a bare grid under dead dropdowns.
- Alias-aware search: searching a canonical name ("AC/DC") now also finds
  songs whose raw tag is a merged variant ("ACDC"), via the artist_alias
  table. Probe-guarded so a no-aliases library keeps the exact original
  3-term query; pure predicate, keyset-safe.
- Details-drawer provenance line: matched/manual rows show "Matched:
  <artist — title> (source) · Fix match" under the Identity fields — the
  wrong-match escape hatch at the point of the data, wired to the same
  fix-match flow the card menu uses. New read-only GET
  /api/enrichment/song/{filename} backs it.

Tests: tests/test_starter_suggestions.py (starter vs normal-shelf behaviour,
length window, attempts-exist path unchanged) + alias-search cases added to
tests/test_artist_alias.py. 34 targeted pass; node --check clean; no new
Tailwind classes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN

* fix(v3 library): stop match-progress poll when leaving the library screen

The 5s enrichment poll (_pollTimer) was cleared on pass completion and on
fetch error, but not when the user navigated away from the library. Leaving
v3-songs mid-pass left the interval pinging /api/enrichment/status in the
background until the pass ended. Subscribe to the existing feedBack
'screen:changed' event: clear the poll when any non-v3-songs screen shows,
and refresh (re-arming if a pass is still running) on returning to v3-songs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-03 08:49:04 +02:00
df2d660d1e Re-enable the v3 "Support Us!" donate button (#727)
Funding was cleared to come back online 2026-06-30 (offending
functionality fully removed). Restore the v3 topbar donate button
(hand-re-applied revert of cad7885 — the topbar was refactored since,
so this re-adds the Support Us! anchor alongside the new v3-search-wrap)
pointing at the feedBack-branded Patreon page
https://patreon.com/got_feedback.

Rebuild static/tailwind.min.css: the button's utilities
(bg-fb-accent, hover:bg-red-600, shadow-fb-accent/20, sm:inline-flex)
were purged when the button was removed and are needed again.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 00:21:10 +02:00
f5c9c34291 library: scraper options — per-source + per-field auto-apply, review-queue order (R1) (#726)
* library: scraper options — per-source + per-field auto-apply toggles, review-queue order (R1)

Grows the Settings→Library "Metadata matching" card into the full
scraper-options panel — not everybody needs the same things out of a
scraper:

- Sources: enrich_src_musicbrainz gates the background matcher (phase 2;
  identity hashes still stamp, and manual Fix-match/search stays
  available — same contract as the master toggle). enrich_src_caa gates
  the Cover Art Archive fetch (phase 3). Rows skipped by an off toggle
  stay unevaluated, so re-enabling picks them up on the next pass —
  nothing is permanently forfeited.
- Auto-apply fields: enrich_apply_names/year/genres filter what an
  AUTOMATIC match may canonicalize (_enrich_field_filter, applied on all
  three automatic paths: cache copy, mbid/isrc exact keys, text auto).
  MusicBrainz ids always stamp — they're identity, not display; the art
  fetch and future re-matching need them. A match the user confirms in
  the review modal applies in full. enrich_apply_art gates the art fetch
  alongside the CAA source toggle (two axes, one behaviour today —
  future art sources slot in without re-teaching the panel).
- Review queue order: enrich_review_order = missing_first (default,
  today's behaviour) | artist | recent, read by GET /api/enrichment/review;
  unknown stored values degrade to the default.
- Settings card: Sources / Auto-apply / Review-queue-order groups wired
  in match-review.js; the master toggle is relabelled "Match songs
  automatically" so it doesn't read the same as the new MusicBrainz
  source toggle. No tailwind rebuild needed — every class was already
  scanned from core source.

Tests: tests/test_scraper_options.py (9) — settings validation,
MB-source-off stamps-without-matching + re-enable, per-field stripping
on auto matches with ids preserved, review-accept full-apply despite
toggles, CAA gating on both axes, review-order modes incl. the
unknown-value fallback. Full-suite failure set A/B-identical to the
base (39 env/pre-existing).

Stacked on feat/enrichment-art (#715) — merge that first.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN

* library: per-field auto-apply honours "nothing forfeited" (backfill + no partial seeding)

The R1 per-FIELD auto-apply toggles settled a `matched` row with the
disabled fields stripped, but enrichment_pending() never revisits an
unchanged-hash matched row — so re-enabling a field never backfilled it,
and enrichment_cache_lookup() (which gates only on mb_recording_id) could
seed sibling charts with the stripped blanks. This broke the same
"nothing is permanently forfeited" contract the source (mb_on) and art
(art_on) toggles already keep.

Fix: persist an `apply_mask` marker (sorted blocked apply-keys) on every
AUTOMATIC match:
- migration: additive `apply_mask TEXT` column (idempotent ALTER).
- enrichment_pending(allowed_keys=...): re-queues a `matched` row whose
  apply_mask names a field that is now re-enabled → backfill on re-enable,
  converges (a fully-applied row is never re-queued).
- enrichment_cache_lookup: only fully-applied donors (apply_mask empty/NULL)
  may seed siblings; a partial row is skipped and the sibling falls through
  to its own re-filtered match.
- _enrich_apply_mask()/_enrich_blocked_apply_keys() helpers; threaded through
  _enrich_one → apply_enrichment_match. Review/manual writers leave it NULL
  (a confirmed pick applies in full).

Tests: re-enable-backfills-and-converges; partial row is not a cache donor
(fully-applied one is). 13 scraper-options tests pass; 170 enrichment/
settings tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 20:57:42 +02:00
8e953e8bc4 library: opt-in gap-fill — write confirmed missing metadata into the pack (R4a) (#724)
* library: opt-in gap-fill — write confirmed missing metadata into the pack (R4a)

The write-back contract agreed with the spec chair (alignment doc §7),
made executable, now that feedpak-spec 1.14.0 (mbid/isrc) is merged:
opt-in + user-initiated, adds ABSENT keys only, spec'd-keys allowlist,
values only from a CONFIRMED identity, atomic write + .bak. Single-song
only — batch write-back stays an open question with the chair.

- songmeta.gap_fill_sloppak: append-only manifest writer. Every added
  key is absent by definition, so the new lines are APPENDED — the
  author's existing bytes (key order, comments, formatting) survive
  verbatim, unlike the metadata editor's full re-serialize. Directory
  form gets a one-time manifest.yaml.bak + temp + atomic replace; zip
  form reuses the editor's backup/temp/replace rewriter. Raises on any
  already-present key: the never-clobber rule lives in the writer, not
  just the callers.
- GET /api/song/{fn}/gap-fill: read-only preview — which of
  album/year/genres/mbid/isrc are missing from the file (absent or
  empty; year 0 = empty), with the values the enrichment match
  supplies. Only a CONFIRMED identity is eligible (matched or a user
  pin); review-tier rows are refused until a human confirms —
  wrong-match > fast, same as everywhere else in the enrichment layer.
- POST /api/song/{fn}/gap-fill {keys}: writes the user-confirmed
  subset. Proposals are RECOMPUTED under _song_io_lock, so a key that
  gained an author value between preview and confirm is skipped, never
  replaced. mbid/isrc written in canonical form only (validated).
  DB stays scanner-consistent (album/year/genre columns + mtime/size
  re-stat, cache invalidation + scan kick — the metadata editor's
  contract). Demo mode blocks the write.
- Details drawer (Identity section): "Write missing info to file…" →
  per-key checkbox confirm ("Only adds what's missing — nothing already
  in the file is changed. A backup (.bak) is kept.") → written
  confirmation; not-eligible states explain themselves. v3 only; no
  new tailwind classes.
- Rides along: _manifest_exact_ids now strips ISRC display separators
  (spec 1.14.0's strip rule) — a hand-authored "AU-AP0-90-00045" hits
  the exact-match tier instead of silently falling back to text.

Tests: tests/test_gap_fill.py (10) — preview eligibility incl.
review-refusal + empty-as-gap, author-bytes-preserved-verbatim on dir
AND zip (with .bak content pinned), skip-not-replace on the mixed
request, the writer's ValueError guard, key validation, demo block,
DB sync; +1 hyphenated-ISRC test in test_mb_enrichment.py. 46 targeted
green; full-suite failure set A/B-identical to the main base (39
env/pre-existing).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN

* gap-fill: align preview with append-only writer (no cleared-value 500)

The R4a preview offered present-but-empty manifest values (album: '',
genres: [], year: 0) as gaps, but the append-only writer's never-clobber
guard raises on ANY key already present — so a user-confirmed POST for
those keys turned into a 500 "write failed" instead of filling the gap.
Appending can't fill an empty-but-present key anyway (it would duplicate
the YAML key).

Fix: _gap_fill_manifest_absent now treats only genuinely-MISSING keys as
gaps; a present-but-empty value is left to the metadata editor (which
re-serializes and can replace in place). This closes the preview→POST
mismatch — the preview never offers what the writer would refuse.

Tests: test_preview_treats_empty_values_as_gaps replaced by
test_preview_excludes_present_but_empty_keys (present-but-empty not
offered; genuinely-absent still offered) + test_write_present_but_empty_
key_is_refused_not_500 (POST → clean 409, file untouched, no .bak; a
genuinely-absent key alongside still writes). Closes the write-path blind
spot in the original empty-value test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-02 20:52:11 +02:00
55060c4f67 v3 library: artist sort orders titles within an artist (tree-view feel) (#720)
* v3 library: artist sort orders titles within an artist (tree-view feel)

Tester report: "the list is set up by artist, but the cards are
alphabetical(-ish random)". Real: the tree orders artist -> album -> title,
while the grid's artist sort ordered within an artist by RAW FILENAME —
community-pack filename noise, so an artist's cards looked shuffled.

- artist / artist-desc gain a title secondary (direction baked per entry so
  the legacy `dir=desc` append can't land on the title term; titles stay
  A->Z under Z->A artists).
- The two-term (value, filename) keyset cursor can't seek a three-term
  order, so artist sorts leave _KEYSET_SORTS and page by OFFSET — measured
  trivial at real library sizes; title/recent keep their keyset. Restore
  via a composite sort-key column if 50k-song libraries ever hurt.
- The tree view says "List view groups by artist — the selected sort
  applies to the card grid" when a non-artist sort is active, instead of
  silently ignoring the picker.
- Keyset proof-tests repinned to the title sort (same property, a sort
  that still keysets); 2 new tests pin the title-within-artist order and
  the OFFSET pagination's no-skip/no-dupe across pages.

Full-suite failure set identical to the same-main baseline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN

* v3 library: honor legacy sort=artist&dir=desc (fold dir into effective sort)

Codex/review follow-up to the title-within-artist change: the new artist
ORDER BY bakes in `ASC` (for the title secondary), so the global `dir=desc`
append is suppressed and `sort=artist&dir=desc` silently returned A->Z
instead of Z->A — a regression on the legacy /api/library dir contract.

Fold `dir=desc` into the canonical sort key BEFORE the sort_map lookup via
the existing _effective_keyset_sort helper (same fold the cursor side already
does), so the ORDER BY is built from the effective sort. Only artist/title
fold (they have `-desc` twins); title/recent/tuning/year/mastery are
unaffected — verified by the keyset/filter suites.

New test pins that legacy `sort=artist&dir=desc` matches the explicit
`artist-desc` ordering (Z->A artists, A->Z titles within each).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-02 20:51:33 +02:00