Compare commits

...
Author SHA1 Message Date
byrongamatos 3d0229556f fix(folder_library): re-window on resize and on show/hide (PR #967 review)
CodeRabbit caught two real bugs in the first pass. Both are mine.

1. GRID RESIZE. perRow and rows were captured once when the list was filled, but
   paint() also runs on resize — and resizing changes the grid's column count.
   The window maths then sliced against the OLD column count: wrong songs on
   screen, and padding sized for a row count the layout no longer had (so the
   scrollbar lied). metrics() now recomputes perRow/itemH/rows together on every
   paint, so the geometry can never disagree with itself.

2. STALE WINDOWS ON SHOW/HIDE. paint() only ran on scroll and resize. Expanding
   or collapsing any section moves every list below it, and a windowed list's
   contents are a function of its POSITION — so those lists kept the window from
   their old position and showed blank padding where songs should be until the
   user happened to scroll. Both toggles now call _repaintVirtualLists().
   Re-opening an already-populated section had the same flaw.

   Collapsed lists also kept doing layout work on every scroll tick. paint() now
   bails early when the list is display:none or detached, and forgets its last
   window so re-showing repaints from scratch instead of short-circuiting on a
   stale memo.

Tests: grid re-window on a column-count change, the padding+rendered=rows
invariant at two different perRow values, and a test that PINS THE FAILURE MODE —
a mismatched perRow/rows pair must not silently look correct. 12/12.
Re-validated the DOM glue in real Chromium with 50k rows (25-31 rows rendered,
scroll height exact). eslint clean; JS 1189/1189; pytest 2597 passed.

CHANGELOG entry added (also flagged).
2026-07-14 21:20:28 +02:00
byrongamatos 1787e19213 perf(folder_library): render only the songs on screen (#965)
A song list rendered EVERY song it held. On a flat 50,944-song library that is
one <div> with 50,938 children and ~1,300,000 DOM nodes — ~4.2 GB of renderer
RSS, for a screen the user may not even be looking at (it was built while the
visible screen was v3-home).

It is not just this plugin's problem. A million-node document poisons unrelated
code: any `document.querySelector` that MISSES has to walk the whole tree before
returning null. That is exactly how song_preview's per-frame menu check ended up
consuming ~50% of the renderer and dropping the app to 2.7 fps
(feedBack-plugin-song-preview#7 fixes the per-frame walk; this fixes the tree it
was walking).

So render only what is on screen. Rows are uniform height (grid cards uniform
size), so the window is pure arithmetic — no per-row observers. Off-window songs
are represented by padding ON THE LIST rather than spacer elements: a spacer div
would become a grid ITEM in grid view and shift the columns, whereas padding
behaves identically in both layouts. Lists at or below VIRTUAL_MIN (200) render
in full exactly as before, so normal folders are untouched.

Two ordering fixes this forced, both real bugs waiting to happen:
  - Both expand handlers populated the list BEFORE showing it. A windowed list
    measures a real row and the scroller viewport, and both are zero under
    display:none. Show first, then populate.
  - _render() now tears down the previous render's scroll listeners. Without it
    they survive against detached nodes and leak on every re-render.

Verified in real Chromium over CDP with 50,000 rows — the DOM glue, not just the
maths:

    at top          rendered= 25 rows   scrollHeight=2,200,000px   [0..24]
    scroll   500k   rendered= 31 rows   scrollHeight=2,200,000px   [11357..11387]
    scroll 1,100k   rendered= 31 rows   scrollHeight=2,200,000px   [24994..25024]
    scroll to end   rendered= 25 rows   scrollHeight=2,200,000px   [49975..49999]

25-31 rows in the DOM instead of 50,000; scroll height exact and constant (the
scrollbar stays honest); the last row lands on song 49,999.

Tests: _visibleWindow is pure and exposed via __test — top/middle/bottom/past-
the-end windows, the grid row-packing case, the padding-plus-rendered-equals-
total invariant that keeps the list from changing height as you scroll, and the
degenerate zero-height case (a list still display:none) falling back to
render-everything rather than to an empty list. eslint clean; full JS suite
1186/1186.
2026-07-14 21:04:17 +02:00
Byron GamatosandGitHub 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.
2026-07-14 19:48:39 +02:00
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>
2026-07-14 16:54:08 +02:00
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>
2026-07-14 14:07:24 +02:00
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>
2026-07-14 14:02:04 +02:00
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>
2026-07-14 11:26:36 +02:00
K. O. A.andGitHub 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>
2026-07-14 01:58:24 -04:00
dd1927e27b feat(career): gigs frontend — poster, runner strip, summary, encore (#956)
ship-ci / ci (push) Waiting to run
* feat(career): gigs frontend — poster, runner strip, summary, encore

Career v3, WS3 (frontend half), rebuilt cleanly on merged main (v3-a/b/c
in) after git interleaved the structurally-similar canvas functions:

- Book a gig from any opened passport: /gigs/propose renders as a GIG
  POSTER (venue presents GENRE NIGHT, numbered bill) with re-roll and
  Save/Copy poster (natively-drawn canvas via blob-io, audible failure
  paths, slash-safe filenames).
- Play the gig: venue override + Venue viz handoff, then
  playQueue.start(..., {source:'gig'}) — the queue's auto-advance runs
  the set; zero new playback machinery.
- Floating gig strip (body-level, pointer-events none, z 35 per the
  chrome invariant) tracks set position and names what's next.
- Completion = song:ended with an empty queue → POST /gigs → summary
  poster overlay with per-song accuracies; encore fires the crowd
  celebrate + confetti (reduced-motion: neither). song:stop with a dead
  queue = abandoned (no log); end-of-song teardown (queue still active)
  must NOT abandon.
- Gigs played render as dated rows in the passport book.

vm tests: runner advance/abandon semantics via the queue-state seam.

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

* fix(career): stage restore survives a setViz throw; size-register row

CodeRabbit on #956: a setViz failure nulled the restore snapshot AFTER
the overrides were written, permanently borrowing the stage — snapshot
now captured before any write, write failures keep it intact. Also
registers career screen.js in docs/size-exemptions.md 'Planned, not
exempt' (1,516 lines; max-lines WARNS non-blocking — the split plan
needs Byron's sign-off).

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

* docs: register career screen.js in the size register (planned, not exempt)

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 01:54:07 +02:00
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>
2026-07-14 01:26:23 +02:00
6272af8d33 feat(career): profile passport wall, home career card, shareable PNG card (#955)
* feat(career): profile passport wall, home career card, shareable PNG card

Career v3, WS2. The identity artifact leaves the plugin tab:

- Profile: #v3-profile-passports-mount (core, one div) filled by career
  on v3:profile-rendered — per-instrument shelves of earned covers,
  hours, gig count, open-career link. Absent-not-empty.
- Home: the plugin-count stat tile becomes #v3-dash-career-slot with the
  old stat as fallback content; career replaces it with a trading-card
  tile (leather + foil shine, badge count, hours, closest-stamp ask) on
  the existing v3:dashboard-rendered event.
- Shareable card: static/js/blob-io.js (downloadBlob lifts the idiom
  duplicated verbatim in settings-io/diagnostics-export — both
  refactored; copyImageBlob wraps ClipboardItem, returns false to signal
  the download fallback). Earned passports get Save/Copy card: a
  natively-drawn 480×640 canvas (leather, stamp ring, stubs+hours line);
  copy falls back to download with a notice when the clipboard refuses.
- Mount-point convention documented in docs/plugin-v3-ui.md.

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

* fix(career): external surfaces stay absent until a passport exists

CodeRabbit on #955: a bare commitment produced a zero-passport wall and
replaced the dashboard fallback. Docs also now say mounts may hold
fallback content and plugins REPLACE, never append.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 01:25:39 +02:00
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>
2026-07-14 01:10:55 +02:00
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>
2026-07-13 20:05:05 +02:00
329cc86315 fix(sloppak): the full mix is a stem — drop the invented original_audio key (#946)
Nightly / build-docker (push) Has been cancelled
ship-ci / ci (push) Waiting to run
* 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>
2026-07-13 12:22:42 -04:00
Byron GamatosandGitHub 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.
2026-07-13 17:13:08 +02:00
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>
2026-07-13 16:53:06 +02:00
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>
2026-07-13 15:36:05 +02:00
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>
2026-07-13 15:31:12 +02:00
b85496fe58 feat(career): passport visuals pack — tilt, emerging ink, gold foil (#944)
* feat(career): passport visuals pack — tilt, emerging ink, gold foil

Career v2, WS4. All CSS + a rAF-throttled pointer handler, no deps:

- Trading-card tilt on EARNED artifacts only (shelf covers + the badge
  page stamp): pointer-tracked perspective rotateX/Y with a glint sweep
  following the pointer. The cover's jitter rotation moves into a CSS
  var so the tilt transform composes with it; the blanket cover-hover
  translate is scoped :not(.pp-tilt) so it can't fight the tilt.
  Hover-capable pointers only; off under prefers-reduced-motion.
- Emerging-stamp ink: the ghost stamp fills by qualifying/required via
  a conic-gradient — the stamp visibly carves in, no numbers added.
- Gold foil preview: the "coming" note gains a dashed foil chip with a
  periodic shimmer — honest, never earnable-looking.

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

* fix(career): tilt rAF race + freshly-slammed stamp becomes a card

CodeRabbit on #944: a queued tilt frame closed over the departed card
and re-applied vars after pointerleave; and the just-slammed stamp
never regained pp-tilt until the next open.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 15:22:41 +02:00
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>
2026-07-13 15:14:39 +02:00
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>
2026-07-13 15:12:23 +02:00
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>
2026-07-13 15:07:28 +02:00
45caa86ab8 Bar pack v4 refresh + crowd sound reactions (#940)
* chore(career): refresh bar pack to v4 + restore crowd-SFX setting

Pack v4: per-character desynced animation starts, flyover intro,
per-venue reaction sounds (sfx-up/sfx-down in manifest).
Settings: re-add the crowd sound reactions toggle that was dropped
when settings.html became the passports data panel.

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

* feat(v3): crowd mood-change sound reactions (cheer up / boo down)

Port the venue-crowd SFX runtime that the settings toggle and the
pack's sfx-up/sfx-down files were built for: on a committed mood
transition, play the venue's own cheer (up) or boo (down) one-shot,
gated by the feedBack-venue-crowd-sfx setting (default off).

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

* chore: rebuild tailwind.min.css for settings toggle classes

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 14:29:34 +02:00
Byron GamatosandGitHub 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
2026-07-13 14:20:58 +02:00
Byron GamatosandGitHub 8b6829a946 Merge pull request #938 from gionnibgud/feat/v3-start-fullscreen
feat(v3): add desktop-only "Start in fullscreen" system option
2026-07-13 14:17:05 +02:00
gionnibgud 3050c7b1d3 feat(v3): reword the fullscreen setting + note the macOS launch caveat
Retitle the toggle "Fullscreen" (from "Start in fullscreen") and reword
the description to "Run fee[dB]ack in fullscreen mode. On macOS, changes
take effect on the next launch."

The macOS note is honest about a native-fullscreen limitation: AppKit
drops the first programmatic fullscreen-enter on a window created
windowed, so on macOS the desktop side applies the pref at next launch
rather than live. Windows/Linux apply it live on the first toggle. The
note is self-scoping text (no platform-detection code needed).

Signed-off-by: gionnibgud <gionnibgud@gmail.com>
2026-07-13 13:43:44 +02:00
Byron GamatosandGitHub ba796b0f27 Merge pull request #934 from got-feedBack/chore/feedpak-spec-gate
ci: gate core against the feedpak spec
2026-07-13 13:31:18 +02:00
byrongamatos ddc06ff1e7 Merge branch 'main' into chore/feedpak-spec-gate
Signed-off-by: byrongamatos <xasiklas@gmail.com>
2026-07-13 13:27:25 +02:00
byrongamatos ac5c5ad20d ci: cover gap-fill manifest key scans
Signed-off-by: byrongamatos <xasiklas@gmail.com>
2026-07-13 13:25:44 +02:00
gionnibgud f8012a8ce4 feat(v3): add desktop-only "Start in fullscreen" system option
Adds a "Start in fullscreen" toggle to the Settings → System panel,
addressing the desktop request in feedBack-desktop#97: users want the
app to launch fullscreen without hitting the OS hotkey every time.

The block ships hidden and is gated exactly like the App-updates block:
setupWindowOptions() only unhides + wires it when the feedBack-desktop
bridge exposes window.feedBackDesktop.window.{getStartFullscreen,
setStartFullscreen}. Web/Docker builds have no such bridge, so the
section never appears there. Persistence lives desktop-side because
only the Electron main process can read the pref at window-creation
time — core just proxies through the bridge.

The desktop bridge + launch behaviour land in a follow-up
feedBack-desktop PR.

Signed-off-by: gionnibgud <gionnibgud@gmail.com>
2026-07-13 12:38:24 +02:00
99b974a5a1 fix(career): passport review polish — a11y semantics + seen-state guard (#937)
CodeRabbit follow-up on #936 (the one Major — overlay outside the click
root — was verified false: the host mounts every screen.html root inside
#plugin-career, ✕-close confirmed working live):

- Tabs: aria-selected/aria-controls + role=tabpanel/aria-labelledby.
- Book overlay: role=dialog + aria-modal + aria-label; focus moves to
  the close button on open and returns to the opener on close.
- seenBadges(): guard non-object JSON so a corrupt stored value cannot
  throw on every passport refresh (covered by a new corruption test).
- Fresh-session suppression test (badge seen → no re-notification).
- Stylelint declaration-empty-line-before nit in .pp-stamp.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 12:14:24 +02:00
7ffa6e2c51 feat(career): passport UI — the book, the stamp, the rack (#936)
The Passports tab beside Venues renders the badge journey physically:

- Per-instrument passport book: embossed CSS-leather cover, 3D page-turn
  spread (badge page left, ticket stubs right), Escape/backdrop close.
- Wax-seal commitment ceremony (Stage 0) — pressing the seal commits the
  instrument; opening a first passport runs the ceremony implicitly.
- Rubber-stamp badge slam: earned badges chime + notify immediately, the
  slam (with ink bleed, page shake, deterministic sin-hash jitter) plays
  when the passport is next opened, then the badge is marked seen.
- Ticket-stub repertoire: qualifying songs as collected stubs.
- Brochure rack: unopened genres as "Explore next" invitations — no
  greyed slots, no completion meters (the anti-list as layout).
- Drill relay: on virtuoso:progress bus events the career screen posts
  the full virtuoso.progress localStorage snapshot to the drill-state
  intake (debounced; one-time bootstrap when the server has none).
- Four synthesized sfx (stamp/seal/page/chime, 13 KB total) as plugin
  assets; prefers-reduced-motion disables the theatrics.

Pure logic (ppKey, ppJitter, badge diff/seen) is covered by a bare-vm
node --test suite via a window.__careerPassportTest seam.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 11:56:32 +02:00
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>
2026-07-13 11:47:59 +02:00
topkoa a60dcd10c2 ci: legible errors for a missing baseline and an unparseable reader
Two review nits: check_allowlist_closed() raised a traceback when
--baseline-exceptions pointed at a missing file (the error now says CI
derives it from the base branch and local runs should omit the flag), and
check_key_coverage() would traceback on a reader with a SyntaxError (now a
::error:: naming the module — belt-and-braces, since such a module can't
pass pytest either, but this job may run first).

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-13 01:23:59 -04:00
topkoa 5dcf39cd62 test: drop unused sys import (review)
Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-13 01:14:02 -04:00
topkoa c485f02211 ci: track the spec's HEAD — the app conforms to the living spec
Design change, at the maintainer's direction: the gate now checks out
feedpak-spec at HEAD instead of a pinned SHA. .feedpak-spec-ref, its
40-char validation step, and the pin-bump machinery are gone.

Rationale: it is vital that the app conforms to the spec — the current
spec, not a snapshot. The pin bought determinism at the cost of a
maintenance loop (bump PRs, a PAT, weekly latency) and a window where the
gate verified against a stale spec. Tracking HEAD makes the dev flow fully
self-serve with zero upkeep: gated PR -> FEP -> spec merge -> re-run
checks -> green. Nothing to bump.

The trade-off is accepted with eyes open, and the docs state it: the
normal FEP is additive and can only loosen the gate, so it cannot redden
anyone's PR. Only a breaking spec change (rare, deliberate, MAJOR per the
spec's compatibility policy) turns PRs red repo-wide — which is the
correct org-wide signal that the app is out of conformance. The CI job
logs the spec SHA each run verified against, so any red run is
reproducible.

Failure messages now also say why it matters beyond the one PR (also at
the maintainer's direction): non-conformance that lands shows up as red CI
on every teammate's PR until it is resolved, and only its author can clear
it — the FEP route keeps everyone else unblocked. Tone softened throughout
(the exceptions-file header now explains rather than shouts).

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-13 01:07:12 -04:00
topkoa 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>
2026-07-13 00:53:09 -04:00
topkoa 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>
2026-07-13 00:45:38 -04:00
topkoa 1e2ce29cf6 docs: make FEP-first impossible to miss before the gate fires
The CI gate catches spec drift at merge time; these two additions catch it
at write time, which is where "developer didn't read the spec first"
actually happens.

CLAUDE.md (Song Formats): a spec-is-sacrosanct paragraph next to the spec
pointer — the spec defines the format, the app implements it, any new
manifest key/file/directory lands in the spec first via the FEP process,
and the gate has no in-repo bypass. AI agents and contributors both hit
this while writing feedpak-touching code, not after CI reddens.

.github/pull_request_template.md (new — the repo had only issue templates):
a feedpak-surface section requiring either "doesn't touch pack I/O" or a
link to the landed FEP + the .feedpak-spec-ref bump, plus the standing
changelog/tests/DCO checklist.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-13 00:41:44 -04:00
topkoa 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>
2026-07-13 00:39:36 -04:00
topkoa ab2e68a638 ci: resolve the allowlist baseline against the real base branch
The allowlist-closed diff hardcoded `origin main`, but ship-ci.yml also runs
this workflow for PRs into release/** and for pushes to release/**, where a
main baseline diffs against the wrong branch and can fail changes that have
nothing to do with the allowlist. It now resolves the base:

  PR   -> github.event.pull_request.base.ref (the branch it merges into)
  push -> github.ref_name (the branch itself; its tip already contains the
          change, so the diff is a no-op — enforcement happens at PR time)

Also from review, all documentation drift introduced by my own earlier
commits:

- The layer count said "three" in the module docstring, the workflow comment,
  the docs, and the changelog. There are four (allowlist-closed was added).
- The changelog listed three scanned modules; there are five.
- The docs and changelog stated the rule for keys core *reads*, omitting
  writes — which are equally gated, and land in every pack we emit.
- The CI summary line labelled grandfathered keys "pending spec", implying
  adoption is the only resolution. For original_audio it is not: the fix is
  removal. Relabelled "grandfathered (tracked debt)".
- feedpak-spec-exceptions.yml said an entry clears when core "stops reading"
  the key; the rule is "no longer reads or writes".
- Replaced a bitwise `&` over two bools with two named results and an
  explicit `and` — both checks must run (a stale READERS list and an
  undeclared key are separate failures; short-circuiting would hide one), and
  `&` reads like a typo.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-13 00:32:24 -04:00
topkoa d806d12c22 ci: close two blind spots in the key scan
Review found the gate was scanning less than it claimed.

READERS missed two modules that genuinely touch feedpak manifests:
lib/routers/ws_highway.py (reads `authors`) and lib/gp2notation.py (loads
manifest.yaml, stamps feedpak_version, writes the file back). Keys touched
there were going entirely unchecked.

The scan also only recognised writes done via subscript, so
`manifest.setdefault("k", v)` — exactly how gp2notation.py stamps
feedpak_version — was invisible. setdefault with a literal key now counts as
a write.

The deeper problem is that READERS is hand-maintained, and a hand-maintained
list rots; that is how both modules went unnoticed. check_readers_complete()
now re-derives the set: any module under lib/ (or server.py) that both
touches manifest keys and shows a feedpak signal must be listed, or the build
fails. It is a guard on the gate itself.

The list stays explicit rather than becoming a glob, because `manifest` is
overloaded here: lib/loosefolder.py (the loose-folder manifest.json) and
lib/diagnostics_bundle.py (the diagnostics bundle manifest) have their own
unrelated manifests, and scanning those would flag *their* keys as feedpak
drift. Both score zero on the feedpak signals, which is what keeps them out.

Now scanning 5 modules: 20 reads, 2 writes, all spec-declared.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-13 00:23:36 -04:00
topkoa 32d723b774 ci: close the escape hatches — the FEP process is the only route
The gate's purpose is to make a non-conforming change *not merge*, so the
person merging must stop and decide whether to take it through the format
process. The escape hatches defeated exactly that: a developer who did not
want to write a FEP could name their key `x-whatever`, or append an entry to
feedpak-spec-exceptions.yml with any issue link, and merge. Both were
self-serve and in-repo. That is a speed bump with a signed excuse note, not a
gate.

The relief valve is the FEP process itself, not something in this repo. The
spec's governance already says so: "A change is not part of the format until
it lands here."

Removed the `x-` prefix bypass. It was invented here, not in the spec — the
spec reserves no experimental namespace. Its "unknown keys are reserved for
forward-compatibility" rule is about *tolerating* other implementations'
keys, not a licence for core to mint its own.

feedpak-spec-exceptions.yml is now a CLOSED grandfather list. A new check
(allowlist-closed) diffs it against the base branch and fails any PR that
ADDS an entry; removal stays allowed, so the list can only shrink. Deleting
an entry does not by itself pass the gate — key-coverage still fails while
core reads the key, so the entry goes when the code goes.

Every failure message now points at the FEP process and at bumping
.feedpak-spec-ref to the merged spec SHA, which is the one supported way a
new manifest key reaches core.

CI fetches the base branch to diff the allowlist; the bootstrap flag covers
the one case with no baseline — the PR introducing the gate.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-13 00:14:57 -04:00
topkoa ceb1e143cd ci: second review pass — recurse examples, reject duplicate exceptions
Six more findings from CodeRabbit and Copilot on #934. All valid; four were
my own docs lagging the write-checking change in d0626f5.

check_forward() now discovers example packs recursively, so a pack nested
under examples/<group>/ can't slip past the "every example pack" contract.
Taken WITHOUT the suggested is_file() filter, which would have broken it: a
feedpak is dual-form — a zip (foo.feedpak) or a directory (foo.feedpak/) —
and the spec's own examples ship as directories, so is_file() would have
matched zero packs. Suffix matching covers both forms.

load_exceptions() rejects duplicate keys instead of silently keeping the
last one, which would quietly retarget the tracking issue for a piece of
debt this file exists to track.

The sloppak import is wrapped so a missing dependency produces a CI-legible
::error:: rather than a bare traceback.

Docs caught up with the code: the exceptions file header, its stale-entry
rule, and the changelog all said "reads" when the gate checks reads AND
writes.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 23:54:07 -04:00
topkoa d0626f5618 ci: address review — check writes too, pin deps, harden the spec pin
Review feedback from CodeRabbit and Copilot on #934. All six findings were
valid; one is fixed the other way round from how it was suggested.

Key-coverage now checks manifest WRITES as well as reads. Copilot correctly
spotted that `ast.walk` ignored subscript context, so
`manifest["year"] = ...` (lib/songmeta.py) scored as a read — but the fix is
not to drop writes. A key core *writes* is spec surface pointed outward: it
lands in every pack we emit, so an undeclared one seeds the ecosystem with
non-spec data. Subscripts are now classified by ctx (Store = write, Load =
read) and both sets are checked, with distinct error messages. Today: 19
reads, 2 writes, all declared.

Workflow:
- persist-credentials: false on the repo checkout — the job runs repository
  code and never pushes (CodeRabbit / zizmor artipacked).
- .feedpak-spec-ref must be a full 40-char SHA. actions/checkout resolves
  branches and tags in `ref` too, so a non-SHA there would silently un-pin
  the spec — precisely what the file exists to prevent.
- Pin jsonschema==4.26.0, for the same reason the spec SHA is pinned: an
  upstream release must not redden this job on a PR that changed neither
  this repo nor the spec.

Script:
- check_forward() guards a missing examples/ dir instead of raising an
  unhandled FileNotFoundError.
- TemporaryDirectory() instead of mkdtemp(), so a local run doesn't leak.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 23:44:15 -04:00
topkoa 0dc9fd7ba8 docs: the fix for original_audio is removal, not adoption
The spec already carries the pre-separation mixdown as a stem
({id: full, file: stems/full.ogg}), so the key added a second, redundant
location for audio to a format that already had one. Adopting it into the
spec would make that permanent; the resolution in #933 is to remove it.

No behaviour change — the gate is agnostic about which way a violation
resolves, and only insists that one of the two happens deliberately and in
the open before the code merges. This just stops the exception entry, the
changelog, and the docs from presupposing adoption.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 23:35:49 -04:00
topkoa 22332bef22 ci: gate core against the feedpak spec
feedpak is published as an open format with its own repo, normative spec,
JSON Schemas, and reference validator. That makes the spec a contract with
everyone outside this repo: third-party packers, converters, and players
build against it, and it is meant to be the complete description of a pack.

Nothing enforced that. #583 added a manifest key (`original_audio`) that
core, lib/enrichment.py, and the stems plugin all now depend on, but which
was never added to the spec — so a spec-compliant pack stopped being a
fully-working pack, the reference validator could not warn authors about a
key it had never heard of, and third-party tooling began emitting an
`original/` directory reverse-engineered from an example in a code comment.
See #933.

We cannot mechanically prove core interprets a key the way the spec means.
We can prove three surface properties, and they cover the drift that
actually happens:

  1. key-coverage — every manifest key core reads is declared in the spec's
     manifest.schema.json (AST scan of lib/sloppak.py, lib/enrichment.py,
     lib/songmeta.py).
  2. forward — core's load_song() ingests every example pack the spec ships.
  3. reverse — every pack committed here passes the spec's own
     tools/validate.py (7/7 pass today).

The spec is pinned by SHA in .feedpak-spec-ref rather than tracked from its
default branch, so a change over there cannot redden an unrelated PR here;
bump it in its own PR, where a red result is precisely the signal that core
does not satisfy the new spec.

A gate with no legitimate way to say "yes, deliberately, not yet" gets
switched off the first time it blocks a release, so there are two escape
hatches: the reserved `x-` key prefix (always permitted, and it tells every
third-party packer the key is not stable surface), and
feedpak-spec-exceptions.yml, which requires a tracking issue per entry. An
exception that goes stale — the spec caught up, or core stopped reading the
key — fails the build, so the allowlist cannot become somewhere drift
quietly accumulates. `original_audio` is seeded there against #933 so the
gate lands green and starts blocking the next instance immediately, rather
than requiring #933 to be resolved first.

Dev/CI tooling only; never on the serve or Docker path (constitution
Principle I). jsonschema is installed in the CI job, not added to
requirements.txt.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 23:30:45 -04:00
K. O. A.andGitHub 342def3851 Merge pull request #931 from got-feedBack/docs/pane-best-practices
ship-ci / ci (push) Waiting to run
docs(panes): best practices for plugin authors
2026-07-12 22:59:05 -04:00
topkoa a0278bd3a7 docs(panes): the lifecycle traps — isConnected lies, and re-injection duplicates
Three more rules, all learned by shipping the bug first. Every one of them
produced a symptom that pointed nowhere near its cause.

RULE 5 REWRITTEN — `isConnected` lies about a panel that is a pane, in BOTH
directions:

  - true when the panel is not here (it is in a pane window)
  - FALSE when the panel is perfectly fine — the host detaches the element the
    moment a pop-out starts, before the new window has loaded

Code that rebuilds on that `false` builds a SECOND panel while the host still
holds the first. Docking brings both home. The one the user can see is the
original, which the module no longer points at — so its close button closes the
other, invisible panel ("the X doesn't work"), and the chip gets re-attached to
the impostor ("the pop-out icon vanished"). Two baffling symptoms, one duplicate,
nothing in the stack trace.

Ask the pane system where the element is (`panes.isOpen(id)`), not the DOM.

RULE 6 (new) — a plugin that can be re-injected must be able to remove itself.
Without a teardown the second run duplicates every observer, timer and listener —
and leaves a stale pane registration, which is worse than untidy: `element` is
resolved LAZILY at open time, so the host gets a node from a dead instance. Pop
out, and it moves a panel nobody owns. Includes the teardown people forget:
panes.unregister().

RULE 1 EXTENDED — panel-internal id lookups. document.getElementById returns null
once the panel has moved, so every update it guards silently stops happening
while the user is looking at the panel. Search from the panel instead. Elements
outside the panel never move and are fine as they are — audit which is which.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 22:58:10 -04:00
topkoa 67e6b25c43 docs(panes): best practices for plugin authors
Every rule here is something that has already gone wrong on this feature —
mostly in core's own code, twice in the two plugins that adopted it first.
They are cheap to get right up front and miserable to diagnose later,
because a broken pane almost always LOOKS perfect.

The traps, and why each one is easy to walk into:

- Your code still runs in the main window. That is exactly why moving the
  element works at all — and exactly why `document.body.appendChild(tooltip)`
  inside a popped-out panel lands in the window the user is NOT looking at.

- Don't hide your own panel when it pops out. Core hides it and leaves a
  stub. A plugin that also hides it hides the node that just moved — which is
  precisely how core's own chip shipped a blank pop-out window.

- Use `hidden` or a class, not inline `display`, for show/hide. `.fb-paned`
  forces the panel visible while it is out; when it docks and that class is
  removed, an inline `display:none` reasserts itself and the panel returns
  invisible.

- `isConnected` does not mean "docked". A panel in a pane window IS connected,
  just not to this document. The test you meant is
  `el.ownerDocument === document`.

- `element` is a function so it can be resolved late: return the LIVE node, and
  re-attach the chip if you rebuild your panel (Camera Director rebuilds on
  every mode change).

- rAF is throttled while the main window is backgrounded — which it is, whenever
  the user is looking at your pane. Event-driven panels don't care; continuously
  animating ones will stutter exactly when they are the only thing on screen.

- Don't synchronise anything. One realm, one panel. Writing sync code means
  you have misunderstood the model.

Also states what core guarantees back, including the one that cost the most to
learn: the element is evacuated BEFORE the pane window's document is destroyed,
so it comes home alive rather than as a photograph of a panel with every
listener in its subtree silently gone.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 22:57:05 -04:00
K. O. A.andGitHub 503716acbf Merge pull request #928 from got-feedBack/feat/panes-core
feat(panes): detachable panes — pop a plugin's real panel out into its own window
2026-07-12 20:59:24 -04:00
topkoa 41bb4482fe docs(panes): say which repo the desktop half lives in
Two comments pointed at `main.ts` and `pane-hosts.ts` as though they were in
this repo. They are not — they are in got-feedback/feedBack-desktop, and a
contributor reading only this codebase would go looking for files that do not
exist.

Named the repo and the paths, and said the part that actually matters: nothing
here depends on that code. In a plain browser a pane window is simply a pop-up;
the desktop side only upgrades it. And the frame-name prefix is a contract
across two repos with no build-time link between them, so the comment IS the
link — worth saying out loud.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 20:52:37 -04:00
topkoa 0955f0b6f2 fix(panes): keep a pane window in step with theme + interface scale
The pane window got a ONE-TIME snapshot of the app's theme classes and the
interface-scale custom property. The app changes both at runtime — Interface
size emits `scale:changed`, the theme emits `theme:changed` /
`v3:cosmetics-applied` — so an already-open pane went on rendering at the old
scale, in the old palette, the moment the user touched either.

"Looks identical" has to keep being true, not merely start out true.

A pane window now follows those three events for as long as it is open, and
stops on unplace(). The inline style is assigned wholesale rather than merged:
unlike the class lists (where pane.html's own `fb-pane-window` must survive),
there is nothing in the pane document's inline style to preserve — and
concatenating on every change would grow the attribute without bound as the
user dragged the scale slider.

Also: the dock's focus() always smooth-scrolled, ignoring
prefers-reduced-motion — which panes.css already honours for the card's flash
animation. A smooth scroll is motion too, and someone who asked for less of it
meant this as well.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 20:44:16 -04:00
topkoa 3a50e593bf fix(panes): fail fast when the pane window is unreachable; validate opts.header
Two from review.

1. _whenReady's own comment said a SecurityError means the pop-out is not
   reachable from this realm and "no amount of waiting will fix it" — and then
   it waited the full 10s deadline anyway. Ten seconds of a detached panel and a
   half-popped-out UI, for a condition we had already diagnosed as fatal.

   It now gives up after a 1s grace instead. Not instantly, deliberately: a
   throw *during* the navigation from about:blank to /pane would otherwise take
   down a pop-out that was about to work perfectly. A second is far more than
   that transition needs and far less than a user should spend staring at a
   detached panel.

2. attachChip() took opts.header on trust. It's a public plugin API, and a
   truthy non-Element header (a selector string, a wrapper object, a ref) is an
   easy mistake — one that surfaced as a confusing DOM exception from deep
   inside core instead of a TypeError naming the offending pane.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 20:36:10 -04:00
topkoa 5049be0523 fix(panes): the dock is born empty, so say so
panes.css hides an empty dock (.fb-pane-dock.is-empty { display: none }), but
the element was created without the class — so between creation and the first
card it was a visible-to-CSS, announced-to-screen-readers role="region"
landmark containing nothing.

Harmless in practice today (the dock is created lazily, on the same tick as the
card that prompted it), but the CSS contract should hold from first paint rather
than from the first _syncEmpty(), and any future caller of dock() gets the right
thing for free.

Found by CodeRabbit on #928.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 20:28:33 -04:00
topkoa de2a42bd35 fix(panes): coerce plugin-supplied pane sizes to numbers
`spec.width` / `spec.height` are plugin-controlled, and the window host builds
window.open()'s feature string by concatenation:

    'popup,width=' + spec.width + ',height=' + spec.height

`spec.width || 380` passed anything truthy straight through. So a width of
'300,menubar=1' would not merely be an invalid size — it would inject window
features. Less dramatically, any non-numeric value produced a malformed feature
string and a pane that failed to open for no visible reason.

They now go through _size(): Number, round, reject anything not finite and
positive, clamp to 120..4000. A hostile or careless value falls back to the
default instead of reaching window.open() at all.

Verified against the obvious inputs: '300,menubar=1' -> 380 (default), '300' ->
300, 0/-50/NaN/{}/'abc' -> 380, 1e9 -> 4000, 5 -> 120.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 20:20:15 -04:00
topkoa 671aba950c chore(panes): drop the leftover adoption diagnostics
A ~15-line console.info dumping computed styles, sizes, child counts and the
element's inline style on every single pop-out. It was instrumentation written
to chase the "panel comes home dead" bug, and it should have gone out with the
rest of the debugging — it survived the cleanup.

Removed rather than downgraded to console.debug: nothing here is worth keeping
even behind a flag. The failures it was built to diagnose are all handled and
commented now, and the paths that can still go wrong (window never loads, adopt
throws) already log a console.error that says what happened.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 20:08:48 -04:00
topkoa 95d6d8a46e fix(panes): keep panes.css last in the pane window's cascade
_copyStyles appended the app's stylesheets to the pane document — which
already links panes.css — so they landed AFTER it. In the app document
panes.css loads last, after tailwind/style/v3, and its rules win ties. In the
pane window that order was silently inverted, letting core styles override the
pane chrome and the .fb-paned placement rules.

Cascade order is not a detail here. "Looks identical" has to include the order
things are said in, or the same markup with the same sheets can still render
differently.

The clones now go in BEFORE pane.html's own link, preserving their relative
order among themselves and leaving panes.css last, exactly as in the app.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 20:01:17 -04:00
topkoa f43779c99e fix(panes): detach the element when the pop-out starts, not when it lands
The window host's place() is asynchronous — it opens the window, waits for
/pane to load, and only then adopts the element in. But the manager emits
`panes:opened` as soon as place() returns, and the chip reacts by putting its
"popped out" stub where the element used to be.

So for that gap the user saw BOTH: the real panel still sitting in its
original spot, and a stub next to it claiming the panel had left. On a window
that never loads, that lasts the full 10s readiness timeout.

Detach the element as soon as we commit to moving it. That is not destructive:
the node keeps its owner document, its listeners and its closures — it is
simply out of the tree, waiting for a document to be adopted into. And if the
window never loads, closePane() puts it straight back at its home, which is
exactly what the failure path already does.

The dock host has no such gap; its place() moves the element synchronously.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 19:52:32 -04:00
topkoa 82aa8a757e fix(panes): harden the persisted host map against unsafe pane ids
A pane id is plugin-controlled, and it becomes a KEY in the persisted
{ paneId: hostId } map. `__proto__` and friends are not ids, they are booby
traps:

  - `map['__proto__'] = 'window'` on a plain object corrupts the map, and
    can reach Object.prototype.
  - `map[id]` on a polluted (or hand-edited) object can return a value straight
    off the prototype chain for a pane that was never remembered at all — so a
    pane could be "restored" to a host nobody ever put it in.

Three layers, because each is a one-liner:

  - Reject `__proto__` / `constructor` / `prototype` as pane ids at
    registration, so they never reach storage.
  - Re-key whatever comes out of localStorage onto a null-prototype object, so
    a corrupt or hand-edited value cannot smuggle a prototype in.
  - Read with an own-property check.

Also from the same review:

  - Removed `window.__fbPaneWindows`. It was exposed for pane-desktop.js back
    when that file needed to reach the window handles; the rewrite dropped that
    need and nothing has referenced it since. Dead global, and its comment
    described a collaborator that no longer exists.
  - Corrected the /pane cache comment. It claimed a stale page would leave the
    window blank, which stopped being true when the readiness check gained a
    `doc.body` fallback — it would still work, just without the pane window's
    own layout. A comment that describes a failure mode the code no longer has
    is worse than no comment.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 19:39:57 -04:00
topkoa cb425ed48d fix(panes): don't hide a docked pane; don't force display; restore visibility
Six more findings from CodeRabbit on #928. Three are real bugs.

1. THE CHIP HID DOCKED PANES. `_onOpened` decided "did the pane take my
   element?" from `ownerDocument !== document`. That is true for a pane in a
   pop-out window — and false for a pane moved into the DOCK, which lives in
   this very document. So docking a pane stamped `.fb-pane-detached`
   (display:none !important) onto the panel the user was looking at, and put
   the stub next to it instead of at its home.

   The element cannot answer this question — `isConnected` is true in a pane
   window, `ownerDocument` is this one in the dock. Both were live bugs. Ask
   the manager, which knows exactly what it handed to the host:
   `panes.elementOf(id)`. That holds for every host, and for reconciling after
   the fact (detail == null), which is what a plugin rebuilding its panel
   mid-pop-out triggers.

2. `.fb-paned` FORCED `display: block !important`. A panel that is
   `display:flex` or `grid` would be silently re-laid-out while detached —
   the exact opposite of "placement only", and precisely the kind of surprise
   this feature exists to avoid. Removed.

   Making a hidden panel visible is a separate job, and it now belongs to the
   manager, which does it without touching the panel's display MODE: clear
   `hidden`, and clear an inline `display:none` if that is how the panel hides.

3. VISIBILITY IS NOW RESTORED. The hosts used to set `el.hidden = false` and
   never put it back, so the docs' "core only changes placement" was a lie and
   a panel's hidden state was quietly lost. The manager stashes both `hidden`
   and the inline `display` on open and restores them on dock: a panel that was
   closed when you opened its pane from the tray goes back to being closed; one
   that was open stays open.

Plus:

- The launcher rebuilt its whole list on every panes:opened/closed — including
  the one fired by clicking a button in that list — destroying the button under
  the user's finger and dropping focus to <body>. It now restores focus to the
  toggled pane's button.
- `_copyStyles` cloned every stylesheet link, including the panes.css that
  pane.html already loads. Skip sheets the pane document already has.
- Docs: the chip may route to the DOCK, not always a window (it goes through
  detach() → the host router). `header` precedence was documented backwards —
  an explicit `header` wins. And the visibility contract above is now written
  down rather than being a surprise.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 19:24:00 -04:00
topkoa b74a364857 docs(panes): re-attach the chip when the panel is rebuilt
A plugin that rebuilds its panel (Camera Director does, on every mode
change) takes the chip with it. attachChip() returns a detach(); call it
before re-attaching, and again in teardown, or you leave a stub pointing at
DOM that no longer exists.

Found by CodeRabbit on #928.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 19:06:04 -04:00
topkoa 859b0036e5 fix(panes): review fixes — stranded elements, duplicate listener, class clobber
Three real findings from CodeRabbit on #928, all in current code.

1. closePane() adopted the element out of the pane window ONLY when its
   original home was still connected. If the panel never had a parent (a
   plugin that builds it lazily and hands it straight over) or its container
   was torn down while the pane was out (a screen change), the whole block was
   skipped — leaving the element inside a window we then close, which strips
   every listener in its subtree. That is exactly the "comes home dead" failure
   this ordering exists to prevent; the guard just moved it from the common
   path to the rare one, where it is far harder to spot.

   Adopting and re-homing are two different jobs and only one of them is
   allowed to fail. Adopt UNCONDITIONALLY — that is what rescues the element —
   and insert only when there is somewhere to insert it. With no home the
   element ends up owned by this document but not in it: detached, intact,
   listeners alive, ready for the plugin to re-insert.

2. The pane window's `beforeunload` handler was registered TWICE, comment block
   and all — a bad scripted edit on my part. Harmless (the handler is
   idempotent via panes.isOpen) but dead duplicate code. Also fixed the stale
   comment further down that still claimed there was no beforeunload listener
   at all.

3. _copyStyles ASSIGNED className on the pane document's <html> and <body>
   instead of merging. pane.html sets `class="fb-pane-window"` on <html>, and
   panes.css hangs the pane window's own chrome off exactly that — so copying
   the app's classes over it silently took the pane window's own layout with
   them. Merge both class lists, and append the interface-scale inline style
   rather than replacing the attribute.

Also guarded the docs' integration example behind a feedBack.panes check: the
doc says the API is optional, and then showed an example that would throw on a
host without it.

Not applicable (reviewed against e5cbea2, the branch's first commit, before the
rebuild): the prototype-pollution findings in pane-bridge.js and pane-mirror.js,
and the `panes[]` manifest validation in plugins/__init__.py. All three files are
gone — 188bdaa deleted the entire cross-realm bridge, mirrorGlobal, and manifest
layer when panes switched to moving the real DOM node.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 19:04:10 -04:00
topkoa a7348052ae fix(panes): give the pop-out stub a focus ring
.fb-pane-stub is a <button>, and both of its sibling controls
(.fb-pane-chip, .fb-pane-card-btn) have an explicit :focus-visible outline.
It didn't, so keyboard focus fell back to the UA default and looked
inconsistent next to them.

Found by CodeRabbit on #928.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 19:01:08 -04:00
topkoa 1e5282e27e fix(panes): get the element out before the pane window's document dies
Docking a popped-out panel brought it home DEAD. It rendered perfectly —
right markup, right size, right place — and every control in it was inert:
the close button, the sliders, the presets, even the pop-out chip. A
photograph of a panel.

Closing a pane window tears down its document, and the panel was still
inside it. The node itself survives (the manager holds a reference), but
every event listener in its subtree goes with the document that hosted
them. Two paths did this:

  1. closePane() called the host's unplace() — which closes the window —
     BEFORE adopting the element back. Order is now reversed, and the
     comment says why so nobody helpfully "tidies" it back.

  2. The user closing the pane window themselves was only noticed by the
     `closed` poll, which by definition runs AFTER the document is gone.
     The window now gets a `beforeunload` listener that brings the element
     home while its document is still alive.

That listener has to be attached AFTER /pane loads: window.open() hands
back a throwaway about:blank document, and anything registered on it is
discarded when the real page replaces it. This is the same trap that made
the pane window blank in the first place — adopt into about:blank and the
panel is destroyed a moment later — and it is now handled in both places.

The `closed` poll stays, but only as a last-resort net for a CRASHED pane
window, where nothing can be saved.

Also fixed while chasing this:

  - The chip stamped `.fb-pane-detached` (display:none !important) onto the
    element to hide it in the main window — and that element is the one we
    move, so the class travelled with it and blanked the pane window. The
    chip now only hides an element the pane did NOT take, and marks the hole
    with its stub otherwise. "Did not take" is an ownerDocument test, not
    isConnected: a panel sitting in a pane window IS connected, just not
    here, and a plugin that rebuilds its panel (Camera Director does, on
    every mode change) re-runs attachChip while popped out.

  - The stub was inserted "before the element", which is nowhere — the
    element has left the document. The manager now hands over the element's
    recorded home, and the stub goes there.

  - GET /pane sent no cache headers. A stale copy is especially nasty here:
    the opener waits for an element inside that page before adopting, so an
    old cached version means the pane window just sits there blank.

Verified in the desktop app: pop out, use the controls in the pane window,
dock back, use them again. Panel comes home alive.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 18:47:38 -04:00
OmikronApexandGitHub d364529919 Merge pull request #930 from got-feedBack/fix/accuracy-floor-not-round
ship-ci / ci (push) Waiting to run
fix(v3): floor accuracy percentages so 100% means all notes hit
2026-07-13 00:41:03 +02:00
OmikronApexandClaude Fable 5 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>
2026-07-13 00:35:09 +02:00
topkoa 9e9f0fdac6 fix(panes): adopt into the real pane document, not about:blank
Both pop-outs opened blank.

window.open() returns immediately, and the window it hands back already has
a document — an `about:blank` one, whose readyState is 'complete'. So the
host cheerfully adopted the panel into THAT, it worked for a few
milliseconds, and then /pane finished loading, replaced the document, and
took the panel with it. Blank window, vanished element.

Waiting for 'load' is no better: it may already have fired for about:blank
before we could listen.

So don't trust readyState and don't trust 'load' — wait for the one thing
that exists only in the document we actually want: pane.html's
#fb-pane-root. Poll for it (guarding the cross-document window while it is
mid-swap), give up after 10s, and on failure bring the element home rather
than stranding it in a window that never loaded.

Also drop the popup's 'beforeunload' listener: it was registered on the
about:blank window and discarded along with it, so it never fired. The
`closed` poll is what notices a user shutting a pane window — as it must be
anyway, since a crashed renderer never says goodbye either.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 18:20:57 -04:00
topkoa 188bdaa837 feat(panes)!: move the real element, instead of rebuilding it
The first cut of this got the model wrong. A pane was a SECOND
implementation of the plugin's panel — its own sliders, its own styling,
driven over a cross-realm bridge (ctx, a state store, capability RPC,
mirrorGlobal, a stream sampler). Popping out gave you something that
resembled the panel you popped, and every feature it did not reimplement
(presets, tabs, EQ, language) was simply gone.

What a user wants from "pop this out" is the thing they popped out.

So: MOVE THE REAL ELEMENT. Same-origin windows can adopt each other's
nodes, and an adopted node keeps its event listeners and its closures.
The panel goes on running the plugin's own code, against the plugin's own
state, in the plugin's own realm — it is merely being DISPLAYED in another
window. Copy the app's stylesheets into that window and it looks identical
too, because it is identical.

The plugin's side collapses to two lines:

    feedBack.panes.register({ id, title, element: () => panelEl });
    feedBack.panes.attachChip(panelEl, id);

and everything comes along: the CSS, the listeners, the presets, the
state. Nothing to keep in step, because there is no second copy.

Deleted, all of it now pointless: pane-bridge (ctx + transports), pane-hub
(the cross-realm server), pane-runtime (the pane realm's boot), pane-streams
(the rAF sampler that existed because an AnalyserNode can't cross a window),
pane-mirror (mirrorGlobal), pane-plugins + the manifest `panes[]` key and its
server-side validation, panes.state(), and both built-in demo panes. ~1200
lines. None of it was wrong — it was all correct machinery for the wrong
problem.

Consequences worth knowing:

- The window MUST be opened by the renderer with window.open(), not by the
  desktop's main process: a window we did not open gives this realm no handle
  to its document, and without the handle there is nothing to adopt into.
  Electron turns the same-origin window.open() into a real BrowserWindow
  anyway (setWindowOpenHandler → 'allow'), so we get the OS window AND the
  live DOM link. The desktop side finds it by frame name.
- `.fb-paned` neutralises PLACEMENT only (position/inset/width/z-index/shadow).
  A plugin panel is nearly always a fixed overlay pinned to a corner of the
  app; alone in a 380px window that positioning is nonsense. Colours, borders,
  padding, fonts and the panel's own internal layout are untouched — the whole
  promise is that what you popped out is what you get.
- The element is returned to its EXACT home on dock: same parent, same position
  among its siblings.
- The plugin's code still runs in the main window. So a document.body
  .appendChild() inside a panel (a tooltip, a popover) lands in the main
  window, not the pane — anchor to the panel instead. And a continuously
  animating panel may run slowly while the main window is backgrounded, since
  its rAF lives there. Both documented.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 18:12:46 -04:00
topkoa 330995588c feat(panes): panes.state(id) — let a plugin apply its own pane's values
mirrorGlobal covers the case where a pane drives a plain global that some
renderer reads each frame. It does not cover the far more common one: a
plugin whose MAIN-realm code is the authority — it clamps, it persists, it
emits events, it owns the audio graph or the camera rig — and which must
therefore APPLY the pane's values itself rather than have core splat them
somewhere.

Camera Director is the case that forced this. Its brain is the sole writer
of the camera store, the sole broadcaster on splitscreen's channel, and the
only thing that clamps an axis to its legal range. A pane cannot write
window.__h3dCamCtl behind its back without desynchronising its presets, its
persistence, and the panel's own sliders — and running the brain inside the
pane realm would make it a SECOND store writer and a second broadcaster,
racing the real one.

So: `panes.state(id)` hands the main realm the open pane's store
(get/set/all/subscribe). A plugin seeds it on `panes:opened`, subscribes,
and applies what comes back through its own API. The pane stays
realm-agnostic — it only ever touches ctx.state — and the plugin stays the
single source of truth.

For that to work, the hub now broadcasts EVERY change to the store, not just
the ones a pane asked for: it subscribes to the store on connect rather than
echoing pane-originated writes by hand. A value the plugin clamps or corrects
therefore reaches the pane window immediately, and there is exactly one path
by which state arrives in a pane — so it cannot drift.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 17:58:51 -04:00
topkoa 254e26bb3a feat(panes): mirrorGlobal, manifest-declared panes, and the plugin docs
Three things a plugin needs before it can actually use panes.

## mirrorGlobal — the camera-director problem

The 3D highways read their free camera from a plain global,
`window.__h3dCamCtl` (highway_3d/FREECAM_BRIDGE.md), once per frame in
_resolveFreeCam(). A camera panel in the main window just writes that
object and the camera moves. A panel in a POP-OUT window cannot:
window.__h3dCamCtl there is a different object in a different realm, and
writing it moves nothing.

So a pane declares one field — `mirrorGlobal: '__h3dCamCtl'` — and
pane-mirror.js (main realm, where the renderers live) copies that pane's
state onto the global whenever it changes. highway_3d, keys_highway_3d
and drum_highway_3d are NOT modified and do not know panes exist.

The rule that makes it work: MUTATE THE OBJECT, NEVER REPLACE IT. A
renderer may be holding the reference, and swapping in a new object would
leave it reading an orphan. Keys the pane doesn't set are left alone
rather than deleted — the global may carry a renderer's own bookkeeping.
Closing the pane deliberately leaves the global as-is: closing the camera
panel should not snap the camera back to a default, which is exactly what
happens today (nobody clears __h3dCamCtl).

## Manifest-declared panes

    "panes": [{ "id": "camera_director", "title": "Camera Director",
                "script": "panes/camera.js", "mirrorGlobal": "__h3dCamCtl" }]

Declaring a pane beats calling panes.register() from screen.js because it
becomes openable FROM THE RAIL OR THE TRAY WITHOUT THE PLUGIN'S SCREEN
EVER HAVING BEEN VISITED — core registers a stub from the manifest and
fetches the script only when the user opens it. A pane you can only reach
by first navigating to the screen it was meant to replace is not much of a
pane.

The script sets `window.feedBackPane_<id> = { mount, unmount }`, mirroring
the existing window.feedBackViz_<id> convention, and the SAME file is what
a pop-out window loads in its own realm.

`script` is validated as a relpath under the plugin's src/ and served
through the sandboxed /api/plugins/<id>/src/ route — the containment rule
`styles` already has for assets/. Traversal, absolute paths, drive letters,
backslashes and non-.js are rejected; a bad entry is dropped with a warning
rather than failing the whole plugin, because one malformed pane should not
cost the user a working plugin.

Note the projection is written TWICE — _nav_entry() and the /api/plugins
route re-project independently — so panes had to be added to both, plus the
pending branch (a pane can be opened while its plugin is still installing
deps; the script is fetched on open, not at discovery).

## docs/plugin-panes.md

The contract, and the one rule it all hangs on: mount(root, ctx) runs in a
realm that may not have the app in it. Everything comes through ctx, or the
pane works docked and silently dies popped out.

Verified: manifest validation rejects ../.., C:\, non-.js, dupes and
missing fields while passing a good entry; /api/plugins projects panes[] for
all 20 plugins. mirrorGlobal mutates the global IN PLACE — a reference held
the way _resolveFreeCam holds it sees the change, and a renderer's own field
on that object survives — both for a local write and for a write arriving
over the channel from a pop-out realm.

pytest: 2401 passed, 8 failed — all 8 reproduce on a clean main (including
the one in tests/test_plugins.py) and are unrelated.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 17:38:05 -04:00
topkoa d508380532 feat(panes): desktop host — real windows and the system tray
Registers a `desktop` pane host at priority 20, above the browser pop-up
host (10) and the dock (0), whenever the Electron bridge exposes
feedBackDesktop.panes. A popped-out pane then gets a real BrowserWindow:
it remembers where you put it, can float above everything, minimizes to
the system tray, and appears in the tray's menu.

In a plain browser — or on an older desktop build that predates the
bridge — this file registers nothing and the browser pop-up host handles
detach exactly as before. Nothing else in the pane system changes. That
is what the host registry is for.

Two things only this realm can decide, so it owns them:

- The user closed a pane window (or it crashed). Close the pane, or the
  dialog its pop-out chip hid never comes back and the user is left with
  no way to reach their own UI.
- The tray asked to toggle a pane it has no window for. Main cannot know
  what opening one means — the pane might belong in the dock — so it asks.

Unlike a browser pop-up, this host needs no user gesture, so it sets
autoRestore: true — a pane you left popped out comes back popped out,
where you left it, on the next launch.

Pairs with got-feedback/feedBack-desktop#103.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 17:23:36 -04:00
topkoa fefb9051a4 feat(panes): pop-out windows — the pane realm, hub, and remote transport
A pane can now leave the main window entirely. Same `mount(root, ctx)`,
same file, different JS realm — which is what the ctx-only contract in the
previous commit was for.

## A purpose-built document, not the app shell with a flag on it

`GET /pane` serves static/panes/pane.html: the bridge, the runtime, and
the pane's own script. No highway, no library, no v3 shell, no <audio>,
no Tailwind.

The splitscreen follower takes the other road — it reloads the whole app
at `/?ssFollower=1` and hides what it doesn't want — and pays for it with
an anti-flash block that must run before any script parses (index.html),
bail-outs in app.js and shell.js, and ~40 lines of CSS hiding core
elements by id. It loads the entire app to throw it away. A pane window
has nothing to throw away, so it boots in milliseconds and there is
nothing to flash.

The cost is that `window.feedBack` in a pane realm is a deliberate,
documented SUBSET. The runtime installs exactly what a pane is promised —
`panes.register`, and the no-op chip/dock calls a shared script may make
at load — so a pane reaching for something it was never given fails
loudly at authoring time instead of subtly at runtime.

## The channel

BroadcastChannel('feedback-panes'), same origin. This works only because
Electron's setWindowOpenHandler returns `action: 'allow'` for same-origin
URLs: `deny` would push the window to the system browser, a different
Chromium instance, where BroadcastChannel cannot reach it and the pane
would silently never sync. That flag is load-bearing.

  hello -> snapshot   resync-on-open, always. The snapshot is the only way
                      the pane realm learns anything.
  state               main is authoritative. A pane's write is a REQUEST;
                      main applies it and echoes to every realm, so a
                      losing write self-corrects instead of splitting brain.
  rpc / rpc:reply     ctx.call() -> the capability bus, with a 10s deadline.
                      Without one, a main window that died mid-call leaves
                      the pane's promise pending forever.
  event               allowlisted bus events, JSON-safe. A CustomEvent
                      carrying a DOM node (highway:canvas-replaced does)
                      would throw on postMessage and take the channel down
                      for everyone, so detail is round-tripped through JSON.
  stream              one coalesced message per pane per frame, OVERWRITING
                      anything not yet flushed. Queueing would build a
                      backlog: Chromium throttles a backgrounded window, and
                      the main window is exactly what's backgrounded while
                      the user looks at the pane.
  sub / unsub         refcounts the main-realm sampler.
  bye                 both directions.

## The follower clock

The pane extrapolates between broadcasts: anchor + observedRate * elapsed,
capped at 2s. observedRate is learned from the broadcasts themselves
(dt/dwall) so it tracks the speed slider without being told about it, and
seeks/pauses are excluded from the fit — a jump is not a tempo. Capping it
means a dead main window decays into a frozen clock rather than one that
confidently runs away. This is splitscreen's hard-won trick, generalized:
panes just call ctx.playhead().

## Failure modes, all of them

- Main window closes -> `bye {main-closed}` and the pane says so plainly,
  rather than showing a frozen playhead that looks live. The host also
  closes its windows outright; a pane that cannot be fed should not be on
  screen.
- Pane window X'd or crashed -> a `closed` poll reaps it (a crashed
  renderer never sends `bye`), the pane closes, and the chip's dialog comes
  back. Without this the user's dialog stays hidden with no way back.
- Popup blocked -> a toast, and we bail BEFORE the manager records
  anything, so the caller's dialog stays exactly where it was.
- Nobody answers `hello` in 5s -> the window says so instead of spinning.
- A pane with no `script` is a closure in this realm and cannot honestly
  cross a window boundary. The window host declines it (canHost) and the
  router falls back to the dock.
- A browser blocks window.open() outside a user gesture, so a popped-out
  pane cannot be auto-restored on page load — it would only ever produce a
  "blocked" toast. Such a pane comes back in the DOCK, and the chip pops it
  out again on the next click. (autoRestore: false. The desktop host will
  set it true.)

Hosts may now declare `remote: true`, meaning the pane's mount() runs in
another realm: the manager then owns only the authoritative state store and
never calls mount() itself. That is the seam the Electron BrowserWindow +
tray host drops into next, with no change here.

Verified: popped Now Playing and Mixer into real windows. The pane realm has
no window.highway, no capability bus and no <audio>, yet the Mixer renders
its faders via ctx.call('audio-mix','list-faders') across the channel — and
dragging that fader IN THE PANE WINDOW moved the main window's song volume
to 55 and persisted it. Closing the pane window un-hid the mixer dialog,
removed the stub and restored the chip, while the other pane window stayed
open.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 17:03:21 -04:00
topkoa e5cbea2e9f feat(panes): core detachable pane system + pop-out chip
The option-heavy player UIs (mixer, camera director, viz, audio routing)
all live in the rail popovers, which are exclusive: openPopFor() closes
the last one before opening the next. You cannot watch the mixer while
riding the camera, and both vanish the moment you look at the highway.

Add `window.feedBack.panes` — a core registry for live UI that is
authored once as `mount(root, ctx)` and hosted anywhere. Panes are
non-exclusive, and they survive song switches structurally: the dock is a
body child outside every .screen, so the per-song teardown never sees it.

The adoption cost for a plugin is two calls:

    feedBack.panes.register({ id, title, icon, mount, unmount });
    feedBack.panes.attachChip(myExistingDialogEl, id);

attachChip injects THE standard pop-out chip. Clicking it opens the pane
and hides the plugin's dialog, leaving a stub to bring it back. Core owns
the hide/restore, so every plugin's pop-out looks and behaves the same —
which is the point. It hides via a dedicated .fb-pane-detached class, not
.hidden/[hidden], because the dialogs we attach to already toggle those.

Everything a pane may touch arrives through `ctx` — never a global. That
is what will let the same mount() run inside a pop-out window, a separate
JS realm with no window.feedBack, no window.highway and no audio graph:

  ctx.call(domain, cmd, payload)  -> the capability bus
  ctx.on(event, fn)              -> the feedBack bus (allowlisted)
  ctx.subscribe(stream, fn)      -> playhead / meters
  ctx.state.get/set              -> persisted, main realm is the only writer
  ctx.playhead(), ctx.song(), ctx.toast(), ctx.close()

ctx tracks every subscription it hands out and drops them on unmount, so
a pane cannot leak listeners across a dock/undock cycle.

Streams exist because an AnalyserNode cannot cross a window boundary:
levels are reduced to numbers in the realm that owns the audio graph.
One shared rAF loop, refcounted against live subscriptions, dirty-checked
before fan-out, and stopped dead when the last pane closes.

Hosts register themselves with the manager rather than being imported by
it — the dock lands at priority 0 (the floor, always available), so the
OS pane window can drop in later without this code changing.

Ships two built-in panes: Now Playing (the reference pane — reads the bus,
a stream, and levels, and touches no globals) and Mixer (the same faders
as the rail, via ctx.call('audio-mix', ...), with the chip attached to the
real #mixer-control). Plus a "Panes" rail popover to open panes that have
no dialog of their own; the system tray will mirror that list.

Note the dock sits at z-index 110, not on the docs/plugin-v3-ui.md ladder
(transport 20, rail 30, popovers 40) — those live INSIDE #player's
stacking context, and #player is itself fixed at z-index 100. A dock below
100 is invisible on the one screen panes exist for. Body-level ladder:
#player 100 < dock 110 < toasts 120 < modals 200.

Pop-out windows, the system tray, manifest-declared panes and mirrorGlobal
(the window.__h3dCamCtl proxy the camera director needs) follow.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 16:54:15 -04:00
ChrisBeWithYouandClaude Fable 5 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>
2026-07-12 15:24:39 -05:00
108 changed files with 11550 additions and 2041 deletions
+17
View File
@@ -0,0 +1,17 @@
## What
<!-- What does this PR do, and why? Link the issue it addresses. -->
## feedpak surface
<!-- The feedpak spec is sacrosanct: the spec defines the format, this app implements it.
Delete this section ONLY if your change doesn't touch how the app reads or writes packs. -->
- [ ] This PR does **not** change how the app reads/writes feedpaks (manifest keys, pack files, folder layout)
- [ ] …or it does, and the spec change landed first via the [FEP process](https://github.com/got-feedback/feedpak-spec/blob/main/CONTRIBUTING.md) — FEP / spec PR: `got-feedback/feedpak-spec#___` (once it merges, re-run this PR's checks and the gate goes green)
## Checklist
- [ ] `CHANGELOG.md` `[Unreleased]` updated (user-visible changes)
- [ ] Tests added/updated for new behaviour
- [ ] Commits are DCO signed off (`git commit -s`)
+88
View File
@@ -124,6 +124,94 @@ jobs:
print(f"Validated {len(manifests)} manifest(s) — OK")
EOF
feedpak-spec:
# Guard that core stays faithful to the feedpak format spec, which lives in
# its own repo (got-feedback/feedpak-spec) and is the contract third-party
# packers and players build against. Four surface checks: core reads/writes
# only manifest keys the spec declares (and the scanned-module list can't
# fall behind); the exception allowlist never grows, so the FEP process is
# the only way a new key lands; core ingests the spec's example packs; packs
# committed here pass the spec's reference validator. Motivated by
# #933, where a manifest key (`original_audio`) shipped in core without ever
# reaching the spec.
#
# The gate checks against the spec repo's HEAD, deliberately: the app must
# conform to the LIVING spec, always. The dev flow is self-serve — a gated
# PR opens a FEP, the spec PR merges, re-running this job goes green; no
# pin file to bump, nothing to maintain. Accepted trade-off: a BREAKING
# spec change (rare, deliberate, MAJOR per the spec's compatibility policy)
# reddens every PR here until core conforms — which is the correct
# org-wide signal that the app is out of conformance. The normal FEP is
# additive and can never redden this job.
name: feedpak-spec
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# This job runs repository code (tools/check_spec_conformance.py) and
# never pushes; don't leave the token in git config for it.
# fetch-depth: 0 so the base branch is available — the gate must prove
# the exception allowlist didn't grow in this PR.
with:
persist-credentials: false
fetch-depth: 0
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
- name: Check out feedpak-spec at HEAD
uses: actions/checkout@v4
with:
repository: got-feedback/feedpak-spec
ref: main
path: .feedpak-spec
persist-credentials: false
- name: Record the spec commit this run verified against
# HEAD-tracking means CI results can differ across time on the same
# commit. Log the exact spec SHA so a red run is reproducible.
run: git -C .feedpak-spec rev-parse HEAD
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
# CI-only: the spec's reference validator needs jsonschema. Not a
# runtime dependency — this gate never runs on the serve/Docker path
# (constitution Principle I). Pinned for the same reason the spec SHA
# is: an upstream release must not turn this job red on a PR that
# changed neither this repo nor the spec.
pip install 'jsonschema==4.26.0'
- name: Fetch the base branch's exception allowlist
id: baseline
run: |
# The allowlist is closed: it grandfathers keys that predate this gate
# and may only shrink. Prove that by diffing against the base branch —
# without this, anyone could append an entry and route around the FEP
# process from inside this repo.
#
# Resolve the base rather than hardcoding `main`: ship-ci.yml also runs
# this workflow for PRs into release/** and for pushes to release/**,
# where a main baseline would diff against the wrong branch.
# PR -> the branch it merges into
# push -> the branch itself (its tip already contains the change, so
# this is a no-op; enforcement happens at PR time)
BASE="${{ github.event.pull_request.base.ref || github.ref_name }}"
echo "diffing the allowlist against origin/$BASE"
git fetch --no-tags --depth=1 origin "$BASE"
if git cat-file -e FETCH_HEAD:feedpak-spec-exceptions.yml 2>/dev/null; then
git show FETCH_HEAD:feedpak-spec-exceptions.yml > "$RUNNER_TEMP/baseline-exceptions.yml"
echo "args=--baseline-exceptions $RUNNER_TEMP/baseline-exceptions.yml" >> "$GITHUB_OUTPUT"
else
# Only true until the PR that introduces this gate lands.
echo "args=--bootstrap-allowlist" >> "$GITHUB_OUTPUT"
fi
- name: Check feedpak spec conformance
run: python tools/check_spec_conformance.py --spec .feedpak-spec ${{ steps.baseline.outputs.args }}
lint:
# Maintainer/CI-only size + module-hygiene gate (constitution Principle I:
# dev tooling, never on the serve/Docker path — same category as
+180
View File
@@ -7,6 +7,181 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **Gold tier (career passports)** — an earned badge turns **gold** when
Virtuoso verifies an improvised jam in the passport's style (the
`gold_improv` artifact relays with the drill snapshot; a genre inherits its
family's style, gained-only, and gold never substitutes for the badge bar
itself). Gold gets its own ceremony, stamp slam, foil chip, and gold ink on
the shelf cover, profile wall, and passport card; the bronze page's "Gold
rung coming" preview becomes a live invitation to jam it.
- **Gigs (the career verb, frontend)** — book a gig from any opened passport:
a gig poster proposes the setlist (re-roll for a different bill; save or
copy the poster as a PNG), "Play the gig" hands the set to the play queue
with the venue on stage, a floating strip tracks the set, and finishing it
logs dated entries with per-song accuracies in the passport book — with an
encore celebration (crowd eruption + confetti) when the whole set clears
the bar, and a summary poster to share. Quitting mid-set simply abandons
it: no log, no fail state.
- **Career on the Profile and Home pages** — the Profile gains a passport
wall (earned-badge covers per instrument, hours, gig count; absent until a
passport exists), injected through the same mount-point + rendered-event
seam the achievements plugin uses (now documented in docs/plugin-v3-ui.md).
The home page's plugin-count stat tile becomes a career trading card
(badges, hours, the closest stamp ask, foil shine) with the old stat as the
built-in fallback when career has no state. Earned passports gain **Save
card / Copy card** — a natively-drawn PNG passport card, downloadable or
copied straight to the clipboard for pasting outside the app (shared
`blob-io` helpers replace the download idiom previously duplicated in
settings-io and diagnostics-export).
- **Gigs (backend)** — career mode gains its verb: `POST
/api/plugins/career/gigs/propose` builds a playable setlist for an
instrument+genre (your qualifying songs plus a couple of stakes songs near
the bar; a young passport fills from unplayed genre songs — the first gig
is how stubs start; re-roll by calling again), naming the room your stars
can book. `POST /gigs` logs a **completed** set — per-song accuracies read
from the set's own freshly-recorded stats, an encore flag at the
data-driven bar (avg ≥ 75%) — into the career state; abandoned sets never
log (no fail state: the gig you finished is the gig you played). Passports
carry their gig log; instruments their gig count.
### Changed
- **Folder library renders only the songs on screen** (#965) — a song list used to
render *every* song it held. On a flat 50,944-song library that was one `<div>`
with 50,938 children and ~1.3 **million** DOM nodes (~4.2 GB of renderer memory),
built even while another screen was showing. A document that size also punishes
unrelated code: any `document.querySelector` that misses has to walk the whole
tree — which is how the song-preview menu check ended up eating ~50% of the
renderer and dropping the app to 2.7 fps. Lists longer than 200 songs are now
windowed (2531 rows in the DOM instead of 50,000); shorter lists are unchanged.
- **The full mix is a stem** (#933) — core no longer depends on `original_audio:`, a
top-level manifest key this repo invented (#583) that the feedpak spec never had.
The format already carried the pre-separation mixdown as a stem; feedpak 1.15.0
(feedpak-spec#53) reserves the id **`full`** for it, so that is where core reads it
from now.
`full` is a mixdown, not a layer — it already contains every instrument — so
`load_song()` lifts it OUT of `LoadedSloppak.stems` onto `LoadedSloppak.full_mix`.
Nothing that sums stems or renders one fader per stem can see it, which is what
makes retaining it safe; leaving it in the list would double the whole song and
leave "guitar" audible with the guitar fader muted. That trap is exactly why the
packer invented the key instead of putting the mixdown where the format says it
goes — the bug was in the reader, and this fixes the reader.
Consequences worth knowing:
- The highway WS `song_info` frame gains `full_mix_url` / `has_full_mix`.
`original_audio_url` / `has_original_audio` remain as **deprecated aliases**
(same values) for one release so a client built against the old frame keeps
working; they go with the fallback below (#945).
- `stems` on `song_info`, and `stem_ids` / `stem_count` in the library index, now
describe *instrument* stems only — a separated pack that retains its mixdown no
longer advertises a bogus "full" stem chip or an inflated stem count.
- Audio fingerprinting (`lib/enrichment.py`) now resolves the mixdown the same
way, which **widens** its coverage: it previously returned `None` for any pack
without the invented key, so fingerprinting silently did nothing for the
overwhelming majority of packs.
- Core still **reads** `original_audio:` as a deprecated fallback, because every
pack written before the spec caught up carries it and would otherwise lose its
pristine mix. `tools/migrate_full_mix_stem.py` rewrites those packs into the
spec shape (moves `original/full.ogg` → `stems/full.ogg`, adds the `full` stem
at `default: off`, drops the key); the fallback and the aliases are removed once
they are migrated (#945).
### Added
- **Genres fall back to MusicBrainz enrichment** — the effective genre now
resolves override → pack genre → the enrichment match's primary genre
(matched or user-pinned manual rows only). Converted packs rarely carry a `genres` manifest key,
which starved the library genre facet and career passports on real
libraries; with the fallback, every enriched song's genre is browsable and
passport-able immediately, and coverage grows as enrichment runs.
- **Badge ceremony in the venue** — earning a genre badge now stages a moment:
the crowd layer erupts (new public `v3VenueCrowd.celebrate()` — instant
ecstatic loop bypassing the stability/dwell hysteresis, plus a cheer stinger;
a no-op without a venue pack) and a full-screen overlay drops the bronze
stamp with a shine sweep and a confetti burst over whatever screen is active
(badges land right after `stats:recorded`, while the player is still up).
Click or wait ~4s to dismiss; `prefers-reduced-motion` gets the existing
chime + notification only. The stamp still slams into the passport book on
next open, unchanged.
- **Hours-per-genre odometer (career passports)** — the app now measures real
play time: the stats recorder accrues **wall-clock** seconds across
play/resume ↔ pause/stop/end spans (wall time, not song position — position
deltas double-count A-B loops and mis-read seeks; single spans clamp at 2h
against suspend/sleep inflation) and piggybacks them as `seconds` on the
`POST /api/stats` calls it already makes. New additive
`song_stats.seconds_total` column; a seconds-only POST banks time for
unscored plays that run to the song's natural end without touching the
resume position (and still counts as playing today for the streak).
Passports surface it honestly: "14.2 h in Blues" under the badge and on the
shelf cover — a true fact that only grows, never a target or a meter.
- **Career passport drills, curated** — Bronze in blues/rock/metal/funk/jazz
now also asks for that genre's signature Virtuoso drill (Blues Shuffle,
Power Chords & Backbeat, Gallop Picking, 16th Pocket, Shell Voicings — one
per genre, data-driven in `passports.json` with display labels). Drill
lists are per-instrument (`virtuoso_nodes: {instrument: [nodes]}`; a flat
list still means guitar), so a keys passport never demands a guitar drill.
A drill counts as cleared on the first real completion artifact — a
top-tier clean pass in one key (`keysCleared`), any depth rung, or
mastery — rather than only the maxed-speed depth flips. Genres without a
curated drill stay songs-only.
- **Career passport visuals pack** — earned covers and badge stamps become
trading cards (pointer-tracked tilt + light glint, hover-capable devices
only); the ghost stamp visibly "carves in" as qualifying songs land (a
conic ink fill, no numbers added); the Gold rung preview is a small foil
chip with a shimmer sweep, still honestly labeled coming. All theatrics
disabled under `prefers-reduced-motion`.
- **Career passports (backend)** — the badge-journey layer on top of career stars.
New career-plugin endpoints: `GET /api/plugins/career/passports` (per-instrument
passport walls: genre badges computed on read from `song_stats` × the library's
effective genre — Bronze = N genre songs at K★, data-driven in
`plugins/career/passports.json`, default 5 songs at 2★ — plus qualifying-song
"ticket stubs", the library genre list, and drill status), `POST /passports/commit`
(instrument commitment), `POST /passports/open` (open a genre
passport), and `POST /drill-state` (intake for the relayed Virtuoso
`virtuoso.progress` snapshot, so drill requirements can gate badges
server-side). Badges are never stored; the only persisted state (commitments,
opened passports, drill snapshot) lives under `CONFIG_DIR/career/` and rides the
settings export/import bundle via `settings.server_files`. Instruments are
attributed via the existing progression arrangement→instrument mapping;
non-graded instruments (bass, drums) render shown-not-judged — repertoire
without a pass bar, never a false badge denial.
- **Career passports (UI)** — the Career screen gains a Passports tab beside
Venues: a physical per-instrument passport book (embossed leather cover, 3D
page-turn) with a wax-seal commitment ceremony (Stage 0), rubber-stamp badge
slam with ink bleed and deterministic per-genre jitter, qualifying songs as
collected ticket stubs, and unopened genres as an "Explore next"
travel-brochure rack (invitations, never greyed-out slots or completion
meters). Badge earns chime + notify immediately; the stamp slam plays when
the passport is next opened. Four small synthesized sound effects ship as
plugin assets. The career screen also relays the Virtuoso `virtuoso.progress`
localStorage snapshot to the drill-state intake on `virtuoso:progress` bus
events (debounced, plus a one-time bootstrap), closing the
fires-into-a-void seam without touching the virtuoso plugin.
- **CI gate: core must stay faithful to the feedpak spec (`feedpak-spec` job).** feedpak is published as
an open format with its own repo, normative spec, JSON Schemas, and reference validator — but nothing
stopped core from reading a manifest key the spec never defined, which is exactly what happened with
`original_audio` (#583 → #933). `tools/check_spec_conformance.py` now enforces four surface properties
in CI: (1) **key-coverage** — every manifest key core reads *or writes* is declared in the spec's
`manifest.schema.json`, found by walking the AST of `lib/sloppak.py`, `lib/enrichment.py`, and
`lib/songmeta.py`, `lib/gp2notation.py`, and `lib/routers/ws_highway.py` (writes are gated too — including
`setdefault()` — and reported separately: a key core writes lands in every pack we emit, so an undeclared
one seeds the ecosystem with non-spec data; a **readers-complete** guard fails the build if that module
list falls behind the codebase); (2) **allowlist-closed** — `feedpak-spec-exceptions.yml` never grows;
(3) **forward** — core's `load_song()` ingests every example pack the spec ships;
(4) **reverse** — every pack committed here passes the spec's own `tools/validate.py` (7/7 pass today).
The gate verifies against the spec repo's **HEAD** — the app must conform to the living spec, and the
flow is self-serve: a gated PR opens a FEP, the spec PR merges, re-running checks goes green. Nothing to
pin, nothing to bump. Each run logs the spec SHA it verified against so results are reproducible.
**There is no in-repo escape hatch, by design.** A blocked PR has exactly one route: land the key in the
spec via the [FEP process](https://github.com/got-feedback/feedpak-spec/blob/main/CONTRIBUTING.md), then
re-run the PR's checks — the gate verifies against the spec's HEAD, so it goes green once the key is real. `feedpak-spec-exceptions.yml` is a **closed
grandfather list** for keys that predate the gate, not a bypass: a fourth check (**allowlist-closed**)
diffs it against the base branch and fails any PR that *adds* an entry, so it may only shrink.
`original_audio` is grandfathered there against #933 so the gate lands green and starts blocking the
*next* instance immediately; the gate takes no position on how #933 resolves (the expected outcome is
removing the key, since the spec already carries the mixdown as a stem — not adopting it). Docs:
[docs/feedpak-spec-gate.md](docs/feedpak-spec-gate.md).
### Removed
- **The classic v2 UI shell is gone — v3 is the only UI (R3a).** `static/index.html`, the
`/v2` route, and the `FEEDBACK_UI` v2/legacy opt-out are deleted; `/` and `/v3` both serve
@@ -27,6 +202,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
engine (`app.js`, `highway.js`, `playSong`, `showScreen`, the capability registry).
### Fixed
- **Career passports review polish** — the passport tabs and book overlay carry
proper ARIA semantics (`aria-selected`/`aria-controls`/`tabpanel`;
`role="dialog"` + `aria-modal` with focus moved to the close button on open
and restored on close), and a corrupt stored seen-badges value (e.g. a stray
`"null"`) can no longer throw on every passport refresh.
- **The packaged desktop app could not start (`ModuleNotFoundError: No module named
'appstate'`).** feedback-desktop's `scripts/bundle-slopsmith.sh` copies a *hardcoded
list* of core files into the app bundle — `server.py`, `VERSION`, `lib/`, `data/`,
+49
View File
@@ -465,6 +465,40 @@ window.feedBack.diagnostics.contribute('my_plugin', {
Loaded from `static/diagnostics.js` ASAP in `<head>` so the console-wrap is in place before any other script runs. Available on the `window.feedBack.diagnostics` namespace alongside `snapshotConsole()`, `snapshotHardware()`, `snapshotUa()`, `snapshotLocalStorage()`, `snapshotContributions()`. Keep your payload small (< 100 KB) and don't include secrets — bundles are shared with maintainers.
### Detachable panes — pop your panel out into its own window
If your plugin has a floating panel that sits over the player — a mixer, a camera rig, a settings board — you can let the user pop it out into its own OS window and leave it there: while they play, across song switches, on a second monitor, minimized to the tray. Two calls:
```js
feedBack.panes.register({
id: 'camera_director',
title: 'Camera Director',
icon: '🎥',
element: () => panelEl, // your existing panel, exactly as it is
});
feedBack.panes.attachChip(panelEl, 'camera_director');
```
**The host moves your real element.** Not a copy, not a re-render — the actual DOM node, adopted into the pop-out window, keeping its listeners and its closures. Your panel goes on running *your* code against *your* state. It looks and behaves like what was popped out because it **is** what was popped out. Nothing to mirror, nothing to keep in sync.
The rules below are all things that have already gone wrong. Full contract: **[docs/plugin-panes.md](docs/plugin-panes.md)**.
- **Your code still runs in the main window.** The element is *displayed* elsewhere; its closures, timers and `document` references still belong to the main realm. That is exactly why everything keeps working — and exactly why `document.body.appendChild(myPopover)` lands in the **main window, not the pane**. Anchor tooltips, popovers and menus to your panel, not to `document.body`. Measure with `el.ownerDocument.defaultView`, never a cached `window`.
- **Don't hide your panel yourself when it pops out.** Core hides it and leaves a "bring it back" stub. If you also hide it, you will hide the node that just moved — and blank the pane window.
- **Prefer `hidden` or a class over inline `display` for show/hide.** While popped out, core neutralises *placement* with `.fb-paned` (`position`, `inset`, `width`, `z-index`, `box-shadow`). An inline `display:none` on your panel reasserts itself the moment the pane docks back and the class is removed, so your panel returns invisible.
- **`element` is a function so it can be resolved late.** Return the *live* node. If you rebuild your panel (Camera Director rebuilds on every mode change), re-run `attachChip` — it returns a `detach()`; call it before re-attaching, and again in your teardown.
- **`isConnected` does not mean "docked".** A panel sitting in a pane window is very much connected — just not to *this* document. Test `el.ownerDocument === document`, or take the `onHost(hostId, el)` callback.
- **rAF is throttled while the main window is backgrounded** — and it will be, whenever the user is looking at your pane. Event-driven panels (sliders, buttons) are unaffected. A panel that *animates continuously* may run slowly while it is the only thing on screen.
- **Don't reach for BroadcastChannel, `postMessage`, or a second copy of your state.** There is one realm and one panel. If you find yourself synchronising, you have misunderstood the model.
- **Nothing is required.** No panes API on the host → skip both calls, and your panel behaves exactly as it does today.
### Keyboard Shortcuts
Plugins can register keyboard shortcuts via the global `window.registerShortcut()` function. Shortcuts appear in the `?` help panel.
@@ -554,6 +588,21 @@ tab, key/scale annotations, etc.). Published as **feedpak**; this codebase still
**sloppak** name internally — same on-disk format. [docs/sloppak-spec.md](docs/sloppak-spec.md) is
a local pointer + code map.
**The spec is sacrosanct — read it BEFORE changing how this app reads or writes packs.** The
spec repo defines the format; this app merely implements it ("a change is not part of the format
until it lands here" — feedpak-spec/GOVERNANCE.md). Any new manifest key, file, or directory the
app touches must land in the spec **first**, via the
[FEP process](https://github.com/got-feedback/feedpak-spec/blob/main/CONTRIBUTING.md) (proposal
issue → one spec PR updating spec + schemas + example + changelog → then re-run your PR's checks
here; the gate verifies against the spec's HEAD, so it goes green the moment your key is real).
CI enforces this: the `feedpak-spec` job
([docs/feedpak-spec-gate.md](docs/feedpak-spec-gate.md)) fails any PR whose code touches a
manifest key the spec doesn't declare, and there is **no in-repo bypass** — the exceptions
file is a closed grandfather list that only shrinks. If the format seems to be missing something
you need, that's a FEP conversation, not a workaround. (Cautionary tale: `original_audio`, #933 —
shipped without a spec entry, and third-party packers reverse-engineered a folder convention out
of a code comment.)
**Key code:**
- `lib/sloppak.py` — format detection, zip/directory resolution, metadata extraction, song loading
- `lib/sloppak_convert.py` — sloppak assembly pipeline, Demucs stem splitting
+132
View File
@@ -0,0 +1,132 @@
# The feedpak spec-conformance gate
`tools/check_spec_conformance.py`, run in CI as the `feedpak-spec` job.
## Why
feedpak is published as an **open format**: its own repo
([got-feedback/feedpak-spec](https://github.com/got-feedback/feedpak-spec)), a normative spec, JSON
Schemas, and a reference validator. That is a promise to everyone outside this codebase — third-party
packers, converters, and players build against the spec, and the spec is meant to be the complete and
authoritative description of a pack.
The moment core reads a manifest key the spec doesn't define, that promise breaks silently:
- A spec-compliant pack is no longer guaranteed to be a fully-working pack.
- The reference validator can't warn authors about a key it has never heard of — it will happily green-light
the key, and every misspelling of it.
- The format's real definition drifts into our source tree. In the case that motivated this gate
([#933](https://github.com/got-feedback/feedback/issues/933)), third-party tooling started emitting an
`original/` directory that no code anywhere requires — the convention was reverse-engineered from an
example in a *code comment*.
The rule this gate enforces: **any manifest key core reads _or writes_ must be in the spec before core
ships code that depends on it.** Spec first, implementation second. Writes are not exempt — a key core
writes lands in every pack we emit, so an undeclared one seeds the ecosystem with non-spec data.
Note that "get it into the spec" is not automatically the right fix for an existing violation — for
`original_audio` it isn't. The spec already carries the pre-separation mixdown as a stem
(`{id: full, file: stems/full.ogg}`), so that key added a *second, redundant* location for audio to a format
that already had one, and the resolution is to remove it rather than bless it. The gate takes no position on
which way a violation resolves; it only insists that one of the two happens deliberately, in the open,
before the code merges.
## What it checks
We can't mechanically prove core *interprets* a key the way the spec means. We can prove four surface
properties, and they cover the drift that actually occurs.
| Layer | Check | Catches |
|---|---|---|
| 1. key-coverage | Every manifest key core reads **or writes** is declared in the spec's `manifest.schema.json`. | Core growing a key the spec never defined — the #933 class. |
| 2. allowlist-closed | `feedpak-spec-exceptions.yml` has not **grown** relative to the base branch. | Someone routing around the FEP process by allowlisting their own new key. |
| 3. forward | Core's `load_song()` ingests every example pack the spec ships. | The spec adding or tightening something core ignores or breaks on. |
| 4. reverse | Every pack committed to this repo passes the spec's `tools/validate.py`. | Core (or a contributor) committing a pack the spec would reject. |
Layer 1 works by walking the AST of the modules listed in `READERS` and collecting every literal key touched
on a manifest dict (`manifest.get("x")`, `manifest["x"]`, and the wrapped
`(load_manifest(p) or {}).get("x")` form used in `lib/enrichment.py`).
**Reads and writes are both checked, and reported differently.** A key core *writes*
(`manifest["x"] = v`, as `lib/songmeta.py` does) is spec surface pointed outward: it puts a key into every
pack we emit, so an undeclared one seeds the ecosystem with non-spec data. Subscripts are classified by AST
context — `Store` is a write, `Load` is a read — so `manifest["year"] = ...` is not miscounted as a read.
## When it fails
You added a manifest key the spec doesn't define. **There is exactly one way forward, and it is not in this
repo.**
Land the key in the spec through the **feedpak Enhancement Proposal (FEP)** process
([feedpak-spec/CONTRIBUTING.md](https://github.com/got-feedback/feedpak-spec/blob/main/CONTRIBUTING.md)):
1. **Open a FEP issue** on `got-feedback/feedpak-spec` — the problem, the proposed on-disk shape (manifest
key and/or side-file), backward compatibility, and the version bump it implies.
2. **Discuss**, until it has a clear shape and rough consensus.
3. **Land one PR there** that updates the normative spec (`spec/feedpak-v1.md`), the relevant JSON
Schema(s), an example in `examples/` that exercises it, and the changelog — *together*. A PR touching
only one of those is incomplete.
4. **Back here**, just re-run your PR's checks. The gate verifies against the spec's HEAD, so the moment
your key is genuinely part of the format, your PR goes green — nothing to bump, nothing to maintain.
That's deliberately the only route — no experimental prefix, no self-serve allowlist — and it's usually a
quick one for additive keys. The reason it's worth the round-trip: the gate checks the whole repo against
the living spec, so if non-conformance ever lands, it shows up as red CI on *every* teammate's open PR, and
only the person who introduced it can clear it. Going through the FEP keeps your change clean and keeps
everyone else unblocked.
The spec's own governance says the same thing:
> This repository defines the format only. Applications that read or write feedpak ... track this spec as a
> dependency; they do not drive it. **A change is not part of the format until it lands here.**
> — [feedpak-spec/GOVERNANCE.md](https://github.com/got-feedback/feedpak-spec/blob/main/GOVERNANCE.md)
### `feedpak-spec-exceptions.yml` is a closed grandfather list, not a hatch
It exists solely because `original_audio` predates the gate. **CI fails any PR that adds an entry** (layer 2
diffs it against the base branch), so the list can only ever shrink. Entries are debt, each carries a
tracking issue, and each disappears when the underlying key is removed from core. The gate also fails on a
*stale* entry — the spec caught up, or core stopped touching the key — so the file cannot quietly become
somewhere drift accumulates.
Deleting an entry does not, by itself, get you past the gate: layer 1 still fails while core reads the key.
The entry goes when the **code** goes.
## Tracking the spec's HEAD
The gate checks out `feedpak-spec` at **HEAD**, on purpose: the app must conform to the *living* spec, and
nobody should have to maintain a pin. The dev flow is fully self-serve — gated PR → FEP → spec merge →
re-run checks → green.
Two properties to know about:
- **The normal FEP is additive** (a new optional key), which only ever makes the gate *looser* — it cannot
redden anyone's PR. Only a **breaking** spec change (removing/renaming a key the app uses, tightening the
validator against committed packs) turns PRs red repo-wide — and per the spec's compatibility policy that
is a rare, deliberate MAJOR event, exactly when an org-wide "the app is out of conformance" signal is the
right outcome. The CI job logs the exact spec SHA each run verified against, so a red run is reproducible.
- **CI results can change over time on the same commit** — that is inherent to tracking a living contract,
and it is the point: green means "conformant *now*", not "conformant when written".
## Limitations
Known, and worth fixing in follow-ups rather than blocking on:
- **Layer 1's receiver detection is heuristic.** Locals *assigned from* `load_manifest(...)` are discovered
flow-aware whatever they're called (chart.py's `m` taught us that), and the inline
`(load_manifest(p) or {}).get(...)` form is recognised — but a manifest that arrives as a **function
parameter** is only recognised by name (`MANIFEST_VARS`: `manifest`, `mf`). A parameter called something
else would slip. The hardening step is to route all manifest access through a single declared
`KNOWN_MANIFEST_KEYS` registry in `lib/sloppak.py`; the gate then compares registry against schema exactly
instead of inferring.
- **Layer 1 covers top-level keys only.** Nested structure (`arrangements[].file`, `.id`, `.notation`) isn't
checked. Extending to it means walking the schema's `$ref` subschemas.
- **Layer 1 recognises `get`, `setdefault`, subscripts, and the known gap-fill helper** as key access.
`update()` and `pop()` aren't used against a feedpak manifest anywhere in the tree, so they're deliberately
not special-cased rather than speculatively handled. `readers-complete` reuses the same scanner
(`keys_touched()`), so this blind spot is shared, not doubled: a module using only unrecognised access forms
would evade both.
- **Layer 4 can't catch unknown keys**, because `manifest.schema.json` sets `additionalProperties: true` and
the reference validator deliberately "treats unknown keys/files as forward-compatible". Fixing this
properly belongs in the spec (tighten the schema, or give the validator a `--strict` mode). Until then,
layer 1 is the only thing standing between us and the next `original_audio`.
+354
View File
@@ -0,0 +1,354 @@
# Detachable panes (`window.feedBack.panes`)
Pop a panel out of the app into its own OS window, and leave it there: while you
play, across song switches, on a second monitor, minimized to the system tray.
Panes exist because the player's rail popovers are **exclusive** — opening one
closes the last. You cannot watch the mixer while riding the camera, and both
vanish the moment you want to look at the highway.
---
## The whole idea, in one sentence
**We move the real element.**
Not a copy of your panel. Not a re-implementation of it in the pop-out window.
The actual DOM node. Same-origin windows can adopt each other's nodes, and an
adopted node keeps its event listeners and its closures — so your panel goes on
running *your* code, against *your* state, in *your* realm. The app's stylesheets
are copied into the pane window, so it looks identical too.
What you popped out is what you get. That is the promise, and it is the reason
there is no `ctx`, no state mirroring, no cross-window RPC and no second copy of
your UI to keep in step with the first. Those are all solutions to a problem we
simply do not have.
---
## Adding a pane to your plugin
Two lines.
```js
// Guard: the panes API is optional. On a host without it, skip both calls and
// your panel behaves exactly as it does today.
const panes = window.feedBack && window.feedBack.panes;
if (panes && typeof panes.register === 'function') {
panes.register({
id: 'camera_director',
title: 'Camera Director',
icon: '🎥',
element: () => panelEl, // your existing panel, as it is
});
panes.attachChip(panelEl, 'camera_director');
}
```
`attachChip()` injects **the** standard pop-out chip (`⇱`) — same glyph, same
place, same behaviour in every plugin. Clicking it moves your panel to whichever
**host** the router picks — usually a pop-out window, but the dock when a window
can't be had (a blocked pop-up, or `defaultHost: 'dock'`) — and leaves a
"⇲ … is popped out" stub in its place. Clicking the stub brings the panel back, to
exactly the spot it left. Core owns the chip, the hiding and the stub, so you write
no show/hide logic.
That's it. Your sliders, your presets, your tabs, your CSS, your event handlers,
your state — all of it comes along, because none of it moved anywhere except into
a different window's document.
### `element` is a function for a reason
It is resolved at open time, not at registration. Plugins commonly build their
panel lazily on first use, or rebuild it wholesale when something changes (Camera
Director rebuilds its panel on every mode change). Asking for it when we need it
means we always move the live one.
**If you rebuild your panel, re-attach the chip.** Rebuilding takes the chip with
it. `attachChip()` returns a `detach()`; call it before re-attaching, and again in
your teardown — otherwise you leave a stub pointing at DOM that no longer exists.
```js
if (chipDetach) chipDetach();
chipDetach = panes.attachChip(panel, PANE_ID, { header: toolsEl });
```
Re-attaching is safe while the pane is popped out: the chip reconciles against the
pane's real state, so a panel rebuilt mid-pop-out stays correctly stubbed.
### The two things core changes about your element
**1. Placement.** `.fb-paned` is added while the pane is out:
```css
position: static; inset: auto; margin: 0; width: 100%;
max-width: none; max-height: none; z-index: auto; box-shadow: none;
```
Your panel was almost certainly a fixed overlay pinned to a corner of the app
(`position:fixed; top:72px; right:18px; width:288px`). Alone in its own window,
every one of those is wrong — it would float 72px down from the top of a 380px
window, still 288px wide, still casting a shadow over nothing.
Note there is deliberately **no `display` override**: a panel that is
`display:flex` or `grid` stays that way. Colours, borders, radius, padding, fonts
and your panel's own internal layout are untouched.
**2. Visibility.** A panel is usually hidden until its launcher is clicked, and a
pane can be opened from the tray or the rail without that ever happening — so core
un-hides it, in the two ways a panel is actually hidden:
```js
el.hidden = false;
if (el.style.display === 'none') el.style.display = '';
```
**Both are restored exactly as they were when the pane docks**, along with the
`.fb-paned` class. A panel that was closed when you opened its pane from the tray
goes back to being closed; one that was open stays open.
---
## Spec
```js
feedBack.panes.register({
id, // required, unique
element, // required — an Element, or a function returning one
title, // shown in the pane window's title bar, the dock card, the tray
icon, // one glyph, for the dock/tray/launcher lists
width, height, // the pane window's initial size (it remembers yours after that)
defaultHost, // 'window' (default) or 'dock'
onHost, // optional (hostId | null, el) => void — re-measure/re-anchor
});
```
```js
feedBack.panes.attachChip(el, paneId, { header }) // → detach()
feedBack.panes.open(id, { host }) / close(id) / detach(id) / dock(id) / focus(id)
feedBack.panes.isOpen(id) / hostOf(id) / get(id) / list()
```
`attachChip` puts the chip in the `header` element you pass, else in
`el.querySelector('[data-pane-header]')` if it finds one, else at the top of `el`.
An explicit `header` always wins.
---
## Hosts
`detach(id)` puts a pane in the best host available:
| host | | |
|---|---|---|
| `window` | 10 | A real OS window. In the desktop app: remembered bounds, always-on-top, system tray. |
| `dock` | 0 | A card in the in-window stack. **The floor** — always available, so opening a pane can never fail. |
You don't pick; you declare `defaultHost` and the router does the rest.
In the **desktop app** a pane you left popped out comes back popped out on next
launch. In a **browser** it comes back **docked** — a browser blocks
`window.open()` without a user gesture, so restoring it would only ever produce a
"pop-up blocked" toast. The chip pops it out again on your next click.
---
## Best practices
Every item below is something that has already gone wrong, in this codebase, on
this feature. They are cheap to get right up front and confusing to diagnose later
— a broken pane usually *looks* perfect.
### 1. Your code still runs in the main window
The element is *displayed* in the pane window, but its closures, its timers and its
`document` references all still belong to the main realm. **That is precisely why
everything keeps working** — and it has one sharp consequence:
```js
// WRONG — lands in the MAIN window, not the pane the user is looking at.
document.body.appendChild(myTooltip);
// RIGHT — anchored to the panel, so it travels with it.
panelEl.appendChild(myTooltip);
```
**And every lookup for something inside your panel.** Once the panel has moved,
`document.getElementById('my-panel-thing')` returns `null` — so every update it
guards silently stops happening, precisely while the user is looking at the panel.
No error. Just a UI that quietly goes dead.
```js
// WRONG — null once the panel is popped out.
document.getElementById('my-panel-hint').textContent = msg;
// RIGHT — search FROM the panel; works in either document.
panelEl.querySelector('#my-panel-hint').textContent = msg;
```
Elements that live outside your panel (your plugin's *screen*, host chrome) never
move, and should keep using `document.getElementById`. Audit which is which — in
the stem mixer, four ids were inside the panel and a dozen were not.
Same for measuring and popovers. `window.innerWidth` is the *main* window's, and a
dismiss listener on `window` watches a window the user isn't clicking in. Use
`el.ownerDocument` / `el.ownerDocument.defaultView` when you need the window your
panel is actually in.
### 2. Don't hide your panel yourself
Core hides it and leaves a "bring it back" stub. If your plugin *also* hides it,
you are hiding the node that just moved — and the pane window renders nothing.
(This is not hypothetical: core's own chip did exactly this, and the first
pop-out shipped blank because of it.)
### 3. Prefer `hidden` or a class for show/hide
Core makes your panel visible while it's hosted — it clears `hidden`, and clears an
inline `display: none` if that's how you hide — and **restores both on dock**. So
either style works.
`hidden` is still the better choice: it composes with everything, and it leaves
your panel's `display` mode (`flex`, `grid`, whatever it is) entirely alone. Core
deliberately does not override `display` for exactly that reason.
```js
panel.hidden = true; // best
panel.style.display = 'none'; // works — core saves and restores it
```
### 4. `element` is a function — return the *live* node
It is resolved when the pane opens, not when you register. Plugins build panels
lazily, and rebuild them wholesale (Camera Director rebuilds on every mode
change). If you rebuild yours, **re-attach the chip**:
```js
if (chipDetach) chipDetach(); // attachChip returns a detach()
chipDetach = feedBack.panes.attachChip(panel, PANE_ID, { header: toolsEl });
```
Call `chipDetach()` in your teardown too, or you leave a stub pointing at DOM that
no longer exists.
### 5. `isConnected` lies about a panel that is a pane
This one has cost more debugging than everything else on this page combined, and
it lies in **both directions**.
**It says `true` when your panel is not here.** A panel sitting in a pane window is
`isConnected` — just not to *this* document. Code asking "am I still mounted?" gets
`true` and then acts on a panel that is somewhere else entirely.
**It says `false` when your panel is perfectly fine.** The host *detaches* the
element the moment a pop-out starts, before the new window has even loaded. In that
gap `isConnected` is `false` — and any code that rebuilds on that basis builds a
**second panel**, while the host is still holding the first.
That second panel is the one your module variables now point at. The one the user
can *see* is the original, owned by nobody. So:
- its close button closes the *other*, invisible panel — "the X doesn't work"
- your chip gets re-attached to the impostor — "the pop-out icon vanished"
Two baffling symptoms, one duplicate, and nothing in the stack trace to suggest it.
**Ask the pane system, not the DOM.** It knows where your element is:
```js
function paneOwnsPanel() {
const panes = window.feedBack && window.feedBack.panes;
return !!(panes && panes.isOpen && panes.isOpen(MY_PANE_ID));
}
// "Is my panel gone?" — not "is it in this document?"
if (panel && (panel.isConnected || paneOwnsPanel())) return panel; // alive; possibly elsewhere
```
Every `isConnected` check on a panel that can be a pane needs this. In the stem
mixer that was `ensureMixerPanel()` (which rebuilt) *and* the MutationObserver's
fast path (which decided the UI was unmounted and swept on every mutation).
For "which document is it in right now", use `el.ownerDocument === document`, or
take the optional `onHost(hostId, el)` callback, which fires on both moves.
### 6. If your plugin can be re-injected, it must be able to remove itself
The host may run your script more than once — a screen re-entry, a version change.
Without a teardown, the second run builds a second panel while the first one is
still on screen, and every module variable in the new instance points at the new,
invisible one. The user clicks the panel they can see; nothing happens.
Everything stateful duplicates: observers, timers, listeners. And one thing is
worse than duplicated — **your pane registration**:
```js
panes.register({ id, element: () => panel }); // resolved LAZILY, at open time
```
First registration wins, so a stale one hands the host `panel` from a **dead
instance**. Popping out then moves a panel nobody owns.
So publish a teardown handle and call it at the top of your script:
```js
if (window.__myPluginInstance?.destroy) {
try { window.__myPluginInstance.destroy(); } catch (e) { /* tear down what we can */ }
}
window.__myPluginInstance = {
destroy() {
observer?.disconnect();
clearTimeout(myTimer);
chipDetach?.(); // attachChip() returned this
panes?.unregister?.(MY_PANE_ID); // ← the one people forget
document.querySelectorAll('#my-panel').forEach((n) => n.remove());
},
};
```
Belt and braces: when you build your panel, remove any node carrying its id that
isn't yours. A zombie panel is worse than no panel — it looks alive and does
nothing.
### 7. Expect rAF to be throttled while your pane has focus
Chromium throttles a **backgrounded** window's `requestAnimationFrame` — and the
main window is exactly what's backgrounded while the user is looking at your pane.
Your rAF lives in the main window.
Event-driven panels (sliders, buttons, presets) don't care. A panel that
*animates continuously* may run slowly precisely when it's the only thing on
screen. Drive such animation from data you already have, or accept the stutter.
### 8. Don't synchronise anything
No `BroadcastChannel`, no `postMessage`, no second copy of your state, no mirrored
UI. There is **one** realm and **one** panel. If you find yourself writing sync
code, you have misunderstood the model — the whole point is that there is nothing
to sync.
### 9. Nothing here is required
On a host without the panes API, `feedBack.panes` is `undefined`. Skip both calls
and your panel behaves exactly as it does today. Guard, don't depend:
```js
const panes = window.feedBack && window.feedBack.panes;
if (!panes || typeof panes.register !== 'function') return;
```
---
## Things core guarantees
- **The element goes home exactly where it came from** — same parent, same position
among its siblings. Don't move it yourself while it's popped out.
- **It comes home alive.** Core evacuates the element *before* the pane window's
document is destroyed. (Get this wrong — dock after the window dies — and the
node returns looking perfect with every listener in its subtree silently gone.
That bug is why this section exists.)
- **A pane window the user closes, or that crashes, is reaped** and the element
docked back. Your panel is never stranded in a dead document.
- **The app's stylesheets are copied into the pane window**, so your panel looks
identical — including your plugin's own `styles` sheet.
+20
View File
@@ -189,3 +189,23 @@ out of the capability graph.
- [ ] `#player` overlays keep `z-index` ≤ the chrome layers (transport/HUD 20,
rail 30, popovers 40).
- [ ] Verify at `/` — it and `/v3` serve the same (and only) v3 shell.
## Injecting into core shells (profile, dashboard)
Core screens that accept plugin sections render **mount points** — usually
empty, sometimes holding core's own **fallback content** (the Dashboard's
career slot ships the plugin-count stat) — and announce each (re)build with a
DOM event, because their `innerHTML` swap wipes anything previously injected.
A plugin listens for the event and **replaces the mount's content** (never
append — a fallback may be present) by id — the same seam every time:
| Shell | Event | Mounts |
| --- | --- | --- |
| Profile | `v3:profile-rendered` | `#v3-profile-passports-mount` (career wall), `#v3-profile-feats-slot`, `#v3-profile-achievements-mount` |
| Dashboard | `v3:dashboard-rendered` | `#v3-dash-career-slot` (career card; core's plugin-count stat is the fallback content a plugin may replace) |
| Settings | `v3:settings-rendered` | per-plugin `settings.html` panels |
Rules: inject on every event (the mount is fresh), keep the section
**absent-not-empty** (no state → leave the mount alone / empty), and guard
re-wired listeners with a `dataset` flag when your own refresh path can run
against an unwiped mount.
+3 -1
View File
@@ -61,6 +61,8 @@ extractions and twenty-two `routers/` modules, plus lib/library_registry.py for
and is a monolith in its own right, to be split per-table once the router train
lands) · `static/v3/songs.js` (4,134) · `static/capabilities/audio-session.js`
(2,974) · `plugins/highway_3d/screen.js` (15,656) · `plugins/keys_highway_3d/screen.js`
(3,780) · `plugins/drum_highway_3d/screen.js` (3,597) — and every monolith with a PR
(3,780) · `plugins/drum_highway_3d/screen.js` (3,597) · `plugins/career/screen.js`
(1,530 — career v3 gigs + gold pushed it over; split plan: carve the gig block into a
`scriptType: module` file when career work next touches it) — and every monolith with a PR
train in the refactor plan. Test files (e.g. `tests/test_plugins.py`) are out of scope
by policy — the norm governs source files.
+56
View File
@@ -0,0 +1,56 @@
# CLOSED grandfather list — manifest keys core reads or writes that predate the
# spec-conformance gate and that the feedpak spec does not define.
#
# Please don't add entries here — CI will flag any PR that grows this list, so
# it can only shrink over time. That's by design, not distrust: the moment the
# app touches a key the spec doesn't define, every teammate's PR starts failing
# the conformance gate too, and whoever added the key is the only person who
# can fix it. The FEP process below avoids putting anyone in that spot. The
# feedpak spec's own governance is explicit:
#
# "This repository defines the format only. Applications that read or write
# feedpak ... track this spec as a dependency; they do not drive it.
# A change is not part of the format until it lands here."
# — got-feedback/feedpak-spec, GOVERNANCE.md
#
# So a new manifest key goes through the feedpak Enhancement Proposal (FEP)
# process — see feedpak-spec/CONTRIBUTING.md:
#
# 1. Open a FEP issue on got-feedback/feedpak-spec describing the problem, the
# on-disk shape, backward compatibility, and the version bump implied.
# 2. Land one PR there updating the normative spec, the JSON Schemas, an
# example that exercises it, and the changelog — together.
# 3. Back here, re-run this PR's checks. The gate verifies against the spec's
# HEAD, so once your key is in the spec, the gate goes green.
#
# That's the supported route — and usually a quick one for additive keys. If
# your PR is blocked by this gate, a FEP will get you unblocked properly; an
# entry here won't (CI rejects it).
#
# Entries below exist ONLY because they predate the gate. Each is debt with a
# tracking issue, and each disappears when its issue is fixed. The gate also
# fails if an entry goes stale — the spec caught up, or core no longer reads or
# writes the key — so this file cannot quietly become a place drift hides.
exceptions:
- key: original_audio
issue: https://github.com/got-feedback/feedback/issues/945
reason: >-
Added by #583 (the full mix played while every stem fader sits at unity,
since demucs recombination is lossy). It never went through a FEP and the
spec does not define it — the drift this gate exists to prevent.
#933 fixed the drift: feedpak 1.15.0 RESERVES the stem id `full` for the
complete mixdown (feedpak-spec#53), and core now reads the full mix from
that stem. Nothing depends on this key any more — not the loader, not
lib/enrichment.py, not the stems plugin, and the packer no longer writes it.
What remains is a READ-ONLY deprecated fallback in lib/sloppak.py
(_legacy_full_mix), kept for one release because every pack produced before
the spec caught up carries `original_audio: original/full.ogg` and would
otherwise silently lose its pristine mix. tools/migrate_full_mix_stem.py
rewrites those packs into the spec shape.
This entry disappears with that fallback — tracked by #945, which cannot be
forgotten: the gate fails if the entry goes stale, and deleting the read is
what makes it stale.
+9 -1
View File
@@ -14,6 +14,7 @@ import os
from pathlib import Path
import appstate
from safepath import resolved_root
def _get_dlc_dir(cfg: dict | None = None) -> Path | None:
@@ -86,7 +87,14 @@ def _resolve_dlc_path(dlc: Path, filename: str) -> Path | None:
or PureWindowsPath(safe).drive):
return None
try:
root = dlc.resolve()
# The library root is fixed for the life of the process, but this
# function runs once per song / art fetch / scanned row — and
# `.resolve()` lstats every path component. Re-resolving here was
# ~23,500 stat calls/sec on a 50,944-song library, which pins a core
# when the library sits on a FUSE mount (NTFS-3G, SMB, sshfs) where each
# stat is a userspace round trip. Resolve the root once; see
# safepath.resolved_root for the caching contract.
root = resolved_root(dlc)
# normpath collapses `.`/`..`/duplicate separators purely lexically —
# it never touches the filesystem, so an in-library junction component
# is preserved (allowed) while `..`/absolute segments still escape and
+20 -5
View File
@@ -368,10 +368,12 @@ def _acoustid_gate() -> "JSONResponse | None":
def _song_audio_file(filename: str) -> "str | None":
"""Resolve a LIBRARY song (by filename/id) to a local master-audio file for
fingerprinting: the full-mix `original_audio` extracted from a sloppak, or a
loose folder's audio. None when the song can't be found or ships no full-mix
audio (some packs carry only stems). Mirrors serve_sloppak_file's containment
guards so a crafted filename can't read outside DLC_DIR / the pack."""
fingerprinting: a sloppak's complete mixdown, or a loose folder's audio. None
when the song can't be found or carries no mixdown (a pack that kept only its
separated stems — an acoustic fingerprint of one re-summed from them would not
match the recording, so we decline rather than submit a lossy reconstruction).
Mirrors serve_sloppak_file's containment guards so a crafted filename can't
read outside DLC_DIR / the pack."""
dlc = _get_dlc_dir()
if not dlc:
return None
@@ -383,7 +385,20 @@ def _song_audio_file(filename: str) -> "str | None":
canon = resolved.relative_to(dlc.resolve()).as_posix()
except ValueError:
return None
rel = (sloppak_mod.load_manifest(resolved) or {}).get("original_audio")
manifest = sloppak_mod.load_manifest(resolved) or {}
# The mixdown is the RESERVED `full` stem (spec §5.3). Unlike playback,
# fingerprinting wants it even when it is the pack's ONLY stem — a
# single-mix pack is exactly the master audio we want to fingerprint —
# so this asks find_full_mix() rather than partition_stems().
stems = manifest.get("stems") or []
full = sloppak_mod.find_full_mix(
[s for s in stems if isinstance(s, dict)]
)
rel = full.get("file") if full else None
# DEPRECATED fallback: packs written before the spec reserved `full` put
# the mixdown behind a top-level `original_audio:` key instead (#933).
if not isinstance(rel, str) or not rel.strip():
rel = manifest.get("original_audio")
if not isinstance(rel, str) or not rel.strip():
return None
src = sloppak_mod.get_cached_source_dir(canon)
+98 -15
View File
@@ -23,9 +23,18 @@ Engine selection
Two transcription paths share a common output:
* `transcribe_vocals_remote(path, server_url, ...)` — POST the vocal
stem to the `/align` endpoint on a feedBack-demucs-server (got-feedBack's
reference server already hosts WhisperX alongside Demucs at the same
URL).
stem to the `/transcribe` endpoint on a feedBack-demucs-server
(got-feedBack's reference server already hosts WhisperX alongside
Demucs at the same URL).
It used to POST to `/align`, which is *forced alignment* — "here are
the lyrics, tell me when each word is sung". Its `text` field is
required and we have no lyrics (transcribing them is the point), so
the server answered 422 from FastAPI's validation layer before its
handler ran, and remote transcription never worked for anyone
(feedBack-plugin-stem-splitter#17). `/transcribe` takes only audio.
Requires feedBack-demucs-server ≥ the revision adding that endpoint;
an older server answers 404 and the error says so.
* `transcribe_vocals_local(path, ...)` — load WhisperX in-process. Heavy
(~3 GB of model weights for `large-v2` + the wav2vec2 aligner) and
@@ -416,6 +425,38 @@ def transcribe_vocals_local(
# ── Remote transcription ────────────────────────────────────────────────────
_MAX_ERR_BODY = 4000
def _err_body(resp) -> str:
"""The server's error body, whole if it plausibly is one, and marked when it isn't.
This was capped at 300 chars, which is enough for "Internal Server Error" and not much else.
The bodies carrying the most diagnosis are the long ones — a FastAPI validation body naming
the field it rejected, a 500 whose traceback answers on its LAST line — and those are exactly
the ones a 300-char cap decapitates. The cap survives so a server answering with a 2 MB HTML
error page can't dump a novel into a log line.
"""
# Strip FIRST, then measure: a body that is 300 chars of JSON and 3900 of trailing whitespace
# is not a long body, and truncating it would cut real content to make room for blanks.
text = (getattr(resp, "text", "") or "").strip()
if len(text) <= _MAX_ERR_BODY:
return text
# Keep the HEAD **and the TAIL**. Head-only truncation throws away the exception line — and
# on a traceback the exception line is the answer. This docstring said as much while the code
# did the opposite: it cut off precisely the part it exists to preserve, which is the same
# mistake, one level up, as the 300-char cap it replaced.
#
# The marker sits inside the bound, not past it: otherwise _MAX_ERR_BODY is a suggestion, and
# the callers who trust it (a log line, a job record persisted to disk) are the ones surprised.
marker = f"\n… [truncated, {len(text)} chars total] …\n"
budget = max(0, _MAX_ERR_BODY - len(marker))
head = budget * 2 // 3 # context: what was being attempted
tail = budget - head # verdict: what actually went wrong
return text[:head].rstrip() + marker + text[len(text) - tail:].lstrip()
def transcribe_vocals_remote(
vocals_path: Path,
server_url: str,
@@ -426,7 +467,17 @@ def transcribe_vocals_remote(
min_word_score: float = 0.35,
progress_cb: ProgressCB = None,
) -> list[dict]:
"""POST the vocal stem to `{server_url}/align` and parse the response.
"""POST the vocal stem to `{server_url}/transcribe` and parse the response.
NOT `/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; producing them is the point. Posting there returned a
422 from FastAPI's validation layer before the server's handler ran, so
remote transcription never worked at all
(feedBack-plugin-stem-splitter#17).
Requires a feedBack-demucs-server carrying `/transcribe`; an older one
answers 404 and the raised error says so.
Expects the server to respond with a JSON object carrying a `words` (or
`segments`) field in WhisperX's native shape; `_whisperx_to_sloppak`
@@ -454,21 +505,53 @@ def transcribe_vocals_remote(
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
params: dict[str, str] = {}
# POST to /transcribe, not /align.
#
# /align is FORCED ALIGNMENT: "here are the lyrics, tell me when each word is sung". Its
# `text` field is required, and we have no lyrics — transcription is the whole point. So the
# server rejected every request with a 422 in FastAPI's validation layer, before its handler
# ever ran, and remote transcription has never worked for anyone. /transcribe answers the
# question we are actually asking and takes only the audio.
# (feedBack-plugin-stem-splitter#17; endpoint added in feedBack-demucs-server#14.)
#
# `language` goes in the FORM BODY, not the query string: the server reads it with
# Form(""), and a query param would be silently ignored — so an explicit language hint would
# do nothing and Whisper's auto-detection would quietly decide instead, which is exactly the
# kind of "it works but it's wrong" that hides for months.
form: dict[str, str] = {}
if language:
params["language"] = language
form["language"] = language
with open(vocals_path, "rb") as f:
resp = requests.post(
f"{server_url}/align",
files={"file": (vocals_path.name, f, "audio/ogg")},
params=params,
headers=headers or None,
timeout=timeout,
# Everything that can go wrong out here comes back as RuntimeError, which is what the
# docstring promises and what the caller catches. A DNS failure, a timeout, a reset
# connection or an unreadable stem file would otherwise surface as requests.RequestException
# or OSError and escape the one handler written to log-and-continue — turning "this song's
# lyrics failed" into "the whole batch died".
try:
with open(vocals_path, "rb") as f:
resp = requests.post(
f"{server_url}/transcribe",
files={"file": (vocals_path.name, f, "audio/ogg")},
data=form or None,
headers=headers or None,
timeout=timeout,
)
except requests.RequestException as e:
raise RuntimeError(f"could not reach the WhisperX server at {server_url}: {e}") from e
except OSError as e:
raise RuntimeError(f"could not read the vocal stem {vocals_path.name}: {e}") from e
if resp.status_code == 404:
# The endpoint isn't there. Say what that means, because "404" on its own sends someone
# hunting for a typo in their URL when the real answer is that their server predates the
# feature. (feedBack-demucs-server#14 added /transcribe.)
raise RuntimeError(
f"the WhisperX server at {server_url} has no /transcribe endpoint (404) — it "
f"predates remote transcription support. Update the server, or use 'Check for "
f"update' if it is the plugin-managed one."
)
if resp.status_code != 200:
raise RuntimeError(f"WhisperX server error ({resp.status_code}): {resp.text[:300]}")
raise RuntimeError(f"WhisperX server error ({resp.status_code}): {_err_body(resp)}")
data = resp.json()
+94 -19
View File
@@ -614,6 +614,14 @@ class MetadataDB:
)
""")
self.conn.execute("CREATE INDEX IF NOT EXISTS idx_song_stats_recent ON song_stats(last_played_at DESC)")
# Cumulative wall-clock play time (career "hours in genre" odometer).
# Fed by the same POST /api/stats the recorder already sends; additive
# + idempotent like every other song_stats change.
try:
self.conn.execute(
"ALTER TABLE song_stats ADD COLUMN seconds_total REAL NOT NULL DEFAULT 0")
except sqlite3.OperationalError:
pass
# Playlists + the reserved "Saved for Later" system playlist. Additive.
self.conn.execute("""
CREATE TABLE IF NOT EXISTS playlists (
@@ -901,6 +909,9 @@ class MetadataDB:
"best_accuracy": max(cur["best_accuracy"] or 0.0, r["best_accuracy"] or 0.0),
"last_score": newer["last_score"], "last_accuracy": newer["last_accuracy"],
"last_position": newer["last_position"],
# Play time is additive: both encodings' hours belong to
# the one canonical song.
"seconds_total": (cur.get("seconds_total") or 0.0) + (r.get("seconds_total") or 0.0),
"last_played_at": newer["last_played_at"], "updated_at": newer["updated_at"],
}
# Atomic swap: clear and reinsert the canonicalized set in one txn.
@@ -1074,26 +1085,57 @@ class MetadataDB:
vals["artist"], vals["title"] = self._romaji_display(filename, vals["artist"], vals["title"])
return vals
# Effective genre = a per-song genre OVERRIDE (Fix-metadata popup) else the
# scanned pack genre. Applied at FILTER/FACET time (like the P4 artist alias)
# so a corrected genre is browsable — the correlated subquery is used ONLY
# when genre overrides actually exist; the common case stays on the plain
# indexed `genre` column. Genre stays a library-only overlay (it isn't a
# write-to-file field), so it never touches the pack.
_EFFECTIVE_GENRE_SQL = (
# Effective genre precedence: per-song OVERRIDE (Fix-metadata popup)
# scanned pack genre → MusicBrainz enrichment primary genre (matched/manual rows
# only — a 'review'/'failed' candidate's genres could belong to the wrong
# recording). Applied at FILTER/FACET time (like the P4 artist alias) so a
# corrected or enriched genre is browsable. The vast majority of converted
# packs carry no `genres` manifest key, so without the enrichment leg the
# genre facet (and career passports) starve on real libraries. The
# correlated subqueries are used ONLY when overrides/enrichment genres
# actually exist; the common case stays on the plain indexed `genre`
# column. Genre stays a library-only overlay (it isn't a write-to-file
# field), so it never touches the pack.
_EFFECTIVE_GENRE_OVERRIDE_SQL = (
"COALESCE((SELECT o.value FROM song_field_override o "
"WHERE o.filename = songs.filename AND o.field = 'genre' "
"AND o.value IS NOT NULL AND o.value != ''), genre)"
)
_EFFECTIVE_GENRE_SQL = (
"COALESCE((SELECT o.value FROM song_field_override o "
"WHERE o.filename = songs.filename AND o.field = 'genre' "
"AND o.value IS NOT NULL AND o.value != ''), "
"NULLIF(genre, ''), "
"(SELECT json_extract(e.genres, '$[0]') FROM song_enrichment e "
"WHERE e.filename = songs.filename AND e.match_state IN ('matched', 'manual') "
"AND e.genres IS NOT NULL AND e.genres NOT IN ('', '[]')), "
"'')"
)
def _has_genre_overrides(self) -> bool:
return self.conn.execute(
"SELECT 1 FROM song_field_override WHERE field = 'genre' "
"AND value IS NOT NULL AND value != '' LIMIT 1").fetchone() is not None
def _has_enrichment_genres(self) -> bool:
try:
return self.conn.execute(
"SELECT 1 FROM song_enrichment WHERE match_state IN ('matched', 'manual') "
"AND genres IS NOT NULL AND genres NOT IN ('', '[]') "
"LIMIT 1").fetchone() is not None
except sqlite3.OperationalError:
return False # stand-ins / DBs without the enrichment table
def _effective_genre_expr(self) -> str:
"""`genre` normally; the override-aware COALESCE only when overrides exist."""
return self._EFFECTIVE_GENRE_SQL if self._has_genre_overrides() else "genre"
"""`genre` normally; the enrichment-aware COALESCE only when trusted
enrichment genres exist (which also proves the table exists — a
stand-in DB without song_enrichment must never receive SQL that
references it); the override-only form when just overrides exist."""
if self._has_enrichment_genres():
return self._EFFECTIVE_GENRE_SQL
if self._has_genre_overrides():
return self._EFFECTIVE_GENRE_OVERRIDE_SQL
return "genre"
def set_song_tags(self, filename: str, tags) -> list:
"""Replace ALL of a song's tags with the given set (each normalized;
@@ -1693,7 +1735,8 @@ class MetadataDB:
# ── Per-song practice stats ───────────────────────────────────────────---
_STATS_COLS = (
"filename", "arrangement", "plays", "best_score", "best_accuracy",
"last_score", "last_accuracy", "last_position", "last_played_at", "updated_at",
"last_score", "last_accuracy", "last_position", "seconds_total",
"last_played_at", "updated_at",
)
def _stats_row(self, filename: str, arrangement: int) -> dict | None:
@@ -2060,8 +2103,9 @@ class MetadataDB:
self.conn.commit()
def record_session(self, filename: str, arrangement: int, *, score: int,
accuracy: float, last_position=None) -> dict:
"""Record a scored play: plays += 1, best_* = max, last_* = new."""
accuracy: float, last_position=None, seconds: float = 0) -> dict:
"""Record a scored play: plays += 1, best_* = max, last_* = new.
`seconds` (wall-clock play time from the recorder) accrues."""
from song_score import merge_stats
with self._lock:
existing = self._stats_row(filename, int(arrangement))
@@ -2071,8 +2115,9 @@ class MetadataDB:
self.conn.execute(
"""INSERT INTO song_stats
(filename, arrangement, plays, best_score, best_accuracy,
last_score, last_accuracy, last_position, last_played_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?,
last_score, last_accuracy, last_position, seconds_total,
last_played_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?,
strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now'))
ON CONFLICT(filename, arrangement) DO UPDATE SET
plays = excluded.plays,
@@ -2081,32 +2126,62 @@ class MetadataDB:
last_score = excluded.last_score,
last_accuracy = excluded.last_accuracy,
last_position = excluded.last_position,
seconds_total = song_stats.seconds_total + excluded.seconds_total,
last_played_at = excluded.last_played_at,
updated_at = excluded.updated_at""",
(filename, int(arrangement), merged["plays"], merged["best_score"],
merged["best_accuracy"], merged["last_score"], merged["last_accuracy"],
merged["last_position"]),
merged["last_position"], float(seconds or 0)),
)
self.conn.commit()
return self._stats_row(filename, int(arrangement))
def touch_position(self, filename: str, arrangement: int, last_position: float) -> dict:
def touch_position(self, filename: str, arrangement: int, last_position: float,
seconds: float = 0) -> dict:
"""Persist just the resume position (no plays/score change), so
Continue-Playing works for non-scored plays. Also stamps
last_played_at — both /api/stats/recent and /api/session/continue
filter/order on it, so a position-only touch must set it or the song
never surfaces as 'recent' / 'continue playing'."""
never surfaces as 'recent' / 'continue playing'. `seconds` accrues
wall-clock play time (career hours odometer)."""
with self._lock:
self.conn.execute(
"""INSERT INTO song_stats (filename, arrangement, last_position,
seconds_total, last_played_at, updated_at)
VALUES (?, ?, ?, ?, strftime('%Y-%m-%d %H:%M:%f','now'),
strftime('%Y-%m-%d %H:%M:%f','now'))
ON CONFLICT(filename, arrangement) DO UPDATE SET
last_position = excluded.last_position,
seconds_total = song_stats.seconds_total + excluded.seconds_total,
last_played_at = excluded.last_played_at,
updated_at = excluded.updated_at""",
(filename, int(arrangement), float(last_position), float(seconds or 0)),
)
self.conn.commit()
return self._stats_row(filename, int(arrangement))
def add_play_seconds(self, filename: str, arrangement: int, seconds: float) -> dict:
"""Accrue wall-clock play time (no plays/score/position change) —
the recorder's seconds-only flush for unscored plays that ran to the
song's natural end (no resume position to touch there: `song:ended`
must not overwrite Continue with the end-of-song offset). Stamps
last_played_at like touch_position does: the song WAS played, so
/api/stats/recent and Continue ordering must see it. Accepted skew:
the recorder retries FAILED flushes later, which stamps recency at
retry time — rare (offline corner), self-healing on the next play,
and preferable to the alternative (keep-existing would leave repeat
plays looking stale, the common case)."""
with self._lock:
self.conn.execute(
"""INSERT INTO song_stats (filename, arrangement, seconds_total,
last_played_at, updated_at)
VALUES (?, ?, ?, strftime('%Y-%m-%d %H:%M:%f','now'),
strftime('%Y-%m-%d %H:%M:%f','now'))
ON CONFLICT(filename, arrangement) DO UPDATE SET
last_position = excluded.last_position,
seconds_total = song_stats.seconds_total + excluded.seconds_total,
last_played_at = excluded.last_played_at,
updated_at = excluded.updated_at""",
(filename, int(arrangement), float(last_position)),
(filename, int(arrangement), float(seconds)),
)
self.conn.commit()
return self._stats_row(filename, int(arrangement))
+34 -2
View File
@@ -76,6 +76,22 @@ def api_record_stats(data: dict):
last_pos = data.get("lastPlayPosition", data.get("last_position"))
if isinstance(last_pos, bool): # float(False)=0.0 would otherwise store a bogus position
return JSONResponse({"error": "lastPlayPosition must be a finite number"}, status_code=400)
# Optional wall-clock play time (career hours odometer). Bounded per POST:
# the recorder flushes on pause/stop/end, so a single delta beyond 6h is a
# clock artifact (suspend/sleep), not practice.
seconds = data.get("seconds")
if seconds is not None:
if isinstance(seconds, bool):
return JSONResponse({"error": "seconds must be a positive number"}, status_code=400)
try:
seconds = float(seconds)
if not math.isfinite(seconds):
raise ValueError("non-finite")
except (TypeError, ValueError, OverflowError):
return JSONResponse({"error": "seconds must be a positive number"}, status_code=400)
if not (0 < seconds <= 6 * 3600):
return JSONResponse({"error": "seconds must be between 0 and 21600"}, status_code=400)
seconds = seconds or 0.0
# A scored session needs BOTH score and accuracy. Exactly one provided is
# ambiguous — don't silently fall through to the position-only branch.
@@ -115,7 +131,8 @@ def api_record_stats(data: dict):
except (TypeError, ValueError, OverflowError):
return JSONResponse({"error": "lastPlayPosition must be a finite number"}, status_code=400)
row = appstate.meta_db.record_session(filename, arrangement, score=score,
accuracy=accuracy, last_position=last_pos)
accuracy=accuracy, last_position=last_pos,
seconds=seconds)
# Unified XP + streak side-effects — never let these drop the stat write.
progress = None
try:
@@ -152,6 +169,21 @@ def api_record_stats(data: dict):
log.warning("stats side-effects (progression) failed", exc_info=True)
return {"stats": row, "progress": progress, "progression": progression_summary}
# Seconds-only accrual: an unscored play that ran to the song's natural
# end has play time to bank but no resume position to touch (song:ended
# must not overwrite Continue with the end-of-song offset). Still counts
# as playing today for the streak below.
if last_pos is None and seconds:
row = appstate.meta_db.add_play_seconds(filename, arrangement, seconds)
progress = None
try:
from datetime import date
appstate.meta_db.record_active_day(date.today().isoformat())
progress = appstate.meta_db.get_progress()
except Exception:
log.warning("stats side-effects (streak) failed", exc_info=True)
return {"stats": row, "progress": progress}
# Position-only touch.
if last_pos is None:
return JSONResponse(
@@ -162,7 +194,7 @@ def api_record_stats(data: dict):
pos = float(last_pos)
if not math.isfinite(pos):
raise ValueError("non-finite")
row = appstate.meta_db.touch_position(filename, arrangement, pos)
row = appstate.meta_db.touch_position(filename, arrangement, pos, seconds=seconds)
except (TypeError, ValueError, OverflowError):
return JSONResponse({"error": "lastPlayPosition must be a finite number"}, status_code=400)
# A resume session still counts as playing today: advance the streak (no XP —
+43 -19
View File
@@ -321,11 +321,16 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
audio_url = None
audio_error: str | None = None # Surfaced in song_info when audio_url is None
stems_payload: list[dict] = []
# URL of the single full-mix audio (sloppak `original_audio:`), when the
# pack ships one. The stems plugin uses this to play the untouched mix
# while every stem slider is at unity; None otherwise (separate stems
# only, loose folder, or archive).
original_audio_url: str | None = None
# URL of the pack's complete mixdown — the RESERVED `full` stem (spec
# §5.3), which sloppak.load_song() lifts out of `stems` because it is a
# mixdown, not a layer. The stems plugin plays it while every stem slider
# is at unity (separation is lossy, so it beats re-summing the stems) and
# crosses to the separated stems as soon as one is attenuated.
#
# None when the pack has no mixdown to offer separately from its stems:
# a single-mix pack (its one stem IS the mixdown), a loose folder, or an
# archive.
full_mix_url: str | None = None
if is_loose:
# Loose folder filenames are relative paths (artist/album/song).
# Hash the *canonical* dlc-relative path (so two URL spellings
@@ -365,21 +370,25 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
url = f"/api/sloppak/{q_fn}/file/{quote(s['file'])}"
stems_payload.append({"id": s["id"], "url": url, "default": s["default"]})
# Full-mix URL (served by the same /api/sloppak/.../file/ endpoint).
if loaded_slop is not None and loaded_slop.original_audio:
original_audio_url = (
f"/api/sloppak/{q_fn}/file/{quote(loaded_slop.original_audio)}"
if loaded_slop is not None and loaded_slop.full_mix:
full_mix_url = (
f"/api/sloppak/{q_fn}/file/{quote(loaded_slop.full_mix)}"
)
if stems_payload:
# Stems present: keep the core <audio> pointed at stem[0]. This
# URL is only ever heard in the degraded path (stems plugin
# refuses takeover / decode fails); the full-mix↔stems switch is
# driven client-side by `original_audio_url`, not `audio_url`.
# driven client-side by `full_mix_url`, not `audio_url`.
audio_url = stems_payload[0]["url"]
elif original_audio_url:
elif full_mix_url:
# Stem-less full-mix pack: nothing to separate, so play the full
# mix natively through the core <audio>. The stems plugin's
# onSongReady returns early on an empty stems list (no graph).
audio_url = original_audio_url
# Reachable only via the deprecated `original_audio:` key, whose
# packs put the mixdown outside `stems` — a pack that carries its
# mixdown as the `full` stem has it IN `stems`, so it lands in the
# branch above with stems_payload == [full].
audio_url = full_mix_url
else:
audio_error = "This sloppak has no playable stems."
else:
@@ -521,16 +530,31 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
# for the credits overlay, so minigames / synthetic highway uses
# (no manifest) never trigger it.
"authors": _sanitize_authors(loaded_slop.manifest) if (is_slop and loaded_slop is not None) else [],
# Instrument stems ONLY. The pack's complete mixdown (the RESERVED
# `full` stem, spec §5.3) is deliberately NOT in this list: consumers
# sum `stems` into one mix and render one fader per entry, and the
# mixdown is neither a layer nor an instrument — summing it would
# double the whole song. It is surfaced separately, below.
"stems": stems_payload,
# Full-mix audio (sloppak `original_audio:`) served alongside the
# separate `stems`. The stems plugin plays this single file while
# every stem slider is at unity and switches to the separate stems
# the moment one drops below 100%. None when the pack ships stems
# only. `has_*` flags mirror the has_drum_tab/has_keys convention so
# a client can branch without re-deriving from the URLs.
"original_audio_url": original_audio_url,
"has_original_audio": bool(original_audio_url),
# The complete mixdown, served by the same /api/sloppak/.../file/
# endpoint as the stems. The stems plugin plays this single file
# while every stem slider is at unity and crosses to the separated
# stems the moment one drops below 100% — separation is lossy, so the
# mixdown is strictly better audio when nothing is muted. None when
# the pack has no mixdown apart from its stems. The `has_*` flags
# mirror the has_drum_tab/has_keys convention so a client can branch
# without re-deriving from the URLs.
"full_mix_url": full_mix_url,
"has_full_mix": bool(full_mix_url),
"has_stems": bool(stems_payload),
# DEPRECATED aliases of the two keys above, kept so a client built
# against the old frame keeps working across one release. They were
# named after `original_audio:` — a manifest key this repo invented
# and the feedpak spec never had (#933). The key is gone; the mixdown
# is a stem. Remove these once the shipped stems plugin reads
# `full_mix_url` (#945).
"original_audio_url": full_mix_url,
"has_original_audio": bool(full_mix_url),
# Surface a drum_tab presence flag so the visualization picker
# can auto-activate the drums plugin even when the chosen
# arrangement isn't named "Drums" (drum_tab.json lives next
+31 -3
View File
@@ -4,9 +4,34 @@ under a server-owned root.
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
@lru_cache(maxsize=16)
def resolved_root(root: Path) -> Path:
"""Canonical (link-resolved) form of a server-owned root directory.
``Path.resolve()`` is a filesystem call: it lstats every component of the
path. The roots we join against — the DLC library, a plugin's asset dir —
are fixed for the life of the process, but the containment helpers below
(and ``dlc_paths._resolve_dlc_path``) were re-resolving them on EVERY call,
and those are called once per song, per art fetch, per scanned row.
On a real 50,944-song library that cost ~23,500 stat/lstat calls per second,
pinning a core. It is brutal when the library lives on a FUSE mount
(NTFS-3G, SMB, sshfs), where every stat is a userspace round trip: the same
three parent directories were being walked over and over.
Cached because a root is a constant here, not because resolution is cheap.
Consequence: if a root's symlink/junction is re-pointed at a NEW target
while the server is running, the old target stays in effect until restart.
That is fine for a library path fixed at startup, and the cache is keyed on
the Path, so switching to a different library dir is a different key.
"""
return root.resolve()
def safe_join(root: Path, name: str) -> Path | None:
"""Resolve ``name`` under ``root`` and return the resolved Path, or
``None`` if it would escape ``root`` or is unrepresentable.
@@ -35,9 +60,12 @@ def safe_join(root: Path, name: str) -> Path | None:
return None
safe = name.replace("\\", "/")
try:
root_resolved = root.resolve()
candidate = (root_resolved / safe).resolve()
if not candidate.is_relative_to(root_resolved):
# The ROOT is a constant — resolve it once (see resolved_root). The
# CANDIDATE must still be resolved on every call: following its symlinks
# is exactly the zip-slip / traversal defence, so it is never cached.
root_res = resolved_root(root)
candidate = (root_res / safe).resolve()
if not candidate.is_relative_to(root_res):
return None
except (ValueError, OSError):
return None
+409 -61
View File
@@ -15,6 +15,7 @@ from __future__ import annotations
import logging
import math
import os
import shutil
import threading
import zipfile
@@ -34,6 +35,21 @@ FEEDPAK_EXT = ".feedpak"
SLOPPAK_EXT = ".sloppak"
SONG_EXTS = (FEEDPAK_EXT, SLOPPAK_EXT) # accepted on read/discovery
# ── The full mix ──────────────────────────────────────────────────────────────
#
# Spec §5.3 RESERVES the stem id `full` for the song's complete mixdown: the
# whole song in one file, as heard before source separation. It is a stem — it
# lives in `stems` like every other audio file in a pack — but it is a *mixdown,
# not a layer*. A reader that sums stems must never include it in the sum: it
# already contains every instrument, so summing it doubles the whole song and
# muting `guitar` still leaves guitar audible inside it.
#
# Keeping it matters because separation is lossy: re-summing guitar+bass+drums+
# vocals does NOT reproduce the file they came from. The mixdown is the only
# faithful rendering of the song a pack can carry, so we play it whenever every
# stem sits at unity and nothing is muted.
FULL_MIX_STEM_ID = "full"
import yaml
from jsonc import load_json
@@ -51,6 +67,97 @@ import drums as drums_mod
import notation as notation_mod
def find_full_mix(stems: list[dict]) -> dict | None:
"""The RESERVED `full` stem (spec §5.3) — the pack's complete mixdown — or None.
Answers "what is this pack's master audio", which is what fingerprinting
wants. For playback use partition_stems() instead: a pack whose *only* stem
is `full` has no mixdown to play *separately from* its stems, and this
function still returns it.
"""
return next(
(s for s in stems if str(s.get("id", "")) == FULL_MIX_STEM_ID), None
)
def partition_stems(stems: list[dict]) -> tuple[dict | None, list[dict]]:
"""Split stem descriptors into (mixdown, instrument_stems) for PLAYBACK.
The mixdown is lifted OUT of the stem list because every consumer of `stems`
treats that list as layers to sum or to show as mixer channels, and `full` is
neither (spec §5.3). Leaving it in is precisely the bug that made the packer
invent `original_audio` in the first place: a listed full mix plays on top of
the stems.
A pack whose only stem is `full` is a single-mix pack, not a separated one:
there are no instruments to be pristine *against*, so `full` stays the sole
playable stem and no mixdown is surfaced. That keeps the freshly-converted
single-stem pack — much the most common shape — behaving exactly as before.
EVERY entry with the reserved id is removed, not just the one we surface. A
malformed pack that lists `full` twice would otherwise leave a copy of the
whole song behind in the stem list, to be summed with the instruments — the
precise failure this function exists to prevent, reintroduced by a duplicate.
"""
if len(stems) < 2:
return None, stems
full = find_full_mix(stems)
if full is None:
return None, stems
return full, [s for s in stems if str(s.get("id", "")) != FULL_MIX_STEM_ID]
def _legacy_full_mix(manifest: dict, source_dir: Path) -> str | None:
"""Full mix from the DEPRECATED `original_audio:` manifest key, or None.
Before feedpak 1.15.0 reserved `full`, §5.3 said the mixdown was "commonly
replaced" by the per-instrument stems on splitting — so it had nowhere to
live, and this repo invented a top-level key pointing at a parallel
`original/` directory (#583) to hold it. That key was never in the spec, and
#933 removed our dependence on it: the mixdown is a stem.
We still READ it, because every pack written before the spec caught up
carries `original_audio: original/full.ogg` and would otherwise lose its full
mix. We never write it. Delete this once those packs are migrated (#945);
`tools/migrate_full_mix_stem.py` is the migration.
NOTE the string literal below. tools/check_spec_conformance.py AST-scans for
`manifest.get("<literal>")` to prove every manifest key core reads is one the
spec declares. Hoisting "original_audio" into a named constant would hide
this read from that scan — the gate would conclude core no longer touches the
key, and the grandfather entry that documents this debt would go stale. The
literal is what keeps the deprecation honest and visible to CI. Leave it.
Same permissive, path-traversal-guarded posture as the optional side-files: a
missing / escaping / unreadable file leaves the pack without a full mix (the
player falls back to the separated stems) rather than aborting the load.
Returns the manifest-relative string, so callers build its URL exactly as
they build a stem's.
"""
rel_raw = manifest.get("original_audio")
if not isinstance(rel_raw, str) or not rel_raw.strip():
return None
rel = rel_raw.strip()
try:
target = (source_dir / rel).resolve()
target.relative_to(source_dir.resolve())
except ValueError:
log.warning("sloppak: original_audio path %r escapes source_dir — skipped", rel)
return None
except OSError as e:
log.warning("sloppak: original_audio path resolution failed (%s) — skipped", e)
return None
if not target.is_file():
return None
log.info(
"sloppak: pack uses the deprecated `original_audio:` key (%r) — the full mix "
"is a stem (id `full`, feedpak spec §5.3). Re-pack with "
"tools/migrate_full_mix_stem.py; support for this key will be removed.",
rel,
)
return rel
# ── Format detection ──────────────────────────────────────────────────────────
def is_sloppak(path: Path) -> bool:
@@ -81,6 +188,116 @@ _unpack_semaphore = threading.BoundedSemaphore(_UNPACK_MAX_CONCURRENCY)
_unpack_locks: dict[str, threading.Lock] = {}
_unpack_locks_guard = threading.Lock()
# Destinations with an unpack in flight right now. Eviction MUST skip these: two
# unpacks run concurrently, so one finishing could otherwise rmtree the other's
# half-written directory and leave that resolver caching an incomplete song.
_unpacking: set[Path] = set()
_unpacking_guard = threading.Lock()
# Cap the unpack cache. Stems are already-compressed audio, so an unpacked song
# is ~1.1x its zip — the cache is effectively a second, DECOMPRESSED copy of
# every song it holds, and it used to grow without any bound at all. A tester
# reached 60 GB from a 1800-song library: their whole library, unpacked, because
# one caller looped the library calling load_song(). Nothing ever deleted any of
# it — not even when the song itself was deleted.
#
# Default 4 GB ≈ 130 average songs of recency, which is far more than the "the
# song I'm playing, and the last few I played" that this cache actually exists
# to serve. Override with FEEDBACK_SLOPPAK_CACHE_MAX_MB (0 disables eviction).
def _unpack_cache_cap_bytes() -> int:
raw = os.environ.get("FEEDBACK_SLOPPAK_CACHE_MAX_MB", "").strip()
try:
mb = int(raw) if raw else 4096
except ValueError:
mb = 4096
return max(0, mb) * 1024 * 1024
def _dir_size(path: Path) -> int:
total = 0
for f in path.rglob("*"):
try:
if f.is_file():
total += f.stat().st_size
except OSError:
continue
return total
def _touch(path: Path) -> None:
"""Bump mtime so the LRU sweep below treats this song as recently used.
Reading files out of an unpacked dir doesn't change the DIRECTORY's mtime,
so without this the song you are actively playing looks as stale as one you
unpacked days ago — and a burst of unpacks could evict it mid-song.
"""
try:
os.utime(path, None)
except OSError:
pass
def _evict_unpack_cache(root: Path, keep: Path | None = None) -> None:
"""Bound the unpack cache: drop least-recently-used songs until under the cap.
`keep` is never evicted — it's the song the caller just resolved, i.e. almost
certainly the one about to be played.
Evicting a directory MUST also drop its `_source_cache` entry. Otherwise
get_cached_source_dir() keeps handing out a path that no longer exists and
the media route 404s on every stem instead of re-unpacking (it only falls
back to resolve_source_dir when the cache returns None).
"""
cap = _unpack_cache_cap_bytes()
if cap <= 0:
return
try:
entries = []
total = 0
for d in root.iterdir():
if not d.is_dir():
continue
try:
size = _dir_size(d)
mtime = d.stat().st_mtime
except OSError:
continue
entries.append((mtime, size, d))
total += size
if total <= cap:
return
keep_resolved = keep.resolve() if keep else None
entries.sort(key=lambda e: e[0]) # oldest first
for _mtime, size, d in entries:
if total <= cap:
break
try:
if keep_resolved and d.resolve() == keep_resolved:
continue
except OSError:
continue
# Check-and-delete under ONE hold of the guard. Releasing between the
# two would let a resolver mark this dest in-flight and start writing
# into it in the gap, and we'd rmtree a song mid-unpack. A resolver
# that blocks here simply proceeds afterwards — _unpack_zip recreates
# the directory anyway.
with _unpacking_guard:
if d in _unpacking:
continue # another thread is writing this
shutil.rmtree(d, ignore_errors=True)
if d.exists():
continue # couldn't remove — don't claim the bytes back
total -= size
with _source_lock:
for fn, (cached_dir, _m, _s) in list(_source_cache.items()):
if cached_dir == d:
_source_cache.pop(fn, None)
log.info("sloppak: evicted %s from the unpack cache (%.0f MB)",
d.name, size / 1e6)
except OSError:
log.warning("sloppak: unpack-cache eviction failed", exc_info=True)
def _unpack_lock_for(filename: str) -> threading.Lock:
"""Return a stable per-file lock so concurrent unpacks of the same sloppak
@@ -145,10 +362,17 @@ def resolve_source_dir(
re-unpacks if mtime/size changed, then returns that dir.
Caches the resolution so subsequent calls are ~free.
NOTE: this writes the WHOLE pack — every stem — to disk. Only call it for a
song you are about to play. To read a *part* of a song (an arrangement, the
lyrics, a tone blob), use read_member_bytes(): unpacking a pack to read a few
KB of JSON is ~45x write amplification, and doing it in a loop over the
library fills the disk with a decompressed copy of every song.
"""
path = dlc_root / filename
stat = path.stat()
mtime, size = stat.st_mtime, stat.st_size
guarded: Path | None = None # a dir WE unpacked, shielded from eviction
with _source_lock:
cached = _source_cache.get(filename)
@@ -159,42 +383,76 @@ def resolve_source_dir(
and cached_size == size
and cached_dir.exists()
):
# Mark it recently-used before returning — see _touch().
if cached_dir != path:
_touch(cached_dir)
return cached_dir
if path.is_dir():
resolved = path
else:
# Zip form — unpack to the cache. Serialize per-file (so concurrent
# callers don't rmtree + re-extract the same dest at once) and cap
# global unpack concurrency (so a burst can't saturate disk/CPU).
dest = unpack_cache_root / _safe_id(filename)
with _unpack_lock_for(filename):
# Re-check the cache inside the per-file lock — a prior holder may
# have just finished unpacking this exact (mtime, size).
with _source_lock:
cached = _source_cache.get(filename)
if (
cached
and cached[1] == mtime
and cached[2] == size
and cached[0].exists()
):
resolved = cached[0]
else:
with _unpack_semaphore:
_unpack_zip(path, dest)
resolved = dest
try:
if path.is_dir():
resolved = path
else:
# Zip form — unpack to the cache. Serialize per-file (so concurrent
# callers don't rmtree + re-extract the same dest at once) and cap
# global unpack concurrency (so a burst can't saturate disk/CPU).
dest = unpack_cache_root / _safe_id(filename)
with _unpack_lock_for(filename):
# Re-check the cache inside the per-file lock — a prior holder may
# have just finished unpacking this exact (mtime, size).
with _source_lock:
cached = _source_cache.get(filename)
if (
cached
and cached[1] == mtime
and cached[2] == size
and cached[0].exists()
):
resolved = cached[0]
else:
# Shield `dest` from eviction from the moment we start writing
# until it is safely in _source_cache. `keep` only shields it
# from OUR OWN sweep — a concurrent resolver sweeping with a
# different `keep` would delete it, and we would then cache and
# return a path that no longer exists. The `finally` below
# releases it on EVERY exit, including a failed unpack: leaving
# a dest marked in-flight would make it un-evictable forever.
with _unpacking_guard:
_unpacking.add(dest)
guarded = dest
with _unpack_semaphore:
_unpack_zip(path, dest)
resolved = dest
# The only moment this cache grows. Sweep here rather than on a
# timer so it can never drift far past the cap.
_evict_unpack_cache(unpack_cache_root, keep=dest)
with _source_lock:
_source_cache[filename] = (resolved, mtime, size)
return resolved
with _source_lock:
_source_cache[filename] = (resolved, mtime, size)
return resolved
finally:
if guarded is not None:
with _unpacking_guard:
_unpacking.discard(guarded)
def get_cached_source_dir(filename: str) -> Path | None:
"""Return the cached source dir for a sloppak if one is known."""
"""Return the cached source dir for a sloppak if one is known AND still there.
The existence check is load-bearing: callers (media.py) only fall back to
resolve_source_dir() when this returns None, so handing back a path that has
been evicted — or that the user deleted by hand to reclaim disk — would 404
every stem for the rest of the process instead of re-unpacking.
"""
with _source_lock:
cached = _source_cache.get(filename)
return cached[0] if cached else None
if not cached:
return None
src = cached[0]
if not src.is_dir():
_source_cache.pop(filename, None)
return None
_touch(src)
return src
# ── Manifest + song loading ───────────────────────────────────────────────────
@@ -233,6 +491,82 @@ def load_manifest(path: Path) -> dict:
return _read_manifest_from_zip(path)
_ZIP_ROOT = Path("/_root").resolve()
def _zip_member_key(name: str) -> str | None:
"""Canonical lookup key for a zip member name, or None if it escapes the root.
Collapses './', 'a/../b' and backslash separators — the same normalization
_unpack_zip()/safe_join() apply when extracting. Both the name the caller asks
for AND the names the archive actually stores must go through this, or a pack
that stores './arrangements/lead.json' unpacks fine but reads back as missing.
"""
safe = safe_join(_ZIP_ROOT, name or "")
# None → escapes the root; == root → a degenerate name like "." or "a/..".
if safe is None or safe == _ZIP_ROOT:
return None
return safe.relative_to(_ZIP_ROOT).as_posix()
def read_member_bytes(path: Path, rel: str) -> bytes | None:
"""Return the bytes of ONE file inside a sloppak, or None if it isn't there.
For a zipped sloppak this opens that single member instead of unpacking the
archive — the same trick read_cover_bytes() uses to keep the library grid
from exploding every pack just to show a cover.
Reach for this whenever you want a *part* of a song (an arrangement's JSON,
the lyrics, a tone blob) rather than a song you're about to play. The
alternative, load_song(), calls resolve_source_dir() and writes the WHOLE
pack — every stem — into the unpack cache. That is a ~45x write amplification
when all you wanted was a few KB of JSON, and looping the library on it
unpacks the entire library (got-feedBack/feedBack: a tester hit 60 GB that
way). Stems are already-compressed audio, so an unpacked song is ~1.1x its
zip: the cache becomes a second, decompressed copy of everything it touches.
"""
rel = (rel or "").strip()
if not rel:
return None
if path.is_dir():
target = safe_join(path.resolve(), rel)
if target is None or not target.is_file():
return None
try:
return target.read_bytes()
except OSError:
return None
# Zip form — read just that member, no unpack. Zip-slip is rejected before we
# open anything, and both sides of the comparison are normalized, so a
# non-canonical-but-valid name ('./arrangements/lead.json') resolves the same
# way it did when we unpacked first.
member = _zip_member_key(rel)
if member is None:
log.warning("sloppak: rejected unsafe member name %r in %r", rel, path)
return None
try:
with zipfile.ZipFile(str(path), "r") as zf:
# Match on the NORMALIZED stored name, and take the LAST match — the
# archive may store './x' or a backslash path (Windows tooling), and
# if it stores two names that normalize to the same file, _unpack_zip
# writes them in order so the last one wins. Reading the raw member by
# exact name would miss the first case and return the wrong bytes in
# the second. A pack has a handful of members; the scan is free.
info = None
for cand in zf.infolist():
if _zip_member_key(cand.filename) == member:
info = cand
if info is None or info.is_dir():
return None
with zf.open(info) as f:
return f.read()
except (zipfile.BadZipFile, OSError, RuntimeError) as e:
log.warning("sloppak: failed to read %r from %s: %s", rel, path.name, e)
return None
_COVER_MEDIA_TYPES = {
".jpg": "image/jpeg", ".jpeg": "image/jpeg",
".png": "image/png", ".webp": "image/webp",
@@ -367,14 +701,21 @@ class LoadedSloppak:
# song.arrangements (not to manifest["arrangements"]) — skipped entries are
# absent so indexing by song.arrangements index is safe.
arrangement_ids: list[str | None] = field(default_factory=list)
# Manifest-relative path to the single full-mix audio file, taken from the
# manifest `original_audio:` key (e.g. "original/full.ogg"). This is the
# pre-separation mixdown that exists alongside the per-instrument `stems`.
# None when the key is absent, points outside source_dir, or the file is
# missing on disk. Served to the front-end via the highway WS as
# `original_audio_url`; the stems plugin uses it to play the untouched mix
# when every stem slider is at unity (and the separate stems otherwise).
original_audio: str | None = None
# Manifest-relative path to the pack's complete mixdown — the whole song in
# one file, as heard before source separation. This is the RESERVED `full`
# stem (spec §5.3), lifted out of `stems` above precisely because it is NOT
# an instrument layer: summing it with the per-instrument stems it was split
# into would double the entire song. See partition_stems().
#
# None when the pack has no mixdown to offer *separately* from its stems —
# which includes the common single-mix pack, whose only stem IS the mixdown
# (there is nothing to be pristine against, so it stays in `stems`).
#
# Served to the front-end via the highway WS as `full_mix_url`; the stems
# plugin plays it while every stem slider sits at unity and crosses to the
# separated stems the moment one drops below 100% — demucs recombination is
# lossy, so the mixdown is strictly the better audio when nothing is muted.
full_mix: str | None = None
def load_song(
@@ -766,6 +1107,13 @@ def load_song(
default_on = bool(default_val)
stems.append({"id": sid, "file": sfile, "default": default_on})
# The complete mixdown is a stem (spec §5.3), but it is not a *layer*: lift
# it out so that no consumer of `stems` — the mixer, the library's stem
# chips, the WS payload — sums it with, or lists it beside, the instruments
# it was separated into. `full_mix_stem` is None for a single-mix pack,
# whose only stem IS the mixdown and stays in the list.
full_mix_stem, stems = partition_stems(stems)
# Optional keys.json — song-level, instrument-independent key/scale track
# (manifest `keys:` key, spec §7.7). Permissive like the other side-files:
# missing / unreadable / malformed -> None, never fatal. Stored as a
@@ -828,28 +1176,22 @@ def load_song(
}
_fpv = manifest.get("feedpak_version")
# Optional full-mix audio — manifest `original_audio:` key. The single
# pre-separation mixdown that ships alongside the per-instrument stems.
# Same permissive, path-traversal-guarded posture as drum_tab above: a
# missing/escaping/absent file simply leaves the full mix unavailable (the
# player falls back to the separate stems) rather than aborting the load.
# We store the manifest-relative string so server.py can build its URL the
# same way it builds stem URLs (via the /api/sloppak/.../file/ endpoint).
original_audio_data: str | None = None
original_audio_rel = manifest.get("original_audio")
if isinstance(original_audio_rel, str) and original_audio_rel.strip():
rel = original_audio_rel.strip()
try:
oa_path = (source_dir / rel).resolve()
oa_path.relative_to(source_dir.resolve())
except ValueError:
log.warning("sloppak: original_audio path %r escapes source_dir — skipped", rel)
oa_path = None
except OSError as e:
log.warning("sloppak: original_audio path resolution failed (%s) — skipped", e)
oa_path = None
if oa_path is not None and oa_path.is_file():
original_audio_data = rel
# The pack's full mix. Normally the RESERVED `full` stem partitioned out
# above (spec §5.3) — no path work needed, it was validated with the other
# stems and its URL is built the same way. Only when the pack has no `full`
# stem do we fall back to the DEPRECATED `original_audio:` key, which is the
# shape every pack written before feedpak 1.15.0 uses.
if full_mix_stem is not None:
full_mix_data: str | None = full_mix_stem["file"]
elif find_full_mix(stems) is not None:
# Single-mix pack: its ONE stem is the mixdown, so there is no mixdown to
# offer *apart from* the stems. Never fall through to the legacy key here
# — a pack that both carries a `full` stem and names the old key would
# otherwise surface the mixdown twice (once as the stem the player is
# already playing, once as a "pristine" track to cross to).
full_mix_data = None
else:
full_mix_data = _legacy_full_mix(manifest, source_dir)
return LoadedSloppak(
song=song,
@@ -864,7 +1206,7 @@ def load_song(
keys=keys_data,
notation_by_id=notation_by_id_data,
arrangement_ids=arrangement_ids_acc,
original_audio=original_audio_data,
full_mix=full_mix_data,
)
@@ -909,7 +1251,7 @@ def extract_meta(path: Path) -> dict:
tuning_offsets = _tuning_for_meta(arr_list)
stems_list = manifest.get("stems", []) or []
stem_ids: list[str] = []
valid_stems: list[dict] = []
for s in stems_list:
if not isinstance(s, dict):
continue
@@ -923,7 +1265,13 @@ def extract_meta(path: Path) -> dict:
isinstance(sid, str) and sid
and isinstance(sfile, str) and sfile
):
stem_ids.append(sid)
valid_stems.append({"id": sid, "file": sfile})
# Partition exactly as load_song() does, for the same reason the library
# filter must not lie: `full` is the mixdown, not an instrument (spec §5.3).
# A separated pack that retains it would otherwise offer the user a "full"
# stem chip alongside guitar/bass/drums and count it as a seventh stem.
_full, instrument_stems = partition_stems(valid_stems)
stem_ids = [s["id"] for s in instrument_stems]
stem_count = len(stem_ids)
return {
+4 -2
View File
@@ -101,14 +101,16 @@ def open_midis_to_freqs(midis: list[int], reference_pitch: float = DEFAULT_REFER
def freqs_to_midis(freqs: list[float], reference_pitch: float = DEFAULT_REFERENCE_PITCH) -> list[int] | None:
"""Return absolute open-string MIDI notes for frequencies at the supplied
A4 reference — the inverse of open_midis_to_freqs. None if any entry is
non-numeric or non-positive (a provider could hand us anything)."""
non-numeric, non-finite, or non-positive (a provider could hand us
anything; NaN/Infinity would otherwise raise inside int(round(...)) and
500 the /api/tunings endpoint)."""
out: list[int] = []
for f in freqs:
try:
f = float(f)
except (TypeError, ValueError):
return None
if f <= 0:
if not math.isfinite(f) or f <= 0:
return None
out.append(int(round(69 + 12 * math.log2(f / reference_pitch))))
return out
+725
View File
@@ -64,3 +64,728 @@
.career-star-row .song .artist { color: #9ca3af; }
.career-star-row .hint { color: #6b7280; white-space: nowrap; }
.career-star-row .hint.close { color: #22d3ee; }
/* ── Passports (badge journey) ─────────────────────────────────────────── */
.career-tabs {
display: flex;
gap: 0.25rem;
margin-bottom: 1rem;
border-bottom: 1px solid rgba(55, 65, 81, 0.6);
}
.career-tab {
padding: 0.375rem 0.875rem;
font-size: 0.85rem;
color: #9ca3af;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.career-tab:hover { color: #e5e7eb; }
.career-tab.active { color: #fff; border-bottom-color: #06b6d4; }
.pp-instruments { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.pp-inst {
padding: 0.3rem 0.8rem;
border-radius: 999px;
font-size: 0.8rem;
color: #d1d5db;
background-color: rgba(31, 41, 55, 0.7);
border: 1px solid transparent;
}
.pp-inst:hover { background-color: rgba(55, 65, 81, 0.9); }
.pp-inst.active { border-color: #06b6d4; color: #fff; }
.pp-inst.uncommitted { color: #6b7280; border-style: dashed; border-color: rgba(107, 114, 128, 0.5); }
.pp-inst-badges { color: #fbbf24; font-size: 0.7rem; }
.pp-inst-plus { color: #6b7280; }
/* Leather covers per-instrument hue, embossed with layered shadows and a
subtle grain gradient (no image assets). Keep the hex pairs in sync with
PP_LEATHER_HEX in screen.js (the canvas card draws the same leather). */
.pp-leather-guitar { background: linear-gradient(160deg, #5c2321, #401412); }
.pp-leather-bass { background: linear-gradient(160deg, #1f3252, #131f36); }
.pp-leather-keys { background: linear-gradient(160deg, #1e4034, #122a21); }
.pp-leather-drums { background: linear-gradient(160deg, #3f3f46, #26262b); }
.pp-shelf { display: flex; flex-wrap: wrap; gap: 1rem; align-items: flex-end; }
.pp-cover, .pp-commit-cover {
position: relative;
width: 9.5rem;
height: 13rem;
border-radius: 0.5rem 0.75rem 0.75rem 0.5rem;
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.06),
inset 0.5rem 0 0.75rem -0.5rem rgba(0, 0, 0, 0.8),
0 6px 16px rgba(0, 0, 0, 0.45);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.35rem;
padding: 0.75rem;
text-align: center;
}
.pp-cover { transition: transform 0.15s ease, box-shadow 0.15s ease; }
.pp-cover:not(.pp-tilt):hover { transform: translateY(-4px) !important; box-shadow: 0 10px 22px rgba(0, 0, 0, 0.55); }
.pp-cover-title {
font-weight: 700;
font-size: 0.85rem;
letter-spacing: 0.14em;
color: rgba(240, 226, 195, 0.92);
text-shadow: 0 1px 0 rgba(0, 0, 0, 0.7), 0 -1px 0 rgba(255, 255, 255, 0.12);
overflow-wrap: anywhere;
}
.pp-cover-inst {
font-size: 0.6rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: rgba(240, 226, 195, 0.55);
}
.pp-cover-sub {
position: absolute;
bottom: 0.6rem;
font-size: 0.6rem;
color: rgba(240, 226, 195, 0.5);
}
.pp-commit-card {
display: flex;
gap: 1.25rem;
align-items: center;
padding: 1rem;
border-radius: 0.75rem;
border: 1px solid rgba(55, 65, 81, 0.6);
background-color: rgba(31, 41, 55, 0.35);
}
.pp-commit-card .pp-commit-cover { width: 7rem; height: 9.5rem; flex: none; }
.pp-rack { display: grid; gap: 0.75rem; grid-template-columns: repeat(auto-fill, minmax(10.5rem, 1fr)); }
.pp-brochure {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.15rem;
padding: 0.75rem 0.875rem;
border-radius: 0.5rem;
text-align: left;
background: linear-gradient(165deg, rgba(45, 55, 72, 0.55), rgba(31, 41, 55, 0.55));
border: 1px solid rgba(75, 85, 99, 0.5);
transition: transform 0.15s ease, border-color 0.15s ease;
}
.pp-brochure:hover { transform: translateY(-2px); border-color: #06b6d4; }
.pp-brochure-art { font-size: 1.4rem; }
.pp-brochure-name { color: #e5e7eb; font-size: 0.85rem; font-weight: 600; }
.pp-brochure-sub { color: #6b7280; font-size: 0.65rem; }
/* The open book */
.pp-overlay { position: fixed; inset: 0; z-index: 60; }
.pp-book-wrap {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(3, 7, 18, 0.72);
backdrop-filter: blur(2px);
}
.pp-book {
position: relative;
width: min(92vw, 720px);
height: min(72vh, 470px);
perspective: 1800px;
}
.pp-page {
position: absolute;
top: 0;
bottom: 0;
width: 50%;
background:
linear-gradient(105deg, rgba(0, 0, 0, 0.08), transparent 12%),
#efe6d0;
color: #3f3428;
padding: 1.1rem 1.2rem;
overflow: hidden;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.pp-page-left { left: 0; border-radius: 0.6rem 0 0 0.6rem; opacity: 0; transition: opacity 0.35s ease 0.3s; align-items: center; }
.pp-page-right { right: 0; border-radius: 0 0.6rem 0.6rem 0; box-shadow: inset 0.4rem 0 0.6rem -0.4rem rgba(0, 0, 0, 0.35); }
.pp-book.open .pp-page-left { opacity: 1; }
.pp-book-cover {
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: 50%;
border-radius: 0 0.6rem 0.6rem 0;
transform-origin: left center;
transform: rotateY(0deg);
backface-visibility: hidden;
transition: transform 0.8s cubic-bezier(0.4, 0.1, 0.2, 1);
z-index: 5;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.35rem;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06), 0 6px 20px rgba(0, 0, 0, 0.5);
}
.pp-book.open .pp-book-cover { transform: rotateY(-180deg); }
.pp-book-close {
position: absolute;
top: -0.75rem;
right: -0.75rem;
z-index: 8;
width: 2rem;
height: 2rem;
border-radius: 999px;
background: rgba(17, 24, 39, 0.95);
color: #d1d5db;
border: 1px solid rgba(107, 114, 128, 0.5);
}
.pp-book-close:hover { color: #fff; border-color: #06b6d4; }
.pp-page-head {
font-size: 0.7rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: #8a7a5e;
border-bottom: 1px solid rgba(138, 122, 94, 0.35);
padding-bottom: 0.4rem;
width: 100%;
text-align: center;
}
/* The rubber stamp */
.pp-stamp {
--pp-rot: 0deg;
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.1rem;
width: 9rem;
height: 9rem;
border-radius: 999px;
border: 3px solid #9a5b16;
box-shadow: inset 0 0 0 3px #efe6d0, inset 0 0 0 4px #9a5b16;
color: #9a5b16;
transform: rotate(var(--pp-rot));
margin-top: 1.25rem;
text-align: center;
padding: 0.75rem;
opacity: 0.92;
}
.pp-stamp-genre { font-size: 0.72rem; font-weight: 800; letter-spacing: 0.16em; overflow-wrap: anywhere; }
.pp-stamp-tier { font-size: 0.58rem; letter-spacing: 0.3em; }
.pp-stamp-ghost {
border-style: dashed;
box-shadow: none;
border-color: #b3a68b;
color: #b3a68b;
opacity: 0.8;
}
.pp-stamp-hidden { opacity: 0; }
.pp-stamp-mini {
position: absolute;
top: 0.5rem;
right: 0.5rem;
width: auto;
height: auto;
border-width: 2px;
box-shadow: none;
border-radius: 999px;
font-size: 0.5rem;
font-weight: 800;
letter-spacing: 0.2em;
color: #d9a253;
border-color: #d9a253;
padding: 0.2rem 0.4rem;
margin: 0;
display: inline-block;
transform: rotate(var(--pp-rot));
opacity: 0.95;
}
.pp-stamp-page::after {
content: '';
position: absolute;
inset: -10%;
border-radius: 999px;
background: radial-gradient(closest-side, rgba(154, 91, 22, 0.25), transparent 72%);
filter: blur(5px);
opacity: 0;
pointer-events: none;
}
.pp-slam { animation: pp-slam 0.5s cubic-bezier(0.2, 0.8, 0.3, 1) forwards; }
.pp-slam::after { animation: pp-ink 0.45s ease-out 0.12s forwards; }
@keyframes pp-slam {
0% { transform: rotate(calc(var(--pp-rot) - 15deg)) scale(2.5); opacity: 0; }
55% { transform: rotate(var(--pp-rot)) scale(0.92); opacity: 1; }
75% { transform: rotate(var(--pp-rot)) scale(1.05); }
100% { transform: rotate(var(--pp-rot)) scale(1); opacity: 0.92; }
}
@keyframes pp-ink {
from { opacity: 0; transform: scale(0.6); }
to { opacity: 1; transform: scale(1); }
}
.pp-shake { animation: pp-shake 0.4s ease-out 0.28s; }
@keyframes pp-shake {
0%, 100% { transform: translate(0, 0) rotate(0); }
25% { transform: translate(2px, 1px) rotate(0.3deg); }
50% { transform: translate(-2px, 2px) rotate(-0.25deg); }
75% { transform: translate(1px, -1px) rotate(0.15deg); }
}
.pp-invite, .pp-snj, .pp-gold-note { font-size: 0.75rem; text-align: center; }
.pp-invite { color: #6d5d40; }
.pp-snj { color: #6d5d40; margin-top: 2rem; font-style: italic; max-width: 15rem; }
.pp-gold-note { color: #a8946d; font-size: 0.62rem; margin-top: 0.5rem; }
.pp-drills { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.7rem; color: #6d5d40; }
.pp-drill.cleared { color: #4d7c0f; }
/* Ticket stubs */
.pp-stubs { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 0.5rem; padding-right: 0.25rem; }
.pp-stub {
background: #f7f1e3;
border: 1px solid #d8cbaa;
border-left: 2px dashed #b6a98c;
border-radius: 0.25rem 0.4rem 0.4rem 0.25rem;
padding: 0.4rem 0.6rem 0.4rem 0.75rem;
display: grid;
grid-template-columns: auto 1fr;
column-gap: 0.6rem;
align-items: baseline;
box-shadow: 0 1px 2px rgba(63, 52, 40, 0.15);
}
.pp-stub-stars { color: #b8860b; font-size: 0.7rem; letter-spacing: 0.08em; grid-row: span 2; }
.pp-stub-title { font-size: 0.78rem; font-weight: 600; color: #3f3428; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pp-stub-artist { grid-column: 2; font-size: 0.65rem; color: #6d5d40; }
.pp-stub-meta { grid-column: 2; font-size: 0.6rem; color: #8a7a5e; }
.pp-stub-empty { font-size: 0.72rem; color: #8a7a5e; font-style: italic; padding: 0.75rem 0.25rem; }
/* Wax-seal commitment ceremony */
.pp-ceremony { width: 11rem; height: 15rem; }
.pp-wax {
position: absolute;
bottom: 1.4rem;
display: flex;
align-items: center;
justify-content: center;
width: 3.4rem;
height: 3.4rem;
border-radius: 999px;
background:
radial-gradient(circle at 32% 30%, #d24545 0%, #a41f1f 42%, #7c1414 100%);
box-shadow:
inset 0 0 0 4px rgba(124, 20, 20, 0.9),
inset 0 2px 4px rgba(255, 255, 255, 0.25),
0 3px 8px rgba(0, 0, 0, 0.55);
color: rgba(255, 235, 235, 0.9);
font-weight: 700;
font-size: 1.15rem;
animation: pp-seal-drop 0.9s cubic-bezier(0.25, 0.9, 0.3, 1.15) 0.35s backwards;
}
@keyframes pp-seal-drop {
0% { transform: translateY(-120px) scale(2.1); opacity: 0; }
60% { transform: translateY(0) scale(0.9); opacity: 1; }
80% { transform: translateY(0) scale(1.05); }
100% { transform: translateY(0) scale(1); }
}
/* Small screens: the spread stacks; the flip cover would straddle both
pages, so the book simply opens. */
@media (max-width: 640px) {
.pp-book { height: min(80vh, 620px); }
.pp-page { position: static; width: 100%; height: 50%; border-radius: 0; }
.pp-page-left { border-radius: 0.6rem 0.6rem 0 0; opacity: 1; }
.pp-page-right { border-radius: 0 0 0.6rem 0.6rem; }
.pp-book-cover { display: none; }
.pp-book { display: flex; flex-direction: column; }
}
@media (prefers-reduced-motion: reduce) {
.pp-book-cover, .pp-page-left, .pp-cover { transition: none; }
.pp-slam, .pp-slam::after, .pp-shake, .pp-wax { animation: none; }
.pp-slam, .pp-stamp-page::after { opacity: 1; }
.pp-stamp-hidden { opacity: 0.92; }
}
/* Badge ceremony (body-level overlay — shows over the player) */
.pp-ceremony-overlay {
position: fixed;
inset: 0;
z-index: 220;
display: flex;
align-items: center;
justify-content: center;
background: rgba(3, 7, 18, 0.55);
backdrop-filter: blur(1.5px);
animation: pp-ceremony-in 0.3s ease-out;
cursor: pointer;
}
.pp-ceremony-out { opacity: 0; transition: opacity 0.3s ease-out; }
.pp-confetti { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
.pp-ceremony-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
text-align: center;
}
.pp-ceremony-stamp {
position: relative;
overflow: hidden;
background: rgba(239, 230, 208, 0.97);
transform: rotate(var(--pp-rot)) scale(1.25);
animation: pp-slam 0.55s cubic-bezier(0.2, 0.8, 0.3, 1) 0.15s backwards;
margin-top: 0;
}
.pp-ceremony-stamp::before {
content: '';
position: absolute;
inset: -40%;
background: linear-gradient(115deg, transparent 42%, rgba(255, 255, 255, 0.55) 50%, transparent 58%);
transform: translateX(-120%);
animation: pp-shine 1.1s ease-out 0.75s forwards;
pointer-events: none;
}
@keyframes pp-shine {
to { transform: translateX(120%); }
}
@keyframes pp-ceremony-in {
from { opacity: 0; }
to { opacity: 1; }
}
.pp-ceremony-title {
margin-top: 1rem;
font-size: 1.15rem;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
color: #f0e2c3;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.8);
}
.pp-ceremony-sub { font-size: 0.8rem; color: #d1d5db; text-shadow: 0 1px 4px rgba(0, 0, 0, 0.8); }
/* Hours odometer (Stage 5 post-cap — a true fact, never a meter) */
.pp-hours {
font-size: 0.72rem;
letter-spacing: 0.08em;
color: #8a7a5e;
margin-top: 0.75rem;
font-variant-numeric: tabular-nums;
}
/* ── Visuals pack: trading-card tilt, emerging ink, gold foil ──────────── */
/* Trading-card tilt (earned artifacts; JS feeds --pp-tilt-* on hover-capable
pointers only). */
.pp-tilt {
position: relative;
overflow: hidden;
will-change: transform;
}
.pp-cover.pp-tilt {
transform: perspective(700px)
rotateX(var(--pp-tilt-x, 0deg)) rotateY(var(--pp-tilt-y, 0deg))
rotate(var(--pp-cover-rot, 0deg));
transition: transform 0.12s ease, box-shadow 0.15s ease;
}
.pp-cover.pp-tilt:hover { box-shadow: 0 12px 26px rgba(0, 0, 0, 0.6); }
.pp-stamp-page.pp-tilt {
overflow: visible;
transform: perspective(600px)
rotateX(var(--pp-tilt-x, 0deg)) rotateY(var(--pp-tilt-y, 0deg))
rotate(var(--pp-rot));
transition: transform 0.12s ease;
}
.pp-tilt::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background: linear-gradient(105deg,
transparent calc(var(--pp-glint-x, 50%) - 14%),
rgba(255, 255, 255, 0.16) var(--pp-glint-x, 50%),
transparent calc(var(--pp-glint-x, 50%) + 14%));
opacity: 0;
transition: opacity 0.2s ease;
pointer-events: none;
}
.pp-tilt:hover::after { opacity: 1; }
/* Emerging-stamp ink: the ghost fills as qualifying songs land. */
.pp-stamp-ghost::before {
content: '';
position: absolute;
inset: 7%;
border-radius: 999px;
background: conic-gradient(rgba(154, 91, 22, 0.16) var(--pp-fill, 0%), transparent 0);
pointer-events: none;
}
/* Gold ink — a REAL gold badge (comb-verified improv). */
.pp-stamp-gold {
border-color: #b8860b;
color: #a97b1b;
box-shadow: inset 0 0 0 3px #f3e8c8, inset 0 0 0 4px #b8860b;
}
.pp-stamp-mini.pp-stamp-gold {
color: #f0c75e;
border-color: #f0c75e;
box-shadow: none;
}
/* Gold foil chip — rendered only alongside an earned gold stamp. */
.pp-gold-foil {
position: relative;
overflow: hidden;
display: inline-flex;
align-items: center;
justify-content: center;
margin-top: 0.9rem;
padding: 0.28rem 0.85rem;
border-radius: 999px;
border: 2px solid #d9a253;
color: #c89040;
font-size: 0.58rem;
font-weight: 700;
letter-spacing: 0.32em;
}
.pp-gold-foil::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(100deg, transparent 40%, rgba(255, 223, 128, 0.35) 50%, transparent 60%);
transform: translateX(-120%);
animation: pp-foil 3.4s ease-in-out infinite;
}
@keyframes pp-foil {
0%, 55% { transform: translateX(-120%); }
100% { transform: translateX(120%); }
}
@media (prefers-reduced-motion: reduce) {
.pp-gold-foil::after { animation: none; }
.pp-cover.pp-tilt, .pp-stamp-page.pp-tilt { transition: none; }
/* The hover glint is motion theatrics too — not just the JS tilt. */
.pp-tilt::after { display: none; }
}
/* Practice invitations — closest stamps + bring-these-up */
.pp-closest {
border: 1px solid rgba(75, 85, 99, 0.45);
border-radius: 0.6rem;
background: linear-gradient(165deg, rgba(45, 55, 72, 0.4), rgba(31, 41, 55, 0.4));
padding: 0.6rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.pp-closest-head {
font-size: 0.62rem;
letter-spacing: 0.22em;
text-transform: uppercase;
color: #9ca3af;
}
.pp-closest-row {
display: flex;
align-items: baseline;
gap: 0.75rem;
text-align: left;
font-size: 0.8rem;
padding: 0.15rem 0.25rem;
border-radius: 0.35rem;
}
.pp-closest-row:hover { background: rgba(55, 65, 81, 0.5); }
.pp-closest-genre { color: #e5e7eb; font-weight: 600; white-space: nowrap; }
.pp-closest-ask { color: #9ca3af; font-size: 0.72rem; }
.pp-closest-ask em { color: #cbd5e1; font-style: italic; }
.pp-nearest { margin-top: 0.6rem; border-top: 1px dashed rgba(138, 122, 94, 0.4); padding-top: 0.5rem; }
.pp-nearest-head {
font-size: 0.58rem;
letter-spacing: 0.22em;
text-transform: uppercase;
color: #8a7a5e;
margin-bottom: 0.25rem;
}
.pp-nearest-row { font-size: 0.7rem; color: #6d5d40; padding: 0.1rem 0; }
.pp-nearest-row em { color: #3f3428; }
/* ── Career surfaces outside the plugin: profile wall + home card ───────── */
.pp-wall { display: flex; flex-direction: column; gap: 0.6rem; }
.pp-wall-head {
display: flex;
align-items: baseline;
justify-content: space-between;
font-weight: 600;
color: #e5e7eb;
font-size: 0.9rem;
}
.pp-wall-meta { color: #9ca3af; font-size: 0.7rem; font-weight: 400; }
.pp-wall-shelf {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.35rem 0;
border-bottom: 1px solid rgba(75, 85, 99, 0.25);
}
.pp-wall-inst {
font-size: 0.62rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: #6b7280;
min-width: 3.6rem;
}
.pp-wall-cover {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.1rem;
width: 4.2rem;
height: 5.6rem;
border-radius: 0.3rem 0.45rem 0.45rem 0.3rem;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.07),
inset 0.25rem 0 0.4rem -0.25rem rgba(0, 0, 0, 0.8),
0 3px 8px rgba(0, 0, 0, 0.4);
padding: 0.3rem;
transition: transform 0.15s ease;
}
.pp-wall-cover:hover { transform: translateY(-3px); }
.pp-wall-cover span {
font-size: 0.5rem;
font-weight: 700;
letter-spacing: 0.1em;
color: rgba(240, 226, 195, 0.9);
overflow-wrap: anywhere;
text-align: center;
}
.pp-wall-cover em {
font-size: 0.42rem;
letter-spacing: 0.22em;
font-style: normal;
color: #d9a253;
}
.pp-wall-none { font-size: 0.7rem; color: #6b7280; font-style: italic; }
.pp-wall-link {
align-self: flex-end;
font-size: 0.72rem;
color: #22d3ee;
padding: 0.15rem 0.3rem;
}
.pp-wall-link:hover { text-decoration: underline; }
/* The home-page career card — a trading card among stat tiles. */
.pp-dash-card {
position: relative;
overflow: hidden;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.2rem;
text-align: left;
padding: 1rem;
border-radius: 0.5rem;
border: 1px solid rgba(217, 162, 83, 0.35);
background:
linear-gradient(135deg, rgba(92, 35, 33, 0.85), rgba(30, 27, 34, 0.92)),
linear-gradient(160deg, #2b1414, #17111c);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.05), 0 4px 14px rgba(0, 0, 0, 0.35);
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.pp-dash-card:hover { transform: translateY(-2px); box-shadow: 0 8px 20px rgba(0, 0, 0, 0.5); }
.pp-dash-shine {
position: absolute;
inset: 0;
background: linear-gradient(105deg, transparent 42%, rgba(255, 223, 128, 0.18) 50%, transparent 58%);
transform: translateX(-130%);
pointer-events: none;
}
.pp-dash-card:hover .pp-dash-shine { animation: pp-foil 1.4s ease-out; }
.pp-dash-head {
font-size: 0.58rem;
letter-spacing: 0.3em;
text-transform: uppercase;
color: #d9a253;
}
.pp-dash-badges { color: #f3ead2; font-size: 1.05rem; }
.pp-dash-badges b { font-weight: 700; margin: 0 0.25rem 0 0.35rem; }
.pp-dash-meta { color: #b5a488; font-size: 0.72rem; }
.pp-dash-ask { color: #8d9aa8; font-size: 0.66rem; }
.pp-dash-ask em { color: #cbd5e1; }
.pp-card-actions { display: flex; gap: 0.5rem; margin-top: 0.9rem; }
@media (prefers-reduced-motion: reduce) {
.pp-dash-card:hover .pp-dash-shine { animation: none; }
.pp-wall-cover, .pp-dash-card { transition: none; }
}
/* ── Gigs: poster, runner strip, summary, log ───────────────────────────── */
.pp-poster {
position: relative;
width: min(92vw, 420px);
padding: 2rem 1.6rem 1.4rem;
border-radius: 0.5rem;
background: linear-gradient(180deg, #141019, #241318);
border: 2px solid rgba(217, 162, 83, 0.45);
box-shadow: 0 10px 32px rgba(0, 0, 0, 0.6);
display: flex;
flex-direction: column;
align-items: center;
gap: 0.35rem;
text-align: center;
}
.pp-poster-venue { color: rgba(240, 226, 195, 0.7); font-size: 0.95rem; letter-spacing: 0.08em; }
.pp-poster-presents { color: rgba(240, 226, 195, 0.4); font-size: 0.58rem; letter-spacing: 0.4em; text-transform: uppercase; }
.pp-poster-title {
color: #d9a253;
font-size: 1.7rem;
font-weight: 800;
letter-spacing: 0.1em;
line-height: 1.15;
overflow-wrap: anywhere;
}
.pp-poster-inst { color: rgba(240, 226, 195, 0.5); font-size: 0.68rem; letter-spacing: 0.2em; text-transform: uppercase; }
.pp-poster-bill { margin: 0.9rem 0 0.5rem; display: flex; flex-direction: column; gap: 0.35rem; width: 100%; }
.pp-poster-line { color: rgba(240, 226, 195, 0.85); font-size: 0.85rem; }
.pp-poster-line span { color: rgba(217, 162, 83, 0.7); margin-right: 0.35rem; }
.pp-poster-line em { color: rgba(240, 226, 195, 0.5); font-style: italic; font-size: 0.72rem; }
.pp-poster-line b { color: #f3d179; margin-left: 0.3rem; }
.pp-poster-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; justify-content: center; margin-top: 0.6rem; }
.pp-poster-summary { cursor: default; }
.pp-gig-strip {
position: fixed;
top: 0.5rem;
left: 50%;
transform: translateX(-50%);
z-index: 35; /* above the rail (30), under popovers (40) — the chrome invariant */
background: rgba(10, 8, 14, 0.85);
border: 1px solid rgba(217, 162, 83, 0.4);
border-radius: 999px;
color: rgba(240, 226, 195, 0.85);
font-size: 0.72rem;
padding: 0.3rem 0.9rem;
pointer-events: none;
backdrop-filter: blur(2px);
}
.pp-gig-strip b { color: #d9a253; letter-spacing: 0.2em; }
.pp-gig-strip em { color: #f3ead2; font-style: italic; }
.pp-giglog { margin-top: 0.6rem; border-top: 1px dashed rgba(138, 122, 94, 0.4); padding-top: 0.5rem; }
.pp-giglog-head {
font-size: 0.58rem;
letter-spacing: 0.22em;
text-transform: uppercase;
color: #8a7a5e;
margin-bottom: 0.25rem;
}
.pp-giglog-row { font-size: 0.7rem; color: #6d5d40; padding: 0.1rem 0; }
.pp-giglog-row b { color: #9a5b16; letter-spacing: 0.06em; }
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+43
View File
@@ -0,0 +1,43 @@
{
"badge_requirement": {
"songs": 5,
"min_stars": 2
},
"gig": {
"min_songs": 3,
"max_songs": 5,
"stakes_songs": 2,
"encore_accuracy": 0.75
},
"families": [
{ "key": "metal", "match": ["metal", "djent", "grindcore", "thrash", "doom"] },
{ "key": "blues", "match": ["blues"] },
{ "key": "jazz", "match": ["jazz", "bebop", "swing", "bossa"] },
{ "key": "funk", "match": ["funk", "disco"] },
{ "key": "rock", "match": ["rock", "punk", "grunge", "shoegaze"] }
],
"genres": {
"blues": { "virtuoso_nodes": { "guitar": ["blues_shuffle"] } },
"rock": { "virtuoso_nodes": { "guitar": ["rock_power_backbeat"] } },
"metal": { "virtuoso_nodes": { "guitar": ["melodic_metal_gallop"] } },
"funk": { "virtuoso_nodes": { "guitar": ["sixteenth_pocket"] } },
"jazz": { "virtuoso_nodes": { "guitar": ["vl_shells"] } }
},
"drill_labels": {
"blues_shuffle": "Blues Shuffle",
"rock_power_backbeat": "Power Chords & Backbeat",
"melodic_metal_gallop": "Gallop Picking",
"sixteenth_pocket": "16th Pocket",
"vl_shells": "Shell Voicings"
},
"graded_instruments": [
"guitar",
"keys"
],
"instruments": [
"guitar",
"bass",
"keys",
"drums"
]
}
+8 -2
View File
@@ -1,12 +1,18 @@
{
"id": "career",
"name": "Career",
"version": "0.1.0",
"version": "0.2.0",
"bundled": true,
"private": false,
"description": "Career mode — gig your way from a local bar to the arena. Earn stars per song; the crowd reacts to how you play.",
"description": "Career mode — gig your way from a local bar to the arena, and build a passport wall of genre badges per instrument. Earn stars per song; the crowd reacts to how you play.",
"screen": "screen.html",
"script": "screen.js",
"styles": "assets/career.css",
"settings": {
"html": "settings.html",
"server_files": [
"career/"
]
},
"routes": "routes.py"
}
+629 -3
View File
@@ -10,27 +10,44 @@ the plugin under ``venue-packs/<id>/`` or downloaded on demand into
``CONFIG_DIR/plugin_uploads/career/venues/<id>/``. Downloaded packs override
bundled packs so release assets can replace a built-in starter venue.
Passports (badge journey per instrument × genre the identity layer on top
of the same stars): badges are COMPUTED on read from ``song_stats`` × the
library's effective genre, never stored. The only persisted career state is
what cannot be derived instrument commitment, opened passports, and the
relayed virtuoso drill snapshot as JSON under ``CONFIG_DIR/career/``
(exported via ``settings.server_files``).
Endpoints (all under /api/plugins/career/):
GET /state stars + per-venue unlock/install/download status
POST /packs/{venue_id}/download start background pack download (409 if running)
DELETE /packs/{venue_id} remove an installed pack
GET /venues/{venue_id}/{filename} serve pack files (manifest.json, loops, stingers)
GET /passports passport walls: badges, stubs, genres, drill status
POST /passports/commit commit to an instrument (the wax seal, Stage 0)
POST /passports/open open a genre passport for an instrument
POST /drill-state relayed virtuoso.progress snapshot (drill intake)
POST /gigs/propose build a playable setlist for a genre gig
POST /gigs log a COMPLETED gig (abandoned sets never log)
"""
import hashlib
import json
import logging
import random
import re
import shutil
import tempfile
import threading
import urllib.request
import zipfile
from datetime import datetime, timezone
from pathlib import Path
from fastapi import HTTPException
from fastapi import Body, HTTPException
from fastapi.responses import FileResponse
from progression import instrument_for_arrangement
PLUGIN_ID = "career"
VENUE_ID_RE = re.compile(r"^[a-z0-9_-]{1,40}$")
PACK_FILENAME_RE = re.compile(r"^[a-z0-9_-]{1,64}\.(mp4|webm|mp3|json)$")
@@ -100,10 +117,9 @@ def _stars():
detail = []
for filename, acc, title, artist in rows:
acc = acc or 0.0
stars = sum(1 for t in thresholds if acc >= t)
stars, next_at = _star_progress(acc, thresholds)
if stars:
per_song[filename] = stars
next_at = next((t for t in thresholds if acc < t), None)
detail.append({
"filename": filename,
"title": title or filename,
@@ -118,6 +134,412 @@ def _stars():
return sum(per_song.values()), per_song, detail
# ── Passports ─────────────────────────────────────────────────────────────────
GENRE_MAX_LEN = 64
DRILL_SNAPSHOT_MAX_BYTES = 256 * 1024
def _now_iso():
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def _genre_display(genre):
return " ".join(str(genre or "").strip().split())
def _genre_key(genre):
return _genre_display(genre).lower()
def _state_file() -> Path:
return _state["state_dir"] / "passports-state.json"
def _drill_file() -> Path:
return _state["state_dir"] / "drill-state.json"
def _load_json(path: Path, default):
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return default
def _save_json(path: Path, obj):
tmp = path.with_name(path.name + ".tmp")
tmp.write_text(json.dumps(obj, indent=2), encoding="utf-8")
tmp.replace(path)
def _career_state():
st = _load_json(_state_file(), {})
if not isinstance(st, dict):
st = {}
if not isinstance(st.get("instruments"), dict):
st["instruments"] = {}
if not isinstance(st.get("passports"), dict):
st["passports"] = {}
return st
def _genre_expr(db):
# Reuse the host's override-aware effective-genre SQL (Fix-metadata popup
# overrides); plain `genre` on stand-ins that don't implement it.
fn = getattr(db, "_effective_genre_expr", None)
return fn() if callable(fn) else "genre"
def _instrument_of(arrangements, arrangement):
"""Progression's arrangement→instrument mapping, via the song_stats
arrangement index into the song's arrangements JSON."""
entry = None
try:
idx = int(arrangement)
if isinstance(arrangements, list) and 0 <= idx < len(arrangements):
entry = arrangements[idx]
except (TypeError, ValueError):
entry = None
return instrument_for_arrangement(entry)
def _played_by_instrument_genre():
"""((instrument, genre_key) → {filename: stub dict},
(instrument, genre_key) total played seconds).
Best accuracy per (instrument, song); seconds sum across every
arrangement row; the JOIN keeps the same dead-song filter as _stars()."""
db = _state["meta_db"]
if db is None:
return {}, {}
thresholds = _state["content"]["star_accuracy_thresholds"]
rows = db.conn.execute(
"SELECT s.filename, s.arrangement, s.best_accuracy, s.last_played_at, "
" s.seconds_total, songs.title, songs.artist, songs.arrangements, "
f" {_genre_expr(db)} "
"FROM song_stats s JOIN songs ON songs.filename = s.filename"
).fetchall()
arrs_cache = {}
out = {}
seconds = {}
for filename, arrangement, acc, played_at, secs, title, artist, arrs_json, genre in rows:
gkey = _genre_key(genre)
if not gkey:
continue
if filename not in arrs_cache:
try:
arrs_cache[filename] = json.loads(arrs_json) if arrs_json else None
except (TypeError, ValueError):
arrs_cache[filename] = None
instrument = _instrument_of(arrs_cache[filename], arrangement)
key = (instrument, gkey)
seconds[key] = seconds.get(key, 0.0) + (secs or 0.0)
acc = acc or 0.0
stub = out.setdefault(key, {}).get(filename)
if stub is None:
out[key][filename] = {
"filename": filename,
"title": title or filename,
"artist": artist or "",
"best_accuracy": acc,
"last_played_at": played_at,
}
else:
stub["best_accuracy"] = max(stub["best_accuracy"], acc)
stub["last_played_at"] = max(stub["last_played_at"] or "", played_at or "") or None
for stubs in out.values():
for stub in stubs.values():
acc = stub["best_accuracy"]
stub["best_accuracy"] = round(acc, 4)
stub["stars"], stub["next_star_at"] = _star_progress(acc, thresholds)
return out, seconds
def _star_progress(acc, thresholds):
"""(stars, next_star_at) — the one place the ascending-thresholds
assumption lives; _stars() and the passport stubs both use it."""
stars = sum(1 for t in thresholds if acc >= t)
next_at = next((t for t in thresholds if acc < t), None)
return stars, next_at
def _library_genres():
"""Distinct effective genres across the live library (the brochure rack)."""
db = _state["meta_db"]
if db is None:
return []
rows = db.conn.execute(
f"SELECT {_genre_expr(db)} AS g, COUNT(*) FROM songs GROUP BY g").fetchall()
by_key = {}
for genre, count in rows:
display = _genre_display(genre)
key = display.lower()
if not key:
continue
cur = by_key.get(key)
if cur: # case-variant duplicates collapse onto the first-seen casing
cur["songs_in_library"] += count
else:
by_key[key] = {"genre_key": key, "genre": display,
"songs_in_library": count}
return sorted(by_key.values(),
key=lambda r: (-r["songs_in_library"], r["genre_key"]))
def _genre_family(gkey):
"""First family whose keyword appears in the genre key (substring — MB's
vocabulary is open: 'metalcore' must hit the 'metal' family without an
exact alias). List order decides ambiguity: families are checked top to
bottom, so 'blues rock' lands on whichever of blues/rock is listed first."""
for fam in _state["passports_content"].get("families") or []:
if not isinstance(fam, dict):
continue
for kw in fam.get("match") or []:
if isinstance(kw, str) and kw and kw in gkey:
return fam.get("key")
return None
def _badge_requirement(gkey, instrument="guitar"):
cfg = _state["passports_content"]
req = dict(cfg.get("badge_requirement") or {})
req.setdefault("songs", 5)
req.setdefault("min_stars", 2)
# Exact per-genre override wins; otherwise the genre inherits its FAMILY's
# requirement — so 'death metal' / 'metalcore' passports carry the metal
# drill without curating every MB sub-genre by hand.
genres_cfg = cfg.get("genres") or {}
override = genres_cfg.get(gkey)
if not isinstance(override, dict):
family = _genre_family(gkey)
override = genres_cfg.get(family) if family else None
if isinstance(override, dict):
req.update(override)
# virtuoso_nodes: {instrument: [node_ids]} — a passport only carries its
# own instrument's drills. A flat list keeps meaning guitar (back-compat;
# virtuoso's drill content is guitar-first).
nodes = req.get("virtuoso_nodes") or []
if isinstance(nodes, dict):
nodes = nodes.get(instrument) or []
elif instrument != "guitar":
nodes = []
req["virtuoso_nodes"] = [n for n in nodes if isinstance(n, str)]
return req
def _drill_by_node():
doc = _load_json(_drill_file(), {})
if not isinstance(doc, dict):
return None, {}, {}
snapshot = doc.get("snapshot") if isinstance(doc.get("snapshot"), dict) else {}
by_node = snapshot.get("byNode") if isinstance(snapshot.get("byNode"), dict) else {}
gold = snapshot.get("goldImprov") if isinstance(snapshot.get("goldImprov"), dict) else {}
return doc.get("received_at"), by_node, gold
def _merge_drill_nodes(old, new):
"""Gained-only merge of virtuoso byNode snapshots: a completion artifact
once relayed never un-earns via a stale snapshot (multi-browser races,
settings import, the once-per-session boot relay). Incoming wins the
descriptive fields; masteredAt / depth flips / keysCleared only grow."""
out = dict(old)
for node_id, incoming in new.items():
if not isinstance(incoming, dict):
continue
cur = out.get(node_id)
if not isinstance(cur, dict):
out[node_id] = incoming
continue
merged = dict(cur)
merged.update(incoming)
merged["masteredAt"] = cur.get("masteredAt") or incoming.get("masteredAt")
d_old = cur.get("depth") if isinstance(cur.get("depth"), dict) else {}
d_new = incoming.get("depth") if isinstance(incoming.get("depth"), dict) else {}
depth = dict(d_new)
for axis, val in d_old.items():
if val and not depth.get(axis):
depth[axis] = val
if depth:
merged["depth"] = depth
keys_old = cur.get("keysCleared") if isinstance(cur.get("keysCleared"), list) else []
keys_new = incoming.get("keysCleared") if isinstance(incoming.get("keysCleared"), list) else []
merged["keysCleared"] = keys_old + [k for k in keys_new if k not in keys_old]
out[node_id] = merged
return out
def _merge_gold(old, new):
"""Gained-only merge of goldImprov artifacts: a minted style never
un-mints via a stale relay; the FIRST artifact per style is kept."""
out = dict(old)
for style_id, art in (new or {}).items():
if isinstance(art, dict) and style_id not in out:
out[style_id] = art
return out
def _node_cleared(by_node, node_id):
"""A drill counts as cleared on real completion evidence: mastered, any
depth rung flipped true, or a key cleared (a top-tier clean pass in one
key virtuoso's first gained-only artifact, and an achievable Bronze
bar; the depth rungs additionally require a maxed speed tier)."""
entry = by_node.get(node_id)
if not isinstance(entry, dict):
return False
depth = entry.get("depth") if isinstance(entry.get("depth"), dict) else {}
keys = entry.get("keysCleared")
return (bool(entry.get("masteredAt"))
or any(bool(v) for v in depth.values())
or bool(isinstance(keys, list) and keys))
def _passports_view():
cfg = _state["passports_content"]
graded = set(cfg.get("graded_instruments") or [])
st = _career_state()
all_gigs = st.get("gigs") if isinstance(st.get("gigs"), list) else []
played, played_seconds = _played_by_instrument_genre()
received_at, by_node, gold_improv = _drill_by_node()
instruments = {}
for inst in cfg.get("instruments") or []:
committed_at = (st["instruments"].get(inst) or {}).get("committed_at")
opened = st["passports"].get(inst)
opened = opened if isinstance(opened, dict) else {}
passports = []
for gkey, meta in sorted(opened.items(),
key=lambda kv: ((kv[1] or {}).get("opened_at") or "", kv[0])):
meta = meta if isinstance(meta, dict) else {}
req = _badge_requirement(gkey, inst)
songs = list(played.get((inst, gkey), {}).values())
for s in songs:
s["qualifies"] = s["stars"] >= req["min_stars"]
songs.sort(key=lambda s: (not s["qualifies"], -s["stars"],
s["title"].lower()))
qualifying = sum(1 for s in songs if s["qualifies"])
required = req["virtuoso_nodes"]
cleared = [n for n in required if _node_cleared(by_node, n)]
is_graded = inst in graded
if not is_graded:
# Where the engine can't fairly grade the instrument's job
# (bass pocket, feel) the passport shows repertoire, never a
# false badge denial — the doc's shown-not-judged rule.
badge = "shown_not_judged"
elif qualifying >= req["songs"] and len(cleared) == len(required):
# Bronze is earned; GOLD upgrades it when a verified improv
# artifact exists for this genre's jam style. Virtuoso mints
# under raw STYLE_PALETTES ids ('punk', 'djent', 'disco', ...),
# which are mostly NOT family keys — so match in family space:
# the same keyword bucketing genres get ('punk' and 'punk
# rock' both bucket to 'rock'), with the exact key as a direct
# hit. Bronze remains a standalone win; gold never becomes an
# obligation.
fam = _genre_family(gkey)
gold = any(
s == gkey or (fam is not None and _genre_family(s) == fam)
for s in gold_improv
)
badge = "gold" if gold else "earned"
else:
badge = "in_progress"
# Practice invitation: the non-qualifying songs closest to the
# QUALIFYING bar (the badge ask), nearest first — invitation
# data, the UI voices it without meters.
thresholds = _state["content"]["star_accuracy_thresholds"]
bar = (thresholds[req["min_stars"] - 1]
if 0 < req["min_stars"] <= len(thresholds) else None)
nearest = [] if bar is None else sorted(
(s for s in songs if not s["qualifies"]),
key=lambda s: bar - s["best_accuracy"])[:3]
for s in nearest:
s["bar_at"] = bar
passports.append({
"genre_key": gkey,
"genre": meta.get("genre") or gkey,
"opened_at": meta.get("opened_at"),
"requirement": req,
"graded": is_graded,
"songs": songs,
"qualifying_count": qualifying,
"nearest": nearest,
# Honest hours odometer (Stage 5 post-cap): a true fact that
# only grows — never a target, never a meter.
"seconds_total": round(played_seconds.get((inst, gkey), 0.0), 1),
"drills": {"required": required, "cleared": cleared},
"badge": badge,
})
inst_gigs = [g for g in all_gigs if g.get("instrument") == inst]
for p in passports:
p["gigs"] = [g for g in inst_gigs if g.get("genre_key") == p["genre_key"]][-20:][::-1]
instruments[inst] = {"committed_at": committed_at, "passports": passports,
"gig_count": len(inst_gigs)}
return {
"config": {
"badge_requirement": cfg.get("badge_requirement") or {},
"graded_instruments": sorted(graded),
"instruments": list(cfg.get("instruments") or []),
# Career-side display names for virtuoso drill node ids.
"drill_labels": dict(cfg.get("drill_labels") or {}),
},
"instruments": instruments,
"genres": _library_genres(),
"drill_state": {"received_at": received_at},
}
def _gig_config():
cfg = _state["passports_content"].get("gig")
cfg = cfg if isinstance(cfg, dict) else {}
def _num(key, default, cast):
# Tuning data, not code: junk falls back instead of 500ing both gig
# endpoints, and a legitimate 0 (stakes_songs: 0) is respected.
val = cfg.get(key)
if isinstance(val, bool) or not isinstance(val, (int, float)):
return default
return cast(val)
return {
"min_songs": max(1, _num("min_songs", 3, int)),
"max_songs": max(1, _num("max_songs", 5, int)),
"stakes_songs": max(0, _num("stakes_songs", 2, int)),
"encore_accuracy": _num("encore_accuracy", 0.75, float),
}
def _current_venue():
"""Highest unlocked venue (the room you can book today)."""
stars_total, _, _ = _stars()
best = None
for v in _state["content"]["venues"]:
if stars_total >= v["star_threshold"]:
if best is None or v["star_threshold"] >= best["star_threshold"]:
best = v
return best
def _unplayed_genre_songs(gkey, exclude, limit):
"""Library songs of a genre with no stats yet — a young passport's gig
still gets a full set (playing them is how stubs start).
ponytail: full stat-less scan + python-side genre match (a few ms at 7k
songs, single-user); push the match into SQL if propose ever feels slow."""
db = _state["meta_db"]
if db is None:
return []
rows = db.conn.execute(
f"SELECT filename, title, artist, {_genre_expr(db)} AS g FROM songs "
"WHERE filename NOT IN (SELECT filename FROM song_stats)"
).fetchall()
out = []
for filename, title, artist, genre in rows:
if _genre_key(genre) != gkey or filename in exclude:
continue
out.append({"filename": filename, "title": title or filename,
"artist": artist or ""})
if len(out) >= limit:
break
return out
def _validate_pack_dir(pack_dir: Path):
"""Raise ValueError unless pack_dir holds a complete venue pack."""
manifest_path = pack_dir / "manifest.json"
@@ -197,6 +619,13 @@ def setup(app, context):
_state["venues_dir"] = (
Path(context["config_dir"]) / "plugin_uploads" / PLUGIN_ID / "venues")
_state["venues_dir"].mkdir(parents=True, exist_ok=True)
_state["passports_content"] = json.loads(
(plugin_dir / "passports.json").read_text(encoding="utf-8"))
# Persisted career state (commitment / opened passports / drill snapshot)
# lives under CONFIG_DIR/career/ — declared in settings.server_files so it
# rides the settings export/import bundle. Packs stay out (they're media).
_state["state_dir"] = Path(context["config_dir"]) / PLUGIN_ID
_state["state_dir"].mkdir(parents=True, exist_ok=True)
_state["meta_db"] = context.get("meta_db")
_state["log"] = context.get("log") or _state["log"]
for v in _state["content"]["venues"]:
@@ -229,6 +658,203 @@ def setup(app, context):
"venues": venues,
}
@app.get(f"/api/plugins/{PLUGIN_ID}/passports")
def get_passports():
with _lock:
return _passports_view()
@app.post(f"/api/plugins/{PLUGIN_ID}/passports/commit")
def commit_instrument(body: dict = Body(...)):
inst = str((body or {}).get("instrument") or "")
if inst not in (_state["passports_content"].get("instruments") or []):
raise HTTPException(400, "Unknown instrument.")
with _lock:
st = _career_state()
entry = st["instruments"].setdefault(inst, {})
# Idempotent: the wax seal is pressed once; re-commits keep the
# original date (only-gained-never-lost).
if not entry.get("committed_at"):
entry["committed_at"] = _now_iso()
_save_json(_state_file(), st)
return {"ok": True, "instrument": inst,
"committed_at": entry["committed_at"]}
@app.post(f"/api/plugins/{PLUGIN_ID}/passports/open")
def open_passport(body: dict = Body(...)):
inst = str((body or {}).get("instrument") or "")
genre = _genre_display((body or {}).get("genre"))
gkey = genre.lower()
if inst not in (_state["passports_content"].get("instruments") or []):
raise HTTPException(400, "Unknown instrument.")
if not gkey or len(genre) > GENRE_MAX_LEN:
raise HTTPException(400, "Provide a genre.")
with _lock:
st = _career_state()
# Opening a passport implies the instrument commitment (permissive
# server, ceremony ordering is the UI's job).
st["instruments"].setdefault(inst, {}).setdefault(
"committed_at", _now_iso())
genres = st["passports"].setdefault(inst, {})
if gkey not in genres:
genres[gkey] = {"genre": genre, "opened_at": _now_iso()}
_save_json(_state_file(), st)
return {"ok": True, "instrument": inst, "passport": genres[gkey]}
@app.post(f"/api/plugins/{PLUGIN_ID}/drill-state")
def post_drill_state(body: dict = Body(...)):
# The relayed virtuoso.progress snapshot (career's screen.js listens to
# the virtuoso:progress bus event and forwards the localStorage doc).
# Only the fields the badge check reads are kept.
if not isinstance(body, dict) or not isinstance(body.get("byNode"), dict):
raise HTTPException(400, "Expected a progress snapshot with byNode.")
# Bound the INCOMING snapshot before the merge — the gained-only merge
# drops junk entries, which must not become a size-guard bypass.
if len(json.dumps(body["byNode"])) > DRILL_SNAPSHOT_MAX_BYTES:
raise HTTPException(413, "Snapshot too large.")
gold_in = body.get("goldImprov", {})
if not isinstance(gold_in, dict):
# A relay bug must be LOUD, not a silent 200 that drops gold.
raise HTTPException(400, "goldImprov must be an object keyed by style id.")
# Keep only plausible artifacts: a dict that names its verifier —
# an empty {} must not mint an evidence-free gold.
gold_in = {k: v for k, v in gold_in.items()
if isinstance(v, dict) and v.get("verifier")}
# Same pre-merge bound byNode gets: the gained-only merge dropping
# junk must not become a size-guard bypass (nor lock-held CPU burn).
if len(json.dumps(gold_in)) > DRILL_SNAPSHOT_MAX_BYTES:
raise HTTPException(413, "Snapshot too large.")
with _lock:
_, existing, existing_gold = _drill_by_node()
snapshot = {"mode": body.get("mode"), "xp": body.get("xp"),
"byNode": _merge_drill_nodes(existing, body["byNode"]),
"goldImprov": _merge_gold(existing_gold, gold_in)}
if len(json.dumps(snapshot)) > DRILL_SNAPSHOT_MAX_BYTES:
raise HTTPException(413, "Snapshot too large.")
_save_json(_drill_file(), {"received_at": _now_iso(),
"snapshot": snapshot})
return {"ok": True}
@app.post(f"/api/plugins/{PLUGIN_ID}/gigs/propose")
def propose_gig(body: dict = Body(...)):
inst = str((body or {}).get("instrument") or "")
genre = _genre_display((body or {}).get("genre"))
gkey = genre.lower()
if inst not in (_state["passports_content"].get("instruments") or []):
raise HTTPException(400, "Unknown instrument.")
if not gkey or len(genre) > GENRE_MAX_LEN:
raise HTTPException(400, "Provide a genre.")
cfg = _gig_config()
try:
size = int((body or {}).get("size") or 4)
except (TypeError, ValueError):
raise HTTPException(400, "size must be a number.")
size = max(cfg["min_songs"], min(cfg["max_songs"], size))
played, _seconds = _played_by_instrument_genre()
stubs = list(played.get((inst, gkey), {}).values())
req = _badge_requirement(gkey, inst)
qualifying = [s for s in stubs if s["stars"] >= req["min_stars"]]
rest = [s for s in stubs if s["stars"] < req["min_stars"]]
# The set: mostly songs you own, plus a couple of stakes songs near
# the bar; a young passport fills from unplayed genre songs so the
# first gig is how stubs start. random per call = free re-roll.
random.shuffle(qualifying)
rest.sort(key=lambda s: -s["best_accuracy"])
qtaken = max(1, size - cfg["stakes_songs"])
picks = qualifying[:qtaken]
for s in rest:
if len(picks) >= size:
break
picks.append(s)
# Surplus qualifying songs backfill a short set — a mature passport
# with no near-bar songs left must still fill the bill. Offset by how
# many QUALIFYING songs were taken, not len(picks): rest's stakes
# additions would otherwise skip eligible qualifying songs entirely.
for s in qualifying[qtaken:]:
if len(picks) >= size:
break
picks.append(s)
if len(picks) < size:
exclude = {s["filename"] for s in picks}
picks.extend(_unplayed_genre_songs(gkey, exclude, size - len(picks)))
if not picks:
raise HTTPException(404, "No songs of this genre in the library.")
venue = _current_venue()
return {
"instrument": inst,
"genre": genre,
"genre_key": gkey,
"venue_id": venue["id"] if venue else None,
"venue_name": venue["name"] if venue else "",
"songs": [{"filename": s["filename"], "title": s.get("title") or s["filename"],
"artist": s.get("artist") or ""} for s in picks[:size]],
}
@app.post(f"/api/plugins/{PLUGIN_ID}/gigs")
def log_gig(body: dict = Body(...)):
# Called by the runner ONLY when the set completed — an abandoned set
# never logs (no fail state; the gig you finished is the gig you
# played). Accuracies come from song_stats, freshly written by the
# set's own plays.
inst = str((body or {}).get("instrument") or "")
genre = _genre_display((body or {}).get("genre"))
gkey = genre.lower()
venue_id = str((body or {}).get("venue_id") or "")
songs = (body or {}).get("songs")
if inst not in (_state["passports_content"].get("instruments") or []):
raise HTTPException(400, "Unknown instrument.")
if not gkey or len(genre) > GENRE_MAX_LEN:
raise HTTPException(400, "Provide a genre.")
if venue_id and (not VENUE_ID_RE.fullmatch(venue_id) or _venue(venue_id) is None):
raise HTTPException(400, "Unknown venue.")
if (not isinstance(songs, list) or not songs or len(songs) > 8
or not all(isinstance(f, str) and f.strip() for f in songs)):
raise HTTPException(400, "songs must be 1-8 filenames.")
db = _state["meta_db"]
entries = []
accuracies = []
for filename in songs:
title = filename
accuracy = None
if db is not None:
# The NEWEST row is the set's own just-recorded play — a
# MAX(last_accuracy) across arrangements would happily log a
# stale higher score from another instrument's old session.
row = db.conn.execute(
"SELECT last_accuracy FROM song_stats WHERE filename = ? "
"ORDER BY last_played_at DESC LIMIT 1",
(filename,)).fetchone()
if row and row[0] is not None:
accuracy = round(float(row[0]), 4)
accuracies.append(accuracy)
trow = db.conn.execute(
"SELECT title FROM songs WHERE filename = ?", (filename,)).fetchone()
if trow and trow[0]:
title = trow[0]
entries.append({"filename": filename, "title": title, "accuracy": accuracy})
# Encore needs the WHOLE set scored at the bar — one scored song must
# not earn an encore for a set that was 4/5 unheard.
encore = (len(accuracies) == len(songs) and
sum(accuracies) / len(accuracies) >= _gig_config()["encore_accuracy"])
gig = {
"at": _now_iso(),
"venue_id": venue_id or None,
"instrument": inst,
"genre": genre,
"genre_key": gkey,
"songs": entries,
"encore": encore,
}
with _lock:
st = _career_state()
if not isinstance(st.get("gigs"), list):
st["gigs"] = []
st["gigs"].append(gig)
# ponytail: hard cap — nothing reads past the last 20 per
# passport; the state file must not grow (and export) forever.
st["gigs"] = st["gigs"][-500:]
_save_json(_state_file(), st)
return {"ok": True, "gig": gig}
@app.post(f"/api/plugins/{PLUGIN_ID}/packs/{{venue_id}}/download")
def start_download(venue_id: str):
venue = _venue(venue_id) if VENUE_ID_RE.fullmatch(venue_id) else None
+34 -12
View File
@@ -3,19 +3,41 @@
<h1 class="text-2xl font-bold text-white">Career</h1>
<div id="career-stars-summary" class="text-sm text-gray-400"></div>
</div>
<p class="text-sm text-gray-400 mb-4">Earn stars by playing songs well — 60% accuracy is a star, 75% two, 85% three. Stars unlock bigger stages, and the crowd plays along with you.</p>
<div id="career-progress-wrap" class="mb-6">
<div class="career-bar-track">
<div id="career-progress-bar" class="career-bar-fill" style="width:0%"></div>
</div>
<div id="career-progress-label" class="text-xs text-gray-500 mt-1"></div>
<div class="career-tabs" role="tablist">
<button class="career-tab" data-career-tab="venues" role="tab" id="career-tab-btn-venues" aria-controls="career-tab-venues">Venues</button>
<button class="career-tab" data-career-tab="passports" role="tab" id="career-tab-btn-passports" aria-controls="career-tab-passports">Passports</button>
</div>
<div id="career-venues" class="career-venues"></div>
<div class="mt-8">
<div class="flex items-end justify-between flex-wrap gap-2 mb-2">
<h2 class="text-lg font-semibold text-white">Your star collection</h2>
<div id="career-star-summary" class="text-xs text-gray-400"></div>
<div id="career-tab-venues" role="tabpanel" aria-labelledby="career-tab-btn-venues">
<p class="text-sm text-gray-400 mb-4">Earn stars by playing songs well — 60% accuracy is a star, 75% two, 85% three. Stars unlock bigger stages, and the crowd plays along with you.</p>
<div id="career-progress-wrap" class="mb-6">
<div class="career-bar-track">
<div id="career-progress-bar" class="career-bar-fill" style="width:0%"></div>
</div>
<div id="career-progress-label" class="text-xs text-gray-500 mt-1"></div>
</div>
<div id="career-venues" class="career-venues"></div>
<div class="mt-8">
<div class="flex items-end justify-between flex-wrap gap-2 mb-2">
<h2 class="text-lg font-semibold text-white">Your star collection</h2>
<div id="career-star-summary" class="text-xs text-gray-400"></div>
</div>
<div id="career-star-list" class="career-star-list"></div>
</div>
</div>
<div id="career-tab-passports" class="hidden" role="tabpanel" aria-labelledby="career-tab-btn-passports">
<p class="text-sm text-gray-400 mb-4">Commit to an instrument, pick a genre, and stamp your way to its badge — five ★★ songs mint a Bronze. Your passport wall is who you are as a musician.</p>
<div id="pp-instruments" class="pp-instruments"></div>
<div id="pp-closest" class="mt-4"></div>
<div id="pp-shelf-wrap" class="mt-5">
<div id="pp-shelf" class="pp-shelf"></div>
</div>
<div id="pp-rack-wrap" class="mt-8">
<h2 class="text-lg font-semibold text-white mb-1">Explore next</h2>
<p class="text-xs text-gray-500 mb-3">More genres, whenever you want them — your wall is complete as it is.</p>
<div id="pp-rack" class="pp-rack"></div>
</div>
<div id="career-star-list" class="career-star-list"></div>
</div>
</div>
<div id="pp-overlay" class="pp-overlay hidden"></div>
+1251 -1
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
<!-- Career plugin — data panel. Exists so the passport/drill state declared in
settings.server_files has a visible home in Settings; nothing to configure. -->
<div class="text-sm text-gray-300 space-y-2">
<p><strong>Career</strong> computes stars and genre badges from your play
stats — they are never stored, so there is nothing to back up or reset.</p>
<p class="text-gray-400">What <em>is</em> saved server-side: your instrument
commitments, opened genre passports, and the practice-drill snapshot the
Virtuoso plugin reports. These ride along in
<em>Settings → Export</em> automatically.</p>
</div>
<hr class="border-gray-800 my-3">
<div class="space-y-3 text-sm">
<label class="flex items-center justify-between gap-4">
<span>
<span class="text-gray-200 font-medium">Crowd sound reactions</span>
<span class="block text-xs text-gray-500">Cheers when the crowd's mood rises, boos when it drops. Uses each venue's own recordings.</span>
</span>
<input type="checkbox" id="career-sfx-toggle" class="accent-cyan-500 w-4 h-4">
</label>
</div>
<script>
(function () {
'use strict';
var KEY = 'feedBack-venue-crowd-sfx';
var box = document.getElementById('career-sfx-toggle');
if (!box) return;
try { box.checked = localStorage.getItem(KEY) === 'on'; } catch (e) { /* ok */ }
box.addEventListener('change', function () {
try { localStorage.setItem(KEY, box.checked ? 'on' : 'off'); } catch (e) { /* ok */ }
});
}());
</script>
+249
View File
@@ -0,0 +1,249 @@
// Passport UI pure-logic tests: load screen.js in a bare vm window and
// exercise the __careerPassportTest seam (no DOM beyond stubs, no network).
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
function load(seed) {
const store = Object.assign({}, seed);
const window = {
console,
localStorage: {
getItem: (k) => (k in store ? store[k] : null),
setItem: (k, v) => { store[k] = String(v); },
},
document: {
readyState: 'complete',
getElementById: () => null,
querySelectorAll: () => [],
addEventListener: () => {},
},
notifications: [],
};
window.window = window;
window.globalThis = window;
window.fbNotify = { show: (n) => window.notifications.push(n) };
const context = vm.createContext(window);
// `document` and `localStorage` resolve as bare names inside the IIFE.
context.document = window.document;
context.localStorage = window.localStorage;
const src = fs.readFileSync(path.join(__dirname, '..', 'screen.js'), 'utf8');
vm.runInContext(src, context, { filename: 'career/screen.js' });
return window;
}
test('module loads (and boots) in a bare vm window', () => {
const w = load();
assert.equal(typeof w.__careerPassportTest.ppKey, 'function');
});
test('ppKey normalizes case and whitespace', () => {
const { ppKey } = load().__careerPassportTest;
assert.equal(ppKey(' Blues Rock '), 'blues rock');
assert.equal(ppKey('FUNK'), 'funk');
assert.equal(ppKey(''), '');
assert.equal(ppKey(null), '');
});
test('ppJitter is deterministic and bounded', () => {
const { ppJitter } = load().__careerPassportTest;
assert.equal(ppJitter('blues', 8), ppJitter('blues', 8));
for (const seed of ['blues', 'funk', 'jazz', 'metal']) {
const j = ppJitter(seed, 8);
assert.ok(j >= -8 && j <= 8, `${seed}${j}`);
}
assert.notEqual(ppJitter('blues', 8), ppJitter('funk', 8));
});
test('detectNewBadges notifies once per badge, never after it is seen', () => {
const w = load();
const t = w.__careerPassportTest;
const view = {
instruments: {
guitar: {
passports: [
{ genre_key: 'blues', genre: 'Blues', badge: 'earned' },
{ genre_key: 'funk', genre: 'Funk', badge: 'in_progress' },
],
},
},
};
t.detectNewBadges(view);
assert.equal(w.notifications.length, 1);
assert.match(w.notifications[0].message, /Blues/);
// Same view again in the same session: no duplicate notification.
t.detectNewBadges(view);
assert.equal(w.notifications.length, 1);
// Seen (slam played) → a fresh session stays quiet too.
t.markBadgeSeen('guitar', 'blues');
// JSON-compare: vm objects carry a foreign Object prototype.
assert.equal(JSON.stringify(t.seenBadges()), '{"guitar/blues":1}');
// Fresh session (new vm, empty notify cache) with the badge already seen:
// detection must stay silent.
const w2 = load({ 'feedBack-career-badges-seen': '{"guitar/blues":1}' });
w2.__careerPassportTest.detectNewBadges(view);
assert.equal(w2.notifications.length, 0);
});
test('a new badge triggers the crowd celebrate() exactly once', () => {
const w = load();
let calls = 0;
w.v3VenueCrowd = { celebrate: () => { calls += 1; } };
const view = { instruments: { guitar: { passports: [
{ genre_key: 'blues', genre: 'Blues', badge: 'earned' }] } } };
w.__careerPassportTest.detectNewBadges(view);
assert.equal(calls, 1);
// Same session, same view: no re-celebration.
w.__careerPassportTest.detectNewBadges(view);
assert.equal(calls, 1);
});
test('ceremony degrades when the crowd layer is absent or throws', () => {
const w = load();
const view = { instruments: { guitar: { passports: [
{ genre_key: 'blues', genre: 'Blues', badge: 'earned' }] } } };
// No v3VenueCrowd at all (already exercised elsewhere, explicit here).
w.__careerPassportTest.detectNewBadges(view);
assert.equal(w.notifications.length, 1);
// celebrate() throwing must not break detection.
const w2 = load();
w2.v3VenueCrowd = { celebrate: () => { throw new Error('no pack'); } };
w2.__careerPassportTest.detectNewBadges(view);
assert.equal(w2.notifications.length, 1);
});
test('seenBadges tolerates corrupt stored values', () => {
for (const bad of ['null', '[1,2]', '"x"', '{{{']) {
const w = load({ 'feedBack-career-badges-seen': bad });
const t = w.__careerPassportTest;
assert.equal(JSON.stringify(t.seenBadges()), '{}', `stored ${bad}`);
// And detection still works on top of the recovered empty state.
t.detectNewBadges({ instruments: { guitar: { passports: [
{ genre_key: 'blues', genre: 'Blues', badge: 'earned' }] } } });
assert.equal(w.notifications.length, 1, `stored ${bad}`);
}
});
test('fmtHours: silent under a minute, minutes under an hour, tenths after', () => {
const { fmtHours } = load().__careerPassportTest;
assert.equal(fmtHours(0), '');
assert.equal(fmtHours(59), '');
assert.equal(fmtHours(60), '1 min');
assert.equal(fmtHours(1800), '30 min');
assert.equal(fmtHours(3600), '1 h');
assert.equal(fmtHours(51120), '14.2 h');
assert.equal(fmtHours(null), '');
assert.equal(fmtHours('junk'), '');
});
test('ppFillFraction: song progress toward the bar, in-progress only', () => {
const { ppFillFraction } = load().__careerPassportTest;
const p = (badge, q, songs) => ({ badge, qualifying_count: q, requirement: { songs } });
assert.equal(ppFillFraction(p('in_progress', 3, 5)), 0.6);
assert.equal(ppFillFraction(p('in_progress', 0, 5)), 0);
assert.equal(ppFillFraction(p('in_progress', 7, 5)), 1); // clamped
assert.equal(ppFillFraction(p('earned', 5, 5)), 0); // no fill once earned
assert.equal(ppFillFraction(p('shown_not_judged', 3, 5)), 0);
assert.equal(ppFillFraction(p('in_progress', 3, 0)), 0); // no bar → no fill
assert.equal(ppFillFraction(null), 0);
});
test('careerTotals / wall + dash card stay absent without commitment', () => {
const w = load();
const t = w.__careerPassportTest;
// No _pp at all → null; committed-less view → null (absent-not-empty).
assert.equal(t.careerTotals(), null);
t.setView({ config: { instruments: ['guitar'] },
instruments: { guitar: { committed_at: null, passports: [] } } });
assert.equal(t.careerTotals(), null);
// Committed but zero passports opened: still absent (no zero-wall).
t.setView({ config: { instruments: ['guitar'] },
instruments: { guitar: { committed_at: 'x', passports: [] } } });
assert.equal(t.careerTotals(), null);
// Committed with an earned badge + hours → totals aggregate.
t.setView({ config: { instruments: ['guitar', 'bass'] },
instruments: {
guitar: { committed_at: 'x', passports: [
{ badge: 'earned', seconds_total: 3600, genre: 'Blues', genre_key: 'blues' },
{ badge: 'in_progress', seconds_total: 120, genre: 'Funk', genre_key: 'funk',
qualifying_count: 4, requirement: { songs: 5, min_stars: 2 } }] },
bass: { committed_at: null, passports: [] },
} });
const totals = t.careerTotals();
assert.equal(totals.badges, 1);
assert.equal(totals.seconds, 3720);
assert.equal(totals.walls.length, 1);
});
test('gig runner lifecycle: advance on ended, abandon on dead-queue stop', () => {
const w = load();
const t = w.__careerPassportTest;
let remaining = 1;
w.feedBack = { playQueue: { remaining: () => remaining, active: () => remaining > 0 } };
t.setGigRun({
songs: [{ filename: 'a', title: 'A' }, { filename: 'b', title: 'B' }],
venue_id: null, genre: 'Soul', genre_key: 'soul', instrument: 'guitar', idx: 0,
});
// First song ends, one remains → the strip advances, no completion.
t.onGigSongEnded();
assert.equal(t.getGigRun().idx, 1);
// Stop while the queue is still active (end-of-song teardown) → run survives.
t.onGigSongStop();
assert.notEqual(t.getGigRun(), null);
// User quits: queue cleared → stop with a dead queue abandons (no log).
remaining = 0;
t.onGigSongStop();
assert.equal(t.getGigRun(), null);
});
test('a gold upgrade notifies even when the bronze moment was already seen', () => {
// Bronze seen under the legacy un-suffixed id; the badge then turns gold.
const w = load({ 'feedBack-career-badges-seen': '{"guitar/blues":1}' });
const t = w.__careerPassportTest;
const view = { instruments: { guitar: { passports: [
{ genre_key: 'blues', genre: 'Blues', badge: 'gold' }] } } };
t.detectNewBadges(view);
assert.equal(w.notifications.length, 1);
assert.match(w.notifications[0].title, /Gold/);
// Same session: no duplicate.
t.detectNewBadges(view);
assert.equal(w.notifications.length, 1);
// Gold slam seen → fresh session stays silent.
t.markBadgeSeen('guitar', 'blues', 'gold');
const w2 = load({ 'feedBack-career-badges-seen': JSON.stringify(t.seenBadges()) });
w2.__careerPassportTest.detectNewBadges(view);
assert.equal(w2.notifications.length, 0);
});
test('a gold slam marks the bronze moment seen too — never both ceremonies', () => {
const w = load();
const t = w.__careerPassportTest;
t.markBadgeSeen('guitar', 'blues', 'gold');
const seen = JSON.parse(JSON.stringify(t.seenBadges()));
assert.equal(seen['guitar/blues@gold'], 1);
assert.equal(seen['guitar/blues'], 1);
// A later view where the badge reads 'earned' (e.g. gold state lost
// server-side) must not replay the bronze ceremony.
const view = { instruments: { guitar: { passports: [
{ genre_key: 'blues', genre: 'Blues', badge: 'earned' }] } } };
const w2 = load({ 'feedBack-career-badges-seen': JSON.stringify(seen) });
w2.__careerPassportTest.detectNewBadges(view);
assert.equal(w2.notifications.length, 0);
});
test('careerTotals counts gold badges on the wall', () => {
const t = load().__careerPassportTest;
t.setView({
config: { instruments: ['guitar'] },
instruments: { guitar: { committed_at: 1, gig_count: 0, passports: [
{ genre_key: 'blues', genre: 'Blues', badge: 'gold', seconds_total: 60 },
{ genre_key: 'funk', genre: 'Funk', badge: 'in_progress', seconds_total: 0 },
] } },
});
const totals = t.careerTotals();
assert.equal(totals.badges, 1);
assert.equal(totals.walls[0].earned[0].badge, 'gold');
});
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,8 @@
{
"venue": "arena",
"version": 1,
"loops": {"bored": "bored.mp4", "neutral": "neutral.mp4", "engaged": "engaged.mp4", "ecstatic": "ecstatic.mp4"},
"stingers": {"clap": "clap.mp4", "cheer": "cheer.mp4"},
"intro": {"video": "intro.mp4", "audio": "arena-ambience.mp3"},
"sfx": {"up": "sfx-up.mp3", "down": "sfx-down.mp3"}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -14,5 +14,9 @@
"intro": {
"video": "intro.mp4",
"audio": "bar-ambience.mp3"
},
"sfx": {
"up": "sfx-up.mp3",
"down": "sfx-down.mp3"
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,8 @@
{
"venue": "club",
"version": 1,
"loops": {"bored": "bored.mp4", "neutral": "neutral.mp4", "engaged": "engaged.mp4", "ecstatic": "ecstatic.mp4"},
"stingers": {"clap": "clap.mp4", "cheer": "cheer.mp4"},
"intro": {"video": "intro.mp4", "audio": "club-ambience.mp3"},
"sfx": {"up": "sfx-up.mp3", "down": "sfx-down.mp3"}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
+162 -6
View File
@@ -878,6 +878,146 @@ function createFolderSurface(cfg) {
var _dragRafId = null;
var _DRAG_THRESH = 5, _DRAG_ZONE = 150, _DRAG_SPEED = 50;
// ── Windowed song lists ─────────────────────────────────────────────
// A song list used to render EVERY song it held. On a flat 50,944-song
// library that is one <div> with 50,938 children and ~1.3 MILLION DOM nodes
// (~25 per row) — ~4.2 GB of renderer RSS, for a screen the user may not
// even be looking at. It also poisons unrelated code: any
// `document.querySelector` miss anywhere in the app must walk that whole
// tree, which is how song_preview's per-frame menu check ended up eating
// ~50% of the renderer and dropping the app to 2.7 fps (feedBack#965).
//
// So render only what is on screen. Rows are uniform height (and grid cards
// uniform size), so the window is pure arithmetic — no per-row observers.
// Off-window rows are represented by padding on the list itself rather than
// spacer elements: a spacer <div> would become a grid ITEM in grid view and
// shift the columns, whereas padding works identically for both layouts.
var VIRTUAL_MIN = 200; // below this, render everything — no behaviour change
var VIRTUAL_BUFFER = 6; // rows kept rendered above/below the viewport
var _virtualCleanups = [];
var _virtualLists = []; // repaint fns, one per live windowed list
// Which slice of the list is on screen. Pure arithmetic — kept separate from
// the DOM so it can be tested directly (see tests/virtual_list.test.js).
//
// top : list's offset relative to the scroller viewport's top. NEGATIVE
// once the user has scrolled the list's start above the fold.
// rows : total ROWS (grid packs `perRow` songs into one row; list view is 1)
//
// Returns the song index range [start, end) to render, plus how many ROWS of
// padding stand in for the songs above and below it.
function _visibleWindow(top, viewportH, itemH, perRow, rows, total) {
if (!(itemH > 0) || !(rows > 0)) return { start: 0, end: total, padRowsTop: 0, padRowsBottom: 0 };
var firstRow = Math.max(0, Math.floor(-top / itemH) - VIRTUAL_BUFFER);
var lastRow = Math.min(rows, Math.ceil((-top + viewportH) / itemH) + VIRTUAL_BUFFER);
// Scrolled entirely past the list (either direction): keep one row alive
// rather than emptying it, so the padding math stays anchored.
if (lastRow <= firstRow) {
firstRow = Math.min(firstRow, rows - 1);
lastRow = firstRow + 1;
}
return {
start: firstRow * perRow,
end: Math.min(total, lastRow * perRow),
padRowsTop: firstRow,
padRowsBottom: Math.max(0, rows - lastRow),
};
}
function _clearVirtualLists() {
_virtualCleanups.forEach(function (fn) { try { fn(); } catch (_) {} });
_virtualCleanups = [];
_virtualLists = [];
}
// Fill `list` with `songs`, windowed when the list is big enough to matter.
// `make(song)` builds one row/card.
function _fillSongList(list, songs, make) {
var sorted = _sortSongs(songs);
if (sorted.length <= VIRTUAL_MIN) {
sorted.forEach(function (s) { list.appendChild(make(s)); });
return;
}
var scroller = _getScrollEl();
var basePadTop = parseFloat(window.getComputedStyle(list).paddingTop) || 0;
var basePadBot = parseFloat(window.getComputedStyle(list).paddingBottom) || 0;
// Measure one real row once — no hardcoded row height to drift out of
// sync with the CSS. (The list is shown before it is populated, so this
// measures a laid-out row, not a zero-height one.)
var probe = make(sorted[0]);
probe.style.visibility = 'hidden';
list.appendChild(probe);
var probeRect = probe.getBoundingClientRect();
var rowH = probeRect.height || 44;
var cardW = probeRect.width || 150;
list.removeChild(probe);
var GRID_GAP = 12; // matches the grid's `gap:12px`
var raf = 0, lastStart = -1, lastEnd = -1;
// Recomputed on EVERY paint, not captured once: a window resize changes
// the grid's column count, and therefore the row count and the height of
// the padding standing in for off-window rows. paint() runs on resize, so
// stale metrics would slice the wrong songs and mis-size the list.
function metrics() {
var perRow = 1, itemH = rowH;
if (_view === 'grid') {
perRow = Math.max(1, Math.floor((list.clientWidth + GRID_GAP) / (cardW + GRID_GAP)));
itemH = rowH + GRID_GAP;
}
return { perRow: perRow, itemH: itemH, rows: Math.ceil(sorted.length / perRow) };
}
function paint() {
raf = 0;
// Collapsed (display:none) or detached: nothing to paint, and don't
// pay for layout on every scroll tick of a section nobody can see.
// Forget the last window so re-showing repaints from scratch against
// the new position rather than short-circuiting on a stale memo.
if (!list.isConnected || list.offsetParent === null) {
lastStart = -1; lastEnd = -1;
return;
}
var m = metrics();
// Where the list sits relative to the scroller's viewport.
var top = list.getBoundingClientRect().top - scroller.getBoundingClientRect().top;
var vh = scroller.clientHeight || window.innerHeight;
var w = _visibleWindow(top, vh, m.itemH, m.perRow, m.rows, sorted.length);
if (w.start === lastStart && w.end === lastEnd) return; // nothing moved
lastStart = w.start; lastEnd = w.end;
var frag = document.createDocumentFragment();
for (var i = w.start; i < w.end; i++) frag.appendChild(make(sorted[i]));
list.textContent = '';
list.style.paddingTop = (basePadTop + w.padRowsTop * m.itemH) + 'px';
list.style.paddingBottom = (basePadBot + w.padRowsBottom * m.itemH) + 'px';
list.appendChild(frag);
}
function schedule() { if (!raf) raf = window.requestAnimationFrame(paint); }
scroller.addEventListener('scroll', schedule, { passive: true });
window.addEventListener('resize', schedule);
// Expanding or collapsing ANY section moves every list below it. Those
// lists' windows are computed from their position, so they must repaint
// too — otherwise they keep the window from their old position and show
// blank padding where songs should be until the user happens to scroll.
_virtualLists.push(schedule);
_virtualCleanups.push(function () {
scroller.removeEventListener('scroll', schedule);
window.removeEventListener('resize', schedule);
if (raf) window.cancelAnimationFrame(raf);
});
paint();
}
// Re-window every live list — call after anything that can move them
// vertically (a folder expanding/collapsing, a section being shown).
function _repaintVirtualLists() {
_virtualLists.forEach(function (fn) { try { fn(); } catch (_) {} });
}
function _getScrollEl() {
var el = _treeEl();
while (el && el !== document.documentElement) {
@@ -1159,8 +1299,8 @@ function createFolderSurface(cfg) {
var _listPopulated = open;
function _populateList() {
_sortSongs(folder.songs).forEach(function (s) {
list.appendChild(_view === 'grid' ? _songCard(s, folder.path) : _songRow(s, folder.path));
_fillSongList(list, folder.songs, function (s) {
return _view === 'grid' ? _songCard(s, folder.path) : _songRow(s, folder.path);
});
(folder.children || []).forEach(function (child) {
childrenWrap.appendChild(_folderSection(child, depth + 1));
@@ -1195,12 +1335,18 @@ function createFolderSurface(cfg) {
hdr.addEventListener('click', function () {
if (_query()) return;
var nowOpen = content.style.display === 'none';
if (nowOpen && !_listPopulated) { _populateList(); _listPopulated = true; }
// Show BEFORE populating: a windowed list measures a real row and the
// scroller viewport, and both are zero while display:none.
content.style.display = nowOpen ? '' : 'none';
if (nowOpen && !_listPopulated) { _populateList(); _listPopulated = true; }
chev.style.transform = nowOpen ? 'rotate(90deg)' : '';
if (nowOpen) _openFolders.add(folder.path);
else _openFolders.delete(folder.path);
_storeJSON('open', [..._openFolders]);
// This toggle moved everything below it — re-window the other lists,
// and re-window THIS one if it was already populated (its saved
// window was computed at its old position).
_repaintVirtualLists();
});
wrap.appendChild(hdr); wrap.appendChild(content);
@@ -1245,8 +1391,8 @@ function createFolderSurface(cfg) {
}
var _populated = _unsortedOpen;
function _populate() {
_sortSongs(songs).forEach(function (s) {
list.appendChild(_view === 'grid' ? _songCard(s, '') : _songRow(s, ''));
_fillSongList(list, songs, function (s) {
return _view === 'grid' ? _songCard(s, '') : _songRow(s, '');
});
}
if (_unsortedOpen) { _populate(); } else { list.style.display = 'none'; }
@@ -1255,10 +1401,12 @@ function createFolderSurface(cfg) {
hdr.addEventListener('click', function () {
if (_query()) return;
_unsortedOpen = list.style.display === 'none';
if (_unsortedOpen && !_populated) { _populate(); _populated = true; }
// Show BEFORE populating — see the folder toggle above.
list.style.display = _unsortedOpen ? (_view === 'grid' ? 'grid' : '') : 'none';
if (_unsortedOpen && !_populated) { _populate(); _populated = true; }
chev.style.transform = _unsortedOpen ? 'rotate(90deg)' : '';
_store(cfg.unsortedKey, String(_unsortedOpen));
_repaintVirtualLists(); // this toggle moved every list below it
});
wrap.appendChild(hdr); wrap.appendChild(list);
@@ -1340,6 +1488,10 @@ function createFolderSurface(cfg) {
// ── Render ──────────────────────────────────────────────────────────
function _render() {
_hoveredFolder = null; // DOM is rebuilt; discard any stale reference
// Drop the scroll listeners of the previous render's windowed lists —
// their `list` nodes are about to be detached, and a surviving listener
// would keep painting into orphaned DOM (and leak on every re-render).
_clearVirtualLists();
var treeEl = _treeEl();
if (!treeEl) return;
var data = _filtered();
@@ -1451,6 +1603,7 @@ function createFolderSurface(cfg) {
// ── Unload (lib surface) ────────────────────────────────────────────
function _unload() {
_clearVirtualLists(); // don't leave scroll listeners behind on teardown
if (!cfg.searchInputId) return;
var el = _el(cfg.searchInputId);
if (el) el.style.maxWidth = '';
@@ -1554,6 +1707,8 @@ function createFolderSurface(cfg) {
init: _init,
onScreenChanged: _onScreenChanged,
render: _render,
// Pure window arithmetic, exposed for tests (no DOM needed).
__test: { visibleWindow: _visibleWindow, VIRTUAL_MIN: VIRTUAL_MIN, VIRTUAL_BUFFER: VIRTUAL_BUFFER },
};
}
@@ -1656,6 +1811,7 @@ if (!window.__folderLibraryLib) {
window.folderLibrary = {
load: function (force) { return _lib.load(force); },
unload: function () { _lib.unload(); },
__test: _lib.__test,
};
// Auto-load if folder view was already active when this script was injected.
@@ -0,0 +1,165 @@
// Windowed song lists (feedBack#965).
//
// A song list used to render EVERY song. On a flat 50,944-song library that is
// one div with 50,938 children and ~1.3 MILLION DOM nodes (~25 per row) —
// ~4.2 GB of renderer RSS, for a screen the user may not even be looking at. It
// also poisoned unrelated code: any `document.querySelector` miss anywhere in
// the app had to walk that whole tree.
//
// _visibleWindow is the arithmetic that decides which slice is on screen. If it
// is wrong the list silently shows the wrong songs, or scrolls to the wrong
// place, so it is tested directly — the DOM glue around it is not the risky bit.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
function load() {
const window = {
console,
document: {
readyState: 'complete',
addEventListener() {},
getElementById() { return null; },
querySelector() { return null; },
querySelectorAll() { return []; },
createElement() { return { style: {}, classList: { add() {}, remove() {}, contains() { return false; } }, addEventListener() {}, appendChild() {} }; },
},
addEventListener() {},
localStorage: { getItem() { return null; }, setItem() {} },
performance: { now: () => 0 },
setInterval() { return 0; },
clearInterval() {},
requestAnimationFrame() { return 0; },
cancelAnimationFrame() {},
getComputedStyle() { return { overflowY: 'visible', paddingTop: '0px', paddingBottom: '0px' }; },
innerHeight: 800,
};
window.window = window;
window.globalThis = window;
const ctx = vm.createContext(window);
vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'screen.js'), 'utf8'), ctx, { filename: 'screen.js' });
assert.ok(window.folderLibrary && window.folderLibrary.__test, 'plugin must expose __test');
return window.folderLibrary.__test;
}
const { visibleWindow, VIRTUAL_BUFFER, VIRTUAL_MIN } = load();
// A flat 50k library in list view: 1 song per row, 44px rows, 800px viewport.
const ROW = 44;
const VH = 800;
const TOTAL = 50938;
test('the whole point: a 50k list renders a bounded window, not 50k rows', () => {
const w = visibleWindow(0, VH, ROW, 1, TOTAL, TOTAL);
const rendered = w.end - w.start;
assert.ok(rendered < 60, `expected a small window, got ${rendered} rows`);
// ~18 rows fit in 800px, plus buffer above and below.
assert.ok(rendered >= Math.ceil(VH / ROW), 'must at least fill the viewport');
});
test('at the top: starts at 0, all remaining rows are bottom padding', () => {
const w = visibleWindow(0, VH, ROW, 1, TOTAL, TOTAL);
assert.equal(w.start, 0);
assert.equal(w.padRowsTop, 0);
assert.equal(w.padRowsBottom, TOTAL - w.end);
});
test('scrolled into the middle: window tracks the scroll, padding adds up', () => {
const scrolled = 10000 * ROW; // row 10,000 at the fold
const w = visibleWindow(-scrolled, VH, ROW, 1, TOTAL, TOTAL);
assert.equal(w.start, (10000 - VIRTUAL_BUFFER) * 1);
assert.ok(w.end > w.start);
// The invariant that keeps the scrollbar honest: padding rows + rendered
// rows must account for every song, or the list changes height as you scroll.
assert.equal(w.padRowsTop + (w.end - w.start) + w.padRowsBottom, TOTAL);
});
test('at the very bottom: no bottom padding, end lands on the last song', () => {
const rows = TOTAL;
const scrolled = rows * ROW - VH; // scrolled to the end
const w = visibleWindow(-scrolled, VH, ROW, 1, rows, TOTAL);
assert.equal(w.end, TOTAL);
assert.equal(w.padRowsBottom, 0);
assert.equal(w.padRowsTop + (w.end - w.start), TOTAL);
});
test('grid view: perRow songs collapse into one row', () => {
const perRow = 6;
const rows = Math.ceil(TOTAL / perRow);
const w = visibleWindow(0, VH, 190, perRow, rows, TOTAL);
assert.equal(w.start, 0);
assert.equal(w.start % perRow, 0, 'a window must start on a row boundary');
assert.ok(w.end <= TOTAL);
assert.ok((w.end - w.start) < 200, 'grid window must stay bounded');
});
test('scrolled far past the list: keeps one row, never a negative window', () => {
const w = visibleWindow(-99999999, VH, ROW, 1, TOTAL, TOTAL);
assert.ok(w.end > w.start, 'window must never invert');
assert.ok(w.start >= 0 && w.end <= TOTAL);
assert.equal(w.padRowsTop + (w.end - w.start) + w.padRowsBottom, TOTAL);
});
test('list not yet scrolled to (below the fold): still yields a valid window', () => {
const w = visibleWindow(5000, VH, ROW, 1, TOTAL, TOTAL); // list starts below viewport
assert.equal(w.start, 0);
assert.ok(w.end > 0);
});
test('degenerate inputs fall back to rendering everything, never to a broken window', () => {
// Measured height of 0 (e.g. list still display:none) must not divide by zero
// and must not silently render an empty list.
const w = visibleWindow(0, VH, 0, 1, TOTAL, TOTAL);
assert.equal(w.start, 0);
assert.equal(w.end, TOTAL);
assert.equal(w.padRowsTop, 0);
assert.equal(w.padRowsBottom, 0);
});
test('small lists are below the virtualization threshold', () => {
assert.ok(VIRTUAL_MIN >= 100, 'threshold must be high enough that normal folders are untouched');
});
// ── the grid must be re-measured when the window resizes (CodeRabbit, #967) ──
// perRow and rows were originally captured once at fill time. paint() also runs
// on resize, so a narrower/wider window changed the column count while the
// window maths still used the OLD one — slicing the wrong songs and mis-sizing
// the padding. These pin that the geometry is a function of perRow, so a stale
// perRow cannot silently survive.
test('resizing the grid to fewer columns re-windows against the new row count', () => {
const total = 10000;
const wide = visibleWindow(0, VH, 190, 6, Math.ceil(total / 6), total);
const narrow = visibleWindow(0, VH, 190, 3, Math.ceil(total / 3), total);
// Same viewport, half the columns -> about half as many songs on screen.
assert.ok(narrow.end < wide.end, 'fewer columns must render fewer songs per screen');
// ...and the total must still add up, or the scrollbar lies after a resize.
for (const [w, perRow] of [[wide, 6], [narrow, 3]]) {
const rows = Math.ceil(total / perRow);
assert.equal(w.padRowsTop + Math.ceil((w.end - w.start) / perRow) + w.padRowsBottom, rows,
`rows must account for every song at perRow=${perRow}`);
}
});
test('a stale perRow would break the total-height invariant (the bug)', () => {
const total = 10000;
// Grid re-laid out to 3 columns, but windowed with the OLD perRow of 6:
// the row count no longer matches the geometry, and the padding is wrong.
const stalePerRow = 6, actualRows = Math.ceil(total / 3);
const bad = visibleWindow(0, VH, 190, stalePerRow, actualRows, total);
const accounted = bad.padRowsTop + Math.ceil((bad.end - bad.start) / 3) + bad.padRowsBottom;
assert.notEqual(accounted, actualRows,
'this asserts the FAILURE mode: mismatched perRow/rows must not silently look correct — ' +
'metrics() recomputes both together on every paint so this cannot happen in practice');
});
test('scrolled grid window always starts on a row boundary', () => {
const total = 10000, perRow = 4;
const rows = Math.ceil(total / perRow);
const w = visibleWindow(-5000, VH, 190, perRow, rows, total);
assert.equal(w.start % perRow, 0, 'a partial row would shift every card in the grid');
});
+1733 -1717
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -218,6 +218,7 @@ import {
setAvOffsetMs,
setInstrumentPathway,
setupAppUpdates,
setupWindowOptions,
syncDefaultArrangementPin,
} from './js/settings.js';
import {
+14 -10
View File
@@ -1908,17 +1908,21 @@ function createHighway() {
// never routable.
const isAudioUrl = msg.audio_url.startsWith('/audio/');
// "Full mix" covers BOTH single-mix pack shapes:
// - stem-less packs (original_audio: in the manifest,
// audio_url == original_audio_url), and
// - single-stem packs (stems: [full.ogg] only) — the server
// puts the full mix in the stems list, has_original_audio
// is false, and audio_url points at the one stem. With one
// stem there is no per-stem mix to preserve, so routing it
// natively loses nothing. Real multi-stem (>1) stays out
// until Phase 2.
// - single-stem packs (stems: [full.ogg] only) — the pack's
// one stem IS its mixdown, so the server leaves it in the
// stems list, has_full_mix is false, and audio_url points
// at that one stem; and
// - legacy stem-less packs, whose mixdown sits outside stems
// behind the deprecated original_audio: key, so has_stems
// is false and audio_url == full_mix_url.
// Either way there is one audible source and no per-stem mix
// to preserve, so routing it natively loses nothing. A pack
// that retains its `full` stem ALONGSIDE separated stems is
// multi-stem (has_full_mix && has_stems) and stays out until
// Phase 2 — routing it natively would drop the mixer.
const isFeedpakFullMix = !isAudioUrl
&& msg.audio_url.startsWith('/api/sloppak/')
&& ((!!msg.has_original_audio && !msg.has_stems)
&& ((!!msg.has_full_mix && !msg.has_stems)
|| (msg.stems || []).length === 1);
// Record the loaded song's audio so app.js can re-route it
// between the HTML5 and JUCE paths if the audio engine is
@@ -1943,7 +1947,7 @@ function createHighway() {
'isFeedpakFullMix=', isFeedpakFullMix,
'has_stems=', !!msg.has_stems,
'stems=', (msg.stems || []).length,
'has_original_audio=', !!msg.has_original_audio,
'has_full_mix=', !!msg.has_full_mix,
'format=', msg.format,
'alreadyLoaded=', alreadyLoaded,
'juceApi=', !!window.feedBackDesktop?.audio);
+28
View File
@@ -0,0 +1,28 @@
// Blob export helpers — the download idiom that used to be duplicated in
// settings-io.js and diagnostics-export.js, plus image-to-clipboard for
// shareable cards/posters. A LEAF module: imports nothing. Classic-script
// plugins reach it via dynamic import('/static/js/blob-io.js').
export function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// Copy an image blob to the system clipboard. Returns true on success, false
// when the Clipboard API is unavailable or refuses (insecure context, no user
// gesture, permission denied) — callers fall back to downloadBlob and say so.
export async function copyImageBlob(blob) {
try {
if (!navigator.clipboard || typeof ClipboardItem === 'undefined') return false;
await navigator.clipboard.write([new ClipboardItem({ [blob.type || 'image/png']: blob })]);
return true;
} catch (_) {
return false;
}
}
+3 -8
View File
@@ -21,6 +21,8 @@
// redact toggles.
// 3. Stream the returned zip to disk.
import { downloadBlob } from './blob-io.js';
function _diagIncludeFromUI() {
const v = (id) => document.getElementById(id)?.checked !== false;
return {
@@ -265,14 +267,7 @@ export async function exportDiagnostics() {
}
try {
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
downloadBlob(blob, filename);
status.textContent = `Exported ${filename}`;
} catch (e) {
status.textContent = `Export failed during download: ${e.message}`;
+4 -9
View File
@@ -1,6 +1,6 @@
// Settings backup — the export / import bundle.
//
// Carved verbatim out of static/app.js (R3a). A LEAF module: imports nothing.
// Carved verbatim out of static/app.js (R3a). Imports only the blob-io leaf.
//
// Two entry points, both inline handlers on the Settings screen, so app.js keeps
// re-exposing them on window. The import is two-phase (server first, atomic; then
@@ -29,6 +29,8 @@
// phase 2; the localStorage side is best-effort merge after server
// success. Failures are reported, never silenced.
import { downloadBlob } from './blob-io.js';
export async function exportSettings() {
const status = document.getElementById('backup-status');
status.textContent = 'Exporting...';
@@ -66,14 +68,7 @@ export async function exportSettings() {
if (match) filename = match[1];
}
const blob = new Blob([JSON.stringify(bundle, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
downloadBlob(blob, filename);
status.textContent = `Exported ${filename}`;
} catch (e) {
status.textContent = `Export failed: ${e.message}`;
+42
View File
@@ -100,6 +100,7 @@ export async function loadSettings() {
// failed fetch below still leaves the desktop updater wired up.
// setupAppUpdates() is idempotent via _appUpdatesWired.
setupAppUpdates();
setupWindowOptions();
const resp = await fetch('/api/settings');
const data = await resp.json();
// Null-guard the form fields: on the v3 tabbed settings page the markup is
@@ -167,6 +168,47 @@ export async function loadSettings() {
hwcInitSettingsUI();
}
// ── Window options (desktop-only) ────────────────────────────────────────
// Desktop-only window preferences (start-in-fullscreen, …). The whole block
// stays hidden in the plain web / Docker app; unhide + wire only when the
// feedBack-desktop bridge (window.feedBackDesktop.window) exposes the getter
// and setter. Persistence lives desktop-side because only the Electron main
// process can read the pref at window-creation time — core just proxies.
export let _windowOptionsWired = false;
export function setupWindowOptions() {
const block = document.getElementById('window-options-block');
if (!block) return;
const winApi = window.feedBackDesktop?.window;
// Per-method capability check: a partial/older bridge may expose `window`
// without this shape. Leave the block hidden rather than half-wiring it.
if (!winApi
|| typeof winApi.getStartFullscreen !== 'function'
|| typeof winApi.setStartFullscreen !== 'function') {
return;
}
block.classList.remove('hidden');
const cb = document.getElementById('setting-start-fullscreen');
if (!cb) return;
// Hydrate from the desktop-persisted value. The getter may be sync or
// async (IPC round-trip); Promise.resolve normalises both.
Promise.resolve(winApi.getStartFullscreen()).then(function (on) {
cb.checked = !!on;
}).catch(function () { /* leave unchecked on error */ });
// Guard only the listener against double-binding; unhide + re-hydrate
// stay idempotent so re-entering Settings refreshes the checkbox.
if (!_windowOptionsWired) {
_windowOptionsWired = true;
cb.addEventListener('change', function () {
try { winApi.setStartFullscreen(cb.checked); } catch (_) { /* best-effort */ }
});
}
}
export const APP_UPDATE_CHANNELS = ['stable', 'rc', 'beta', 'alpha'];
export let _appUpdatesWired = false;
+190
View File
@@ -0,0 +1,190 @@
/*
* fee[dB]ack the pop-out chip.
*
* One affordance, core-owned, identical everywhere: the small button a plugin
* drops into the panel it already has.
*
* feedBack.panes.register({ id: 'camera_director', title: 'Camera', element: () => panelEl });
* feedBack.panes.attachChip(panelEl, 'camera_director');
*
* That is the entire adoption cost. Clicking the chip pops the panel out; a stub
* takes its place so the user can find it again; closing the pane brings the panel
* home and restores the chip. The plugin writes no show/hide logic if it did,
* every plugin would invent a slightly different one, which is exactly the
* inconsistency this exists to prevent.
*
* The panel a chip is attached to is USUALLY the very element the pane moves into
* the pop-out window so most of the time there is nothing here left to hide, and
* the job is simply to mark the hole it left. Hiding it would in fact be actively
* harmful: `.fb-pane-detached` is `display:none !important`, and it would travel
* with the node straight into the pane window and blank it.
*
* When the chip IS attached to something the pane didn't take (a wrapper, a
* launcher row), that element stays put and is hidden with `.fb-pane-detached`
* a dedicated class, not `.hidden`/[hidden], because the panels we attach to
* already toggle those themselves.
*/
(function () {
'use strict';
const panes = window.feedBack && window.feedBack.panes;
if (!panes || typeof panes.register !== 'function') {
console.error('[panes] pane-manager.js must load before pane-chip.js');
return;
}
// paneId -> { el, chip, stub, spec }
const attached = new Map();
function _makeChip(spec) {
const b = document.createElement('button');
b.type = 'button';
b.className = 'fb-pane-chip';
b.title = 'Pop out';
b.setAttribute('aria-label', 'Pop out ' + spec.title);
b.textContent = '⇱';
b.addEventListener('click', (e) => {
// Rail popovers close on any document click that lands outside them
// (player-chrome.js). Without this the popover would close under the
// chip mid-click, which reads as the button not working.
e.stopPropagation();
e.preventDefault();
panes.detach(spec.id);
});
return b;
}
function _makeStub(spec) {
const s = document.createElement('button');
s.type = 'button';
s.className = 'fb-pane-stub';
s.setAttribute('aria-label', 'Bring ' + spec.title + ' back');
s.title = 'Bring it back';
const glyph = document.createElement('span');
glyph.className = 'fb-pane-stub-glyph';
glyph.textContent = '⇲';
const label = document.createElement('span');
label.textContent = spec.title + ' is popped out';
s.appendChild(glyph);
s.appendChild(label);
s.addEventListener('click', (e) => {
e.stopPropagation();
e.preventDefault();
panes.close(spec.id);
});
return s;
}
// The pane is out. Leave a stub where its panel used to be.
//
// The subtlety: the panel a chip is attached to is USUALLY the very element the
// pane moved into the pop-out window. It is no longer in this document at all —
// so hiding it would be worse than pointless (the `display:none` travels with
// the node and blanks the pane window, which is exactly the bug this fixes), and
// the stub cannot be inserted "before it", because it is not here to be before.
//
// Hence `home`: the manager tells us where the element used to live, and the
// stub goes there. If the chip is attached to something the pane did NOT take —
// a wrapper, a launcher row — that element is still here, and we hide it as
// before.
function _onOpened(rec, detail) {
// Did the pane take MY element?
//
// Ask the manager, which knows exactly what it handed to the host. Do not
// try to infer it from the element:
//
// - `isConnected` says "still here" for a panel sitting in a pane window.
// It IS connected — to that window.
// - `ownerDocument` says "still here" for a panel moved into the DOCK,
// which is in this very document. Hiding it there would blank a pane the
// user is looking at.
//
// Both were live bugs. The manager's answer is the only one that holds for
// every host, and it works when reconciling after the fact (detail == null),
// which is what a plugin rebuilding its panel mid-pop-out triggers.
const takenEl = (detail && detail.el) || panes.elementOf(rec.spec.id);
const moved = takenEl === rec.el;
if (!moved && rec.el.isConnected) {
rec.el.classList.add('fb-pane-detached');
if (!rec.stub.isConnected && rec.el.parentNode) rec.el.parentNode.insertBefore(rec.stub, rec.el);
return;
}
// Mark the hole the element left. `home` comes with the event, or from the
// manager when we are reconciling after the fact.
const home = (detail && detail.home) || panes.homeOf(rec.spec.id);
if (!rec.stub.isConnected && home && home.parent && home.parent.isConnected) {
const next = (home.next && home.next.parentNode === home.parent) ? home.next : null;
home.parent.insertBefore(rec.stub, next);
}
}
function _onClosed(rec) {
// The element is back. Whatever we did to hide it, undo — including a class
// it might have carried out of the document and back.
rec.el.classList.remove('fb-pane-detached');
rec.stub.remove();
}
/**
* attachChip(el, paneId, opts)
*
* `el` the dialog to hide when the pane pops out. The chip is injected
* into `el.querySelector('[data-pane-header]')` when present, else
* prepended to `el` itself.
* `opts` { header: Element } to place the chip somewhere specific.
*
* Returns a detach function that removes the chip and stub and restores the
* dialog call it if your plugin tears its dialog down.
*/
function attachChip(el, paneId, opts) {
opts = opts || {};
if (!(el instanceof Element)) throw new TypeError('panes.attachChip: el must be an Element');
// Validate here, not at the insertBefore below. This is a public plugin API,
// and a truthy non-Element `header` (a selector string, a jQuery-ish wrapper,
// a ref object) is an easy mistake to make — one that would otherwise surface
// as a confusing DOM exception from deep inside core.
if (opts.header != null && !(opts.header instanceof Element)) {
throw new TypeError('panes.attachChip(' + paneId + '): opts.header must be an Element');
}
const spec = panes.get(paneId);
if (!spec) { console.warn('[panes] attachChip: register the pane first:', paneId); return () => {}; }
if (attached.has(paneId)) { console.warn('[panes] attachChip: already attached:', paneId); return () => {}; }
const chip = _makeChip(spec);
const stub = _makeStub(spec);
const host = opts.header || el.querySelector('[data-pane-header]') || el;
if (host === el) host.insertBefore(chip, host.firstChild);
else host.appendChild(chip);
const rec = { el, chip, stub, spec };
attached.set(paneId, rec);
// Reconcile immediately: register() reopens a pane the user left open at
// last unload, and that can land before (or after) attachChip runs.
if (panes.isOpen(paneId)) _onOpened(rec, null);
return () => {
if (attached.get(paneId) !== rec) return;
attached.delete(paneId);
chip.remove();
_onClosed(rec);
};
}
// One pair of bus listeners for every chip, rather than one pair per chip.
const bus = window.feedBack;
if (bus && typeof bus.on === 'function') {
bus.on('panes:opened', (e) => {
const rec = attached.get(e.detail && e.detail.id);
if (rec) _onOpened(rec, e.detail);
});
bus.on('panes:closed', (e) => {
const rec = attached.get(e.detail && e.detail.id);
if (rec) _onClosed(rec);
});
}
window.feedBack.panes.attachChip = attachChip;
})();
+47
View File
@@ -0,0 +1,47 @@
/*
* fee[dB]ack desktop upgrades for pane windows.
*
* In the desktop app a pane window is a real BrowserWindow: it remembers where you
* put it, it stays off the taskbar, it minimizes to the system tray, and the tray
* lists every pane you have.
*
* Note what this file does NOT do: it does not open the window, and it does not
* close it. That stays in pane-window-host.js, and it stays `window.open()`
* because the pane's element is MOVED into that window's document, and a window
* the main process created for us would give this realm no handle to adopt into.
*
* Electron turns our same-origin `window.open()` into a real BrowserWindow anyway,
* and the main process recognises it by its frame name (`fbpane-<id>`) and takes
* over the OS-level behaviour from there. So the only thing left to say across IPC
* is "here are the panes that exist" for the tray and to listen for the tray
* saying "open that one".
*
* In a browser, or on an older desktop build, this file does nothing and pop-out
* works anyway. Everything here is an upgrade, not a dependency.
*/
(function () {
'use strict';
const panes = window.feedBack && window.feedBack.panes;
const bus = window.feedBack;
const desktop = window.feedBackDesktop && window.feedBackDesktop.panes;
if (!panes || !bus || !desktop) return;
// The tray asked to toggle a pane. Only this realm knows what that means — the
// pane might belong in the dock, and its element lives here.
desktop.onToggle((paneId) => {
if (panes.isOpen(paneId)) panes.close(paneId);
else panes.detach(paneId);
});
// Keep the tray's menu in step with the registry. Cheap and rare — panes are
// registered at load and toggled by hand, never on a playback path.
function sync() {
desktop.sync(panes.list().map((p) => ({ id: p.id, title: p.title, icon: p.icon, open: p.open })));
}
bus.on('panes:registered', sync);
bus.on('panes:unregistered', sync);
bus.on('panes:opened', sync);
bus.on('panes:closed', sync);
sync();
})();
+121
View File
@@ -0,0 +1,121 @@
/*
* fee[dB]ack pane dock (the in-window pane host).
*
* A right-edge stack of cards, one per open pane. Deliberately NOT a rail popover:
* the rail is exclusive (player-chrome.js's openPopFor closes the last one before
* opening the next), which is exactly why you cannot watch the mixer while riding
* the camera. Cards here coexist.
*
* As everywhere in this system, the card holds the plugin's REAL element moved,
* not copied. The dock is a frame; the panel inside it is the panel.
*
* Song-switch survival is structural, not defended: #fb-pane-dock is a <body>
* child outside every .screen, so the per-song teardown never sees it.
*
* Registers as the `dock` host at priority 0 the floor. Whatever else exists
* (an OS window), a pane can always land here, so opening one can never fail.
*/
(function () {
'use strict';
const panes = window.feedBack && window.feedBack.panes;
if (!panes || typeof panes.registerHost !== 'function') {
console.error('[panes] pane-manager.js must load before pane-dock.js');
return;
}
let dockEl = null;
const cards = new Map(); // paneId -> card element
function dock() {
if (dockEl && dockEl.isConnected) return dockEl;
dockEl = document.getElementById('fb-pane-dock');
if (!dockEl) {
dockEl = document.createElement('div');
dockEl.id = 'fb-pane-dock';
// `is-empty` from the start: panes.css hides an empty dock, and a dock
// born without the class is a visible-to-CSS, announced-to-screen-readers
// `role="region"` landmark with nothing in it until the first card
// arrives. Born empty, because it is.
dockEl.className = 'fb-pane-dock is-empty';
dockEl.setAttribute('role', 'region');
dockEl.setAttribute('aria-label', 'Panes');
document.body.appendChild(dockEl);
}
return dockEl;
}
function _syncEmpty() {
dock().classList.toggle('is-empty', cards.size === 0);
}
function place(spec, el) {
const card = document.createElement('section');
card.className = 'fb-pane-card';
card.dataset.paneId = spec.id;
card.setAttribute('aria-label', spec.title);
const head = document.createElement('header');
head.className = 'fb-pane-card-head';
const title = document.createElement('span');
title.className = 'fb-pane-card-title';
// textContent, not innerHTML — a pane title comes from a plugin.
title.textContent = spec.icon + ' ' + spec.title;
const close = document.createElement('button');
close.type = 'button';
close.className = 'fb-pane-card-btn';
close.setAttribute('aria-label', 'Close ' + spec.title);
close.title = 'Close';
close.textContent = '✕';
close.addEventListener('click', () => panes.close(spec.id));
head.appendChild(title);
head.appendChild(close);
const body = document.createElement('div');
body.className = 'fb-pane-card-body';
// Same neutralisation as the window host: the panel was a fixed overlay
// pinned to a corner of the app, and inside a card that positioning is
// nonsense. .fb-paned unpins it and nothing else.
el.classList.add('fb-paned');
body.appendChild(el);
card.appendChild(head);
card.appendChild(body);
dock().appendChild(card);
cards.set(spec.id, card);
_syncEmpty();
}
function unplace(id, el) {
// Hand the element back unmarked. The manager returns it to its home right
// after this, and it must arrive as the plugin left it — a panel that
// stayed .fb-paned would come back with its own positioning stripped.
if (el) el.classList.remove('fb-paned');
const card = cards.get(id);
if (card) card.remove();
cards.delete(id);
_syncEmpty();
}
function focus(id) {
const card = cards.get(id);
if (!card) return;
// Honour prefers-reduced-motion, as the flash animation below already does
// in panes.css. A smooth scroll is motion too, and a user who asked for less
// of it meant this as well.
const calm = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
card.scrollIntoView({ block: 'nearest', behavior: calm ? 'auto' : 'smooth' });
// Re-trigger the flash even if the class is still there — repeat focus of
// the same card would otherwise be a no-op animation.
card.classList.remove('is-flash');
void card.offsetWidth;
card.classList.add('is-flash');
setTimeout(() => card.classList.remove('is-flash'), 700);
}
panes.registerHost({ id: 'dock', priority: 0, available: () => !!document.body, place, unplace, focus });
})();
+71
View File
@@ -0,0 +1,71 @@
/*
* fee[dB]ack pane launcher (the "Panes" rail popover).
*
* A chip only works for a pane that already has a dialog to hide. Panes with no
* dialog a readout, a plugin's optional extra need somewhere to be opened
* from, so every registered pane gets one: a checkbox list in the rail.
*
* The rail popover is the right home for this precisely because it IS exclusive
* and transient. It's a menu, not a workspace; the panes it opens are the
* workspace, and they persist.
*
* Populated from the registry, so a plugin that calls panes.register() appears
* here with no further work. (The system tray will mirror this list.)
*/
(function () {
'use strict';
const panes = window.feedBack && window.feedBack.panes;
const bus = window.feedBack;
if (!panes || !bus || typeof bus.on !== 'function') return;
let listEl = null;
function render() {
if (!listEl || !listEl.isConnected) listEl = document.getElementById('v3-rail-panes-list');
if (!listEl) return;
const all = panes.list();
// Toggling a pane from this list fires panes:opened/closed, which re-renders
// the list — destroying the very button the user just pressed and dropping
// focus to <body>. Remember which one had it and give it back, so keyboard
// and screen-reader users can toggle several panes without losing their place.
const focusedId = (listEl.contains(document.activeElement) && document.activeElement.dataset)
? document.activeElement.dataset.paneId : null;
listEl.replaceChildren();
if (!all.length) {
const empty = document.createElement('div');
empty.className = 'v3-pop-empty';
empty.textContent = 'No panes available.';
listEl.appendChild(empty);
return;
}
all.forEach((p) => {
const b = document.createElement('button');
b.type = 'button';
b.className = 'v3-pop-btn';
b.dataset.paneId = p.id;
b.setAttribute('aria-pressed', p.open ? 'true' : 'false');
b.textContent = (p.open ? '● ' : '○ ') + p.icon + ' ' + p.title;
b.addEventListener('click', (e) => {
e.stopPropagation();
if (panes.isOpen(p.id)) panes.close(p.id); else panes.detach(p.id);
});
listEl.appendChild(b);
if (p.id === focusedId) b.focus();
});
}
// The registry changes when plugins load and when panes open/close. Render is
// cheap and rare (never on a playback path), so just re-run it.
bus.on('panes:registered', render);
bus.on('panes:unregistered', render);
bus.on('panes:opened', render);
bus.on('panes:closed', render);
if (document.readyState !== 'complete') document.addEventListener('DOMContentLoaded', render);
else render();
})();
+381
View File
@@ -0,0 +1,381 @@
/*
* fee[dB]ack pane manager.
*
* The registry and host router behind `window.feedBack.panes`.
*
* A "pane" is a piece of UI a plugin already has a mixer panel, a camera rig,
* a settings board that the user can pop out into its own OS window and leave
* open: while they play, across song switches, on a second monitor, minimized to
* the tray.
*
* The whole design is one sentence: WE MOVE THE REAL ELEMENT.
*
* Not a copy of it, not a re-implementation of it in the pop-out window the
* actual DOM node. Same-origin windows can adopt each other's nodes, and an
* adopted node keeps its event listeners and its closures. So the panel goes on
* running the plugin's own code, against the plugin's own state, in the plugin's
* own realm. It looks and behaves exactly like the thing that was popped out,
* because it IS the thing that was popped out.
*
* That is what makes the plugin's side of this two lines:
*
* feedBack.panes.register({ id: 'camera_director', title: 'Camera', element: () => panelEl });
* feedBack.panes.attachChip(panelEl, 'camera_director');
*
* No state mirroring, no cross-window RPC, no second copy of the UI to keep in
* step with the first. Those were all workarounds for a problem we simply do not
* have once the node itself moves.
*
* The manager owns which pane is open and where, and crucially where each
* pane's element CAME FROM, so docking it puts it back exactly where it was.
*/
(function () {
'use strict';
const HOSTS_KEY = 'fbPaneHosts'; // { paneId: hostId } — panes open at last unload
// id -> normalized spec
const specs = new Map();
// id -> { spec, hostId, el, home: { parent, next } }
const open = new Map();
// hostId -> host provider
const hosts = new Map();
// ── Persistence ──────────────────────────────────────────────────────────
// Only which pane was open, and where. A pane's CONTENTS are the plugin's own
// DOM and the plugin's own state — none of our business.
// A pane id is plugin-controlled and is used as a key in the persisted
// host map. `__proto__` and friends are not ids, they are booby traps: writing
// `map['__proto__'] = 'window'` on a plain object corrupts the map (and can
// reach Object.prototype), and reading `map[id]` can pick a value straight off
// the prototype chain for a pane that was never remembered at all.
//
// Rejected at registration, so the id never reaches storage — and the reads
// below are own-property checks anyway, because defence in depth is cheap here.
const UNSAFE_KEYS = ['__proto__', 'constructor', 'prototype'];
function _isUnsafeId(id) { return UNSAFE_KEYS.indexOf(id) >= 0; }
function _readJSON(key, fallback) {
try {
const raw = localStorage.getItem(key);
if (!raw) return fallback;
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return fallback;
// Re-key onto a null-prototype object: whatever was in storage (hand
// edited, corrupt, polluted) can no longer smuggle in a prototype.
const safe = Object.create(null);
Object.keys(parsed).forEach((k) => { if (!_isUnsafeId(k)) safe[k] = parsed[k]; });
return safe;
} catch (e) { return fallback; } // private mode / corrupt value
}
function _writeJSON(key, value) {
try { localStorage.setItem(key, JSON.stringify(value)); } catch (e) { /* quota / private mode: non-fatal */ }
}
function _rememberHost(id, hostId) {
if (_isUnsafeId(id)) return;
const map = _readJSON(HOSTS_KEY, Object.create(null));
if (hostId) map[id] = hostId; else delete map[id];
_writeJSON(HOSTS_KEY, map);
}
function _rememberedHost(id) {
const map = _readJSON(HOSTS_KEY, Object.create(null));
return Object.prototype.hasOwnProperty.call(map, id) ? map[id] : undefined;
}
// ── Spec ─────────────────────────────────────────────────────────────────
// A pane window's initial size. Plugin-controlled, and the window host builds
// window.open()'s feature string by concatenation — so this has to come out the
// other side as a number, not merely as something number-ish.
const MIN_PANE_PX = 120;
const MAX_PANE_PX = 4000; // wider than any real display; a guard, not a policy
function _size(v, fallback) {
const n = Math.round(Number(v));
if (!Number.isFinite(n) || n <= 0) return fallback;
return Math.min(MAX_PANE_PX, Math.max(MIN_PANE_PX, n));
}
function _normalize(spec) {
if (!spec || typeof spec !== 'object') throw new TypeError('panes.register: spec must be an object');
if (!spec.id || typeof spec.id !== 'string') throw new TypeError('panes.register: spec.id is required');
// See UNSAFE_KEYS: a pane id becomes a key in the persisted host map.
if (_isUnsafeId(spec.id)) throw new TypeError('panes.register: unsafe pane id: ' + spec.id);
if (typeof spec.element !== 'function' && !(spec.element instanceof Element)) {
throw new TypeError('panes.register(' + spec.id + '): spec.element must be an Element, or a function returning one');
}
return {
id: spec.id,
title: spec.title || spec.id,
icon: spec.icon || '▣',
// Resolved lazily: a plugin often builds its panel on first use, so the
// element may not exist at registration time — and it may be rebuilt
// later (Camera Director rebuilds its panel on every mode change).
// Asking for it at open time means we always move the live one.
element: typeof spec.element === 'function' ? spec.element : () => spec.element,
// Coerced to real numbers, because these are plugin-controlled and the
// window host concatenates them into window.open()'s feature string. A
// `width` of '300,menubar=1' would not merely be an invalid size — it
// would inject window features. Anything that isn't a finite positive
// number falls back to the default, and absurd sizes are clamped rather
// than honoured.
width: _size(spec.width, 380),
height: _size(spec.height, 560),
defaultHost: spec.defaultHost || 'window',
// Called after the element lands in (or returns from) a pane window,
// for a plugin that needs to re-measure or re-anchor something.
onHost: typeof spec.onHost === 'function' ? spec.onHost : null,
};
}
// ── Host routing ─────────────────────────────────────────────────────────
function _resolveHost(preferred) {
const wanted = hosts.get(preferred);
if (wanted && wanted.available()) return wanted;
// Fall back to the best available host. The dock registers at priority 0
// and is always available, so a pane can never fail to open.
let best = null;
hosts.forEach((h) => {
if (!h.available()) return;
if (!best || h.priority > best.priority) best = h;
});
return best;
}
function _emit(name, detail) {
const bus = window.feedBack;
if (bus && typeof bus.emit === 'function') bus.emit(name, detail);
}
// ── Open / close ─────────────────────────────────────────────────────────
function openPane(id, opts) {
opts = opts || {};
const spec = specs.get(id);
if (!spec) { console.warn('[panes] open: no such pane:', id); return false; }
if (open.has(id)) { focusPane(id); return true; }
let el;
try { el = spec.element(); } catch (e) { el = null; }
if (!(el instanceof Element)) {
console.warn('[panes] open: pane has no element yet:', id);
return false;
}
const host = _resolveHost(opts.host || spec.defaultHost);
if (!host) { console.error('[panes] open: no host available for', id); return false; }
// Where the element lives right now, so docking can put it back EXACTLY
// there — same parent, same position among its siblings. Anything less and
// a docked panel reappears at the bottom of its container, or not at all.
const home = { parent: el.parentNode, next: el.nextSibling };
// An element on its way OUT of this document must not carry a class whose
// whole job is to hide it IN this document. `.fb-pane-detached` is
// `display:none !important`, and it travels with the node — straight into
// the pane window, which then renders nothing at all.
el.classList.remove('fb-pane-detached');
// Make it visible, and remember exactly how it wasn't.
//
// A plugin's panel is usually hidden until its launcher is clicked, and a
// pane can be opened from the tray or the rail without that ever happening.
// So we un-hide it — but only in the two ways a panel is actually hidden
// (`hidden`, or an inline `display:none`), and we put both back on dock.
//
// Note what we do NOT do: force a `display`. A panel that is `display:flex`
// must stay flex. Neutralising placement is one thing; silently re-laying
// out someone's panel is another.
const vis = { hidden: el.hidden, display: el.style.display };
el.hidden = false;
if (el.style.display === 'none') el.style.display = '';
try {
host.place(spec, el);
} catch (e) {
console.error('[panes] host', host.id, 'failed to take', id, e);
el.hidden = vis.hidden;
el.style.display = vis.display;
return false;
}
open.set(id, { spec, hostId: host.id, el, home, vis });
if (opts.remember !== false) _rememberHost(id, host.id);
if (spec.onHost) { try { spec.onHost(host.id, el); } catch (e) { console.error('[panes]', id, 'onHost threw', e); } }
// `home` rides along because the element has LEFT this document — anything
// that wants to mark the hole it left (the chip's stub) needs to know where
// the hole is, and can no longer ask the element itself.
_emit('panes:opened', { id: id, host: host.id, el: el, home: home });
return true;
}
function closePane(id, opts) {
opts = opts || {};
const entry = open.get(id);
if (!entry) return false;
open.delete(id);
// ORDER IS LOAD-BEARING: bring the element home BEFORE the host lets go of
// it. The host's unplace() closes the pane window, and closing a window
// tears down its document — with the element still inside it. The node
// survives (we hold a reference) but comes back stripped of its event
// listeners, so the panel returns looking perfect and completely dead: no
// buttons, no sliders, nothing.
//
// Adopt first, while the pane window is still alive, and the node moves out
// of a living document into a living document, which is the only case the
// DOM actually guarantees.
// ADOPT UNCONDITIONALLY, INSERT CONDITIONALLY. The rescue and the
// re-homing are two different jobs, and only one of them is allowed to
// fail.
//
// Adopting is what saves the element: it transfers ownership away from the
// pane window's document, so that document can be destroyed without taking
// the listeners with it. Do that FIRST, and always — even when there is
// nowhere to put the element afterwards.
//
// Re-homing can legitimately be impossible: the panel may never have had a
// parent (a plugin that builds it lazily and hands it straight to us), or
// its container may have been torn down while the pane was out (a screen
// change). Gating the adopt on a reachable home would mean that in exactly
// those cases we leave the element inside a window we are about to close —
// which is the "comes home dead" failure this whole ordering exists to
// prevent. It just moves it from the common path to the rare one, where it
// is far harder to spot.
//
// With no home, the element ends up owned by this document but not in it:
// detached, intact, listeners alive, and ready for the plugin to re-insert
// whenever it rebuilds its UI.
try {
// adoptNode, not appendChild: the node's owner is currently the pane
// window's document, and adopting is what transfers ownership back.
const node = document.adoptNode(entry.el);
const home = entry.home;
if (home && home.parent && home.parent.isConnected) {
if (home.next && home.next.parentNode === home.parent) home.parent.insertBefore(node, home.next);
else home.parent.appendChild(node);
} else {
console.warn('[panes]', id, 'has no home to return to — the element is detached but intact');
}
} catch (e) {
console.error('[panes] could not bring', id, 'back out of its pane window', e);
}
const host = hosts.get(entry.hostId);
try { if (host) host.unplace(id, entry.el); } catch (e) { console.error('[panes] host', entry.hostId, 'threw releasing', id, e); }
// Put its visibility back exactly as we found it. A panel that was closed
// when the pane was opened from the tray goes back to being closed; one that
// was open stays open. We forced it visible; we un-force it.
if (entry.vis) {
entry.el.hidden = entry.vis.hidden;
entry.el.style.display = entry.vis.display;
}
if (opts.remember !== false) _rememberHost(id, null);
if (entry.spec.onHost) { try { entry.spec.onHost(null, entry.el); } catch (e) { /* non-fatal */ } }
_emit('panes:closed', { id: id, host: entry.hostId });
return true;
}
function focusPane(id) {
const entry = open.get(id);
if (!entry) return false;
const host = hosts.get(entry.hostId);
if (host && typeof host.focus === 'function') host.focus(id);
return true;
}
// What the pop-out chip calls: put this pane wherever a pane most wants to
// live. That is a window if one can be had, and the dock otherwise.
function detach(id) {
const spec = specs.get(id);
return openPane(id, { host: (spec && spec.defaultHost) || 'window' });
}
function dock(id) {
if (open.has(id)) closePane(id, { remember: false });
return openPane(id, { host: 'dock' });
}
// ── Registry ─────────────────────────────────────────────────────────────
function register(spec) {
const s = _normalize(spec);
if (specs.has(s.id)) {
// First registration wins, matching libraryCardActions.register. A
// silent overwrite would swap the element out from under an open pane.
console.warn('[panes] pane already registered, ignoring:', s.id);
return () => {};
}
specs.set(s.id, s);
_emit('panes:registered', { id: s.id, title: s.title });
// Reopen where the user left it. Deferred a tick so a plugin can call
// register() and attachChip() back to back — the chip must exist before
// the pane opens, or it has nothing to hide.
//
// A host may refuse to be auto-restored: a browser blocks window.open()
// without a user gesture, so restoring a popped-out pane on page load
// would only ever produce a "pop-up blocked" toast. Such a pane comes back
// in the dock, and the chip pops it out again on the user's next click.
let remembered = _rememberedHost(s.id);
if (remembered) {
const h = hosts.get(remembered);
if (h && h.autoRestore === false) remembered = 'dock';
setTimeout(() => { if (specs.has(s.id) && !open.has(s.id)) openPane(s.id, { host: remembered, remember: false }); }, 0);
}
return () => unregister(s.id);
}
function unregister(id) {
if (open.has(id)) closePane(id, { remember: false });
specs.delete(id);
_emit('panes:unregistered', { id: id });
}
function registerHost(host) {
if (!host || !host.id) throw new TypeError('panes: host needs an id');
hosts.set(host.id, {
id: host.id,
priority: host.priority || 0,
autoRestore: host.autoRestore !== false,
available: typeof host.available === 'function' ? host.available : () => true,
place: host.place,
unplace: host.unplace,
focus: host.focus,
});
}
const api = {
version: 2,
register,
unregister,
open: openPane,
close: closePane,
detach,
dock,
focus: focusPane,
isOpen: (id) => open.has(id),
hostOf: (id) => { const e = open.get(id); return e ? e.hostId : null; },
// Where an open pane's element came from. The chip needs this to mark the
// hole the element left, since it can no longer ask the element itself.
homeOf: (id) => { const e = open.get(id); return e ? e.home : null; },
// The element a host actually took. The chip needs this to tell "the pane
// took MY element" from "the pane took something else" — and it cannot ask
// the element, which may now be in a dock card or another window entirely.
elementOf: (id) => { const e = open.get(id); return e ? e.el : null; },
get: (id) => specs.get(id) || null,
list: () => Array.from(specs.values()).map((s) => ({
id: s.id, title: s.title, icon: s.icon,
open: open.has(s.id), host: (open.get(s.id) || {}).hostId || null,
})),
registerHost,
};
window.feedBack = window.feedBack || {};
window.feedBack.panes = Object.assign(window.feedBack.panes || {}, api);
})();
+355
View File
@@ -0,0 +1,355 @@
/*
* fee[dB]ack the pop-out window host.
*
* Opens a real OS window and MOVES THE PANE'S ELEMENT INTO IT.
*
* The move is the whole trick, and it works because the pane window is same-origin
* and opener-linked: `document.adoptNode()` re-parents a live node into another
* window's document, and an adopted node keeps its event listeners, its closures,
* and every reference anything else holds to it. So the plugin's panel goes on
* running the plugin's own code in the plugin's own realm it is just being
* *displayed* somewhere else. It looks and behaves exactly like what was popped
* out, because it is exactly what was popped out.
*
* That is why this file must use `window.open()` and not ask the desktop's main
* process to make a BrowserWindow: a window we didn't open gives us no handle to
* its document, and without the handle there is nothing to adopt into.
*
* Electron turns this same-origin `window.open()` into a real BrowserWindow anyway
* its setWindowOpenHandler answers same-origin URLs with `action: 'allow'` and
* the main process then recognises the window by its frame name and gives it
* remembered bounds, skip-taskbar and a system-tray entry. So we get the OS window
* AND the DOM link. (That code lives in the separate desktop repo,
* got-feedback/feedBack-desktop: src/main/main.ts and src/main/pane-hosts.ts. It is
* not in this repo, and nothing here depends on it in a plain browser this is
* simply a pop-up.)
*
* Styles come across too the pane document starts empty, so we copy the app's
* stylesheets into it. Without that the panel would land unstyled, which is the
* one thing a "pop out exactly this" feature cannot do.
*/
(function () {
'use strict';
const panes = window.feedBack && window.feedBack.panes;
if (!panes || typeof panes.registerHost !== 'function') {
console.error('[panes] pane-manager.js must load before pane-window-host.js');
return;
}
// The frame name every pane window is opened with. In the desktop app the main
// process matches on this prefix to recognise a pane window and give it its
// remembered bounds, skip-taskbar and tray entry — so changing it here without
// changing it there silently downgrades every pane to a plain pop-up.
//
// The other half lives in a DIFFERENT REPO (got-feedback/feedBack-desktop,
// src/main/pane-hosts.ts). There is no build-time link between them; this comment
// is the link.
const FRAME_PREFIX = 'fbpane-';
const wins = new Map(); // paneId -> Window
let reaper = null;
// A pane window the user closed with the OS X button gets no reliable
// beforeunload (a crashed renderer certainly gets none). Poll `closed` and
// reap — otherwise the pane stays "open" forever, its chip stays stubbed out,
// and the element it holds is stranded in a dead document with no way back.
function _startReaper() {
if (reaper != null) return;
reaper = setInterval(() => {
wins.forEach((w, id) => { if (w.closed) panes.close(id); });
if (!wins.size) { clearInterval(reaper); reaper = null; }
}, 400);
}
// Give the pane document the app's styles, so the panel looks identical.
// Cloned rather than shared: a <link> node can only live in one document, and
// we are not about to steal the app's own stylesheet out of its head.
function _copyStyles(doc) {
// pane.html already links panes.css, so don't clone a second copy of it —
// duplicate sheets cost a redundant fetch and an extra style recalc for no
// change in appearance.
const own = Array.from(doc.querySelectorAll('link[rel="stylesheet"]'));
const have = new Set(own.map((l) => l.href));
// Insert the app's sheets BEFORE pane.html's own, not after.
//
// Cascade order is the whole game here. In the app document panes.css loads
// LAST, after tailwind/style/v3 — so its rules win ties. Appending the app's
// sheets into the pane document would put them after panes.css and silently
// invert that, letting core styles override the pane chrome and the .fb-paned
// placement rules. "Looks identical" has to include the order things are
// said in.
const anchor = own[0] || null;
document.querySelectorAll('link[rel="stylesheet"], style').forEach((node) => {
if (node.tagName === 'LINK' && have.has(node.href)) return;
try { doc.head.insertBefore(node.cloneNode(true), anchor); } catch (e) { /* skip a node we can't clone */ }
});
_syncChrome(doc);
}
// The theme/scale hooks the app hangs on <html> and <body>. v3 keys off these
// for its colour tokens and its interface scale, and a panel that lands without
// them renders in the wrong palette at the wrong size.
//
// MERGE, don't assign: pane.html sets `class="fb-pane-window"` on <html>, and
// panes.css hangs the pane window's own chrome off it. Overwriting the class
// list would take that with it and the window would lose its own layout — the
// app's classes and the pane document's are both wanted.
//
// Re-run on every theme/scale change for as long as the pane is open (see
// _followChrome). A one-time snapshot would leave an already-open pane rendering
// at the old scale the moment the user touched Interface size — "looks identical"
// has to keep being true, not merely start out true.
function _syncChrome(doc) {
try {
document.documentElement.classList.forEach((c) => doc.documentElement.classList.add(c));
document.body.classList.forEach((c) => doc.body.classList.add(c));
// The inline style on <html> carries the interface-scale custom property
// (--fb-scale). Assign it wholesale: unlike the class lists, pane.html
// sets no inline style of its own, so there is nothing here to preserve —
// and merging by concatenation would grow the attribute without bound as
// the user dragged the scale slider.
doc.documentElement.style.cssText = document.documentElement.style.cssText;
} catch (e) { /* the window may be closing under us */ }
}
// paneId -> stop following the app's theme/scale
const chromeFollowers = new Map();
function _followChrome(paneId, doc) {
const bus = window.feedBack;
if (!bus || typeof bus.on !== 'function') return;
const sync = () => _syncChrome(doc);
bus.on('scale:changed', sync);
bus.on('theme:changed', sync);
bus.on('v3:cosmetics-applied', sync);
chromeFollowers.set(paneId, () => {
bus.off('scale:changed', sync);
bus.off('theme:changed', sync);
bus.off('v3:cosmetics-applied', sync);
});
}
function _unfollowChrome(paneId) {
const off = chromeFollowers.get(paneId);
if (off) { off(); chromeFollowers.delete(paneId); }
}
// How long a "we cannot even see the pop-out's document" condition has to persist
// before we call it fatal. A SecurityError means the window is not reachable from
// this realm at all, and waiting cannot fix that — but we give it a moment anyway
// rather than bailing on the first tick, because a throw *during* the navigation
// from about:blank to /pane would otherwise take down a pop-out that was about to
// work perfectly. A second is far more than that transition needs, and far less
// than the 10s a user would otherwise stare at a detached panel for.
const UNREACHABLE_GRACE_MS = 1000;
// Wait for the REAL pane document.
//
// window.open() returns immediately, with an `about:blank` document that is
// already readyState 'complete'. Adopt into that and it works for a few
// milliseconds — and then /pane finishes loading, replaces the document, and
// takes the panel with it. The window is left blank and the element is gone.
//
// So we do not trust readyState, and we do not trust 'load' (which may have
// fired for about:blank before we could listen). We wait for the one thing that
// only exists in the document we actually want: pane.html's #fb-pane-root.
function _whenReady(w, onReady, onFail) {
const deadline = performance.now() + 10000;
let reachFailure = null; // why we could never see the pop-out's document
let reachFailureAt = 0; // when we first couldn't
const tick = () => {
if (w.closed) return;
let doc = null;
try { doc = w.document; }
catch (e) {
// A SecurityError here is the one that matters: it means the pop-out
// is not reachable from this realm at all (a separate process /
// browsing-context group), and no amount of waiting will fix it —
// adoptNode can never work.
doc = null;
if (!reachFailure) reachFailureAt = performance.now();
reachFailure = e;
}
// Unreachable, and it has stayed that way. Fail now rather than leaving
// the panel detached and the UI mid-pop-out for the full 10s deadline,
// when we already know this can never succeed.
if (reachFailure && !doc && performance.now() - reachFailureAt > UNREACHABLE_GRACE_MS) {
onFail(new Error('the pane window\'s document is NOT reachable from this window ('
+ reachFailure.name + ': ' + reachFailure.message
+ ') — it is in a separate process, so the element cannot be moved into it'));
return;
}
if (doc && doc.readyState !== 'loading') {
// Only ever adopt into the document we actually navigated TO.
// about:blank reports readyState 'complete' from the moment
// window.open() returns, and adopting into it means the panel is
// destroyed when /pane replaces it a moment later.
const href = (doc.location && doc.location.href) || '';
const isPaneDoc = href.indexOf('/pane') >= 0;
if (isPaneDoc) {
// Prefer pane.html's own root, but never fail for want of it —
// a stale cached copy of the page (or a future rename) must not
// leave the user with a blank window and no panel.
const root = doc.getElementById('fb-pane-root') || doc.body;
if (root) { onReady(root); return; }
}
}
if (performance.now() > deadline) {
let why;
if (reachFailure) {
why = 'the pane window\'s document is NOT reachable from this window ('
+ reachFailure.name + ': ' + reachFailure.message
+ ') — it is in a separate process, so the element cannot be moved into it';
} else if (!doc) {
why = 'the pane window exposed no document at all';
} else {
why = 'the pane window never loaded /pane (it is showing '
+ ((doc.location && doc.location.href) || 'an unknown URL')
+ ', readyState ' + doc.readyState + ')';
}
onFail(new Error(why));
return;
}
setTimeout(tick, 25);
};
tick();
}
function _adopt(w, root, spec, el) {
const doc = w.document;
_copyStyles(doc);
// The panel was almost certainly a fixed/absolute overlay pinned to a
// corner of the app. In a window of its own that positioning is nonsense —
// it would sit 72px from the top of a 380px window, still 288px wide, still
// casting a drop shadow over nothing. Neutralise the *placement* while
// touching nothing else about how it looks.
el.classList.add('fb-paned');
root.appendChild(doc.adoptNode(el));
doc.title = spec.title + ' — fee[dB]ack';
// Keep the pane window's theme and interface scale in step with the app for
// as long as it is open. Stopped in unplace().
_followChrome(spec.id, doc);
// THE ELEMENT MUST LEAVE BEFORE THE DOCUMENT DIES.
//
// When the user closes a pane window, its document is torn down — and the
// panel is inside it. The node itself survives (we hold a reference) and
// comes home looking perfect: right markup, right classes, right size. But
// it comes home DEAD: every event listener in the subtree is gone with the
// document that hosted them. A panel that renders and does nothing.
//
// The `closed` poll cannot save us: by the time `w.closed` is true, the
// document is already gone. `beforeunload` fires while it is still alive, so
// this is the last moment we can get the element out — and panes.close()
// adopts it back into the main document synchronously.
//
// We attach it HERE, not when the window was opened: back then the window
// still held its throwaway about:blank document, and a listener registered
// on that is discarded when /pane replaces it.
w.addEventListener('beforeunload', () => {
if (panes.isOpen(spec.id)) panes.close(spec.id);
});
}
function place(spec, el) {
const w = window.open(
window.location.origin + '/pane',
FRAME_PREFIX + spec.id,
'popup,width=' + spec.width + ',height=' + spec.height,
);
if (!w) {
// Popup blocked. Throw BEFORE the manager records anything, so the
// caller's panel stays exactly where it is — and say so out loud rather
// than appearing to do nothing.
if (window.fbNotify) {
window.fbNotify.show({
title: 'Pop-out blocked',
message: 'Allow pop-ups for this site to detach ' + spec.title + '.',
icon: '⚠️', accent: '#f59e0b',
});
}
throw new Error('pop-up blocked');
}
wins.set(spec.id, w);
_startReaper();
// Take the element out of the document NOW, not when the window is ready.
//
// Everything below this line is async: the window has to load /pane before
// there is anything to adopt into. But the manager emits `panes:opened` as
// soon as we return, and the chip reacts by putting its "popped out" stub
// where the element used to be — so for that whole gap the user would see
// BOTH the real panel and a stub claiming it had left. On a window that
// never loads, that lasts the full 10s timeout.
//
// Detaching is not destructive: the node keeps its owner document (this
// one), its listeners and its closures. It is simply out of the tree,
// waiting — and if the window never loads, closePane() puts it straight
// back at its home.
el.remove();
_whenReady(w, (root) => {
try { _adopt(w, root, spec, el); }
catch (e) {
console.error('[panes] failed to move', spec.id, 'into its window', e);
panes.close(spec.id); // brings the element home
}
}, (err) => {
console.error('[panes]', spec.id, err);
panes.close(spec.id); // never strand the element in a dead window
});
// The pane window's 'beforeunload' listener is registered in _adopt(), NOT
// here: a listener added now would attach to the window's throwaway
// about:blank document and be discarded when /pane replaces it.
}
function unplace(id, el) {
_unfollowChrome(id);
// Hand the element back unmarked. The manager returns it to its home right
// after this, and it must arrive as the plugin left it — a panel that
// stayed .fb-paned would come back with its own positioning stripped.
if (el) el.classList.remove('fb-paned');
const w = wins.get(id);
wins.delete(id);
// The manager adopts the element back into this document immediately after
// this returns, so the window is empty by the time it closes.
if (w && !w.closed) { try { w.close(); } catch (e) { /* already gone */ } }
}
function focus(id) {
const w = wins.get(id);
if (w && !w.closed) { try { w.focus(); } catch (e) { /* the OS may refuse */ } }
}
// A BROWSER blocks window.open() outside a user gesture, so a pane remembered
// here cannot be restored on page load — it would only ever produce a "blocked"
// toast. Such a pane comes back in the dock, and the chip pops it out again on
// the user's next click. The DESKTOP app has no such restriction, so there a
// pane left popped out comes back popped out, where you left it.
const isDesktop = !!(window.feedBackDesktop && window.feedBackDesktop.panes);
panes.registerHost({
id: 'window',
priority: 10,
autoRestore: isDesktop,
place, unplace, focus,
});
// Our windows; they must not outlive us. A pane window whose opener is gone
// holds an element belonging to a dead document — there is nothing left to
// dock it back into.
window.addEventListener('beforeunload', () => {
wins.forEach((w) => { if (!w.closed) { try { w.close(); } catch (e) { /* ignore */ } } });
});
})();
+24
View File
@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en" class="fb-pane-window">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>fee[dB]ack</title>
<link rel="icon" href="/static/assets/favicon.png">
<!-- Deliberately almost empty.
This document does not build a pane; it RECEIVES one. The opener moves the
real element in here with document.adoptNode() and copies the app's
stylesheets across, so the panel arrives complete — its own markup, its own
CSS, its own listeners, its own closures, still running the plugin's code
back in the main window.
So there is nothing to load, nothing to boot, and nothing to keep in step
with the app. Only panes.css, for the window chrome and the layout reset the
adopted element needs. -->
<link rel="stylesheet" href="/static/panes/panes.css">
</head>
<body>
<main id="fb-pane-root"></main>
</body>
</html>
+193
View File
@@ -0,0 +1,193 @@
/* fee[dB]ack detachable panes.
*
* Hand-authored (not Tailwind-scanned) so a runtime-installed plugin gets the chip
* and the dock without shipping its own stylesheet the same reason .fb-selectable
* is hand-authored in core CSS.
*
* Z-index: the dock is a child of <body>, so it is NOT on the ladder from
* docs/plugin-v3-ui.md (transport 20, rail 30, popovers 40) those numbers live
* *inside* #player's stacking context, and #player itself is `position:fixed;
* z-index:100` covering the viewport. A dock below 100 is invisible on the one
* screen panes exist for. Body-level ladder: #player 100 < dock 110 < toasts 120
* < modals 200.
*/
/* The popped-out element
*
* The single most important rule in this file.
*
* A plugin's panel is almost always a fixed overlay pinned to a corner of the app:
* `position:fixed; top:72px; right:18px; width:288px; z-index:99999`, with a drop
* shadow and a max-height sized against the viewport. Inside a dock card, or alone
* in a 320px window, every one of those is wrong it would float 72px down from
* the top of its own window, still 288px wide, still casting a shadow over nothing.
*
* So we neutralise PLACEMENT and nothing else. Colours, borders, radius, padding,
* fonts, the panel's own internal layout: all untouched, because the whole promise
* of this feature is that what you popped out is what you get. */
.fb-paned {
position: static !important;
inset: auto !important;
margin: 0 !important;
width: 100% !important;
max-width: none !important;
max-height: none !important;
z-index: auto !important;
box-shadow: none !important;
/* Deliberately NO `display` override. Forcing `display:block` would silently
re-lay-out a panel that is `display:flex` or `grid` which is the opposite
of "placement only", and exactly the kind of surprise this feature exists to
avoid. Making a hidden panel visible is the manager's job (it clears the
element's `hidden`/inline `display:none` on open and restores them on dock),
and it does it without touching the panel's own display mode. */
}
/* ── The pop-out chip ────────────────────────────────────────────────────── */
.fb-pane-chip {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border: 1px solid rgba(51, 65, 85, .7);
border-radius: .4rem;
background: rgba(30, 41, 59, .8);
color: #94a3b8;
font-size: .8rem;
line-height: 1;
cursor: pointer;
transition: color .15s, border-color .15s, background .15s;
}
.fb-pane-chip:hover {
color: #e2e8f0;
border-color: #4080e0;
background: rgba(64, 128, 224, .18);
}
.fb-pane-chip:focus-visible { outline: 2px solid #4080e0; outline-offset: 1px; }
/* The panel, while its pane is popped out. A dedicated class rather than
.hidden/[hidden]: the panels we attach to toggle those themselves, and two owners
of one class is a bug waiting for a bad day. */
.fb-pane-detached { display: none !important; }
/* What the user sees in the panel's place. */
.fb-pane-stub {
display: inline-flex;
align-items: center;
gap: .4rem;
padding: .35rem .6rem;
border: 1px dashed rgba(64, 128, 224, .55);
border-radius: .5rem;
background: rgba(64, 128, 224, .08);
color: #93b4e8;
font-size: .72rem;
cursor: pointer;
transition: background .15s, border-color .15s;
}
.fb-pane-stub:hover { background: rgba(64, 128, 224, .18); border-color: #4080e0; }
.fb-pane-stub:focus-visible { outline: 2px solid #4080e0; outline-offset: 1px; }
.fb-pane-stub-glyph { font-size: .85rem; }
/* ── The dock ────────────────────────────────────────────────────────────── */
.fb-pane-dock {
position: fixed;
top: 4.5rem;
right: 1rem;
bottom: 1rem;
z-index: 110; /* above #player (100), below toasts (120) */
width: 22rem;
max-width: calc(100vw - 2rem);
display: flex;
flex-direction: column;
gap: .6rem;
overflow-y: auto;
overflow-x: hidden;
/* A frame around cards, not a surface the empty space below them must never
eat a click meant for the highway. */
pointer-events: none;
scrollbar-width: thin;
}
.fb-pane-dock.is-empty { display: none; }
.fb-pane-card {
pointer-events: auto;
flex: 0 0 auto;
display: flex;
flex-direction: column;
background: rgba(15, 23, 42, .96);
border: 1px solid rgba(51, 65, 85, .6);
border-radius: .9rem;
box-shadow: 0 12px 40px rgba(0, 0, 0, .5);
overflow: hidden;
}
.fb-pane-card-head {
display: flex;
align-items: center;
gap: .5rem;
padding: .5rem .7rem;
border-bottom: 1px solid rgba(51, 65, 85, .5);
background: rgba(30, 41, 59, .6);
}
.fb-pane-card-title {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: .78rem;
font-weight: 600;
color: #cbd5e1;
}
.fb-pane-card-btn {
flex: 0 0 auto;
width: 1.4rem;
height: 1.4rem;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: .35rem;
background: transparent;
color: #94a3b8;
font-size: .75rem;
line-height: 1;
cursor: pointer;
}
.fb-pane-card-btn:hover { background: rgba(51, 65, 85, .7); color: #e2e8f0; }
.fb-pane-card-btn:focus-visible { outline: 2px solid #4080e0; outline-offset: 1px; }
.fb-pane-card-body { overflow: auto; }
/* focus(id) a brief highlight, so re-opening an already-open pane says so
instead of appearing to do nothing. */
.fb-pane-card.is-flash { animation: fb-pane-flash .7s ease-out; }
@keyframes fb-pane-flash {
0% { border-color: #4080e0; box-shadow: 0 0 0 3px rgba(64, 128, 224, .35), 0 12px 40px rgba(0, 0, 0, .5); }
100% { border-color: rgba(51, 65, 85, .6); box-shadow: 0 12px 40px rgba(0, 0, 0, .5); }
}
@media (prefers-reduced-motion: reduce) {
.fb-pane-card.is-flash { animation: none; }
}
/* The pop-out window (static/panes/pane.html)
*
* The window's own chrome everything INSIDE it is the adopted element, styled by
* the app's stylesheets, which the host copies into this document. */
html.fb-pane-window,
html.fb-pane-window body {
margin: 0;
padding: 0;
height: 100%;
background: #0f172a;
}
html.fb-pane-window body { display: flex; flex-direction: column; overflow: hidden; }
#fb-pane-root {
flex: 1 1 auto;
overflow: auto;
padding: .6rem;
}
+1 -1
View File
File diff suppressed because one or more lines are too long
+11 -2
View File
@@ -120,11 +120,20 @@
if (!r.ok) return;
const data = await r.json();
_tuningsByKey = data.tunings || {};
// Build TUNING_NOTE from the first (lowest) string frequency of each tuning.
// Build TUNING_NOTE from the lowest string of each tuning. Prefer the
// exact integer midis the server now sends (tuningMidis, #829) — the
// frequency path reconstructs the note via log2 against a hardcoded
// 440 and can land a semitone off at non-440 reference pitches.
// Frequencies remain the fallback for older cached responses.
const midisByKey = data.tuningMidis || {};
TUNING_NOTE = {};
for (const key of Object.keys(_tuningsByKey)) {
for (const [name, freqs] of Object.entries(_tuningsByKey[key])) {
if (!(name in TUNING_NOTE) && Array.isArray(freqs) && freqs.length > 0) {
if (name in TUNING_NOTE) continue;
const midis = midisByKey[key] && midisByKey[key][name];
if (Array.isArray(midis) && midis.length > 0 && Number.isFinite(midis[0])) {
TUNING_NOTE[name] = NOTE_NAMES[((midis[0] % 12) + 12) % 12];
} else if (Array.isArray(freqs) && freqs.length > 0) {
TUNING_NOTE[name] = _freqToNote(freqs[0]);
}
}
+8 -2
View File
@@ -33,7 +33,8 @@
// Accuracy badge ramp (design/04-badges.md §C): ≥90% good, 5089% mid, <50% low.
function accuracyBadge(acc) {
if (acc == null) return '';
const pct = Math.round(acc * 100);
// Floor, never round: 100% must mean every note hit.
const pct = Math.floor(acc * 100);
const color = acc >= 0.9 ? 'bg-fb-good' : (acc >= 0.5 ? 'bg-fb-mid' : 'bg-fb-low');
const text = acc >= 0.5 && acc < 0.9 ? 'text-black' : 'text-white';
return '<span class="absolute bottom-0 right-0 ' + color + '/90 ' + text +
@@ -207,12 +208,17 @@
'</div></div></div>' +
continueCard +
'</div>' +
// Stats row
// Stats row. The third slot belongs to the career plugin (it
// replaces the slot's content on v3:dashboard-rendered); the
// plugin-count stat is the built-in fallback when career is
// absent or has no state yet.
'<div class="grid md:grid-cols-3 gap-6 mt-6">' +
audioRoutingCard() +
statCard(String(songCount), 'songs', 'text-fb-gold') +
'<div id="v3-dash-career-slot" class="grid">' +
statCard(String(pluginCount), 'active', 'text-fb-good') +
'</div>' +
'</div>' +
recentSection +
'</div>';
+41
View File
@@ -99,6 +99,10 @@
<link rel="stylesheet" href="/static/tour-engine.css">
<!-- v0.3.0 shell styles (radial-gradient bg, custom scrollbars). -->
<link rel="stylesheet" href="/static/v3/v3.css">
<!-- Detachable panes: the pop-out chip, the dock, and the widgets built-in
panes render with. Hand-authored (not Tailwind-scanned) so a
runtime-installed plugin can use the chip without shipping its own CSS. -->
<link rel="stylesheet" href="/static/panes/panes.css">
<!-- EVERY external script below is `defer`. Do not add a plain one.
`defer` and `type="module"` scripts share a single "execute after
parsing" list and run in DOCUMENT ORDER; a plain classic script runs
@@ -749,6 +753,21 @@
<a href="https://github.com/got-feedback/feedback-desktop/releases" target="_blank" rel="noopener" class="text-accent hover:text-accent-light underline">download new versions from GitHub Releases</a>.
</p>
</div>
<!-- Window options — desktop-only; setupWindowOptions() unhides. -->
<div id="window-options-block" class="hidden">
<div class="fb-srow">
<div class="fb-srow-main">
<div class="fb-srow-title">Fullscreen</div>
<div class="fb-srow-desc">Run fee[dB]ack in fullscreen mode. On macOS, changes take effect on the next launch.</div>
</div>
<div class="fb-srow-control">
<label class="fb-switch">
<input type="checkbox" id="setting-start-fullscreen">
<span class="fb-switch-track"></span>
</label>
</div>
</div>
</div>
<!-- Library folder path -->
<div class="fb-srow fb-srow-stack">
<div class="fb-srow-main">
@@ -1048,6 +1067,10 @@
<span class="v3-rail-border"></span>
<svg class="v3-rail-svg" viewBox="0 0 24 24" aria-hidden="true"><path d="M12,2A3,3 0 0,1 15,5V11A3,3 0 0,1 12,14A3,3 0 0,1 9,11V5A3,3 0 0,1 12,2M19,11C19,14.53 16.39,17.44 13,17.93V21H11V17.93C7.61,17.44 5,14.53 5,11H7A5,5 0 0,0 12,16A5,5 0 0,0 17,11H19Z"/></svg>
</button>
<button class="v3-rail-icon" type="button" data-rail="panes" aria-haspopup="true" aria-expanded="false" aria-controls="v3-rail-pop-panes" title="Panes" aria-label="Panes">
<span class="v3-rail-border"></span>
<svg class="v3-rail-svg" viewBox="0 0 24 24" aria-hidden="true"><path d="M19,4H5A2,2 0 0,0 3,6V18A2,2 0 0,0 5,20H19A2,2 0 0,0 21,18V6A2,2 0 0,0 19,4M13,18H5V6H13V18M19,18H15V6H19V18Z"/></svg>
</button>
<button class="v3-rail-icon" type="button" data-rail="plugins" aria-haspopup="true" aria-expanded="false" aria-controls="v3-rail-pop-plugins" title="Plugin controls" aria-label="Plugin controls">
<span class="v3-rail-border"></span>
<span class="v3-rail-badge" id="v3-plugin-count" hidden></span>
@@ -1064,6 +1087,15 @@
injects into #player-controls (the auto-hiding transport) into
this stable, always-reachable popover. See player-chrome.js
(rehoming MutationObserver). -->
<!-- Panes: open/close any registered detachable pane. Populated from
the pane registry by static/panes/pane-launcher.js — a plugin
that calls feedBack.panes.register() shows up here for free. -->
<div id="v3-rail-pop-panes" class="v3-rail-pop hidden" role="group" aria-label="Panes">
<div class="v3-pop-label">Panes</div>
<div id="v3-rail-panes-list" class="flex flex-col gap-1"></div>
<p class="text-xs text-gray-500 px-1 pb-1 leading-snug">Panes stay open while you play, and across song switches.</p>
</div>
<div id="v3-rail-pop-plugins" class="v3-rail-pop hidden" role="group" aria-label="Plugin controls">
<div class="v3-pop-label">Plugin controls</div>
<div id="v3-plugin-controls-slot" class="v3-plugin-slot"></div>
@@ -1300,6 +1332,15 @@
<script defer src="/static/v3/interface-size-nudge.js"></script>
<script defer src="/static/v3/feedbarcade.js"></script>
<script defer src="/static/v3/player-chrome.js"></script>
<!-- Detachable panes. The manager first; then the hosts, which register
themselves with it; then the chip and the launcher, which drive it.
pane-desktop only does anything inside the desktop app. -->
<script defer src="/static/panes/pane-manager.js"></script>
<script defer src="/static/panes/pane-dock.js"></script>
<script defer src="/static/panes/pane-window-host.js"></script>
<script defer src="/static/panes/pane-desktop.js"></script>
<script defer src="/static/panes/pane-chip.js"></script>
<script defer src="/static/panes/pane-launcher.js"></script>
<script>
// Navbar scroll effect
window.addEventListener('scroll', () => {
+1 -1
View File
@@ -74,7 +74,7 @@
if (st && st.passed) return '<span class="text-fb-good text-xs font-bold flex items-center gap-1">✓ Passed</span>';
if (st && st.best_accuracy != null && st.best_accuracy > 0) {
const acc = st.best_accuracy;
const pct = Math.round(acc * 100);
const pct = Math.floor(acc * 100);
const color = acc >= 0.9 ? 'text-fb-good' : (acc >= 0.5 ? 'text-fb-mid' : 'text-fb-low');
return '<span class="' + color + ' text-xs font-bold">' + pct + '%</span>';
}
+2 -1
View File
@@ -21,7 +21,8 @@
function accuracyPct(hits, misses) {
const judged = hits + misses;
if (judged <= 0) return null;
return Math.round((hits / Math.max(1, judged)) * 100);
// Floor, never round: 100% must mean every judged note was hit.
return Math.floor((hits / Math.max(1, judged)) * 100);
}
function calculateLivePerformanceState({ hits = 0, misses = 0, streak = 0, bestStreak = 0 } = {}) {
+1 -1
View File
@@ -73,7 +73,7 @@
? ' data-play-fn="' + esc(playFn) + '"' + (arrIdx != null ? ' data-play-arr="' + arrIdx + '"' : '')
: '';
const acc = (isAlbum && typeof opts.acc === 'number')
? '<span class="text-xs font-bold shrink-0 ' + (opts.acc >= 0.9 ? 'text-fb-good' : opts.acc >= 0.5 ? 'text-fb-mid' : 'text-fb-low') + '">' + Math.round(opts.acc * 100) + '%</span>'
? '<span class="text-xs font-bold shrink-0 ' + (opts.acc >= 0.9 ? 'text-fb-good' : opts.acc >= 0.5 ? 'text-fb-mid' : 'text-fb-low') + '">' + Math.floor(opts.acc * 100) + '%</span>'
: '';
const pin = (isAlbum && s.arrangement)
? '<span class="ml-2 text-[0.625rem] bg-fb-primary/20 text-fb-primary font-bold px-1.5 py-0.5 rounded-sm" title="Pinned arrangement">' + esc(s.arrangement) + '</span>' : '';
+5 -1
View File
@@ -191,6 +191,10 @@
'<div class="space-y-6">' +
headerCard +
bestsCard +
// Passport wall — rendered by the career plugin on
// v3:profile-rendered (absent-not-empty: nothing shows until a
// passport exists).
'<div id="v3-profile-passports-mount"></div>' +
// Feats of Power trophy shelf — rendered by the achievements plugin
// (earned Feats only; hidden-until-earned, so empty when none).
'<div id="v3-profile-feats-slot"></div>' +
@@ -232,7 +236,7 @@
host.innerHTML =
'<ol class="space-y-2">' + rows.map((s, i) => {
const acc = Number(s.best_accuracy) || 0;
const pct = Math.round(acc * 100);
const pct = Math.floor(acc * 100);
const score = Number(s.best_score) || 0;
return '<li data-fn="' + esc(s.filename) + '" class="flex items-center gap-3 cursor-pointer rounded-md px-2 py-1.5 hover:bg-fb-card transition">' +
'<span class="w-5 text-center text-fb-textDim font-semibold shrink-0">' + (i + 1) + '</span>' +
+1 -1
View File
@@ -265,7 +265,7 @@
const onboarding = st.onboarding || {};
if (onboarding.calibration_status === 'completed') return; // raced a 100% run
const pending = onboarding.calibration_status === 'pending';
const pct = Math.max(0, Math.min(100, Math.round((detail.accuracy || 0) * 100)));
const pct = Math.max(0, Math.min(100, Math.floor((detail.accuracy || 0) * 100)));
const overlay = document.createElement('div');
overlay.id = 'v3-calibration-retry';
+3 -2
View File
@@ -482,7 +482,8 @@
function accuracyBadge(filename, variant) {
const acc = state.accuracy[filename];
if (acc == null) return '';
const pct = Math.round(acc * 100);
// Floor, never round: 100% must mean every note hit.
const pct = Math.floor(acc * 100);
if (variant === 'tree') {
const color = acc >= MASTERY_ACCURACY ? 'text-fb-good' : acc >= 0.5 ? 'text-fb-mid' : 'text-fb-low';
return '<span class="fb-acc-badge text-xs font-bold ' + color + '">' + pct + '%</span>';
@@ -1312,7 +1313,7 @@
c.year ? String(c.year) : '']
.filter(Boolean).join(' · ');
const acc = (typeof c.best_accuracy === 'number')
? '<span class="font-bold ' + (c.best_accuracy >= MASTERY_ACCURACY ? 'text-fb-good' : c.best_accuracy >= 0.5 ? 'text-fb-mid' : 'text-fb-low') + '">' + Math.round(c.best_accuracy * 100) + '%</span>'
? '<span class="font-bold ' + (c.best_accuracy >= MASTERY_ACCURACY ? 'text-fb-good' : c.best_accuracy >= 0.5 ? 'text-fb-mid' : 'text-fb-low') + '">' + Math.floor(c.best_accuracy * 100) + '%</span>'
: '<span class="text-fb-textDim/60">not played</span>';
return '<div role="radio" aria-checked="' + (checked ? 'true' : 'false') + '" tabindex="0" data-ch="' + esc(c.filename) + '"' +
' title="' + (checked ? esc(prefLabel) : 'Make this the preferred chart') + '"' +
+81 -3
View File
@@ -27,7 +27,60 @@
let cur = null; // active session
let recordedThisSession = false;
// Wall-clock play time (career hours odometer). Accrued across
// play/resume ↔ pause/stop/ended spans — wall time, NOT song position:
// position deltas double-count A-B loops and mis-read seeks.
let playingSince = 0; // performance.now() at span start, 0 while not playing
let accruedSeconds = 0; // played time not yet sent
// Failed seconds keep their song identity — restoring them into the
// global accumulator would let the NEXT song claim them after a session
// switch. Bounded; oldest dropped beyond the cap (honest loss beats
// misattribution).
let pendingSeconds = []; // [{filename, arrangement, seconds}] awaiting retry
function queuePendingSeconds(filename, arrangement, seconds) {
pendingSeconds.push({ filename, arrangement, seconds });
if (pendingSeconds.length > 20) pendingSeconds.shift();
}
function retryPendingSeconds() {
if (!pendingSeconds.length) return;
const batch = pendingSeconds;
pendingSeconds = [];
for (const body of batch) {
post(body).then((r) => { if (r == null) queuePendingSeconds(body.filename, body.arrangement, body.seconds); });
}
}
function clockStart() { if (!playingSince) playingSince = performance.now(); }
function clockStop() {
if (!playingSince) return;
const delta = (performance.now() - playingSince) / 1000;
playingSince = 0;
// A single unbroken span beyond 2h of wall clock is a suspend/sleep
// artifact, not practice — clamp it.
if (Number.isFinite(delta) && delta > 0) accruedSeconds += Math.min(delta, 7200);
}
// Take whatever has accrued (closing any open span) for sending; the
// caller restores it if the POST fails so the time isn't lost.
function takeSeconds() {
clockStop();
const s = Math.round(accruedSeconds);
accruedSeconds = 0;
return s > 0 ? s : 0;
}
// Unsent seconds belong to the outgoing song/arrangement — flush before
// a session reset would re-attribute them.
function flushSeconds() {
const s = takeSeconds();
if (!s) return;
if (!cur || !cur.filename) return; // no session to attribute to — drop
const body = { filename: cur.filename, arrangement: cur.arrangement, seconds: s };
post(body).then((r) => { if (r == null) queuePendingSeconds(body.filename, body.arrangement, s); });
}
function reset(filename, arrangement) {
flushSeconds();
cur = {
filename: filename || null,
arrangement: Number.isFinite(arrangement) ? arrangement : 0,
@@ -48,6 +101,10 @@
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
// A 4xx/5xx JSON error body must read as FAILURE — callers
// re-queue accrued seconds on null, and a parsed error object
// would silently drop them.
if (!r.ok) return null;
try { return await r.json(); } catch (e) { return null; }
} catch (e) { return null; /* offline / endpoint absent — non-fatal */ }
}
@@ -84,6 +141,7 @@
if (!cur || !cur.filename || recordedThisSession) return;
if (!cur.scored || (cur.hits + cur.misses) <= 0) return; // no real scoring this session
recordedThisSession = true;
const seconds = takeSeconds();
const body = {
filename: cur.filename,
arrangement: cur.arrangement,
@@ -94,7 +152,9 @@
bestStreak: cur.bestStreak,
lastPlayPosition: Number.isFinite(position) ? position : cur.lastTime,
};
if (seconds) body.seconds = seconds;
post(body).then(async (response) => {
if (response == null && seconds) queuePendingSeconds(body.filename, body.arrangement, seconds);
await notifyProgression(response, body, !!natural);
// Refresh the profile badge AFTER the progression state moved so
// the rank/dB it renders are post-award values.
@@ -112,7 +172,10 @@
// Allow 0: restarting a song and stopping at the very beginning must be
// able to clear a stale Continue offset. Only negatives are invalid.
if (!Number.isFinite(position) || position < 0) return;
post({ filename: cur.filename, arrangement: cur.arrangement, lastPlayPosition: position });
const seconds = takeSeconds();
const body = { filename: cur.filename, arrangement: cur.arrangement, lastPlayPosition: position };
if (seconds) body.seconds = seconds;
post(body).then((r) => { if (r == null && seconds) queuePendingSeconds(body.filename, body.arrangement, seconds); });
}
// ── Session lifecycle ─────────────────────────────────────────────────--
@@ -164,13 +227,28 @@
});
});
// ── Play-time clock ───────────────────────────────────────────────────--
sm.on('song:play', () => { clockStart(); retryPendingSeconds(); });
sm.on('song:resume', clockStart);
// ── Finalize / resume-position ────────────────────────────────────────--
sm.on('song:ended', (e) => finalizeScored(e && e.detail && e.detail.time, true));
sm.on('song:pause', (e) => touchPosition(e && e.detail && e.detail.time));
sm.on('song:ended', (e) => {
clockStop();
finalizeScored(e && e.detail && e.detail.time, true);
// Unscored natural end: no finalize POST and no position touch
// (Continue must not point at the end of the song) — bank the play
// time on its own.
flushSeconds();
});
sm.on('song:pause', (e) => {
clockStop();
touchPosition(e && e.detail && e.detail.time);
});
sm.on('song:stop', (e) => {
// Record the scored session if it wasn't already (e.g. user closed the
// player before the track ended), then persist the resume position.
// Not a natural end — no calibration-retry prompt for deliberate quits.
clockStop();
const t = e && e.detail && e.detail.time;
finalizeScored(t, false);
touchPosition(t);
+63 -1
View File
@@ -34,6 +34,7 @@
const STREAK_MILESTONES = [25, 50, 100];
const CANPLAY_TIMEOUT_MS = 4000;
const DEV_FLAG_KEY = 'feedBack-venue-crowd-dev';
const SFX_KEY = 'feedBack-venue-crowd-sfx'; // 'on' | 'off' (default off)
// ---------------------------------------------------------------------
// Pure, clock-injected decision logic (unit-tested in
@@ -58,6 +59,15 @@
candidate = null;
lastSwitchAt = -Infinity;
},
// Commit a state NOW, bypassing stability/dwell (badge ceremony).
// Stamping lastSwitchAt makes the dwell window hold the forced
// state before the real perf machine can reassert.
force(state, nowMs) {
if (!CROWD_STATES.includes(state)) return;
current = state;
candidate = null;
lastSwitchAt = nowMs;
},
// Feed the latest perf state; returns the new crowd state when a
// transition commits, else null.
update(perfState, nowMs) {
@@ -144,7 +154,11 @@
video: abs(m.intro && m.intro.video),
audio: abs(m.intro && m.intro.audio),
};
return { loops, stingers, intro };
const sfx = {
up: abs(m.sfx && m.sfx.up),
down: abs(m.sfx && m.sfx.down),
};
return { loops, stingers, intro, sfx };
}
function ensureVideos() {
@@ -410,6 +424,30 @@
return true;
}
let _sfxEl = null;
function sfxEnabled() {
try { return localStorage.getItem(SFX_KEY) === 'on'; } catch (_) { return false; }
}
// One-shot crowd reaction on committed mood transitions (toggleable):
// up the ladder → cheer, down → boos. Committed transitions are already
// hysteresis-limited, so this can't spam.
function playMoodSfx(direction) {
if (!sfxEnabled() || !_manifest || !_manifest.sfx || _introActive) return;
const url = direction > 0 ? _manifest.sfx.up : _manifest.sfx.down;
if (!url || typeof document === 'undefined') return;
if (!_sfxEl) {
_sfxEl = document.createElement('audio');
_sfxEl.preload = 'auto';
_sfxEl.style.display = 'none';
document.body.appendChild(_sfxEl);
}
_sfxEl.src = url;
_sfxEl.volume = 0.6;
_sfxEl.play().catch(() => { /* pre-gesture; skip silently */ });
}
function onSongPlay() {
// Song audio starting is the hard cue: the ambience must yield.
fadeAudioOut(1000);
@@ -430,8 +468,10 @@
if (sting && !_introActive && CROWD_RANK[machine.current] >= CROWD_RANK.neutral) {
playStinger(sting);
}
const prevRank = CROWD_RANK[machine.current];
const next = machine.update(d.state, now());
if (next) {
playMoodSfx(CROWD_RANK[next] - prevRank);
// A stinger or the intro owns the idle layer; defer the switch.
if (_stingerUntilEnded || _introActive) _pendingLoop = next;
else showLoop(next, FADE_MS);
@@ -489,6 +529,7 @@
_introGen++;
_introActive = false;
stopAudio();
if (_sfxEl && !_sfxEl.paused) _sfxEl.pause();
_stingerUntilEnded = false;
_pendingLoop = null;
_loadingLoop = null;
@@ -564,6 +605,26 @@
if (dev && !_manifest) setManifest(dev);
}
// Badge-ceremony hook (career passports): the crowd erupts NOW — ecstatic
// loop bypassing stability/dwell (the dwell window then holds it while
// the real perf state waits its turn) plus a cheer. Degrades to a no-op
// without a pack / outside the player, like every other entry point.
function celebrate() {
if (!_venueActive || !_manifest || !_videos[0]) return false;
machine.force('ecstatic', now());
if (_stingerUntilEnded || _introActive) {
// A stinger/intro owns the idle layer (likely the end-of-song
// accuracy cheer — the crowd is already reacting); queue the
// ecstatic loop for when it ends, same as onPerformanceState.
_pendingLoop = 'ecstatic';
} else {
showLoop('ecstatic', FADE_MS);
_lastStingerAt = -Infinity; // a badge earn always gets its cheer
playStinger('cheer');
}
return true;
}
function getState() {
return {
venueActive: _venueActive,
@@ -589,6 +650,7 @@
setVenueActive,
bindRuntime,
getState,
celebrate,
};
if (root) root.v3VenueCrowd = api;
+50
View File
@@ -51,6 +51,56 @@ test('bar venue pack ships with intro media in the plugin checkout', () => {
}
});
test('arena venue pack ships with full media in the plugin checkout', () => {
const packDir = path.join(PLUGIN_DIR, 'venue-packs', 'arena');
const manifest = JSON.parse(fs.readFileSync(path.join(packDir, 'manifest.json'), 'utf8'));
assert.equal(manifest.venue, 'arena');
assert.deepEqual(manifest.loops, {
bored: 'bored.mp4', neutral: 'neutral.mp4',
engaged: 'engaged.mp4', ecstatic: 'ecstatic.mp4',
});
assert.deepEqual(manifest.stingers, { clap: 'clap.mp4', cheer: 'cheer.mp4' });
assert.deepEqual(manifest.sfx, { up: 'sfx-up.mp3', down: 'sfx-down.mp3' });
assert.equal(manifest.intro.video, 'intro.mp4');
assert.equal(manifest.intro.audio, 'arena-ambience.mp3');
for (const f of [
...Object.values(manifest.loops),
...Object.values(manifest.stingers),
manifest.intro.video,
manifest.intro.audio,
manifest.sfx.up,
manifest.sfx.down,
]) {
const stat = fs.statSync(path.join(packDir, f));
assert.ok(stat.size > 0, `${f} must be present`);
}
});
test('club venue pack ships with full media in the plugin checkout', () => {
const packDir = path.join(PLUGIN_DIR, 'venue-packs', 'club');
const manifest = JSON.parse(fs.readFileSync(path.join(packDir, 'manifest.json'), 'utf8'));
assert.equal(manifest.venue, 'club');
assert.deepEqual(manifest.loops, {
bored: 'bored.mp4', neutral: 'neutral.mp4',
engaged: 'engaged.mp4', ecstatic: 'ecstatic.mp4',
});
assert.deepEqual(manifest.stingers, { clap: 'clap.mp4', cheer: 'cheer.mp4' });
assert.deepEqual(manifest.sfx, { up: 'sfx-up.mp3', down: 'sfx-down.mp3' });
assert.equal(manifest.intro.video, 'intro.mp4');
assert.equal(manifest.intro.audio, 'club-ambience.mp3');
for (const f of [
...Object.values(manifest.loops),
...Object.values(manifest.stingers),
manifest.intro.video,
manifest.intro.audio,
manifest.sfx.up,
manifest.sfx.down,
]) {
const stat = fs.statSync(path.join(packDir, f));
assert.ok(stat.size > 0, `${f} must be present`);
}
});
test('shell promotes the career plugin into the sidebar', () => {
const src = fs.readFileSync(SHELL_JS, 'utf8');
assert.match(src, /key: 'career',\s*screen: 'plugin-career'/);
+2 -1
View File
@@ -170,7 +170,8 @@ test('DOM text updates after hit and miss events', () => {
runtime.onHit();
runtime.onMiss();
assert.equal(els.percent.textContent, '67%');
// Floored, not rounded: 100% must mean every judged note was hit.
assert.equal(els.percent.textContent, '66%');
assert.equal(els.hits.textContent, 'Hits 2 / 3');
assert.equal(els.streak.textContent, 'Streak 0');
assert.match(els.state.textContent, /Recovering/);
+19
View File
@@ -128,3 +128,22 @@ test('venue-scene-3d activates/deactivates the crowd layer', () => {
assert.match(src, /syncCrowd\(false\)/);
assert.match(src, /v3VenueCrowd/);
});
test('machine.force commits instantly and dwell holds the forced state', () => {
const m = crowd.createCrowdMachine();
m.force('ecstatic', 100000);
assert.equal(m.current, 'ecstatic');
// The real perf state cannot reassert until the dwell window passes.
m.update('smoke', 100000 + crowd.STABLE_MS);
assert.equal(m.update('smoke', 100000 + crowd.DWELL_MS - 1), null);
assert.equal(m.current, 'ecstatic');
assert.equal(m.update('smoke', 100000 + crowd.DWELL_MS), 'bored');
// Bogus states are ignored.
m.force('confused', 200000);
assert.equal(m.current, 'bored');
});
test('celebrate() is exported and no-ops without a manifest/active venue', () => {
assert.equal(typeof crowd.celebrate, 'function');
assert.equal(crowd.celebrate(), false);
});
+33 -8
View File
@@ -1,3 +1,4 @@
import json
import sqlite3
import sys
from pathlib import Path
@@ -14,25 +15,49 @@ import routes as career_routes
class FakeMetaDb:
"""song_stats-only stand-in for MetadataDB (the plugin reads nothing else)."""
"""song_stats/songs stand-in for MetadataDB (the plugin reads nothing else).
The real song_stats.arrangement is an INTEGER index into the song's
arrangements JSON; the legacy star tests pass strings ("guitar"), which
the passport code treats as index-less instrument defaults to guitar."""
def __init__(self):
self.conn = sqlite3.connect(":memory:", check_same_thread=False)
self.conn.execute(
"""CREATE TABLE song_stats (
filename TEXT, arrangement TEXT, best_accuracy REAL
filename TEXT, arrangement TEXT, best_accuracy REAL,
last_accuracy REAL, last_played_at TEXT,
seconds_total REAL NOT NULL DEFAULT 0
)"""
)
self.conn.execute(
"""CREATE TABLE songs (
filename TEXT, title TEXT, artist TEXT,
genre TEXT DEFAULT '', arrangements TEXT
)"""
)
self.conn.execute("CREATE TABLE songs (filename TEXT, title TEXT, artist TEXT)")
def add(self, filename, arrangement, best_accuracy, in_library=True):
self.conn.execute("INSERT INTO song_stats VALUES (?, ?, ?)",
(filename, arrangement, best_accuracy))
def add(self, filename, arrangement, best_accuracy, in_library=True,
genre="", arrangements=None, last_played_at=None, seconds_total=0,
last_accuracy=None):
self.conn.execute("INSERT INTO song_stats VALUES (?, ?, ?, ?, ?, ?)",
(filename, arrangement, best_accuracy,
last_accuracy if last_accuracy is not None else best_accuracy,
last_played_at, seconds_total))
if in_library:
self.conn.execute(
"INSERT INTO songs SELECT ?, ?, ? WHERE NOT EXISTS "
"INSERT INTO songs SELECT ?, ?, ?, ?, ? WHERE NOT EXISTS "
"(SELECT 1 FROM songs WHERE filename = ?)",
(filename, filename.replace(".feedpak", "").title(), "Test Artist", filename))
(filename, filename.replace(".feedpak", "").title(), "Test Artist",
genre,
json.dumps(arrangements) if arrangements is not None else None,
filename))
self.conn.commit()
def add_song_only(self, filename, genre=""):
"""A library song with no plays — feeds the genre (brochure) list."""
self.conn.execute("INSERT INTO songs VALUES (?, ?, ?, ?, ?)",
(filename, filename, "Test Artist", genre, None))
self.conn.commit()
+447
View File
@@ -0,0 +1,447 @@
"""HTTP-level tests for the passport layer: badges, stubs, genres, drill intake.
Badges are computed on read (never stored): N genre songs at min_stars with
stars 2 meaning best_accuracy 0.75 under the default 0.6/0.75/0.85
thresholds plus any configured virtuoso drills.
"""
import routes as career_routes
LEAD = [{"type": "lead", "name": "Lead"}]
BASS = [{"type": "bass", "name": "Bass"}]
def _open(client, instrument="guitar", genre="Blues"):
res = client.post("/api/plugins/career/passports/open",
json={"instrument": instrument, "genre": genre})
assert res.status_code == 200
return res.json()
def _passport(client, instrument="guitar", genre_key="blues"):
view = client.get("/api/plugins/career/passports").json()
for p in view["instruments"][instrument]["passports"]:
if p["genre_key"] == genre_key:
return p
return None
def test_badge_earned_at_five_genre_songs_two_stars(client, meta_db):
# Soul has no curated drill requirement — songs alone mint the badge.
for i in range(5):
meta_db.add(f"soul{i}.feedpak", 0, 0.8, genre="Soul", arrangements=LEAD)
_open(client, "guitar", "Soul")
p = _passport(client, "guitar", "soul")
assert p["badge"] == "earned"
assert p["qualifying_count"] == 5
assert all(s["qualifies"] and s["stars"] == 2 for s in p["songs"])
def test_shipped_blues_drill_gates_and_keys_cleared_clears_it(client, meta_db):
# Blues ships a guitar drill (blues_shuffle): songs alone are not enough.
for i in range(5):
meta_db.add(f"blues{i}.feedpak", 0, 0.8, genre="Blues", arrangements=LEAD)
_open(client)
p = _passport(client)
assert p["badge"] == "in_progress"
assert p["drills"]["required"] == ["blues_shuffle"]
# One key cleared (a top-tier clean pass) counts as cleared — the depth
# rungs are a higher bar than Bronze needs.
res = client.post("/api/plugins/career/drill-state", json={
"mode": "casual", "xp": 10,
"byNode": {"blues_shuffle": {"reps": 12, "keysCleared": ["E"],
"depth": {"travel": None, "clean": None},
"masteredAt": None}}})
assert res.status_code == 200
p = _passport(client)
assert p["drills"]["cleared"] == ["blues_shuffle"]
assert p["badge"] == "earned"
def test_drill_lists_are_per_instrument(client, meta_db):
# Keys is graded but Blues curates only a GUITAR drill — a keys passport
# earns on songs alone.
keys_arr = [{"type": "lead", "name": "Keys"}]
for i in range(5):
meta_db.add(f"kb{i}.feedpak", 0, 0.9, genre="Blues", arrangements=keys_arr)
_open(client, "keys")
p = _passport(client, "keys")
assert p["drills"]["required"] == []
assert p["badge"] == "earned"
def test_badge_in_progress_below_the_bar(client, meta_db):
for i in range(4):
meta_db.add(f"blues{i}.feedpak", 0, 0.8, genre="Blues", arrangements=LEAD)
meta_db.add("weak.feedpak", 0, 0.65, genre="Blues", arrangements=LEAD) # 1★
_open(client)
p = _passport(client)
assert p["badge"] == "in_progress"
assert p["qualifying_count"] == 4
# Qualifying stubs sort ahead of the near-misses.
assert [s["qualifies"] for s in p["songs"]] == [True] * 4 + [False]
def test_instruments_split_and_bass_is_shown_not_judged(client, meta_db):
# Same 5 songs but played on the BASS arrangement: no guitar badge credit.
for i in range(5):
meta_db.add(f"blues{i}.feedpak", 0, 0.9, genre="Blues", arrangements=BASS)
_open(client, "guitar")
_open(client, "bass")
guitar = _passport(client, "guitar")
bass = _passport(client, "bass")
assert guitar["qualifying_count"] == 0 and guitar["badge"] == "in_progress"
assert bass["qualifying_count"] == 5
# Bass isn't a graded instrument: repertoire shows, no pass/fail bar.
assert bass["badge"] == "shown_not_judged" and bass["graded"] is False
def test_best_accuracy_per_instrument_across_arrangements(client, meta_db):
both = [{"type": "lead", "name": "Lead"}, {"type": "lead", "name": "Alt. Lead"}]
meta_db.add("song.feedpak", 0, 0.7, genre="Blues", arrangements=both)
meta_db.add("song.feedpak", 1, 0.9, genre="Blues", arrangements=both)
_open(client)
p = _passport(client)
assert len(p["songs"]) == 1
assert p["songs"][0]["best_accuracy"] == 0.9
assert p["songs"][0]["stars"] == 3
def test_orphaned_songs_do_not_feed_stubs(client, meta_db):
meta_db.add("gone.feedpak", 0, 0.9, genre="Blues", arrangements=LEAD,
in_library=False)
_open(client)
assert _passport(client)["songs"] == []
def test_genre_rack_collapses_case_and_skips_blank(client, meta_db):
meta_db.add_song_only("a.feedpak", genre="Blues")
meta_db.add_song_only("b.feedpak", genre="blues")
meta_db.add_song_only("c.feedpak", genre="Funk")
meta_db.add_song_only("d.feedpak", genre="")
genres = client.get("/api/plugins/career/passports").json()["genres"]
assert genres == [
{"genre_key": "blues", "genre": "Blues", "songs_in_library": 2},
{"genre_key": "funk", "genre": "Funk", "songs_in_library": 1},
]
def test_commit_is_idempotent_and_open_implies_commit(client):
first = client.post("/api/plugins/career/passports/commit",
json={"instrument": "guitar"}).json()
again = client.post("/api/plugins/career/passports/commit",
json={"instrument": "guitar"}).json()
assert first["committed_at"] == again["committed_at"]
_open(client, "bass", "Funk")
view = client.get("/api/plugins/career/passports").json()
assert view["instruments"]["bass"]["committed_at"]
# Re-opening the same passport keeps the original opened_at.
opened = view["instruments"]["bass"]["passports"][0]["opened_at"]
_open(client, "bass", " funk ") # normalizes to the same key
view = client.get("/api/plugins/career/passports").json()
assert [p["opened_at"] for p in view["instruments"]["bass"]["passports"]] == [opened]
def test_open_and_commit_validation(client):
assert client.post("/api/plugins/career/passports/commit",
json={"instrument": "theremin"}).status_code == 400
assert client.post("/api/plugins/career/passports/open",
json={"instrument": "guitar", "genre": " "}).status_code == 400
assert client.post("/api/plugins/career/passports/open",
json={"instrument": "guitar", "genre": "x" * 65}).status_code == 400
def test_drill_requirement_gates_badge_until_snapshot_clears_it(client, meta_db):
for i in range(5):
meta_db.add(f"blues{i}.feedpak", 0, 0.8, genre="Blues", arrangements=LEAD)
career_routes._state["passports_content"]["genres"]["blues"] = {
"virtuoso_nodes": ["node.shuffle"]}
_open(client)
p = _passport(client)
assert p["badge"] == "in_progress"
assert p["drills"] == {"required": ["node.shuffle"], "cleared": []}
res = client.post("/api/plugins/career/drill-state", json={
"mode": "casual", "xp": 120,
"byNode": {"node.shuffle": {"masteredAt": 1720000000,
"depth": {"travel": None}}}})
assert res.status_code == 200
p = _passport(client)
assert p["drills"]["cleared"] == ["node.shuffle"]
assert p["badge"] == "earned"
def test_drill_state_validation(client):
assert client.post("/api/plugins/career/drill-state",
json={"mode": "casual"}).status_code == 400
huge = {"byNode": {"pad": "x" * (300 * 1024)}}
assert client.post("/api/plugins/career/drill-state",
json=huge).status_code == 413
def test_hours_odometer_sums_seconds_per_instrument_and_genre(client, meta_db):
both = [{"type": "lead", "name": "Lead"}, {"type": "bass", "name": "Bass"}]
# Two lead arrangements' time sums; the bass row stays on the bass passport.
meta_db.add("a.feedpak", 0, 0.8, genre="Blues", arrangements=both, seconds_total=600)
meta_db.add("b.feedpak", 0, 0.8, genre="Blues", arrangements=both, seconds_total=300)
meta_db.add("b.feedpak", 1, 0.9, genre="Blues", arrangements=both, seconds_total=1200)
_open(client, "guitar")
_open(client, "bass")
assert _passport(client, "guitar")["seconds_total"] == 900
assert _passport(client, "bass")["seconds_total"] == 1200
def test_drill_state_merge_is_gained_only(client, meta_db):
# A cleared drill survives a later STALE snapshot that lacks it
# (multi-browser race / settings import / the boot relay).
for i in range(5):
meta_db.add(f"blues{i}.feedpak", 0, 0.8, genre="Blues", arrangements=LEAD)
_open(client)
client.post("/api/plugins/career/drill-state", json={
"byNode": {"blues_shuffle": {"keysCleared": ["E"]}}})
assert _passport(client)["badge"] == "earned"
# Stale relay: empty byNode, then one with the node but nothing earned.
client.post("/api/plugins/career/drill-state", json={"byNode": {}})
client.post("/api/plugins/career/drill-state", json={
"byNode": {"blues_shuffle": {"reps": 2, "keysCleared": [],
"depth": {"travel": None}, "masteredAt": None}}})
p = _passport(client)
assert p["drills"]["cleared"] == ["blues_shuffle"]
assert p["badge"] == "earned"
def test_genre_families_inherit_drills(client, meta_db):
# 'death metal' has no exact entry — it inherits the metal family's drill.
for i in range(5):
meta_db.add(f"dm{i}.feedpak", 0, 0.9, genre="Death Metal", arrangements=LEAD)
_open(client, "guitar", "Death Metal")
p = _passport(client, "guitar", "death metal")
assert p["drills"]["required"] == ["melodic_metal_gallop"]
assert p["badge"] == "in_progress"
# 'metalcore' (single word) matches by substring, no alias needed.
_open(client, "guitar", "Metalcore")
assert _passport(client, "guitar", "metalcore")["drills"]["required"] == \
["melodic_metal_gallop"]
# 'blues rock' resolves by family LIST ORDER: blues comes before rock.
_open(client, "guitar", "Blues Rock")
assert _passport(client, "guitar", "blues rock")["drills"]["required"] == \
["blues_shuffle"]
# A genre outside every family stays songs-only.
_open(client, "guitar", "Reggae")
assert _passport(client, "guitar", "reggae")["drills"]["required"] == []
# Exact per-genre entries still beat the family (the shipped 'metal' entry
# IS the exact entry for genre key 'metal').
_open(client, "guitar", "Metal")
assert _passport(client, "guitar", "metal")["drills"]["required"] == \
["melodic_metal_gallop"]
def test_family_drills_stay_per_instrument(client, meta_db):
# Family inheritance must not leak guitar drills onto other instruments.
keys_arr = [{"type": "lead", "name": "Keys"}]
for i in range(5):
meta_db.add(f"kdm{i}.feedpak", 0, 0.9, genre="Death Metal", arrangements=keys_arr)
_open(client, "keys", "Death Metal")
p = _passport(client, "keys", "death metal")
assert p["drills"]["required"] == []
assert p["badge"] == "earned"
def test_nearest_invitations_order_and_exclusions(client, meta_db):
# Non-qualifying songs sorted by distance to the QUALIFYING bar;
# qualifying songs never appear; capped at 3.
meta_db.add("q.feedpak", 0, 0.80, genre="Soul", arrangements=LEAD) # qualifies
meta_db.add("close.feedpak", 0, 0.74, genre="Soul", arrangements=LEAD) # 1% to 2★
meta_db.add("mid.feedpak", 0, 0.70, genre="Soul", arrangements=LEAD) # 5% to 2★
meta_db.add("far.feedpak", 0, 0.30, genre="Soul", arrangements=LEAD) # 30% to 1★
meta_db.add("far2.feedpak", 0, 0.25, genre="Soul", arrangements=LEAD)
_open(client, "guitar", "Soul")
p = _passport(client, "guitar", "soul")
names = [s["filename"] for s in p["nearest"]]
assert names == ["close.feedpak", "mid.feedpak", "far.feedpak"]
assert all(s["next_star_at"] is not None for s in p["nearest"])
assert "q.feedpak" not in names
def test_nearest_targets_the_qualifying_bar_not_next_star(client, meta_db):
# A 0★ song 1% from its NEXT star is farther from the ★★ badge bar than
# a 1★ song 5% from it — nearest must rank by the badge bar.
meta_db.add("one_star.feedpak", 0, 0.70, genre="Soul", arrangements=LEAD) # 5% to bar
meta_db.add("zero_star.feedpak", 0, 0.59, genre="Soul", arrangements=LEAD) # 1% to next ★, 16% to bar
_open(client, "guitar", "Soul")
p = _passport(client, "guitar", "soul")
assert [s["filename"] for s in p["nearest"]] == ["one_star.feedpak", "zero_star.feedpak"]
assert all(s["bar_at"] == 0.75 for s in p["nearest"])
# ── Gigs ──────────────────────────────────────────────────────────────────────
def test_gig_propose_mixes_owned_and_stakes(client, meta_db):
for i in range(4):
meta_db.add(f"own{i}.feedpak", 0, 0.85, genre="Soul", arrangements=LEAD)
meta_db.add("stake.feedpak", 0, 0.70, genre="Soul", arrangements=LEAD)
meta_db.add_song_only("fresh.feedpak", genre="Soul")
res = client.post("/api/plugins/career/gigs/propose",
json={"instrument": "guitar", "genre": "Soul", "size": 4})
assert res.status_code == 200
gig = res.json()
files = [s["filename"] for s in gig["songs"]]
assert len(files) == 4
assert "stake.feedpak" in files # a near-bar song gives the set stakes
assert gig["venue_id"] == "bar" # 9 stars < 50: the dive bar
# A young passport (nothing played) still gets a playable set from the
# library's unplayed genre songs.
res2 = client.post("/api/plugins/career/gigs/propose",
json={"instrument": "guitar", "genre": "Ska"})
assert res2.status_code == 404 # no ska in the library at all
meta_db.add_song_only("ska1.feedpak", genre="Ska")
res3 = client.post("/api/plugins/career/gigs/propose",
json={"instrument": "guitar", "genre": "Ska"})
assert [s["filename"] for s in res3.json()["songs"]] == ["ska1.feedpak"]
def test_gig_log_computes_encore_and_surfaces_in_passports(client, meta_db):
for i in range(2):
meta_db.add(f"s{i}.feedpak", 0, 0.9, genre="Soul", arrangements=LEAD,
last_accuracy=0.9)
_open(client, "guitar", "Soul")
res = client.post("/api/plugins/career/gigs", json={
"instrument": "guitar", "genre": "Soul", "venue_id": "bar",
"songs": ["s0.feedpak", "s1.feedpak"]})
assert res.status_code == 200
gig = res.json()["gig"]
assert gig["encore"] is True # avg 0.9 ≥ 0.75
assert gig["songs"][0]["accuracy"] == 0.9
view = client.get("/api/plugins/career/passports").json()
assert view["instruments"]["guitar"]["gig_count"] == 1
p = _passport(client, "guitar", "soul")
assert len(p["gigs"]) == 1 and p["gigs"][0]["encore"] is True
def test_gig_log_validation_and_no_fail_state(client):
# Unknown venue / bad songs shapes are rejected; nothing is ever logged
# as a failed gig — the endpoint only appends completed sets.
assert client.post("/api/plugins/career/gigs", json={
"instrument": "guitar", "genre": "Soul", "venue_id": "nope",
"songs": ["x"]}).status_code == 400
assert client.post("/api/plugins/career/gigs", json={
"instrument": "guitar", "genre": "Soul", "songs": []}).status_code == 400
assert client.post("/api/plugins/career/gigs", json={
"instrument": "guitar", "genre": "Soul",
"songs": ["f"] * 9}).status_code == 400
def test_gig_accuracy_reads_newest_row_and_encore_needs_full_set(client, meta_db):
# Newest row wins: a stale higher accuracy on another arrangement must
# not inflate the gig log.
meta_db.add("dual.feedpak", 1, 0.95, genre="Soul", arrangements=BASS,
last_accuracy=0.95, last_played_at="2026-06-01T00:00:00")
meta_db.add("dual.feedpak", 0, 0.60, genre="Soul", arrangements=LEAD,
last_accuracy=0.60, last_played_at="2026-07-14T00:00:00")
res = client.post("/api/plugins/career/gigs", json={
"instrument": "guitar", "genre": "Soul", "songs": ["dual.feedpak"]})
assert res.json()["gig"]["songs"][0]["accuracy"] == 0.6
# A set with an unscored song never earns the encore off one good song.
meta_db.add("scored.feedpak", 0, 0.9, genre="Soul", arrangements=LEAD,
last_accuracy=0.9, last_played_at="2026-07-14T00:01:00")
res2 = client.post("/api/plugins/career/gigs", json={
"instrument": "guitar", "genre": "Soul",
"songs": ["scored.feedpak", "ghost.feedpak"]})
assert res2.json()["gig"]["encore"] is False
def test_gig_propose_backfills_from_surplus_qualifying(client, meta_db):
# Mature passport: plenty of qualifying songs, nothing near the bar,
# nothing unplayed — the set still fills to size.
for i in range(8):
meta_db.add(f"own{i}.feedpak", 0, 0.9, genre="Ska", arrangements=LEAD)
res = client.post("/api/plugins/career/gigs/propose",
json={"instrument": "guitar", "genre": "Ska", "size": 5})
assert len(res.json()["songs"]) == 5
def test_gig_propose_backfill_offset_survives_stakes(client, meta_db):
# 4 qualifying + 1 near-bar stake, size 5: the stake must not shift the
# qualifying backfill window past eligible songs.
for i in range(4):
meta_db.add(f"q{i}.feedpak", 0, 0.9, genre="Reggae", arrangements=LEAD)
meta_db.add("near.feedpak", 0, 0.7, genre="Reggae", arrangements=LEAD)
res = client.post("/api/plugins/career/gigs/propose",
json={"instrument": "guitar", "genre": "Reggae", "size": 5})
files = [s["filename"] for s in res.json()["songs"]]
assert len(files) == 5 and len(set(files)) == 5
assert "near.feedpak" in files
# ── Gold rung ─────────────────────────────────────────────────────────────────
def test_gold_upgrades_bronze_via_family_style_artifact(client, meta_db):
# Bronze earned on 'death metal' (family: metal); a metal gold artifact
# from the jam verifier upgrades it — bronze-only stays 'earned' elsewhere.
for i in range(5):
meta_db.add(f"dm{i}.feedpak", 0, 0.9, genre="Death Metal", arrangements=LEAD)
career_routes._state["passports_content"]["genres"]["metal"] = {} # no drill gate for this test
_open(client, "guitar", "Death Metal")
client.post("/api/plugins/career/drill-state", json={"byNode": {}})
assert _passport(client, "guitar", "death metal")["badge"] == "earned"
client.post("/api/plugins/career/drill-state", json={
"byNode": {}, "goldImprov": {"metal": {"at": 1, "verifier": "comb", "inKeyPct": 0.9}}})
assert _passport(client, "guitar", "death metal")["badge"] == "gold"
def test_gold_without_bronze_stays_in_progress(client, meta_db):
meta_db.add("one.feedpak", 0, 0.9, genre="Soul", arrangements=LEAD)
_open(client, "guitar", "Soul")
client.post("/api/plugins/career/drill-state", json={
"byNode": {}, "goldImprov": {"soul": {"at": 1, "verifier": "comb"}}})
assert _passport(client, "guitar", "soul")["badge"] == "in_progress"
def test_gold_merge_is_gained_only(client, meta_db):
for i in range(5):
meta_db.add(f"s{i}.feedpak", 0, 0.9, genre="Soul", arrangements=LEAD)
_open(client, "guitar", "Soul")
client.post("/api/plugins/career/drill-state", json={
"byNode": {}, "goldImprov": {"soul": {"at": 1, "verifier": "comb"}}})
assert _passport(client, "guitar", "soul")["badge"] == "gold"
# A stale relay without the artifact never un-mints.
client.post("/api/plugins/career/drill-state", json={"byNode": {}})
assert _passport(client, "guitar", "soul")["badge"] == "gold"
# And a different artifact for the same style never overwrites the first —
# asserted against the PERSISTED snapshot (the view doesn't expose
# artifact contents), so a last-write-wins regression can't stay green.
client.post("/api/plugins/career/drill-state", json={
"byNode": {}, "goldImprov": {"soul": {"at": 999, "verifier": "yin"}}})
_, _, gold = career_routes._drill_by_node()
assert gold["soul"] == {"at": 1, "verifier": "comb"}
def test_gold_matches_raw_style_id_through_family(client, meta_db):
# Virtuoso mints under raw STYLE_PALETTES ids ('punk', not 'rock'): a
# 'punk rock' passport (family rock) must go gold from a 'punk' artifact.
for i in range(5):
meta_db.add(f"pk{i}.feedpak", 0, 0.9, genre="Punk Rock", arrangements=LEAD)
career_routes._state["passports_content"]["genres"]["rock"] = {} # no drill gate
_open(client, "guitar", "Punk Rock")
client.post("/api/plugins/career/drill-state", json={
"byNode": {}, "goldImprov": {"punk": {"at": 1, "verifier": "comb"}}})
assert _passport(client, "guitar", "punk rock")["badge"] == "gold"
def test_gold_intake_rejects_junk(client, meta_db):
# A non-dict goldImprov is a relay bug: loud 400, never a silent drop.
res = client.post("/api/plugins/career/drill-state",
json={"byNode": {}, "goldImprov": ["metal"]})
assert res.status_code == 400
# Evidence-free artifacts (no verifier) never mint.
for i in range(5):
meta_db.add(f"j{i}.feedpak", 0, 0.9, genre="Soul", arrangements=LEAD)
_open(client, "guitar", "Soul")
client.post("/api/plugins/career/drill-state", json={
"byNode": {}, "goldImprov": {"soul": {}}})
assert _passport(client, "guitar", "soul")["badge"] == "earned"
# An oversized goldImprov is bounded BEFORE the merge, like byNode.
blob = {f"s{i}": {"verifier": "comb", "pad": "x" * 4096} for i in range(200)}
res = client.post("/api/plugins/career/drill-state",
json={"byNode": {}, "goldImprov": blob})
assert res.status_code == 413
+6 -1
View File
@@ -121,12 +121,17 @@ def test_pack_file_serving_and_traversal_guard(client):
def test_state_reports_installed_and_delete_removes(client):
# All three venues now ship bundled, so deleting the downloaded copy
# falls back to the bundled pack: installed stays True by design
# (downloaded packs override bundled ones, never replace them).
_install_fake_pack("club")
state = client.get("/api/plugins/career/state").json()
assert {v["id"]: v["installed"] for v in state["venues"]}["club"] is True
assert client.delete("/api/plugins/career/packs/club").status_code == 200
state = client.get("/api/plugins/career/state").json()
assert {v["id"]: v["installed"] for v in state["venues"]}["club"] is False
assert {v["id"]: v["installed"] for v in state["venues"]}["club"] is True
# the downloaded override itself is gone
assert not (career_routes._venue_dir("club") / "manifest.json").exists()
def test_download_worker_end_to_end(client, tmp_path):
+48
View File
@@ -273,3 +273,51 @@ def test_title_keyset_paging_is_complete_with_overrides(client, server):
if not cursor:
break
assert sorted(seen) == ["a.archive", "b.archive", "c.archive"] # each exactly once
def test_enrichment_genre_fallback_precedence(client, server):
# Precedence: override → pack genre → MusicBrainz enrichment (matched only).
_put(server, "a.archive", title="A", genre="Rock") # pack wins over enrichment
_put(server, "b.archive", title="B", genre="") # falls back to enrichment
_put(server, "c.archive", title="C", genre="") # override beats enrichment
_put(server, "d.archive", title="D", genre="") # unmatched candidate: ignored
ins = "INSERT INTO song_enrichment (filename, match_state, genres) VALUES (?, ?, ?)"
server.meta_db.conn.execute(ins, ("a.archive", "matched", '["metal"]'))
server.meta_db.conn.execute(ins, ("b.archive", "matched", '["progressive rock", "rock"]'))
server.meta_db.conn.execute(ins, ("c.archive", "matched", '["jazz"]'))
server.meta_db.conn.execute(ins, ("d.archive", "review", '["country"]'))
_put(server, "e.archive", title="E", genre="") # manual pin is trusted too
server.meta_db.conn.execute(ins, ("e.archive", "manual", '["ska"]'))
server.meta_db.conn.commit()
server.meta_db.set_song_override("c.archive", "genre", value="City Pop")
genres = client.get("/api/library/genres").json()["genres"]
assert "Rock" in genres # pack value kept for a
assert "metal" not in genres # enrichment never overrides a pack genre
assert "progressive rock" in genres # b: enrichment primary ([0]) surfaces
assert "City Pop" in genres and "jazz" not in genres # override beats enrichment
assert "country" not in genres # review/failed candidates never leak
assert "ska" in genres # user-pinned (manual) matches count
# Filtering by the enriched genre finds the song.
r = client.get("/api/library", params={"genre": "progressive rock"}).json()
assert [s["filename"] for s in r["songs"]] == ["b.archive"]
def test_no_enrichment_and_no_overrides_uses_plain_column(server):
_put(server, "a.archive", title="A", genre="Rock")
assert server.meta_db._effective_genre_expr() == "genre"
def test_overrides_without_enrichment_table_stay_safe(server):
# A stand-in scenario: overrides exist but song_enrichment is gone — the
# expression must not reference the missing table.
server.meta_db.conn.execute("DROP TABLE song_enrichment")
_put(server, "a.archive", title="A", genre="")
server.meta_db.set_song_override("a.archive", "genre", value="City Pop")
expr = server.meta_db._effective_genre_expr()
assert "song_enrichment" not in expr
# And it still evaluates: the override surfaces through the facet query.
row = server.meta_db.conn.execute(
f"SELECT {expr} FROM songs WHERE filename = 'a.archive'").fetchone()
assert row[0] == "City Pop"
+201
View File
@@ -0,0 +1,201 @@
"""The remote transcription REQUEST — the thing that was never tested and never worked.
`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". Its `text` field is required,
and 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 had never worked for anybody (feedBack-plugin-stem-splitter#17).
Nothing caught it because every test of this module tested the *mapper* `_whisperx_to_sloppak`,
fed a hand-written dict. The mapper was always fine. The request was never exercised, and the
request was the bug.
So these tests assert the request: which endpoint, and how `language` is carried. Both are
invisible to a mapper test, and both are wrong in ways that fail quietly rather than loudly.
"""
from pathlib import Path
from unittest import mock
import pytest
from lyrics_transcribe import transcribe_vocals_remote
_ALIGNED = {
"segments": [{
"start": 1.0, "end": 2.0, "text": "hello world",
"words": [
{"word": "hello", "start": 1.0, "end": 1.4, "score": 0.9},
{"word": "world", "start": 1.5, "end": 2.0, "score": 0.9},
],
}]
}
class _Resp:
def __init__(self, status=200, payload=None, text=""):
self.status_code = status
self._payload = payload if payload is not None else _ALIGNED
self.text = text
def json(self):
return self._payload
@pytest.fixture
def vocals(tmp_path: Path) -> Path:
p = tmp_path / "vocals.ogg"
p.write_bytes(b"not really ogg, we never decode it here")
return p
def _post_call(vocals: Path, resp: _Resp, **kw):
with mock.patch("requests.post", return_value=resp) as post:
out = transcribe_vocals_remote(vocals, "http://server:7865", **kw)
return post.call_args, out
def test_it_posts_to_transcribe_not_align(vocals):
"""THE regression. /align requires `text`; we have none, so it 422s every time."""
call, out = _post_call(vocals, _Resp())
url = call.args[0]
assert url.endswith("/transcribe"), (
f"posted to {url!r} — /align is forced alignment and its `text` field is required, so "
f"this request is rejected with a 422 before the server's handler ever runs"
)
assert "/align" not in url
assert out, "a successful transcription must return syllables"
def test_the_language_hint_is_a_form_field_not_a_query_param(vocals):
"""The server reads `language` with Form(""). Sent as a query param it is silently ignored —
so an explicit hint does nothing, Whisper's auto-detection quietly decides instead, and the
wrong wav2vec2 aligner gets loaded. It "works", it's just wrong: the failure mode that hides
for months."""
call, _ = _post_call(vocals, _Resp(), language="es")
assert (call.kwargs.get("data") or {}).get("language") == "es", (
"the language hint must ride in the form body — the server reads Form('language'), and "
"a query param is dropped without a word"
)
assert "language" not in (call.kwargs.get("params") or {})
def test_no_language_sends_no_hint(vocals):
# Absent is not the empty string: "" would pin detection to a language named "".
call, _ = _post_call(vocals, _Resp())
assert not (call.kwargs.get("data") or {})
def test_the_file_is_sent_as_a_multipart_upload(vocals):
call, _ = _post_call(vocals, _Resp())
files = call.kwargs.get("files") or {}
assert "file" in files, "the server reads File('file')"
assert files["file"][0] == "vocals.ogg"
def test_an_api_key_is_sent_as_a_bearer_token(vocals):
call, _ = _post_call(vocals, _Resp(), api_key="secret")
assert (call.kwargs.get("headers") or {})["Authorization"] == "Bearer secret"
def test_an_instrumental_is_an_answer_not_a_crash(vocals):
# The server returns 200 + no segments for a stem with no singing in it. That is a valid
# answer ("this song has no vocals"), and it must not read as a failure.
_, out = _post_call(vocals, _Resp(payload={"segments": [], "language": "en"}))
assert out == []
def test_a_server_error_surfaces_the_whole_body(vocals):
"""The error body IS the diagnosis. A 422's JSON names the field it rejected; a 500's
traceback answers on its last line. The old 300-char cap decapitated both which is how
this bug stayed invisible: the message explaining it was inside the part that got cut."""
tb = "Traceback (most recent call last):\n" + (" File x, line 1\n" * 40) + \
"RuntimeError: CUDA out of memory"
assert len(tb) > 300 and "CUDA out of memory" not in tb[:300]
with pytest.raises(RuntimeError) as exc:
_post_call(vocals, _Resp(status=500, text=tb))
assert "CUDA out of memory" in str(exc.value)
def test_truncation_keeps_the_exception_line_not_just_the_header():
"""A traceback's ANSWER is its last line. Head-only truncation throws it away.
This is the same mistake as the 300-char cap, one level up: cutting off precisely the part
the function exists to preserve. A 4000-char window that contains "Traceback (most recent
call last)" and none of the exception is a window onto nothing."""
from lyrics_transcribe import _MAX_ERR_BODY, _err_body
frames = "".join(f' File "/app/server.py", line {i}, in run\n step()\n'
for i in range(2000)) # far over the cap on its own
tb = "Traceback (most recent call last):\n" + frames + \
"RuntimeError: CUDA out of memory. Tried to allocate 2.20 GiB"
body = _err_body(_Resp(text=tb))
assert len(body) <= _MAX_ERR_BODY
assert "CUDA out of memory" in body, (
"the exception line is the diagnosis — a truncation that drops it keeps the part that "
"says work was happening and discards the part that says what went wrong"
)
assert "Traceback (most recent call last)" in body, "the head is context worth keeping too"
assert "truncated" in body
def test_the_cap_is_a_bound_not_a_suggestion():
"""The truncation marker must fit INSIDE _MAX_ERR_BODY, not be appended past it.
Otherwise the cap is advisory, and the callers who trust it a log line, a job record
persisted to disk and re-read on every load are the ones that get surprised."""
from lyrics_transcribe import _MAX_ERR_BODY, _err_body
body = _err_body(_Resp(text="x" * 500_000))
assert len(body) <= _MAX_ERR_BODY, (
f"body is {len(body)} chars, over the {_MAX_ERR_BODY} cap it claims to enforce"
)
assert "truncated" in body and "500000" in body
def test_trailing_whitespace_is_not_content():
# A 300-char JSON body followed by 3900 blanks is not a long body, and cutting real content
# to make room for whitespace would be a silly way to lose the diagnosis.
from lyrics_transcribe import _err_body
payload = '{"detail":"nope"}'
assert _err_body(_Resp(text=payload + " " * 8000)) == payload
def test_a_404_explains_that_the_server_is_too_old(vocals):
"""A bare "404" sends someone hunting for a typo in their URL. The real answer is that their
server predates the endpoint, and only we can know that."""
with pytest.raises(RuntimeError) as exc:
_post_call(vocals, _Resp(status=404, text='{"detail":"Not Found"}'))
msg = str(exc.value)
assert "404" in msg
assert "/transcribe" in msg
assert "predates" in msg or "Update the server" in msg
class TestEverythingFailsAsRuntimeError:
"""The docstring promises one failure mode: RuntimeError. The caller
(`_maybe_transcribe_lyrics`) catches exactly that so one song's failed lyrics don't take down
the batch around it. A transport error escaping as requests.RequestException walks straight
past that handler turning "this song's lyrics failed" into "the whole batch died"."""
def test_a_connection_failure(self, vocals):
import requests
with mock.patch("requests.post",
side_effect=requests.ConnectionError("name resolution failed")):
with pytest.raises(RuntimeError, match="could not reach"):
transcribe_vocals_remote(vocals, "http://nope:7865")
def test_a_timeout(self, vocals):
import requests
with mock.patch("requests.post", side_effect=requests.Timeout("timed out")):
with pytest.raises(RuntimeError, match="could not reach"):
transcribe_vocals_remote(vocals, "http://server:7865")
def test_an_unreadable_stem(self, tmp_path):
missing = tmp_path / "gone.ogg" # never created
with pytest.raises(RuntimeError, match="could not read"):
transcribe_vocals_remote(missing, "http://server:7865")
+429
View File
@@ -0,0 +1,429 @@
"""tools/migrate_full_mix_stem.py — packs off the deprecated `original_audio:` key.
The migration moves real audio inside tens of thousands of archives, so the
interesting cases are the ones where it must NOT act: a pack it would corrupt, a
pack it has already done, a pack whose mixdown isn't where the key claims.
"""
from __future__ import annotations
import importlib.util
import zipfile
from pathlib import Path
import pytest
import yaml
_SPEC = importlib.util.spec_from_file_location(
"migrate_full_mix_stem",
Path(__file__).resolve().parent.parent / "tools" / "migrate_full_mix_stem.py",
)
mig = importlib.util.module_from_spec(_SPEC)
_SPEC.loader.exec_module(mig)
def _manifest(**extra) -> dict:
m = {
"feedpak_version": "1.13.0",
"title": "T",
"artist": "A",
"duration": 1.0,
"arrangements": [{"id": "lead", "file": "arrangements/lead.json"}],
"stems": [
{"id": "guitar", "file": "stems/guitar.ogg", "default": "on"},
{"id": "drums", "file": "stems/drums.ogg", "default": "on"},
],
"original_audio": "original/full.ogg",
}
m.update(extra)
return m
def _write_pack(path: Path, manifest: dict, files: dict[str, bytes] | None = None) -> Path:
files = files or {
"original/full.ogg": b"MIXDOWN",
"stems/guitar.ogg": b"g",
"stems/drums.ogg": b"d",
"arrangements/lead.json": b"{}",
}
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr("manifest.yaml", yaml.safe_dump(manifest, sort_keys=False))
for name, data in files.items():
zf.writestr(name, data)
return path
def _read(path: Path) -> tuple[dict, set[str]]:
with zipfile.ZipFile(path) as zf:
return yaml.safe_load(zf.read("manifest.yaml")), set(zf.namelist())
# ── plan_manifest: the decisions, without the archives ──────────────────────
def test_plan_adds_the_full_stem_and_drops_the_key():
new, move = mig.plan_manifest(_manifest())
assert move == "original/full.ogg"
assert "original_audio" not in new
assert new["stems"][0] == {
"id": "full",
"file": "stems/full.ogg",
"default": "off",
}
# The separated stems survive, in order, untouched.
assert [s["id"] for s in new["stems"]] == ["full", "guitar", "drums"]
assert new["feedpak_version"] == "1.15.0"
def test_plan_marks_the_retained_mixdown_default_off():
"""The one line that keeps a pre-1.15.0 reader from doubling the song: a
reader that sums every stem still won't play `full` on open if it honours
`default`, which has been normative since 1.0.0."""
new, _ = mig.plan_manifest(_manifest())
assert new["stems"][0]["default"] == "off"
def test_plan_marks_a_sole_mixdown_default_on():
"""With no separated stems the mixdown IS the audio — off would mute the pack."""
new, _ = mig.plan_manifest(_manifest(stems=[]))
assert new["stems"] == [{"id": "full", "file": "stems/full.ogg", "default": "on"}]
def test_plan_preserves_unknown_keys_verbatim():
"""Spec §3: a writer that re-emits a pack SHOULD preserve unknown keys."""
new, _ = mig.plan_manifest(_manifest(source_tool="ExampleTool v1.2.3", rigs="rigs.json"))
assert new["source_tool"] == "ExampleTool v1.2.3"
assert new["rigs"] == "rigs.json"
def test_plan_skips_an_already_migrated_pack():
m = _manifest(
stems=[{"id": "full", "file": "stems/full.ogg", "default": "off"}],
)
del m["original_audio"]
with pytest.raises(mig.Skip):
mig.plan_manifest(m)
def test_plan_skips_a_pack_that_never_had_the_key():
m = _manifest()
del m["original_audio"]
with pytest.raises(mig.Skip):
mig.plan_manifest(m)
def test_plan_drops_a_stale_key_without_moving_anything():
"""Mixdown already a stem, dead key lingering beside it."""
new, move = mig.plan_manifest(
_manifest(stems=[{"id": "full", "file": "stems/full.ogg", "default": "off"}])
)
assert move == ""
assert "original_audio" not in new
assert [s["id"] for s in new["stems"]] == ["full"]
def test_plan_forces_an_existing_full_stem_off_beside_instrument_stems():
"""Dropping the stale key is not enough if the mixdown it duplicated is left
ENABLED: a reader that honours `default` would then play the whole song on top
of the stems on open. The migration must not hand back a pack in the exact
state it exists to remove."""
new, move = mig.plan_manifest(
_manifest(
stems=[
{"id": "full", "file": "stems/full.ogg", "default": "on"},
{"id": "guitar", "file": "stems/guitar.ogg", "default": "on"},
]
)
)
assert move == ""
assert new["stems"][0] == {"id": "full", "file": "stems/full.ogg", "default": "off"}
assert new["stems"][1]["default"] == "on" # instruments untouched
def test_plan_leaves_a_sole_full_stem_enabled_when_dropping_a_stale_key():
"""No instruments beside it — the mixdown IS the audio. Forcing it off here
would mute the pack."""
new, _ = mig.plan_manifest(
_manifest(stems=[{"id": "full", "file": "stems/full.ogg", "default": "on"}])
)
assert new["stems"] == [{"id": "full", "file": "stems/full.ogg", "default": "on"}]
def test_plan_needs_no_move_when_the_key_already_points_at_the_canonical_path():
new, move = mig.plan_manifest(_manifest(original_audio="stems/full.ogg"))
assert move == ""
assert new["stems"][0]["file"] == "stems/full.ogg"
# ── migrate_zip: the archive rewrite ────────────────────────────────────────
def test_migrate_moves_the_audio_and_rewrites_the_manifest(tmp_path: Path):
pak = _write_pack(tmp_path / "song.feedpak", _manifest())
assert mig.migrate_zip(pak, dry_run=False) == "migrated"
manifest, names = _read(pak)
assert "original/full.ogg" not in names # the invented directory is gone
assert "stems/full.ogg" in names # audio lives where the format says
assert "original_audio" not in manifest
assert manifest["stems"][0]["id"] == "full"
assert mig.verify_zip(pak) == "ok"
def test_migrate_preserves_the_mixdown_bytes(tmp_path: Path):
"""It is a rename, not a re-encode. Losing a byte here loses the master audio."""
pak = _write_pack(tmp_path / "song.feedpak", _manifest())
mig.migrate_zip(pak, dry_run=False)
with zipfile.ZipFile(pak) as zf:
assert zf.read("stems/full.ogg") == b"MIXDOWN"
assert zf.read("stems/guitar.ogg") == b"g"
def test_migrate_is_idempotent(tmp_path: Path):
pak = _write_pack(tmp_path / "song.feedpak", _manifest())
assert mig.migrate_zip(pak, dry_run=False) == "migrated"
before = pak.read_bytes()
assert mig.migrate_zip(pak, dry_run=False) == "skip"
assert pak.read_bytes() == before # a re-run touches nothing
def test_dry_run_changes_nothing(tmp_path: Path):
pak = _write_pack(tmp_path / "song.feedpak", _manifest())
before = pak.read_bytes()
assert mig.migrate_zip(pak, dry_run=True) == "would-migrate"
assert pak.read_bytes() == before
def test_migrate_refuses_when_the_mixdown_is_absent(tmp_path: Path):
"""The key points at audio the archive doesn't contain. Fabricating a stem
entry for a missing file would break every reader refuse, don't guess."""
pak = _write_pack(
tmp_path / "song.feedpak",
_manifest(),
files={"stems/guitar.ogg": b"g", "arrangements/lead.json": b"{}"},
)
before = pak.read_bytes()
assert mig.migrate_zip(pak, dry_run=False) == "missing-audio"
assert pak.read_bytes() == before
def test_migrate_refuses_when_the_target_path_is_taken(tmp_path: Path):
"""A `stems/full.ogg` that is NOT the mixdown already occupies the target.
Overwriting it would destroy a stem."""
pak = _write_pack(
tmp_path / "song.feedpak",
_manifest(),
files={
"original/full.ogg": b"MIXDOWN",
"stems/full.ogg": b"SOMETHING-ELSE",
"arrangements/lead.json": b"{}",
},
)
assert mig.migrate_zip(pak, dry_run=False) == "target-occupied"
with zipfile.ZipFile(pak) as zf:
assert zf.read("stems/full.ogg") == b"SOMETHING-ELSE"
def test_migrate_drops_a_stale_key_beside_a_non_canonical_full_stem(tmp_path: Path):
"""The mixdown is already a stem, but at a path of the pack's own choosing —
which is legal (§2.2: readers resolve through the manifest, never by
filename). Only the dead key needs removing. Demanding `stems/full.ogg` here
would reject a perfectly valid pack as `missing-audio`."""
m = _manifest(stems=[{"id": "full", "file": "audio/mixdown.ogg", "default": "off"}])
pak = _write_pack(
tmp_path / "song.feedpak",
m,
files={"audio/mixdown.ogg": b"MIXDOWN", "arrangements/lead.json": b"{}"},
)
assert mig.migrate_zip(pak, dry_run=False) == "migrated"
manifest, names = _read(pak)
assert "original_audio" not in manifest
assert manifest["stems"] == [
{"id": "full", "file": "audio/mixdown.ogg", "default": "off"}
]
assert "audio/mixdown.ogg" in names # the audio never moved
assert mig.verify_zip(pak) == "ok"
# ── verify_zip ──────────────────────────────────────────────────────────────
def test_verify_rejects_a_retained_mixdown_that_plays_on_open(tmp_path: Path):
"""The hazard the migration must never create: `full` alongside instrument
stems AND default-on means a summing reader plays the whole song twice."""
m = _manifest(
stems=[
{"id": "full", "file": "stems/full.ogg", "default": "on"},
{"id": "guitar", "file": "stems/guitar.ogg", "default": "on"},
]
)
del m["original_audio"]
pak = _write_pack(
tmp_path / "song.feedpak",
m,
files={"stems/full.ogg": b"M", "stems/guitar.ogg": b"g"},
)
assert mig.verify_zip(pak) == "full-stem-default-on"
@pytest.mark.parametrize(
"default, expected",
[
({"default": "off"}, "ok"), # the one safe, canonical shape
({"default": "OFF"}, "ok"), # case-insensitive
({"default": " off "}, "ok"), # surrounding whitespace tolerated
({}, "full-stem-default-not-off"), # MISSING — core defaults to True (ON)
({"default": ""}, "full-stem-default-not-off"), # empty → ON in core
({"default": False}, "full-stem-default-not-off"), # boolean, not the string
({"default": True}, "full-stem-default-on"), # boolean truthy → plays
({"default": "false"}, "full-stem-default-not-off"), # off-ish but non-canonical
({"default": "0"}, "full-stem-default-not-off"),
({"default": "no"}, "full-stem-default-not-off"),
({"default": "maybe"}, "full-stem-default-not-off"), # malformed
({"default": "on"}, "full-stem-default-on"),
({"default": "yes"}, "full-stem-default-on"),
({"default": "1"}, "full-stem-default-on"),
],
)
def test_verify_requires_an_explicit_off_on_a_retained_mixdown(tmp_path, default, expected):
"""Beside instrument stems, `full` is safe only with an explicit normalized
`off`. Core defaults an ABSENT `default` to ON and treats empty/unknown as
ON, so a missing or blank default is the double-audio hazard itself, not a
lesser one `verify` must not certify it."""
m = _manifest(
stems=[
{"id": "full", "file": "stems/full.ogg", **default},
{"id": "guitar", "file": "stems/guitar.ogg", "default": "on"},
]
)
del m["original_audio"]
pak = _write_pack(
tmp_path / f"{tmp_path.name}.feedpak",
m,
files={"stems/full.ogg": b"M", "stems/guitar.ogg": b"g"},
)
assert mig.verify_zip(pak) == expected
def test_verify_ignores_default_on_a_sole_full_stem(tmp_path: Path):
"""A single `full` stem IS the audio — the len>1 gate means its default is
not policed, so an on/absent default is fine (off would mute the pack)."""
for default in ({"default": "on"}, {}, {"default": ""}):
m = _manifest(stems=[{"id": "full", "file": "stems/full.ogg", **default}])
del m["original_audio"]
pak = _write_pack(
tmp_path / f"{tmp_path.name}-{len(default)}.feedpak",
m,
files={"stems/full.ogg": b"M"},
)
assert mig.verify_zip(pak) == "ok"
def test_verify_rejects_an_unmigrated_pack(tmp_path: Path):
pak = _write_pack(tmp_path / "song.feedpak", _manifest())
assert mig.verify_zip(pak) == "still-has-key"
# ── Unsafe manifest paths must not be laundered into playable audio ─────────
@pytest.mark.parametrize(
"rel", ["../outside.ogg", "/etc/passwd", "a/../../x.ogg", "C:/x.ogg", "a\\b.ogg"]
)
def test_migrate_refuses_an_unsafe_full_mix_path(tmp_path: Path, rel: str):
"""Core's loader REFUSES a full-mix path that escapes the pack — such a pack
simply has no full mix, and the audio is inert. Migrating it into
`stems/full.ogg` would take content the reader deliberately rejected and hand
it back as a valid, playable stem. Report it; never promote it."""
pak = _write_pack(
tmp_path / "song.feedpak",
_manifest(original_audio=rel),
files={rel: b"EVIL", "stems/guitar.ogg": b"g"},
)
before = pak.read_bytes()
assert mig.migrate_zip(pak, dry_run=False) == "unsafe-path"
assert pak.read_bytes() == before
def test_safe_relpath_accepts_ordinary_pack_paths():
assert mig.is_safe_relpath("stems/full.ogg")
assert mig.is_safe_relpath("original/full.ogg")
assert not mig.is_safe_relpath("")
assert not mig.is_safe_relpath("a//b.ogg")
# ── Damaged packs must not abort the run ────────────────────────────────────
def test_a_corrupt_archive_is_reported_not_fatal(tmp_path: Path, capsys):
"""A real library has damage in it — a truncated download, an archive left
half-written by an interrupted converter. One of those must not kill a
50,000-pack run and throw away the summary: the pack is reported, skipped,
and everything else still migrates."""
good = _write_pack(tmp_path / "good.feedpak", _manifest())
bad = tmp_path / "bad.feedpak"
bad.write_bytes(b"this is not a zip file at all")
rc = mig.main([str(tmp_path)])
out = capsys.readouterr().out
assert rc == 1 # a problem pack fails the run's exit code
assert "corrupt-zip" in out
assert "migrated" in out
assert mig.verify_zip(good) == "ok" # the healthy pack still got migrated
assert bad.read_bytes() == b"this is not a zip file at all" # untouched
# ── Directory-form (authoring) packs are discovered, not silently skipped ────
def _write_dir_pack(path: Path, manifest: dict, files: dict[str, bytes] | None = None) -> Path:
"""Build a directory-form pack (`song.sloppak/`), the authoring shape."""
files = files or {
"original/full.ogg": b"MIXDOWN",
"stems/guitar.ogg": b"g",
"stems/drums.ogg": b"d",
"arrangements/lead.json": b"{}",
}
path.mkdir()
(path / "manifest.yaml").write_text(yaml.safe_dump(manifest, sort_keys=False))
for name, data in files.items():
p = path / name
p.parent.mkdir(parents=True, exist_ok=True)
p.write_bytes(data)
return path
def test_iter_packs_discovers_directory_form_packs(tmp_path: Path):
"""A `song.sloppak/` directory is a pack; os.walk must yield it whole and
NOT descend into it (its stems/ are contents, not packs)."""
d = _write_dir_pack(tmp_path / "song.sloppak", _manifest())
z = _write_pack(tmp_path / "other.feedpak", _manifest())
found = set(mig.iter_packs(tmp_path))
assert d in found and z in found
# Nothing inside the directory pack was yielded as its own pack.
assert not any(d in p.parents for p in found)
def test_iter_packs_yields_a_directly_passed_dir_pack(tmp_path: Path):
d = _write_dir_pack(tmp_path / "song.sloppak", _manifest())
assert list(mig.iter_packs(d)) == [d]
def test_directory_form_pack_is_reported_not_silently_skipped(tmp_path: Path, capsys):
"""The migrator rewrites single-file packs atomically; a directory can't be
swapped that way, so it is surfaced as a problem rather than vanishing from
the run (the silent-skip this guards against) or being rewritten unsafely."""
d = _write_dir_pack(tmp_path / "song.sloppak", _manifest())
good = _write_pack(tmp_path / "good.feedpak", _manifest())
rc = mig.main([str(tmp_path)])
out = capsys.readouterr().out
assert rc == 1 # a reported problem fails the exit code
assert "dir-form-unsupported" in out
assert mig.verify_zip(good) == "ok" # the zip pack still migrated
# The directory pack is untouched: legacy key intact, mixdown not moved.
manifest = yaml.safe_load((d / "manifest.yaml").read_text())
assert manifest.get("original_audio") == "original/full.ogg"
assert (d / "original" / "full.ogg").read_bytes() == b"MIXDOWN"
def test_verify_reports_directory_form_packs(tmp_path: Path):
d = _write_dir_pack(tmp_path / "song.sloppak", _manifest())
assert mig.verify_pack(d) == "dir-form-unsupported"
+103
View File
@@ -0,0 +1,103 @@
"""The library root must be resolved ONCE, not on every path check.
`Path.resolve()` lstats every component of a path. `_resolve_dlc_path` and
`safe_join` run once per song / art fetch / scanned row, and both used to
re-resolve their root every single call.
Measured on a real 50,944-song library sitting on an NTFS-3G (FUSE) mount:
~23,500 stat/lstat calls per second, re-walking the same three parent
directories, pinning a core of the server. Every stat crosses into userspace on
FUSE, so the constant re-resolution not the work itself was the cost.
These tests pin the fix (root resolved once) AND that caching it did not weaken
containment, which is the thing that matters: `safe_join` is the zip-slip guard.
"""
from pathlib import Path
import pytest
from dlc_paths import _resolve_dlc_path
from safepath import resolved_root, safe_join
@pytest.fixture(autouse=True)
def _clear_cache():
resolved_root.cache_clear()
yield
resolved_root.cache_clear()
def test_dlc_root_is_resolved_once_across_many_lookups(tmp_path):
"""The regression: 500 lookups must not mean 500 root resolutions."""
(tmp_path / "a.feedpak").write_bytes(b"x")
for i in range(500):
assert _resolve_dlc_path(tmp_path, f"song{i}.feedpak") is not None
info = resolved_root.cache_info()
assert info.misses == 1, (
f"the library root must be resolved ONCE, not per call "
f"(got {info.misses} resolutions for 500 lookups)"
)
assert info.hits == 499
def test_safe_join_resolves_its_root_once_too(tmp_path):
for i in range(200):
assert safe_join(tmp_path, f"asset{i}.png") is not None
assert resolved_root.cache_info().misses == 1
def test_a_different_root_is_a_different_cache_entry(tmp_path):
other = tmp_path / "other"
other.mkdir()
_resolve_dlc_path(tmp_path, "a.feedpak")
_resolve_dlc_path(other, "a.feedpak")
assert resolved_root.cache_info().misses == 2, "switching library dir must re-resolve"
# ── containment must be unchanged (the part that matters) ───────────────────
@pytest.mark.parametrize("evil", [
"../etc/passwd",
"..\\etc\\passwd",
"a/../../etc/passwd",
"/etc/passwd",
"C:/Windows/system.ini",
"",
])
def test_resolve_dlc_path_still_rejects_escapes(tmp_path, evil):
assert _resolve_dlc_path(tmp_path, evil) is None
@pytest.mark.parametrize("evil", [
"../outside.txt",
"..\\outside.txt",
"a/../../outside.txt",
"",
])
def test_safe_join_still_rejects_escapes(tmp_path, evil):
assert safe_join(tmp_path, evil) is None
def test_safe_join_still_follows_symlinks_out(tmp_path):
"""safe_join's candidate resolution is the zip-slip defence and is NOT cached:
a symlink pointing outside the root must still be refused."""
outside = tmp_path.parent / "outside_secret"
outside.mkdir(exist_ok=True)
(outside / "secret.txt").write_text("x")
root = tmp_path / "root"
root.mkdir()
(root / "escape").symlink_to(outside)
assert safe_join(root, "escape/secret.txt") is None, (
"a symlink escaping the root must still be rejected — caching the ROOT "
"must not disable resolution of the CANDIDATE"
)
def test_in_library_paths_still_resolve(tmp_path):
assert _resolve_dlc_path(tmp_path, "sub/song.feedpak") == tmp_path / "sub" / "song.feedpak"
assert safe_join(tmp_path, "art/cover.png") == (tmp_path / "art" / "cover.png").resolve()

Some files were not shown because too many files have changed in this diff Show More