The v3 library tree rows showed no arrangement badges, unlike the grid/card
view. Render the same clickable chips in tree rows so both views match, and
clicking a specific arrangement opens THAT arrangement in the highway.
Extract the grid's chip markup into a shared arrChipsHtml(song) (one
<button data-arr="<index>"> per arrangement, capped at 4) and use it in both
songCard and the tree row. No new wiring needed: wireCards() already binds
[data-arr] → playCard(song, index) → playSong(filename, index) for any
[data-fn] scope, and the arrangement index is preserved through
/api/library/artists. Chips are hidden on the narrowest viewports
(hidden sm:flex) so they don't crowd the dense single-line tree row.
Closes#581
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
After the slopsmith→feedBack rename (#537) the minigames SDK publishes
window.feedBackMinigames and only drains window.__feedBackMinigamesPending.
Minigame plugins that still use the pre-rename shim register against
window.slopsmithMinigames and queue to window.__slopsmithMinigamesPending
when the SDK isn't up yet, so their specs are stranded in the legacy queue
and never register. In v3, FeedBarcade renders those games as non-launchable
"Loading…" tiles that do nothing on click (the tile itself comes from the
server registry, so it appears even though the JS spec never registered).
Publish window.slopsmithMinigames as an alias and drain the legacy pending
queue too (register() is keyed on spec.id, so double-queued specs register
once). Also fire the legacy slopsmith-minigames-ready event. Bump the plugin
version so the desktop renderer cache-busts the updated screen.js.
This rescues every not-yet-migrated minigame plugin, including community
ones we don't control.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tester: "at the tune step, pressing the Tuner button starts a second wizard at
the input-select step."
Root cause is stacked full-screen overlays. During onboarding the input-setup
flow runs as #input-setup-overlay (z-210) on top of the onboarding modal
#v3-onboarding (z-200), and note_detect's Calibration Wizard (z-300) launches on
top of that. When the player opens the Tuner, that wizard minimizes itself to
transparent + pointer-events:none so the Tuner (z-1000) is usable — but the
input-setup overlay underneath, still showing its "select your input" card, then
shows through behind the floating tuner and reads as a second wizard.
Two targeted hides so only the active surface is visible:
- input_setup: hide #input-setup-overlay while launchCalibration runs; restore on
its onDone/onCancel (one always fires on close), so the calibration wizard /
tuner own the screen.
- onboarding runInputSetup: hide #v3-onboarding for the whole input-setup phase
(its own overlay replaces it visually); restore in finally before advancing to
the calibration-challenge step.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cosmetic follow-up to #537 (doc-only).
- Virtuoso: README + CHANGELOG used lowercase
`got-feedback/feedback-plugin-virtuoso`; the canonical repo (like every
other got-feedback repo) is capital-B `feedBack-plugin-virtuoso`. Brought
it in line with the sibling rows.
- Community plugin references in CLAUDE.md, TODO.md, docs/, and the bundled
tuner README were over-renamed to `feedBack-*` by the rename, but those
repos are owned by community members who never renamed them
(topkoa/stems+notedetect, OmikronApex/tuner, masc0t/update-manager).
Restored their real `slopsmith-*` names. got-feedback's own `feedBack-*`
references in the same files are left untouched.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The v3 library loaded the best-accuracy map (/api/stats/best) once into
state.accuracy at render time and only refreshed it on a full re-render.
The play->return flow takes the screen-entry fast-path that restores the
cached grid DOM without re-fetching, so a just-earned score stayed
invisible on the card until the next app restart re-ran render().
stats-recorder now emits a `stats:recorded` event (filename/arrangement)
once the scored POST /api/stats resolves on the server -- the correct
moment, since song:stop fires before the POST completes. songs.js
listens: if the library is the active screen it re-fetches
/api/stats/best and patches the affected card/row badge in place;
otherwise it marks the filename dirty and onV3SongsScreenEnter applies
it on return. A failed fetch keeps the entry dirty so a later trigger
retries instead of silently dropping the update.
Badge markup is factored into a shared accuracyBadge(filename, variant)
(grid pill + tree-row percentage, both tagged .fb-acc-badge) so the
in-place repaintAccuracy can find and replace them without a full list
re-render, preserving scroll and pagination. The old empty song:stop
"refresh lazily next render" placeholder is replaced.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Equipping a cosmetic theme recolored text, fb-* utility surfaces, and
body, but the left sidebar's navy radial wash stayed on its default — so
the interface read as "only the fonts change, not the backgrounds".
Cause: #v3-sidebar is painted with a hardcoded radial-gradient in v3.css
and carries no fb-* utility class, so theme-core's per-utility override
loop never reaches it (#1e293b == default card, #0f172a == default bg).
Extend cssFor() — which already special-cases body — to re-point the
sidebar gradient at the theme, gated by html[data-fb-theme] so the
default (no-theme) look is untouched. Only background-image is overridden,
preserving v3.css's background-attachment:fixed.
Verified in Chromium against the real tailwind.min.css + v3.css +
theme-core.js: default = navy gradient (unthemed), apply() recolors the
sidebar to the theme's card->bg stops, apply(null) reverts to navy.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses Codex review of #526/#528:
- midi-input discover(): one provider's enumerate() rejection no longer aborts
the whole discovery — other providers (e.g. a native/desktop MIDI provider)
are still queried; denial is only reported when NO provider enumerates.
- Home tour now waits for a 'v3:dashboard-rendered' event (dashboard.js emits
it after the #v3-home innerHTML swap) before attaching Shepherd, instead of a
single animation frame that could latch onto pre-render nodes the async
dashboard render then replaces.
- "Play it now" onboarding now arms the tour (armPendingFirstRun) to run the
first time the user returns to v3-home, instead of silently never showing it.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The section_map plugin injects #section-map as #player's first child — a
~20px bar pinned to top:0 (z-index:5). #player-hud is also top:0/absolute
but at z-index:10 with only py-3 (12px) top padding, so its song name
(top-left) and timer (top-right) paint on top of that bar.
Push the HUD's content below the bar when it is present. The general-
sibling combinator only matches when #section-map precedes #player-hud —
exactly how the plugin inserts it — so the bar-less layout is untouched.
ID-on-ID specificity overrides Tailwind's .py-3 top padding.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Switching between the 3D drum highway (renders onto #highway) and the 3D
guitar highway (renders into its own .h3d-wrap overlay) left the previous
drum frame showing through the gap the overlay did not cover.
Core: _setRenderer now replaces #highway on a genuine viz change (keyed on
viz id via _rendererVizKey, not object identity, so benign same-viz
re-installs don't churn the canvas) as well as on a context-type change.
highway_3d: applySize pins the .h3d-wrap overlay to #highway's exact box,
derived from the same getBoundingClientRect measurements that size the
renderer (sub-pixel correct under zoom). Re-pins once the canvas lays out
(init race) and resets to the static anchor in the not-laid-out fallback.
Reviewed locally via codex (5 rounds, converged clean). CI checks are the
known org Actions billing block, not real failures.
The stats-recorder relays URL-encoded filenames (encodeURIComponent:
'/'→'%2F', ' '→'%20') and POST /api/stats stored them verbatim, but the
`songs` table — and every stats read that filters on
`filename IN (SELECT filename FROM songs)` — keys on the decoded library
path. So recorded plays landed under a non-matching key and were dropped
by the filter: the profile "Your best scores" panel, the library accuracy
badges (/api/stats/best) and "Jump back in" (/api/stats/recent) all read
empty despite real history. PR #549/#550 wired the panel correctly; this
fixes the data layer underneath it.
- Canonicalize the filename to its decoded form on the write path
(_decode_song_filename in api_record_stats). This also lets the
arrangement-count bound resolve the real song.
- One-time idempotent backfill (_migrate_decode_stat_filenames) that
decodes existing rows, merging PK collisions with best=max / plays=sum /
last-wins semantics.
- Regression tests: encoded write surfaces in top/best/recent + per-song
read; arrangement bound still applies; migration decodes + merges legacy
rows and is idempotent.
Verified against a copy of a real profile DB: top_stats went 0→5 rows,
best-accuracy map 0→12, zero encoded ghosts left.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
window.h3dSetFretSpacing was the only 3D-highway setting that applied via
location.reload(). The SPA boots with #home as the active screen and has
no restore-last-screen mechanism, so the reload ejected the user from
Settings onto the home screen.
Apply it live like every other 3D-highway setting: rebind the module-scope
_h3dFretUniform flag (so panels mounted later this session pick up the new
mode), recompute the two fretX-derived scalars baked at init
(_fretLabelScaleRefW, FRET_WIDTH_MID), and broadcast a 'fretSpacing' change
over the existing _bgEmitChange pub-sub so every mounted panel rebuilds its
board via buildBoard(). Per-frame note geometry already reads fretX live.
Settings copy updated (no longer reloads) and tests/js pin the no-reload /
live-rebuild behavior.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The topbar search (#v3-search) rendered on every screen and, on the library
screen, was hidden behind the filter toolbar while scrolling (both were
sticky top-0 z-20 in the #v3-main scroller).
- shell.js: wrap the search in #v3-search-wrap (hidden by default) and toggle
it in syncActive() so it only shows on #v3-songs; bump the topbar to z-30 so
it always sits above the toolbar.
- songs.js: drop the toolbar's top-0 and pin it beneath the topbar by measuring
the topbar height (positionToolbar). A ResizeObserver on #v3-topbar keeps the
offset correct as the topbar height changes (viewport width, search show/hide)
and fixes the initial position regardless of render()/syncActive() ordering.
Fixes#559
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(v3): pedal click opens the plugin's screen, not its settings
The v3 Pedalboard's settingsTarget() resolved settings-first, so a
plugin that ships both a screen and a settings panel (notably the
bundled Audio Engine) could only ever reach its settings from the
pedalboard — its actual page was unreachable.
Flip to screen-first (stompbox metaphor: step on the pedal, see the
pedal), falling back to settings when there is no screen. Keep a
settings fallback in openPluginSettings() when a declared screen
isn't mounted yet (installing/failed) so settings-bearing plugins are
never stranded on a toast. Drive the pedal aria-label off the same
target so it never promises the wrong surface. Update the unit test
contract to screen > settings > none.
Fixes#555
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(player): global autoplay & auto-exit option (songs + lessons)
Single Settings toggle (autoplayExit, default ON) that auto-starts a song
once it's ready and returns to the launching menu when it ends. Auto-exit
defers while a results/score overlay is on top (heuristic + holdAutoExit()
contract) so a scoring plugin's screen drives the exit. Player origin is now
context-aware (lessons return to the lessons screen via setReturnScreen()),
fixing lesson completion bouncing to the library.
Core-only; songs and lessons share the playSong -> highway path. Adds a
read-only window.slopsmith.autoplayExit getter + holdAutoExit()/setReturnScreen()
for plugins. Unit tests for the pure helpers (_autoplayExitEnabled,
_resolvePlayerOrigin, _resultsOverlayVisible).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The v3 Pedalboard's settingsTarget() resolved settings-first, so a
plugin that ships both a screen and a settings panel (notably the
bundled Audio Engine) could only ever reach its settings from the
pedalboard — its actual page was unreachable.
Flip to screen-first (stompbox metaphor: step on the pedal, see the
pedal), falling back to settings when there is no screen. Keep a
settings fallback in openPluginSettings() when a declared screen
isn't mounted yet (installing/failed) so settings-bearing plugins are
never stranded on a toast. Drive the pedal aria-label off the same
target so it never promises the wrong surface. Update the unit test
contract to screen > settings > none.
Fixes#555
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(progression): fancy notifications for quest/path progress + completion (#551)
Surface achievement feedback as in-app toasts when the player advances or
finishes a daily/weekly quest, and when they progress or level up an
instrument path.
- progression-core.js: _diff() now emits two partial-advance events —
quest-progressed (a still-incomplete quest whose count rose) and
path-progressed (a challenge toward the next level completed without a
level-up). Both are guarded so the increment that COMPLETES a quest /
the level-up itself stays a single quest-completed / path-level-up event
(no double toast). Period rollovers and brand-new quest ids emit nothing.
New events added to the capability owner's declared events list.
- notifications.js (new): reusable window.fbNotify toast surface (stacked,
animated, auto-dismiss; animation + accent via inline styles so no new
Tailwind utilities) + progression wiring — subtle toasts for advances,
celebratory toasts for quest completion, path level-up, and rank-up.
- index.html: load notifications.js after progression-core.
- tests: progression_progress_events (diff emission + guards) and
progression_notifications (toast rendering + wiring) — 11 cases.
No backend change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(progression): unwrap CustomEvent .detail in notification handlers
Codex P2: window.slopsmith.on delivers a CustomEvent (bus.on →
addEventListener), so the progression payload is e.detail — not the raw
argument. All five notifications.js handlers read the arg directly, so in
the browser every field was undefined (e.g. rank-changed never toasted).
Unwrap e.detail in each handler, matching every other sm.on consumer.
The test harness masked this by invoking handlers with raw payloads; it now
wraps them as {detail: payload} like the real bus, so the unwrap is actually
exercised (the tests fail without the fix).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The profile card's "Your best scores" panel was a hardcoded placeholder
(`#v3-profile-bests` was never filled), so it always read "Play a song to
start tracking..." regardless of how many songs had been scored. The
backend already records best_score/best_accuracy per song; only this
panel was left unwired.
- server.py: add MetadataDB.top_stats(limit) (per-song aggregate, best
score first, scored songs only, dead songs skipped) + /api/stats/top
route that enriches rows with title/artist/art, mirroring
/api/stats/recent. Declared before the /api/stats/{filename} catch-all.
- static/v3/profile.js: renderBests() fetches /api/stats/top and fills the
panel (rank, title/artist, best accuracy %, score; click to play),
keeping the placeholder only when nothing's been scored.
- tests: cover ordering, per-song aggregation, limit, and
resume-only/dead-song exclusion.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Post-merge Codex review of #544 found chord_template_to_wire emitted ct.caged and
ct.guide_tones raw — so a directly-constructed ChordTemplate(caged="X") or
guide_tones=[99] would write a schema-invalid value to the feedpak wire, even
though the decoder guards on input. The spec constrains caged to C/A/G/E/D and
guideTones to 0..11.
Run the same _sanitize_caged / _sanitize_guide_tones guards on emit: caged is
written only when a valid enum value, guideTones only as the in-range ints (empty
result -> key omitted). +1 test (invalid caged dropped, mixed guideTones filtered to
the valid in-range subset, wholly-invalid list omitted).
Codex-reviewed: clean. 154 song tests pass.
Part of got-feedback/feedback#334.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mirror the voicing/fn.rn teaching-mark render for the two new chord-template
fields, in both the 2D and 3D highways:
- Extend the shared pure chordHarmonyLabels() helper (identical in static/highway.js
and plugins/highway_3d/screen.js) to also surface caged ("CAGED: E") and
guideTones ("gt 4,10"), pre-formatted and node-testable. Invalid caged enum and
out-of-range / non-int guide tones are filtered out.
- Draw both, stacked above the existing rn/voicing labels, in distinct colors.
- Gated behind the SAME teaching-marks toggle (_showTeachingMarks 2D /
teachingMarksVisible 3D) — no clutter on the default highway.
Render only — no scoring / NoteVerifier coupling.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mirror the voicing field for the two deferred FEP #24 harmony annotations on
ChordTemplate:
- caged: str ("C"/"A"/"G"/"E"/"D", "" = unset)
- guideTones: list[int] (semitone offsets 0..11 above the root, [] = unset)
Both are default-omitted on the wire and sanitized on decode (caged enum-guarded,
guideTones filtered to in-range ints, rejecting bool) so a malformed value can't
round-trip. GP import is untouched — GP carries no CAGED / guide-tone data.
Teaching annotations only; never fed to a grader.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(core): carry chord harmony fn + template voicing on the wire (§6.3.1, §6.6)
Add two OPTIONAL per-chord harmony annotations (feedpak 1.7.0), mirroring the
teaching-marks (fg/ch/sd) wire work:
- Chord.fn (instance): {rn, q, deg} harmonic-function object, key-dependent.
Validated by _validate_fn on BOTH decode and emit so a partial / out-of-range
fn (which would fail the schema's required-keys rule) never rides the wire.
Default-omitted, mirroring bend bnv.
- ChordTemplate.voicing (template): key-independent voicing-type string
("open", "triad", "shell", "drop2", "barre", ...). Emitted only when
non-empty; non-string wire values fall back to "".
Display/teaching only — never fed to a grader (honesty rule). fn auto-derivation
is DEFERRED (carry-only): a complete rn/q needs chord-quality analysis, and a
deg-only fn would be schema-invalid, so server.py carries author-provided fn
unchanged. GP import unchanged (no reliable per-chord function/voicing).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(highway): render chord harmony fn.rn + voicing on 2D + 3D (§6.3.1, §6.6)
Draw the chord's harmonic-function Roman numeral (instance fn.rn) and its
template voicing string, stacked above the chord name on both highways. A shared
pure helper chordHarmonyLabels(fn, voicing) formats the two labels (empty when
absent/malformed) and is node-tested against both files.
Both labels are gated behind the EXISTING teaching-marks opt-in
(_showTeachingMarks / teachingMarksVisible bundle flag) — they're chord-level
teaching overlays, same class as sd/ch, so they stay off the default highway.
2D guards the empty-note-chord case; 3D reuses the gold chord-label sprite style.
Render only — no scoring / NoteVerifier path is touched (honesty rule).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add two OPTIONAL per-chord harmony annotations (feedpak 1.7.0), mirroring the
teaching-marks (fg/ch/sd) wire work:
- Chord.fn (instance): {rn, q, deg} harmonic-function object, key-dependent.
Validated by _validate_fn on BOTH decode and emit so a partial / out-of-range
fn (which would fail the schema's required-keys rule) never rides the wire.
Default-omitted, mirroring bend bnv.
- ChordTemplate.voicing (template): key-independent voicing-type string
("open", "triad", "shell", "drop2", "barre", ...). Emitted only when
non-empty; non-string wire values fall back to "".
Display/teaching only — never fed to a grader (honesty rule). fn auto-derivation
is DEFERRED (carry-only): a complete rn/q needs chord-quality analysis, and a
deg-only fn would be schema-invalid, so server.py carries author-provided fn
unchanged. GP import unchanged (no reliable per-chord function/voicing).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Post-merge review of #538 noted the fg finger numeral rendered unconditionally on
both highways and couldn't be turned off — only sd/ch sat behind the (default-off)
teaching-marks toggle. A user who finds per-note numerals busy had no way to
declutter.
Add a SEPARATE finger-hints gate that keeps fg shown by default but makes it
hideable, independent of the sd/ch opt-in (so the two defaults — fg on, sd/ch off —
coexist; a single boolean can't express that):
- 2D static/highway.js: _showFingerHints (localStorage 'showFingerHints' !==
'false', i.e. default on), a fingerHintsVisible bundle flag, and
get/toggle/setFingerHintsVisible API; gates the fg label.
- 3D plugins/highway_3d/screen.js: mirrors via bundle.fingerHintsVisible !== false
(default on); gates the fg sprite. sd/ch unchanged.
Default-on preserved (absent localStorage / absent bundle flag => shown); only an
explicit false hides fg. Codex-reviewed: clean. Render test 7/7.
Part of got-feedback/feedback#334.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Render the three per-note teaching marks on both highways, mirroring the
bend-curve render (#532). Display only — no scoring / NoteVerifier coupling.
- 2D static/highway.js: fg renders by default as a small finger numeral hugging
the gem (T = thumb, 1..4); sd (degree label) and ch (strum bracket connecting
notes that share a ch key, arrow direction from pkd) are opt-in behind a new
`showTeachingMarks` toggle (exposed via toggle/get/set + the bundle's
`teachingMarksVisible` flag). Pure helpers teachingFingerLabel /
teachingDegreeLabel / strumGroupBuckets drive the glyphs. ch bracket is
note-stream-only (chord notes already read as one gesture).
- 3D plugins/highway_3d/screen.js: fg (default) + sd (opt-in, mirrors the 2D
toggle via bundle.teachingMarksVisible) render next to the per-note fret label
via a new pooled sprite (pTeachMarkLbl); _scrChordNote resets fg/sd so chord
notes don't inherit stale marks. ch strum brackets are deferred in 3D (no
cross-note batch pass in the per-note render); 2D covers ch.
Tests: tests/js/highway_teaching_marks.test.js extracts the pure helpers from
both files (extract-and-eval) and asserts label mapping + strum-group bucketing.
Part of got-feedback/feedback#334
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the three OPTIONAL per-note feedpak 1.5.0 teaching marks — fg (fret-hand
finger), ch (strum-group key), sd (scale degree) — to the Note model and wire
format, mirroring the bend-shape work (#531). These are DISPLAY/TEACHING ONLY:
nothing in the scoring / note-verification path reads them.
- lib/song.py: Note.fret_finger / strum_group / scale_degree, default-omitted
on the wire (fg/ch/sd) and decoded via _wire_int_optional; _parse_note reads
the GP-written fretFinger XML attr. Pure helpers key_to_tonic_pc (§7.7 key
name -> tonic pitch class) + scale_degree_for_pitch, plus base_open_string_midis
/ pitch_from_base / note_pitch_midi (tuning offsets + capo + fret -> MIDI,
mirroring app.js _TUNING_BASE_MIDI).
- lib/gp2rs.py: GP5 note.effect.leftHandFinger -> fg (RsNote field + fretFinger
XML attr), reusing the chord Fingering value convention.
- lib/gp2rs_gpx.py: GP8/GPIF per-note <LeftFingering> (p-i-m-a-c letter codes,
verified against real GP8 exports) -> fg.
- server.py highway_ws: derive sd for notes + chord notes from the active
keys.json key + sounding pitch when the author didn't author one (author value
wins); base hoisted out of the per-note loop.
Tests: round-trip + omit-when-default + malformed-tolerance for fg/ch/sd;
key_to_tonic_pc + scale_degree_for_pitch + note_pitch_midi (standard/drop-D/
capo/bass) units; GP5 leftHandFinger and GP8 <LeftFingering> import.
Part of got-feedback/feedback#334
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Post-merge Codex review of the bend-curve PRs (#531/#532) surfaced edge cases:
- GP8 (#531 P2): bnv timing used rn.sustain, which is zeroed for notes <= 0.2s,
so short GP8 bends kept the scalar bn but lost bt/bnv. Use the beat duration
`dur` (matching the GP5 path) so the curve survives.
- 2D highway (#532 P2): bnvNormalizedPoints mapped x over the curve's own t-range
[first,last] instead of the note span, so curves not starting at 0 / ending at
sus were time-distorted. Now maps over [0, sus] (clamped), with a curve-span
fallback when sus<=0 (existing no-sus callers unaffected).
- 3D highway (#532 P3): the sustain ribbon + bend chevron were gated on bn>0, so a
note carrying an authoritative bnv with bn==0 drew no ribbon/marker. Both now
also fire on bnv presence; chevron steps derived from max(bn, bnv peak).
Codex-reviewed: clean (no findings). +1 JS test (sus-relative mapping + fallback).
JS 8/8, 250 core GP/song tests pass.
NB: GP8's short-bend path still lacks a dedicated synthetic-GPIF fixture (same gap
as the GP8 offset-prop-names P3) — _gpx_bend_shape units cover the function; the
fix is the one-line caller change.
Part of got-feedback/feedback#334.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PR-B of the bend-shape feature (feedpak §6.2.1). Both highways drew a bend
from the scalar `bn` only; now they trace the authoritative `bnv` curve
([{t, v}]) when present and fall back to the `bn` arc/envelope otherwise.
2D (static/highway.js drawNote): when a note carries `bnv`, draw the real
shape as a contour above the gem (round-trip rises then falls, pre-bend
starts high, release descends — `bt` is implicit in the point shape), with
an arrowhead only when the gesture ends rising. `bnvNormalizedPoints` maps
{t,v} to a 0..1 x span. The scalar-arrow path is preserved unchanged as the
fallback; the peak label is unchanged.
3D (plugins/highway_3d/screen.js): `bnvSampleAt` linearly interpolates the
curve (clamped to its endpoints) and `bendSemisAtTime` samples it when
present, else keeps the synthetic rise→hold→release envelope from `bn`. The
chevron count still comes from the peak. Fixed a stale-scratch hazard: the
reused `_scrChordNote` now resets `bnv`/`bt` (omit-when-default) after
Object.assign, mirroring the existing `fhm` reset, so a chord note without a
curve can't inherit the previous note's contour.
Render-only — no wire/schema change. Pure helpers covered by
tests/js/highway_bend_curve.test.js (interp, clamping, round-trip,
degenerate/empty); node --check passes on both files; full tests/js green.
Part of got-feedback/feedback#334
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Implements feedpak spec §6.2.1 (feedpak 1.4.0) per-note bend shape on the
core side:
- `bn` stays the bend's peak magnitude in semitones (unchanged).
- `bt` — bend intent (0 up, 1 release, 2 pre-bend, 3 pre-bend-release,
4 round-trip), default 0, default-omitted on the wire.
- `bnv` — time-stamped bend curve [{t: seconds-from-onset, v: semitones}],
authoritative when present; default-omitted. Older readers ignore both.
Wire (lib/song.py): Note.bend_intent/bend_values; note_to_wire emits bt/bnv
only when set; note_from_wire reads them via _sanitize_bend_curve (drops
malformed entries, empty -> None never []). _parse_note reads them from the
GP-import XML (bendIntent attr + bendValues JSON) so GP curves survive
import -> XML -> wire -> highway.
GP5 (lib/gp2rs.py): _gp_bend_shape maps pyguitarpro BendPoints to a bnv
curve — semitones = value/2.0 (consistent with the existing scalar bn),
t = position/12 * duration — and _bend_intent_from_values derives bt from
the shape. Emitted for <note> and <chordNote> via the shared _build_xml.
GP8 (lib/gp2rs_gpx.py): _gpx_bend_shape builds a 3-point curve from the
GPIF origin/middle/destination value+offset Properties (value/divisor
semitones, offset/100 * sustain seconds), reusing the shared _build_xml.
GPIF offset Property names should be confirmed against a real GP8 export.
Tests cover wire round-trip + default-omit + sanitization, GP5 unit/time
mapping + intent classification end-to-end through the XML, and the GP8
curve builder.
Part of got-feedback/feedback#334
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Core never read or emitted the manifest `feedpak_version` field. Adopt it:
- sloppak.py: `FEEDPAK_VERSION = "1.2.0"` constant (the format version this build
targets); `LoadedSloppak.feedpak_version` read from the manifest on load
(string, else None for legacy/absent).
- Stamp the version on the two core manifest-rewrite paths, without downgrading
an existing (possibly higher) declared version:
- gp2notation: `setdefault` before its notation-add rewrite.
- songmeta: opportunistically when a metadata field is supplied (gated on the
existing `dirty` flag, so never a standalone rewrite).
Core has no create-from-scratch path (RS-free repo) — the editor plugin's
create-mode save stamping FEEDPAK_VERSION is a follow-up in that repo. Internal
"sloppak" naming is intentionally left as-is (a rename is out of scope / risky).
Codex-reviewed: no P1/P2. +6 tests (read present/absent/non-string; metadata-write
stamp-when-absent / preserve-existing / no-op-no-stamp) + updated the gp2notation
key-order test for the appended version. 197 sloppak/songmeta/gp2notation tests pass.
Closes#527. Part of got-feedback/feedback#334.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feedpak 1.2.0 added song-level `tempos` + `time_signatures` to song_timeline.json
and a per-chart `tempos` override on arrangements (§6.10). Core stored the raw
song_timeline dict but never consumed the maps, and didn't read per-chart tempos.
- song.py: shared `sanitize_tempos([{time,bpm}])` (finite non-bool time, finite
bpm>0, sorted); `Arrangement.tempos` field wired through arrangement_to_wire
(omitted when None/empty per §6.10) / arrangement_from_wire.
- sloppak.py: `_sanitize_time_signatures([{time,ts:[num,den]}])`;
LoadedSloppak.tempos / .time_signatures, loaded from song_timeline.json
INDEPENDENTLY of beats/sections (all are optional in 1.2.0).
- server.py: stream `tempos` + `time_signatures` highway-WS messages; the active
arrangement's per-chart `tempos` overrides the song-level map for that chart.
Renderer/UI surfacing is a thin follow-up; this lands the data plumbing.
Codex-reviewed: clean (no findings). +9 tests (sanitizers, per-chart wire
round-trip + omit-when-absent, song-level load/sanitize/absent + maps-without-
beats). 90 song/sloppak tests pass. (Pre-existing unrelated failure:
test_diagnostics_redact, fails on clean main too.)
Closes#526. Part of got-feedback/feedback#334.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The feedpak spec defines keys.json (instrument-independent key/scale-change
track, §7.7) but core never loaded it. Add it, mirroring the song_timeline /
drum_tab side-file pattern:
- lib/sloppak.py: LoadedSloppak gains a `keys` field; a permissive loader reads
the manifest `keys:` key, path-safety-checks it, and stores a SANITIZED
{version, events:[{t, key, scale?}]} — finite non-bool t (bad-t events dropped,
not rewritten to 0), non-empty string key, optional string scale, sorted.
Missing / unreadable / malformed -> None, never fatal. int-only version
(a float/NaN version can't abort the load).
- server.py: stream a `keys` highway-WS message when present + a `has_keys`
song_info flag so a consumer can light up a key/scale display.
Renderer/HUD surfacing is a thin follow-up; this lands the data plumbing so
the highway, plugins, and the upcoming scale-degree (`sd`) annotation can read
the active key/mode from the WS.
Codex-reviewed (2 rounds: version-int-coercion + bad-t-drop hardening); clean.
+7 loader tests (happy path, absent/permissive variants, sanitize/sort,
non-int-version no-abort). 150 sloppak/load tests pass.
Closes#525. Part of got-feedback/feedback#334.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 2D highway drew pitched slides (sl) but ignored unpitched slides (slu) —
drawNote read only opts.sl. The 3D highway already renders both (slideTrailEnd).
drawNote now draws slu as a dashed diagonal with no arrowhead (no definite
target pitch), keeping the solid arrow+arrowhead for pitched sl. The two are
mutually exclusive in the data. Chord notes flow through the same drawNote, so
chord-note unpitched slides are covered too.
Also fixes a latent pre-existing bug flagged in review: `opts?.sl || -1`
discarded a pitched slide-to-open (sl: 0); now `?? -1` preserves fret-0 targets
and keeps pitched precedence.
Codex-reviewed: no P1/P2; dash state reset on all paths, no pitched-slide
regression. node --check clean.
Closes#336. Part of got-feedback/feedback#334.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Post-merge Codex review of E3 (PR #522) flagged two GP5 edge cases:
- P2: enrichment applied the diagram's name/fingers to the played template
without checking the diagram described the voicing actually played. A
mismatched chord label/diagram could mis-name/finger the played template, and
the back-fill spread it to other strums of the same played pattern. Now gated
on an exact, full-span fret-pattern match (new _chord_diagram_frets), mirroring
the GP8 guard — and comparing over max(played width, num_strings) so a
7/8-string diagram can't falsely match a narrower played voicing.
- P3: name and fingers back-fill were coupled (a name-only first annotation
blocked a later beat's fingers). Now independent.
Codex re-reviewed twice (the first match-gate trimmed extended strings; fixed by
the full-span compare); final pass clean. +4 tests (mismatch-not-applied,
name-then-fingers decoupled, higher-position absolute match, 7-string extended
string regression). 163 GP tests pass.
Follow-up to #522. Part of got-feedback/feedback#334.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GP imports previously landed chord templates with blank fingerings (GP5 +
GP8) and blank names (GP8), so the editor/highway had nothing to show even
though E0 preserves and E1 authors that data. E3 extracts the real chord
diagrams so imports arrive rich, keyed on the same fret-pattern join key the
editor (E0) and GP5 already use.
GP8 (lib/gp2rs_gpx.py): parse the per-track GPIF DiagramCollection
(Item @name + Diagram Fret/Fingering) into a fret-pattern -> {name, fingers}
map and enrich matching played voicings at the template build site. Diagram
string indices share the note String index space, so they go through the same
pitch-rank transform; <Fret fret> is treated as absolute.
GP5 (lib/gp2rs.py): pyguitarpro exposes the voicing on beat.effect.chord
(.strings indexed 0=highest string, .fingerings the parallel Fingering enum,
already RS finger ints). New _chord_fingers maps them to RS string order; the
template enrich now back-fills any still-blank template so the annotated chord
attaches even when an earlier unannotated strum of the same voicing created it.
Finger encoding (none/open=-1, thumb=0, index=1, middle=2, ring=3, pinky=4)
matches the editor E1 + RS serializer. Only enriches on exact fret-pattern
match; diagram-less charts import identically (blank). Verified end-to-end
against real files (GP8_Test.gp, joplin-janis-piece_of_my_heart.gp4).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rename the user-visible label of the HOME-group nav entry (and the matching
"Open Shop →" button on the progress page) from "Shop" to "Unlockables".
Internal identifiers (nav key 'shop', screen id v3-shop, window.v3Shop) are
left unchanged so wiring/state are unaffected.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Collapse the per-plugin sidebar list down to the single "Plugins" entry
(the gallery is the one entry point for general plugins) and give two
bundled plugins their own first-class sidebar slots instead:
- SlopScale (manifest label "SlopScale - Practice"), directly under FeedBarcade
- Rig Builder, directly after the Library group
Both are driven by a PROMOTED_PLUGINS table: each slot is anchored after
a nav key and filled by renderPromotedNav() only when the plugin is
present in /api/plugins, so an absent bundle shows nothing rather than a
dead entry that bounces to the Plugins screen. The visible label uses the
plugin's own manifest nav.label (escaped), falling back to the static NAV
label.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* sloppak: read cover without unpacking + serialize/cap zip unpacks
Album art for a zip-form sloppak was served by resolve_source_dir(), which
unpacks the ENTIRE archive (stems included, ~30 MB) to disk just to read
cover.jpg. On the library grid that meant a full extraction per card on scroll.
- read_cover_bytes(): opens only the cover member from the zip (or reads the
file for dir-form), with zip-slip guarding. ~4 ms vs a full unpack.
- resolve_source_dir(): per-file lock + bounded global semaphore so concurrent
callers don't rmtree + re-extract the same dest at once (a race), and a burst
can't saturate disk/CPU. 8 concurrent calls now dedupe to 1 unpack.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* server: serve sloppak art via read_cover_bytes + cache album-art responses
- get_song_art() sloppak branch now reads the cover directly (no full unpack),
off-thread via asyncio.to_thread.
- All art responses carry Cache-Control: public, max-age=86400. URLs are already
cache-busted with ?v=<mtime>, so the browser stops re-fetching every cover on
scroll-back; day bound self-heals any URL missing ?v.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* v3 library: lazy-load + async-decode card cover images
The grid (24 cards/page) and artist-row thumbnails emitted plain <img> with no
loading hint, so a whole page of covers fetched + decoded at once on each
scroll batch. Add loading="lazy" decoding="async" to defer off-screen fetches
and keep image decode off the main thread.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* address Codex review: zip cover normalization + correct art revalidation
Findings from the preflight Codex passes:
- sloppak.read_cover_bytes (zip form) read the raw manifest cover string via
zf.read(), so a non-canonical name like './cover.jpg' or 'art/../cover.jpg'
404'd. Normalize via safe_join → relative member; reject escape and the
degenerate root-collapse case ('.', 'subdir/..') like _unpack_zip does.
- Album-art caching is correctness-first: Cache-Control: no-cache plus a strong
validator, with real conditional handling (Starlette FileResponse emits an
ETag but doesn't evaluate If-None-Match). All three art paths route through
_art_conditional/_file_art_response → bodyless 304 on a matching validator.
A long immutable max-age was rejected because the frontend ?v=<mtime> buster
is only second-resolution and would pin a same-second rewrite.
- The sloppak cover is validated by CONTENT (sha1 of the bytes), not a stat:
a dir-form sloppak edited in place changes the cover file's mtime but not the
directory's, so a dir-stat ETag could emit a stale 304. Content hashing is
correct for both dir- and zip-form. get_song_art gained an optional request
(internal get_art caller passes none — safe).
Adds tests/test_sloppak_cover_art.py pinning read_cover_bytes (canonical,
non-canonical, degenerate/escape, dir/zip, webp) and the endpoint's 304 contract
incl. the dir-form in-place-edit no-stale-304 regression.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The highway showed two overlapping note-detection scoreboards at once: the
core v3 live-performance HUD (#v3-live-performance-hud) and the note_detect
plugin's own HUD (.nd-hud). Both auto-render off the same note:hit/note:miss
events and neither suppressed the other.
Add a Settings → Visualization "Scoreboard" selector (Streak / Detailed /
Off, default Streak) backed by a single source of truth on
<html data-scoreboard>. CSS shows exactly one:
core (default) → core HUD; hide .nd-hud
detailed → .nd-hud; hide the core HUD
off → hide both
CSS-based suppression keys the default off ":not(detailed):not(off)", so the
correct HUD is right even before the pref script runs (no flash) and it
robustly hides any .nd-hud regardless of how many note_detect instances load.
Detection itself is untouched — only the duplicate scoreboard panel is hidden.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* style(tour): align tour engine + Shepherd bubbles to the v3 fb-* palette
The tour/help engine shipped its own indigo/blue dark palette (#181830 / #4080e0)
that predates the v3 fee[dB]ack tokens, and the spotlight bubbles themselves used
the vendored Shepherd LIGHT default (white card, black text) — both clashed with
the navy/sky v3 UI behind them.
- Recolor the "?" menu button, popover and first-visit toast to the fb-* tokens
(card #1e293b, primary #0ea5e9, border #334155, text #f8fafc/#94a3b8, gold
#e8c040 unchanged).
- Add a dark .shepherd-* override block (loads after the vendored shepherd.css,
which is left pristine for upgrades): dark bubble + arrow, fb-primary Next/Done
button, slate secondary button, fb text scale, and bump the modal dim to 0.6 to
match the onboarding overlay.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(tour-engine): let client/core tours register into the consolidated menu
The tour engine only listed server-discovered plugins (those with a tour.json,
populated from /api/plugins) in the "?" menu, and always prompted unseen relevant
tours via the toast + button pulse. Generalize register() so a core/client-owned
tour can participate:
- `name` registers the tour into the menu catalog (_tourPlugins) so it shows in
the "?" menu even without a server plugin; never clobbers a real plugin entry.
- `autoPrompt:false` opts the tour OUT of the unseen toast + pulse (for tours
driven programmatically by their owner), while still listing + running on
demand. _unseenRelevant honours it.
Both options are additive and default to the prior behaviour.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(v3): add stable tour anchors to home cards + instrument badge
Give the first-run home tour stable spotlight targets: #v3-hero on the hero
panel and data-tour="continue" on the three continue/pick/browse card variants
(dashboard.js), and #v3-instrument-wrap on the topbar instrument selector
(badges.js, mirroring the existing #v3-tuner-wrap). The other targets (audio
routing, tuner, profile, sidebar nav) already had stable ids.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(onboarding): first-run home tour (spotlight coach marks)
After a genuine onboarding completion, dim the home page and spotlight one card
at a time with an explanatory bubble + Next, reusing the shared tour engine
(Shepherd). 7 stops: Hero/Start Playing → Continue/Pick → Instrument selector →
Tuner → Audio Routing → Profile → Sidebar nav. Auto-runs once; replayable
forever from the "?" tour menu as "Welcome tour".
- New static/v3/onboarding-tour.js: registers the spotlight tour (screens:
['v3-home'], name "Welcome tour", autoPrompt:false) and exposes startFirstRun(),
gated on the engine's seen/dismissed state so it never repeats; loaded after
tour-engine.js + dashboard.js.
- profile.js finish(): trigger startFirstRun() only on a real onboarding
completion (!editing) — a later profile edit must not relaunch it.
Verified headlessly (native core + Playwright): all 7 anchors resolve, the
spotlight advances one bubble at a time in the v3 dark theme, completion marks
seen, startFirstRun is once-only, and the "?" menu lists "Welcome tour".
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(capabilities): clear the handler timeout timer once the race settles
Codex round-6: _withTimeout raced the handler promise against a bare setTimeout
but never cleared it, so a handler that resolves first leaves the timer alive
until it fires. Harmless at 250ms, but the new 15s MIDI permission-command
overrides (discover/open-source) kept the event loop alive ~15s after every
successful call (and the test process hung that long) and could accumulate
delayed callbacks across repeated scans. Capture the timer and clearTimeout it in
a .finally on the race. (Domain/capabilities tests now finish in ~0.1s, not 15s.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(midi-input): give the built-in Web-MIDI provider a distinct participant id
Codex round-7: the built-in Web-MIDI provider registered with participantId
'core.midi-input' — the same id as the domain owner. unregisterProvider()
unregisters the provider's participant, so a provider swap/hot-reload would tear
down the domain OWNER too, leaving midi-input with no owner for later commands.
Register the provider as 'core.midi-input.web-midi'.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(onboarding): don't start the home tour when launching the diagnostic
Codex round-7: on the final onboarding step, "Play it now" calls finish() (which
started the home tour) and THEN playSong(target). startFirstRun() navigated to
v3-home and scheduled the tour, then playSong switched to the player — so the
tour spotlighted hidden home elements / stole focus from the diagnostic. Gate the
tour on a launchingSong flag (passed by the "Play it now" path); the Skip path
stays on home, so the tour still runs there.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(capabilities): add core-owned midi-input control-plane domain (#873, #880)
The MIDI analog of audio-input: a core-owned provider-coordinator over MIDI
device discovery, selection, and shared open/close sessions. Separate from
audio-input (whose source/open contract is audio-frame-centric) and not owned
by any feature plugin, so the device-access boundary outlives the input-setup
wizard. `discover` is the Web-MIDI permission boundary; selection persists by
redaction-safe logicalSourceKey; diagnostics redact device labels and never
carry raw MIDI messages.
- static/capabilities/midi-input.js + load-order wiring in both shells
- spec 012 + capability-domains/safety-matrix entries; midi-control narrowed
to mappings-only (split)
- 9 domain tests against the real runtime
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(input_setup): bundled plugin owning input-calibration + Web-MIDI provider (#872)
Bundled core plugin that supplies the Web-MIDI source provider to the core
midi-input domain, owns the input-calibration workflow domain (run/status/
inspect), and renders the per-instrument wizard (guitar/bass -> audio-input +
note_detect; keys/drums -> midi-input live note/pad test). Idempotent
hydration; redaction-safe. .gitignore allowlists the in-tree plugin.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(onboarding): input-device setup step between paths and calibration (#874)
After instrument-path selection and before the note-detect calibration
challenge, dispatch input-calibration `run` (fire-and-launch) and await the
`calibration-done` event. Fail-soft: a non-handled outcome (plugin/runtime
absent) advances immediately so onboarding can never be stranded.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(midi-input): ship a built-in Web-MIDI provider in the core domain
Move the Web-MIDI source provider out of input_setup and into the core
midi-input domain so every consumer (piano, drums, input_setup) gets MIDI
devices from the domain without depending on any one plugin being loaded.
input_setup is now a pure midi-input requester (manifest role updated).
Prepares piano/drums full consumption (#876/#877). +1 domain test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(input_setup): Settings panel to re-run input setup (#878)
Adds a settings.html with a "Set up input devices" button (window
._inputSetupRelaunch) that re-runs the wizard for the player's selected
instrument paths (from /api/progression; falls back to all instruments). Makes
the calibration wizard re-launchable outside first-run onboarding.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(midi-control): formalize the midi-input/midi-control split (#882)
Narrow the reserved midi-control domain to mappings ONLY (CC/pitchbend/note →
action routing), consuming the delivered midi-input domain for device access.
Adds spec 013 defining the contract + intended consumers (feedback-plugin-midi,
drums learn-mode), updates the safety-matrix row, and cross-references it from
capability-domains. Per governance, midi-control stays RESERVED (no runtime
domain) until a concrete mapping consumer + tests exist.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(onboarding): wait for input_setup before the calibration step (#874)
The input-setup wizard is a mandatory onboarding step, but plugins load
asynchronously — in the desktop app (40+ plugins) the user can reach path
selection and click Next before input_setup has registered its
input-calibration owner. The dispatch then got a no-owner outcome and
onboarding fell through to the calibration challenge, silently skipping the
wizard. Now wait (bounded, 8s) for the plugin's public global before
dispatching; fall through only if it never appears. Race-verified.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(onboarding): add Song directory step after name+avatar (#874)
New first-run step (now step 2 of 4: name+avatar → song directory → paths →
calibration challenge) where the player sets their songs folder, fixing the
"folder not configured" error on a fresh install. Saves to settings (dlc_dir)
and kicks a library scan; persists to config.json so it survives restart. A
native folder picker is offered on desktop (window.slopsmithDesktop
.pickDirectory); web users type/paste the path. "Skip for now" leaves it
unconfigured (settable later in Settings).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(input_setup): filter MIDI entries out of the guitar audio-input picker (#876)
Other plugins export pseudonymized MIDI sources ('midi-input-N') into the
audio-input domain; they aren't audio inputs and the cryptic labels confused
the guitar/bass device dropdown. Filter them out so only real audio inputs show.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(input_setup): de-dupe audio input picker entries (#876)
The desktop audio engine enumerates the same device under multiple driver
types, so the guitar audio-input dropdown showed repeated entries. De-dupe by
display label (paired with the desktop fix that surfaces real device names).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(midi-input): drop vanished devices on re-discovery; reset setup confirm on switch
Codex preflight findings:
- midi-input domain `_discover()` only upserted enumerated sources, so an
unplugged device (statechange re-discovery) lingered in list-sources and later
open/select hit stale state. Reconcile each provider's sources against the
fresh enumeration (close any live session, keep the selectedKey preference).
- input_setup MIDI panel left "Continue" enabled (and the instrument marked
done) after switching the device selection following a prior hit. Reset the
waiting state + disable Continue on every selection change, and discard a
stale open if the selection changed mid-await. +1 reconciliation test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(midi-input): coalesce concurrent opens; commit shown audio source pre-calibration
Codex re-review (round 2):
- midi-input domain: two concurrent open-source calls for the same source both
passed the `sessions.get` guard and each called provider.open(), which for the
built-in Web-MIDI provider overwrites the shared input.onmidimessage handler
and orphans the earlier session — leaving the device silent. Coalesce in-flight
opens onto one provider session (await the pending open, adopt its session;
re-check after open and release a redundant handle if another open won). +test.
- input_setup: the guitar/bass audio <select> shows its first option by default
but fires no `change`, so on a first run with nothing selected, audio-input was
never told before launchCalibration(). Commit the shown option on render.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(midi-input): longer timeout for MIDI permission commands; stale-open guard in wizard
Codex re-review (round 3):
- The advertised command surface ran `discover`/`open-source` through the 250 ms
default handler timeout, but those front a real Web-MIDI permission prompt /
device open that commonly takes longer, so dispatch returned `failed` while the
operation was still completing. Add per-(capability,command) timeout overrides
(15 s for those two), folding the existing audio-mix special-case into the same
table so both the command() and dispatch() paths honor it.
- input_setup MIDI panel: openSelected() compared the mutable shared `activeKey`
after its awaits, so a device switch mid-open could bind the old device's
listener / close the wrong session. Capture the requested key in a local and
use a generation guard to discard a superseded open.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(onboarding): detect 200-with-error song-dir saves; close MIDI session on skip
Codex re-review (round 4):
- /api/settings reports an invalid folder as a 200 response with an `error` body
(a bare dict return, not a non-2xx status), so saveSongDir's res.ok-only check
treated the failure as success and advanced onboarding without saving. Parse
the body and throw on `error` too.
- input_setup: the opened MIDI test session was only closed on the Continue
button, so using the generic "Skip for now" after scanning leaked the listener
and kept the Web-MIDI input live. Run teardown on every panel exit via a
per-panel cleanup hook invoked by advance().
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(input_setup): don't hard-code Web MIDI in the device wizard
Codex re-review (round 5): the MIDI panel gated availability on
navigator.requestMIDIAccess and filtered sources to providerId === 'web-midi',
which defeats the midi-input domain's provider-coordinator abstraction — a
native/desktop MIDI adapter registered with the domain would be reported
unavailable and hidden from the picker. Gate availability on the domain
(window.slopsmith.midiInput) and show every source it surfaces.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- diagnostics_redact: keep '.psarc' in the song-filename scrub regex.
The purge swapped it for '.archive' (not a real extension), which
would leak real .psarc filenames still on users' disks into
diagnostic bundles. This is a redaction allow-list, not brand text.
- sloppak: the legacy lyrics-source alias was a no-op
({"notechart": "notechart"}), so old manifests with
lyrics_source=="sng" fell back to "xml" instead of migrating.
Map {"sng": "notechart"} so the rename stays back-compatible.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>