mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-18 22:42:25 +00:00
1712803dc753504b820ac06114ed541d815310e6
248
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
1712803dc7 |
feat(notation_lift): authored per-note hands steer the split — heuristic only guesses the rest (#992)
The keys LH/RH hand arc, the lift slice. split_hands was purely heuristic (mean pitch vs middle C / largest-gap), so ANY edit to a keys arrangement re-derived hand splits that could contradict the score's authored grand-staff assignment — the documented "produces wrong hand splits" failure, now that authored hands actually reach the wire (editor #299 emits per-note `hand`; core #990 round-trips it). - decode_wire_notes carries `hand` through ('lh'/'rh' strict enum; junk → None so a hand-edited pack can't steer the split). - split_hands: an authored hand always wins, and explicit notes are REMOVED from their simultaneous group BEFORE the heuristic math runs — one authored assignment must never skew its chordmates' guesses (e.g. an authored LH melody note above middle C dragging the group mean down and flipping the rest). All-explicit groups skip the heuristic entirely; unassigned notes behave exactly as before. Design per the piano-pedagogy review of the arc: binary lh/rh + absent = unassigned; per-note explicit > heuristic precedence; crossing-hands textures are exactly why the override is load-bearing. Tests: five new in test_notation_lift.py (authored wins incl. a crossing-hands case, group-removal-before-math with exact mean arithmetic, all-explicit group, junk enum, decode carry-through); the decode shape pin updated for the new key. Suite: 1723 passed (+5 vs main; the pre-existing env failures reproduce identically on pristine main). Claude-Session: https://claude.ai/code/session_01EBQCHCNA81E9tHmSDHSe2Q Signed-off-by: ChrisBeWithYou <chris@rifflarr.local> Co-authored-by: ChrisBeWithYou <chris@rifflarr.local> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
00fce2772d |
feat(song): per-note keys hand assignment (hand) on the Note wire (#990)
The editor's keys LH/RH hand arc needs a per-note hand assignment
('lh'/'rh', from a MusicXML grand staff import today, hand-editable
later) to survive a sloppak save → reload: the editor already emits it
on the wire, but note_from_wire dropped unknown keys, so the field died
on every reopen.
- Note gains `hand: str | None = None` (None = unassigned; the
heuristic hand split keeps owning unassigned notes). Distinct from
`right_hand` (the bass plucking finger) — hence the spelled-out
`hand` wire key, since `rh` is taken.
- note_to_wire emits it default-omitted and validates on emit; older
readers ignore it (feedpak: unknown note keys are permitted).
- note_from_wire decodes it as a strict enum — anything but 'lh'/'rh'
(junk, wrong case, bools) falls back to unassigned rather than
poisoning downstream hand-split / hands-separate practice logic.
Groundwork consumers land separately: notation_lift.split_hands
respecting per-note overrides, and the editor's hand surface.
Editor counterpart: feedBack-plugin-editor #299.
Tests: three new wire round-trip tests in tests/test_song.py (literal
key, default-omitted, junk rejection both directions), matching the
teaching-marks test style.
Claude-Session: https://claude.ai/code/session_01EBQCHCNA81E9tHmSDHSe2Q
Signed-off-by: ChrisBeWithYou <chris@rifflarr.local>
Co-authored-by: ChrisBeWithYou <chris@rifflarr.local>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.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> |
||
|
|
2413991c5a |
feat(highway_3d): fret wires flash on a confirmed hit (#969)
ship-ci / ci (push) Waiting to run
* feat(highway_3d): fret wires flash on a confirmed hit The fret wires were static scenery: gold inside the anchor lane, grey outside, and nothing tied them to what the player was actually doing. Give them a job. Widen the lane/neck contrast so the wires around the active lane read as a focus cue, and flash the wires bracketing a note when a scorer confirms it. A fretted note lights the wire behind it and the wire it is pressed against; a chord lights only the outermost wires of its shape, so it reads as one bracketed block rather than a picket fence; an open string has no fret of its own and its gem is drawn as a slab spanning the lane, so it lights the lane's edge wires instead. Gated on the provider verdict, never the proximity heuristic -- the latter only means "near the strike line", so it would flash on every passing note whether or not it was played. With no scorer attached the neck behaves exactly as before. Emissive (and emissiveIntensity) carry the flash, not albedo: these are MeshStandard materials in a scene with no envMap, so raising albedo alone barely brightens them. Every value is a named constant -- see FRET_WIRE_* -- because the look is a taste call that wants tuning by eye, not a derivation. Signed-off-by: Kris Anderson <topkoa@gmail.com> * feat(highway_3d): cap the fret-wire flash at one outer pair Fast passages overlap their decay tails: consecutive notes on nearby frets left three, four, five wires glowing at once — the picket fence the chord rule was written to avoid, arriving through time instead of through a shape. The apply pass now decays every wire's glow state as before, but flashes only the outermost pair of the lit span (or the single wire when only one is above threshold). Interior wires keep decaying invisibly — the base tier loop re-seeds their materials each frame — so the bracket tightens naturally as the outer tails expire, and a hit inside the current span widens nothing. Net effect: at most two wires are ever lit, and everything currently glowing reads as one bracket, exactly like a chord. Signed-off-by: topkoa <topkoa@gmail.com> * feat(highway_3d): chord flash frames the lane, not the shape The lit lane strip spans the anchor's width (minimum ~4 frets), which can run a fret past the chord's outermost fret. The chord flash bracketed the shape (wire behind its lowest fret, wire at its highest), so on those anchors the bracket sat one wire INSIDE the lit lane — reading as misaligned rather than as a frame around what's lit. Chord hits now light the anchor lane's edge wires: the exact wires the lane strip itself spans, and the same pair open strings already use, so every hit shape inside a lane produces the same bracket. The shape's own outer pair survives only as the fallback for charts with no anchors. Fretted and open intensities merge into one entry (they light the same two wires now), and an all-open chord on an anchor-less chart still degrades to no flash rather than a bad index. Signed-off-by: topkoa <topkoa@gmail.com> * feat(highway_3d): gem rims flash string-coloured, wire-fashion On a confirmed hit the gem's outline now flashes in the STRING'S OWN colour with the same intensity treatment as the fret wires — the FRET_WIRE_HIT_INTENSITY emissive ramp, faded by the provider's alpha — instead of the fixed spring-green mHitBright rim. Just the rims: the lateral face fill keeps its existing green, and the sustain trail is untouched. Mechanics mirror the wires' pattern. mRimFlash[s] is one material per string (created with the other per-string materials, palette-retint aware, fog-exempt, disposed in teardown); drawNote() assigns it as the outline on a good verdict and records the verdict alpha into a per-frame per-string max (_rimFlashIn); the flash pass applies the intensity ramp once per string. Shared-per-string is the same compromise mGlow already makes — two same-string gems flashing in different phases share the brighter alpha. No decay tail of our own, deliberately: the material is only assigned while the provider confirms the note, and the provider's alpha already fades. When it goes silent the outline reverts, so idle intensity never shows. Signed-off-by: topkoa <topkoa@gmail.com> * feat(highway_3d): wire flash is a lightning strike, not a lingering glow The flash was instant-on with a 0.32 s exponential tail, and a held sustain kept re-feeding it — wires stayed lit for the whole note. The requested feel is a shock: light hits the frets, they jolt, it's over. The flash is now a one-shot pulse triggered on the input's rising edge: a near-instant crack up (RISE 25 ms), a fast fall (FALL 160 ms) shaped (1-u)^2 so it drops hard then eases out, with a 26 Hz flicker biting into the fall (the electric shudder — the crack itself stays clean), then hard zero. A held 'active' verdict keeps the input high continuously, which by construction triggers nothing new: one strike per hit, and the wires go dark while the note rings on. A re-strike after the provider goes silent re-triggers cleanly. Seeking backward or a long stall clears all pulse state, and a pulse whose strike time lands ahead of the playhead after a seek is discarded. The outer-pair bracket rule is unchanged — it now selects across pulses instead of decay tails. Knobs: FRET_WIRE_HIT_RISE / _FALL / _FLICKER_HZ / _FLICKER_DEPTH (replacing FRET_WIRE_HIT_DECAY). Signed-off-by: topkoa <topkoa@gmail.com> * fix(highway_3d): one wire strike per judged hit, not per wire edge The strike trigger was a rising edge on each WIRE's input, which merged distinct hits: two consecutive correct notes on the same fret kept that wire's input continuously high, so the second note produced no strike at all. The wires must respond to what the player did — one strike per judged hit-zone event. The trigger is now per event identity, using the same seen-map pattern as _sparkSeen: the first frame a note gets a good verdict its key (string|fret|time — or the chord key for a strum, which strikes once as a unit) lands in _fwStruck and requests a strike on its wires; the event never fires again however long its verdict stays live. Because every producer is gated, any nonzero input in the apply pass IS a fresh strike, so it restarts a pulse already in flight — a rapid re-hit on the same wire re-cracks instead of being swallowed. Seeks clear the map (replayed notes strike again); it is size-bounded like _sparkSeen. Envelope, flicker, and the outer-pair rule unchanged. Signed-off-by: topkoa <topkoa@gmail.com> * Revert the lightning-strike experiment — back to the decaying glow Reverts |
||
|
|
3717e4338d |
fix(plugins): restore window.esc for out-of-tree plugins (#986)
ship-ci / ci (push) Waiting to run
app.js exported `esc` as an implicit global back when it was a classic
script. a9fce29 made it an ES module and
|
||
|
|
0b4b174d33 |
perf(scan): skip full library re-stat when the tree is unchanged (#979)
ship-ci / ci (push) Waiting to run
* perf(scan): skip full library re-stat when the tree is unchanged
Startup scans globbed the whole DLC tree twice (*.feedpak, *.wem) and
stat()'d every file to detect changes — ~100k filesystem round trips on
a 50k-song library, and painful on a slow NTFS-3G FUSE mount (the "big
drive churns on every launch" report).
Adds/removes/renames of songs all bump the mtime of the containing
directory (verified on the target mount), so after a full pass we persist
{reldir: mtime_ns} for every library dir (scan_dir_signature.json, keyed
by DLC path). The next scan re-stats only those dirs — a handful vs 100k
ops — and skips the entire listing/stat pass when none changed.
Blind spot: a pack rewritten in place under the same name bumps the file
mtime but not its dir's. Rare for a song library, and the manual Refresh
(/api/rescan + /api/rescan/full) now passes force=True to always do the
full pass. force threads through kick_scan -> _scan_runner and coalesces
like the rescan-pending flag.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(scan): track directory-form songs' own dir in the signature
CodeRabbit: _library_dirs recorded only each song's parent. For a
directory-form song (loose-song folder or directory sloppak bundle),
adding/removing/replacing a file INSIDE the folder bumps that folder's
own mtime, not its parent's — so the fast path would skip a rescan it
should run. Record the song's own dir when f.is_dir(). File-form
sloppaks (a single .feedpak zip) aren't dirs, so the flat file library
is unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.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)
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. |
||
|
|
e14ef64224 |
fix(playback): the song queue must survive a playSong wrapper that drops options (#977)
ship-ci / ci (push) Waiting to run
Tester: "Passports does not advance in the song queue." The play queue tells playSong "don't clear the queue I'm driving" by passing options.fromQueue. But window.playSong is wrapped by a CHAIN of plugins — nam_tone, midi_amp, fretboard, invert_highway, tabview — and each wrapper forwards only (filename, arrangement), silently dropping the options object. So fromQueue never reached playSong: it cleared the queue the instant its first song started, and a gig/album/playlist never advanced. Reproduced on the real build via a queue.start + a hooked clear(): the queue went inactive with 0 remaining immediately after start, and the clear stack ran through nam_tone -> midi_amp -> invert_highway -> fretboard -> session.js. Fixing six plugin wrappers is whack-a-mole and the next plugin re-breaks it. Fix it at the source instead: the queue raises an out-of-band flag (_consumeInternalPlay, one-shot) beside the wrapper chain, not through it, and playSong's clear-guard honours it. options.fromQueue stays as the in-band path. The flag is consumed on read so a later MANUAL play still abandons the queue. Verified on the real build: the gig queue stays active after start and advances on song:ended (Iron Maiden -> Blind Guardian), and a manual play still clears. Tests drive the real clear-guard against the queue for: a dropped-options wrapper (the bug), the one-shot manual-play-still-clears invariant, and the in-band fromQueue path on its own. All 3 fail on the pre-fix source. JS 1211/1211. |
||
|
|
365cec1d29 |
fix(career): gig song selection — full-genre pool, working re-roll, and the venue pack loads (#976)
* fix(career): a gig's song pool is the whole genre, and re-roll varies it Two tester reports, one root: the gig song pool was built from only two sets — songs played ON THIS PASSPORT'S INSTRUMENT, and songs never played AT ALL (`filename NOT IN song_stats`). A song played on a DIFFERENT instrument's arrangement is in neither: it has a stats row (so the "unplayed" filler skipped it), and its played bucket is that other instrument's, not this passport's. It could never be gigged. - "Metalcore says 137 songs only shows 1 in the gig list" — a library of metalcore all played on another instrument. Reproduced: a guitar passport with 137 bass-played metalcore songs got a 404, zero songs. The "1" the tester saw was whatever handful happened to be on-instrument or truly unplayed. - "Passport re-roll does not change songs" — a set drawn from that filler was the library's first N in table ORDER, every call. Re-roll re-proposes, so it returned the identical set. Reproduced: 3 proposals, byte-identical. _unplayed_genre_songs -> _fill_genre_songs: the pool is now every library song of the genre the set hasn't already picked (a stats row on some other instrument has no bearing on whether a song can be in THIS gig), and it is shuffled so re-roll actually re-rolls. Both reproduced against the real propose logic before the fix and pinned as regression tests (both fail on the pre-fix routes.py). Full career suite green. * fix(career): load the gig's venue pack when the gig starts Tester: "Venue doesn't load when starting song from passport. Loads standard particles." crowd.setManifest(venue) — the call that actually loads a venue's crowd/stage pack — is reached ONLY through pushCrowdManifest, and pushCrowdManifest is called ONLY from refresh(), the career tab's own reload. A gig navigates AWAY from the career tab to the player, so refresh() never runs during it. startGig set the venue override and nulled _appliedManifestVenue but never re-pushed, so the venue visualization turned on (3D highway) while its pack never loaded — the song played over the bare highway backdrop, or over whatever venue a previous refresh() had left applied. startGig now pushes the crowd manifest for the gig venue right after setting the override, using the career state the booking screen already fetched. This is a call-graph fact, not a guess (pushCrowdManifest has exactly one other caller and startGig is not it), but it is fixed by static analysis — I could not reproduce the user-visible symptom locally because this instance happened to have a manifest already applied from a prior refresh. On-device confirmation on a real passport gig is still owed. Guard test: startGig must push the manifest after setting the override (fails on the pre-fix source). Career suite green. |
||
|
|
1702afa379 |
feat(career): extract the whole setlist before the gig starts (no more waiting between songs) (#971)
ship-ci / ci (push) Waiting to run
* feat(career): extract the whole setlist before the gig starts
A feedpak is a zip, and the first play of one pays for its extraction into
sloppak_cache. Inside a set that cost landed BETWEEN songs: the player finished
a number and then sat there waiting for the next one to unpack, mid-gig.
A setlist is a known list up front, so unpack it all while the poster is still on
screen. New POST /gigs/prepare walks the set through resolve_source_dir; the
poster's Play button shows "Preparing set…" while it runs.
Best-effort by design, at every level:
- a corrupt pak in the set does not sink the prepare (it is reported in
`failed`; the play itself surfaces the error exactly as it does outside a
gig — slow beats blocked)
- a host without the library resolvers degrades to a no-op rather than 500
- a failed request just falls through to the old lazy extraction
Ordering matters and is pinned: the set is unpacked BEFORE the stage is borrowed
(venue/viz overwritten) and before the queue starts, so a proposal cancelled
while unpacking leaves nothing half-applied to unwind.
Tests unpack REAL zips rather than mocking the extractor: every song of the set
lands on disk before the first note, a re-prepare does not duplicate the unpack,
one bad pak still leaves the good one prepared, and no-library / empty-setlist
degrade cleanly. 18/18.
NB the other half of the gig report — the per-song results popup interrupting
the set (and worse, claimAutoExit'ing so the queue would not advance until it was
dismissed) — is fixed in the note_detect plugin repo, which is not part of this
checkout.
* fix(career): bound the prepare request; validate the setlist (PR #971 review)
Both CodeRabbit findings were right.
1. A HUNG PREPARE COULD BLOCK THE GIG FOREVER.
`await fetch(...)` only rejects on a network ERROR. A server that accepts the
connection and then never answers hangs indefinitely — and the gig would never
start. That makes this optimisation the exact thing the PR promises it can
never be: the reason you cannot play.
The request is now bounded by an AbortController (PREPARE_TIMEOUT_MS, generous
because unpacking a setlist is real work — but a CEILING, not a wait). Past it
we start the gig and let the first play extract lazily, as it always did. The
Play button is restored in a `finally`, so a timeout cannot strand the poster
on "Preparing set…" with Play disabled — which would have been the same bug
wearing a different hat.
2. THE `songs` BODY WAS UNVALIDATED.
A str is iterable: "abc" would have prepared three one-character "songs". And
the endpoint unpacks zips, so an arbitrary caller could ask for unbounded work.
Now list-only, string entries, blanks dropped, capped at MAX_GIG_SONGS.
Tests: the fetch is abortable and the button is re-enabled on EVERY path
including the abort; non-list bodies, non-string/blank entries, and an
oversized setlist. 50 career tests, JS 5/5, eslint clean.
* fix(career): path-traversal guard on prepare; a cap test that actually tests the cap
CodeRabbit again, and the first one is a real hole I put there.
1. PATH TRAVERSAL. sloppak.resolve_source_dir() does a bare `dlc_root / filename`
with NO containment guard — so `../../x` walks straight out of the library, and
my new endpoint handed it attacker-supplied filenames. Every filename now goes
through _resolve_dlc_path first, the same check every other filename-bound
handler applies. Pinned: `..`, backslash traversal, an absolute POSIX path and
a Windows drive path are all refused, and nothing outside the library is
unpacked.
2. THE CAP TEST WAS VACUOUS. It asserted `prepared == 0` against a fixture with no
library — where the endpoint exits before extraction — so it passed whether or
not MAX_GIG_SONGS existed. It now runs against a real library and asserts the
endpoint CONSIDERED at most MAX_GIG_SONGS of the 82 it was handed. Verified to
fail when the cap is removed.
Same class of mistake as the notedetect gigBlock: a test that passes for the
wrong reason. Worth saying out loud since it is twice in one day.
3. E702 — semicolon-joined statements in the new tests, split.
51 career tests; full suite green.
|
||
|
|
917d81c2d2 |
fix(highway): a SUPERSEDED renderer init is not a FAILED one (#970)
Starting a gig dropped the player onto the fallback 2D highway with no venue.
startGig() calls setViz('venue'), which installs the 3D renderer — whose init is
async — and then immediately starts its play queue. playSong() re-initialises
that same renderer a tick later. A renderer mints a fresh readyPromise per
init() and rejects the previous one with "superseded"; highway.js only checked
that the RENDERER OBJECT was unchanged, which it is. So it treated a healthy,
re-initialising renderer as a failed one, tore it down, and reverted to 2D:
renderer async init failure: Error: superseded
viz picker: reverted to default renderer (async-init-failure)
The guard now also checks the PROMISE identity: a rejection from an init cycle
the renderer has already moved on from is ignored. The renderer-identity guard
stays (a rejection for a renderer since REPLACED is also not ours), and a
genuine failure of the CURRENT cycle still reverts — both init() call sites go
through _setRenderer, which re-wires the handler every time, so the new cycle is
always watched.
Reproduced and fixed against the real build:
before: vizSelection=default viz-picker=default venue=inactive viz:reverted
after: vizSelection=venue viz-picker=venue venue=ACTIVE (no revert)
Also widens the paused-frame throttle's opt-out. The throttle fires whenever the
CHART CLOCK is stalled — not only on a pause, but through a count-in and the
credits/author overlay too. Its opt-out only asked "is a crowd video rolling",
but the venue scene animates on a clock of its own with no pack at all (backdrop
breathe, parallax, haze drift, warmth pulse — Math.sin(t) in the draw loop), so
that motion was still being throttled. It now claims frames for both sources; a
plain 3D highway with no venue reads motion mode 'off' and keeps the #654 GPU
saving.
HONEST CAVEAT on that second part: I could not get the throttle to fire in a
reproduction. A control run on the shipped code showed 100 draws/sec while
paused, not the ~10/sec a firing throttle would give — so the change is
defensible on its own terms (a stalled clock is genuinely not a static picture)
but it does NOT have a demonstrated symptom behind it. The viz fix above does.
Tests: the superseded guard, and that the throttle opt-out covers both motion
sources. All fail against the pre-fix source. eslint 0 errors; JS 1207/1207.
|
||
|
|
939c98214b |
feat(song-info): publish the playable stem list so stems can preload (fixes the 698ms freeze) (#972)
* feat(song-info): publish the playable stem list, so stems can preload
The stems plugin could only learn its stem list from the highway's WS `ready`,
which arrives once the highway is already up. So it fetched, decoded, and then
handed every stem's PCM to its audio worklet — copying the WHOLE SONG — with the
player already on screen.
For a 4-minute 6-stem pack that is over half a GIGABYTE of memcpy, in one frame,
on the main thread. Measured on a real load: a 698 ms frame, right as the
song-credits card appeared, with the venue video visibly stopping. That is the
"the video pauses when the author appears" report.
GET /api/song/{f}?stems=1 now returns the same list — [{id, url, default}] plus
full_mix_url — so the plugin can start the whole load at `song:loading`, before
the highway (and the venue) is drawn, where a stalled frame costs nothing.
Nothing about the work changes; only WHEN.
Opt-in via the query param so the library's own metadata calls — the hot path —
pay nothing. Deliberately NOT stored in the metadata cache: that is a
fixed-column table, and widening it would mean a schema migration plus a stale
row for every song already scanned, to cache something that is a plain manifest
read on an already-unpacked pack.
The safety property: REST and the WS must publish the SAME list. If they
disagreed the plugin would preload a graph and then throw it away and rebuild —
strictly worse than not preloading. So both now resolve `default` through one
shared helper (stem_default_on, extracted from load_song), and a test rebuilds
the WS's payload from load_song and requires the REST helper to produce the
identical list, rather than pinning either against a snapshot.
Also pinned: the mixdown is lifted OUT of the stem list (spec 5.3 — `full` is
not a layer; listing it beside the instruments would play the whole song on top
of the stems) while staying reachable as full_mix_url, a single-`full` pack keeps
it as its only playable stem, and an unreadable pack yields an empty list rather
than failing the request. Full suite 2608 passed.
Consumed by feedBack-plugin-stems (preloadSong).
* fix(song-info): call load_song for the stem payload — do not reimplement it
CodeRabbit caught a real bug, and it would have hit most real libraries.
load_song() falls back to the DEPRECATED `original_audio:` key when a pack has no
reserved `full` stem — which is every pack written before feedpak 1.15.0. My
payload rebuilt the full-mix rule from extract_meta and returned None for those:
REST would say "no full mix" while the WS said there was one.
Worse than a wrong field: the plugin would preload a graph WITHOUT the pristine
mix and — because the stem signature still matched — never rebuild. Unity
playback would silently downgrade to the lossy stem recombination.
That is exactly the drift this PR claims to prevent, and my test had a hole: I
only covered packs that carry a `full` stem.
So stop reimplementing. The payload now calls load_song, whose LoadedSloppak
already carries the partitioned stems and the resolved full mix, and builds the
URLs exactly as ws_highway does. Drift is now impossible by construction rather
than by agreement. extract_meta is reverted to its original shape (it never
needed to change), and the shared stem_default_on helper stays as the one place
`default: off` is resolved.
Tests rewritten to compare against load_song — the WS's own function — for a
reserved-`full` pack, a LEGACY original_audio pack (the case that was broken), and
a single-`full` pack. Also documents the `?stems=1` contract in CHANGELOG.md.
Full suite green.
|
||
|
|
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. |
||
|
|
e729c44d5b |
perf(paths): resolve the library root once, not on every path check (#966)
`Path.resolve()` is a filesystem call — it lstats every component of the path.
`_resolve_dlc_path` and `safe_join` both re-resolved their ROOT on every single
call, and those run once per song, per art fetch, per scanned row.
Found while profiling a 2-fps report: on a real 50,944-song library the server
was issuing ~23,500 stat/lstat calls per second, re-walking the same three
parent directories over and over, and burning ~50% of a core doing it. It is
worst exactly where big libraries live — the library was on an NTFS-3G (FUSE)
mount, where every stat is a userspace round trip through mount.ntfs-3g (itself
visible in top). The cost was the constant re-resolution, not the work.
A root is fixed for the life of the process, so resolve it once
(safepath.resolved_root, lru_cache). Measured, 5,000 lookups against a real
library path:
before: 15,264 stat syscalls (54.2 ms)
after: 277 stat syscalls ( 0.8 ms) 55x fewer
Containment is unchanged, which is the part that matters:
- safe_join still resolves the CANDIDATE on every call — following its
symlinks IS the zip-slip / traversal defence, so it is never cached. Only
the server-owned root is.
- _resolve_dlc_path keeps its lexical containment check (deliberately does not
follow symlinks, so junction-mounted libraries keep working).
Tradeoff, documented on resolved_root: if a root's symlink is re-pointed at a
NEW target while the server runs, the old target stays in effect until restart.
Fine for a library path fixed at startup; the cache is keyed on the Path, so
switching library dir is a different key.
Tests: root resolved once across 500 lookups (the regression), a different root
is a different entry, and the containment contract re-pinned — traversal,
Windows drive-absolute, backslash, NUL, empty, and a symlink escaping the root
must still be refused. Full suite green.
|
||
|
|
2991612531 |
feat(career): bundle the AXA club venue pack (career stage 2) (#963)
ship-ci / ci (push) Waiting to run
The Velvet Room (50 stars) now ships in every build like the bar and arena: 4 reactive crowd loops, 2 stingers, and a balcony flyover intro rendered from the AXA Music Stage scene (110 spectators, state-scaled stage washes over the venue's own neon). Audio files are dive-bar placeholders until club-scale recordings land. The installed/delete test now asserts bundled-fallback semantics: with every venue bundled, deleting a downloaded pack reveals the bundled copy instead of uninstalling. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
af611770aa |
test(career): assert exact arena manifest mappings (#962)
CodeRabbit follow-up on #961: presence checks alone would pass with swapped loop filenames; assert the full loops/stingers/sfx objects. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
ea9da0acde |
feat(career): bundle the arena venue pack (career stage 3) (#961)
Feedback Arena (150 stars) now ships in every build like the bar: 4 reactive crowd loops, 2 stingers, and a flyover intro rendered from the UE5 arena scene (200 spectators + 396-body intro fill, state-reactive rig lighting). Served by the existing bundled-pack fallback; venues.json unchanged. Audio files are dive-bar placeholders until arena-scale recordings land. Largest file is 89MB — future re-renders must stay under GitHub's 100MB hard limit. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
be473dc7af |
Career v3: Gold tier — verified improv upgrades an earned badge (#960)
* feat(career): Gold tier — a family-style goldImprov artifact upgrades an earned badge The drill-state relay's goldImprov map (virtuoso gold_improv mints, gained-only merged like drill nodes) turns an earned badge gold when the passport's genre — or its genre family — has a verified improv artifact. Gold never substitutes for the badge bar: gold-without-bronze stays in_progress. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(career): Gold tier frontend — relay, ceremony, slam, gold ink everywhere The drill-state relay now carries virtuoso's goldImprov map; a badge that comes back gold gets its own ceremony + notification (tier-suffixed seen ids — the bronze moment stays seen under its legacy id, a gold slam marks both), a gold stamp slam in the book, gold ink on the shelf-cover mini stamp, and the real gold foil chip. The bronze page's dashed 'Gold rung coming' preview becomes a live invitation to jam the style in Virtuoso. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(career): gold review fixes — family-space style matching, intake guards, rail counter The review's showstopper: virtuoso mints goldImprov under raw STYLE_PALETTES ids ('punk', 'djent', 'disco'), which are mostly NOT family keys — the tier check now matches in family space (artifact style and passport genre bucket through the same _genre_family keyword match), so a 'punk' gold reaches a 'punk rock' passport. Also: non-dict goldImprov 400s loudly instead of silently dropping; evidence-free artifacts (no verifier) never mint; goldImprov gets the same pre-merge size bound byNode has (junk under the cap could otherwise persist forever and wedge every later relay at the post-merge check); the instrument-rail badge counter counts gold (earning gold no longer made a badge vanish from the rail); first-artifact-wins is now asserted against the persisted snapshot instead of vacuously. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
0d35228d56 |
fix: remote transcription posts to /transcribe, not /align (stem-splitter#17) (#959)
ship-ci / ci (push) Waiting to run
* fix: remote transcription posts to /transcribe, not /align (stem-splitter#17)
transcribe_vocals_remote() POSTed the vocal stem to /align. That endpoint is FORCED ALIGNMENT —
"here are the lyrics, tell me when each word is sung" — and its `text` field is required. We have
no lyrics; transcribing them is the entire point. So the server rejected every request with a 422
from FastAPI's validation layer, before its handler ever ran, and remote transcription has never
worked for anyone.
It now posts to /transcribe (added in feedBack-demucs-server#14), which takes only the audio.
`language` moves from the query string to the FORM BODY, where the server actually reads it
(Form("")). As a query param it was silently ignored, so an explicit hint did nothing and
Whisper's auto-detection quietly decided instead — loading the wrong wav2vec2 aligner. It
"worked", it was just wrong, which is the failure mode that hides for months.
Error bodies are no longer cut at 300 chars. The body IS the diagnosis: a 422's JSON names the
field it rejected, a 500's traceback answers on its LAST line. Both got decapitated — which is
part of why this stayed invisible for so long. The message explaining the bug was inside the part
that got cut.
Nothing caught any of this because every test of this module tested the MAPPER, fed a hand-written
dict. The mapper was always fine. The request was never exercised, and the request was the bug.
tests/test_lyrics_transcribe_remote.py now pins it: the endpoint, the form field, the multipart
upload, the bearer token, an instrumental returning no lyrics rather than an error, and a 404
saying the server is too old. Verified they FAIL against /align + params.
Signed-off-by: topkoa <topkoa@gmail.com>
* fix: make the error-body cap an actual bound; correct the docstring's endpoint
- _err_body() appended the truncation marker AFTER slicing to _MAX_ERR_BODY, so the result could
exceed the cap it exists to enforce (4014 chars for a 4000 bound). A cap that is only a
suggestion surprises exactly the callers who trust it — a log line, a job record persisted to
disk and re-read on every load. The marker now fits inside the bound.
It also stripped after measuring, so a short JSON body followed by kilobytes of trailing
whitespace got truncated: real content cut to make room for blanks. Strip first, then measure.
- The public docstring still advertised /align — the exact contract this PR exists to change, in
the one place a reader would look for it. It now says what the function does and why, and that
an older server answers 404.
Found by Copilot and CodeRabbit on #959.
Signed-off-by: topkoa <topkoa@gmail.com>
* fix: keep the exception line when truncating — the tail is the answer
_err_body() kept only the HEAD of an over-long body. On a traceback the last line is the
diagnosis, and the docstring said exactly that while the code threw it away: a 4000-char window
holding "Traceback (most recent call last)" and none of the exception is a window onto nothing.
Same mistake as the 300-char cap it replaced, one level up — cutting off precisely the part the
function exists to preserve.
Head AND tail now, both inside the bound: two thirds head (what was being attempted), one third
tail (what actually went wrong), with the marker between them. Verified the test FAILS against
head-only truncation.
Found by Copilot on #959.
Signed-off-by: topkoa <topkoa@gmail.com>
* fix: every failure out of transcribe_vocals_remote() is a RuntimeError; 404 says why
The docstring promised one failure mode — RuntimeError — and the caller (_maybe_transcribe_lyrics)
catches exactly that so one song's failed lyrics don't take down the batch around it. But a DNS
failure, a timeout, a reset connection or an unreadable stem escaped as requests.RequestException
or OSError, walked straight past that handler, and turned "this song's lyrics failed" into "the
whole batch died".
A 404 now explains itself. Bare "404" sends someone hunting for a typo in their server URL; the
real answer is that their server predates /transcribe, and we are the only ones in a position to
know that.
Found by Copilot on #959.
Signed-off-by: topkoa <topkoa@gmail.com>
---------
Signed-off-by: topkoa <topkoa@gmail.com>
|
||
|
|
7c897e9f2b |
feat(career): gigs backend — propose a setlist, log the completed set (#954)
* feat(career): gigs backend — propose a setlist, log the completed set
Career v3, WS3 (backend half). A gig is career's verb:
- POST /gigs/propose {instrument, genre, size}: setlist from the
passport's own stubs — qualifying songs (per the genre's badge bar,
family-aware) shuffled for a free re-roll, topped with the
highest-accuracy near-bar songs as stakes, and filled from UNPLAYED
genre songs when the passport is young (the first gig is how stubs
start). Names the highest venue the current stars can book.
- POST /gigs: logs a COMPLETED set only (abandoned sets never log — no
fail state). Per-song accuracy = MAX(last_accuracy) from song_stats,
freshly written by the set's own plays; encore = avg ≥ the data-driven
bar (passports.json gig.encore_accuracy, 0.75). Appends to the career
state file (same atomic _save_json pattern).
- Passports view: per-passport gigs (newest first, capped 20) and
per-instrument gig_count — the profile wall's gig line lights up.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(career): gig backfill offsets by qualifying taken, not picks length
CodeRabbit on #954: after the stakes loop appends near-bar songs,
qualifying[len(picks):] overshoots and skips eligible qualifying songs
— a stocked passport could still get a short set.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
831117fb96 |
feat(career): practice invitations — closest stamps + bring-these-up (#953)
* feat(career): practice invitations — closest stamps + bring-these-up Career v3, WS1. The passport now points at the practice that pays: - Stubs carry next_star_at (the same primitive _stars() uses) and each passport exposes `nearest`: the top 3 non-qualifying songs by distance to their next star, in the worklist order. - "Closest stamps" strip above the shelf: the in-progress graded passports nearest to minting, each row naming the ask — N more songs (with the nearest title + best %) or the blocking Virtuoso drill. Rows open the passport. - "Bring these up" list on the stubs page of in-progress passports; earned pages stay memorabilia (no homework on a won badge). - Invitation-voiced throughout: no meters, no completion pressure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(test): comment says qualifying-bar ranking, matching the assertion Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
6cc0312661 |
feat(career): genre families — sub-genres inherit the family drill (#951)
The enrichment fallback made the passport rack real (hundreds of MB
sub-genres) but only the five exact umbrella keys carried Virtuoso
drills. Genres now resolve to a family by keyword substring (MB's
vocabulary is open — 'metalcore' must hit metal without an alias),
first-match-wins in list order ('blues rock' → blues), and inherit the
family's requirement from the same genres map. Exact entries still win;
per-instrument scoping unchanged; unmatched genres stay songs-only.
Data: families for metal (incl. djent/grindcore/thrash/doom), blues,
jazz (bebop/swing/bossa), funk (disco), rock (punk/grunge/shoegaze).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
329cc86315 |
fix(sloppak): the full mix is a stem — drop the invented original_audio key (#946)
* fix(sloppak): the full mix is a stem — drop the invented `original_audio` key (#933) Core read, served, and depended on `original_audio:` — a top-level manifest key this repo invented in #583 that the feedpak spec never defined. The format already had a home for the pre-separation mixdown: it is a stem. feedpak 1.15.0 (feedpak-spec#53) RESERVES the id `full` for it, so read it from there. The key existed to work around a bug in our own reader. The packer's comment said so plainly: "we must NOT list the full mix as a playable stem — the player sums every entry in `stems` and does not gate playback on `default`, so a listed full mix plays on top of the stems". Faced with a reader that would double the song, the packer put the mixdown outside `stems` and invented a key to point at it. The fix belongs in the reader, and that is what this is. load_song() now partitions the stem list: `full` comes out as LoadedSloppak.full_mix, the instruments stay in .stems. Nothing that sums stems or draws one fader per stem can see the mixdown, so retaining it is safe — which is what lets the packer put it where the format says it goes. - ws_highway: `song_info` gains full_mix_url / has_full_mix. The old original_audio_url / has_original_audio remain as deprecated aliases for one release so an older stems plugin keeps working (#945). - `stems` on the wire, and stem_ids / stem_count in the library index, are now INSTRUMENT stems only — a separated pack that retains its mixdown no longer advertises a bogus "full" chip or an inflated stem count. - enrichment: fingerprint against the mixdown wherever it lives. This widens coverage — _song_audio_file() previously returned None for any pack without the invented key, so fingerprinting silently did nothing for nearly every pack. - sloppak: `original_audio:` is still READ as a deprecated fallback, because every pack in the wild carries it and would otherwise lose its pristine mix. tools/migrate_full_mix_stem.py rewrites those packs into the spec shape (original/full.ogg -> stems/full.ogg, add the `full` stem at default:off, drop the key); the fallback and the aliases die with #945. The spec gate keeps the debt honest: the grandfather entry now tracks #945, and the gate fails if it goes stale. Verified: spec gate OK (4/4, incl. ingesting the spec's new example pack that retains `full`); 2493 python tests, 995 js tests; migrator round-tripped over real packs from the library and the results pass the spec's reference validator. * fix(migrate): discover directory-form packs instead of silently skipping them iter_packs() searched only files, so a directory-form pack (`song.sloppak/`, the authoring shape) was walked INTO and never yielded — silently missed by a run that's meant to be exhaustive. Discover suffix-named directories too (yielded whole, not descended into), and route packs through migrate_pack/verify_pack. Directory packs are REPORTED as `dir-form-unsupported`, not rewritten in place: a single-file pack is replaced atomically (a fully-built temp archive swapped in with one os.replace), but a populated directory can't be swapped that way, so an interrupted in-place rewrite could leave an authoring pack half-migrated. The status is a problem status, so it counts against the run's exit code and shows in the summary — the operator re-packs or migrates it as a `.feedpak` instead of it vanishing from the report. Addresses a CodeRabbit review finding. Signed-off-by: Kris Anderson <topkoa@gmail.com> * fix(migrate): verify requires an explicit `off` on a retained full mix verify_zip accepted any non-truthy `default` on a multi-stem `full` (missing, empty, boolean, `false`/`no`/`0`, malformed) as "ok". But core defaults an ABSENT `default` to True — ON (lib/sloppak.py: `s.get("default", True)`) — and treats an empty/unrecognized string as ON too, so a migrated-shape pack whose `full` stem has a missing or blank default beside instrument stems would actually play the mixdown on open and double the song. verify was certifying that as safe. Require an explicit normalized `off` beside instrument stems: `on`-ish values are reported `full-stem-default-on` (actively plays), everything that is not a normalized `off` is reported `full-stem-default-not-off`. The migrator already writes the literal `off`, so its own output is unaffected; this also certifies the pack is in the tool's canonical, most-portable shape. The len>1 gate is kept, so a sole `full` stem (which IS the audio) is not policed. Adds parametrized coverage for missing / empty / boolean / off-ish / malformed defaults, and a sole-full-stem case. Addresses a CodeRabbit review finding. Signed-off-by: Kris Anderson <topkoa@gmail.com> --------- Signed-off-by: Kris Anderson <topkoa@gmail.com> Co-authored-by: Kris Anderson <topkoa@gmail.com> |
||
|
|
d876ded00f |
fix(sloppak): bound the unpack cache; add read_member_bytes() so callers stop unpacking whole songs (#950)
* fix(sloppak): bound the unpack cache, and add a way to read a song without unpacking it A tester's sloppak_cache reached 60 GB from an 1800-song library — his entire library, unpacked, none of it played. Stems are already-compressed audio, so an unpacked pack is ~1.1x its zip: the cache is a second, DECOMPRESSED copy of every song it touches. It had no size cap, no LRU, and no cleanup of any kind — not even when the song itself was deleted. Two halves: 1. resolve_source_dir() now evicts least-recently-used songs to stay under a cap (FEEDBACK_SLOPPAK_CACHE_MAX_MB, default 4 GB ≈ 130 songs of recency; 0 disables). The sweep runs on unpack — the only moment the cache grows — so it can't drift. An evicted song is dropped from _source_cache too: get_cached_source_dir() is the only thing media.py consults before falling back, so a stale path there would 404 every stem for the rest of the process instead of re-unpacking. get_cached_source_dir() now also verifies the dir still exists, which makes "just delete sloppak_cache/ to reclaim disk" safe advice. 2. read_member_bytes() reads ONE file out of a pack without unpacking it — the same trick read_cover_bytes() uses so the library grid doesn't explode every pack to show a cover. Unpacking a whole song to read a few KB of JSON is ~45x write amplification; doing it in a loop over the library is what produced the 60 GB. rig_builder's library-wide tone batch is the caller that did exactly that (fixed separately); this gives it, and everyone else, the right primitive. Eviction is concurrency-safe: unpacks run 2-at-a-time, so a dir being written is marked in-flight and the sweep skips it — checked and rmtree'd under one hold of the guard, and the marker is released even if the unpack raises (a leaked marker would make that dir permanently un-evictable). read_member_bytes normalizes both the requested path AND the archive's stored member names through safe_join, taking the last match — so './arrangements/x.json', backslash members from Windows tooling, and duplicate members that normalize to the same path all read back exactly as unpack-then-read did. Zip-slip is rejected before anything is opened. Tests: tests/test_sloppak_unpack_cache.py. All bite-tested (reverted each fix, watched it fail) — including one that was passing vacuously: a freshly-unpacked dir is the most-recently-used, so the LRU never reaches it and the in-flight race test proved nothing until the packs were sized to force the sweep that far. * test: split semicolon-joined statements (E702) CodeRabbit on #950. Style only; no behaviour change. |
||
|
|
18d77d2d41 |
feat(library): effective genre falls back to MusicBrainz enrichment (#949)
* feat(library): effective genre falls back to MusicBrainz enrichment Converted packs rarely carry a genres manifest key — on Byron's real library 1188/1190 songs had no genre, starving the genre facet and the career passport rack (2 usable genres) while song_enrichment already held MB genres for 636 matched songs. The effective-genre expression now resolves: per-song override → pack genre → json_extract(enrichment.genres, '$[0]') for MATCHED rows only (review/failed candidates could carry the wrong recording's genres). Same fast-path gating as before: the plain indexed column is used unless overrides or enrichment genres actually exist; stand-in DBs without the table degrade via the OperationalError guard. Career passports pick this up automatically through _effective_genre_expr(). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: changelog names manual rows in the enrichment fallback Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
5921157f35 |
test(stats): prove seconds-only recency on a fresh row (#948)
CodeRabbit on #947: the prior lastPlayPosition POST already stamped last_played_at, so the assertion passed even if the seconds-only path left it unchanged — a guard that cannot fail. Assert on a fresh row. 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> |
||
|
|
4027c31a61 |
feat(career): curated genre drills — per-instrument, achievably cleared (#943)
Career v2, WS3. Bronze in blues/rock/metal/funk/jazz now also asks for
the genre's signature Virtuoso drill, data-driven in passports.json:
blues_shuffle, rock_power_backbeat, melodic_metal_gallop,
sixteenth_pocket, vl_shells — with career-side display labels the
passport page renders instead of raw node ids.
- virtuoso_nodes becomes {instrument: [node_ids]} so a keys passport
never demands a guitar drill; a flat list keeps meaning guitar
(virtuoso's content is guitar-first).
- _node_cleared also accepts keysCleared (a top-tier clean pass in one
key — virtuoso's FIRST gained-only artifact). The depth rungs
additionally require a maxed speed tier, too high a bar for Bronze.
- Genres without a curated entry stay songs-only.
Note: pre-release behavior change — v1 passports aren't in any shipped
build, so no earned badge can demote in the wild.
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>
|
||
|
|
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 |
||
|
|
ddc06ff1e7 |
Merge branch 'main' into chore/feedpak-spec-gate
Signed-off-by: byrongamatos <xasiklas@gmail.com> |
||
|
|
ac5c5ad20d |
ci: cover gap-fill manifest key scans
Signed-off-by: byrongamatos <xasiklas@gmail.com> |
||
|
|
3832a5762b |
feat(career): passport backend — genre badges computed from stars (#935)
The badge-journey layer on top of career stars (Christian's career-mode
v2 design, composed with the shipped venue system). Badges are computed
on read from song_stats × the library's effective genre — never stored:
Bronze = N genre songs at min_stars (data-driven in passports.json,
default 5 songs at 2★) plus any configured virtuoso drill nodes.
New endpoints under /api/plugins/career/:
- GET /passports passport walls per instrument: badges, ticket
stubs (qualifying songs), library genres, drills
- POST /passports/commit instrument commitment (idempotent wax seal)
- POST /passports/open open a genre passport (implies commitment)
- POST /drill-state intake for the relayed virtuoso.progress
snapshot (career's frontend listens on the bus)
Instrument attribution reuses progression.instrument_for_arrangement via
the song_stats arrangement index; the genre column goes through the
host's override-aware effective-genre SQL. Non-graded instruments (bass,
drums) render shown-not-judged — repertoire, never a false badge denial.
Persisted state (commitments, opened passports, drill snapshot) lives
under CONFIG_DIR/career/ and rides the settings export bundle via
settings.server_files; a minimal settings.html documents it.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
5dcf39cd62 |
test: drop unused sys import (review)
Signed-off-by: topkoa <topkoa@gmail.com> |
||
|
|
203f82b6fe |
ci: legible failures for malformed exceptions file; docs catch up
_parse_exceptions() now validates the document shape — top level must be a mapping, 'exceptions' must be a list, each entry a mapping, and YAML parse errors are caught — each failing with a ::error:: instead of an AttributeError traceback. CI output must say what to fix. Parametrised tests cover all four malformed shapes. docs/feedpak-spec-gate.md: the Limitations section still described the pre-flow-aware scanner (KEY_OPS, name-list-only receivers). Now states the actual residual gaps: function-parameter manifests are recognised by name only, and helper-mediated literal keys (song.py's _gap_fill_manifest_absent(manifest, "album")) are unseen by both the scan and the readers-complete guard, since they share one detector. Signed-off-by: topkoa <topkoa@gmail.com> |
||
|
|
0158286d06 |
ci: flow-aware manifest discovery — a name list missed real readers
Review found lib/routers/chart.py binding `m = load_manifest(p) or {}` and
reading eight manifest keys through it. `m` was not in MANIFEST_VARS and
`m.get` did not match the readers-complete regex, so the module was
invisible to BOTH halves of the gate — unlisted and unscanned. Same for
lib/routers/song.py (binds `manifest` from load_manifest for enrichment
gap-fill). Both are now in READERS.
The structural fix, not the name-list patch: keys_touched() now discovers
receivers flow-aware — any local assigned from load_manifest(...) is a
manifest dict, whatever it is called. MANIFEST_VARS remains only as the
fallback for manifests that arrive as function parameters (ws_highway).
A plain `m = {}` is not a receiver; test pins that.
readers-complete now reuses keys_touched() itself instead of a parallel
KEY_OPS regex — the two detectors diverged once already (that is exactly
how chart.py slipped through), so now there is one detector and one truth.
check_reverse() gets a 300s subprocess timeout: the validator executes at a
pinned SHA, but a pathological pack or validator bug should fail the job,
not hang the runner to the Actions-level timeout.
Tests: flow-aware receiver under an arbitrary name (read + write), and the
negative — a plain dict named `m` stays out of the scan. 17 pass.
Signed-off-by: topkoa <topkoa@gmail.com>
|
||
|
|
b54b65d35c |
test: give the spec gate its own regression suite
Self-review finding: the gate is what keeps the app from drifting off the feedpak spec, but the gate itself had zero pytest coverage — a refactor could quietly weaken keys_touched or the allowlist logic and nothing would notice. The protector needs protecting. tests/test_spec_gate.py pins the load-bearing behaviours: - read/write classification: get() reads; subscript Store and setdefault() write (the two forms that were blind spots in review); the load_manifest-wrapped get; unrelated dicts and non-literal keys ignored. - exceptions file: duplicate keys and issue-less entries rejected. - the closed allowlist: growth fails, shrink and steady state pass, bootstrap skips. - live-tree checks, same as CI: READERS matches the codebase, and the only non-spec key core touches is the grandfathered original_audio. Also fixes stale "Layer 2/3" docstrings on check_forward/check_reverse (they are layers 3/4 since allowlist-closed landed) — the same docs-lag-the-code class this PR's review kept catching; now the numbering is asserted by the printed [n/4] headers next to them. Signed-off-by: topkoa <topkoa@gmail.com> |
||
|
|
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> |
||
|
|
e2215df753 | feat(career): bundle dive bar venue pack (#927) | ||
|
|
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)
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>
|
||
|
|
f27d4f623c |
fix(nav): the library sometimes showed the legacy screen — map 'home' inside showScreen (#923)
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>
|
||
|
|
545e569ad6 |
refactor(app): carve the song session out of app.js — playSong, showScreen, closeCurrentSong (R3d) (#921)
36 declarations + the 4 autoplay/auto-exit gate statements. 359 lines. app.js 3,772 -> 3,242. Bodies VERBATIM. ━━━ THIS WAS "THE UNCUTTABLE HEART", AND IT IS 359 LINES ━━━ At the start of this epic, seeding a dependency closure from count-in, from loops, from section-practice, or from the JUCE seek shim all returned the SAME 178-function, 3,360-line set. playSong and showScreen called each other; everything called them; nothing could be cut anywhere. The conclusion — correct at the time — was that NO closure-based carve could touch it at any seed, and the answer was a host seam. That was true THEN. Every slice taken out since (transport, loops, count-in, section-practice, the library, the edit modal, settings) removed edges, and the strongly-connected component DISSOLVED. This closure is 36 declarations with an interface width of FOUR. The lesson is not that the seam was wrong — the seam is what MADE this possible, by letting the carves proceed against a cyclic core instead of stalling on it. The lesson is to RE-MEASURE. An SCC is a fact about a graph at a moment, not a property of the code. ━━━ THE BUG NO SCAN COULD SEE, AND THE A/B DID ━━━ First cut passed every gate — no-undef clean, no-cycle clean, 1045/1045, pytest green — and THREW IN THE BROWSER: "Assignment to constant variable." window.feedBack.holdAutoplay / holdAutoExit and their two event handlers are TOP-LEVEL STATEMENTS, not declarations. They WRITE this cluster's state (_autoplayHeld, _autoExitTimer, …), and an imported binding is READ-ONLY — so left behind in app.js, every one threw the instant the module existed. A dependency scan that walks DECLARATIONS cannot see them. Mine didn't. This is the same blind spot that nearly shipped a dead library A-Z rail (#896): app.js keeps its public API in top-level statements, and a call-graph is blind to every one of them. The extractor now finds them by construction — any top-level statement that WRITES a moved binding comes with the carve — and the gate statements live beside the machinery they drive, which is where they belonged anyway. ━━━ ZERO OUTSIDE WRITES, BY MOVING THE BOUNDARY RATHER THAN BUILDING MACHINERY ━━━ The autoplay scalars and the wake-lock state were written from outside the cluster, which would have forced a setter or a state container. But the writers — _releaseAutoplay, _acquireWakeLock — plainly belong here. Pulling them in left ZERO outside writes, so every export is a plain import. Same move as settings (#920): measure the writers before you reach for a container. VERIFIED. A/B against origin/main in two browsers, IDENTICAL, zero page errors — including the autoplay gate driven end to end: a plugin HOLDS autoplay, the song loads but does not start, the RELEASE fires it, and a stale release is a no-op. That is the exact machinery that was throwing. node 1045, pytest 2425, ESLint 0 (no-cycle clean), host contract 2/2, Codex 0. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
84fe29688c |
refactor(app): carve settings into static/js/settings.js (R3d) (#920)
22 declarations, 446 lines. app.js 4,218 -> 3,772. Bodies VERBATIM. Settings load/save, the AV-offset nudge, the default-arrangement pin, the instrument pathway, and the app-update channel. ━━━ INTERFACE WIDTH 1, AND IT GOT THERE BY DRAWING THE BOUNDARY IN THE RIGHT PLACE ━━━ app.js calls loadSettings() and nothing else. The first cut was NOT clean: _defaultArrangement was written from OUTSIDE the cluster, and an imported binding is READ-ONLY, so that one write would have forced a setter or a state container — as it did for the player (player-state.js) and the library (library-state.js). But the writers were saveSettings and pinCurrentArrangementDefault, which ARE settings functions. Widening the slice to include them left ZERO outside writes. Every export is now a plain read-only import and no container is needed. Worth naming, because I reached for a container twice before: the fix for "this binding is written from outside" is sometimes a container, and sometimes it just means the boundary is in the wrong place. Measure the writers before you build machinery. ━━━ handleSliderInput STAYS A HOST HOOK, DELIBERATELY ━━━ It lives in settings now (it is a settings control), but player-controls.js must NOT import it: this module already imports player-controls (_applyMastery, _autoplayExitEnabled, …), so a direct back-import would close a cycle. player-controls keeps reading it through the host seam, and app.js — the root, which imports both — wires it. That is exactly what the seam is for, and the contract test proves the wiring survived. VERIFIED. A/B against origin/main in two browsers: the window contract, the settings screen rendering, the AV-offset and default-arrangement controls present, and a real `input` event dispatched on a slider — which is the path that goes through the host seam. IDENTICAL, zero page errors. node 1045, pytest 2425, ESLint 0 (no-cycle clean), host contract 2/2, Codex 0. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
69aac32278 |
refactor(app): carve the edit-song modal into static/js/edit-modal.js (R3d) (#919)
4 functions, 234 lines. app.js 4,452 -> 4,218. Bodies VERBATIM. INTERFACE WIDTH ZERO — nothing in app.js calls into this cluster. app.js needs only the names on the window contract, so the markup's onclick= handlers resolve. That is what makes it the cleanest slice left. AND IT ONLY BECAME CLEAN BECAUSE THE LIBRARY CAME OUT FIRST (#896). Every dependency the modal has is a module now: it reads six bindings out of ./library.js (loadLibrary, loadFavorites, loadTreeView, _removeLibCardsForFilename, libView, _lastLibSelected) plus dom.js and the L container. Before that carve, extracting this would have dragged the whole library with it. Checked, and it matters: the modal never WRITES any of those six. An imported binding is READ-ONLY, so a single write would have forced a setter or a state container. Every use is a read, so plain imports suffice. Acyclic: edit-modal -> { dom, library-state, library }, and library imports none of them back. VERIFIED. A/B against origin/main in two browsers: the window contract, the modal actually OPENING off a real library row, its title and year fields rendering, and the data-edit-save wiring (rather than an inline onclick embedding the filename — the fix this cluster's harness exists to guard). IDENTICAL, no new page errors. node 1045, pytest 2425, ESLint 0 (no-cycle clean), host contract 2/2, Codex 0. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |