mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-25 22:31:48 +00:00
main
144 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
0e3522ccc3
|
feat(player): drum-part picker for multiple drum charts (re-land of #1021) (#1028)
Some checks are pending
ship-ci / ci (push) Waiting to run
* feat(player): drum-part picker for multiple drum charts (feedpak 1.17.0) The last mile of the multiple-drum-parts feature: let a player CHOOSE which drum chart plays. #1020 taught the loader + highway WS to carry several drum parts (song_info.drum_parts + ?drum_part=<id> + a part_id echo on drum_tab); this adds the host-chrome selector that drives it. A "Drum part" <select> sits beside the arrangement switcher in the advanced settings popover, shown only when a song has 2+ drum charts (drum_parts is always present — empty for non-drum songs — so single-drum / no-drum songs hide the row and nothing changes for them). Selecting a part re-streams that part's tab over the highway WS, exactly like an arrangement switch. - static/highway.js: - reconnect() gains a third `drumPart` arg → sets `?drum_part=<id>` on the WS URL (mirrors the existing `arrangement` param one line up). Empty/undefined → the primary part, i.e. byte-identical to today for any pack untouched. - song_info handler populates #drum-part-select from msg.drum_parts and shows/hides #v3-drum-part-row on `length > 1` (parallel to the #arr-select block right above it). - drum_tab handler carries msg.part_id onto hwState.drumTab (plugins can read bundle.drumTab.part_id) and reflects it as the picker's selected value, so the dropdown stays honest even when the server resolves an unknown/absent selection to the primary. - static/app.js: - changeArrangement() gains an optional `drumPart`; at reconnect it forwards the explicit part, else preserves the current picker selection — so an ARRANGEMENT switch keeps the chosen drum part (parts are song-level). - new changeDrumPart(id) delegates to changeArrangement with the current arrangement held + the new part applied (a part switch is the same re-stream, so it reuses all the transition ceremony). Exported on window. - static/v3/index.html: the #drum-part-select row (hidden by default). No plugin change: the drum renderers just draw whatever drum_tab streams. RUNTIME-VERIFIED (Playwright, the core player, a 2-drum pack + a no-drum pack): 10/10 — the picker populates with both parts and shows for the multi-drum song; song_info.drum_parts reaches getSongInfo(); the primary is pre-selected; selecting the 2nd part drives highway.reconnect with the id and the WS URL carries `?drum_part=drums-2`; the picker then reflects the server's part_id echo; a no-drum song hides the row; no page errors. ESLint 0 errors (the two max-lines warnings are pre-existing on these files). No pytest touched (JS-only). Stacked on #1020. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xGPjDBF8NTwTK7VQvizix Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com> * Update reconnect source contract test --------- Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
270cb39f41
|
Enable Linux nightly AppImage auto-update in the System settings UI (#999)
Some checks are pending
ship-ci / ci (push) Waiting to run
* fix(settings): enable Linux nightly AppImage auto-update in System settings
Fixes the Settings → System "App updates" panel so it actually works on
Linux, and adds Nightly as a selectable channel — previously missing
entirely, so Linux self-update couldn't be reached from this UI at all.
- The channel dropdown no longer gets permanently disabled the moment
the desktop bridge reports 'unsupported', which is the normal state
whenever the channel isn't Nightly on Linux. It stays enabled so the
user can switch to Nightly, the only way out of that state.
- Shows live download progress ("Downloading update… N%") and an
explicit button state machine (Check → grayed out while busy →
Restart now once staged), instead of a frozen "Checking…" during the
~1.5GB background download.
- Renders every status update from the triggering action's own return
value (checkNow()/setChannel()'s result) rather than a separate
follow-up getStatus() call, which can race against other state
changes and show a stale result even after a real success.
- setupAppUpdates() no longer re-syncs the channel to the backend on
every Settings-panel re-render — only once per page load — so a
redundant sync can no longer stomp an in-flight download's state.
- Routes update-flow events into the existing diagnostics.js
console-capture + contribute() snapshot API, so the user's existing
"Export Diagnostics" button now captures the full update decision
trace end to end — no new UI or log file. This diagnostic tracing is
what actually root-caused the bugs above, from real on-device
captures rather than guesswork.
Companion PR in feedBack-desktop (the underlying update engine).
Verified end-to-end on a Steam Deck: channel switch → check → live
download progress → restart button → relaunch onto the new build,
confirmed via a real Export Diagnostics capture showing a clean,
fully-accounted-for trace.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(settings): extract + unit-test the app-update status view; dedupe diag log
- Extract the status→UI state machine from renderFrom into a pure, exported
_appUpdateStatusView() (DOM-free) and cover it with tests/js — settings.js's
large module graph made importing it for a full harness impractical, so the
pure function is the testable seam. Behavior-preserving; renderFrom applies
the returned shape to the DOM exactly as before.
- Dedupe the [update-diag] renderFrom console line so the ~1.5s download poll
no longer floods the diagnostics ring buffer with byte-identical entries;
every real state/percent change still logs, and the structured contribute()
snapshot stays unconditional.
Left the 'audio_engine' diagnostics key as-is: the server export filters
client contributions to loaded plugin ids (diagnostics_bundle.py path-traversal
guard), so a dedicated key would be silently dropped from the bundle.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Byron Gamatos <xasiklas@gmail.com>
---------
Signed-off-by: Byron Gamatos <xasiklas@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Byron Gamatos <xasiklas@gmail.com>
|
||
|
|
23c509322b
|
Add gamepad/controller support (#1001)
* feat(input): add gamepad/controller support
Adds full gamepad/controller navigation and playback control, driven
by requests from players who use fee[dB]ack on a TV/console setup and
from wheelchair users for whom a controller is far more convenient
than a keyboard + mouse. Confirmed working end-to-end on a Steam Deck
across several rounds of on-device testing.
- static/v3/gamepad.js: polls navigator.getGamepads() and dispatches
synthetic keydown events (Arrow/Enter/Space/Escape) on the focused
element (falling back to document), reusing the app's existing
keyboard pipeline (static/js/shortcuts.js's scope-aware dispatcher,
player shortcuts, text-field/modal guards) instead of a parallel
action-mapping table. Only acts on gamepads reporting the W3C
"standard" mapping — which is what Steam Input presents for the
Deck's built-in controls, both in Gaming Mode and in Desktop Mode
via a non-Steam shortcut — so button order is guaranteed correct
and a non-standard/raw device safely no-ops instead of misfiring.
Handles Steam Input's virtual-pad duplicates (a real controller
plus 1-2 mirrored XInput slots) without spamming connect toasts or
losing input when the live pad isn't at index 0. Xbox-style face
button mapping: bottom face = Space (play/pause, and activates the
focused control), right face = Escape (back), top face reveals the
player screen's tool rail (focuses it into visibility via the
existing CSS :focus-within rule). D-pad/stick repeat while held,
mirroring OS keyboard auto-repeat.
- static/v3/gamepad-nav.js: fills the one real gap in that reuse
strategy — no screen but the song library grid had any arrow-key
navigation, and Chromium doesn't run native Enter/Space button
activation for untrusted synthetic events even when dispatched at
the focused element. Gated entirely on `!e.isTrusted`, so it only
ever reacts to gamepad-originated events and never touches real
keyboard/mouse users: emulates Tab-order (the sidebar + active
screen's real, already-focusable buttons/links) for Arrow keys,
explicitly .click()s the focused element for Enter/Space, and gives
Escape a consistent "go back" behavior — an existing in-screen back
button if one's visible (reusing each screen's own drill-down logic
for free), else the main menu. Every branch defers via
`e.defaultPrevented` to any screen that already handles the key
itself (the song grid, the player, settings), so nothing here
overrides existing behavior.
- static/v3/songs.js: adds real 2D d-pad/arrow-key navigation to the
song library's virtualized grid (only a slice of the library is
ever in the DOM), including fetching/scrolling off-screen rows into
view and correcting for the sticky filter toolbar's occlusion.
- static/v3/index.html: wires up the two new scripts.
* chore: regenerate stale tailwind.min.css
Rebuilt in a fresh clone (not the local working copy). Several plugin
directories (audio_engine, plugin_manager, community_charts, etc.) are
gitignored locally but present on disk from checking out plugin repos
for local dev/testing — Tailwind's content scan picks them up
regardless, so a rebuild against the contaminated local working copy
bakes in extra utility classes that don't belong in the real,
git-tracked build. A clean checkout reproduces CI's expected output
exactly.
* fix(gamepad): check all matching back buttons, not just the first
document.querySelector on the combined [data-ap-back], [data-albums-back],
#v3-pl-back selector only ever inspects the first match in DOM order —
since screens stay in the DOM (hidden, not removed) when you navigate
away, a hidden back button from an unrelated screen could sort before
the one that's actually visible, incorrectly falling through to
showScreen('v3-home') instead of clicking it. Uses querySelectorAll +
find(visible) instead.
* fix(gamepad): address CodeRabbit findings on connect/disconnect and grid nav
- gamepad.js: anyLiveConnectedPad -> anyLiveStandardPad, filtering by
mapping === 'standard' like firstLiveStandardPad already does, and
applied at the top of the gamepadconnected handler too. A still-
connected non-standard raw mirror could otherwise mask the real
pad's disconnect (toast never fires, polling never stops).
- songs.js _gpMove: an unset cursor now always seeds at index 0
before the first press, instead of applying that press's delta
immediately (ArrowDown/Right previously skipped straight past row
0; Left/Up only looked right by accident of clamping). Matches the
existing convention in shortcuts.js's legacy _handleLibArrowNav.
- songs.js _gpBlockedTarget: form-control/button blocking now
requires the element to be visible (offsetParent !== null), not
just present. Screens stay in the DOM hidden (not removed) when you
navigate away, so a real button focused on some other now-hidden
screen could leave document.activeElement pointing at it and block
all grid navigation indefinitely. (An el.closest('#v3-songs') scope
was tried first and reverted — it fixed that case but broke
blocking for the topbar search input, which lives outside
#v3-songs's DOM subtree even while v3-songs is active; visibility
is the distinction that actually matters, not DOM nesting.)
Skipped two CodeRabbit suggestions, verified against current code:
gating songs.js's grid keydown listener to synthetic-only events
would regress the real keyboard accessibility this PR intentionally
added (v3-songs' grid had none before); renaming the _gp* helpers to
drop their underscore prefix would break from this codebase's own
established module-private naming convention.
Verified in-browser: first arrow press lands on index 0, stale hidden
focus no longer blocks grid nav, the topbar search input still
correctly blocks it, and normal nav resumes after blur.
* test(gamepad): unit-cover the controller + nav state machines
- gamepad.test.js (10): standard-mapping filter, Steam Input duplicate-slot
dedup, disconnect masking, button edge-detection, d-pad/stick repeat timing,
analog deadzone — driven via a fake navigator + manual rAF queue.
- gamepad_nav.test.js (10): !isTrusted/defaultPrevented gating, arrow focus
traversal + clamping, hidden-element skipping, Enter/Space click activation
(not into text fields/body), Escape visible-back-button vs home fallback.
songs.js grid nav is left to on-device coverage (async + windowed-DOM heavy).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Byron Gamatos <xasiklas@gmail.com>
---------
Signed-off-by: Byron Gamatos <xasiklas@gmail.com>
Co-authored-by: Byron Gamatos <xasiklas@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
05be9ebdbe
|
Add new chart-transform plugin capability (#1000)
* Chart-transform plugin capability * PR comments * Cleanup * Fix markdown * CodeRabbit feedback Signed-off-by: Joe <jphinspace@gmail.com> --------- Signed-off-by: Joe <jphinspace@gmail.com> Co-authored-by: Byron Gamatos <xasiklas@gmail.com> |
||
|
|
39d1a8cb9b
|
feat(v3): one-click "Not split" library filter + piano stem pill (#1010)
Finding un-split songs took five taps (cycle each stem pill to its "lacks" state) — and was quietly wrong even then: the drawer offered five of the canonical six stems, so a piano-only song lacked all five listed and matched a hand-built "not split" filter despite being split. The stems section gains a "Not split" toggle that sets stem_lacks to every instrument stem in one tap (the same lacks-ALL query Stem Splitter's missing-stems view runs, backend semantics unchanged), and piano joins the pill row (already in the backend's allowed set). (Rebuilt on current main after #1003/#810/#92e78be rewrote the drawer region — the original branch conflicted whole-file.) Signed-off-by: topkoa <topkoa@gmail.com> |
||
|
|
1745b13ba7
|
feat(playlists): flag songs that are not in your current tuning (#1009)
* feat(playlists): flag songs that are not in your current tuning
Making the library's tuning filter instrument-aware does not repair playlists
already built under the old guitar-first behaviour. Those keep their
wrong-tuning songs, so a player still hits a surprise retune mid-practice and
reasonably concludes nothing was fixed.
Adds a per-playlist check: each row is marked against the player's current
tuning, with a summary ("3 of 24 songs are not in your tuning"), a filter to
show only those, and an explicit removal that lists every affected song by
title and states they stay in the library. Flagging is the feature -- nothing
is ever removed without being asked for, and removal reuses the existing
per-song DELETE rather than adding a bulk destructive endpoint.
Reuses the tuner capability's coverage report and `window.feedBack
.workingTuning`, the same pair the library cards already score against,
rather than introducing another source of truth.
Two deliberate departures:
- A coverage report reads "not covered" both for a real mismatch and for a
bail-out it could not evaluate. Only a report carrying an actual reason
counts as a mismatch; the rest render as unknown. This differs from the
library grid, which paints every not-covered song amber -- acceptable on a
grid, not on a hand-curated playlist where a false warning costs trust.
- With no tuning perspective available it makes no claim at all, rather than
defaulting to guitar and reproducing the original bug in a new place.
Playlist rows carry `tuning_offsets` and `bass_only`; a tuning *name* cannot
be scored, since two "Custom Tuning" rows are different tunings.
Fully correct once the instrument-aware tuning filter lands. That dependency
is confined to `rowTuningForCheck()` in static/v3/playlists.js, marked SEAM.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SFDokqh2H6mEjk1Kgbi6JW
Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
* build(tailwind): regenerate for the playlist tuning-check classes
CI's tailwind-fresh gate rebuilds static/tailwind.min.css and hard-fails if
the committed file differs. The new chip/summary/filter markup introduces
classes the previous build never saw.
Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
* fix(playlists): stay within the shipped Tailwind class set
Reverts the regenerated static/tailwind.min.css and reworks the tuning-check
markup to use only classes already in the committed sheet.
Regenerating that file is not reproducible off CI: nothing pins tailwindcss,
autoprefixer or caniuse-lite, so a local `npx -y tailwindcss@3.4.19` resolves
different browser data and rewrites unrelated bytes -- a clean checkout of
main rebuilds with the -webkit-backdrop-filter prefixes dropped. Committing
that output fails the tailwind-fresh gate no matter how many times it is
regenerated.
Six utilities were new: bg-fb-good/10, border-fb-accent/50,
hover:bg-fb-accent/10, list-disc, list-inside, max-h-48, plus gap-x-3/gap-y-2.
Substituted bg-fb-good/30, the amber border already used by the mismatch
state, hover:bg-fb-card, a literal bullet in a div, max-h-32 and gap-3. Visual
intent is unchanged.
The removal-confirm test pinned the <li> markup; it now accepts either
wrapper, since what it guards is that every song is named and escaped ahead
of any DELETE, not which element wraps it.
Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
* Use instrument tuning in playlist checks
---------
Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
cc75cb876a
|
fix(library): tuning filter answers for your instrument, not always guitar (#1003)
* fix(library): tuning filter answers for your instrument, not always guitar The library indexed exactly one tuning per song, chosen guitar-first (lead > rhythm > combo, bass only as a last resort), and nothing consulted the player's instrument. A bassist filtering by tuning was shown the guitar chart's tuning, so playlists built by tuning contained songs needing a retune. Reported by a tester building bass practice sets; Covet "Shibuya" is the clean case, with a custom guitar tuning over a standard bass chart. Indexes each arrangement role's own tuning and makes the facet, filter, sort and labels answer for one perspective. `guitar-lead` reads the original unprefixed columns and adds no payload keys, so the default response is unchanged. The same defect existed inside guitar -- lead and rhythm charts can disagree -- so perspective is three-valued (guitar-lead, guitar-rhythm, bass) driven by one PERSPECTIVES table rather than parallel column families. Songs with no chart for the perspective fall back to the song-level tuning rather than vanishing (18 of 59 packs in the test library have no bass chart), but the fallback is marked inferred in the facet counts and on the row instead of being silently coalesced. "Only real charts" reuses the existing `arrangements_has` filter rather than adding one. Bass-specific handling, from measured content: - Bass tuning arrays are padded to six entries; charts never reference string index 4 or 5. Truncated to four before naming and grouping. - Grouping uses a canonical open-pitch key, so [-2,0,0,0] and [-2,0,0,0,0,0] are one facet row instead of two. - Offsets above +1 semitone are refused a name. Bassists tune down, near never up; one pack ships [5,5,5,5,4,4] (A-D-G-C, unplayable, and its own notes sit in the song's real key under standard tuning). Naming that would send a player to retune to a tuning that does not exist. Rhythm deliberately does not truncate -- padding is a bass finding, and cutting a seven-string array would invent a tuning the chart lacks. Adds an opt-in `tuning_match=playable` mode alongside exact match: a chart is offered when your lowest open pitch is at or below its lowest open pitch, so a five-string bass covers four-string standard and drop-D with no retune. Open strings only -- note range is not indexed and the scan stays manifest-only -- so it fails conservative: unknown low pitch is excluded, and the upper bound is unchecked and documented rather than guessed. Existing installs would otherwise never populate: the tree-signature fast path reports "unchanged" forever on a settled library. Rows with NULL marker columns re-extract, and the fast path is disabled until that backfill converges (writes use '' rather than NULL, so it self-clears). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SFDokqh2H6mEjk1Kgbi6JW Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com> * test(v3): accept the tuning-perspective indirection in the badge guard The album-art badge now reads shownTuningName(), so the source-pattern guard no longer matched the inline `tuning_name || tuning` form and CI went red. Accept the helper, and pin the helper's own fallback in a companion test so the guard still fails if a guitar player's tuning label is ever dropped. Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com> --------- Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
f0d9c3abc0
|
feat(playlists): manual drag order + Sort A-Z for the playlist list (#1004)
Playlists could only ever be listed alphabetically (system playlists first). Users who group playlists by purpose had no way to put the ones they reach for daily at the front. Adds a nullable `position` column and orders by `(system_key IS NULL), (position IS NULL), position, name COLLATE NOCASE`, so manually-ordered playlists lead, unpositioned ones keep sorting alphabetically behind them, and system playlists stay pinned first. Drag-reorder mirrors the existing within-playlist song reorder, adapted for grid tiles (insert side decided on the horizontal midpoint since tiles flow left-to-right then wrap). System playlists are neither drag sources nor drop targets. `POST /api/playlists/reorder` requires an exact permutation of the current non-system ids, so a duplicate, omission, extra, unknown id, or a system id is rejected rather than silently producing duplicate positions; booleans are rejected explicitly because `sorted([True, 2]) == sorted([1, 2])` would otherwise slip through the permutation check. `POST /api/playlists/sort-alpha` clears the manual order again. Claude-Session: https://claude.ai/code/session_01SFDokqh2H6mEjk1Kgbi6JW Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
2f2a095e4c
|
fix(venue): fly in once per set, not before every song (#978)
Some checks are pending
ship-ci / ci (push) Waiting to run
Tester, mid-gig: "the second song in the gig started when the first one ended. But it showed the flyover intro again." The flyover is arriving at the venue, and you arrive once. #968 stopped it replaying on an arrangement SWITCH (same filename), but a gig's song 2 is a genuinely different file, so it took the full-teardown path and played the arrival flyover again — the camera flew in from the back of the room before every track of the set. The play queue now answers isContinuation(): false for the first song of a set (or a standalone play — an arrival), true for song 2..N. onSongLoaded carries the room over to the new song's loop on a continuation, and only a real arrival plays the intro. Verified on the built AppImage: isContinuation goes false (song 1) -> true (song 2) across an advance, and song 2 no longer flies in. Also confirmed NOT a bug, same session: "didn't show the author for the second song." The credits card shows on a queue advance whenever the song carries authors — reproduced with a song that has them as the advanced-to track. The tester's song 2 simply had no `authors:` metadata (most auto-converted feedpaks don't). No code change. Tests: isContinuation across start/advance/clear, and that onSongLoaded gates the flyover on the continuation check. Both fail on pre-fix source. JS 1214/1214. |
||
|
|
4e0e3c5417
|
fix(venue/highway): flyover replay on arrangement switch, venue on Virtuoso, and the paused throttle starving the venue (#968)
* fix(venue): don't replay the flyover on an arrangement switch; keep the venue off other screens Two bugs from a live career session. 1. CHANGING ARRANGEMENT REPLAYED THE ARRIVAL FLYOVER. changeArrangement() reloads the song through the normal load path, so highway.js re-emits `song:loaded` — same filename, new arrangement. The venue could not tell that from a fresh arrival, so it reset the machine and flew the camera in from the back of the room again, mid-set, every time the player switched lead -> rhythm. The player is already on stage. onSongLoaded now compares the filename. A repeat of the song already on stage keeps the video pipeline running and only re-syncs the mood: the performance restarts, so the loop follows the reset machine with a quiet crossfade, never the intro. A genuinely different song still gets the full teardown + flyover. 2. THE VENUE SHOWED UP ON THE VIRTUOSO HIGHWAY. The venue was gated purely on `isVenueViz()` — the selected visualization, which is a GLOBAL preference and says nothing about what is on screen. Virtuoso borrows the same highway_3d renderer for its practice charts, so with Venue selected it inherited the backdrop: the crowd and the stage behind a chromatic exercise. Selecting Venue is a preference for the PLAYER; it is not a licence to paint the venue over whatever else happens to be using the renderer. The venue is now gated on viz AND screen (`shouldBeActive`), and follows `screen:changed` — it tears down on leaving the player and rebuilds on return. Nothing else changes: stop() already unbinds the videos from the renderer, so deactivating is enough to clear the backdrop. Tests: both decisions exposed as pure predicates and pinned — arrangement switch vs new song (including the first load, and a malformed payload that must not suppress the flyover forever), and the venue's screen scope. The existing syncViz test encoded the OLD contract (activate regardless of screen), so it now states the new one and additionally asserts the venue does NOT activate on virtuoso. Includes a guard test: with Venue selected AND on the player, the venue IS active — without it, every "not active" assertion could pass vacuously. All 8 new/updated assertions fail against the pre-fix source. eslint clean; JS 1199/1199; pytest 2597 passed. * fix(highway): the paused-frame throttle was throttling the whole venue Pausing the song dropped the venue, the crowd and the stage to ~10 fps — "everything around the highway drops fps by a lot". draw() caps paused frames to one per _PAUSED_FRAME_INTERVAL_MS (100ms), on an assumption stated plainly in highway-constants.js: a heavy WebGL renderer "does a full render every frame even while paused. That is pure waste." That was true when a paused chart was a still picture. The venue broke the assumption. Its video backdrop keeps playing and its crowd reacts on a clock of their own, and BOTH are drawn into the same canvas as the notes — so a throttle aimed at static notes throttled the entire room. The scene only got a texture upload 10 times a second while the transport sat paused. Renderers can now declare that their picture is not static while the chart clock is stopped: an optional needsContinuousFrames(). The throttle is skipped only when it returns exactly true, and the probe fails closed — a renderer that doesn't implement it, or one that throws, keeps the throttle unchanged. So the GPU saving that motivated #654 survives everywhere it was actually valid. highway_3d implements it and claims continuous frames ONLY while a crowd video is genuinely rolling (bound, unpaused, not ended, readyState >= 2). With no venue pack — the common case — the paused scene really is static, so it keeps the throttle and the GPU still idles. Tests extend tests/js/highway_pause_throttle.test.js, which guards this code path source-level (the draw loop owns the rAF + WebGL lifecycle and is deliberately not reproduced in a vm — see the file header). The new guards pin that the capability GATES the early return rather than merely being called near it, that the probe fails closed on absent/non-function/throwing/truthy-but-not- true, and that the 3D renderer keys off the real video elements and can still return false. All 3 fail against the pre-fix source. eslint 0 errors; JS 1202/1202; pytest 2597 passed. |
||
|
|
6272af8d33
|
feat(career): profile passport wall, home career card, shareable PNG card (#955)
* feat(career): profile passport wall, home career card, shareable PNG card Career v3, WS2. The identity artifact leaves the plugin tab: - Profile: #v3-profile-passports-mount (core, one div) filled by career on v3:profile-rendered — per-instrument shelves of earned covers, hours, gig count, open-career link. Absent-not-empty. - Home: the plugin-count stat tile becomes #v3-dash-career-slot with the old stat as fallback content; career replaces it with a trading-card tile (leather + foil shine, badge count, hours, closest-stamp ask) on the existing v3:dashboard-rendered event. - Shareable card: static/js/blob-io.js (downloadBlob lifts the idiom duplicated verbatim in settings-io/diagnostics-export — both refactored; copyImageBlob wraps ClipboardItem, returns false to signal the download fallback). Earned passports get Save/Copy card: a natively-drawn 480×640 canvas (leather, stamp ring, stubs+hours line); copy falls back to download with a notice when the clipboard refuses. - Mount-point convention documented in docs/plugin-v3-ui.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(career): external surfaces stay absent until a passport exists CodeRabbit on #955: a bare commitment produced a zero-passport wall and replaced the dashboard fallback. Docs also now say mounts may hold fallback content and plugins REPLACE, never append. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
3e57ba0345
|
fix(career): hours polish — recency stamp + non-2xx POST is a failure (#947)
CodeRabbit follow-up on #942: - add_play_seconds() now stamps last_played_at (like touch_position): an unscored play that ran to the natural end WAS played — recent / Continue ordering must see it. Resume position stays untouched. - stats-recorder post() treats non-2xx as failure: a 4xx/5xx JSON error body parsed as an object read as success, silently dropping the accrued seconds instead of re-queuing them. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
0fc6a4beed
|
feat(career): hours-per-genre odometer — honest wall-clock play time (#942)
Career v2, WS2. Nothing measured play time before (the achievements plugin's final-position shortcut double-counts loops and mis-reads seeks). Now: - stats-recorder.js accrues WALL-CLOCK seconds across song:play/resume ↔ pause/stop/ended spans (single spans clamp at 2h against suspend inflation) and piggybacks them as `seconds` on the POSTs it already sends; failed POSTs restore the accumulator; a session reset flushes first so time can't re-attribute to the next song/arrangement. - POST /api/stats accepts optional `seconds` (finite, 0 < s ≤ 6h) on the scored and position branches, plus a new seconds-only branch for unscored plays that ran to the natural end — banks time WITHOUT touching the resume position (song:ended must not overwrite Continue) and still counts as playing today for the streak. - song_stats gains additive idempotent `seconds_total`; record_session/ touch_position accrue, new add_play_seconds() for the seconds-only path; the legacy-encoding stats merge sums seconds across duplicates. - Passports surface it: "14.2 h in Blues" under the badge stamp and on the shelf cover sub-line — a true fact that only grows, never a target or a meter (Stage 5 post-cap, per the career design). Tests: seconds accrual/validation/seconds-only branch (stats API), per-instrument-and-genre summing (career), fmtHours formatting (vm). Full suites: pytest 2480, JS 1165. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
d26347981c
|
feat(career): badge ceremony — the crowd erupts, the stamp drops (#941)
Earning a genre badge now stages the full moment (career v2, WS1):
- venue-crowd.js gains a public celebrate(): machine.force('ecstatic')
commits instantly (bypassing STABLE_MS/DWELL_MS; the stamped
lastSwitchAt makes the dwell window HOLD the forced state before the
real perf machine reasserts) + a cheer stinger via the same
_lastStingerAt=-Infinity bypass the end-of-song reaction uses. If a
stinger/intro owns the idle layer, the ecstatic loop is queued via
_pendingLoop exactly like onPerformanceState. No-op without a
manifest/active venue.
- career detectNewBadges() calls badgeCeremony(): crowd first, then a
body-appended full-screen overlay 300ms later (it cannot live in
#pp-overlay — #plugin-career is display:none during playback): dimmed
backdrop, the bronze stamp slamming in with a shine sweep, a 42-piece
canvas confetti burst, click-or-4s dismiss.
- prefers-reduced-motion: chime + fbNotify only, no overlay.
Tests: machine.force commit/dwell-hold/bogus-state, celebrate export +
no-manifest no-op, celebrate-called-once-per-badge, crowd-absent and
crowd-throwing degradation.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
45caa86ab8
|
Bar pack v4 refresh + crowd sound reactions (#940)
* chore(career): refresh bar pack to v4 + restore crowd-SFX setting Pack v4: per-character desynced animation starts, flyover intro, per-venue reaction sounds (sfx-up/sfx-down in manifest). Settings: re-add the crowd sound reactions toggle that was dropped when settings.html became the passports data panel. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(v3): crowd mood-change sound reactions (cheer up / boo down) Port the venue-crowd SFX runtime that the settings toggle and the pack's sfx-up/sfx-down files were built for: on a committed mood transition, play the venue's own cheer (up) or boo (down) one-shot, gated by the feedBack-venue-crowd-sfx setting (default off). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: rebuild tailwind.min.css for settings toggle classes Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
8f1906a0c1
|
Merge pull request #926 from got-feedBack/feat/tuning-midis-followups
tuningMidis follow-ups: NaN/Inf guard in freqs_to_midis + v3 badge adopts exact midis |
||
|
|
3050c7b1d3 |
feat(v3): reword the fullscreen setting + note the macOS launch caveat
Retitle the toggle "Fullscreen" (from "Start in fullscreen") and reword the description to "Run fee[dB]ack in fullscreen mode. On macOS, changes take effect on the next launch." The macOS note is honest about a native-fullscreen limitation: AppKit drops the first programmatic fullscreen-enter on a window created windowed, so on macOS the desktop side applies the pref at next launch rather than live. Windows/Linux apply it live on the first toggle. The note is self-scoping text (no platform-detection code needed). Signed-off-by: gionnibgud <gionnibgud@gmail.com> |
||
|
|
f8012a8ce4 |
feat(v3): add desktop-only "Start in fullscreen" system option
Adds a "Start in fullscreen" toggle to the Settings → System panel,
addressing the desktop request in feedBack-desktop#97: users want the
app to launch fullscreen without hitting the OS hotkey every time.
The block ships hidden and is gated exactly like the App-updates block:
setupWindowOptions() only unhides + wires it when the feedBack-desktop
bridge exposes window.feedBackDesktop.window.{getStartFullscreen,
setStartFullscreen}. Web/Docker builds have no such bridge, so the
section never appears there. Persistence lives desktop-side because
only the Electron main process can read the pref at window-creation
time — core just proxies through the bridge.
The desktop bridge + launch behaviour land in a follow-up
feedBack-desktop PR.
Signed-off-by: gionnibgud <gionnibgud@gmail.com>
|
||
|
|
503716acbf
|
Merge pull request #928 from got-feedBack/feat/panes-core
feat(panes): detachable panes — pop a plugin's real panel out into its own window |
||
|
|
81ef11d855 |
fix(v3): floor accuracy percentages so 100% means all notes hit
Math.round let 431/433 (99.54%) display as 100%. Floor at every accuracy display site (HUD, library badges, dashboard, lessons, profile, playlists, calibration overlay); stored fractions and mastery thresholds unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
188bdaa837 |
feat(panes)!: move the real element, instead of rebuilding it
The first cut of this got the model wrong. A pane was a SECOND
implementation of the plugin's panel — its own sliders, its own styling,
driven over a cross-realm bridge (ctx, a state store, capability RPC,
mirrorGlobal, a stream sampler). Popping out gave you something that
resembled the panel you popped, and every feature it did not reimplement
(presets, tabs, EQ, language) was simply gone.
What a user wants from "pop this out" is the thing they popped out.
So: MOVE THE REAL ELEMENT. Same-origin windows can adopt each other's
nodes, and an adopted node keeps its event listeners and its closures.
The panel goes on running the plugin's own code, against the plugin's own
state, in the plugin's own realm — it is merely being DISPLAYED in another
window. Copy the app's stylesheets into that window and it looks identical
too, because it is identical.
The plugin's side collapses to two lines:
feedBack.panes.register({ id, title, element: () => panelEl });
feedBack.panes.attachChip(panelEl, id);
and everything comes along: the CSS, the listeners, the presets, the
state. Nothing to keep in step, because there is no second copy.
Deleted, all of it now pointless: pane-bridge (ctx + transports), pane-hub
(the cross-realm server), pane-runtime (the pane realm's boot), pane-streams
(the rAF sampler that existed because an AnalyserNode can't cross a window),
pane-mirror (mirrorGlobal), pane-plugins + the manifest `panes[]` key and its
server-side validation, panes.state(), and both built-in demo panes. ~1200
lines. None of it was wrong — it was all correct machinery for the wrong
problem.
Consequences worth knowing:
- The window MUST be opened by the renderer with window.open(), not by the
desktop's main process: a window we did not open gives this realm no handle
to its document, and without the handle there is nothing to adopt into.
Electron turns the same-origin window.open() into a real BrowserWindow
anyway (setWindowOpenHandler → 'allow'), so we get the OS window AND the
live DOM link. The desktop side finds it by frame name.
- `.fb-paned` neutralises PLACEMENT only (position/inset/width/z-index/shadow).
A plugin panel is nearly always a fixed overlay pinned to a corner of the
app; alone in a 380px window that positioning is nonsense. Colours, borders,
padding, fonts and the panel's own internal layout are untouched — the whole
promise is that what you popped out is what you get.
- The element is returned to its EXACT home on dock: same parent, same position
among its siblings.
- The plugin's code still runs in the main window. So a document.body
.appendChild() inside a panel (a tooltip, a popover) lands in the main
window, not the pane — anchor to the panel instead. And a continuously
animating panel may run slowly while the main window is backgrounded, since
its rAF lives there. Both documented.
Signed-off-by: topkoa <topkoa@gmail.com>
|
||
|
|
254e26bb3a |
feat(panes): mirrorGlobal, manifest-declared panes, and the plugin docs
Three things a plugin needs before it can actually use panes.
## mirrorGlobal — the camera-director problem
The 3D highways read their free camera from a plain global,
`window.__h3dCamCtl` (highway_3d/FREECAM_BRIDGE.md), once per frame in
_resolveFreeCam(). A camera panel in the main window just writes that
object and the camera moves. A panel in a POP-OUT window cannot:
window.__h3dCamCtl there is a different object in a different realm, and
writing it moves nothing.
So a pane declares one field — `mirrorGlobal: '__h3dCamCtl'` — and
pane-mirror.js (main realm, where the renderers live) copies that pane's
state onto the global whenever it changes. highway_3d, keys_highway_3d
and drum_highway_3d are NOT modified and do not know panes exist.
The rule that makes it work: MUTATE THE OBJECT, NEVER REPLACE IT. A
renderer may be holding the reference, and swapping in a new object would
leave it reading an orphan. Keys the pane doesn't set are left alone
rather than deleted — the global may carry a renderer's own bookkeeping.
Closing the pane deliberately leaves the global as-is: closing the camera
panel should not snap the camera back to a default, which is exactly what
happens today (nobody clears __h3dCamCtl).
## Manifest-declared panes
"panes": [{ "id": "camera_director", "title": "Camera Director",
"script": "panes/camera.js", "mirrorGlobal": "__h3dCamCtl" }]
Declaring a pane beats calling panes.register() from screen.js because it
becomes openable FROM THE RAIL OR THE TRAY WITHOUT THE PLUGIN'S SCREEN
EVER HAVING BEEN VISITED — core registers a stub from the manifest and
fetches the script only when the user opens it. A pane you can only reach
by first navigating to the screen it was meant to replace is not much of a
pane.
The script sets `window.feedBackPane_<id> = { mount, unmount }`, mirroring
the existing window.feedBackViz_<id> convention, and the SAME file is what
a pop-out window loads in its own realm.
`script` is validated as a relpath under the plugin's src/ and served
through the sandboxed /api/plugins/<id>/src/ route — the containment rule
`styles` already has for assets/. Traversal, absolute paths, drive letters,
backslashes and non-.js are rejected; a bad entry is dropped with a warning
rather than failing the whole plugin, because one malformed pane should not
cost the user a working plugin.
Note the projection is written TWICE — _nav_entry() and the /api/plugins
route re-project independently — so panes had to be added to both, plus the
pending branch (a pane can be opened while its plugin is still installing
deps; the script is fetched on open, not at discovery).
## docs/plugin-panes.md
The contract, and the one rule it all hangs on: mount(root, ctx) runs in a
realm that may not have the app in it. Everything comes through ctx, or the
pane works docked and silently dies popped out.
Verified: manifest validation rejects ../.., C:\, non-.js, dupes and
missing fields while passing a good entry; /api/plugins projects panes[] for
all 20 plugins. mirrorGlobal mutates the global IN PLACE — a reference held
the way _resolveFreeCam holds it sees the change, and a renderer's own field
on that object survives — both for a local write and for a write arriving
over the channel from a pop-out realm.
pytest: 2401 passed, 8 failed — all 8 reproduce on a clean main (including
the one in tests/test_plugins.py) and are unrelated.
Signed-off-by: topkoa <topkoa@gmail.com>
|
||
|
|
d508380532 |
feat(panes): desktop host — real windows and the system tray
Registers a `desktop` pane host at priority 20, above the browser pop-up host (10) and the dock (0), whenever the Electron bridge exposes feedBackDesktop.panes. A popped-out pane then gets a real BrowserWindow: it remembers where you put it, can float above everything, minimizes to the system tray, and appears in the tray's menu. In a plain browser — or on an older desktop build that predates the bridge — this file registers nothing and the browser pop-up host handles detach exactly as before. Nothing else in the pane system changes. That is what the host registry is for. Two things only this realm can decide, so it owns them: - The user closed a pane window (or it crashed). Close the pane, or the dialog its pop-out chip hid never comes back and the user is left with no way to reach their own UI. - The tray asked to toggle a pane it has no window for. Main cannot know what opening one means — the pane might belong in the dock — so it asks. Unlike a browser pop-up, this host needs no user gesture, so it sets autoRestore: true — a pane you left popped out comes back popped out, where you left it, on the next launch. Pairs with got-feedback/feedBack-desktop#103. Signed-off-by: topkoa <topkoa@gmail.com> |
||
|
|
fefb9051a4 |
feat(panes): pop-out windows — the pane realm, hub, and remote transport
A pane can now leave the main window entirely. Same `mount(root, ctx)`,
same file, different JS realm — which is what the ctx-only contract in the
previous commit was for.
## A purpose-built document, not the app shell with a flag on it
`GET /pane` serves static/panes/pane.html: the bridge, the runtime, and
the pane's own script. No highway, no library, no v3 shell, no <audio>,
no Tailwind.
The splitscreen follower takes the other road — it reloads the whole app
at `/?ssFollower=1` and hides what it doesn't want — and pays for it with
an anti-flash block that must run before any script parses (index.html),
bail-outs in app.js and shell.js, and ~40 lines of CSS hiding core
elements by id. It loads the entire app to throw it away. A pane window
has nothing to throw away, so it boots in milliseconds and there is
nothing to flash.
The cost is that `window.feedBack` in a pane realm is a deliberate,
documented SUBSET. The runtime installs exactly what a pane is promised —
`panes.register`, and the no-op chip/dock calls a shared script may make
at load — so a pane reaching for something it was never given fails
loudly at authoring time instead of subtly at runtime.
## The channel
BroadcastChannel('feedback-panes'), same origin. This works only because
Electron's setWindowOpenHandler returns `action: 'allow'` for same-origin
URLs: `deny` would push the window to the system browser, a different
Chromium instance, where BroadcastChannel cannot reach it and the pane
would silently never sync. That flag is load-bearing.
hello -> snapshot resync-on-open, always. The snapshot is the only way
the pane realm learns anything.
state main is authoritative. A pane's write is a REQUEST;
main applies it and echoes to every realm, so a
losing write self-corrects instead of splitting brain.
rpc / rpc:reply ctx.call() -> the capability bus, with a 10s deadline.
Without one, a main window that died mid-call leaves
the pane's promise pending forever.
event allowlisted bus events, JSON-safe. A CustomEvent
carrying a DOM node (highway:canvas-replaced does)
would throw on postMessage and take the channel down
for everyone, so detail is round-tripped through JSON.
stream one coalesced message per pane per frame, OVERWRITING
anything not yet flushed. Queueing would build a
backlog: Chromium throttles a backgrounded window, and
the main window is exactly what's backgrounded while
the user looks at the pane.
sub / unsub refcounts the main-realm sampler.
bye both directions.
## The follower clock
The pane extrapolates between broadcasts: anchor + observedRate * elapsed,
capped at 2s. observedRate is learned from the broadcasts themselves
(dt/dwall) so it tracks the speed slider without being told about it, and
seeks/pauses are excluded from the fit — a jump is not a tempo. Capping it
means a dead main window decays into a frozen clock rather than one that
confidently runs away. This is splitscreen's hard-won trick, generalized:
panes just call ctx.playhead().
## Failure modes, all of them
- Main window closes -> `bye {main-closed}` and the pane says so plainly,
rather than showing a frozen playhead that looks live. The host also
closes its windows outright; a pane that cannot be fed should not be on
screen.
- Pane window X'd or crashed -> a `closed` poll reaps it (a crashed
renderer never sends `bye`), the pane closes, and the chip's dialog comes
back. Without this the user's dialog stays hidden with no way back.
- Popup blocked -> a toast, and we bail BEFORE the manager records
anything, so the caller's dialog stays exactly where it was.
- Nobody answers `hello` in 5s -> the window says so instead of spinning.
- A pane with no `script` is a closure in this realm and cannot honestly
cross a window boundary. The window host declines it (canHost) and the
router falls back to the dock.
- A browser blocks window.open() outside a user gesture, so a popped-out
pane cannot be auto-restored on page load — it would only ever produce a
"blocked" toast. Such a pane comes back in the DOCK, and the chip pops it
out again on the next click. (autoRestore: false. The desktop host will
set it true.)
Hosts may now declare `remote: true`, meaning the pane's mount() runs in
another realm: the manager then owns only the authoritative state store and
never calls mount() itself. That is the seam the Electron BrowserWindow +
tray host drops into next, with no change here.
Verified: popped Now Playing and Mixer into real windows. The pane realm has
no window.highway, no capability bus and no <audio>, yet the Mixer renders
its faders via ctx.call('audio-mix','list-faders') across the channel — and
dragging that fader IN THE PANE WINDOW moved the main window's song volume
to 55 and persisted it. Closing the pane window un-hid the mixer dialog,
removed the stub and restored the chip, while the other pane window stayed
open.
Signed-off-by: topkoa <topkoa@gmail.com>
|
||
|
|
e5cbea2e9f |
feat(panes): core detachable pane system + pop-out chip
The option-heavy player UIs (mixer, camera director, viz, audio routing)
all live in the rail popovers, which are exclusive: openPopFor() closes
the last one before opening the next. You cannot watch the mixer while
riding the camera, and both vanish the moment you look at the highway.
Add `window.feedBack.panes` — a core registry for live UI that is
authored once as `mount(root, ctx)` and hosted anywhere. Panes are
non-exclusive, and they survive song switches structurally: the dock is a
body child outside every .screen, so the per-song teardown never sees it.
The adoption cost for a plugin is two calls:
feedBack.panes.register({ id, title, icon, mount, unmount });
feedBack.panes.attachChip(myExistingDialogEl, id);
attachChip injects THE standard pop-out chip. Clicking it opens the pane
and hides the plugin's dialog, leaving a stub to bring it back. Core owns
the hide/restore, so every plugin's pop-out looks and behaves the same —
which is the point. It hides via a dedicated .fb-pane-detached class, not
.hidden/[hidden], because the dialogs we attach to already toggle those.
Everything a pane may touch arrives through `ctx` — never a global. That
is what will let the same mount() run inside a pop-out window, a separate
JS realm with no window.feedBack, no window.highway and no audio graph:
ctx.call(domain, cmd, payload) -> the capability bus
ctx.on(event, fn) -> the feedBack bus (allowlisted)
ctx.subscribe(stream, fn) -> playhead / meters
ctx.state.get/set -> persisted, main realm is the only writer
ctx.playhead(), ctx.song(), ctx.toast(), ctx.close()
ctx tracks every subscription it hands out and drops them on unmount, so
a pane cannot leak listeners across a dock/undock cycle.
Streams exist because an AnalyserNode cannot cross a window boundary:
levels are reduced to numbers in the realm that owns the audio graph.
One shared rAF loop, refcounted against live subscriptions, dirty-checked
before fan-out, and stopped dead when the last pane closes.
Hosts register themselves with the manager rather than being imported by
it — the dock lands at priority 0 (the floor, always available), so the
OS pane window can drop in later without this code changing.
Ships two built-in panes: Now Playing (the reference pane — reads the bus,
a stream, and levels, and touches no globals) and Mixer (the same faders
as the rail, via ctx.call('audio-mix', ...), with the chip attached to the
real #mixer-control). Plus a "Panes" rail popover to open panes that have
no dialog of their own; the system tray will mirror that list.
Note the dock sits at z-index 110, not on the docs/plugin-v3-ui.md ladder
(transport 20, rail 30, popovers 40) — those live INSIDE #player's
stacking context, and #player is itself fixed at z-index 100. A dock below
100 is invisible on the one screen panes exist for. Body-level ladder:
#player 100 < dock 110 < toasts 120 < modals 200.
Pop-out windows, the system tray, manifest-declared panes and mirrorGlobal
(the window.__h3dCamCtl proxy the camera director needs) follow.
Signed-off-by: topkoa <topkoa@gmail.com>
|
||
|
|
ea0ca94742
|
feat(career): career plugin — stars, venue tiers, pack downloads (career mode 2/3) (#907)
* feat(career): career plugin — stars from song_stats, venue tiers, pack downloads (career mode PR2) Bundled plugin: per-song stars from best_accuracy (60/75/85% → 1/2/3★), cumulative stars unlock bar → club → arena (data-driven venues.json). Venue packs (UE-rendered crowd loops) download on demand to CONFIG_DIR/plugin_uploads/career/ on a background thread with sha256 + zip-slip validation, served via FileResponse. Career screen (promoted sidebar entry) shows progress and pushes the active venue's manifest into the crowd video layer (v3VenueCrowd, PR1) — degrades cleanly when either side is absent. Pack URLs land in venues.json in PR3. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(career): keep manifest cleanup path alive on delete; badge only for installed venues Codex preflight: nulling _appliedManifestVenue on delete skipped pushCrowdManifest's setManifest(null) cleanup, leaving the crowd layer on a deleted pack; and the 'playing here' badge showed for an override venue whose pack was removed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(career): generation-guard in-flight manifest fetches Codex preflight: a manifest fetch resolving after a newer refresh (pack deleted, venue switched) could re-apply a stale pack over the user's newer selection — fetches now carry a generation token and bail when superseded. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(career): exclude orphaned song_stats from star totals Codex preflight: scans hide rather than delete stats of removed songs, so stars now apply the same existing-song filter other stats surfaces use (filename IN (SELECT filename FROM songs)). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(career): 50/150 star thresholds + star collection overview Byron's progression tuning: club at 50★, arena at 150★. /state now returns star_detail rows (title/artist joined from the library, stars, best accuracy, next-star threshold) sorted closest-to-next-star first, and the career screen renders a collection panel: tier summary plus a per-song list with a 'N% to next star' practice hint. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(career): venue select/unselect UX, intro manifest support, fullmatch guards - 'Play here' now also defaults the visualization to Venue (remembering the prior viz); active venues show 'Leave venue' which restores it and sets the '__none__' override so no installed venue silently reapplies. - Pack manifests may ship an intro block (flyover video + ambience mp3); files validate like loops/stingers, .mp3 added to the serving whitelist. - Codex preflight: whitelist regexes use fullmatch (trailing-newline names could validate but 500 on serving). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(career): let pushCrowdManifest clear the manifest on Leave venue Codex preflight: nulling _appliedManifestVenue before refresh skipped the setManifest(null) cleanup branch, leaving the crowd playing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(career): refresh tailwind output --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
e779c72396
|
feat(venue): reactive crowd video layer behind the 3D highway (career mode 1/3) (#905)
* feat(venue): reactive crowd video layer behind the 3D highway (career mode PR1)
Two crossfading video backdrop planes in the highway_3d venue background
style, driven by a new venue-crowd.js state machine that maps
v3:live-performance-state to crowd states (bored/neutral/engaged/ecstatic)
with 3s stability + 8s dwell hysteresis, plus one-shot reaction stingers
on streak milestones and end-of-song accuracy. Inert without a venue pack
manifest (career plugin, PR2) or the feedBack-venue-crowd-dev flag — the
static bg plate behaves exactly as before.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(venue-crowd): retry renderer binding + preserve mid-stinger transitions
Codex preflight P2s: (1) videos created before highway_3d registered its
globals never reached the backdrop planes — binding is now idempotent and
retried from start/perf-event/re-activation paths; (2) a crowd-state
switch committing while a stinger played was dropped because the machine
had already advanced — it is now deferred and played when the stinger ends.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(venue-crowd): per-video load tokens + unbind renderer on stop
Codex preflight round 2: (1) the global load token let a stinger cancel a
committed loop load on the other layer — tokens are now per-element, and a
stinger preempting an in-flight loop on its own layer requeues that loop
for when the stinger ends; (2) setManifest(null)/deactivate left the last
crowd frame bound and visible over the static plate — stop() now unbinds
both layers from the renderer and zeroes the mix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(venue-crowd): flush deferred loop on stinger failure, source accuracy from perf events
Codex preflight round 3: (1) a failed/timed-out stinger left a deferred
loop switch queued forever; the failure path now flushes it. (2)
stats:recorded only carries {filename, arrangement}, so the end-of-song
reaction now uses the accuracyPct from the song's last
v3:live-performance-state event (a real percentage) instead of a field
that never existed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(venue-crowd): requeue mid-crossfade loops preempted by stingers; hard-stop on manifest swap
Codex preflight round 4: (1) idleLayer() still points at the fading-in
layer during a crossfade, so a stinger firing mid-fade overwrote the new
loop with nothing requeued — the fading loop is now tracked and requeued
like an in-flight load; (2) swapping venue packs while active now goes
through stop() so _stopGen invalidates the old manifest's in-flight loads.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(venue-crowd): generation-gate stinger handlers; recrop on video size change
Codex preflight round 5: (1) an ended/timeout handler orphaned by stop()
could fire into a later stinger's lifecycle on the reused element — handlers
now detach unconditionally and carry a generation token; (2) the renderer
only re-applied cover-crop on camera aspect changes, so a src swap with a
different intrinsic size kept stale repeat/offset — it now recrops when
videoWidth/Height change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(venue-crowd): bail loop-fade completion when a stinger preempted the layer
Codex preflight round 6: the loop crossfade's completion callback could
still run between a stinger's start and its canplaythrough, promoting the
stinger's layer to active and pausing the real loop — it now bails when
the fading loop was preempted.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(venue-crowd): keep rear video layer opaque during crossfades
Two half-transparent layers let the static bg plate bleed through (~25%
at mid-fade) — visible as a flash of the old still image on every state
transition. The crossfade is now always the front layer fading over an
opaque rear layer.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(venue-crowd): reset active layer with mix on stop
Codex preflight: stop() zeroed the mix but left _activeLayer at 1, so a
restart flashed layer 0's stale frame until the new loop loaded.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(venue-crowd): reset crowd mood to neutral on song load
Codex preflight: a song ending in ecstatic/bored left the next song's
crowd stuck in that mood until the hysteresis window passed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(venue-crowd): cancel in-flight fade when a stinger preempts it
Codex preflight: the orphaned ramp kept pushing the mix toward the layer
whose src the stinger had just replaced.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(venue-crowd): don't let null accuracy resets wipe the end-of-song value
Codex preflight: Number(null) is 0, so idle HUD resets overwrote
_lastAccuracyPct before stats:recorded consumed it, suppressing the
end-of-song stinger.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(venue-crowd): abort stale stinger state on song load
Codex preflight: a stinger straddling a song change could fade back into
the previous song's layer or flush its pending loop.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(venue-crowd): always detach load listeners, gate only the callback
Codex preflight: superseded loads left canplaythrough/error listeners
attached to the persistent video elements — unbounded growth over a
session.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(venue-crowd): flyover intro with crowd-ambience ducking
On song:loaded, an optional pack intro plays once: a camera flyover video
(idle layer, one-shot) with bar-crowd ambience audio that ducks out on
song:play, near the flyover's landing, or at handoff — whichever first.
Machine commits and stingers defer during the intro; stop()/song-change
abort it. Packs without an intro behave as before.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(venue-crowd): fall back to the loop when the intro fails to load
Codex preflight: a failed/timed-out intro left the song with no crowd
loop at all.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
ffc52f13ce |
Harden freqs_to_midis against NaN/Inf; badges read exact tuningMidis
Follow-ups to #829 (CodeRabbit's review nit + the consumer adoption the PR body promised): - freqs_to_midis: reject non-finite frequencies (NaN/Infinity) — a provider handing one through would otherwise raise inside int(round(...)) and 500 GET /api/tunings. Tests cover nan/inf/-inf alongside the existing garbage cases. - v3 instrument badge: TUNING_NOTE now prefers the exact integer midis the server serves (tuningMidis) over reconstructing the note from the lowest string's frequency via log2 against a hardcoded 440 — which can land a semitone off at non-440 reference pitches. Frequency path kept as the fallback for older cached responses. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MS2YFb6UUSwJVV6CmEa25i Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com> |
||
|
|
8d3db5f42c
|
fix(nav): nobody may monkey-patch window.showScreen — add screen:changing, make the shell listen (#924) (#925)
Some checks are pending
ship-ci / ci (push) Waiting to run
* fix(nav): the library sometimes showed the legacy screen — map 'home' inside showScreen
Testers: "randomly, when moving to the library from another menu option, the library shows the
old interface — never when a song ends."
━━━ WHAT WAS ACTUALLY HAPPENING ━━━
#home is the PRE-V3 library screen. The v3 shell replaced it with #v3-songs, and the mapping DID
exist — but only inside WRAPPERS on window.showScreen, and only for callers that go through
`window`. THREE independent parties monkey-patch it, each capturing whatever happens to be there
at the time:
app.js publishes the raw function
-> shell.js wraps it, adding the home -> v3-songs mapping
-> the stems plugin wraps it AGAIN (src/main.js:1029), capturing the current value
Plugins load ASYNCHRONOUSLY. The chain links up in whatever order the race settles, and any
capture taken before shell.js installs — or any re-assignment after it — silently drops the
mapping. Hence "randomly".
AND THE INTERNAL CALLERS NEVER TOUCHED window.showScreen AT ALL. closeCurrentSong and the
Esc-from-settings shortcut call the IMPORTED showScreen, which no wrapper ever sees. Reproduced
in a browser: the unwrapped function with 'home' lands on the dead legacy screen EVERY time.
"Never when a song ends" is the tell, and it is what identified the mechanism: closeCurrentSong
resolves its target through _resolvePlayerOrigin(), which ALREADY applies this mapping. That one
path was fine — which is exactly why the bug looked random rather than total.
PRE-EXISTING, not a regression from the module carve: the onclick="showScreen('home')" links and
the wrapper-only mapping both date to 2026-06-22.
━━━ THE FIX ━━━
The guard lives inside showScreen now: ONE place, in the function every caller routes through,
instead of a chain of monkey-patches that must each remember. Wrapper order stops mattering, and
the module-internal callers are covered for the first time.
Verified in a browser: the raw, unwrapped showScreen('home') — which reproduced as #home — now
lands on #v3-songs, and cannot be undone by any wrapper order.
━━━ AND A [P1] I INTRODUCED, WHICH CODEX CAUGHT ━━━
My first cut mapped BOTH 'home' and 'v3-home', copied straight from _resolvePlayerOrigin.
That is correct THERE and wrong HERE. _resolvePlayerOrigin computes where to RETURN TO after a
song, and landing on the Songs list from the dashboard is the right behaviour. But #v3-home is
the v3 DASHBOARD — a real screen that the shell's Home nav, the onboarding tour and the dashboard
re-render listener all target. Redirecting it would have made Home unreachable.
A LEGACY ALIAS IS NOT THE SAME THING AS A RETURN TARGET. Only 'home' is mapped now, and a test
pins that: re-adding 'v3-home' to the guard fails it.
4 tests, bite-tested both ways.
node 1049, pytest 2425, ESLint 0, Codex 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(nav): nobody may monkey-patch window.showScreen — add screen:changing, make the shell listen (#924)
window.showScreen was wrapped by THREE independent parties, each capturing whatever happened to be
there at the time:
app.js publishes the raw function
-> static/v3/shell.js wrapped it (to call syncActive, and to map home -> v3-songs)
-> the stems plugin wrapped it AGAIN (to tear down on leaving the player)
Plugins load ASYNCHRONOUSLY, so the chain linked up in whatever order the race settled. A capture
taken before shell.js installed silently dropped the mapping it carried — and the library opened on
the dead legacy #home screen. Testers saw that as "randomly, the library shows the old interface"
(#923).
#923 fixed the symptom by moving the mapping inside showScreen. This removes the CAUSE: neither
wrapper ever needed to be one.
━━━ TWO EVENTS, AND THE DISTINCTION IS THE WHOLE POINT ━━━
screen:changing emitted BEFORE anything happens. "I am leaving `from`." Teardown/cancel here.
screen:changed emitted after the DOM and data settle. "I am on `id`." Now carries `from`.
screen:changing is new, and it exists because Codex caught me collapsing the two. The stems plugin
tore down its audio graph BEFORE showScreen did anything; screen:changed fires at the very END,
after core awaits library and provider loads — so moving the plugin onto it would have delayed
teardown behind a slow fetch, or skipped it entirely if that fetch threw, and stems would keep
playing on a non-player screen. A test pins the ordering: screen:changing must precede the first
await.
shell.js is a plain screen:changed listener now, like app.js, audio-mixer.js and tour-engine.js
already were. window.showScreen is an unwrapped function again, and tests/js/
no_showscreen_monkeypatch.test.js fails CI if anything in static/ ever assigns to it again — so the
hazard is structurally impossible rather than merely avoided.
━━━ AND A FALLBACK THAT COULD NEVER FIRE ━━━
My retry-if-the-bus-is-late path listened for `slopsmith:capabilities:ready`. Core dispatches
`feedBack:capabilities:ready` (capabilities.js:1536) — the slopsmith: name is the PRE-DMCA event
and nothing has emitted it since the rename. Codex caught it. A guard that cannot fire is worse
than no guard: it reads as protection and is decoration.
(The same dead-event bug turned out to be sitting in THREE of the stems plugin's fallbacks, where
it has silently disabled its lifecycle wiring whenever the bus was late. Fixed in
feedback-plugin-stems#38.)
VERIFIED. A/B against origin/main: the nav highlight and topbar title follow IDENTICALLY with
shell.js as a listener; screen:changing -> screen:changed fire in order with the right {id, from};
window.showScreen is unwrapped; and showScreen('home') still lands on v3-songs.
node 1053, pytest 2425, ESLint 0, Codex 0.
Closes #924
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
c6963fdf30
|
refactor(highway): flip highway.js to an ES module (R3c) (#913)
Two lines. index.html: defer -> type="module". highway.js: one explicit assignment.
highway.js can now `import`, which is the whole point — the carve can begin.
━━━ THE ONE THING THE FLIP ACTUALLY BREAKS: window.createHighway ━━━
A top-level `function createHighway()` in a CLASSIC script IMPLICITLY becomes
window.createHighway. In a module it does not — module declarations are module-scoped, and
the name vanishes from the global object the instant the tag grows type="module".
The constitution names window.createHighway as PUBLIC EXTENSION CONTRACT (alongside
window.playSong / showScreen / feedBack). NOTHING IN-TREE CALLS IT. That is exactly why this
would have shipped: the only consumers are third-party plugins rendering their own highway
panel, and I cannot grep those. Green CI, green tests, and a broken plugin API.
Verified by removing the assignment and reloading:
flip WITHOUT an explicit assignment: window.createHighway === undefined <-- gone
flip WITH it: window.createHighway === function
So it is assigned explicitly now — same object, same behaviour, no longer an accident of how
the file happens to be loaded.
━━━ AND A CORRECTION TO #912 ━━━
#912 (merged) rewrote 73 bare `highway.x` -> `window.highway.x` on the stated grounds that
the flip would turn every one of them into a ReferenceError. HAVING NOW ACTUALLY FLIPPED IT,
THAT WAS WRONG. highway.js already did `window.highway = highway`, which puts the name on the
GLOBAL OBJECT — and bare-identifier resolution falls back to the global object whether or not
a lexical global binding exists. Measured on both builds: bare `highway` resolves either way.
#912 is defensible as hygiene and it does not hurt, but it was not a precondition and it fixed
no latent bug. A correction is posted on the PR so its commit message does not mislead. The
real hazard was the factory, not the instance — same class of breakage, wrong name.
ORDERING is unchanged: classic-defer and non-async type="module" share ONE post-parse
execution queue, in document order, so highway.js keeps its position at index.html:1244.
VERIFIED. A/B against origin/main: 15 probes IDENTICAL, zero page errors — window.highway,
window.createHighway, the full API surface, a real song playing, the chart clock advancing,
and the seek->setTime sync. THE PERF GATE PASSES at 1.85ms against its 12ms budget (module
evaluation costs nothing at render time), which is exactly what #910 was built to tell me.
node 1045, pytest 2416, ESLint 0, Codex 0.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
d9fa6d3f55
|
refactor(highway): make the highway global explicit before the module flip (R3c) (#912)
73 bare `highway.x` references -> `window.highway.x`, across app.js and 10 other files. Provably a NO-OP today. It is the precondition for flipping highway.js to a module. ━━━ WHY THIS HAS TO LAND FIRST ━━━ highway.js is a CLASSIC script. Its top-level `const highway = createHighway()` therefore creates a GLOBAL LEXICAL BINDING — visible as a bare name to every other classic script AND to every ES module. 73 call sites quietly rely on that. The moment highway.js becomes a module, that binding is gone. `const` in a module is module-scoped, not global. Every one of those 73 sites becomes a ReferenceError, and the flip is impossible until they say what they mean. `window.highway = highway` is already set, to the same object, on the same line. So this is an identity rewrite — verified in the browser below. ━━━ THE REWRITE BIT ME THREE TIMES. REGEX IS NOT ENOUGH FOR THIS. ━━━ 1. A SHADOWED LOCAL. capabilities/note-detection.js does `const highway = window.highway`. Its 9 bare uses are LOCAL and already correct; a blind rewrite would have emitted `const window.highway = window.highway`. Excluded. 2. HALF-CONVERTED GUARDS — the dangerous one. Six sites read `typeof highway !== 'undefined' && highway && typeof highway.setTime === 'function'`. The regex converted the CONSEQUENT and left the TEST, which is WORSE than not touching them: after the flip `typeof highway` is 'undefined', so each guard is PERMANENTLY FALSE and the code behind it silently never runs. transport.js's was the seek->setTime sync: the chart clock would have quietly desynced after every seek, with nothing failing. All six now test window.highway. 3. TWO MORE BARE REFERENCES, found by Codex [P2] and confirmed by an AST scan: app.js:3114 and :3176 use `highway && typeof window.highway.getSections === 'function'`. My grep searched for `typeof highway`, not `highway &&`. After the flip these throw, the catch swallows it, and the editor silently falls back to a ±4s edit window and arrangement 0. Regex missed a shadow, a half-conversion, and two bare reads. The final check is an AST pass that resolves scopes and reports every `highway` identifier not bound locally. It now reports ZERO. VERIFIED. A/B against origin/main in two browsers, 15 probes, IDENTICAL, zero page errors: window.highway is the same object as the bare global, the whole API surface resolves, a real song plays, the chart clock advances, getPerf().drawMs > 0 — and `seek syncs chart` passes, which is the exact guard I nearly broke in (2). node 1045, pytest 2416, ESLint 0, Codex 0. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
c223ace419
|
refactor(ui): load the capabilities as ES modules (R3a) (#875)
Some checks are pending
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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
|
||
|
|
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> |
||
|
|
69c8ad4e0c
|
fix(settings): don't let a stale DLC path block saving the Demucs server address (#795)
Some checks are pending
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> |
||
|
|
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> |
||
|
|
612b1f2e0d
|
feat(v3): choose handedness in the instrument selector + onboarding (give lefties a break) (#793)
Some checks are pending
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> |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|