From 5fa6f1395146ed9ffe7bb89490d4a4126b626c89 Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou <16130099+ChrisBeWithYou@users.noreply.github.com> Date: Fri, 10 Jul 2026 00:14:50 -0500 Subject: [PATCH 01/18] fix(grading-display): fix copy-card accuracy + restore 3D highway hit-flare (v0.1.10) Two grading-DISPLAY bugs; detection was always correct. Card 0%/0-of-0 hero: shareCardAction preferred note_detect's renderResultsCard, whose hero reads note_detect's own 0/0 last-session counters (the contained verifier scored, not nd's UI) while Virtuoso's real numbers only reached the stats[] override row. Retire the delegation so Virtuoso draws its own verdict-led, skinned card (the ratified v0.1.4 "keep our renderer, don't consume note_detect's" decision); this also restores the skins + mastery crest the delegation silently suppressed on desktop. _ndCardApi / _resultsCardData / _virCardOverlay kept dormant for a possible future host-level card API. 3D highway hit-flare invisible: highway_3d's feedBack#254 per-note provider (getNoteState) is authoritative over the window-event marks and culls a gem ~100ms past the strike line unless the provider returns a live verdict that frame. Our provider only reported 'hit' after the async contained verdict (~150-400ms late), so the gem was gone before green could paint. Add a display-only { state:'active', live:true } keep-alive from the live YIN ear (mirrors note_detect's own noteStateFor): pitch-gated no looser than the credit floor, level-gated, +/-0.12s window; it renders no judgment and drives no credit - credit stays verifier-only via _ptScored. Contract confirmed present in highway_3d 3.30.0 (desktop bundle) and 3.31.3 (core). Verified on the installed desktop (real DI dogfood). Co-Authored-By: Claude Opus 4.8 (1M context) --- plugin.json | 2 +- screen.js | 66 +++++++++++++++++++++++++++++++++++++++++------------ 2 files changed, 52 insertions(+), 16 deletions(-) diff --git a/plugin.json b/plugin.json index 8a85b7e..1cf0272 100644 --- a/plugin.json +++ b/plugin.json @@ -1,7 +1,7 @@ { "id": "virtuoso", "name": "Virtuoso", - "version": "0.1.9", + "version": "0.1.10", "description": "Practice studio for guitar & bass — scale, technique, and rhythm drills, timed workouts, and jam backing that teach skills you take off the screen.", "category": "practice", "icon": "assets/thumb.png", diff --git a/screen.js b/screen.js index 6cc09b8..2e5b8f4 100644 --- a/screen.js +++ b/screen.js @@ -48,7 +48,7 @@ // a plugin's own version into its screen (note_detect hardcodes `_ND_VERSION` // the same way), so this is the display mirror of plugin.json's "version". // BUMP THIS WHENEVER plugin.json's version changes (release checklist). - const VIRTUOSO_VERSION = '0.1.9'; + const VIRTUOSO_VERSION = '0.1.10'; // =========================================================================== // §1 · CONSTANTS & MUSIC-THEORY DATA @@ -3547,7 +3547,7 @@ if (renderer && activeBundle) drawOnce(); } // Pitch tracker state — wraps slopsmithMinigames.scoring.createContinuous (no registration required) - let _ptHandle = null, _ptNotes = [], _ptOpenMidis = [], _ptScored = new Set(), _ptByKey = new Map(), _ptHadInput = false; + let _ptHandle = null, _ptNotes = [], _ptOpenMidis = [], _ptScored = new Set(), _ptByKey = new Map(), _ptHadInput = false, _ptLive = null; let _ptRunInfo = null; // results-modal diagnostics: judged universe + exemptions + which ear (set per run) let _ptSpanCount = 0; // tremolo spans this run (unit-denominated) let _ptExempt = null; // Slice-2 class counts { legato, fast, spanNotes, floorMs } (set per run) @@ -3812,7 +3812,9 @@ // ── Borrowed-highway judgment events (feel-panel reconciliation 2026-06-07) ─ // The host 3D highway renders its native hit flare / miss mark + the // "↑ +Nms" / "♯ +N¢" label sprites from window CustomEvents - // (notedetect:hit / notedetect:miss — normalizer hw:5620, listener hw:5669). + // (notedetect:hit / notedetect:miss). Since feedBack#254 highway_3d's getNoteState + // PROVIDER is authoritative over these window marks — see ptGetNoteState's live + // keep-alive, which stops the provider-cull deleting a gem before our async verdict. // Dispatching them from OUR judge gets that rendering free. The panel's // binding conditions: HIGHWAY-ONLY (our own surfaces keep the quiet // grammar); dispatch = TRUTH (evidence-backed judged outcomes only — exempt @@ -22353,6 +22355,13 @@ ptUpdateMeter({ show: true, active: false, cents: 0, note: '--', hits: _ptScoredUnits, total: passedTotal }); return; } + // Live-ear snapshot for the borrowed-highway keep-alive gem (feedBack#254): a + // confident lock THIS frame, captured raw. ptGetNoteState reads it to return a + // display-only { state:'active', live:true } so the host gem survives at the strike + // line until the async contained verdict lands — else the provider-cull deletes it + // ~100ms past the line, before our verdict exists → no green flare. DISPLAY ONLY: + // it never credits (credit stays verifier-only, _ptScored) and never judges. + _ptLive = { t: currentPracticeTime, f: freqHz, c: confidence }; // Fresh-lock gate: only frames the detector locked THIS frame may score // (≤0.85 = a fade re-emitting the previous pitch — display-only). const fresh = confidence > PT_CONF_FRESH; @@ -22448,6 +22457,7 @@ function stopPitchTracker() { if (_ptHandle) { try { _ptHandle.stop(); } catch (_) {} _ptHandle = null; } + _ptLive = null; // drop the live-ear snapshot so a stale lock can't light a gem on the next run ndStopVerify(); // clear the verify target + listener, restore note_detect stopLevelMeter(); // tear down the level/onset tap (poll / RAF / mic stream) // Clear scoring state so a later silent preview can't read this run's @@ -22487,6 +22497,31 @@ const missAt = (w ? w.matchEnd : end + 0.06) + 0.10; // candidate window + display grace if (now - ptLatency() > missAt) return 'miss'; // window passed on the JUDGE clock, never hit } + // Live keep-alive for the borrowed highway (feedBack#254 provider): while this note + // straddles the strike line AND the live ear hears its target pitch NOW, return a + // display-only { state:'active', live:true } so the host gem stays lit until the async + // contained verdict credits it — the provider-cull would otherwise delete the gem + // ~100ms past the line, before our verdict lands (the "hits invisible on the 3D + // highway" bug). MIRRORS note_detect's own noteStateFor provisional glow: it renders + // NO judgment and drives NO credit ('hit'/'miss'/credit stay verifier-only, above); + // the 2D strip's string check ignores the object, so that surface stays credited-only. + // live:true routes it through the highway's non-latched 'hit-live' branch, so it + // extinguishes on mute / relights on re-strike and can never stick a false green. + if (_ptLive && _ptLive.c >= PT_CONF_INPUT) { + const age = now - _ptLive.t, om = _ptOpenMidis[note.s]; + if (age > -0.05 && age < 0.08 && om != null && now >= tn.t - 0.12 && now <= end + 0.12) { + const cents = Math.abs(1200 * Math.log2(_ptLive.f / midiToFreq(om + note.f))); + const tol = note.bn ? PT_BEND_CENTS : 50; // no looser than the credit floor + // level/onset evidence — the SAME host-mirror tap the credit gate trusts, so a + // confident YIN lock on an inaudible residual can't false-light the gem. + let leveled = true; + if (_lvlMode !== 'none') { const lo = now - PT_LVL_RECENT_WIN; leveled = !ptHasSamplesIn(lo, now) || ptPeakLevelIn(lo, now) >= PT_LVL_FLOOR_MIN; } + if (cents <= tol && leveled) { + const alpha = 0.55 + 0.4 * Math.max(0, Math.min(1, (_ptLive.c - PT_CONF_INPUT) / (1 - PT_CONF_INPUT))); + return { state: 'active', live: true, alpha }; + } + } + } return null; // not yet judged — don't pre-color } @@ -23290,18 +23325,19 @@ // (Promise to keep the click activation) → download PNG → text. // Returns 'copied' | 'saved' | 'copied-text' | 'failed'. async function shareCardAction(s, action) { - const nd = _ndCardApi(); - if (nd) { - const data = _resultsCardData(s); - if (data) { - const overlay = _virCardOverlay(); - try { - if (action === 'download') { const r = await nd.saveResultsCard(data, { overlayEl: overlay }); return (r && r.ok) ? 'saved' : 'failed'; } - return (await nd.copyResultsCard(data, { overlayEl: overlay })) || 'failed'; - } catch (_) { /* fall through to the local fallback below */ } - finally { try { overlay.remove(); } catch (_) {} } - } - } + // Virtuoso renders its OWN verdict-led, skinned card — the ratified v0.1.4 + // decision (memory project_card_theme_skins): "KEEP our renderer, don't + // consume note_detect's." The card is north-star IP and carries our skins + // (Neon/Esports/Metal/Warm/Focus) + the mastery crest. The old delegation to + // window.noteDetect.renderResultsCard is RETIRED here: whenever note_detect + // was present it PREFERRED note_detect's renderer, which (a) drew its hero + // accuracy from note_detect's own last-session counters — 0/0 under our + // contained verifier, the "ACCURACY 0% · 0/0 notes hit" bug (same mechanism + // as the v0.1.8 leak) — while our real numbers only reached the stats[] row, + // and (b) suppressed every skin + the crest on desktop (the headless smoke + // host lacks note_detect's card API, so the skin probes never exercised this + // path). _ndCardApi/_resultsCardData/_virCardOverlay are kept dormant below + // in case a host-level card capability ever supersedes ours. // Ensure the active theme's display face is loaded before the canvas rasterizes // (the DOM card gets it via CSS font-display; the canvas needs it resident). await loadCardFonts(); From 6f60d70dccd0507af9b393199a0d03424b2d1d5d Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou <16130099+ChrisBeWithYou@users.noreply.github.com> Date: Fri, 10 Jul 2026 00:21:06 -0500 Subject: [PATCH 02/18] docs(roadmap): log v0.1.10 grading-display fixes + correct stale version refs New 2026-07-10 STOPPED HERE: card 0/0 (retired the stale note_detect card delegation) + 3D highway flare (live-ear keep-alive vs the feedBack#254 provider cull), the Program-Files installed-app deploy gotcha, and the version corrections (highway_3d 3.30.0 desktop / 3.31.3 core; note_detect 1.28.0 desktop) that supersede the stale 3.26/1.19 refs. Co-Authored-By: Claude Opus 4.8 (1M context) --- ROADMAP.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index 9103ea7..378f1a4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -26,6 +26,13 @@ Verification: 18 Playwright smoke suites (`.claude/skills/run-virtuoso/`, `npm t ## Open threads +- **STOPPED HERE (2026-07-10 session) — v0.1.10 grading-DISPLAY fixes SHIPPED to `virtuoso-dev` (`5fa6f13`); PR #4 dev→main OPEN (not merged), so `main` is still 0.1.9 and beta is NOT yet re-cut.** Detail + lessons: memory `project_grading_display_fixes_2026-07-10`. + - **Card `0%`/`0-of-0` hero** — `shareCardAction` was STILL delegating to note_detect's `renderResultsCard`/`copyResultsCard` (a stale v0.1.3 path that survived the ratified v0.1.4 "keep our renderer" decision). nd's card hero reads top-level `d.accuracy`/`d.hits`/`d.misses` we never set (only `stats[]`) → 0/0, while the delegation silently suppressed our skins + crest on desktop. **Retired the delegation** → Virtuoso always draws its own skinned `renderShareCardImage`. (`_ndCardApi`/`_resultsCardData`/`_virCardOverlay` kept dormant; the `virtuoso_beta` brand was `cut-beta.mjs`'s token-rename, not note_detect.) + - **3D highway hit-flare invisible** — highway_3d's **feedBack#254** per-note PROVIDER (`getNoteState`) is authoritative over the `notedetect:hit` window-event marks and culls the gem ~100ms past the strike line unless the provider returns a live verdict THAT FRAME; ours only returned `'hit'` after the async contained drain (~150-400ms late) → gem culled first. Fix: a display-only `{ state:'active', live:true }` **live-ear keep-alive** (`_ptLive` captured in `ptOnPitch`; mirrors note_detect's own `noteStateFor`), pitch-gated ≤ the credit floor + level-gated + ±0.12s window; it renders NO judgment and credit stays verifier-only via `_ptScored`. notedetect-expert APPROVED (not a parallel grader). Return the OBJECT with `live:true` (highway's non-latched `hit-live` branch) — a bare `'active'` string sticks green. **Flare CONFIRMED lighting on Christian's real DI** ("nailed it") → the `_ptHandle` minigames ear DOES hear his DI. + - **⭐ DEPLOY-TARGET GOTCHA (cost a fully-broken build):** Christian dogfoods the **INSTALLED app** at `C:\Program Files\feedback\current\resources\slopsmith\plugins\virtuoso` (hand-copied; needs an **ELEVATED** shell to write), NOT the `%APPDATA%\slopsmith-desktop` dev symlink (which → the working tree). A **PARTIAL** copy (`screen.js` over an older `screen.html`) → version SKEW → `screen.js` boots against a mismatched DOM → total break (no theming/highway/buttons; `node --check` still passes since nothing runs at load). **Always copy the FULL runtime set** (`screen.js` `screen.html` `plugin.json` `routes.py` `settings.html` + `static/` + `assets/`). highway_3d there is NOT symlinked (uses its stale bundle). + - **VERSION CORRECTIONS (supersede the stale refs in the older threads below):** **highway_3d = 3.30.0** (installed desktop bundle) / **3.31.3** (core checkout) — NOT 3.26.0/3.22.0. **note_detect on the desktop = 1.28.0** (via the `%APPDATA%` symlink → repo checkout) — NOT 1.19.0/1.15.3. Both highway_3d versions carry the feedBack#254 provider contract. + - **NEXT:** merge PR #4 → `main` (ships all users via `update_manager`); re-cut beta (`node scripts/cut-beta.mjs --push` → 0.1.10-beta.1). Smoke still MOCKS the contained verifier → both fixes are **real-desktop-DI-dogfood-verified only** (the standing owe). + - **STOPPED HERE (2026-07-09 session) — v0.1.8 + v0.1.9 grading-DISPLAY fixes SHIPPED to main + dev + beta (all pushed). `main` 0.1.6 → 0.1.9.** Full detail + lessons: memory `project_grading_display_fixes_2026-07-09`. - **REFRAME (headline):** tester reports (MajorMokoto/Kyri, Discord) of "grading broken / scored 0 / B-standard 24%" were **NOT detection** — notes count correctly (95/95, 100% on the HUD + our own card). All three bugs were grading-**DISPLAY**. - **Fix 1 — structural tunings mis-scored (v0.1.8 `51ab8d7`).** drop-\*/B-standard/BEAD/DADGAD/open on a KEYED rung charted on STANDARD opens while the player was physically re-strung → verifier targets missed → 24%. Built the `applyTuningAdaptL1` STRUCTURAL branch (chart on the player's REAL opens at concert pitch; degree-driven resolver re-fingers the line) = the bass-ruling Phase-2 gap, **now BUILT**. Guarded by new smoke-strings `8-struct` rows. From d1f1157b1c05b63271598e5366e3afe81c3e5924 Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou <16130099+ChrisBeWithYou@users.noreply.github.com> Date: Fri, 10 Jul 2026 01:31:19 -0500 Subject: [PATCH 03/18] =?UTF-8?q?feat(ui):=20host-parity=20topbar=20?= =?UTF-8?q?=E2=80=94=20Tuner/Instrument/Profile=20badges=20+=20host=20sett?= =?UTF-8?q?ings=20sync=20(v0.1.11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "fullscreen":true hides the host topbar on our screen, so the header now mirrors its three badges in the host's exact order (compact 32px density variants of the host cards). Spec: docs/topbar-host-parity.md. HOST CHECK (feedback-compatibility, 2026-07-10, vs FeedBack 0.3.0-alpha.1): - Tuner: BORROW launch (window.tuner.toggle, feature-detected) + MIRROR card (live note via bus 'tuner:frame'). Flip: a host-embeddable badge component. - Instrument: BORROW persistence (GET/POST /api/settings, GET /api/tunings, workingTuning.setCurrentInstrument, 'instrument:changed') + MIRROR badge/ panel UI. Host = source of truth for guitar/bass; piano (gated) never POSTs. Flip: host adding piano to STRING_COUNTS. - Profile: MIRROR chip (avatar+streak+rank, /api/profile*) + LINK (goScreen('v3-profile')); renders nothing when !onboarded. Never a second profile/economy implementation. Sync semantics: user panel changes funnel through instrumentStoreSave (the L1 declaration boundary) -> debounced POST + workingTuning + instrument:changed + tuner-config push (gated on window.tuner: a blind POST 404s and trips the smoke console-error guards). Boot reconcile: local L1 store wins and pushes host-ward (pre-merge tunings live only locally; host default is indistinguishable from untouched); fresh install adopts host; live external 'instrument:changed' always pulls. Per-rung overrides never write (anti-leak). Interop maps through ABSOLUTE open-string midis only. note_detect reads neither workingTuning nor /api/settings - the per-call verify ctx stays. Also: progress chip hidden (pending the profile-merge design; P-sheet opens via new gear-menu "Progress (P)" + the P hotkey), setup label now instrument-strings-tuning, Floating-tuner courtesy button retired (the badge is that affordance), Tune... relabeled "Tune to this tuning...", tuning dropdown gains the host named-tuning group (stable built-in ids preserved - deliberate deviation from spec's host-name-wins: id stability beats label provenance), host-parity uniform standards added as offset:true presets (C#/C std 6-str, Bb/A std 7-str, E std 8-str), and the latent guitar_8_standard.tuning [2,0,...] metadata bug fixed to [0,...] (host 8-string base is F#; the old offset would detune string 0 a whole step on a host round-trip). Smoke: 16/19 - the 3 reds (backing-engine voice-leading, progress, variation) fail identically on base (pre-existing). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01MS2YFb6UUSwJVV6CmEa25i --- docs/topbar-host-parity.md | 326 ++++++++++++++++++++++++++++++++ plugin.json | 2 +- screen.html | 134 ++++++++++---- screen.js | 368 +++++++++++++++++++++++++++++++++++-- 4 files changed, 778 insertions(+), 52 deletions(-) create mode 100644 docs/topbar-host-parity.md diff --git a/docs/topbar-host-parity.md b/docs/topbar-host-parity.md new file mode 100644 index 0000000..d0056a9 --- /dev/null +++ b/docs/topbar-host-parity.md @@ -0,0 +1,326 @@ +# Topbar host parity — Tuner · Instrument · Profile in the Virtuoso header + +> **Status: SPEC (2026-07-10, ux lane) — no code changes yet.** +> Why now: Virtuoso's `"fullscreen": true` opt-in hides the host topbar entirely on our +> screen, so the three host-standard topbar elements (Open Tuner, Instrument selector, +> Profile) vanish for the player while inside Virtuoso. Part 1 rule 1: match the host +> standard — bring them into our header, in the host's order, speaking the host's +> visual language, without inventing variants. +> +> Host recon verified 2026-07-10 against the local FeedBack clone (v0.3.0-alpha.1, +> `static/v3/shell.js` + `badges.js` + `profile.js`, `server.py`). + +--- + +## HOST CHECK + +- **Capability:** topbar Tuner / Instrument / Profile parity inside Virtuoso's fullscreen screen. +- **What the host has:** `shell.js renderTopbar` (~L198–230) ends with three right-aligned + badges — tuner (`badges.js renderTuner` ~L318: 92×96px card, 11-segment meter, note name, + ref-hz; click → `window.tuner.toggle()`, live data `feedBack.on('tuner:frame')`), + instrument (`renderInstrument` ~L424: card + dropdown panel; `STRING_COUNTS + {guitar:[6,7,8], bass:[4,5,6]}`, named tunings from `GET /api/tunings` keyed + `-`, custom offset-arrays low-string-first; persists + `POST /api/settings`, syncs `feedBack.workingTuning.setCurrentInstrument`, emits + `instrument:changed`), profile (`profile.js renderBadge` ~L43: avatar + streak + + Mastery Rank + dB wallet from `GET /api/profile` + `/api/profile/progress` + + `window.v3Progression`; click → `showScreen('v3-profile')`; renders nothing when + `!onboarded`). Backend routes confirmed live in `server.py` (L9579+/L10734+/L10747+). +- **Evidence + date:** source reads 2026-07-10; Virtuoso screen.js currently has ZERO + references to `/api/settings`, `/api/tunings`, `workingTuning`, `instrument:changed`, + `tuner:frame`, or `v3Progression` (grep-verified) — this is all new integration surface. +- **Verdicts:** + - **Tuner: BORROW the launch** (`window.tuner.toggle()`, feature-detected) **+ MIRROR the + card** at our header scale. Our own target-aware `Tune…` stays (different job — see §3). + - **Instrument: BORROW the persistence** (`/api/settings` + `/api/tunings` + + `workingTuning` + `instrument:changed`) **+ MIRROR the badge/panel UI**, absorbing + Virtuoso's extra tuning content. Host settings become source of truth for guitar/bass. + - **Profile: MIRROR the chip** (compact, from host endpoints) **and LINK on click** + (`v3-profile` via `goScreen`) — never a second profile implementation. Our own + progress chip hides pending a real profile-merge design. +- **What host change would flip a verdict:** a host-provided embeddable badge component + (or a `renderTopbar` slot the fullscreen screen could opt back into) would flip all + three MIRRORs to pure BORROW — delete our compact badges, keep only the sync plumbing. + +--- + +## 1. Header layout + +### Order (left → right) + +``` +[ Virtuoso wordmark + tagline ] [ 4-mode segments (centered) ] [ 🎯Tuner ][ Instrument ▾ ][ Profile ] [ ⚙ ] +``` + +- The **right cluster preserves the host's order exactly**: tuner · instrument · profile + (rule 1 — the muscle memory a player brings from the main menu must transfer). It + replaces today's `Setup ▾ … progress-chip` pair in that region. +- The **instrument badge replaces `#virtuoso-setup-btn`** in place — same flex slot + (`.virtuoso-header-setup` moves from left-of-modes into the right cluster; see §2). + Rationale: on the host, instrument identity lives right-of-content; keeping our Setup + button on the left would duplicate the concept in two places. One selector (directive 2). +- **⚙ stays outermost-right** — it is Virtuoso chrome, not host parity; keeping it past + the host triplet keeps the triplet reading as one borrowed unit. +- The **progress chip is hidden** (§4); the mode segments stay centered (they are our + primary nav — the host topbar has no analog and cedes the center). +- Wrap container: one new `.virtuoso-header-host` flex group (gap `--vir-sp-2`) holding + the three badges, so the cluster degrades and themes as a unit. + +### Scale — honest adaptation (host look, our scale) + +Host badges are ~92px-tall menu-hero cards; our header is a slim working toolbar +(~40px content). Tripling header height would steal stage rows from the practice +surface on every screen — the opposite of what fullscreen bought us. So: + +- **Keep** the host's card *grammar*: card background (`--vir-inset`), 1px `--vir-border`, + rounded (`--vir-r-control`-family, ~10px — echoing the host's rounded-2xl at our size), + the same content *hierarchy* (tuner: meter+note; instrument: instrument+tuning label; + profile: avatar+streak+rank). +- **Compact** to a 32px-tall badge row (matches `.virtuoso-settings-btn` height — the + established header icon size, Fitts-friendly and consistent with the existing family). +- This is a *density variant of the host component*, not an invented look: same order, + same fills, same click behavior, same data — smaller box. Document the deviation in + the build commit the way the cockpit-skin work documented its scoped radius overrides. + +### Narrow-width degrade (extends the existing media-query ladder) + +Protect, in order: mode nav > instrument selector > ⚙ > profile > tuner > tagline. + +| Breakpoint | Change | +|---|---| +| ≤1200px (existing) | tagline drops (unchanged) | +| ≤1120px (existing) | instrument badge label shortens via the existing `data-short` mechanism (reuses today's `#virtuoso-setup-label` trick); **profile badge drops its text → avatar-only** | +| ≤1000px (existing) | mode segments go icon-only (unchanged); **tuner badge hides** (the tuner plugin's own floating button remains reachable — nothing is lost, only the shortcut) | +| ≤880px (existing) | mode bar → `Mode ▾` dropdown (unchanged); **profile hides entirely** | + +The instrument badge never hides — it is load-bearing setup identity (same reason the +old Setup button survived every breakpoint). + +--- + +## 2. The merged instrument selector + +### Badge (closed state) + +- Reuses `#virtuoso-setup-btn` / `#virtuoso-setup-label` / the existing popover wiring + (`toggleSetupPopover`, screen.js ~17651) — **restyled, not renamed**, so `bind()` and + the `data-short` degrade keep working untouched. +- Label format mirrors the host badge's content hierarchy: **`Guitar · 6 · Drop D ▾`** + (instrument · strings · tuning name). Today's "Guitar · Standard" already half-does + this; add the string count. +- Style: the compact badge recipe from §1 (inset card + border + radius), active/open + state = `--vir-accent` border (today's `[aria-expanded="true"]` rule, unchanged). + +### Panel (open state) — host panel skeleton, Virtuoso content absorbed + +Row order (top → bottom), mirroring the host `instRow` layout (label left, control right): + +1. **Instrument** — chip row `Guitar | Bass | Piano(disabled)`. Reuses + `.virtuoso-instr-btn` + `syncInstrumentFamilyButtons()`. Piano stays our gated chip + exactly as today (the host model has no piano; piano is **local-only** — see §5). +2. **Strings** — count chips per family. Host `STRING_COUNTS` is *identical* to ours + (guitar 6/7/8, bass 4/5/6) — `syncStringCountChips()` unchanged. +3. **Tuning** — `#virtuoso-tuning-select`, now populated from **both** sources: + - Host named tunings for `-` from `GET /api/tunings` (listed + first — these persist *by name* host-side and sync to the tuner plugin). + - Virtuoso curated extras from `TUNING_PRESETS` not present host-side (drop C/B, + DADGAD, open G/D, BEAD, high-C…), under a `───` option-group separator. + - `Custom…` last, as today. + De-dup by resolved MIDI array (host offsets + E-standard base → absolute MIDIs; same + low-first index both sides, so it's a direct per-string map). When both sides define + the same tuning, the host's *name* wins in the list; Virtuoso's `offset:true` intent + tag still applies (it's keyed by resolved MIDIs in our layer, invisible here). +4. **Custom tuning editor** — unchanged block (`#virtuoso-custom-tuning`, note inputs + low→high, `+ Save tuning…` to our `/api/plugins/virtuoso/tunings` CRUD). Additionally + write-through to host settings as a custom offset array (§5). +5. **Tune…** row — our target-aware tuner entry stays here, unchanged position + (established ruling: tuning *identity* lives in this panel, so the tool that gets the + physical instrument into that tuning lives here too). **Remove the courtesy + `Floating tuner ↗` button from this row** — the new header Tuner badge *is* that + affordance now, in the host's own position; keeping both would be duplication. + Relabel our entry **"Tune to this tuning…"** so the header tuner (generic chromatic) + and ours (target-aware) stop reading as unexplained duplicates — this resolves the + 2026-06-21 presentation gap. +6. **Dropped from the host panel:** the Pathway select (host areas — not our concept) + and the Reference-pitch slider (v1: the host settings screen owns it; we *read* + `reference_pitch` for future tuner display use but render no control — see open + question Q2). + +**Hidden plumbing stays byte-identical:** `customOpenMidis`, `keyNominal`, +`tuningOffset`, `anchorStation` (the `form="virtuoso-controls"` hidden inputs inside the +popover) must survive any markup reshuffle — they are how tunings reach `readConfig()`. +Verify by grep after the build. + +**Whole-pane note (rule 3):** the panel keeps the existing popover chrome +(`.virtuoso-setup-popover` surface/border/shadow) and the `.virtuoso-tuning-row` +label-left grid — we adopt the host's row *order and content model*, not its Tailwind +classes. Colors stay `--vir-*` tokens; dark `color-scheme` select styling already +handles the dropdown (design-system §8). + +--- + +## 3. Tuner badge + +- **Click = `window.tuner.toggle()`**, feature-detected exactly like the host badge; if + `window.tuner` is absent the badge does not render (no dead control — honesty rule). +- **Display** (compact mirror of the host card): a small vertical/segment meter glyph + + the live note name. Subscribe `hostBus().on('tuner:frame', cb)` → `{note, hz, cents}`; + when no frames are flowing (tuner closed), show the resting glyph + "Tuner" label — + matching the host card's idle state, at our scale. No hz sub-label at 32px (fold it + into the `title` tooltip: "Open Tuner — 440hz"). +- **Never** re-implement detection or draw on the tuner's surfaces — launch + passive + frame display only (the borrow boundary that kept our target-aware tuner legal). +- Relationship to ours: header badge = the player's *generic chromatic* tuner (host + standard, host engine, host UI); Setup-panel `Tune to this tuning…` = the + *target-aware* verify-each-string tuner on our pitch strip. Two jobs, two entries, + now visually distinct (header icon-badge vs in-panel labelled row). +- **No audio is emitted by the badge itself** — it opens the host tuner UI; mic-in only. + +--- + +## 4. Progress chip hiding + the P-sheet affordance + +- **Mechanism: CSS-hidden, not removed.** Add the `hidden` attribute to + `#virtuoso-progress-strip` in `screen.html` plus a CSS guard + (`.virtuoso-progress-strip[hidden] { display: none !important; }`). All wiring stays + live and harmless: `syncProgressStrip()` (~24226), `syncCrest()` (~23761), the + `chip-open` toggle (~16338), and the click/keydown bindings (~25378) keep writing to a + hidden element. Zero JS surgery; un-hiding when the profile-merge design lands is a + one-attribute revert. +- **P-sheet affordance moves into the ⚙ menu** as a labelled menu item — + **"Progress (P)"** — placed above "Keyboard shortcuts". Rationale: opening P is a + low-frequency ambient check; a labelled menu item preserves recognition-over-recall + without spending header width, and the `P` hotkey + the session-end auto-present + (`presentSessionSummary`) remain the primary paths. Rejected alternative: hanging P + off the new profile badge — that badge *navigates away* to the host profile screen; + overloading it with an in-place sheet would make one control do two opposite things. +- **Crest/streak fallback:** interim loss of the ambient header streak is acceptable and + guardrail-safe (§13: absence is silent; streaks never nag). The data still surfaces in + the P sheet itself and in the session-end summary card. The host profile badge shows + the *host* streak — do not restyle it to imply it is Virtuoso's practice streak. + +--- + +## 5. Profile badge + sync design + +### Profile badge + +- **Compact mirror**: avatar (16–20px) + streak + Mastery Rank text, fetched from + `GET /api/profile` + `GET /api/profile/progress`; dB wallet omitted at this scale + (fold into `title`). Data read-only — we never write profile state. +- **Click → `goScreen('v3-profile')`** (the §12 host-aware navigation accessor — + `hostBus().navigate` / `window.showScreen`; same path the ⚙ "Plugin settings ↗" item + uses). Never monkey-patch. If playback is running, route through the same + leave-screen path existing navigation uses (the AudioContext-sharing gate already + handles backgrounding). +- **`!onboarded` → render nothing** (exact host behavior). Endpoint 404/absent/error → + render nothing. The header must look intentional without it (the cluster just ends at + the instrument badge). + +### Instrument sync — read / write / react + +**Read (on screen show + first bind):** +1. `GET /api/settings` → `{instrument, string_count, tuning, reference_pitch}`. +2. `GET /api/tunings` → named tuning table per `-`. +3. Map to Virtuoso state: `instrument`+`string_count` → family/count chips; `tuning` + (name → offsets → absolute MIDIs via the E-standard base for that family/count; + custom offset-array → absolute MIDIs directly) → match against `STRING_SETUPS` / + `TUNING_PRESETS` MIDIs (the existing resolution path) → either a preset selection or + `customOpenMidis`. **Same low-first string index both sides — direct `i→i` map, no + reversal.** + +**Write (on user change in our panel):** +1. Apply locally first (Virtuoso stays functional regardless of host write success). +2. `POST /api/settings` with the patch — **mirror the host's own commit discipline** + (`badges.js saveSettings` ~L196): only on an accepted response (HTTP ok and no + `{error}` body) do we (a) treat host state as updated, (b) call + `hostBus().workingTuning.setCurrentInstrument(instrument, stringCount)` + (feature-detected), (c) emit `instrument:changed` with the host's exact payload shape + `{instrument, stringCount, tuning, pathway}` (echo the host's stored `pathway` + untouched — we dropped the *control*, not the field). The host's own `saveSettings` + also pushes to the tuner plugin's config; ours should POST the same + `/api/plugins/tuner/config {lastInstrument, lastTuning?}` mirror (named tunings only — + custom arrays have no name, skip `lastTuning`, exactly as the host does). +3. Reverse tuning mapping: our selection's MIDIs match a host named tuning → POST the + name; otherwise POST the custom offset array (`ourMidis[i] − eStandardMidis[i]`). + +**React (external changes while Virtuoso is open):** +- `hostBus().on('instrument:changed', handler)` → re-read `/api/settings`, re-apply. +- **Echo guard:** set a short-lived `_selfEmit` flag around our own emit and ignore the + bounce (or compare incoming payload to current state and no-op on equality). +- Unsubscribe/re-subscribe safely on re-bind (idempotence — no listener stacking). + +**Precedence rules (the anti-leak line):** +- Host settings are the source of truth for the player's *own* guitar/bass identity — + on entry, host wins over `localStorage`-remembered Virtuoso setup. +- **Per-rung tuning overrides never write to the host.** A pathway's `customOpenMidis` + / anchor adaptation is exercise-scoped chart plumbing (already anti-leak defaulted in + `applyPathwayConfig`); only *user-initiated panel changes* POST to `/api/settings`. +- **Piano never POSTs** — host `STRING_COUNTS` has no piano; pushing it would corrupt + the host badge. Piano selection (when it un-gates) stays Virtuoso-local, and the + header badge label shows it without the host round-trip. + +**Failure modes:** +- `/api/settings` or `/api/tunings` unreachable (older host, headless smoke oddity, + network) → panel behaves exactly as today: `STRING_SETUPS` + `TUNING_PRESETS` + + Virtuoso's own persistence. Write-throughs are skipped silently; a one-shot + console.warn, never a user-facing error. +- Virtuoso's own `localStorage`/preset persistence is retained as the offline cache — + it is what makes the standalone/degraded path identical to today's behavior. + +--- + +## 6. Degrade when host globals are absent + +Each element **feature-detects independently and hides cleanly** (the `ptAvailable()` +pattern): + +| Element | Requires | Absent → | +|---|---|---| +| Tuner badge | `window.tuner.toggle` (+ optional `tuner:frame` via bus) | badge not rendered | +| Instrument badge | nothing (host endpoints optional) | renders always; host sync skipped | +| Profile badge | `GET /api/profile` ok + `onboarded` + a navigate path | badge not rendered | + +**Smoke-host assumption — verified server-side, flagged client-side:** the headless +smoke host runs the same FastAPI (`/api/settings`, `/api/tunings`, `/api/profile` +confirmed present in `server.py`), so the *sync* paths are exercisable in smoke. +Frontend globals are less certain: `window.tuner` exists only when the bundled tuner +plugin loads, and `v3Progression`/profile-onboarding state depends on the smoke +profile. **Do not let any smoke suite assert the badges exist** — assert only that the +header renders and the instrument badge works with sync on/off. A `probe-topbar-*.mjs` +should verify the real desktop presents all three (⭐ standing lesson: smoke mocks can +ship real-host bugs green). + +--- + +## 7. Build order + verification notes + +1. Progress-chip hide + ⚙ "Progress (P)" item (independent, zero-risk, lands first). +2. Header regroup: move `.virtuoso-header-setup` into the new right cluster; compact + badge restyle of the setup button (IDs preserved). +3. Tuner badge (feature-detected launch + frame display). +4. Profile badge (fetch + navigate + `!onboarded` gate). +5. Instrument sync layer (read → write → react, in that order; each step degrades to + the previous). +6. Remove `#virtuoso-tuner-ext` from the Setup panel + relabel `Tune…` (rides step 3). + +After each step: `node --check screen.js`, then grep every touched ID +(`virtuoso-setup-btn`, `virtuoso-setup-label`, `virtuoso-tuning-select`, +`virtuoso-custom-open-midis`, `virtuoso-progress-strip`, `virtuoso-tuner-ext`, +`virtuoso-tune-btn`) to confirm wiring integrity; full smoke suite; no host global +monkey-patched; no second transport/player/tuner engine introduced. + +## Open questions for Christian + +- **Q1 — Reference pitch:** surface the host's 430–450hz control in our panel (full + host-panel parity) or leave it to the host settings screen (my default: leave it out + in v1; our audio engine doesn't consume it yet, and a control that changes nothing + audible violates the count-in honesty rule)? +- **Q2 — Header streak source:** the profile badge shows the *host* streak while our + practice streak hides with the progress chip. Fine for the interim, or should the + profile-merge design be scheduled sooner because two streak definitions will coexist + invisibly? +- **Q3 — Handedness:** the host persists `localStorage['lefty']`; Virtuoso has its own + lefty display pref in the bundle prefs. Sync them (host wins on entry) or keep + independent? Default: read host `lefty` as the *initial default* only, never + write it. diff --git a/plugin.json b/plugin.json index 1cf0272..5c2fe88 100644 --- a/plugin.json +++ b/plugin.json @@ -1,7 +1,7 @@ { "id": "virtuoso", "name": "Virtuoso", - "version": "0.1.10", + "version": "0.1.11", "description": "Practice studio for guitar & bass — scale, technique, and rhythm drills, timed workouts, and jam backing that teach skills you take off the screen.", "category": "practice", "icon": "assets/thumb.png", diff --git a/screen.html b/screen.html index 1f67806..7cfd9b3 100644 --- a/screen.html +++ b/screen.html @@ -654,7 +654,11 @@ align-items: center; justify-content: space-between; gap: 16px; - flex-wrap: wrap; + /* nowrap (topbar parity): the host cluster widened the right side, and wrap's + first casualty was the ⚙ orphaned onto a second row. The masthead shrinks + instead (tagline ellipsizes) and the media-query ladder handles real + narrow-width degrade. */ + flex-wrap: nowrap; padding: 2px 6px 11px; margin-bottom: 12px; border-bottom: 1px solid var(--vir-hairline); @@ -664,13 +668,37 @@ .virtuoso-header-modes { flex: 1 1 auto; display: flex; justify-content: center; min-width: 0; } .virtuoso-header-modes .virtuoso-mode-bar { flex: 0 0 auto; } .virtuoso-header .virtuoso-progress-strip { flex: 0 0 auto; min-width: 200px; } + /* Host-parity right cluster (docs/topbar-host-parity.md): tuner · instrument · + profile in the host topbar's exact order, reading as one borrowed unit (tight + internal gap vs the header's 16px). Compact 32px density variants of the host's + 92px badge cards — same card grammar (inset fill, 1px border, rounded), same + content hierarchy, same click behavior; smaller box (a slim working toolbar + must not steal stage height — the opposite of what fullscreen bought). */ + .virtuoso-header-host { display: flex; align-items: center; gap: 8px; flex: 0 0 auto; } + .virtuoso-tuner-badge, .virtuoso-profile-badge { display: inline-flex; align-items: center; gap: 7px; height: 32px; padding: 0 10px; border-radius: 8px; border: 1px solid var(--vir-border, #334155); background: var(--vir-inset, #0b1220); color: var(--vir-text, #e2e8f0); cursor: pointer; transition: background 0.12s, border-color 0.12s; white-space: nowrap; } + .virtuoso-tuner-badge:hover, .virtuoso-profile-badge:hover { background: var(--vir-hover-soft); } + .virtuoso-tuner-badge[hidden], .virtuoso-profile-badge[hidden] { display: none !important; } + /* Tuner card content: mini segment meter + the big italic note name (the host + card's hierarchy at our scale; the Hz sub-label folds into the title tooltip). */ + .virtuoso-tuner-meter { display: inline-flex; align-items: center; gap: 2px; } + .virtuoso-tuner-meter i { width: 3px; height: 10px; border-radius: 1px; background: var(--vir-border, #334155); } + .virtuoso-tuner-meter i:nth-child(3) { height: 14px; } + .virtuoso-tuner-meter i.on { background: #34d399; } + .virtuoso-tuner-meter i.on.off-pitch { background: #f59e0b; } + .virtuoso-tuner-note { font-size: 13px; font-weight: 800; font-style: italic; min-width: 34px; text-align: center; } + .virtuoso-profile-avatar { width: 18px; height: 18px; border-radius: 50%; object-fit: cover; } + .virtuoso-profile-text { font-size: 12px; font-weight: 600; color: var(--vir-text-dim, #cbd5e1); } + /* Progress chip HIDDEN (topbar parity) — pending the profile-merge design. */ + .virtuoso-progress-strip[hidden] { display: none !important; } /* Setup popover (instrument + strings + tuning) — collapsed behind one header button. */ .virtuoso-header-setup { position: relative; flex: 0 0 auto; } .virtuoso-setup-btn { display: inline-flex; align-items: center; gap: 6px; padding: 6px 11px; font-size: 12.5px; font-weight: 600; border-radius: 8px; border: 1px solid var(--vir-border, #334155); background: var(--vir-inset, #0b1220); color: var(--vir-text, #e2e8f0); cursor: pointer; transition: background 0.12s, border-color 0.12s; white-space: nowrap; } .virtuoso-setup-btn:hover { background: var(--vir-hover-soft); } .virtuoso-setup-btn[aria-expanded="true"] { border-color: var(--vir-accent, #4080e0); } .virtuoso-setup-caret { color: var(--vir-text-faint, #64748b); font-size: 10px; } - .virtuoso-setup-popover { position: absolute; top: calc(100% + 6px); left: 0; z-index: 30; min-width: 290px; padding: 12px; display: flex; flex-direction: column; gap: 10px; background: var(--vir-surface, #0d1424); border: 1px solid var(--vir-border, #334155); border-radius: 10px; box-shadow: 0 12px 32px rgba(0,0,0,0.45); } + /* Anchored right (the setup button now lives in the right-side host cluster — + a left-anchored 290px popover would overflow the viewport edge). */ + .virtuoso-setup-popover { position: absolute; top: calc(100% + 6px); right: 0; left: auto; z-index: 30; min-width: 290px; padding: 12px; display: flex; flex-direction: column; gap: 10px; background: var(--vir-surface, #0d1424); border: 1px solid var(--vir-border, #334155); border-radius: 10px; box-shadow: 0 12px 32px rgba(0,0,0,0.45); } .virtuoso-setup-popover[hidden] { display: none; } /* Progress chip — the header strip is now P's open affordance (clickable). */ .virtuoso-progress-chip { cursor: pointer; transition: border-color 0.12s, background 0.12s; } @@ -734,23 +762,30 @@ @media (max-width: 1120px) { #virtuoso-setup-label { font-size: 0; } #virtuoso-setup-label::after { content: attr(data-short); font-size: 12.5px; font-weight: 600; } + /* Profile badge drops its text → avatar-only (protect order: mode nav > + instrument > ⚙ > profile > tuner > tagline). */ + .virtuoso-profile-text { display: none; } } @media (max-width: 1000px) { .virtuoso-view-label, .virtuoso-fb-toggle-label, #virtuoso-theme-toggle { display: none !important; } /* Mode switcher goes icon-only here (aria-label keeps the accessible name). */ .virtuoso-mode-btn-label { display: none; } .virtuoso-mode-btn { gap: 0; padding: 0; } + /* Tuner badge yields (the tuner stays reachable via the Setup panel's + target-aware entry; only the shortcut is lost). */ + #virtuoso-tuner-badge { display: none !important; } } @media (max-width: 880px) { .virtuoso-header-modes .virtuoso-mode-bar { display: none; } .virtuoso-mode-select { display: inline-block; } + #virtuoso-profile-badge { display: none !important; } } /* Wordmark: mirror the host's signature gradient treatment so "Virtuoso" reads as a sibling of the host wordmark, not a flat-white outlier (host-harmony seam). The version span resets text-fill so it stays solid muted, not clipped. */ .virtuoso-header h1 { margin: 0; font-size: 22px; line-height: 1.1; letter-spacing: -0.02em; font-weight: 800; background: linear-gradient(115deg, var(--vir-accent-hover) 0%, #b984f5 100%); -webkit-background-clip: text; background-clip: text; color: transparent; } .virtuoso-version { margin-left: 7px; font-size: 11px; font-weight: 500; letter-spacing: 0.01em; color: var(--vir-text-muted, #94a3b8); -webkit-text-fill-color: var(--vir-text-muted, #94a3b8); vertical-align: middle; } - .virtuoso-tagline { margin: 3px 0 6px 0; font-size: 12px; line-height: 1.35; color: var(--vir-text-muted, #94a3b8); max-width: 760px; } + .virtuoso-tagline { margin: 3px 0 6px 0; font-size: 12px; line-height: 1.35; color: var(--vir-text-muted, #94a3b8); max-width: 760px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .virtuoso-tagline strong { font-weight: 600; color: var(--vir-text-dim, #cbd5e1); } .virtuoso-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; font-size: 12px; color: #cbd5e1; } .virtuoso-meta #virtuoso-renderer-status { color: #94a3b8; } @@ -2067,9 +2102,46 @@

Virtuoso

+ +
+
+ + + + +
+ + +
+ +
+ + + + tuning; clicking opens the popover. Moved out of the rail (shell IA); + now the host-parity INSTRUMENT badge slot (host settings are the source of + truth for guitar/bass — see the sync layer in screen.js). -->
- diff --git a/screen.js b/screen.js index 2e5b8f4..625090f 100644 --- a/screen.js +++ b/screen.js @@ -48,7 +48,7 @@ // a plugin's own version into its screen (note_detect hardcodes `_ND_VERSION` // the same way), so this is the display mirror of plugin.json's "version". // BUMP THIS WHENEVER plugin.json's version changes (release checklist). - const VIRTUOSO_VERSION = '0.1.10'; + const VIRTUOSO_VERSION = '0.1.11'; // =========================================================================== // §1 · CONSTANTS & MUSIC-THEORY DATA @@ -85,7 +85,12 @@ guitar_6_standard: { label:'6-string guitar — standard', instrument:'guitar', openMidis:[40,45,50,55,59,64], tuning:[0,0,0,0,0,0] }, guitar_6_drop_d: { label:'6-string guitar — Drop D', instrument:'guitar', openMidis:[38,45,50,55,59,64], tuning:[-2,0,0,0,0,0] }, guitar_7_standard: { label:'7-string guitar — standard', instrument:'guitar', openMidis:[35,40,45,50,55,59,64], tuning:[0,0,0,0,0,0,0] }, - guitar_8_standard: { label:'8-string guitar — standard', instrument:'guitar', openMidis:[30,35,40,45,50,55,59,64], tuning:[2,0,0,0,0,0,0,0] }, + // tuning was [2,0,…] — internally inconsistent metadata (implied a nonsense E + // base for string 0; the 8-string family standard IS F#, offset 0 — matches the + // host's STANDARD_OPEN_MIDIS["guitar-8"]). Inert for playback (openMidisForConfig + // never reads .tuning) but live in chart.tuning: a host round-trip through that + // offset would detune string 0 a whole step. Fixed 2026-07-10 (guitar-pedagogy). + guitar_8_standard: { label:'8-string guitar — standard', instrument:'guitar', openMidis:[30,35,40,45,50,55,59,64], tuning:[0,0,0,0,0,0,0,0] }, bass_4_standard: { label:'4-string bass — standard', instrument:'bass', openMidis:[28,33,38,43], tuning:[0,0,0,0] }, bass_5_standard: { label:'5-string bass — standard low B', instrument:'bass', openMidis:[23,28,33,38,43], tuning:[0,0,0,0,0] }, bass_6_standard: { label:'6-string bass — standard (B-E-A-D-G-C)', instrument:'bass', openMidis:[23,28,33,38,43,48], tuning:[0,0,0,0,0,0] } @@ -113,6 +118,12 @@ { id:'drop_b', label:'Drop B (B F# B E G# C#)', midis:[35,42,47,52,56,61] }, { id:'eb_standard', label:'Eb Standard (down ½ step)', midis:[39,44,49,54,58,63], offset:true }, { id:'d_standard', label:'D Standard (down 1 step)', midis:[38,43,48,53,57,62], offset:true }, + // Host-parity uniform standards (2026-07-10): the host tuning table names + // these; tagging them offset:true here keeps applyTuningAdaptL1's transpose + // path working when they arrive via the host selector sync ("add a tagged + // preset, don't grow a delta heuristic" — bass-pedagogy ruling). + { id:'cs_standard', label:'C# Standard (down 1½ steps)', midis:[37,42,47,52,56,61], offset:true }, + { id:'c_standard', label:'C Standard (down 2 steps)', midis:[36,41,46,51,55,60], offset:true }, { id:'dadgad', label:'DADGAD', midis:[38,45,50,55,57,62] }, { id:'open_g', label:'Open G (D G D G B D)', midis:[38,43,50,55,59,62] }, { id:'open_d', label:'Open D (D A D F# A D)', midis:[38,45,50,54,57,62] }, @@ -120,10 +131,15 @@ guitar_7: [ { id:'standard', label:'Standard (B E A D G B E)', midis:[35,40,45,50,55,59,64], offset:true }, { id:'drop_a', label:'Drop A (A E A D G B E)', midis:[33,40,45,50,55,59,64] }, + // Host-parity uniform standards (see the guitar_6 note). + { id:'bb_standard', label:'Bb Standard (down ½ step)', midis:[34,39,44,49,54,58,63], offset:true }, + { id:'a_standard', label:'A Standard (down 1 step)', midis:[33,38,43,48,53,57,62], offset:true }, ], guitar_8: [ { id:'standard', label:'Standard (F# B E A D G B E)', midis:[30,35,40,45,50,55,59,64], offset:true }, { id:'drop_e', label:'Drop E (E B E A D G B E)', midis:[28,35,40,45,50,55,59,64] }, + // Host-parity uniform standards (see the guitar_6 note). + { id:'e_standard', label:'E Standard (down 1 step)', midis:[28,33,38,43,48,53,57,62], offset:true }, ], bass_4: [ { id:'standard', label:'Standard (E A D G)', midis:[28,33,38,43], offset:true }, @@ -17633,7 +17649,9 @@ if (tun && tun.selectedOptions && tun.selectedOptions[0]) { tuning = tun.selectedOptions[0].textContent.replace(/\s*\(.*\)\s*/, '').trim(); } - return tuning ? `${instr} · ${tuning}` : instr; + // Host-badge content hierarchy: instrument · strings · tuning (topbar parity). + const count = currentStringCount(); + return tuning ? `${instr} · ${count} · ${tuning}` : `${instr} · ${count}`; } function updateSetupButton() { const lbl = $('virtuoso-setup-label'); @@ -17656,18 +17674,281 @@ btn.setAttribute('aria-expanded', open ? 'true' : 'false'); // The Tune… row needs the host scoring SDK (the mic detector) — sync its // visibility on every open so a host without it never shows a dead button. - // The courtesy button rides the same sync: shown only when the third-party - // floating-tuner plugin's public API is present (feature-detect, no dependency). + // (The old courtesy "Floating tuner ↗" button retired 2026-07-10: the header + // Tuner badge is that affordance now, in the host's own topbar position.) if (open) { const row = $('virtuoso-tune-row'); - const extOk = typeof window.tuner?.toggle === 'function'; - if (row) row.style.display = (ptAvailable() || ndVerifyAvailable() || extOk) ? '' : 'none'; + if (row) row.style.display = (ptAvailable() || ndVerifyAvailable()) ? '' : 'none'; const tuneBtn = $('virtuoso-tune-btn'); if (tuneBtn) tuneBtn.style.display = ptAvailable() ? '' : 'none'; - const ext = $('virtuoso-tuner-ext'); - if (ext) ext.style.display = extOk ? '' : 'none'; } } + // ── Host topbar parity: tuner + profile badges & instrument-settings sync ─── + // (docs/topbar-host-parity.md, 2026-07-10.) Virtuoso's "fullscreen":true hides + // the host topbar on this screen, so the header mirrors its three badges. HOST + // CHECK verdicts: tuner = BORROW the launch (window.tuner.toggle) + MIRROR the + // card; instrument = BORROW persistence (/api/settings + /api/tunings + + // workingTuning + instrument:changed) + MIRROR the badge; profile = MIRROR the + // chip + LINK (goScreen('v3-profile')). Host settings are the source of truth + // for the player's guitar/bass identity; piano (gated) NEVER posts — the host + // model has no piano. Per-rung tuning overrides (applyPathwayConfig / + // setFieldSilent paths) never reach hostSettingsWrite — only user-driven panel + // changes funnel through instrumentStoreSave. All host paths fail silent so + // the standalone/degraded behavior is exactly the pre-parity plugin. + // NOTE (notedetect-expert, load-bearing): note_detect reads NEITHER + // workingTuning nor /api/settings — the verifier's tuning reference is solely + // the per-call ctx Virtuoso already passes. This sync must never replace it. + let _hostTuningsByKey = null; // {'guitar-6': {name: [hz,…]}} from GET /api/tunings + let _hostTuningsRef = 440; // referencePitch the hz table was scaled to + let _hostSettingsCache = null; // last GET /api/settings body (pathway echo) + let _hostApplying = false; // true while applying host → panel (no write echo) + let _hostSelfEmit = false; // true around our own instrument:changed emit + let _hostWriteTimer = null; + function hostStandardMidisFor(family, count) { + return (STRING_SETUPS[`${family}_${count}_standard`] || {}).openMidis || null; + } + // The host serves tunings as FREQUENCIES scaled to its reference pitch; recover + // absolute midis against that same reference (A4 = 69 at referencePitch). + function hostTuningEntries(family, count) { + const table = _hostTuningsByKey && _hostTuningsByKey[`${family}-${count}`]; + if (!table) return []; + const ref = _hostTuningsRef || 440; + const out = []; + for (const [name, freqs] of Object.entries(table)) { + if (!Array.isArray(freqs) || freqs.length !== count) continue; + const midis = freqs.map(hz => Math.round(69 + 12 * Math.log2(hz / ref))); + if (midis.every(m => Number.isFinite(m) && m >= 0 && m <= 127)) out.push({ name, midis }); + } + return out; + } + function hostTuningNameForMidis(family, count, midis) { + const hit = hostTuningEntries(family, count) + .find(e => e.midis.length === midis.length && e.midis.every((m, i) => m === midis[i])); + return hit ? hit.name : null; + } + async function loadHostTunings() { + try { + const r = await fetch('/api/tunings'); + if (!r.ok) return; + const d = await r.json(); + _hostTuningsByKey = (d && d.tunings) || null; + _hostTuningsRef = Number(d && d.referencePitch) || 440; + syncTuningOptions(); // repaint the dropdown with the host named group + } catch (_) { /* older host / offline — Virtuoso presets only */ } + } + // The panel's currently-effective open midis (custom override else the setup's). + function effectiveMidisNow() { + const count = currentStringCount(); + const hidden = $('virtuoso-custom-open-midis'); + const custom = (hidden?.value || '').split(',').map(Number).filter(Number.isFinite); + if (custom.length === count) return custom; + const setup = document.querySelector('[name="stringSetup"]'); + return ((STRING_SETUPS[setup?.value] || {}).openMidis || []).slice(); + } + // Write-through: user panel change → POST /api/settings (host commit discipline: + // adopt only on an accepted response — /api/settings can return {error} with + // HTTP 200), then workingTuning + instrument:changed + the tuner-config push, + // exactly the host badge's own saveSettings sequence. Named tunings round-trip + // by NAME; everything else as a custom offset array vs the family standard + // (interop always maps through ABSOLUTE midis — never reconcile via .tuning). + function hostSettingsWriteSoon() { + if (_hostApplying) return; // applying host → panel; don't echo it back + if (_hostWriteTimer) clearTimeout(_hostWriteTimer); + _hostWriteTimer = setTimeout(() => { _hostWriteTimer = null; hostSettingsWriteNow(); }, 150); + } + async function hostSettingsWriteNow() { + const instrEl = document.querySelector('[name="instrument"]'); + if (!instrEl || instrEl.value === 'piano') return; // no piano in the host model + const family = currentFamily(); + const count = currentStringCount(); + const std = hostStandardMidisFor(family, count); + const midis = effectiveMidisNow(); + if (!std || midis.length !== std.length) return; + const named = hostTuningNameForMidis(family, count, midis); + const tuning = named || midis.map((m, i) => m - std[i]); + let accepted = false; + try { + const r = await fetch('/api/settings', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ instrument: family, string_count: count, tuning }), + }); + if (r.ok) { const body = await r.json().catch(() => ({})); accepted = !(body && body.error); } + } catch (_) { /* host settings unreachable — Virtuoso persistence still holds */ } + if (!accepted) return; + try { hostBus()?.workingTuning?.setCurrentInstrument?.(family, count); } catch (_) {} + _hostSelfEmit = true; + try { + hostBus()?.emit?.('instrument:changed', { + instrument: family, stringCount: count, tuning, + pathway: (_hostSettingsCache && _hostSettingsCache.pathway) || 'songs', // echo, control dropped + }); + } catch (_) {} + setTimeout(() => { _hostSelfEmit = false; }, 250); + // Tuner-config push only when the tuner plugin is actually present (its + // global is the cheap detect for its routes — a blind POST 404s and the + // console noise trips the smoke suites' console-error guards). + if (typeof window.tuner?.toggle === 'function') { + try { + const body = { lastInstrument: `${family}-${count}` }; + if (typeof tuning === 'string') body.lastTuning = tuning; // custom arrays have no name — skip + await fetch('/api/plugins/tuner/config', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), + }); + if (typeof window._tunerReloadConfig === 'function') await window._tunerReloadConfig(); + } catch (_) { /* tuner plugin config route absent */ } + } + } + // Resolve a host settings body to {inst, count, midis} (absolute midis — the + // interop currency; never reconcile via offsets alone). + function hostSettingsResolve(s) { + const inst = s.instrument === 'bass' ? 'bass' : 'guitar'; + const counts = inst === 'bass' ? [4, 5, 6] : [6, 7, 8]; + let count = Number(s.string_count); + if (!counts.includes(count)) count = counts[0]; + const std = hostStandardMidisFor(inst, count); + if (!std) return null; + let midis; + if (typeof s.tuning === 'string') { + const named = hostTuningEntries(inst, count).find(e => e.name === s.tuning); + midis = named ? named.midis.slice() : std.slice(); + } else if (Array.isArray(s.tuning) && s.tuning.length === std.length) { + midis = std.map((m, i) => m + (Number(s.tuning[i]) || 0)); + } else { + midis = std.slice(); + } + return { inst, count, midis }; + } + function hostStateMatchesPanel(res) { + const cur = effectiveMidisNow(); + return currentFamily() === res.inst && currentStringCount() === res.count + && cur.length === res.midis.length && cur.every((m, i) => m === res.midis[i]); + } + // Apply host → panel (a PULL: fresh install, or a live instrument:changed from + // the host badge / another screen). Applies via the normal setup-change path so + // pathway repaint/regenerate behave exactly like a user change — _hostApplying + // suppresses only the write-back echo. + function applyHostInstrument(s) { + const res = hostSettingsResolve(s); + if (!res) return; + if (hostStateMatchesPanel(res)) return; // already showing it (breaks the react loop) + const { inst, count, midis } = res; + const instrEl = document.querySelector('[name="instrument"]'); + const setupEl = document.querySelector('[name="stringSetup"]'); + const hidden = $('virtuoso-custom-open-midis'); + if (!instrEl || !setupEl) return; + // Exact built-in setup match else custom override vs the family standard. + let setupName = null; + for (const [k, v] of Object.entries(STRING_SETUPS)) { + if (v.instrument === inst && v.openMidis.length === midis.length + && v.openMidis.every((m, i) => m === midis[i])) { setupName = k; break; } + } + _hostApplying = true; + try { + instrEl.value = inst; + if (setupName) { setupEl.value = setupName; if (hidden) hidden.value = ''; } + else { setupEl.value = `${inst}_${count}_standard`; if (hidden) hidden.value = midis.join(','); } + syncInstrumentFamilyButtons(); + // The full user-path handler: syncs, L1 store save, pathway repaint, regenerate. + setupEl.dispatchEvent(new Event('change', { bubbles: true })); + syncCustomTuningInputs(); + } finally { _hostApplying = false; } + } + async function hostSettingsFetch() { + try { + const r = await fetch('/api/settings'); + if (!r.ok) return null; + const s = await r.json(); + if (!s || typeof s !== 'object') return null; + _hostSettingsCache = s; + return (s.instrument === 'guitar' || s.instrument === 'bass') ? s : null; + } catch (_) { return null; /* older host / offline */ } + } + // Boot-time reconcile. Direction matters: while this plugin is resident it + // tracks host changes LIVE (the instrument:changed subscription), so at boot a + // divergence means the LOCAL declaration is the survivor to trust — a pre-merge + // player's tuning lives only in the L1 store, and the host side may still be + // factory default ("chose Standard" and "never touched" are indistinguishable). + // Local wins and is pushed host-ward; a fresh install (no L1 store) adopts the + // host. Live external changes always PULL (the event handler below). + async function hostSettingsRead() { + const s = await hostSettingsFetch(); + if (!s) return; + const res = hostSettingsResolve(s); + if (!res || hostStateMatchesPanel(res)) return; // in sync — nothing to do + if (!instrumentStoreLoad()) { applyHostInstrument(s); return; } + hostSettingsWriteNow(); + } + // Tuner badge: launch is the host's own action; display is a passive mirror of + // the bus 'tuner:frame' stream ({note, cents, freq, hasSignal}). Never draws on + // the tuner's surfaces or re-implements detection (the borrow boundary). + function syncTunerBadge() { + const b = $('virtuoso-tuner-badge'); + if (b) b.hidden = typeof window.tuner?.toggle !== 'function'; + } + function applyTunerFrame(d) { + const note = $('virtuoso-tuner-note'), meter = $('virtuoso-tuner-meter'); + if (!note || !meter) return; + const has = d && d.hasSignal !== false && d.note; + note.textContent = has ? String(d.note) : 'Tuner'; + const segs = meter.children; + const cents = has && Number.isFinite(Number(d.cents)) ? Number(d.cents) : null; + // 5 segments ≈ the host's 11-bar meter at our scale: lit segment tracks cents + // (±25¢ range), green in tune, amber off-pitch. + const lit = cents == null ? -1 : Math.max(0, Math.min(4, Math.round(cents / 10) + 2)); + for (let i = 0; i < segs.length; i++) { + segs[i].classList.toggle('on', i === lit); + segs[i].classList.toggle('off-pitch', i === lit && Math.abs(cents) > 5); + } + } + // Profile badge: compact mirror (avatar + streak + rank) + LINK. Renders nothing + // unless the profile endpoint answers AND the player is onboarded (host rule). + async function loadProfileBadge() { + const btn = $('virtuoso-profile-badge'); + if (!btn || !btn.hidden) return; // absent or already rendered + try { + const r = await fetch('/api/profile'); + if (!r.ok) return; + const p = await r.json(); + if (!p || !p.onboarded) return; + let prog = null; + try { const r2 = await fetch('/api/profile/progress'); if (r2.ok) prog = await r2.json(); } catch (_) {} + let rank = ''; + try { rank = String(window.v3Progression?.get?.()?.mastery_rank || ''); } catch (_) {} + const av = $('virtuoso-profile-avatar'); + if (av && p.avatar_url) { av.src = p.avatar_url; av.hidden = false; } + const streak = Number(prog && prog.current_streak) || 0; + const bits = []; + if (streak > 0) bits.push(`${streak}🔥`); + if (rank) bits.push(rank); + if (!bits.length) bits.push(p.display_name || 'Profile'); + const txt = $('virtuoso-profile-text'); + if (txt) txt.textContent = bits.join(' · '); + btn.title = [p.display_name, rank, streak ? `${streak}-day streak` : ''] + .filter(Boolean).join(' · ') || 'Profile'; + btn.hidden = false; + } catch (_) { /* endpoint absent (web/smoke oddity) — badge stays hidden */ } + } + // One idempotent init: feature-detects each badge, loads host state, subscribes + // the bus streams. Called from bind() + a delayed retry (plugin load order — the + // tuner plugin / bus may register after us, mirroring the ptShowIdleStrip retry). + let _hostTopbarSubscribed = false; + function initHostTopbar() { + syncTunerBadge(); + loadProfileBadge(); + if (_hostTopbarSubscribed) return; + const bus = hostBus(); + if (!bus || typeof bus.on !== 'function') return; + _hostTopbarSubscribed = true; + try { bus.on('tuner:frame', (e) => applyTunerFrame((e && e.detail) || e)); } catch (_) {} + try { + bus.on('instrument:changed', async () => { + if (_hostSelfEmit) return; // our own emit bouncing back + // External change (host badge, another screen) — always a PULL. + const s = await hostSettingsFetch(); + if (s) applyHostInstrument(s); + }); + } catch (_) {} + } // Header settings menu (⚙) + its prefs: accent theme (live), default XP mode (a // stored default — ready for the unbuilt XP store), default count-in (seeds the // count-in control on load). All persisted to localStorage. @@ -19751,6 +20032,12 @@ customOpenMidis: (hidden?.value || '').trim(), })); } catch (_) {} + // Host write-through (topbar parity): this function IS the user-driven L1 + // declaration funnel — every call site is a real user panel change (pathway/ + // programmatic writes go through setFieldSilent and never land here), so it is + // exactly the anti-leak boundary the host sync must respect. Debounced; + // no-ops while applying host → panel and on every host failure. + hostSettingsWriteSoon(); } function instrumentStoreLoad() { try { @@ -19844,7 +20131,11 @@ ? customMidis : ((STRING_SETUPS[setupName] || {}).openMidis || []); let activeId = null; - // Built-in presets (Standard, Drop D, DADGAD, …). + // Built-in presets (Standard, Drop D, DADGAD, …). These keep their STABLE ids + // (`standard`, `d_standard`, …) — tests, stored prefs, and the L1 adapt paths + // key off them, so the host merge must never remove or rename them (deliberate + // deviation from the spec's "host name wins": id stability beats label + // provenance; the labels are near-identical anyway). const presetGroup = document.createElement('optgroup'); presetGroup.label = 'Built-in'; for (const p of presets) { @@ -19858,6 +20149,29 @@ } } sel.appendChild(presetGroup); + // Host named tunings the curated set doesn't carry (topbar parity): the host + // tuning table is the fuller catalog (Drop A/Ab, Open E, …) — midis-duplicates + // of a built-in preset are skipped. Round-tripping to the host by NAME is + // unaffected by which option the user picks (hostTuningNameForMidis matches by + // resolved midis), as is the offset:true intent tag (applyTuningAdaptL1 + // matches TUNING_PRESETS by midis). + const hostEntries = hostTuningEntries(family, count) + .filter(e => !presets.some(p => p.midis.length === e.midis.length && p.midis.every((m, i) => m === e.midis[i]))); + if (hostEntries.length) { + const hostGroup = document.createElement('optgroup'); + hostGroup.label = 'FeedBack'; + for (const e of hostEntries) { + const opt = document.createElement('option'); + opt.value = `host:${e.name}`; + opt.textContent = e.name; + opt.dataset.midis = e.midis.join(','); + hostGroup.appendChild(opt); + if (!activeId && effective.length === e.midis.length && effective.every((m, i) => m === e.midis[i])) { + activeId = `host:${e.name}`; + } + } + sel.appendChild(hostGroup); + } // Saved tunings filtered to the current family + string count. const mine = savedTunings.filter(t => t.family === family && t.string_count === count); if (mine.length) { @@ -19882,7 +20196,9 @@ customOpt.textContent = 'Custom…'; sel.appendChild(customOpt); if (!activeId && customMidis && customMidis.length === count) activeId = 'custom'; - sel.value = activeId || (presets[0] && presets[0].id) || 'custom'; + // Fallback = the first listed option (the host group may have absorbed the + // built-in Standard entry, so presets[0].id can be absent from the DOM). + sel.value = activeId || (sel.options[0] && sel.options[0].value) || 'custom'; syncCustomTuningInputs(); updateSetupButton(); // header Setup button shows the live instrument + tuning } @@ -19979,10 +20295,10 @@ function onTuningPresetChange() { const sel = $('virtuoso-tuning-select'); if (!sel) return; const hidden = $('virtuoso-custom-open-midis'); - // Saved tunings come back as `saved:`; the midis live on the option's - // dataset.midis (set by syncTuningOptions). Apply them as a custom - // override against the family's standard stringSetup. - if (sel.value && sel.value.startsWith('saved:')) { + // Saved tunings come back as `saved:`, host named tunings as `host:`; + // the midis live on the option's dataset.midis (set by syncTuningOptions). + // Apply them as a custom override against the family's standard stringSetup. + if (sel.value && (sel.value.startsWith('saved:') || sel.value.startsWith('host:'))) { const opt = sel.options[sel.selectedIndex]; const midis = (opt?.dataset.midis || '').split(',').map(Number).filter(Number.isFinite); if (midis.length) { @@ -25604,13 +25920,22 @@ ptShowIdleStrip(); setTimeout(ptShowIdleStrip, 2000); $('virtuoso-tuner-done')?.addEventListener('click', () => stopTuner()); - // Courtesy hook for the third-party floating tuner (never touch its DOM — - // its own public API only; the click is impossible unless feature-detect - // showed the button). - $('virtuoso-tuner-ext')?.addEventListener('click', () => { - toggleSetupPopover(false); + // Header tuner badge (topbar parity) — the host topbar card's exact action. + // Feature-detected by initHostTopbar/syncTunerBadge; never touches the tuner's + // DOM, its own public API only. + $('virtuoso-tuner-badge')?.addEventListener('click', () => { try { window.tuner?.toggle?.(); } catch (_) {} }); + // Header profile badge → the host profile screen (LINK, never a second + // profile implementation). + $('virtuoso-profile-badge')?.addEventListener('click', () => goScreen('v3-profile')); + // Host topbar parity init: badges + /api/tunings + host-settings read (host + // wins over the L1 restore above for the player's guitar/bass identity) + + // bus subscriptions. Delayed retry covers plugin load order — the tuner + // plugin / bus may register after us (mirrors the ptShowIdleStrip retry). + loadHostTunings().then(() => hostSettingsRead()); + initHostTopbar(); + setTimeout(initHostTopbar, 2000); $('virtuoso-tuning-select')?.addEventListener('change', updateSetupButton); document.addEventListener('click', (e) => { const pop = $('virtuoso-setup-popover'); if (!pop || pop.hidden) return; @@ -25620,6 +25945,9 @@ updateSetupButton(); // Header settings menu (⚙): toggle, items, close-on-outside-click. $('virtuoso-settings-btn')?.addEventListener('click', (e) => { e.stopPropagation(); toggleSettingsMenu(); }); + // P-sheet affordance (the header progress chip is hidden pending the + // profile-merge design; P hotkey + session-end auto-present stay primary). + $('virtuoso-settings-progress')?.addEventListener('click', () => { toggleSettingsMenu(false); toggleProgressSheet(true); }); $('virtuoso-settings-shortcuts')?.addEventListener('click', () => { toggleSettingsMenu(false); toggleCheatSheet(true); }); $('virtuoso-settings-host')?.addEventListener('click', () => { toggleSettingsMenu(false); From 143a391ef35adbaf9b1bb38d236ef59424f6be90 Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou <16130099+ChrisBeWithYou@users.noreply.github.com> Date: Fri, 10 Jul 2026 01:50:22 -0500 Subject: [PATCH 04/18] fix(generators): string-count-aware system defaults + bass shape guards (v0.1.12) Audit verdict: "8-string exercises ignore the extra strings" was DESIGN, not plumbing - string count/tuning reach every builder, but CAGED/Open anchor the top-six EADGBE subset by construction (correct for CAGED itself) and 'caged' was the universal beginner/session default, so most exercises never touched strings 7-8 (renderers draw all N lanes; the bottom two just sat empty). Fix (guitar-pedagogy + bass-pedagogy + metal-idiom panel): - defaultFretboardSystem(instrument, count): bass -> 'position' (bass never uses guitar shape systems; the pathway path already coded this - now Custom/beginner + sessions agree), guitar N>6 -> '3nps' (THE extended-range scale system - tiles every string, so 7/8-string runs actually reach the low B/F#), guitar 6 -> 'caged' (unchanged). Explicit advanced-mode picks and rung-coded systems always win; a rung that TEACHES a CAGED shape stays a CAGED lesson. Wired into readConfig + buildSegmentConfig. - Instrument-first guards (defense-in-depth) on every count-gated shape path: resolveCurrentShape, cagedShapeNotesForChord, templateFromShape, sweep wantShape. Kills a REAL latent pitch bug: a 6-string bass satisfies the old count-only >=6 guards, but the CAGED templates bake EADGBE's G->B major 3rd - on an all-4ths B-E-A-D-G-C bass the top two strings land a semitone flat. Only UI suppression protected it (hidden shape controls); programmatic configs (preset import, host sync) were exposed. Smoke: new smoke-strings rows (12) default routing (8-string default=3nps, reaches s=0/s=1, no-unison holds; 6-string stays caged; explicit caged preserved) + (13) bass never resolves a shape, forced 6-string-bass caged sweeps stay diatonic, 5-string scale plays the low-B string. Suite green; full run 16/19 with only the pre-existing base reds (backing-engine voice- leading row, progress, variation); contained-verifier + level-gate-async flaked under concurrency, green solo. Deferred by panel verdict (ROADMAP open thread): opt-in extended-range CAGED window-continuation (never a re-rooted box), bassRootGrip low-fifth downward reach, drop-tuning s0+s1+s2 barre primitive. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01MS2YFb6UUSwJVV6CmEa25i --- .claude/skills/run-virtuoso/smoke-strings.mjs | 77 +++++++++++++++++++ ROADMAP.md | 6 ++ plugin.json | 2 +- screen.js | 67 ++++++++++++---- 4 files changed, 134 insertions(+), 18 deletions(-) diff --git a/.claude/skills/run-virtuoso/smoke-strings.mjs b/.claude/skills/run-virtuoso/smoke-strings.mjs index 3d6d843..cfddcff 100644 --- a/.claude/skills/run-virtuoso/smoke-strings.mjs +++ b/.claude/skills/run-virtuoso/smoke-strings.mjs @@ -306,6 +306,83 @@ try { ok(fg.n > 0 && fg.ho && fg.po, "bass legato generates slurs (hammer-ons + pull-offs)", `n=${fg.n} ho=${fg.ho} po=${fg.po}`); ok(fg.fgs.includes(4), "legato carries fg incl. the PINKY (fg 4) — the bass pinky drill works", `fgs=[${fg.fgs.join(",")}]`); + console.log("-- (12) DEFAULT fretboard-system routing is instrument/count-aware (string-count fix, 2026-07-10) --"); + // Beginner mode used to force 'caged' for EVERYTHING — on 7/8-string the box + // anchors top-six (rows 5/6, correct for CAGED itself) so the extra strings + // never sounded (the "switched to 8-string and nothing changed" report), and + // bass got guitar-shape geometry (bass-pedagogy: bass never uses CAGED). + // defaultFretboardSystem: bass → position, guitar N>6 → 3nps, guitar 6 → caged; + // an explicit advanced-mode pick still wins. + const d8 = await page.evaluate(() => { + window.__t.setAdvanced(false); window.__t.setTuning(null); + window.__t.setForm({ stringSetup: "guitar_8_standard", practiceType: "scale", scale: "major", key: "C" }); + const cfg = window.Virtuoso.readConfig(); + const ex = window.Virtuoso.generateExercise(cfg); + const opens = [30, 35, 40, 45, 50, 55, 59, 64]; // guitar_8_standard + const strings = [...new Set(ex.chart.notes.map(n => n.s))].sort((a, b) => a - b); + const midis = [...new Set(ex.chart.notes.map(n => `${n.s}:${n.f}`))].map(k => { const [s, f] = k.split(":").map(Number); return opens[s] + f; }); + return { sys: cfg.fretboardSystem, n: ex.chart.notes.length, strings, unison: midis.length !== new Set(midis).size }; + }); + ok(d8.sys === "3nps", "(12a) 8-string beginner default = 3nps (the extended-range scale system)", d8.sys); + ok(d8.n > 0 && d8.strings.includes(0) && d8.strings.includes(1), "(12b) default 8-string scale run REACHES the low F#/B (s=0,1)", `[${d8.strings.join(",")}]`); + ok(!d8.unison, "(12c) the extended default path holds the no-unison rule (unique position = unique pitch)"); + const d6 = await page.evaluate(() => { + window.__t.setForm({ stringSetup: "guitar_6_standard", practiceType: "scale", scale: "major", key: "C" }); + return window.Virtuoso.readConfig().fretboardSystem; + }); + ok(d6 === "caged", "(12d) 6-string guitar beginner default stays caged (unchanged)", d6); + const dExplicit = await page.evaluate(() => { + window.__t.setAdvanced(true); + window.__t.setForm({ stringSetup: "guitar_8_standard", practiceType: "scale", scale: "major", key: "C", fretboardSystem: "caged", shape: "E" }); + const cfg = window.Virtuoso.readConfig(); + window.__t.setAdvanced(false); + return cfg.fretboardSystem; + }); + ok(dExplicit === "caged", "(12e) an EXPLICIT advanced-mode caged pick on 8-string is preserved (default only fills the unset case)", dExplicit); + console.log("-- (13) BASS never routes through a guitar shape system (6-string bass passed the old count-only guards) --"); + // Face 1: beginner Custom bass got CAGED-derived geometry (pathways already + // coded 'position'). Face 2 (latent, pitch-wrong): a 6-STRING bass satisfies + // stringCount>=6, but the CAGED templates bake EADGBE's G→B major 3rd — on an + // all-4ths bass the top two strings land a semitone flat (a real wrong pitch). + // Guards are now instrument-first at every layer (resolveCurrentShape, + // cagedShapeNotesForChord, templateFromShape, sweep wantShape). + for (const su of ["bass_4_standard", "bass_5_standard", "bass_6_standard"]) { + const r = await page.evaluate((s) => { + window.__t.setAdvanced(false); window.__t.setTuning(null); + window.__t.setForm({ stringSetup: s, practiceType: "scale", scale: "major", key: "C" }); + const cfg = window.Virtuoso.readConfig(); + return { sys: cfg.fretboardSystem, hasShape: !!(cfg.shapeNotes && cfg.shapeNotes.length) }; + }, su); + ok(r.sys === "position" && !r.hasShape, `(13) ${su} beginner default = position, no resolved shape`, `sys=${r.sys} shape=${r.hasShape}`); + } + const b6 = await page.evaluate(() => { + // Force the hostile config: a 6-string bass shoved INTO caged sweep territory. + // wantShape must refuse (instrument guard) and fall to the interval-derived + // path, so every emitted pitch stays a chord/scale tone of C major. + window.__t.setAdvanced(true); window.__t.setTuning(null); + window.__t.setForm({ stringSetup: "bass_6_standard", practiceType: "sweep_arpeggios", scale: "major", key: "C", fretboardSystem: "caged", shape: "E" }); + const ex = window.Virtuoso.generateExercise(window.Virtuoso.readConfig()); + const opens = [23, 28, 33, 38, 43, 48]; // bass_6_standard (all 4ths — no G→B 3rd) + const pcs = [...new Set(ex.chart.notes.map(n => ((opens[n.s] + n.f) % 12 + 12) % 12))].sort((a, b) => a - b); + const cMajor = new Set([0, 2, 4, 5, 7, 9, 11]); + window.__t.setAdvanced(false); + return { n: ex.chart.notes.length, offKey: pcs.filter(pc => !cMajor.has(pc)) }; + }); + ok(b6.n > 0 && b6.offKey.length === 0, "(13b) 6-string bass forced at caged sweeps: every pitch stays diatonic (no semitone-flat template leak)", `n=${b6.n} offKey=[${b6.offKey.join(",")}]`); + const lowB = await page.evaluate(() => { + window.__t.setAdvanced(false); window.__t.setTuning(null); + window.__t.setForm({ stringSetup: "bass_5_standard", practiceType: "scale", scale: "major", key: "E" }); + const ex = window.Virtuoso.generateExercise(window.Virtuoso.readConfig()); + const strings = [...new Set(ex.chart.notes.map(n => n.s))].sort((a, b) => a - b); + window.__t.setForm({ stringSetup: "guitar_6_standard" }); // self-clean + return { n: ex.chart.notes.length, strings }; + }); + // The B string (s=0) must be PLAYED — a root-anchored run legitimately starts + // AT the root (E sits on the B string, fret 5), so assert string usage, not + // sub-E pitch. Reaching BELOW the root (the low-fifth idiom) is the deferred + // bassRootGrip downward-reach feature (ROADMAP open thread), not this fix. + ok(lowB.n > 0 && lowB.strings.includes(0), "(13c) 5-string bass scale actually plays ON the low B string (s=0)", `strings=[${lowB.strings.join(",")}]`); + ok(pageErrs.length === 0, "no uncaught page errors", pageErrs.join(" | ")); console.log(`\n${fails === 0 ? "PASS" : "FAIL"} strings/tuning: ${fails} failure(s)`); process.exit(fails ? 1 : 0); diff --git a/ROADMAP.md b/ROADMAP.md index 378f1a4..06501b9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -26,6 +26,12 @@ Verification: 18 Playwright smoke suites (`.claude/skills/run-virtuoso/`, `npm t ## Open threads +- **STOPPED HERE (2026-07-10 late session) — v0.1.11 topbar host parity (PR #5) + v0.1.12 string-count-aware generation (PR #6, stacked on #5) — both OPEN against `virtuoso-dev`.** Design + rulings: memory `project_topbar_parity_stringcount_2026-07-10`; spec `docs/topbar-host-parity.md`. + - **PR #5 (v0.1.11)** — the fullscreen opt-in hides the host topbar, so the header now mirrors its three badges (tuner · instrument · profile, compact 32px density variants) + a host-settings sync layer (`/api/settings` + `/api/tunings` + `workingTuning` + `instrument:changed`; user changes funnel via `instrumentStoreSave`, boot = local-L1-wins-and-pushes, live events pull; piano + per-rung overrides never write). Progress chip HIDDEN (one-attribute revert) pending a profile-merge design; P-sheet opens from ⚙ "Progress (P)". Latent `guitar_8_standard.tuning [2,…]→[0,…]` bug fixed; host uniform standards added as `offset:true` presets. + - **PR #6 (v0.1.12)** — the "8-string shows 6 strings" audit verdict was DESIGN not plumbing: CAGED/Open deliberately top-six + `caged` was the universal beginner default. Fix: `defaultFretboardSystem()` (bass → `position`, guitar N>6 → `3nps`, guitar 6 → `caged`; explicit picks + coded rungs win) in `readConfig` + `buildSegmentConfig`, plus instrument-first guards killing the **real latent 6-string-bass pitch bug** (count-only `>=6` guards let an all-4ths bass into EADGBE-interval templates → top-two strings a semitone flat). New smoke-strings rows (12)/(13) lock both. + - **Deferred (designed, not built):** opt-in extended-range CAGED **window-continuation** (degree-filled low-string notes in the same fret window — never a re-rooted box; guitar-pedagogy verdict), bassRootGrip **low-fifth downward reach** (the "why own a 5-string" idiom; bass-pedagogy), the **drop-tuning single-finger barre** primitive (s0+s1+s2; metal-idiom flag), profile/progress-chip merge design (two streak definitions must not coexist visibly), UX spec Q1–Q3 (reference-pitch control · header streak source · handedness sync) awaiting Christian. + - **Owed:** real-desktop probe of the three badges (`window.tuner` + onboarding absent headless — suites can't assert them). Pre-existing smoke reds on base: `backing-engine` (voice-leading common-tone row), `progress`, `variation` (404 console-guard). + - **STOPPED HERE (2026-07-10 session) — v0.1.10 grading-DISPLAY fixes SHIPPED to `virtuoso-dev` (`5fa6f13`); PR #4 dev→main OPEN (not merged), so `main` is still 0.1.9 and beta is NOT yet re-cut.** Detail + lessons: memory `project_grading_display_fixes_2026-07-10`. - **Card `0%`/`0-of-0` hero** — `shareCardAction` was STILL delegating to note_detect's `renderResultsCard`/`copyResultsCard` (a stale v0.1.3 path that survived the ratified v0.1.4 "keep our renderer" decision). nd's card hero reads top-level `d.accuracy`/`d.hits`/`d.misses` we never set (only `stats[]`) → 0/0, while the delegation silently suppressed our skins + crest on desktop. **Retired the delegation** → Virtuoso always draws its own skinned `renderShareCardImage`. (`_ndCardApi`/`_resultsCardData`/`_virCardOverlay` kept dormant; the `virtuoso_beta` brand was `cut-beta.mjs`'s token-rename, not note_detect.) - **3D highway hit-flare invisible** — highway_3d's **feedBack#254** per-note PROVIDER (`getNoteState`) is authoritative over the `notedetect:hit` window-event marks and culls the gem ~100ms past the strike line unless the provider returns a live verdict THAT FRAME; ours only returned `'hit'` after the async contained drain (~150-400ms late) → gem culled first. Fix: a display-only `{ state:'active', live:true }` **live-ear keep-alive** (`_ptLive` captured in `ptOnPitch`; mirrors note_detect's own `noteStateFor`), pitch-gated ≤ the credit floor + level-gated + ±0.12s window; it renders NO judgment and credit stays verifier-only via `_ptScored`. notedetect-expert APPROVED (not a parallel grader). Return the OBJECT with `live:true` (highway's non-latched `hit-live` branch) — a bare `'active'` string sticks green. **Flare CONFIRMED lighting on Christian's real DI** ("nailed it") → the `_ptHandle` minigames ear DOES hear his DI. diff --git a/plugin.json b/plugin.json index 5c2fe88..9d8666a 100644 --- a/plugin.json +++ b/plugin.json @@ -1,7 +1,7 @@ { "id": "virtuoso", "name": "Virtuoso", - "version": "0.1.11", + "version": "0.1.12", "description": "Practice studio for guitar & bass — scale, technique, and rhythm drills, timed workouts, and jam backing that teach skills you take off the screen.", "category": "practice", "icon": "assets/thumb.png", diff --git a/screen.js b/screen.js index 625090f..ce4b980 100644 --- a/screen.js +++ b/screen.js @@ -48,7 +48,7 @@ // a plugin's own version into its screen (note_detect hardcodes `_ND_VERSION` // the same way), so this is the display mirror of plugin.json's "version". // BUMP THIS WHENEVER plugin.json's version changes (release checklist). - const VIRTUOSO_VERSION = '0.1.11'; + const VIRTUOSO_VERSION = '0.1.12'; // =========================================================================== // §1 · CONSTANTS & MUSIC-THEORY DATA @@ -3096,6 +3096,22 @@ // fretMin/fretMax handling. const SHAPE_AWARE_SYSTEMS = new Set(['caged', '3nps', 'open']); function isShapeAwareSystem(system) { return SHAPE_AWARE_SYSTEMS.has(system); } + // Default fretboard system per instrument profile (string-count fix, 2026-07-10; + // guitar-pedagogy + bass-pedagogy panel): + // - bass → 'position': bass NEVER uses the guitar shape systems (CAGED/Open/3NPS + // are EADGBE artifacts; the movable position box is bass's system — the pathway + // path already coded this at apply time, this aligns Custom/beginner + sessions). + // - guitar N>6 → '3nps': THE extended-range scale system — it tiles every string + // uniformly, so a 7/8-string exercise actually uses the low B/F#. A caged + // default anchors the top-six and leaves the extra strings dead (the + // "switched to 8-string and nothing changed" bug). CAGED itself deliberately + // stays a top-six system (no re-rooted mega-box — no method teaches one). + // - guitar 6 → 'caged' (unchanged). + // An EXPLICIT user/rung choice always wins — this is only the unset default. + function defaultFretboardSystem(instrument, stringCount) { + if (instrument === 'bass') return 'position'; + return (stringCount || 6) > 6 ? '3nps' : 'caged'; + } // Sensible default shape for a system + key. function defaultShapeForSystem(system, keyPc, scale, openMidis) { @@ -3191,6 +3207,12 @@ // isn't shape-aware (callers should fall back to raw fretMin/fretMax). function resolveCurrentShape(cfg, openMidis) { if (!isShapeAwareSystem(cfg.fretboardSystem)) return null; + // Bass never resolves a guitar shape system (defense-in-depth: the count + // guards alone pass a 6-string bass, whose all-4ths tuning breaks the + // EADGBE-interval templates — notes land a semitone flat on the top two + // strings; bass-pedagogy 2026-07-10). The UI hides shape controls for bass, + // but a programmatic config (preset import, host sync) must be safe too. + if (cfg.instrument === 'bass') return null; const keyPc = NOTE_ALIASES[cfg.key] ?? 0; let shape = cfg.shape; // Coerce numeric shape ids for 3NPS (form values are strings). @@ -4268,11 +4290,13 @@ let fretMax = Math.min(MAX_FRET, Math.max(fretMin + 1, parseInt(_fretVal('fretMax', '5'), 10) || 5)); const practiceType = data.get('practiceType') || data.get('mode') || 'scale'; const advancedMode = data.get('advancedMode') === 'on'; - // Default fretboard system is CAGED in beginner mode and whatever the user - // picks in advanced mode. The shape-aware systems (caged/3nps/open) drive + // Default fretboard system is instrument/count-aware (defaultFretboardSystem: + // bass → position, guitar 7/8 → 3nps, guitar 6 → caged); an advanced-mode + // explicit pick wins. The shape-aware systems (caged/3nps/open) drive // fretMin/fretMax via the resolved shape; raw fretMin/fretMax inputs only // matter for the 'position' / 'single_string' / 'full_neck' legacy paths. - const fretboardSystem = advancedMode ? (data.get('fretboardSystem') || 'caged') : 'caged'; + const fretboardSystem = (advancedMode && data.get('fretboardSystem')) + || defaultFretboardSystem(setup.instrument, setup.openMidis.length); let shape = data.get('shape'); let shapeNotes = null, shapeDisplayName = null; // Frame-window types (the chromatic warmup, the fingerstyle spider) take @@ -4282,7 +4306,7 @@ // 2026-06-12 building the spider, whose whole walk rides the window). const frameWindowType = practiceType === 'chromatic' || practiceType === 'spider'; if (isShapeAwareSystem(fretboardSystem) && !frameWindowType) { - const resolved = resolveCurrentShape({ fretboardSystem, key: data.get('key') || 'C', scale: data.get('scale') || 'major', shape }, effectiveOpenMidis); + const resolved = resolveCurrentShape({ fretboardSystem, instrument: setup.instrument, key: data.get('key') || 'C', scale: data.get('scale') || 'major', shape }, effectiveOpenMidis); if (resolved) { shape = resolved.shape; shapeNotes = resolved.resolved.notes; @@ -6384,10 +6408,13 @@ } function cagedShapeNotesForChord(cfg, shape, quality, rootFret) { - // CAGED chord-tone templates are a 6-string (EADGBE) system. Need ≥6 strings; - // bass 4/5 (<6) → return null so the caller falls back. On a 7/8-string the + // CAGED chord-tone templates are a 6-string (EADGBE) system. Need ≥6 GUITAR + // strings; bass 4/5 (<6) → null so the caller falls back — and bass at ANY + // count → null: a 6-string bass passes the count guard but is all-4ths (no + // G→B major 3rd), so the interval-baked template lands a semitone flat on the + // top two strings (bass-pedagogy 2026-07-10). On a 7/8-string guitar the // template anchors on the TOP SIX strings (off = N-6), mirroring resolveCAGEDShape. - if (cfg.stringCount < 6) return null; + if (cfg.instrument === 'bass' || cfg.stringCount < 6) return null; const def = CAGED_SHAPES[shape]; if (!def) return null; const tmpl = def.chordTemplates[cagedShapeQualityKey(quality)]; @@ -6953,9 +6980,11 @@ // chord voicing (all strings the shape covers, not just the strings the // generator happened to play) with sensible fingerings. function templateFromShape(name, shape, quality, rootFret, cfg, arp) { - // 6-string (EADGBE) template; need ≥6 strings. On 7/8 it sits on the top-six - // (off = N-6) so the chord box matches the anchored CAGED shape. - if (cfg.stringCount < 6) return null; + // 6-string (EADGBE) template; need ≥6 GUITAR strings (a 6-string bass passes + // the count but its all-4ths tuning breaks the template intervals — see + // cagedShapeNotesForChord). On 7/8 it sits on the top-six (off = N-6) so the + // chord box matches the anchored CAGED shape. + if (cfg.instrument === 'bass' || cfg.stringCount < 6) return null; const def = CAGED_SHAPES[shape]; if (!def) return null; const tmpl = def.chordTemplates[cagedShapeQualityKey(quality)]; @@ -9272,11 +9301,12 @@ // carries fingering by construction (the standard CAGED arpeggio shapes). // pickShapeRootFret walks the chosen shape up/down the neck per chord. const shape = cfg.shape || cfg.cagedShape; - // ≥6 strings: a 7/8-string can host the 6-string CAGED sweep template on its - // top-six (anchored via `off` in pickShapeRootFret/cagedShapeNotesForChord), + // ≥6 GUITAR strings: a 7/8-string can host the 6-string CAGED sweep template + // on its top-six (anchored via `off` in pickShapeRootFret/cagedShapeNotesForChord), // so the extended-range sweep keeps by-construction fingering instead of the - // greedy all-string rake. <6 (bass) → falls to sweepArpeggioPositions. - const wantShape = cfg.stringCount >= 6 && !!CAGED_SHAPES[shape]; + // greedy all-string rake. <6 OR bass (a 6-string bass passes the count but + // breaks the EADGBE template intervals) → falls to sweepArpeggioPositions. + const wantShape = cfg.instrument !== 'bass' && cfg.stringCount >= 6 && !!CAGED_SHAPES[shape]; // The CAGED chordTemplates carry only TRIADS (maj/min/dim) — cagedShapeQualityKey // collapses min7→min and maj7/dom7→maj, so a seventh sweep through the template // would silently drop the 7th (its whole color). Route seventh sweeps to the @@ -11756,7 +11786,10 @@ // Structural defaults key:'C', scale:'major', bpm:80, bars:4, direction:'up_down', sequence:'none', meter:'4/4', // safety-net default so a meter-less config never yields meter.denominator undefined - subdivision:'eighth', fretboardSystem:'caged', shape:'E', fretMin:0, fretMax:5, + // fretboardSystem: instrument/count-aware (bass → position, guitar 7/8 → + // 3nps, guitar 6 → caged); a segment's own coded system still wins (a rung + // that TEACHES a CAGED shape stays a CAGED lesson on its coded strings). + subdivision:'eighth', fretboardSystem: defaultFretboardSystem(setup.instrument, setup.openMidis.length), shape:'E', fretMin:0, fretMax:5, chordDepth:'seventh', progression:'ii-V-I', chordOverride:'auto', chordScaleStrategy:'mode_of_moment', chromaticPattern:'1234', keyCycle:'none', repeatCount:1, advancedMode:true, voices:'thirds_only', renderer:'highway_3d', @@ -11780,7 +11813,7 @@ // Resolve CAGED / 3NPS / Open shape into fretMin/fretMax + shapeNotes raw.shapeNotes = null; raw.shapeDisplayName = null; if (isShapeAwareSystem(raw.fretboardSystem)) { - const resolved = resolveCurrentShape({ fretboardSystem:raw.fretboardSystem, key:raw.key, scale:raw.scale, shape:raw.shape }, effectiveOpenMidis); + const resolved = resolveCurrentShape({ fretboardSystem:raw.fretboardSystem, instrument:raw.instrument, key:raw.key, scale:raw.scale, shape:raw.shape }, effectiveOpenMidis); if (resolved) { raw.shape = resolved.shape; raw.shapeNotes = resolved.resolved.notes; From 9e234f6288b2303922443925092283b49fdb581c Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou <16130099+ChrisBeWithYou@users.noreply.github.com> Date: Fri, 10 Jul 2026 09:30:41 -0500 Subject: [PATCH 05/18] feat(ui): 2D Highway joins the view lineup; view pills become a dropdown (v0.1.13) The in-tree 2D highway was only reachable as a silent fallback (builtin_2d is the Jumping Tab borrow slot; highway_3d falls back to it too). New first-class kind 'highway_2d' -> makeBuiltin2DRenderer, always available, any string count. Five stringed views + Piano Roll outgrew the segmented pill row, so the view switcher is now a dropdown (#virtuoso-view-select) in the same control family as the mode/settings selects. change -> onViewSwitch (same path a pill click took); syncViewSwitcher mirrors what's ACTUALLY rendered (saved-pref restore, >8-string force) by value-set only, so syncing never re-triggers a switch. Piano/stringed option visibility keeps the old CSS mechanism, retargeted from .virtuoso-view-btn[data-renderer] to option[value]. highway_2d is fretboard-strip capable; theme toggle stays Tab/Notation-only; hand-marks off-view stays 3D-only. Retired .virtuoso-view-btn/.virtuoso-view-tabs CSS kept (local shot-*.mjs scripts + skin overrides still reference it) with a RETIRED note; no live markup uses it. Harness: driver.mjs + all suites drive the dropdown (value + bubbled change); readiness waits retargeted to #virtuoso-view-select; smoke-renderers gains a highway_2d row (enforcePixels: true - it draws into the in-tree canvas); smoke-highway-settings cycles it through the attach/detach churn loop; driver.mjs screenshot/all-renderers accept highway_2d. Smoke: 16/19 - same 3 pre-existing base reds (backing-engine voice-leading, progress, variation). Screenshot-verified: dropdown renders, 2D highway attaches + draws lanes/gems. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01MS2YFb6UUSwJVV6CmEa25i --- .claude/skills/run-virtuoso/driver.mjs | 17 ++++-- .../run-virtuoso/smoke-backing-engine.mjs | 2 +- .claude/skills/run-virtuoso/smoke-connect.mjs | 2 +- .../skills/run-virtuoso/smoke-core-purity.mjs | 2 +- .../skills/run-virtuoso/smoke-generators.mjs | 2 +- .../run-virtuoso/smoke-highway-settings.mjs | 19 ++++--- .../run-virtuoso/smoke-host-surface.mjs | 11 ++-- .../skills/run-virtuoso/smoke-renderers.mjs | 30 +++++++---- .claude/skills/run-virtuoso/smoke-strings.mjs | 2 +- plugin.json | 2 +- screen.html | 53 ++++++++++++++----- screen.js | 26 +++++---- 12 files changed, 113 insertions(+), 55 deletions(-) diff --git a/.claude/skills/run-virtuoso/driver.mjs b/.claude/skills/run-virtuoso/driver.mjs index 8a6d06a..0f87f01 100644 --- a/.claude/skills/run-virtuoso/driver.mjs +++ b/.claude/skills/run-virtuoso/driver.mjs @@ -26,7 +26,7 @@ import { fileURLToPath } from "node:url"; const HOST = process.env.SLOPSMITH_HOST || "http://127.0.0.1:8765"; const SHOT_DIR = process.env.SHOT_DIR || resolve(dirname(fileURLToPath(import.meta.url)), "../../../.virtuoso-shots"); -const RENDERERS = ["highway_3d", "builtin_2d", "tab_2d", "notation_2d"]; +const RENDERERS = ["highway_3d", "highway_2d", "builtin_2d", "tab_2d", "notation_2d"]; async function ensureHost() { const r = await fetch(`${HOST}/api/plugins/virtuoso/status`).catch(() => null); @@ -64,7 +64,7 @@ async function gotoVirtuoso(page) { await page.waitForSelector("#virtuoso-root", { state: "attached", timeout: 10_000 }); // The bootstrap script in screen.html runs bind() on DOMContentLoaded; give // it a moment to wire up everything (pathway dropdown, view-switcher). - await page.waitForSelector(".virtuoso-view-btn", { timeout: 5_000 }); + await page.waitForSelector("#virtuoso-view-select", { timeout: 5_000 }); } async function generate(page) { @@ -93,9 +93,16 @@ async function generate(page) { } async function switchRenderer(page, kind) { - const btn = await page.$(`.virtuoso-view-btn[data-renderer="${kind}"]`); - if (!btn) throw new Error(`Renderer button not found: ${kind}`); - await btn.click(); + // View selector is a dropdown (v0.1.13): set the value + dispatch change, + // the same event path a user pick takes (change → onViewSwitch). + const ok = await page.evaluate((k) => { + const sel = document.querySelector("#virtuoso-view-select"); + if (!sel || ![...sel.options].some((o) => o.value === k)) return false; + sel.value = k; + sel.dispatchEvent(new Event("change", { bubbles: true })); + return true; + }, kind); + if (!ok) throw new Error(`Renderer option not found: ${kind}`); await page.waitForTimeout(600); // attachRenderer is async; let it settle } diff --git a/.claude/skills/run-virtuoso/smoke-backing-engine.mjs b/.claude/skills/run-virtuoso/smoke-backing-engine.mjs index 581eeb4..22b048e 100644 --- a/.claude/skills/run-virtuoso/smoke-backing-engine.mjs +++ b/.claude/skills/run-virtuoso/smoke-backing-engine.mjs @@ -57,7 +57,7 @@ try { catch (e) { if (i >= 2) throw e; await page.waitForTimeout(1500); } } await page.waitForSelector("#virtuoso-root", { state: "attached" }); - await page.waitForSelector(".virtuoso-view-btn"); + await page.waitForSelector("#virtuoso-view-select"); await page.waitForFunction(() => window.Virtuoso && typeof window.Virtuoso.generateExercise === "function" && globalThis.__ss_debug); await page.waitForTimeout(600); // let the screen's boot settle (first-load race) diff --git a/.claude/skills/run-virtuoso/smoke-connect.mjs b/.claude/skills/run-virtuoso/smoke-connect.mjs index 90002f6..86cb068 100644 --- a/.claude/skills/run-virtuoso/smoke-connect.mjs +++ b/.claude/skills/run-virtuoso/smoke-connect.mjs @@ -27,7 +27,7 @@ try { await page.waitForFunction(() => typeof window.showScreen === "function"); await page.evaluate(() => window.showScreen("plugin-virtuoso")); await page.waitForSelector("#virtuoso-root", { state: "attached" }); - await page.waitForSelector(".virtuoso-view-btn"); + await page.waitForSelector("#virtuoso-view-select"); await page.waitForFunction(() => window.Virtuoso && typeof window.Virtuoso.generateExercise === "function", { timeout: 10000 }); const run = (form) => page.evaluate((f) => { diff --git a/.claude/skills/run-virtuoso/smoke-core-purity.mjs b/.claude/skills/run-virtuoso/smoke-core-purity.mjs index 68e591a..e4b68be 100644 --- a/.claude/skills/run-virtuoso/smoke-core-purity.mjs +++ b/.claude/skills/run-virtuoso/smoke-core-purity.mjs @@ -38,7 +38,7 @@ async function gotoVirtuoso(page) { await page.waitForFunction(() => typeof window.showScreen === "function", { timeout: 5_000 }); await page.evaluate(() => window.showScreen("plugin-virtuoso")); await page.waitForSelector("#virtuoso-root", { state: "attached", timeout: 10_000 }); - await page.waitForSelector(".virtuoso-view-btn", { timeout: 5_000 }); + await page.waitForSelector("#virtuoso-view-select", { timeout: 5_000 }); await page.waitForFunction(() => window.Virtuoso && typeof window.Virtuoso.generateExercise === "function", { timeout: 5_000 }); } diff --git a/.claude/skills/run-virtuoso/smoke-generators.mjs b/.claude/skills/run-virtuoso/smoke-generators.mjs index 0230e65..2935c1e 100644 --- a/.claude/skills/run-virtuoso/smoke-generators.mjs +++ b/.claude/skills/run-virtuoso/smoke-generators.mjs @@ -45,7 +45,7 @@ async function gotoVirtuoso(page) { await page.waitForFunction(() => typeof window.showScreen === "function", { timeout: 5_000 }); await page.evaluate(() => window.showScreen("plugin-virtuoso")); await page.waitForSelector("#virtuoso-root", { state: "attached", timeout: 10_000 }); - await page.waitForSelector(".virtuoso-view-btn", { timeout: 5_000 }); + await page.waitForSelector("#virtuoso-view-select", { timeout: 5_000 }); await page.waitForFunction(() => window.Virtuoso && typeof window.Virtuoso.generateExercise === "function", { timeout: 5_000 }); } diff --git a/.claude/skills/run-virtuoso/smoke-highway-settings.mjs b/.claude/skills/run-virtuoso/smoke-highway-settings.mjs index f112534..2e44329 100644 --- a/.claude/skills/run-virtuoso/smoke-highway-settings.mjs +++ b/.claude/skills/run-virtuoso/smoke-highway-settings.mjs @@ -46,11 +46,17 @@ async function main() { await page.waitForFunction(() => typeof window.showScreen === "function", { timeout: 5000 }); await page.evaluate(() => window.showScreen("plugin-virtuoso")); await page.waitForSelector("#virtuoso-root", { state: "attached", timeout: 10000 }); - await page.waitForSelector(".virtuoso-view-btn", { timeout: 5000 }); + await page.waitForSelector("#virtuoso-view-select", { timeout: 5000 }); - // Ensure the 3D highway is the active view, then settle. - const hwBtn = await page.$('.virtuoso-view-btn[data-renderer="highway_3d"]'); - if (hwBtn) { await hwBtn.click(); await page.waitForTimeout(600); } + // Ensure the 3D highway is the active view, then settle. (View selector is + // a dropdown since v0.1.13 — value + change, the user event path.) + const setView = (k) => page.evaluate((kind) => { + const sel = document.querySelector("#virtuoso-view-select"); + if (!sel) return; + sel.value = kind; + sel.dispatchEvent(new Event("change", { bubbles: true })); + }, k); + await setView("highway_3d"); await page.waitForTimeout(600); // Baseline snapshot AFTER the highway has attached once. const before = await page.evaluate(snapshotH3dBg); @@ -69,9 +75,8 @@ async function main() { // Cycle through the other renderers and back to the highway (attach/detach // churn is the most likely place a stray settings-write could hide). - for (const kind of ["builtin_2d", "tab_2d", "notation_2d", "highway_3d"]) { - const b = await page.$(`.virtuoso-view-btn[data-renderer="${kind}"]`); - if (b) { await b.click(); await page.waitForTimeout(400); } + for (const kind of ["builtin_2d", "highway_2d", "tab_2d", "notation_2d", "highway_3d"]) { + await setView(kind); await page.waitForTimeout(400); } const after = await page.evaluate(snapshotH3dBg); diff --git a/.claude/skills/run-virtuoso/smoke-host-surface.mjs b/.claude/skills/run-virtuoso/smoke-host-surface.mjs index 512736d..5b5002d 100644 --- a/.claude/skills/run-virtuoso/smoke-host-surface.mjs +++ b/.claude/skills/run-virtuoso/smoke-host-surface.mjs @@ -59,7 +59,7 @@ async function gotoVirtuoso(page) { await page.evaluate(() => window.showScreen("plugin-virtuoso")); await page.evaluate(() => document.getElementById("v3-onboarding")?.remove()).catch(() => {}); await page.waitForSelector("#virtuoso-root", { state: "attached", timeout: 10_000 }); - await page.waitForSelector(".virtuoso-view-btn", { timeout: 5_000 }); + await page.waitForSelector("#virtuoso-view-select", { timeout: 5_000 }); } async function generate(page) { @@ -110,8 +110,13 @@ async function run() { // Switch to highway_3d, then require the factory global registered AND the // renderer actually attached (status != "...(fallback)"). A silent 2D // fallback (the v0.3.0 feedBackViz_* rename) fails here. - const hwBtn = await page.$('.virtuoso-view-btn[data-renderer="highway_3d"]'); - if (hwBtn) { await hwBtn.click(); await page.waitForTimeout(900); } + await page.evaluate(() => { + const sel = document.querySelector("#virtuoso-view-select"); + if (!sel) return; + sel.value = "highway_3d"; + sel.dispatchEvent(new Event("change", { bubbles: true })); + }); + await page.waitForTimeout(900); const hw = await page.evaluate(() => { const f = window.feedBackViz_highway_3d || window.slopsmithViz_highway_3d; const status = (document.getElementById("virtuoso-renderer-status")?.textContent || "").trim(); diff --git a/.claude/skills/run-virtuoso/smoke-renderers.mjs b/.claude/skills/run-virtuoso/smoke-renderers.mjs index 04ae354..d7c6991 100644 --- a/.claude/skills/run-virtuoso/smoke-renderers.mjs +++ b/.claude/skills/run-virtuoso/smoke-renderers.mjs @@ -6,7 +6,7 @@ // so it can gate a refactor of screen.js without eyeballing PNGs. // // Per renderer it asserts: -// 1. view switch took — the clicked .virtuoso-view-btn is .active +// 1. view switch took — #virtuoso-view-select reflects the kind // 2. renderer attached — #virtuoso-renderer-status is non-empty // 3. a render surface exists — a sized, visible in .virtuoso-render-host // 4. it actually drew — non-uniform pixels (enforced only for the @@ -30,11 +30,14 @@ import { fileURLToPath } from "node:url"; const HOST = process.env.SLOPSMITH_HOST || "http://127.0.0.1:8765"; const SHOT_DIR = process.env.SHOT_DIR || resolve(dirname(fileURLToPath(import.meta.url)), "../../../.virtuoso-shots"); -// kind matches data-renderer on the view buttons. enforcePixels is true only -// where Virtuoso draws into the in-tree #virtuoso-canvas with a 2D context; +// kind matches the #virtuoso-view-select option values. enforcePixels is true +// only where Virtuoso draws into the in-tree #virtuoso-canvas with a 2D context; // highway_3d and builtin_2d borrow host viz that mount their own canvas. +// highway_2d = the in-tree 2D highway, first-class since the view dropdown +// (v0.1.13; previously reachable only as the fallback slot). const RENDERERS = [ { kind: "highway_3d", enforcePixels: false }, + { kind: "highway_2d", enforcePixels: true }, { kind: "builtin_2d", enforcePixels: false }, { kind: "tab_2d", enforcePixels: true }, { kind: "notation_2d", enforcePixels: true }, @@ -72,7 +75,7 @@ async function gotoVirtuoso(page) { await page.waitForFunction(() => typeof window.showScreen === "function", { timeout: 5_000 }); await page.evaluate(() => window.showScreen("plugin-virtuoso")); await page.waitForSelector("#virtuoso-root", { state: "attached", timeout: 10_000 }); - await page.waitForSelector(".virtuoso-view-btn", { timeout: 5_000 }); + await page.waitForSelector("#virtuoso-view-select", { timeout: 5_000 }); } // Trigger a generate. Pathway mode (default) has no Regenerate button — a @@ -92,9 +95,16 @@ async function generate(page) { } async function switchRenderer(page, kind) { - const btn = await page.$(`.virtuoso-view-btn[data-renderer="${kind}"]`); - if (!btn) throw new Error(`Renderer button not found: ${kind}`); - await btn.click(); + // View selector is a dropdown (v0.1.13): set the value + dispatch change, + // the same event path a user pick takes (change → onViewSwitch). + const ok = await page.evaluate((k) => { + const sel = document.querySelector("#virtuoso-view-select"); + if (!sel || ![...sel.options].some((o) => o.value === k)) return false; + sel.value = k; + sel.dispatchEvent(new Event("change", { bubbles: true })); + return true; + }, kind); + if (!ok) throw new Error(`Renderer option not found: ${kind}`); await page.waitForTimeout(700); // attachRenderer is async (may lazy-load host viz) } @@ -190,9 +200,9 @@ async function run() { await generate(page); const active = await page - .$eval(`.virtuoso-view-btn[data-renderer="${r.kind}"]`, (b) => b.classList.contains("active")) - .catch(() => false); - if (!active) fails.push("view button not active after click"); + .$eval("#virtuoso-view-select", (s) => s.value) + .catch(() => ""); + if (active !== r.kind) fails.push(`view select shows "${active}" after switch`); const status = (await page.$eval("#virtuoso-renderer-status", (e) => e.textContent.trim()).catch(() => "")) || ""; if (!status) fails.push("renderer-status label empty"); diff --git a/.claude/skills/run-virtuoso/smoke-strings.mjs b/.claude/skills/run-virtuoso/smoke-strings.mjs index cfddcff..a6dd686 100644 --- a/.claude/skills/run-virtuoso/smoke-strings.mjs +++ b/.claude/skills/run-virtuoso/smoke-strings.mjs @@ -32,7 +32,7 @@ try { await page.waitForFunction(() => typeof window.showScreen === "function"); await page.evaluate(() => window.showScreen("plugin-virtuoso")); await page.waitForSelector("#virtuoso-root", { state: "attached" }); - await page.waitForSelector(".virtuoso-view-btn"); + await page.waitForSelector("#virtuoso-view-select"); await page.waitForFunction(() => window.Virtuoso && typeof window.Virtuoso.generateExercise === "function", { timeout: 10000 }); // Install in-page helpers that drive the real form + readConfig. diff --git a/plugin.json b/plugin.json index 9d8666a..2f09f4a 100644 --- a/plugin.json +++ b/plugin.json @@ -1,7 +1,7 @@ { "id": "virtuoso", "name": "Virtuoso", - "version": "0.1.12", + "version": "0.1.13", "description": "Practice studio for guitar & bass — scale, technique, and rhythm drills, timed workouts, and jam backing that teach skills you take off the screen.", "category": "practice", "icon": "assets/thumb.png", diff --git a/screen.html b/screen.html index 7cfd9b3..18cbffa 100644 --- a/screen.html +++ b/screen.html @@ -1091,9 +1091,29 @@ active = a lit accent pill. */ .virtuoso-view-tabs { display: inline-flex; gap: 2px; padding: 3px; background: var(--vir-input); border: 1px solid var(--vir-hairline); border-radius: 9px; box-shadow: var(--vir-recess); } .virtuoso-view-ico { font-size: 13px; line-height: 1; opacity: 0.9; } + /* View dropdown (replaced the segmented tabs once 2D Highway joined the + lineup — 5 stringed views + Piano Roll outgrew a pill row). Same control + family as the mode/settings selects; the accent border marks it as the + stage's primary switch. */ + .virtuoso-view-select { + min-height: 28px; + padding: 0 26px 0 10px; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.02em; + border-radius: 7px; + border: 1px solid var(--vir-border, #334155); + background: var(--vir-inset, #0b1220); + color: var(--vir-text, #e2e8f0); + cursor: pointer; + } + .virtuoso-view-select:hover { background: var(--vir-hover-soft); } + .virtuoso-view-select:focus-visible { outline: 2px solid var(--vir-accent, #4080e0); outline-offset: 1px; } /* Tabs read as tabs: unselected is a subtle outlined chip; selected gets a saturated background, a bottom highlight, and a slight lift so the - active view is unmistakable at a glance. */ + active view is unmistakable at a glance. + (RETIRED with the view dropdown — kept only for the local shot-*.mjs + scripts + skin overrides that still reference the class; no live markup.) */ .virtuoso-view-btn { position: relative; display: inline-flex; @@ -1754,10 +1774,11 @@ the selected instrument; when Piano is active, the guitar/bass-oriented 3D Highway and Jumping Tab views are hidden. (Piano Roll stays disabled until piano playback ships — groundwork only.) */ - .virtuoso-view-btn[data-renderer="piano_roll"] { display: none; } - .virtuoso-root.virtuoso-piano-instrument .virtuoso-view-btn[data-renderer="piano_roll"] { display: inline-flex; align-items: center; } - .virtuoso-root.virtuoso-piano-instrument .virtuoso-view-btn[data-renderer="highway_3d"], - .virtuoso-root.virtuoso-piano-instrument .virtuoso-view-btn[data-renderer="builtin_2d"] { display: none; } + .virtuoso-view-select option[value="piano_roll"] { display: none; } + .virtuoso-root.virtuoso-piano-instrument .virtuoso-view-select option[value="piano_roll"] { display: block; } + .virtuoso-root.virtuoso-piano-instrument .virtuoso-view-select option[value="highway_3d"], + .virtuoso-root.virtuoso-piano-instrument .virtuoso-view-select option[value="highway_2d"], + .virtuoso-root.virtuoso-piano-instrument .virtuoso-view-select option[value="builtin_2d"] { display: none; } .virtuoso-tuning-label { flex: 0 0 56px; font-size: 10px; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; color: #94a3b8; } .virtuoso-string-count-row { display: flex; gap: 4px; flex: 1; } .virtuoso-string-count-btn { @@ -3263,14 +3284,20 @@

Virtuoso

View - -
- - - - -
+ + diff --git a/screen.js b/screen.js index ce4b980..3f0a1ab 100644 --- a/screen.js +++ b/screen.js @@ -48,7 +48,7 @@ // a plugin's own version into its screen (note_detect hardcodes `_ND_VERSION` // the same way), so this is the display mirror of plugin.json's "version". // BUMP THIS WHENEVER plugin.json's version changes (release checklist). - const VIRTUOSO_VERSION = '0.1.12'; + const VIRTUOSO_VERSION = '0.1.13'; // =========================================================================== // §1 · CONSTANTS & MUSIC-THEORY DATA @@ -13816,6 +13816,10 @@ const f = await borrowHostViz('jumpingtab', '/api/plugins/jumpingtab/screen.js'); return f ? { factory:f, label:'Jumping Tab' } : { factory:makeBuiltin2DRenderer, label:'2D Highway (fallback)' }; } + // The in-tree 2D highway, DIRECTLY selectable (view dropdown, 2026-07-10). + // Historically only the builtin_2d/highway_3d fallback; 'highway_2d' is its + // first-class slot. No host borrow — always available, any string count. + if (kind === 'highway_2d') return { factory:makeBuiltin2DRenderer, label:'2D Highway' }; if (kind === 'tab_2d') return { factory:makeBuiltin2DTabRenderer, label:'Tab' }; if (kind === 'notation_2d') return { factory:makeBuiltin2DNotationRenderer, label:'Notation' }; // Piano Roll — groundwork; borrows the host Piano Highway viz. Reachable @@ -24938,19 +24942,21 @@ // ── End Session UI ────────────────────────────────────────────────────────── function syncViewSwitcher(kind) { - document.querySelectorAll('.virtuoso-view-btn').forEach(btn => { - btn.classList.toggle('active', btn.dataset.renderer === kind); - }); + // The view dropdown mirrors what's ACTUALLY rendered (saved-pref restore, + // the >8-string force in attachRenderer) — value-set only, no change event, + // so syncing never re-triggers onViewSwitch. + const viewSel = $('virtuoso-view-select'); + if (viewSel && viewSel.value !== kind) viewSel.value = kind; // Theme toggle is only meaningful for the themed renderers (Tab, // Notation); the highway renderers have their own visual identity. const root = $('virtuoso-root'); if (root) { root.classList.toggle('virtuoso-theme-renderer', kind === 'tab_2d' || kind === 'notation_2d'); // Fretboard strip is offered on every stringed-instrument view — 3D - // Highway, Jumping Tab, Tab, and Notation; the user can toggle it off. - // (Always hidden for the Piano instrument via CSS.) + // Highway, 2D Highway, Jumping Tab, Tab, and Notation; the user can + // toggle it off. (Always hidden for the Piano instrument via CSS.) root.classList.toggle('virtuoso-fb-capable', - kind === 'highway_3d' || kind === 'builtin_2d' || kind === 'tab_2d' || kind === 'notation_2d'); + kind === 'highway_3d' || kind === 'highway_2d' || kind === 'builtin_2d' || kind === 'tab_2d' || kind === 'notation_2d'); // The HUD title only shows for 3D Highway — every other renderer draws // the exercise name in-canvas itself, so showing it here too would double. root.classList.toggle('virtuoso-hud-title-on', kind === 'highway_3d'); @@ -25516,10 +25522,8 @@ document.querySelectorAll('.virtuoso-practice-pill input').forEach(inp => { inp.addEventListener('change', () => { writeShareHash(); if (activeBundle) onGenerate(); }); }); - // View switcher tabs in the render stage — independent of exercise mode - document.querySelectorAll('.virtuoso-view-btn').forEach(btn => { - btn.addEventListener('click', () => onViewSwitch(btn.dataset.renderer)); - }); + // View dropdown in the render stage — independent of exercise mode + $('virtuoso-view-select')?.addEventListener('change', (e) => onViewSwitch(e.target.value)); // Hand-marks pill (hand-marks Slice 1): default ON, persisted, never // auto-flipped. Renderers + the strip read handMarksOn() per frame, so a // flip shows on the next draw with no re-attach. From af8fed8d38c48451e6a264b01f8194bc719a1991 Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou <16130099+ChrisBeWithYou@users.noreply.github.com> Date: Fri, 10 Jul 2026 09:40:12 -0500 Subject: [PATCH 06/18] feat(ui): highway_2d slot borrows the host Classic 2D Highway when available Clarified target: the 2D Highway view means Byron's Classic 2D Highway (host core), not Jumping Tab and not merely our in-tree renderer. HOST CHECK (2026-07-10): it is createHighway()'s private _defaultRenderer (static/highway.js), picker id 'default' (RESERVED), no feedBackViz factory, closure-fed + websocket-fed (no chart setter) -> NOT borrowable today; verdict MIRROR, flips to BORROW when the host ships a bundle-driven feedBackViz_highway_2d factory (asked in got-feedBack/feedBack#835). Wiring is borrow-FIRST via a factory-global probe only (vizFactoryFor - no speculative script fetch; the URL doesn't exist yet and a blind 404 would trip the smoke console-guards). The moment the host registers the factory, every install upgrades to the real Classic 2D Highway with zero plugin changes; until then the in-tree 2D highway renders the slot. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01MS2YFb6UUSwJVV6CmEa25i --- screen.js | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/screen.js b/screen.js index 3f0a1ab..4765686 100644 --- a/screen.js +++ b/screen.js @@ -13816,10 +13816,21 @@ const f = await borrowHostViz('jumpingtab', '/api/plugins/jumpingtab/screen.js'); return f ? { factory:f, label:'Jumping Tab' } : { factory:makeBuiltin2DRenderer, label:'2D Highway (fallback)' }; } - // The in-tree 2D highway, DIRECTLY selectable (view dropdown, 2026-07-10). - // Historically only the builtin_2d/highway_3d fallback; 'highway_2d' is its - // first-class slot. No host borrow — always available, any string count. - if (kind === 'highway_2d') return { factory:makeBuiltin2DRenderer, label:'2D Highway' }; + // 'highway_2d' = the 2D Highway slot (view dropdown, 2026-07-10). The + // TARGET is the host's "Classic 2D Highway" (Byron's), but that renderer is + // host-CORE, not borrowable today — HOST CHECK 2026-07-10: it's the private + // _defaultRenderer inside createHighway() (static/highway.js), picker id + // 'default' (RESERVED), no feedBackViz factory, closure-fed (no bundle) and + // websocket-fed (no chart setter). Verdict MIRROR; flips to BORROW when the + // host ships a bundle-driven feedBackViz_highway_2d factory (asked via host + // issue). Borrow-FIRST probe (factory global only — no speculative script + // fetch, its URL doesn't exist yet and a blind 404 trips the smoke + // console-guards): the moment the host registers the factory this slot + // upgrades itself; until then the in-tree 2D highway renders. + if (kind === 'highway_2d') { + const f = vizFactoryFor('highway_2d'); + return f ? { factory:f, label:'2D Highway (host)' } : { factory:makeBuiltin2DRenderer, label:'2D Highway' }; + } if (kind === 'tab_2d') return { factory:makeBuiltin2DTabRenderer, label:'Tab' }; if (kind === 'notation_2d') return { factory:makeBuiltin2DNotationRenderer, label:'Notation' }; // Piano Roll — groundwork; borrows the host Piano Highway viz. Reachable From a5d04eca6f37e03bdb25e508e4027853849b45e6 Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou <16130099+ChrisBeWithYou@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:28:55 -0500 Subject: [PATCH 07/18] test(smoke): seed the L1 instrument store per suite (host-sync contamination) The v0.1.11 host-settings sync treats an empty localStorage as a fresh install and ADOPTS host config on boot. Smoke pages always boot with empty localStorage, and any suite whose panel drives dispatch real change events WRITES THROUGH to the persistent host config - so a later suite (or a later run) could boot into whatever instrument the previous one left (the panel flipped to bass mid-suite; smoke-strings rows 3/4 went red, and this is the likely mechanism behind the contained-verifier/level-gate-async flakes under the parallel run-all). Fix: every suite (and driver.mjs) seeds localStorage['virtuoso.instrument'] with the 6-string-standard default via addInitScript BEFORE page scripts run. The boot reconcile then takes the local-wins path - deterministic panel state regardless of suite ordering, and each boot heals the host config for the suites after it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01MS2YFb6UUSwJVV6CmEa25i --- .claude/skills/run-virtuoso/driver.mjs | 7 ++++ .../skills/run-virtuoso/smoke-audioctx.mjs | 7 ++++ .../run-virtuoso/smoke-backing-engine.mjs | 7 ++++ .claude/skills/run-virtuoso/smoke-connect.mjs | 7 ++++ .../run-virtuoso/smoke-contained-verifier.mjs | 7 ++++ .../skills/run-virtuoso/smoke-core-purity.mjs | 7 ++++ .claude/skills/run-virtuoso/smoke-gems.mjs | 7 ++++ .../skills/run-virtuoso/smoke-generators.mjs | 7 ++++ .claude/skills/run-virtuoso/smoke-herta.mjs | 7 ++++ .../run-virtuoso/smoke-highway-settings.mjs | 7 ++++ .../run-virtuoso/smoke-host-surface.mjs | 7 ++++ .../run-virtuoso/smoke-level-gate-async.mjs | 7 ++++ .../run-virtuoso/smoke-meter-subdiv.mjs | 7 ++++ .../run-virtuoso/smoke-over-barline.mjs | 7 ++++ .../skills/run-virtuoso/smoke-progress.mjs | 7 ++++ .../skills/run-virtuoso/smoke-renderers.mjs | 14 +++++++ .../skills/run-virtuoso/smoke-scoring-e2e.mjs | 7 ++++ .../run-virtuoso/smoke-session-sync.mjs | 7 ++++ .claude/skills/run-virtuoso/smoke-strings.mjs | 39 +++++++++++++++++++ .../skills/run-virtuoso/smoke-variation.mjs | 7 ++++ 20 files changed, 179 insertions(+) diff --git a/.claude/skills/run-virtuoso/driver.mjs b/.claude/skills/run-virtuoso/driver.mjs index 0f87f01..5956f6d 100644 --- a/.claude/skills/run-virtuoso/driver.mjs +++ b/.claude/skills/run-virtuoso/driver.mjs @@ -118,6 +118,13 @@ async function withBrowser(fn) { try { const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); const page = await ctx.newPage(); + // Seed the L1 instrument store BEFORE page scripts run: the host-settings + // sync (v0.1.11) treats an empty localStorage as a fresh install and ADOPTS + // host config — which persists whatever instrument the PREVIOUS suite's panel + // drives wrote through (cross-suite contamination; the panel flipped to bass + // mid-suite). With the store seeded, the local-wins boot path holds the + // deterministic 6-string default AND heals the host config for later suites. + await page.addInitScript(() => { try { localStorage.setItem("virtuoso.instrument", JSON.stringify({ stringSetup: "guitar_6_standard", customOpenMidis: "" })); } catch (_) {} }); page.on("pageerror", (e) => console.error("[page error]", e.message)); page.on("console", (m) => { const t = m.type(); diff --git a/.claude/skills/run-virtuoso/smoke-audioctx.mjs b/.claude/skills/run-virtuoso/smoke-audioctx.mjs index 0265b0d..b19ba69 100644 --- a/.claude/skills/run-virtuoso/smoke-audioctx.mjs +++ b/.claude/skills/run-virtuoso/smoke-audioctx.mjs @@ -43,6 +43,13 @@ try { const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); await ctx.addInitScript(() => { globalThis.__SS_HARNESS__ = true; }); // sink rows read __ss_debug const page = await ctx.newPage(); + // Seed the L1 instrument store BEFORE page scripts run: the host-settings + // sync (v0.1.11) treats an empty localStorage as a fresh install and ADOPTS + // host config — which persists whatever instrument the PREVIOUS suite's panel + // drives wrote through (cross-suite contamination; the panel flipped to bass + // mid-suite). With the store seeded, the local-wins boot path holds the + // deterministic 6-string default AND heals the host config for later suites. + await page.addInitScript(() => { try { localStorage.setItem("virtuoso.instrument", JSON.stringify({ stringSetup: "guitar_6_standard", customOpenMidis: "" })); } catch (_) {} }); page.on("pageerror", e => console.error("[page error]", e.message)); await page.goto(`${HOST}/`, { waitUntil: "domcontentloaded" }); await page.waitForSelector("#plugin-virtuoso", { state: "attached", timeout: 20000 }); diff --git a/.claude/skills/run-virtuoso/smoke-backing-engine.mjs b/.claude/skills/run-virtuoso/smoke-backing-engine.mjs index 22b048e..3a37dfc 100644 --- a/.claude/skills/run-virtuoso/smoke-backing-engine.mjs +++ b/.claude/skills/run-virtuoso/smoke-backing-engine.mjs @@ -46,6 +46,13 @@ try { try { new PerformanceObserver((l) => l.getEntries().forEach((e) => window.__longTasks.push(Math.round(e.duration)))).observe({ entryTypes: ["longtask"] }); } catch {} }); const page = await ctx.newPage(); + // Seed the L1 instrument store BEFORE page scripts run: the host-settings + // sync (v0.1.11) treats an empty localStorage as a fresh install and ADOPTS + // host config — which persists whatever instrument the PREVIOUS suite's panel + // drives wrote through (cross-suite contamination; the panel flipped to bass + // mid-suite). With the store seeded, the local-wins boot path holds the + // deterministic 6-string default AND heals the host config for later suites. + await page.addInitScript(() => { try { localStorage.setItem("virtuoso.instrument", JSON.stringify({ stringSetup: "guitar_6_standard", customOpenMidis: "" })); } catch (_) {} }); const errs = []; page.on("pageerror", (e) => errs.push(e.message)); await page.goto(`${HOST}/`, { waitUntil: "domcontentloaded" }); await page.waitForSelector("#plugin-virtuoso", { state: "attached", timeout: 20000 }); diff --git a/.claude/skills/run-virtuoso/smoke-connect.mjs b/.claude/skills/run-virtuoso/smoke-connect.mjs index 86cb068..678fca9 100644 --- a/.claude/skills/run-virtuoso/smoke-connect.mjs +++ b/.claude/skills/run-virtuoso/smoke-connect.mjs @@ -20,6 +20,13 @@ try { const r = await fetch(`${HOST}/api/plugins/virtuoso/status`).catch(() => null); if (!r || !r.ok) throw new Error(`Host not reachable at ${HOST}. launch.ps1 first.`); const page = await (await browser.newContext({ viewport: { width: 1440, height: 900 } })).newPage(); + // Seed the L1 instrument store BEFORE page scripts run: the host-settings + // sync (v0.1.11) treats an empty localStorage as a fresh install and ADOPTS + // host config — which persists whatever instrument the PREVIOUS suite's panel + // drives wrote through (cross-suite contamination; the panel flipped to bass + // mid-suite). With the store seeded, the local-wins boot path holds the + // deterministic 6-string default AND heals the host config for later suites. + await page.addInitScript(() => { try { localStorage.setItem("virtuoso.instrument", JSON.stringify({ stringSetup: "guitar_6_standard", customOpenMidis: "" })); } catch (_) {} }); const pageErrs = []; page.on("pageerror", e => pageErrs.push(e.message)); await page.goto(`${HOST}/`, { waitUntil: "domcontentloaded" }); diff --git a/.claude/skills/run-virtuoso/smoke-contained-verifier.mjs b/.claude/skills/run-virtuoso/smoke-contained-verifier.mjs index afa9750..b539efc 100644 --- a/.claude/skills/run-virtuoso/smoke-contained-verifier.mjs +++ b/.claude/skills/run-virtuoso/smoke-contained-verifier.mjs @@ -82,6 +82,13 @@ try { try { if (navigator.mediaDevices) navigator.mediaDevices.getUserMedia = () => Promise.reject(new Error("smoke-contained: no mic by design")); } catch (_) {} }); const page = await ctx.newPage(); + // Seed the L1 instrument store BEFORE page scripts run: the host-settings + // sync (v0.1.11) treats an empty localStorage as a fresh install and ADOPTS + // host config — which persists whatever instrument the PREVIOUS suite's panel + // drives wrote through (cross-suite contamination; the panel flipped to bass + // mid-suite). With the store seeded, the local-wins boot path holds the + // deterministic 6-string default AND heals the host config for later suites. + await page.addInitScript(() => { try { localStorage.setItem("virtuoso.instrument", JSON.stringify({ stringSetup: "guitar_6_standard", customOpenMidis: "" })); } catch (_) {} }); const errs = []; page.on("pageerror", (e) => { if (!isBenign(e.message)) errs.push(e.message); }); await page.goto(`${HOST}/`, { waitUntil: "domcontentloaded" }); diff --git a/.claude/skills/run-virtuoso/smoke-core-purity.mjs b/.claude/skills/run-virtuoso/smoke-core-purity.mjs index e4b68be..ae02819 100644 --- a/.claude/skills/run-virtuoso/smoke-core-purity.mjs +++ b/.claude/skills/run-virtuoso/smoke-core-purity.mjs @@ -109,6 +109,13 @@ async function run() { try { const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); const page = await ctx.newPage(); + // Seed the L1 instrument store BEFORE page scripts run: the host-settings + // sync (v0.1.11) treats an empty localStorage as a fresh install and ADOPTS + // host config — which persists whatever instrument the PREVIOUS suite's panel + // drives wrote through (cross-suite contamination; the panel flipped to bass + // mid-suite). With the store seeded, the local-wins boot path holds the + // deterministic 6-string default AND heals the host config for later suites. + await page.addInitScript(() => { try { localStorage.setItem("virtuoso.instrument", JSON.stringify({ stringSetup: "guitar_6_standard", customOpenMidis: "" })); } catch (_) {} }); page.on("pageerror", (e) => pageErrors.push(e.message)); await gotoVirtuoso(page); const practiceTypes = await page.$$eval('select[name="practiceType"] option', (os) => os.map((o) => o.value)); diff --git a/.claude/skills/run-virtuoso/smoke-gems.mjs b/.claude/skills/run-virtuoso/smoke-gems.mjs index 30aacab..29718a6 100644 --- a/.claude/skills/run-virtuoso/smoke-gems.mjs +++ b/.claude/skills/run-virtuoso/smoke-gems.mjs @@ -34,6 +34,13 @@ try { try { if (navigator.mediaDevices) navigator.mediaDevices.getUserMedia = () => Promise.reject(new Error("smoke-gems: no mic by design")); } catch (_) {} }); const p = await ctx.newPage(); + // Seed the L1 instrument store BEFORE page scripts run: the host-settings + // sync (v0.1.11) treats an empty localStorage as a fresh install and ADOPTS + // host config — which persists whatever instrument the PREVIOUS suite's panel + // drives wrote through (cross-suite contamination; the panel flipped to bass + // mid-suite). With the store seeded, the local-wins boot path holds the + // deterministic 6-string default AND heals the host config for later suites. + await p.addInitScript(() => { try { localStorage.setItem("virtuoso.instrument", JSON.stringify({ stringSetup: "guitar_6_standard", customOpenMidis: "" })); } catch (_) {} }); const errs = []; p.on("pageerror", e => { if (!isBenign(e.message)) errs.push(e.message); }); await p.goto(`${HOST}/`, { waitUntil: "domcontentloaded" }); diff --git a/.claude/skills/run-virtuoso/smoke-generators.mjs b/.claude/skills/run-virtuoso/smoke-generators.mjs index 2935c1e..fc4b9ab 100644 --- a/.claude/skills/run-virtuoso/smoke-generators.mjs +++ b/.claude/skills/run-virtuoso/smoke-generators.mjs @@ -104,6 +104,13 @@ async function run() { try { const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); const page = await ctx.newPage(); + // Seed the L1 instrument store BEFORE page scripts run: the host-settings + // sync (v0.1.11) treats an empty localStorage as a fresh install and ADOPTS + // host config — which persists whatever instrument the PREVIOUS suite's panel + // drives wrote through (cross-suite contamination; the panel flipped to bass + // mid-suite). With the store seeded, the local-wins boot path holds the + // deterministic 6-string default AND heals the host config for later suites. + await page.addInitScript(() => { try { localStorage.setItem("virtuoso.instrument", JSON.stringify({ stringSetup: "guitar_6_standard", customOpenMidis: "" })); } catch (_) {} }); page.on("pageerror", (e) => { if (!isBenign(e.message)) pageErrors.push(e.message); }); page.on("console", (m) => { if (m.type() === "error" && !isBenign(m.text())) consoleErrors.push(m.text()); }); diff --git a/.claude/skills/run-virtuoso/smoke-herta.mjs b/.claude/skills/run-virtuoso/smoke-herta.mjs index c6d5aab..4225633 100644 --- a/.claude/skills/run-virtuoso/smoke-herta.mjs +++ b/.claude/skills/run-virtuoso/smoke-herta.mjs @@ -16,6 +16,13 @@ try { const r = await fetch(`${HOST}/api/plugins/virtuoso/status`).catch(() => null); if (!r || !r.ok) throw new Error(`Host not reachable at ${HOST}. launch.ps1 first.`); const page = await (await b.newContext({ viewport: { width: 1440, height: 900 } })).newPage(); + // Seed the L1 instrument store BEFORE page scripts run: the host-settings + // sync (v0.1.11) treats an empty localStorage as a fresh install and ADOPTS + // host config — which persists whatever instrument the PREVIOUS suite's panel + // drives wrote through (cross-suite contamination; the panel flipped to bass + // mid-suite). With the store seeded, the local-wins boot path holds the + // deterministic 6-string default AND heals the host config for later suites. + await page.addInitScript(() => { try { localStorage.setItem("virtuoso.instrument", JSON.stringify({ stringSetup: "guitar_6_standard", customOpenMidis: "" })); } catch (_) {} }); const pageErrs = []; page.on("pageerror", e => pageErrs.push(e.message)); await page.goto(`${HOST}/`, { waitUntil: "domcontentloaded" }); await page.waitForSelector("#plugin-virtuoso", { state: "attached", timeout: 20000 }); diff --git a/.claude/skills/run-virtuoso/smoke-highway-settings.mjs b/.claude/skills/run-virtuoso/smoke-highway-settings.mjs index 2e44329..b83a4bd 100644 --- a/.claude/skills/run-virtuoso/smoke-highway-settings.mjs +++ b/.claude/skills/run-virtuoso/smoke-highway-settings.mjs @@ -38,6 +38,13 @@ async function main() { try { const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); const page = await ctx.newPage(); + // Seed the L1 instrument store BEFORE page scripts run: the host-settings + // sync (v0.1.11) treats an empty localStorage as a fresh install and ADOPTS + // host config — which persists whatever instrument the PREVIOUS suite's panel + // drives wrote through (cross-suite contamination; the panel flipped to bass + // mid-suite). With the store seeded, the local-wins boot path holds the + // deterministic 6-string default AND heals the host config for later suites. + await page.addInitScript(() => { try { localStorage.setItem("virtuoso.instrument", JSON.stringify({ stringSetup: "guitar_6_standard", customOpenMidis: "" })); } catch (_) {} }); page.on("pageerror", (e) => failures.push(`pageerror: ${e.message}`)); // Load + activate Virtuoso. diff --git a/.claude/skills/run-virtuoso/smoke-host-surface.mjs b/.claude/skills/run-virtuoso/smoke-host-surface.mjs index 5b5002d..db7fecd 100644 --- a/.claude/skills/run-virtuoso/smoke-host-surface.mjs +++ b/.claude/skills/run-virtuoso/smoke-host-surface.mjs @@ -79,6 +79,13 @@ async function run() { try { const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); const page = await ctx.newPage(); + // Seed the L1 instrument store BEFORE page scripts run: the host-settings + // sync (v0.1.11) treats an empty localStorage as a fresh install and ADOPTS + // host config — which persists whatever instrument the PREVIOUS suite's panel + // drives wrote through (cross-suite contamination; the panel flipped to bass + // mid-suite). With the store seeded, the local-wins boot path holds the + // deterministic 6-string default AND heals the host config for later suites. + await page.addInitScript(() => { try { localStorage.setItem("virtuoso.instrument", JSON.stringify({ stringSetup: "guitar_6_standard", customOpenMidis: "" })); } catch (_) {} }); page.on("console", (m) => { if (m.type() === "error" && !isBenign(m.text())) consoleErrors.push(m.text()); }); page.on("pageerror", (e) => { if (!isBenign(e.message)) consoleErrors.push(`pageerror: ${e.message}`); }); diff --git a/.claude/skills/run-virtuoso/smoke-level-gate-async.mjs b/.claude/skills/run-virtuoso/smoke-level-gate-async.mjs index 3f04be6..5c76665 100644 --- a/.claude/skills/run-virtuoso/smoke-level-gate-async.mjs +++ b/.claude/skills/run-virtuoso/smoke-level-gate-async.mjs @@ -69,6 +69,13 @@ try { try { if (navigator.mediaDevices) navigator.mediaDevices.getUserMedia = () => Promise.reject(new Error("smoke-level-gate-async: no mic by design")); } catch (_) {} }); const page = await ctx.newPage(); + // Seed the L1 instrument store BEFORE page scripts run: the host-settings + // sync (v0.1.11) treats an empty localStorage as a fresh install and ADOPTS + // host config — which persists whatever instrument the PREVIOUS suite's panel + // drives wrote through (cross-suite contamination; the panel flipped to bass + // mid-suite). With the store seeded, the local-wins boot path holds the + // deterministic 6-string default AND heals the host config for later suites. + await page.addInitScript(() => { try { localStorage.setItem("virtuoso.instrument", JSON.stringify({ stringSetup: "guitar_6_standard", customOpenMidis: "" })); } catch (_) {} }); const errs = []; page.on("pageerror", (e) => { if (!isBenign(e.message)) errs.push(e.message); }); await page.goto(`${HOST}/`, { waitUntil: "domcontentloaded" }); diff --git a/.claude/skills/run-virtuoso/smoke-meter-subdiv.mjs b/.claude/skills/run-virtuoso/smoke-meter-subdiv.mjs index ed2c27c..26fbca1 100644 --- a/.claude/skills/run-virtuoso/smoke-meter-subdiv.mjs +++ b/.claude/skills/run-virtuoso/smoke-meter-subdiv.mjs @@ -18,6 +18,13 @@ try { const r = await fetch(`${HOST}/api/plugins/virtuoso/status`).catch(() => null); if (!r || !r.ok) throw new Error(`Host not reachable at ${HOST}. launch.ps1 first.`); const page = await (await browser.newContext({ viewport: { width: 1440, height: 900 } })).newPage(); + // Seed the L1 instrument store BEFORE page scripts run: the host-settings + // sync (v0.1.11) treats an empty localStorage as a fresh install and ADOPTS + // host config — which persists whatever instrument the PREVIOUS suite's panel + // drives wrote through (cross-suite contamination; the panel flipped to bass + // mid-suite). With the store seeded, the local-wins boot path holds the + // deterministic 6-string default AND heals the host config for later suites. + await page.addInitScript(() => { try { localStorage.setItem("virtuoso.instrument", JSON.stringify({ stringSetup: "guitar_6_standard", customOpenMidis: "" })); } catch (_) {} }); const pageErrs = []; page.on("pageerror", e => pageErrs.push(e.message)); await page.goto(`${HOST}/`, { waitUntil: "domcontentloaded" }); diff --git a/.claude/skills/run-virtuoso/smoke-over-barline.mjs b/.claude/skills/run-virtuoso/smoke-over-barline.mjs index c9c146a..f17e4f8 100644 --- a/.claude/skills/run-virtuoso/smoke-over-barline.mjs +++ b/.claude/skills/run-virtuoso/smoke-over-barline.mjs @@ -18,6 +18,13 @@ try { const r = await fetch(`${HOST}/api/plugins/virtuoso/status`).catch(() => null); if (!r || !r.ok) throw new Error(`Host not reachable at ${HOST}. launch.ps1 first.`); const page = await (await b.newContext({ viewport: { width: 1440, height: 900 } })).newPage(); + // Seed the L1 instrument store BEFORE page scripts run: the host-settings + // sync (v0.1.11) treats an empty localStorage as a fresh install and ADOPTS + // host config — which persists whatever instrument the PREVIOUS suite's panel + // drives wrote through (cross-suite contamination; the panel flipped to bass + // mid-suite). With the store seeded, the local-wins boot path holds the + // deterministic 6-string default AND heals the host config for later suites. + await page.addInitScript(() => { try { localStorage.setItem("virtuoso.instrument", JSON.stringify({ stringSetup: "guitar_6_standard", customOpenMidis: "" })); } catch (_) {} }); const pageErrs = []; page.on("pageerror", e => pageErrs.push(e.message)); await page.goto(`${HOST}/`, { waitUntil: "domcontentloaded" }); await page.waitForSelector("#plugin-virtuoso", { state: "attached", timeout: 20000 }); diff --git a/.claude/skills/run-virtuoso/smoke-progress.mjs b/.claude/skills/run-virtuoso/smoke-progress.mjs index 2caccef..4854dbd 100644 --- a/.claude/skills/run-virtuoso/smoke-progress.mjs +++ b/.claude/skills/run-virtuoso/smoke-progress.mjs @@ -251,6 +251,13 @@ async function run() { try { const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); const page = await ctx.newPage(); + // Seed the L1 instrument store BEFORE page scripts run: the host-settings + // sync (v0.1.11) treats an empty localStorage as a fresh install and ADOPTS + // host config — which persists whatever instrument the PREVIOUS suite's panel + // drives wrote through (cross-suite contamination; the panel flipped to bass + // mid-suite). With the store seeded, the local-wins boot path holds the + // deterministic 6-string default AND heals the host config for later suites. + await page.addInitScript(() => { try { localStorage.setItem("virtuoso.instrument", JSON.stringify({ stringSetup: "guitar_6_standard", customOpenMidis: "" })); } catch (_) {} }); await page.addInitScript(() => { window.__SS_HARNESS__ = true; }); // exposes __ss_debug (blockFeltInfo rows) page.on("pageerror", (e) => { if (!isBenign(e.message)) pageErrors.push(e.message); }); page.on("console", (m) => { if (m.type() === "error" && !isBenign(m.text())) pageErrors.push(m.text()); }); diff --git a/.claude/skills/run-virtuoso/smoke-renderers.mjs b/.claude/skills/run-virtuoso/smoke-renderers.mjs index d7c6991..fb38c2b 100644 --- a/.claude/skills/run-virtuoso/smoke-renderers.mjs +++ b/.claude/skills/run-virtuoso/smoke-renderers.mjs @@ -178,6 +178,13 @@ async function run() { const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); await ctx.addInitScript(() => { globalThis.__SS_HARNESS__ = true; }); // exposes __ss_debug.avSync() for the transport row (harmless: debug-only) const page = await ctx.newPage(); + // Seed the L1 instrument store BEFORE page scripts run: the host-settings + // sync (v0.1.11) treats an empty localStorage as a fresh install and ADOPTS + // host config — which persists whatever instrument the PREVIOUS suite's panel + // drives wrote through (cross-suite contamination; the panel flipped to bass + // mid-suite). With the store seeded, the local-wins boot path holds the + // deterministic 6-string default AND heals the host config for later suites. + await page.addInitScript(() => { try { localStorage.setItem("virtuoso.instrument", JSON.stringify({ stringSetup: "guitar_6_standard", customOpenMidis: "" })); } catch (_) {} }); page.on("pageerror", (e) => { if (!isBenign(e.message)) pageErrors.push(e.message); }); page.on("console", (m) => { if (m.type() !== "error") return; @@ -598,6 +605,13 @@ async function run() { const ctx2 = await browser.newContext({ viewport: { width: 1440, height: 900 } }); await ctx2.addInitScript(() => { window.__awake = []; window.feedBackDesktop = { power: { setScreenAwake: (v) => window.__awake.push(!!v) } }; }); // feedBackDesktop = the FeedBack desktop bridge (hostDesktop() reads it first) const p2 = await ctx2.newPage(); + // Seed the L1 instrument store BEFORE page scripts run: the host-settings + // sync (v0.1.11) treats an empty localStorage as a fresh install and ADOPTS + // host config — which persists whatever instrument the PREVIOUS suite's panel + // drives wrote through (cross-suite contamination; the panel flipped to bass + // mid-suite). With the store seeded, the local-wins boot path holds the + // deterministic 6-string default AND heals the host config for later suites. + await p2.addInitScript(() => { try { localStorage.setItem("virtuoso.instrument", JSON.stringify({ stringSetup: "guitar_6_standard", customOpenMidis: "" })); } catch (_) {} }); p2.on("pageerror", (e) => { if (!isBenign(e.message)) wlFails.push(`pageerror: ${e.message}`); }); await p2.goto(`${HOST}/`, { waitUntil: "domcontentloaded" }); await p2.waitForSelector("#plugin-virtuoso", { state: "attached", timeout: 20000 }); diff --git a/.claude/skills/run-virtuoso/smoke-scoring-e2e.mjs b/.claude/skills/run-virtuoso/smoke-scoring-e2e.mjs index 8973613..0014777 100644 --- a/.claude/skills/run-virtuoso/smoke-scoring-e2e.mjs +++ b/.claude/skills/run-virtuoso/smoke-scoring-e2e.mjs @@ -68,6 +68,13 @@ const launchArgs = (wav) => ([ async function openScreen(browser) { const page = await (await browser.newContext({ viewport: { width: 1440, height: 900 } })).newPage(); + // Seed the L1 instrument store BEFORE page scripts run: the host-settings + // sync (v0.1.11) treats an empty localStorage as a fresh install and ADOPTS + // host config — which persists whatever instrument the PREVIOUS suite's panel + // drives wrote through (cross-suite contamination; the panel flipped to bass + // mid-suite). With the store seeded, the local-wins boot path holds the + // deterministic 6-string default AND heals the host config for later suites. + await page.addInitScript(() => { try { localStorage.setItem("virtuoso.instrument", JSON.stringify({ stringSetup: "guitar_6_standard", customOpenMidis: "" })); } catch (_) {} }); const pageErrs = [], consoleErrs = []; page.on("pageerror", (e) => pageErrs.push(e.message)); page.on("console", (m) => { if (m.type() === "error") consoleErrs.push(m.text()); }); diff --git a/.claude/skills/run-virtuoso/smoke-session-sync.mjs b/.claude/skills/run-virtuoso/smoke-session-sync.mjs index c54e3a9..d3f14b0 100644 --- a/.claude/skills/run-virtuoso/smoke-session-sync.mjs +++ b/.claude/skills/run-virtuoso/smoke-session-sync.mjs @@ -23,6 +23,13 @@ try { const r = await fetch(`${HOST}/api/plugins/virtuoso/status`).catch(() => null); if (!r || !r.ok) throw new Error(`Host not reachable at ${HOST}. launch.ps1 first.`); const page = await (await browser.newContext({ viewport: { width: 1440, height: 900 } })).newPage(); + // Seed the L1 instrument store BEFORE page scripts run: the host-settings + // sync (v0.1.11) treats an empty localStorage as a fresh install and ADOPTS + // host config — which persists whatever instrument the PREVIOUS suite's panel + // drives wrote through (cross-suite contamination; the panel flipped to bass + // mid-suite). With the store seeded, the local-wins boot path holds the + // deterministic 6-string default AND heals the host config for later suites. + await page.addInitScript(() => { try { localStorage.setItem("virtuoso.instrument", JSON.stringify({ stringSetup: "guitar_6_standard", customOpenMidis: "" })); } catch (_) {} }); page.on("pageerror", e => console.log("PAGEERR:", e.message)); await page.goto(`${HOST}/`, { waitUntil: "domcontentloaded" }); await page.waitForSelector("#plugin-virtuoso", { state: "attached", timeout: 20000 }); diff --git a/.claude/skills/run-virtuoso/smoke-strings.mjs b/.claude/skills/run-virtuoso/smoke-strings.mjs index a6dd686..b97704d 100644 --- a/.claude/skills/run-virtuoso/smoke-strings.mjs +++ b/.claude/skills/run-virtuoso/smoke-strings.mjs @@ -25,6 +25,13 @@ const browser = await chromium.launch({ headless: true }); try { await ensureHost(); const page = await (await browser.newContext({ viewport: { width: 1440, height: 900 } })).newPage(); + // Seed the L1 instrument store BEFORE page scripts run: the host-settings + // sync (v0.1.11) treats an empty localStorage as a fresh install and ADOPTS + // host config — which persists whatever instrument the PREVIOUS suite's panel + // drives wrote through (cross-suite contamination; the panel flipped to bass + // mid-suite). With the store seeded, the local-wins boot path holds the + // deterministic 6-string default AND heals the host config for later suites. + await page.addInitScript(() => { try { localStorage.setItem("virtuoso.instrument", JSON.stringify({ stringSetup: "guitar_6_standard", customOpenMidis: "" })); } catch (_) {} }); const pageErrs = []; page.on("pageerror", e => pageErrs.push(e.message)); await page.goto(`${HOST}/`, { waitUntil: "domcontentloaded" }); @@ -383,6 +390,38 @@ try { // bassRootGrip downward-reach feature (ROADMAP open thread), not this fix. ok(lowB.n > 0 && lowB.strings.includes(0), "(13c) 5-string bass scale actually plays ON the low B string (s=0)", `strings=[${lowB.strings.join(",")}]`); + console.log("-- (14) power-chord grip is INTERVAL-aware: drop tuning collapses to the same-fret barre --"); + // powerChordGrip hardcoded +2 on s1/s2 (standard-4ths assumption) — in every + // drop-X/DADGAD/Open-D tuning (s0→s1 = a FIFTH) that sounded root + MAJOR 6TH + // instead of root+5th (guitar-pedagogy 2026-07-12). Fixed by-pitch: drop-D + // 5oct = the iconic one-finger barre {s0:F, s1:F, s2:F} = root·5th·octave; + // standard keeps {F, F+2, F+2}. Drives strum_comp with chordOverride=5oct. + const pchord = await page.evaluate(() => { + const run = (su) => { + window.__t.setAdvanced(true); window.__t.setTuning(null); + window.__t.setForm({ stringSetup: su, practiceType: "strum_comp", key: "D", chordOverride: "5oct" }); + const cfg = window.Virtuoso.readConfig(); + const ex = window.Virtuoso.generateExercise(cfg); + const t0 = Math.min(...ex.chart.notes.map(n => n.t)); + // Strum notes are ROLL-staggered a few ms per string — collect the first + // chord within a strum window, one note per string. + const first = []; + for (const n of ex.chart.notes.filter(n => n.t < t0 + 0.09 && n.s <= 2).sort((a, b) => a.s - b.s)) { + if (!first.some(p => p.s === n.s)) first.push({ s: n.s, f: n.f }); + } + const opens = su === "guitar_6_drop_d" ? [38, 45, 50, 55, 59, 64] : [40, 45, 50, 55, 59, 64]; + const midis = first.map(p => opens[p.s] + p.f); + return { first, ivs: midis.slice(1).map(m => m - midis[0]) }; + }; + const out = { drop: run("guitar_6_drop_d"), std: run("guitar_6_standard") }; + window.__t.setAdvanced(false); window.__t.setForm({ stringSetup: "guitar_6_standard" }); // self-clean + return out; + }); + ok(pchord.drop.first.length === 3 && pchord.drop.first.every(p => p.f === pchord.drop.first[0].f), + "(14a) drop-D 5oct power chord = SAME-FRET barre across s0/s1/s2", JSON.stringify(pchord.drop.first)); + ok(pchord.drop.ivs.join(",") === "7,12", "(14b) drop-D barre sounds root·5th·octave (was root·MAJOR-6TH·octave)", `ivs=[${pchord.drop.ivs}]`); + ok(pchord.std.ivs.join(",") === "7,12", "(14c) standard-tuning grip still sounds root·5th·octave (F/F+2/F+2 unchanged)", `ivs=[${pchord.std.ivs}] frets=${JSON.stringify(pchord.std.first)}`); + ok(pageErrs.length === 0, "no uncaught page errors", pageErrs.join(" | ")); console.log(`\n${fails === 0 ? "PASS" : "FAIL"} strings/tuning: ${fails} failure(s)`); process.exit(fails ? 1 : 0); diff --git a/.claude/skills/run-virtuoso/smoke-variation.mjs b/.claude/skills/run-virtuoso/smoke-variation.mjs index c9384ef..b459fec 100644 --- a/.claude/skills/run-virtuoso/smoke-variation.mjs +++ b/.claude/skills/run-virtuoso/smoke-variation.mjs @@ -179,6 +179,13 @@ async function run() { try { const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); const page = await ctx.newPage(); + // Seed the L1 instrument store BEFORE page scripts run: the host-settings + // sync (v0.1.11) treats an empty localStorage as a fresh install and ADOPTS + // host config — which persists whatever instrument the PREVIOUS suite's panel + // drives wrote through (cross-suite contamination; the panel flipped to bass + // mid-suite). With the store seeded, the local-wins boot path holds the + // deterministic 6-string default AND heals the host config for later suites. + await page.addInitScript(() => { try { localStorage.setItem("virtuoso.instrument", JSON.stringify({ stringSetup: "guitar_6_standard", customOpenMidis: "" })); } catch (_) {} }); page.on("pageerror", (e) => { if (!isBenign(e.message)) pageErrors.push(e.message); }); page.on("console", (m) => { if (m.type() === "error" && !isBenign(m.text())) consoleErrors.push(m.text()); }); await gotoVirtuoso(page); From 4ee1f3af2e9b196ba170731e21ed05184f4e0a96 Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou <16130099+ChrisBeWithYou@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:30:28 -0500 Subject: [PATCH 08/18] =?UTF-8?q?fix(generators):=20interval-aware=20power?= =?UTF-8?q?-chord=20grip=20=E2=80=94=20the=20drop-tuning=20barre=20(v0.1.1?= =?UTF-8?q?4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit powerChordGrip hardcoded the standard-4ths offsets (+2 on s1/s2), so in every tuning where the s0->s1 interval is a FIFTH (drop-D/C/B, drop-A, the bass drops, DADGAD, Open D) the '5'/'5oct' grip sounded root + MAJOR 6TH instead of root + 5th - a real wrong pitch on the strum_comp/pickStrumGrip path. Fret offsets are now computed BY PITCH against the actual open intervals: in a drop tuning the grip collapses to the iconic one-finger SAME-FRET barre {s0:F, s1:F, s2:F} = root-5th-octave; standard tuning keeps {F, F+2, F+2} unchanged. Guitar-pedagogy 2026-07-12: gate on the interval (DADGAD/Open D are TRUE positives - the barre is the idiomatic voicing there), apply by default (the barre is the only correct voicing in a drop tuning), F=0 is a valid open voicing; templateFromPositions already fingers a same-fret row as a shared index barre (fg 1) by construction. Negative-fret guard bails on hostile custom tunings. (buildPedalRiffExercise's own s1/s2/s3 power chord was already interval-correct - untouched.) Smoke: smoke-strings row (14) - drop-D 5oct = same-fret barre sounding root-5th-octave; standard grip unchanged. Full run 16/19 (the 3 pre-existing base reds); the contained-verifier/level-gate flakes stayed green with the suite seeding from the previous commit. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01MS2YFb6UUSwJVV6CmEa25i --- plugin.json | 2 +- screen.js | 22 ++++++++++++++++++---- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/plugin.json b/plugin.json index 2f09f4a..8948457 100644 --- a/plugin.json +++ b/plugin.json @@ -1,7 +1,7 @@ { "id": "virtuoso", "name": "Virtuoso", - "version": "0.1.13", + "version": "0.1.14", "description": "Practice studio for guitar & bass — scale, technique, and rhythm drills, timed workouts, and jam backing that teach skills you take off the screen.", "category": "practice", "icon": "assets/thumb.png", diff --git a/screen.js b/screen.js index 4765686..45e087f 100644 --- a/screen.js +++ b/screen.js @@ -48,7 +48,7 @@ // a plugin's own version into its screen (note_detect hardcodes `_ND_VERSION` // the same way), so this is the display mirror of plugin.json's "version". // BUMP THIS WHENEVER plugin.json's version changes (release checklist). - const VIRTUOSO_VERSION = '0.1.13'; + const VIRTUOSO_VERSION = '0.1.14'; // =========================================================================== // §1 · CONSTANTS & MUSIC-THEORY DATA @@ -10876,7 +10876,16 @@ } // A movable power-chord grip (root + 5th [+ octave]) on the low string-set — // built directly from the interval geometry (CAGED has no power-chord template). - // Standard 4ths between the low strings: 5th = next string +2 frets, oct = +2 up two strings. + // Fret offsets are computed BY PITCH against the actual open-string intervals, + // not hardcoded (+2 assumed standard 4ths — WRONG in drop tunings, where the + // s0→s1 fifth collapses the grip to the iconic SAME-FRET one-finger barre: + // {s0:F, s1:F, s2:F} = root·5th·octave. The old +2 sounded root + MAJOR 6TH + // in every drop-X/DADGAD/Open-D tuning — a real wrong pitch. Guitar-pedagogy + // 2026-07-12: gate on the interval (opens[1]-opens[0] === 7 collapses; DADGAD + // and Open D are TRUE positives — the barre is the idiomatic voicing there), + // apply by default (in a drop tuning the barre is the only correct voicing), + // F=0 = a valid open voicing. templateFromPositions fingers a same-fret row + // as a shared-finger barre (fg 1) by construction. function powerChordGrip(cfg, rootPc, quality, prevRootFret) { const opens = openMidisForConfig(cfg); if (opens.length < 3) return null; @@ -10884,8 +10893,13 @@ let f = (((rootPc - open0) % 12) + 12) % 12; // 0..11 on the lowest string if (prevRootFret != null && prevRootFret >= 0 && f + 12 <= 12 && Math.abs((f + 12) - prevRootFret) < Math.abs(f - prevRootFret)) f += 12; - const gripNotes = [{ s:0, f, midi:opens[0] + f }, { s:1, f:f + 2, midi:opens[1] + f + 2 }]; - if (quality === '5oct') gripNotes.push({ s:2, f:f + 2, midi:opens[2] + f + 2 }); + const fifthF = f + 7 - (opens[1] - opens[0]); // 5th above root, on s1 + if (fifthF < 0) return null; // hostile custom tuning — no grippable 5th + const gripNotes = [{ s:0, f, midi:opens[0] + f }, { s:1, f:fifthF, midi:opens[1] + fifthF }]; + if (quality === '5oct') { + const octF = f + 12 - (opens[2] - opens[0]); // octave above root, on s2 + if (octF >= 0) gripNotes.push({ s:2, f:octF, midi:opens[2] + octF }); + } const name = chordName(rootPc, quality); return { shape:'power', rootFret:f, gripNotes, template:templateFromPositions(name, gripNotes, cfg, false) }; } From 40cb71d6c33ce3dd1a37b05378c54c861571e0d9 Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou <16130099+ChrisBeWithYou@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:36:57 -0500 Subject: [PATCH 09/18] =?UTF-8?q?feat(generators):=20bass=20low-fifth=20re?= =?UTF-8?q?ach=20=E2=80=94=20rfoPattern=20'low5'=20(v0.1.15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bassRootGrip only reached UP (fifth s+1, octave s+2); the fifth BELOW the root - the reach the low string exists for, and the whole point of a 5-string's B - had no primitive (deferred from the string-count audit). bassRootGrip gains lowFifth: the P5 below the root on the string below, computed by pitch with an exact-midi check. Guards (bass-pedagogy spec 2026-07-12): root.s>=1 (a string exists below) AND root.f>=2 (at f0/f1 the pc-math octave-push wraps the note UP a 4th - the opposite of intent); null when unavailable. root_fifth_octave gains the opt-in rfoPattern='low5' variant: R-low5-5-8 (root on the downbeat, traverses both fifths + the octave); an unreachable low fifth degrades to the upper 5th so the pattern stays playable everywhere. The default R-5-8-5 box is deliberately untouched (the canonical pre-scales lesson) - guarded byte-identical by smoke. Scope per spec: root_fifth_octave ONLY (octave_groove stays pure R-8 per the soul-motown ruling; the right_hand_technique modes are pitch-invisible by design). No root-selection bias - nearest-to-prev stands. The Root-5th-Octave rung gains a final vary step teaching the reach (rfoPattern coded in base so it never leaks across the rung's own vary steps). Smoke: smoke-strings row (15) - low5 geometry (R, root-7 on the string below, 5th, octave), default-parity (field absent === 'r5o', byte-identical), 4-string null-degrade to the upper 5th. Full run 16/19 (the 3 pre-existing base reds). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01MS2YFb6UUSwJVV6CmEa25i --- .claude/skills/run-virtuoso/smoke-strings.mjs | 45 +++++++++++++++++++ plugin.json | 2 +- screen.html | 3 ++ screen.js | 37 ++++++++++++--- 4 files changed, 81 insertions(+), 6 deletions(-) diff --git a/.claude/skills/run-virtuoso/smoke-strings.mjs b/.claude/skills/run-virtuoso/smoke-strings.mjs index b97704d..396aa0c 100644 --- a/.claude/skills/run-virtuoso/smoke-strings.mjs +++ b/.claude/skills/run-virtuoso/smoke-strings.mjs @@ -422,6 +422,51 @@ try { ok(pchord.drop.ivs.join(",") === "7,12", "(14b) drop-D barre sounds root·5th·octave (was root·MAJOR-6TH·octave)", `ivs=[${pchord.drop.ivs}]`); ok(pchord.std.ivs.join(",") === "7,12", "(14c) standard-tuning grip still sounds root·5th·octave (F/F+2/F+2 unchanged)", `ivs=[${pchord.std.ivs}] frets=${JSON.stringify(pchord.std.first)}`); + console.log("-- (15) bass low-fifth reach (rfoPattern='low5', bass-pedagogy 2026-07-12) --"); + // R–low5–5–8: the fifth BELOW the root on the string below (the reach the low + // string exists for). Default R-5-8-5 must be BYTE-IDENTICAL with the field + // absent (the canonical pre-scales box never changes under a beginner); + // unreachable low fifth (root on the lowest string) degrades to the upper 5th. + const rfo = await page.evaluate(() => { + const run = (su, pattern, key, fretMax) => { + window.__t.setAdvanced(true); window.__t.setTuning(null); + // Bass-native practice types only enter the select once the FAMILY is + // bass — dispatch a real setup change first (mirrors row 10), else the + // silent practiceType set falls back to a scale exercise. + const setup = document.querySelector('#virtuoso-controls [name="stringSetup"]'); + setup.value = su; setup.dispatchEvent(new Event("change", { bubbles: true })); + const pt = document.querySelector('[name="practiceType"]'); + pt.value = "root_fifth_octave"; pt.dispatchEvent(new Event("change", { bubbles: true })); + const rp = document.querySelector('[name="rfoPattern"]'); if (rp) rp.value = pattern || ""; + window.__t.setForm({ key, progression: "I-IV-V", fretMin: 0, fretMax: fretMax || 7 }); + const cfg = window.Virtuoso.readConfig(); + if (cfg.mode !== "root_fifth_octave") return `WRONG-MODE:${cfg.mode}`; + const ex = window.Virtuoso.generateExercise(cfg); + return ex.chart.notes.map(n => `${n.t.toFixed(3)}:${n.s}:${n.f}`).join("|"); + }; + const opens5 = [23, 28, 33, 38, 43]; + const firstBar = (sig) => sig.split("|").slice(0, 4).map(k => { const [, s, f] = k.split(":").map(Number); return { s, f }; }); + // (a) geometry on 5-string, key C (root lands fretted above the low B). + const low5 = firstBar(run("bass_5_standard", "low5", "C")); + const midis = low5.map(p => opens5[p.s] + p.f); + // (b) default parity: no pattern field vs explicit 'r5o' — identical charts. + const def = run("bass_5_standard", "", "C"); + const r5o = run("bass_5_standard", "r5o", "C"); + // (c) null-degrade: 4-string key E root sits on the lowest string (s0) → + // low fifth unreachable → the low5 chart equals swapping in the UPPER 5th. + const deg = firstBar(run("bass_4_standard", "low5", "E", 5)); + const opens4 = [28, 33, 38, 43]; + const degMidis = deg.map(p => opens4[p.s] + p.f); + const rp = document.querySelector('[name="rfoPattern"]'); if (rp) rp.value = ""; // self-clean + window.__t.setAdvanced(false); window.__t.setForm({ stringSetup: "guitar_6_standard" }); + return { midis, defEqualsR5o: def === r5o, defSig: def.split("|").length, degMidis }; + }); + ok(rfo.midis.length === 4 && rfo.midis[1] === rfo.midis[0] - 7 && rfo.midis[2] === rfo.midis[0] + 7 && rfo.midis[3] === rfo.midis[0] + 12, + "(15a) low5 bar = R, low-5th (root−7, the string below), 5th, octave", `midis=[${rfo.midis}]`); + ok(rfo.defEqualsR5o && rfo.defSig > 0, "(15b) default R-5-8-5 chart is BYTE-IDENTICAL with the field absent vs 'r5o' (no leak into the canonical box)"); + ok(rfo.degMidis.length === 4 && rfo.degMidis[1] === rfo.degMidis[0] + 7, + "(15c) unreachable low fifth (4-string, root on the lowest string) degrades to the UPPER 5th", `midis=[${rfo.degMidis}]`); + ok(pageErrs.length === 0, "no uncaught page errors", pageErrs.join(" | ")); console.log(`\n${fails === 0 ? "PASS" : "FAIL"} strings/tuning: ${fails} failure(s)`); process.exit(fails ? 1 : 0); diff --git a/plugin.json b/plugin.json index 8948457..b926fec 100644 --- a/plugin.json +++ b/plugin.json @@ -1,7 +1,7 @@ { "id": "virtuoso", "name": "Virtuoso", - "version": "0.1.14", + "version": "0.1.15", "description": "Practice studio for guitar & bass — scale, technique, and rhythm drills, timed workouts, and jam backing that teach skills you take off the screen.", "category": "practice", "icon": "assets/thumb.png", diff --git a/screen.html b/screen.html index 18cbffa..1bfa189 100644 --- a/screen.html +++ b/screen.html @@ -2852,6 +2852,9 @@

Virtuoso

(pulse|crossing|rake|three_finger). Pathway-driven; 'pulse' default; anti-leak defaulted in applyPathwayConfig. --> + + diff --git a/screen.js b/screen.js index 45e087f..4596ffa 100644 --- a/screen.js +++ b/screen.js @@ -48,7 +48,7 @@ // a plugin's own version into its screen (note_detect hardcodes `_ND_VERSION` // the same way), so this is the display mirror of plugin.json's "version". // BUMP THIS WHENEVER plugin.json's version changes (release checklist). - const VIRTUOSO_VERSION = '0.1.14'; + const VIRTUOSO_VERSION = '0.1.15'; // =========================================================================== // §1 · CONSTANTS & MUSIC-THEORY DATA @@ -1264,8 +1264,12 @@ goal:'The foundational bass box: root, fifth, octave, fifth, anchored on each chord. Before scales, a bassist owns this shape — it outlines any chord and lays the harmonic floor under the band. The skill is finding the root and reaching the 5th/octave by feel in any key.', scales:['major','natural_minor'], tempoTiers:[60, 80, 100, 120], - base:{ practiceType:'root_fifth_octave', scale:'major', progression:'I-IV-V', chordDepth:'triad', chordOverride:'auto', meter:'4/4', subdivision:'quarter', bpm:80, bars:8, direction:'up_down', sequence:'none', advancedMode:true, fretboardSystem:'position', stringSetup:'bass_4_standard', renderer:'highway_3d', key:'C', fretMin:0, fretMax:5 }, - vary:[ { key:'C', progression:'I-IV-V' }, { key:'G', progression:'I-IV-V' }, { key:'A', progression:'i-VII-VI-VII', scale:'natural_minor' }, { key:'E', progression:'I-V-vi-IV' }, { key:'D', progression:'I-IV-V' } ] + base:{ practiceType:'root_fifth_octave', scale:'major', progression:'I-IV-V', chordDepth:'triad', chordOverride:'auto', meter:'4/4', subdivision:'quarter', bpm:80, bars:8, direction:'up_down', sequence:'none', advancedMode:true, fretboardSystem:'position', stringSetup:'bass_4_standard', renderer:'highway_3d', key:'C', fretMin:0, fretMax:5, rfoPattern:'r5o' }, + // Final vary = the low-fifth reach (R–low5–5–8): the same box, now also + // dropping BELOW the root — the move the low string exists for (and the + // whole point of a 5-string's B). rfoPattern is coded explicitly in base + // ('r5o') so the variant never leaks across this rung's own vary steps. + vary:[ { key:'C', progression:'I-IV-V' }, { key:'G', progression:'I-IV-V' }, { key:'A', progression:'i-VII-VI-VII', scale:'natural_minor' }, { key:'E', progression:'I-V-vi-IV' }, { key:'D', progression:'I-IV-V' }, { key:'C', progression:'I-IV-V', rfoPattern:'low5', fretMin:0, fretMax:7 } ] }, bass_octave_groove: { label:'Octave Groove', @@ -4454,6 +4458,10 @@ // rhTechMode (Bass Groove & Right-Hand ladder): pulse|crossing|rake|three_finger. // Pathway-driven hidden field; absent = 'pulse' (the back-compatible default). rhTechMode: data.get('rhTechMode') || 'pulse', + // rfoPattern (root_fifth_octave): 'r5o' (default R-5-8-5, the canonical box) + // | 'low5' (R–low5–5–8, the fifth-below reach — bass-pedagogy 2026-07-12). + // Pathway-driven hidden field; absent = the untouched default. + rfoPattern: data.get('rfoPattern') || 'r5o', // walkApproach (Bass Lines & Changes ladder): how the walking line targets the // NEXT chord on the bar's last beat — scale (current scale-walk) | chromatic // (½-step into the next root) | dominant (the 5th of the next chord, V→I) | @@ -11308,7 +11316,18 @@ while (opens[s] + f < targetMidi - 6 && f + 12 <= 17) f += 12; return { s, f, midi: opens[s] + f }; }; - return { root, fifth: onString(root.s + 1, root.midi + 7), octave: onString(root.s + 2, root.midi + 12) }; + // lowFifth — the P5 BELOW the root on the string below (the "why own a + // 5-string" reach; bass-pedagogy spec 2026-07-12). Guards: root.s>=1 (a + // string exists below) AND root.f>=2 (at f0/f1 the pc-math octave-push + // wraps the note UP a 4th — the opposite of the intent), plus an exact + // pitch check (never trust pc arithmetic alone on a custom tuning). Null + // when unavailable — consumers degrade to the upper fifth. + let lowFifth = null; + if (root.s >= 1 && root.f >= 2) { + const cand = onString(root.s - 1, root.midi - 7); + if (cand.midi === root.midi - 7 && cand.f >= 0) lowFifth = cand; + } + return { root, fifth: onString(root.s + 1, root.midi + 7), octave: onString(root.s + 2, root.midi + 12), lowFifth }; } // right_hand_technique — pitch-INVISIBLE plucking-hand stamina (alternating i-m / @@ -11371,7 +11390,15 @@ while (t < totalTime - 0.001) { const grip = bassRootGrip(cfg, chordRootForDegree(cfg, degrees[bar % degrees.length]), prevMidi); if (grip) { - const seq = [grip.root, grip.fifth, grip.octave, grip.fifth]; // R-5-8-5 + // Default R-5-8-5 — the canonical pre-scales box, deliberately untouched. + // rfoPattern 'low5' (opt-in, bass-pedagogy spec 2026-07-12) = R–low5–5–8: + // root on the downbeat, then the fifth BELOW on the string below (the + // 5-string reach), traversing both fifths + the octave. When the low + // fifth is unreachable (root on the lowest string / low fret) the grip + // degrades to the upper fifth — the pattern stays playable everywhere. + const seq = (cfg.rfoPattern === 'low5') + ? [grip.root, grip.lowFifth || grip.fifth, grip.fifth, grip.octave] + : [grip.root, grip.fifth, grip.octave, grip.fifth]; // R-5-8-5 for (let b = 0; b < beatsPerBar; b++) { const p = seq[b % seq.length], nt = t + b * beatSec; if (nt < totalTime) notes.push(noteDefaults({ t:Number(nt.toFixed(6)), s:p.s, f:p.f, sus, ac:(b === 0) })); From 3a4efe95f4660feb2fbf90dce26e11e810e2ce19 Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou <16130099+ChrisBeWithYou@users.noreply.github.com> Date: Sun, 12 Jul 2026 21:46:10 -0500 Subject: [PATCH 10/18] =?UTF-8?q?feat(render):=20Classic=202D=20Highway=20?= =?UTF-8?q?parity=20=E2=80=94=20perspective=20falling-fret=20renderer=20(v?= =?UTF-8?q?0.1.16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `highway_2d` view slot rendered our horizontal-lane tab scroller, not the 2D highway Christian meant: Byron's "Classic 2D Highway" — the perspective falling-fret highway that is createHighway()'s private _defaultRenderer (static/highway.js + static/js/highway-{geometry,draw,state-primitives}.js). HOST CHECK 2026-07-10 verdict was MIRROR (core/closure-fed, no viz factory to borrow; issue got-feedBack/feedBack#835 asks the host to expose one, which would flip this to a pure borrow). New makeClassic2DHighwayRenderer() — a faithful port reading our bundle, with the host constants/geometry lifted from source: #080810 BG, the perspective project() (VISIBLE_SECONDS 3.0 / Z_CAM 2.2 / Z_MAX 10.0, strike line at y=0.82), the centered fretX() trapezoid, converging fret lines, the beat sweep, the DEFAULT_STRING_COLORS/DIM/BRIGHT palette, the glowing now line, falling perspective-scaled fret-number gems (rounded-rect body + dim glow + white fret number, open-string wide bar, dead-note X, technique glyphs), sustain trails, and the amber fret-number rail with the live anchor-window highlight. Fret window follows the chart anchors via the host's smoothed displayMaxFret (0.4 rate), falling back to the notes' max fret when a chart carries no anchors. Lefty mirror + inverted string order honored. resolveRendererFactory's highway_2d slot now falls back to this (was makeBuiltin2DRenderer); the borrow-first vizFactoryFor('highway_2d') probe is unchanged, so the host factory still wins when #835 ships. makeBuiltin2DRenderer stays the Jumping-Tab (builtin_2d) fallback and the >8-string path, untouched. Deliberate approximations (documented in the renderer header, not oversights): the exotic host overlays our bundle doesn't feed — unison-bend connectors, strum-group brackets, master-difficulty phrase filtering, lyrics, chord frames — are omitted; and a CREDITED hit keeps Virtuoso's own green-gem grammar (#22c55e + glow, the cross-surface "a hit is unmistakable" guarantee from the 2026-07-09 tester fix) rather than the host's string-bright lit gem, so a hit reads as clearly here as on our Tab/Notation surfaces (consume-the-judge: credit stays verifier-only; this is display). Verification: screenshot-confirmed static (receding trapezoid, fret lines, strike line, string palette, 0-8 amber rail) AND playing (numbered gems falling with perspective scale + sustain trails, near gem at the strike line). Full smoke 16/19 - only the 3 pre-existing base reds (backing-engine, progress, variation); smoke-renderers' pixel-enforced highway_2d row green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01MS2YFb6UUSwJVV6CmEa25i --- plugin.json | 2 +- screen.js | 255 +++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 254 insertions(+), 3 deletions(-) diff --git a/plugin.json b/plugin.json index b926fec..34b7a87 100644 --- a/plugin.json +++ b/plugin.json @@ -1,7 +1,7 @@ { "id": "virtuoso", "name": "Virtuoso", - "version": "0.1.15", + "version": "0.1.16", "description": "Practice studio for guitar & bass — scale, technique, and rhythm drills, timed workouts, and jam backing that teach skills you take off the screen.", "category": "practice", "icon": "assets/thumb.png", diff --git a/screen.js b/screen.js index 4596ffa..ebc7834 100644 --- a/screen.js +++ b/screen.js @@ -48,7 +48,7 @@ // a plugin's own version into its screen (note_detect hardcodes `_ND_VERSION` // the same way), so this is the display mirror of plugin.json's "version". // BUMP THIS WHENEVER plugin.json's version changes (release checklist). - const VIRTUOSO_VERSION = '0.1.15'; + const VIRTUOSO_VERSION = '0.1.16'; // =========================================================================== // §1 · CONSTANTS & MUSIC-THEORY DATA @@ -12892,6 +12892,257 @@ }; } + // ── Classic 2D Highway (host-parity MIRROR) ───────────────────────────── + // A faithful port of the host's "Classic 2D Highway" (Byron's) — the + // perspective falling-fret highway that is createHighway()'s private + // _defaultRenderer (static/highway.js + static/js/highway-{geometry,draw, + // state-primitives}.js). HOST CHECK 2026-07-10: that renderer is core, + // closure-fed + websocket-fed, and exposes no viz factory, so it can't be + // BORROWED — verdict MIRROR (issue got-feedBack/feedBack#835 asks the host to + // ship a bundle-driven feedBackViz_highway_2d, which would flip this to a + // pure borrow). This drives the `highway_2d` view slot; our horizontal-lane + // makeBuiltin2DRenderer stays the Jumping-Tab fallback + the >8-string path. + // + // Ported faithfully (constants + geometry lifted from the host source): the + // #080810 BG, the perspective project() (VISIBLE_SECONDS/Z_CAM/Z_MAX), the + // centered fretX() trapezoid, the fret lines, the beat sweep, the string + // palette (DEFAULT_STRING_COLORS/DIM/BRIGHT), the strike (now) line, the + // falling fret-number gems, and the amber fret-number rail with anchor + // highlight. Deliberate approximations (documented, not oversights): the + // exotic overlays our bundle doesn't feed (unison-bend connectors, strum- + // group brackets, master-difficulty phrase filtering, lyrics, chord frames) + // are omitted; and a CREDITED hit keeps Virtuoso's own green-gem grammar + // (#22c55e + glow — the cross-surface "a hit is unmistakable" guarantee from + // the 2026-07-09 tester fix) rather than the host's string-bright lit gem, + // so the hit stays as visible here as on our Tab/Notation surfaces. + function makeClassic2DHighwayRenderer() { + let canvas = null, ctx = null, W = 0, H = 0; + let displayMaxFret = 12, lastTime = null; + const VISIBLE_SECONDS = 3.0, Z_CAM = 2.2, Z_MAX = 10.0, BG = '#080810'; + const SC = ['#cc0000','#cca800','#0066cc','#cc6600','#00cc66','#9900cc','#cc00aa','#00cccc']; + const SDIM = ['#520000','#524200','#002952','#522900','#005229','#3d0052','#520042','#005252']; + const SBRIGHT = ['#ff3c3c','#ffe040','#3c9cff','#ff9c3c','#3cff9c','#cc3cff','#ff3ce0','#3ce0e0']; + + function resize() { + if (!canvas) return; + const box = canvas.parentElement || $('virtuoso-render-host'); + const r = box ? box.getBoundingClientRect() : { width: canvas.width, height: canvas.height }; + W = Math.max(640, Math.round(r.width || 1280)); + H = Math.max(420, Math.round(r.height || 720)); + canvas.width = W; canvas.height = H; + } + // Perspective projection: fret plane recedes UP from the strike line at + // y=0.82. Exact host math (highway-geometry.js project()). + function project(tOff) { + if (tOff > VISIBLE_SECONDS || tOff < -0.05) return null; + if (tOff < 0) return { y: 0.82 + Math.abs(tOff) * 0.3, scale: 1.0 }; + const z = tOff * (Z_MAX / VISIBLE_SECONDS), denom = z + Z_CAM; + if (denom < 0.01) return null; + const scale = Z_CAM / denom; + return { y: 0.82 + (0.08 - 0.82) * (1.0 - scale), scale }; + } + // Centered fret→x band (highway-state-primitives.js fretX()). + function fretX(fret, scale) { + const hw = W * 0.52 * scale, margin = hw * 0.06, usable = hw * 2 - 2 * margin; + return W / 2 - hw + margin + (fret / Math.max(1, displayMaxFret)) * usable; + } + function anchorsOf(bundle) { return bundle.anchors && bundle.anchors.length ? bundle.anchors : null; } + function anchorAt(bundle, t) { + const src = anchorsOf(bundle); + if (!src) { let mf = 0; for (const n of bundle.notes || []) if (n.f > mf) mf = n.f; return { fret: 0, width: Math.max(4, mf) }; } + let a = src[0] || { fret: 1, width: 4 }; + for (const anc of src) { if (anc.time > t) break; a = anc; } + return a; + } + function maxFretInWindow(bundle, t) { + const src = anchorsOf(bundle); + if (!src) { let mf = 0; for (const n of bundle.notes || []) if (n.f > mf) mf = n.f; return mf; } + let mf = 0; + for (const anc of src) { + if (anc.time > t + VISIBLE_SECONDS + 2) break; + if (anc.time + 2 < t) continue; + const top = anc.fret + anc.width; if (top > mf) mf = top; + } + return mf; + } + function smoothFret(bundle, now, dt) { + const a = anchorAt(bundle, now); + const needed = Math.max(a.fret + a.width, maxFretInWindow(bundle, now)); + const target = Math.max(needed + 3, 8); + displayMaxFret += (target - displayMaxFret) * Math.min(0.4 * dt, 0.4); + } + // Text stays left-to-right readable under the lefty mirror transform. + function textReadable(txt, x, y, lefty) { + if (!lefty) { ctx.fillText(txt, x, y); return; } + ctx.save(); ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.fillText(txt, W - x, y); ctx.restore(); + } + + function drawHighway() { + const strips = 40; + for (let i = 0; i < strips; i++) { + const p0 = project((i / strips) * VISIBLE_SECONDS), p1 = project(((i + 1) / strips) * VISIBLE_SECONDS); + if (!p0 || !p1) continue; + const hw0 = W * 0.26 * p0.scale, hw1 = W * 0.26 * p1.scale, bright = 18 + 10 * p0.scale; + ctx.fillStyle = `rgb(${bright | 0},${bright | 0},${(bright + 14) | 0})`; + ctx.beginPath(); + ctx.moveTo(W / 2 - hw0, p0.y * H); ctx.lineTo(W / 2 + hw0, p0.y * H); + ctx.lineTo(W / 2 + hw1, p1.y * H); ctx.lineTo(W / 2 - hw1, p1.y * H); + ctx.fill(); + } + } + function drawFretLines() { + const hi = Math.ceil(displayMaxFret); + ctx.strokeStyle = '#2d2d45'; ctx.lineWidth = 1; + for (let fret = 0; fret <= hi; fret++) { + ctx.beginPath(); + for (let i = 0; i <= 40; i++) { + const p = project((i / 40) * VISIBLE_SECONDS); if (!p) continue; + const x = fretX(fret, p.scale); + if (i === 0) ctx.moveTo(x, p.y * H); else ctx.lineTo(x, p.y * H); + } + ctx.stroke(); + } + } + function drawBeats(bundle, now) { + for (const beat of bundle.beats || []) { + const p = project(beat.time - now); + if (!p || p.scale < 0.06) continue; + const hw = W * 0.26 * p.scale, isMeasure = beat.measure >= 0; + ctx.strokeStyle = isMeasure ? '#343450' : '#202038'; ctx.lineWidth = isMeasure ? 2 : 1; + ctx.beginPath(); ctx.moveTo(W / 2 - hw, p.y * H); ctx.lineTo(W / 2 + hw, p.y * H); ctx.stroke(); + } + } + function drawStrings(nStr, inverted) { + const strTop = H * 0.83, strBot = H * 0.95, margin = W * 0.03, span = Math.max(1, nStr - 1); + ctx.lineWidth = 3; + for (let i = 0; i < nStr; i++) { + const yi = inverted ? (nStr - 1 - i) : i, y = strTop + (yi / span) * (strBot - strTop); + ctx.strokeStyle = SC[i] || '#888'; + ctx.beginPath(); ctx.moveTo(margin, y); ctx.lineTo(W - margin, y); ctx.stroke(); + } + } + function drawNowLine() { + const y = H * 0.82, hw = W * 0.26; + for (let i = 1; i < 5; i++) { + const a = Math.max(0, 70 - i * 15); + ctx.strokeStyle = `rgba(${a},${a},${a + 8},1)`; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(W / 2 - hw, y - i); ctx.lineTo(W / 2 + hw, y - i); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(W / 2 - hw, y + i); ctx.lineTo(W / 2 + hw, y + i); ctx.stroke(); + } + ctx.strokeStyle = '#dce0f0'; ctx.lineWidth = 2; + ctx.beginPath(); ctx.moveTo(W / 2 - hw, y); ctx.lineTo(W / 2 + hw, y); ctx.stroke(); + } + function drawFretNumbers(bundle, now, lefty) { + const y = H * 0.97, hi = Math.ceil(displayMaxFret), a = anchorAt(bundle, now); + ctx.font = 'bold 20px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + for (let fret = 0; fret <= hi; fret++) { + const inAnchor = fret >= a.fret && fret <= a.fret + a.width; + ctx.fillStyle = inAnchor ? '#e8c040' : '#8a6830'; + textReadable(String(fret), fretX(fret, 1.0), y, lefty); + } + } + function drawSustains(bundle, now) { + for (const n of bundle.notes || []) { + if (!(n.sus > 0)) continue; + const p0 = project(Math.max(-0.05, n.t - now)), p1 = project(Math.max(-0.05, n.t + n.sus - now)); + if (!p0 && !p1) continue; + const yTop = (p1 ? p1.y : 0.0) * H, yBot = (p0 ? p0.y : 0.82) * H, sc = p0 ? p0.scale : 1.0; + const x = fretX(n.f, sc), w = Math.max(3, 10 * sc * (H / 900)); + ctx.strokeStyle = SDIM[n.s] || '#333'; ctx.lineWidth = w; ctx.lineCap = 'round'; + ctx.beginPath(); ctx.moveTo(x, Math.min(yBot, H * 0.82)); ctx.lineTo(fretX(n.f, p1 ? p1.scale : sc), yTop); ctx.stroke(); + } + ctx.lineCap = 'butt'; + } + function drawNote(bundle, n, x, y, scale, lefty) { + const sz = Math.max(12, 80 * scale * (H / 900)), half = sz / 2, s = n.s; + const st = bundle.getNoteState ? bundle.getNoteState(n) : null; + const stState = st && (typeof st === 'string' ? st : st.state); + const hit = stState === 'hit' || stState === 'active', miss = stState === 'miss'; + // Dead/muted note: hollow X (host draws an X marker; keep it simple). + if (n.mt) { + ctx.strokeStyle = SC[s] || '#888'; ctx.lineWidth = Math.max(2, sz / 8); + ctx.beginPath(); ctx.moveTo(x - half * 0.6, y - half * 0.6); ctx.lineTo(x + half * 0.6, y + half * 0.6); + ctx.moveTo(x + half * 0.6, y - half * 0.6); ctx.lineTo(x - half * 0.6, y + half * 0.6); ctx.stroke(); + return; + } + if (miss) ctx.globalAlpha = 0.5; + // Open string: wide centered bar (host grammar). + if (n.f === 0) { + const hw = W * 0.26 * scale, barH = Math.max(6, sz * 0.45); + ctx.fillStyle = SDIM[s] || '#222'; roundRectPath(W / 2 - hw - 1, y - barH / 2 - 1, hw * 2 + 2, barH + 2, 3); ctx.fill(); + if (hit) { ctx.shadowColor = '#22c55e'; ctx.shadowBlur = 14; } + ctx.fillStyle = hit ? '#22c55e' : (SC[s] || '#888'); roundRectPath(W / 2 - hw, y - barH / 2, hw * 2, barH, 2); ctx.fill(); + ctx.shadowBlur = 0; + ctx.fillStyle = '#fff'; ctx.font = `bold ${Math.max(8, sz * 0.5) | 0}px sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + textReadable('0', W / 2, y, lefty); + if (miss) ctx.globalAlpha = 1; + return; + } + // Fretted gem: glow rect + body rounded rect + white fret number. + ctx.fillStyle = hit ? '#166534' : (SDIM[s] || '#222'); + roundRectPath(x - half - 4, y - half - 4, sz + 8, sz + 8, sz / 3); ctx.fill(); + if (hit) { ctx.shadowColor = '#22c55e'; ctx.shadowBlur = 14; } + ctx.fillStyle = hit ? '#22c55e' : (SC[s] || '#888'); + roundRectPath(x - half, y - half, sz, sz, sz / 5); ctx.fill(); + ctx.shadowBlur = 0; + ctx.fillStyle = '#fff'; ctx.font = `bold ${Math.max(10, sz * 0.5) | 0}px sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + textReadable(String(n.f), x, y, lefty); + // Technique glyph above (PM / h / p / ~ / accent) — small, host-adjacent. + if (sz >= 14) { + const g = n.pm ? 'PM' : n.ho ? 'H' : n.po ? 'P' : n.tp ? 'T' : n.tr ? '~' : ''; + if (g) { ctx.fillStyle = '#ffe08a'; ctx.font = `bold ${Math.max(9, sz * 0.3) | 0}px sans-serif`; textReadable(g, x, y - half - 6, lefty); } + } + if (miss) ctx.globalAlpha = 1; + } + function roundRectPath(x, y, w, h, r) { + const rr = Math.min(r, w / 2, h / 2); + ctx.beginPath(); + ctx.moveTo(x + rr, y); ctx.arcTo(x + w, y, x + w, y + h, rr); ctx.arcTo(x + w, y + h, x, y + h, rr); + ctx.arcTo(x, y + h, x, y, rr); ctx.arcTo(x, y, x + w, y, rr); ctx.closePath(); + } + function drawNotes(bundle, now, lefty) { + // Far notes first so nearer (bigger) gems paint over them. + const src = (bundle.notes || []).filter(n => { const dt = n.t - now; return dt <= VISIBLE_SECONDS && (dt > -0.05 || (n.sus > 0 && n.t + n.sus > now)); }); + src.sort((a, b) => b.t - a.t); + for (const n of src) { + let tOff = n.t - now, p; + if (tOff < -0.05 && n.sus > 0 && n.t + n.sus > now) p = { y: 0.82, scale: 1.0 }; + else p = project(tOff); + if (!p) continue; + drawNote(bundle, n, fretX(n.f, p.scale), p.y * H, p.scale, lefty); + } + } + + function draw(bundle) { + if (!ctx || !bundle) return; + resize(); + const now = bundle.currentTime || 0; + const dt = lastTime == null ? 1 / 60 : Math.max(1 / 240, Math.min(0.1, now - lastTime)); + lastTime = now; + smoothFret(bundle, now, dt); + const nStr = Math.max(1, bundle.stringCount || 6), inverted = !!bundle.inverted, lefty = !!bundle.lefty; + ctx.fillStyle = BG; ctx.fillRect(0, 0, W, H); + ctx.save(); + if (lefty) { ctx.translate(W, 0); ctx.scale(-1, 1); } + drawHighway(); + drawFretLines(); + drawBeats(bundle, now); + drawStrings(nStr, inverted); + drawSustains(bundle, now); + drawNowLine(); + drawNotes(bundle, now, lefty); + drawFretNumbers(bundle, now, lefty); + ctx.restore(); + } + + return { + init(c, bundle) { canvas = c; ctx = c.getContext('2d'); displayMaxFret = 12; lastTime = null; resize(); if (bundle) { const a = (bundle.anchors || [])[0]; if (a) displayMaxFret = Math.max(8, a.fret + a.width + 3); } window.addEventListener('resize', resize); }, + draw, + resize, + destroy() { window.removeEventListener('resize', resize); canvas = null; ctx = null; } + }; + } + function makeBuiltin2DTabRenderer() { // Standard guitar-tab look: paper background, string lines, fret numbers // sitting on the strings with the line broken behind each number, plain @@ -13870,7 +14121,7 @@ // upgrades itself; until then the in-tree 2D highway renders. if (kind === 'highway_2d') { const f = vizFactoryFor('highway_2d'); - return f ? { factory:f, label:'2D Highway (host)' } : { factory:makeBuiltin2DRenderer, label:'2D Highway' }; + return f ? { factory:f, label:'2D Highway (host)' } : { factory:makeClassic2DHighwayRenderer, label:'2D Highway' }; } if (kind === 'tab_2d') return { factory:makeBuiltin2DTabRenderer, label:'Tab' }; if (kind === 'notation_2d') return { factory:makeBuiltin2DNotationRenderer, label:'Notation' }; From cb6cc0d1ff7b1b1ebe01dd993259eee86bc73643 Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou <16130099+ChrisBeWithYou@users.noreply.github.com> Date: Tue, 14 Jul 2026 06:22:30 -0500 Subject: [PATCH 11/18] feat(ladder): add guitar + bass tapping ladders (concept_tapping pack, v0.1.11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New Concepts pack carved out of Legato (the lone leg_tapping rung re-homed as tap_articulation). Designed by a 6-agent panel: learning-design (chair), guitar/bass-pedagogy, metal-idiom, harmony-theory, rhythm-meter. Engine: split buildTappingExercise on a tapArp flag. Plain 'tapping' is now the single-string tap->pull articulation drill (replaces the off-idiom "scalar tap +12 across the neck"); tapArp:true routes to buildTappedArpeggioExercise / buildTapCascadePath — idiomatic tap->pull->hammer cascade cells reusing the sweepArpeggioPositions grid (guitar 3-note EVH cell, bass 2-note tap->pull; wide interval always routed to the RH tap; fret cap 22; no-unison seam pass for the dim7 symmetry). tapArp plumbed like sweepStrings (hidden field + readConfig + anti-leak default). Rungs: guitar 5 (tap_articulation -> tap_cell -> tap_cascade -> tap_sevenths -> tap_changes, descending ceilings, tap_cascade density-ramps triplet->sextuplet); bass 4 (btap_articulation -> btap_arpeggio -> btap_groove -> btap_apply, register-clamped frets 5-12). One concept_tapping band (bass rungs instrument-hidden). Skill-tree edges, form-cues, and screen.html options wired. Verified: node --check + probe-tapping-ladders (9/9 rungs) + smoke-generators 134/134, core-purity 40/40, strings, renderers 8/8. Generation-path (core) work — no HOST CHECK needed. Deferred to v2: bass two-hand independence, bass open-string cascades, guitar tapped-pedal-point. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01MS2YFb6UUSwJVV6CmEa25i --- ROADMAP.md | 6 ++ plugin.json | 2 +- screen.html | 16 +++- screen.js | 270 ++++++++++++++++++++++++++++++++++++++++++++++++---- 4 files changed, 276 insertions(+), 18 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 378f1a4..d81b817 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -26,6 +26,12 @@ Verification: 18 Playwright smoke suites (`.claude/skills/run-virtuoso/`, `npm t ## Open threads +- **STOPPED HERE (2026-07-14 session) — TAPPING LADDERS (guitar + bass) BUILT on `virtuoso-dev` (v0.1.11); NOT yet committed/pushed.** New `concept_tapping` Concepts pack, carved out of Legato (the lone `leg_tapping` rung re-homed as `tap_articulation`). Full 6-agent group-design (learning-design chair + guitar/bass-pedagogy + metal-idiom + harmony + rhythm). Detail + decisions: memory `project_tapping_ladders_2026-07-14`. + - **Engine:** `buildTappingExercise` split — plain `tapping` = the single-string tap→pull ARTICULATION drill (replaces the off-idiom "scalar tap +12 across the neck" metal-idiom flagged); new `tapArp` flag → `buildTappedArpeggioExercise`/`buildTapCascadePath` = idiomatic tap→pull→hammer CASCADE cells (reuses `sweepArpeggioPositions` grid; guitar = 3-note EVH cell, bass = 2-note tap→pull; wide interval always routed to the RH tap; fret cap 22; no-unison adjacency pass incl. the dim7-symmetry seam). `tapArp` plumbed like `sweepStrings` (hidden field + readConfig + anti-leak default). + - **Rungs:** guitar 5 (`tap_articulation`→`tap_cell`→`tap_cascade`→`tap_sevenths`→`tap_changes`, descending tempo ceilings, `tap_cascade` density-ramps triplet→sixteenth_triplet); bass 4 (`btap_articulation`→`btap_arpeggio`→`btap_groove`→`btap_apply`, register-clamped frets 5–12). Both in the one `concept_tapping` band (bass rungs instrument-hidden). Edges + FORM_CUE + `screen.html` options wired. + - **Verified:** `node --check` + `probe-tapping-ladders.mjs` (9/9 rungs: startup guards pass, tp/po/ho present, frets ≤22, no adjacent unison, bass on bass strings) + smoke-generators 134/134, core-purity 40/40, strings, renderers 8/8. Screenshots confirm the `17ᵀ→5ᵖ→9ʰ` cell renders in Tab. **No HOST CHECK needed — generation-path (core) work, our USP, not shell.** + - **DEFERRED (v2, engine gaps flagged by the panel):** bass two-hand independence (needs a two-voice engine), bass open-string cascades, guitar tapped-pedal-point color rung. Not gated the ladder on these. + - **NEXT:** commit (Conventional Commits) when asked; consider promoting to `main`/beta alongside the still-open PR #4. - **STOPPED HERE (2026-07-10 session) — v0.1.10 grading-DISPLAY fixes SHIPPED to `virtuoso-dev` (`5fa6f13`); PR #4 dev→main OPEN (not merged), so `main` is still 0.1.9 and beta is NOT yet re-cut.** Detail + lessons: memory `project_grading_display_fixes_2026-07-10`. - **Card `0%`/`0-of-0` hero** — `shareCardAction` was STILL delegating to note_detect's `renderResultsCard`/`copyResultsCard` (a stale v0.1.3 path that survived the ratified v0.1.4 "keep our renderer" decision). nd's card hero reads top-level `d.accuracy`/`d.hits`/`d.misses` we never set (only `stats[]`) → 0/0, while the delegation silently suppressed our skins + crest on desktop. **Retired the delegation** → Virtuoso always draws its own skinned `renderShareCardImage`. (`_ndCardApi`/`_resultsCardData`/`_virCardOverlay` kept dormant; the `virtuoso_beta` brand was `cut-beta.mjs`'s token-rename, not note_detect.) - **3D highway hit-flare invisible** — highway_3d's **feedBack#254** per-note PROVIDER (`getNoteState`) is authoritative over the `notedetect:hit` window-event marks and culls the gem ~100ms past the strike line unless the provider returns a live verdict THAT FRAME; ours only returned `'hit'` after the async contained drain (~150-400ms late) → gem culled first. Fix: a display-only `{ state:'active', live:true }` **live-ear keep-alive** (`_ptLive` captured in `ptOnPitch`; mirrors note_detect's own `noteStateFor`), pitch-gated ≤ the credit floor + level-gated + ±0.12s window; it renders NO judgment and credit stays verifier-only via `_ptScored`. notedetect-expert APPROVED (not a parallel grader). Return the OBJECT with `live:true` (highway's non-latched `hit-live` branch) — a bare `'active'` string sticks green. **Flare CONFIRMED lighting on Christian's real DI** ("nailed it") → the `_ptHandle` minigames ear DOES hear his DI. diff --git a/plugin.json b/plugin.json index 1cf0272..5c2fe88 100644 --- a/plugin.json +++ b/plugin.json @@ -1,7 +1,7 @@ { "id": "virtuoso", "name": "Virtuoso", - "version": "0.1.10", + "version": "0.1.11", "description": "Practice studio for guitar & bass — scale, technique, and rhythm drills, timed workouts, and jam backing that teach skills you take off the screen.", "category": "practice", "icon": "assets/thumb.png", diff --git a/screen.html b/screen.html index 1f67806..50bbd33 100644 --- a/screen.html +++ b/screen.html @@ -2454,7 +2454,15 @@

Virtuoso

- + + + + + + + + + @@ -2755,6 +2763,12 @@

Virtuoso

rung). Pathway-driven; 0 = full box. Same setFieldSilent→readConfig contract; anti-leak defaulted in applyPathwayConfig. --> + + diff --git a/screen.js b/screen.js index 2e5b8f4..95292ea 100644 --- a/screen.js +++ b/screen.js @@ -48,7 +48,7 @@ // a plugin's own version into its screen (note_detect hardcodes `_ND_VERSION` // the same way), so this is the display mirror of plugin.json's "version". // BUMP THIS WHENEVER plugin.json's version changes (release checklist). - const VIRTUOSO_VERSION = '0.1.10'; + const VIRTUOSO_VERSION = '0.1.11'; // =========================================================================== // §1 · CONSTANTS & MUSIC-THEORY DATA @@ -637,6 +637,21 @@ ['sweep5_box', 'sweep_roll_apex'], ['sweep_roll_apex', 'sweep7_color'], ['sweep7_color', 'sweep_changes'], + // Tapping ladder (panel 2026-07-14) — guitar builds on Legato (tapping = both-hands + // legato) with a cross-link to Arpeggios (know the shape before you tap it, same + // logic as arp_sweeps→sweep3_triad); bass builds on the pinky-legato rung + the + // neck-arpeggio rung. First incoming edge = each rung's "Builds on" hint. + ['leg_runs', 'tap_articulation'], + ['tap_articulation', 'tap_cell'], + ['tap_cell', 'tap_cascade'], + ['arp_seventh_shapes', 'tap_cascade'], + ['tap_cascade', 'tap_sevenths'], + ['tap_sevenths', 'tap_changes'], + ['bass_finger_legato', 'btap_articulation'], + ['btap_articulation', 'btap_arpeggio'], + ['bass_arp_neck', 'btap_arpeggio'], + ['btap_arpeggio', 'btap_groove'], + ['btap_groove', 'btap_apply'], ]; // Band map — the single source for the two-level picker that replaces the SVG // skill-tree display (which tangles at 27 nodes). L1 = band (array order); L2 = @@ -677,8 +692,13 @@ { id:'concept_expression', label:'Expression', kind:'style', family:'Concepts', buildsOn:'Builds on Core Beginner — a fretted note and a target pitch. Make the note SING: vibrato width first, then bends that land dead in tune (half → whole → mixed).', pathways:['exp_vibrato','exp_bend_half','exp_bend_whole','exp_bend_mixed'] }, { id:'concept_rhythm', label:'Rhythm', kind:'style', family:'Concepts', buildsOn:'Builds on Core — a steady pulse and the pentatonic box. Own TIME itself, easy→mastery: the grid (subdivisions, the 16th pocket) → the feel (swing, syncopation, the gallop, moving the accent) → the pulse frame (one-note pulse, half/double-time, odd meters) → two pulses at once (over the barline) → trade bars and make your own groove. World rhythms (tresillo, clave) ride the one-note pulse. Instrument-agnostic — works on guitar or bass.', pathways:['rhy_subdivision','rhy_sixteenth','rhy_swing','rhy_displacement','rhy_gallop_snap','rhy_accent_displace','rhy_single_string','rhy_half_double','rhy_odd_meter','rhy_over_barline','rhy_trade_bars'] }, { id:'concept_picking', label:'Picking', kind:'style', family:'Concepts', buildsOn:'Builds on Core — the chromatic warmup and one-finger-per-fret sync. The pick-hand engine: tremolo, alternate across strings, economy crossings, string skipping, hybrid picking.', pathways:['pick_tremolo','pick_alternate','pick_chromatic_16ths','pick_economy','pick_string_skip','pick_hybrid','pick_herta'] }, - { id:'concept_legato', label:'Legato', kind:'style', family:'Concepts', buildsOn:'Builds on Core — clean fretting and a scale shape. The fretting-hand engine: hammer-ons/pull-offs, 3NPS legato runs, then two-hand tapping.', pathways:['leg_hopo','leg_runs','leg_tapping'] }, + { id:'concept_legato', label:'Legato', kind:'style', family:'Concepts', buildsOn:'Builds on Core — clean fretting and a scale shape. The fretting-hand engine: hammer-ons/pull-offs, then 3NPS legato runs. (Two-hand tapping graduated into its own Tapping ladder.)', pathways:['leg_hopo','leg_runs'] }, { id:'concept_sweeps', label:'Sweep Picking', kind:'style', family:'Concepts', buildsOn:'Builds on the Arpeggios pack — you can already spell a triad across the neck in a shape. Sweep picking turns that vertical chord shape into one fluid raked motion, the fretting hand muting everything not sounding: 3-string triad -> the 5-string box -> the finger-roll & clean apex -> seventh sweeps -> sweeps through the changes. Cleanliness comes first; speed is a byproduct.', pathways:['sweep3_triad','sweep5_box','sweep_roll_apex','sweep7_color','sweep_changes'] }, + // Tapping ladder — two-hand legato, carved out of the Legato pack (panel + // 2026-07-14). Guitar climbs to the neoclassical cascade; the bass rungs (btap_*) + // are register-clamped and shown only on bass via isHiddenNode. Guitar and bass + // mirror rungs 1–2, then diverge (guitar → shred cascade, bass → tapped groove). + { id:'concept_tapping', label:'Tapping', kind:'style', family:'Concepts', buildsOn:"Builds on the Legato pack — clean hammer-ons and pull-offs. Tapping is legato with both hands: the picking hand joins the fretting hand on the fingerboard to sound notes beyond one hand's reach. Isolate the tap-and-pull, build the tapped cell, then the wide arpeggio cascades — and end over the changes and in a groove, not as fireworks.", pathways:['tap_articulation','tap_cell','tap_cascade','tap_sevenths','tap_changes','btap_articulation','btap_arpeggio','btap_groove','btap_apply'] }, // Bass family — DISSOLVED into the instrument-aware Core (2026-06-08): the former // Bass: Foundations/Scales/Arpeggios/Groove packs were fundamentals filed as Style // only because there was no bass Core band. Their rungs now live in core_* above @@ -1736,14 +1756,9 @@ base:{ practiceType:'legato', scale:'natural_minor', meter:'4/4', subdivision:'sixteenth', bpm:85, bars:8, direction:'up_down', sequence:'none', advancedMode:true, fretboardSystem:'3nps', stringSetup:'guitar_6_standard', renderer:'highway_3d', key:'A', shape:1 }, vary:[ { shape:1 }, { shape:2 }, { shape:3 }, { scale:'major', key:'C' }, { shape:5 } ] }, - leg_tapping: { - label:'Tapping', - goal:'The picking hand joins the fretting hand on the fingerboard — tap a high note, pull off to fretted notes below. Two-hand tapping extends legato past the stretch of one hand and opens the wide arpeggio cascades that define the technique.', - scales:['natural_minor','harmonic_minor','minor_pentatonic'], - tempoTiers:[60, 80, 100, 120], - base:{ practiceType:'tapping', scale:'natural_minor', meter:'4/4', subdivision:'eighth', bpm:80, bars:8, direction:'up_down', sequence:'none', advancedMode:true, fretboardSystem:'caged', stringSetup:'guitar_6_standard', renderer:'highway_3d', key:'A', shape:'E' }, - vary:[ { key:'A', shape:'E' }, { key:'E', shape:'E' }, { scale:'harmonic_minor' }, { key:'D', shape:'E' }, { scale:'minor_pentatonic', key:'A' } ] - }, + // (Tapping moved out of Legato into its own concept_tapping ladder — panel + // 2026-07-14. The old single `leg_tapping` rung is re-homed as `tap_articulation`, + // the ladder's rung 1; the full guitar+bass ladders live below the sweep pack.) // ── SWEEP PICKING ladder (new concept_sweeps pack) ────────────────────────── // The dedicated sweep vertical (was only 2 isolated rungs). guitar-pedagogy + // metal-idiom panel 2026-06-07: string count grows 3→5→6, tempo ceilings DESCEND @@ -1795,6 +1810,98 @@ base:{ practiceType:'sweep_arpeggios', scale:'natural_minor', chordDepth:'triad', chordOverride:'auto', progression:'i-VI-III-VII', meter:'4/4', subdivision:'sixteenth', bpm:52, bars:8, direction:'up_down', sequence:'none', advancedMode:true, fretboardSystem:'caged', stringSetup:'guitar_6_standard', renderer:'highway_3d', key:'A', shape:'E', fretMin:0, fretMax:17 }, vary:[ { key:'A', shape:'E', progression:'i-VI-III-VII' }, { key:'A', shape:'A', progression:'i-VII-VI-VII' }, { key:'C', shape:'E', scale:'major', progression:'ii-V-I' }, { key:'G', shape:'E', scale:'major', progression:'I-vi-IV-V' }, { key:'A', shape:'E', scale:'harmonic_minor', chordDepth:'seventh', progression:'minor_ii_V_i' } ] }, + // ── TAPPING ladder (new concept_tapping pack) ────────────────────────────── + // The two-hand-legato vertical, carved out of Legato (panel 2026-07-14: + // learning-design chair + guitar/bass-pedagogy + metal-idiom + harmony + rhythm). + // Engine facts this pack rides on: + // • Rung 1 is the ARTICULATION drill (plain 'tapping': single-string tap→pull); + // rungs 2–5 set tapArp:true → the CASCADE builder (tap→pull→hammer per string, + // the wide interval always routed to the RH tap). + // • Subdivision tracks the CELL note-count so cells land on the beat (rhythm): + // tap→pull = eighth, the 3-note tap-pull-hammer cell = triplet, the rolling + // two-cells-per-beat cascade = sixteenth_triplet. + // • Ceilings DESCEND on the wider-reach rungs (clean before fast — the sweep + // ladder's discipline; a tapped note must ring as loud as a picked one). + // • Cascade rungs stay on scales WITH a diatonic-quality row (natural/harmonic + // minor, major) — never pentatonic (no seventh row). + tap_articulation: { + label:'Tap & Pull-Off', + goal:"The atom of tapping: the picking-hand finger joins the fretboard, TAPS a note high on the string, then flicks OFF it — a pull-off that sounds the note your fretting hand is already holding. One string, one tapped note, one fretted note. The whole lesson is the pull-off being a sideways flick toward the palm (it plucks the string), not a lift, so the fretted note rings clean instead of dying. Tap on the beat, pull-off on the '&' — a dead-even trill. A click instead of a note is what kills it; build the tempo slowly.", + scales:['minor_pentatonic','natural_minor'], + tempoTiers:[60, 75, 90, 105], + base:{ practiceType:'tapping', scale:'minor_pentatonic', meter:'4/4', subdivision:'eighth', bpm:60, bars:8, direction:'up_down', sequence:'none', advancedMode:true, fretboardSystem:'single_string', stringSetup:'guitar_6_standard', renderer:'highway_3d', key:'A' }, + vary:[ { key:'A' }, { key:'E' }, { key:'D' }, { scale:'natural_minor', key:'A' }, { scale:'natural_minor', key:'E' } ] + }, + tap_cell: { + label:'The Tapped Cell', + goal:"The figure that made tapping famous — the Van Halen cell. On one string: TAP a high note, PULL OFF to a fretting finger, HAMMER ON to a second — three notes spelling a triad you could never reach with one hand, fired as a rolling triplet. The tap buys the wide interval the stretch can't; that's the whole point of the technique. Start with the root-octave-fifth shape and keep the triplet dead even — clean and slow beats fast and smeared, every time.", + scales:['natural_minor','harmonic_minor'], + tempoTiers:[55, 70, 85, 100], + base:{ practiceType:'tapping', tapArp:true, scale:'natural_minor', chordDepth:'triad', chordOverride:'auto', progression:'i-VI-III-VII', meter:'4/4', subdivision:'triplet', bpm:55, bars:8, direction:'up_down', sequence:'none', advancedMode:true, fretboardSystem:'caged', stringSetup:'guitar_6_standard', renderer:'highway_3d', key:'A', shape:'E', sweepStrings:1, fretMin:5, fretMax:14 }, + vary:[ { key:'A' }, { key:'E' }, { scale:'harmonic_minor', key:'A' }, { key:'D' }, { scale:'harmonic_minor', key:'E' } ] + }, + tap_cascade: { + label:'Tapped Arpeggio Cascade', + goal:"Move the cell across the strings and a whole arpeggio pours down the neck — tap→pull→hammer on one string hands off to the next, outlining a triad through octaves you could never sweep cleanly. The lesson is the string change: a gap-free hand-off with no doubled note, each string muted the instant it's done. Learn the shape as one cell per beat, then let it double into the rolling cascade — the density jump IS the rung.", + scales:['natural_minor','harmonic_minor','major'], + tempoTiers:[50, 62, 66, 74], + tempoTierSubdivs:['triplet', 'triplet', 'sixteenth_triplet', 'sixteenth_triplet'], + base:{ practiceType:'tapping', tapArp:true, scale:'natural_minor', chordDepth:'triad', chordOverride:'auto', progression:'i-VI-III-VII', meter:'4/4', subdivision:'triplet', bpm:50, bars:8, direction:'up_down', sequence:'none', advancedMode:true, fretboardSystem:'caged', stringSetup:'guitar_6_standard', renderer:'highway_3d', key:'A', shape:'E', sweepStrings:3, fretMin:5, fretMax:17 }, + vary:[ { sweepStrings:3, key:'A' }, { sweepStrings:5, key:'E' }, { sweepStrings:5, key:'A' }, { sweepStrings:6, key:'D' }, { sweepStrings:6, key:'A', scale:'harmonic_minor' } ] + }, + tap_sevenths: { + label:'Seventh & Diminished Cascade', + goal:"Add the 7th and one more string-crossing: m7 / maj7 / dom7 arpeggios tapped across the neck, the seventh's color turning the triad into a chord with a voice. Harmonic minor hands you the diminished-7th cascade — one symmetric shape you slide up in minor thirds over the dominant, the sound of a shred solo taking flight. Outline the chord, not a scale; a clean tapped 7th is worth more than a fast blur.", + scales:['natural_minor','major','harmonic_minor'], + tempoTiers:[46, 58, 70, 82], + base:{ practiceType:'tapping', tapArp:true, scale:'natural_minor', chordDepth:'seventh', chordOverride:'auto', progression:'i-VI-III-VII', meter:'4/4', subdivision:'triplet', bpm:46, bars:8, direction:'up_down', sequence:'none', advancedMode:true, fretboardSystem:'caged', stringSetup:'guitar_6_standard', renderer:'highway_3d', key:'A', shape:'E', fretMin:0, fretMax:19 }, + vary:[ { scale:'natural_minor', key:'A' }, { scale:'major', key:'C' }, { scale:'harmonic_minor', key:'A' }, { key:'E' }, { scale:'harmonic_minor', key:'D' } ] + }, + tap_changes: { + label:'Tapping the Changes', + goal:"The musical payoff: run the tapped cascade through a progression, re-rooting the shape on each chord in time. One figure slides to the nearest position per change — the skill is landing the new root's first tap cleanly on the downbeat, not the fireworks. This is tapping turned into a line, not a trick.", + scales:['natural_minor','major','harmonic_minor'], + tempoTiers:[50, 60, 70, 80], + base:{ practiceType:'tapping', tapArp:true, scale:'natural_minor', chordDepth:'triad', chordOverride:'auto', progression:'i-VI-III-VII', meter:'4/4', subdivision:'triplet', bpm:50, bars:8, direction:'up_down', sequence:'none', advancedMode:true, fretboardSystem:'caged', stringSetup:'guitar_6_standard', renderer:'highway_3d', key:'A', shape:'E', fretMin:0, fretMax:17 }, + vary:[ { progression:'i-VI-III-VII', key:'A' }, { progression:'i-VII-VI-VII', key:'A' }, { scale:'major', key:'C', progression:'ii-V-I' }, { scale:'major', key:'G', progression:'I-vi-IV-V' }, { scale:'harmonic_minor', chordDepth:'seventh', progression:'minor_ii_V_i', key:'A' } ] + }, + // ── Bass tapping ladder (mirrors the arc; DIVERGES on content — bass-pedagogy + // 2026-07-14). Register-clamped octave/arpeggio taps: the fretted note stays in + // the neck's meat (5–12), the tap reaches past hand-span (a feature, not a + // stretch). 2-note tap→pull cells (root + one chord tone — no dense low clusters); + // the two-hand-independence capstone needs a two-voice engine, staged for v2. + btap_articulation: { + label:'Bass Tap & Pull-Off', + goal:"Tapping's atom on bass: the picking hand taps a note up the string, then flicks off it — a pull-off that sounds the fretted note your fretting hand holds. Stay in the meat of the neck where the low strings speak clean and punchy; a weak tap gives a dead thump, so the lesson is an articulate tap and a ringing pull-off. Tap on the beat, pull on the '&'.", + scales:['minor_pentatonic','natural_minor'], + tempoTiers:[52, 64, 76, 88], + base:{ practiceType:'tapping', scale:'minor_pentatonic', meter:'4/4', subdivision:'eighth', bpm:52, bars:8, direction:'up_down', sequence:'none', advancedMode:true, fretboardSystem:'single_string', stringSetup:'bass_4_standard', renderer:'highway_3d', key:'A' }, + vary:[ { key:'A' }, { key:'E' }, { key:'G' }, { scale:'natural_minor', key:'A' }, { stringSetup:'bass_5_standard', key:'E' } ] + }, + btap_arpeggio: { + label:'Tapped Arpeggio Reach', + goal:"Now use the tap for what only tapping can do: reach past your hand. Tap the octave (or tenth) above, pull to the fretted root and chord tone below — a wide arpeggio spanned without shifting your fretting hand at all. Two independent hands, so the twelve-fret span is a gift, not a stretch. Root low and clean on the downbeat; keep the strings you're not tapping damped.", + scales:['natural_minor','harmonic_minor'], + tempoTiers:[44, 54, 64, 72], + base:{ practiceType:'tapping', tapArp:true, scale:'natural_minor', chordDepth:'triad', chordOverride:'auto', progression:'i-VI-III-VII', meter:'4/4', subdivision:'sixteenth', bpm:44, bars:8, direction:'up_down', sequence:'none', advancedMode:true, fretboardSystem:'caged', stringSetup:'bass_4_standard', renderer:'highway_3d', key:'A', fretMin:5, fretMax:15, sweepStrings:2 }, + vary:[ { key:'A' }, { key:'E' }, { key:'D' }, { scale:'harmonic_minor', key:'A' }, { stringSetup:'bass_5_standard', key:'B' } ] + }, + btap_groove: { + label:'The Tapped Figure', + goal:"Tapping as a LINE, not a stunt: a repeating tapped arpeggio figure that could carry a groove, spread across the strings and locked in a pocket. Damping is the whole game on bass — sequence a few strings before more, and let only the note you mean to sound ring. Make it repeatable and even over eight bars; a tapped figure you can hold is worth more than a fast one you can't.", + scales:['natural_minor','harmonic_minor'], + tempoTiers:[46, 56, 66, 76], + base:{ practiceType:'tapping', tapArp:true, scale:'natural_minor', chordDepth:'triad', chordOverride:'auto', progression:'i-VI-III-VII', meter:'4/4', subdivision:'sixteenth', bpm:46, bars:8, direction:'up_down', sequence:'none', advancedMode:true, fretboardSystem:'caged', stringSetup:'bass_4_standard', renderer:'highway_3d', key:'A', fretMin:5, fretMax:17, sweepStrings:3 }, + vary:[ { sweepStrings:3, key:'A' }, { sweepStrings:3, key:'E' }, { sweepStrings:4, key:'A' }, { key:'D' }, { stringSetup:'bass_5_standard', key:'B', sweepStrings:4 } ] + }, + btap_apply: { + label:'Tapping Over a Vamp', + goal:"The payoff: run the tapped figure over a vamp, re-rooting on each chord in time so the tapped line serves the harmony. Land the new root low on the downbeat and let the figure follow the changes — a musical tapped bass line, not decoration. This is where the technique earns its place in a song.", + scales:['natural_minor','harmonic_minor'], + tempoTiers:[46, 56, 66, 76], + base:{ practiceType:'tapping', tapArp:true, scale:'natural_minor', chordDepth:'triad', chordOverride:'auto', progression:'i-VI-III-VII', meter:'4/4', subdivision:'eighth', bpm:46, bars:8, direction:'up_down', sequence:'none', advancedMode:true, fretboardSystem:'caged', stringSetup:'bass_4_standard', renderer:'highway_3d', key:'A', fretMin:5, fretMax:17, sweepStrings:3 }, + vary:[ { progression:'i-VI-III-VII', key:'A' }, { progression:'i-VII-VI-VII', key:'A' }, { key:'E' }, { key:'D' }, { stringSetup:'bass_5_standard', key:'B', progression:'i-VI-III-VII' } ] + }, // ── FINGERSTYLE pack (Acoustic & Fingerstyle — 2026-06-12, the community // spider thread). The fingerstyle hand from the ground up: the p-i-m-a // free-stroke pattern over open grips, then the two-string crossing spider @@ -4411,6 +4518,10 @@ // sweepStrings (Sweep ladder entry rung): constrain a sweep to the top N // strings. Pathway-driven hidden field; 0/absent = the full box. 2–6 clamp. sweepStrings: Math.max(0, Math.min(6, parseInt(data.get('sweepStrings') || '0', 10) || 0)), + // tapArp (Tapping ladder): route practiceType 'tapping' through the tapped- + // arpeggio cascade builder (tap→pull→hammer per string) instead of the single- + // string tap→pull articulation drill. Pathway-driven hidden field; '' = drill. + tapArp: (data.get('tapArp') || '').toString() === 'true', // rhTechMode (Bass Groove & Right-Hand ladder): pulse|crossing|rake|three_finger. // Pathway-driven hidden field; absent = 'pulse' (the back-compatible default). rhTechMode: data.get('rhTechMode') || 'pulse', @@ -10257,21 +10368,139 @@ return { notes, chords: [], chordTemplates: [], handShapes: [], sections, duration: Math.max(t, totalTime) }; } + // Tapping ladder (concept_tapping pack, panel 2026-07-14) splits into two engines: + // the ARTICULATION drill (this function) and the tapped-arpeggio CASCADE + // (buildTappedArpeggioExercise). tapArp routes 'tapping' to the cascade. function buildTappingExercise(cfg) { + if (cfg.tapArp) return buildTappedArpeggioExercise(cfg); + // Articulation drill (ladder rung 1): a single-string two-note tap cell — the + // fretting hand HOLDS a scale tone, the picking hand TAPS the octave above, and a + // pull-off sounds the fretted note. tap (on the beat) → pull-off (the "&"). This + // replaces the old off-idiom "every scale note tapped +12 across the whole neck" + // (metal-idiom 2026-07-14: nobody taps a scale — the atom is the tap/pull cell). const step = secondsPerDivision(cfg), totalTime = cfg.bars * measureSeconds(cfg); + const TAP = 12, FRET_CAP = 22; // most necks end ~22; keep the tapped note ON the neck + const isBass = String(cfg.stringSetup || '').indexOf('bass') === 0; + // Register clamp (bass-pedagogy 2026-07-14): the fretted note stays where the + // string speaks cleanly — frets 5+ on bass (below = sub-fundamental mud + the + // grading floor), 2+ on guitar — and the tap (+12) stays under the fret cap. + const loFret = isBass ? 5 : 2; const allPos = scalePositionsForSystem(cfg); - const TAP = 12; const events = []; for (const p of allPos) { - if (p.f + TAP > 24) continue; - events.push({ s: p.s, f: p.f, tp: false }); - events.push({ s: p.s, f: p.f + TAP, tp: true }); + if (p.f < loFret || p.f + TAP > FRET_CAP) continue; + events.push({ s: p.s, f: p.f + TAP, tp: true }); // TAP the octave (picking hand) + events.push({ s: p.s, f: p.f, po: true }); // PULL-OFF to the fretted tone } - if (!events.length) throw new Error('No tapping positions — try fretMin ≤ 12.'); + if (!events.length) throw new Error('No tapping positions — need a fretted note between fret ' + loFret + ' and ' + (FRET_CAP - TAP) + ' (widen the range / use a single-string system).'); const sus = Math.max(0.05, step * 0.85); return fillNotesFromSeq(events, { step, totalTime, sus, loopCycleSteps: events.length, name: `Tapping — ${cfg.key} ${cfg.scale}` }); } + // One tapped-arpeggio CASCADE path for one chord (Tapping ladder rungs 2–5). The + // picking-hand tap buys reach past one hand's span, so a WIDE arpeggio pours down + // the neck as a string of tap→pull→hammer cells — one cell per string, ascending + // low→high, spelling the chord. Harmony-theory: it rides the same one-tone-per- + // string grid the sweep ladder builds (root on the bass string). Notes are pick- + // transparent (tp/po/ho) — no pkd, nothing re-judged on top of the host verdict. + function buildTapCascadePath(cfg, rootPc, quality, anchorFret) { + const opens = openMidisForConfig(cfg); + const isBass = String(cfg.stringSetup || '').indexOf('bass') === 0; + const FRET_CAP = 22; + const fLo = Math.max(isBass ? 5 : 0, Number.isFinite(cfg.fretMin) ? cfg.fretMin : 5); + const fHi = Math.min(FRET_CAP, Number.isFinite(cfg.fretMax) ? cfg.fretMax : 19); + const formula = CHORD_FORMULAS[quality] || CHORD_FORMULAS.min; + const tonePcs = new Set(formula.intervals.map(iv => (rootPc + iv) % 12)); + // One anchor chord-tone per string (root on the bass string), optionally sliced to + // the top-N strings for the entry rungs (sweepStrings:1 = the single-string EVH + // cell). dedupeUnisons drops a grid tone that repeats a pitch already taken on a + // lower string (the dim7-symmetry no-unison edge, harmony flag). + let grid = dedupeUnisons(sweepArpeggioPositions(cfg, rootPc, quality, anchorFret)); + if (cfg.sweepStrings) grid = sliceSweepTopStrings(grid, cfg.sweepStrings); + grid = grid.slice().sort((a, b) => a.s - b.s); // ascending low→high string + const cells = []; + for (const g of grid) { + const openS = opens[g.s]; + const toneFrets = []; + for (let f = fLo; f <= fHi; f++) if (tonePcs.has((openS + f) % 12)) toneFrets.push(f); + const lhLow = g.f; // fretting-hand anchor (the grid tone) + const reach = (lhLow < 5) ? 3 : 4; // hand span low on the neck is tighter + // Second fretting-hand tone: nearest chord tone within reach ABOVE the anchor. + const lhHigh = toneFrets.find(f => f > lhLow && f - lhLow <= reach); + // Tapped tone: a WIDE chord tone (≥5 frets — the span one hand can't cover) above + // the highest fretting tone; that unreachable interval is exactly why the tap + // exists. Falls back to the octave of the anchor. Wide-to-the-tap is the rule. + const topLH = lhHigh != null ? lhHigh : lhLow; + let tap = toneFrets.find(f => f > topLH && f - topLH >= 5 && f <= FRET_CAP); + if (tap == null && lhLow + 12 <= FRET_CAP) tap = lhLow + 12; + if (tap == null) continue; // no reachable tap on this string → skip it + const cell = [ + { s: g.s, f: tap, tp: true, midi: openS + tap }, // TAP the high tone + { s: g.s, f: lhLow, po: true, midi: openS + lhLow } // PULL-OFF to the anchor + ]; + // Guitar adds the HAMMER-ON to the second fretting tone (the 3-note EVH cell); + // bass stays a 2-note tap→pull (root + one chord tone, no dense low clusters — + // bass-pedagogy). No hammer where there's no in-reach second tone. + if (!isBass && lhHigh != null) cell.push({ s: g.s, f: lhHigh, ho: true, midi: openS + lhHigh }); + cells.push(cell); + } + if (!cells.length) return []; + let path = [].concat.apply([], cells); + // up_down: pour the cascade back down (reverse the CELL order, cells intact). + if (cfg.direction === 'up_down' && cells.length > 1) { + path = path.concat([].concat.apply([], cells.slice(0, -1).reverse())); + } + // No-unison pass (this builder bypasses the startup shape-guard): collapse an + // event repeating the immediately-preceding pitch — the audible dim7-symmetry + // artifact where a tap meets the next string's anchor at the same MIDI. + return path.filter((n, i, arr) => i === 0 || n.midi !== arr[i - 1].midi); + } + + // Tapped-arpeggio cascade exercise: lays one cascade per bar, re-rooting on the + // bar's chord, so a single-tonic drill and a "tap over changes" rung share one path + // (same per-bar chord machinery as buildSweepArpeggioExercise). + function buildTappedArpeggioExercise(cfg) { + const degrees = progressionDegreesForConfig(cfg); + const mLen = measureSeconds(cfg), step = secondsPerDivision(cfg); + const totalBars = Math.max(1, cfg.bars), duration = totalBars * mLen; + const isBass = String(cfg.stringSetup || '').indexOf('bass') === 0; + const fMin = Number.isFinite(cfg.fretMin) ? cfg.fretMin : 5; + const fMax = Number.isFinite(cfg.fretMax) ? cfg.fretMax : (isBass ? 15 : 19); + const anchorFret = Math.floor((fMin + fMax) / 2); + const perBar = Math.max(1, Math.floor(mLen / step + 1e-6)); + const sus = Math.max(0.05, step * 0.85); + const cfgWin = Object.assign({}, cfg, { fretMin: fMin, fretMax: fMax }); + const notes = [], sections = []; + // Carried across the whole exercise so no seam — the loop-wrap when a short + // cascade tiles to fill a bar, or the join between two bars' chords — ever + // re-articulates the same (string,fret) twice in a row (the no-unison adjacency + // rule; the per-path midi filter can't see these seams). + let lastKey = null; + for (let bar = 0; bar < totalBars; bar++) { + const degree = degrees[bar % degrees.length]; + const rootPc = chordRootForDegree(cfg, degree); + const quality = chordQualityForDegree(cfg.scale, cfg.chordDepth, degree, cfg.chordOverride, cfg.progression); + const path = buildTapCascadePath(cfgWin, rootPc, quality, anchorFret); + if (!path.length) continue; + const barStart = bar * mLen; + sections.push({ name: chordName(rootPc, quality), number: sections.length + 1, time: Number(barStart.toFixed(6)) }); + let cursor = 0; + for (let i = 0; i < perBar; i++) { + const t = barStart + i * step; + if (t >= duration - 1e-6) break; + let ev = path[cursor % path.length]; + let key = ev.s + ':' + ev.f; + if (key === lastKey) { cursor++; ev = path[cursor % path.length]; key = ev.s + ':' + ev.f; } + cursor++; lastKey = key; + const nf = { t: Number(t.toFixed(6)), s: ev.s, f: ev.f, sus }; + for (const k of SEQ_NOTE_FIELDS) if (ev[k] !== undefined) nf[k] = ev[k]; + notes.push(noteDefaults(nf)); + } + } + if (!notes.length) throw new Error('No tapped-arpeggio positions — widen the fret window (need chord tones a tap-span apart).'); + return { notes, chords: [], chordTemplates: [], handShapes: [], sections, duration }; + } + function buildPedalPointExercise(cfg) { const step = secondsPerDivision(cfg), totalTime = cfg.bars * measureSeconds(cfg); const allPos = scalePositionsForSystem(cfg); @@ -20187,6 +20416,9 @@ // sweepStrings (Sweep ladder): anti-leak defaulted → a 3-string entry rung's // limiter never persists into the next pathway's full-box sweep. setFieldSilent('sweepStrings', config.sweepStrings != null ? config.sweepStrings : '0'); + // tapArp (Tapping ladder): anti-leak defaulted → a cascade rung's tapped-arpeggio + // mode never persists into the next 'tapping' pathway's articulation drill. + setFieldSilent('tapArp', config.tapArp ? 'true' : ''); // rhTechMode (Bass Groove & Right-Hand): anti-leak defaulted to 'pulse' so a // rake/crossing/three-finger rung never carries into the next pathway. setFieldSilent('rhTechMode', config.rhTechMode || 'pulse'); @@ -20931,7 +21163,12 @@ fs_spider_wide: ['finger_proximity', 'light_grip'], leg_hopo: ['finger_proximity', 'light_grip'], leg_runs: ['finger_proximity', 'light_grip'], - leg_tapping: ['finger_proximity'], + // Tapping ladder (panel 2026-07-14): clean pull-off + light grip on the taps. + tap_articulation: ['finger_proximity', 'light_grip'], + tap_cell: ['finger_proximity', 'light_grip'], + tap_cascade: ['finger_proximity', 'light_grip'], + tap_sevenths: ['finger_proximity', 'light_grip'], + tap_changes: ['finger_proximity', 'light_grip'], major_scale_caged: ['guide_finger'], whole_neck_freedom: ['guide_finger'], }; @@ -20940,6 +21177,7 @@ core_beginner: 'light_grip', concept_picking: 'wrist_neutral', concept_legato: 'finger_proximity', + concept_tapping: 'finger_proximity', concept_rhythm: 'anchor_palm', }; // Bass candidates derive from the exercise CLASS (the bass ruling maps cues From 13a9c8f1fd5955704a4ce6b41b9b3fa68efd68c7 Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou <16130099+ChrisBeWithYou@users.noreply.github.com> Date: Tue, 14 Jul 2026 06:55:42 -0500 Subject: [PATCH 12/18] docs(roadmap): mark tapping ladders shipped to dev + folded into PR #4 cb6cc0d pushed; PR #4 now carries both v0.1.10 grading fixes and v0.1.11 tapping ladders. Corrects the STOPPED-HERE handoff + NEXT. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01MS2YFb6UUSwJVV6CmEa25i --- ROADMAP.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d81b817..da1d17d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -26,12 +26,12 @@ Verification: 18 Playwright smoke suites (`.claude/skills/run-virtuoso/`, `npm t ## Open threads -- **STOPPED HERE (2026-07-14 session) — TAPPING LADDERS (guitar + bass) BUILT on `virtuoso-dev` (v0.1.11); NOT yet committed/pushed.** New `concept_tapping` Concepts pack, carved out of Legato (the lone `leg_tapping` rung re-homed as `tap_articulation`). Full 6-agent group-design (learning-design chair + guitar/bass-pedagogy + metal-idiom + harmony + rhythm). Detail + decisions: memory `project_tapping_ladders_2026-07-14`. +- **STOPPED HERE (2026-07-14 session) — TAPPING LADDERS (guitar + bass) SHIPPED to `virtuoso-dev` (v0.1.11, commit `cb6cc0d`), PUSHED. Folded into PR #4 (dev→main) at Christian's call — PR #4 now carries BOTH the v0.1.10 grading fixes AND the v0.1.11 tapping ladders; title/body updated to match.** New `concept_tapping` Concepts pack, carved out of Legato (the lone `leg_tapping` rung re-homed as `tap_articulation`). Full 6-agent group-design (learning-design chair + guitar/bass-pedagogy + metal-idiom + harmony + rhythm). Detail + decisions: memory `project_tapping_ladders_2026-07-14`. - **Engine:** `buildTappingExercise` split — plain `tapping` = the single-string tap→pull ARTICULATION drill (replaces the off-idiom "scalar tap +12 across the neck" metal-idiom flagged); new `tapArp` flag → `buildTappedArpeggioExercise`/`buildTapCascadePath` = idiomatic tap→pull→hammer CASCADE cells (reuses `sweepArpeggioPositions` grid; guitar = 3-note EVH cell, bass = 2-note tap→pull; wide interval always routed to the RH tap; fret cap 22; no-unison adjacency pass incl. the dim7-symmetry seam). `tapArp` plumbed like `sweepStrings` (hidden field + readConfig + anti-leak default). - **Rungs:** guitar 5 (`tap_articulation`→`tap_cell`→`tap_cascade`→`tap_sevenths`→`tap_changes`, descending tempo ceilings, `tap_cascade` density-ramps triplet→sixteenth_triplet); bass 4 (`btap_articulation`→`btap_arpeggio`→`btap_groove`→`btap_apply`, register-clamped frets 5–12). Both in the one `concept_tapping` band (bass rungs instrument-hidden). Edges + FORM_CUE + `screen.html` options wired. - **Verified:** `node --check` + `probe-tapping-ladders.mjs` (9/9 rungs: startup guards pass, tp/po/ho present, frets ≤22, no adjacent unison, bass on bass strings) + smoke-generators 134/134, core-purity 40/40, strings, renderers 8/8. Screenshots confirm the `17ᵀ→5ᵖ→9ʰ` cell renders in Tab. **No HOST CHECK needed — generation-path (core) work, our USP, not shell.** - **DEFERRED (v2, engine gaps flagged by the panel):** bass two-hand independence (needs a two-voice engine), bass open-string cascades, guitar tapped-pedal-point color rung. Not gated the ladder on these. - - **NEXT:** commit (Conventional Commits) when asked; consider promoting to `main`/beta alongside the still-open PR #4. + - **NEXT:** review/merge **PR #4** (dev→main — now the combined grading+tapping PR, https://github.com/got-feedBack/feedBack-plugin-virtuoso/pull/4); on merge `main` goes 0.1.9→0.1.11 (skips a standalone 0.1.10 tag — both land together). Then re-cut beta (`node scripts/cut-beta.mjs --push`). Smoke still MOCKS the contained verifier, so the grading fixes remain real-desktop-DI-dogfood-verified only (standing owe). - **STOPPED HERE (2026-07-10 session) — v0.1.10 grading-DISPLAY fixes SHIPPED to `virtuoso-dev` (`5fa6f13`); PR #4 dev→main OPEN (not merged), so `main` is still 0.1.9 and beta is NOT yet re-cut.** Detail + lessons: memory `project_grading_display_fixes_2026-07-10`. - **Card `0%`/`0-of-0` hero** — `shareCardAction` was STILL delegating to note_detect's `renderResultsCard`/`copyResultsCard` (a stale v0.1.3 path that survived the ratified v0.1.4 "keep our renderer" decision). nd's card hero reads top-level `d.accuracy`/`d.hits`/`d.misses` we never set (only `stats[]`) → 0/0, while the delegation silently suppressed our skins + crest on desktop. **Retired the delegation** → Virtuoso always draws its own skinned `renderShareCardImage`. (`_ndCardApi`/`_resultsCardData`/`_virCardOverlay` kept dormant; the `virtuoso_beta` brand was `cut-beta.mjs`'s token-rename, not note_detect.) - **3D highway hit-flare invisible** — highway_3d's **feedBack#254** per-note PROVIDER (`getNoteState`) is authoritative over the `notedetect:hit` window-event marks and culls the gem ~100ms past the strike line unless the provider returns a live verdict THAT FRAME; ours only returned `'hit'` after the async contained drain (~150-400ms late) → gem culled first. Fix: a display-only `{ state:'active', live:true }` **live-ear keep-alive** (`_ptLive` captured in `ptOnPitch`; mirrors note_detect's own `noteStateFor`), pitch-gated ≤ the credit floor + level-gated + ±0.12s window; it renders NO judgment and credit stays verifier-only via `_ptScored`. notedetect-expert APPROVED (not a parallel grader). Return the OBJECT with `live:true` (highway's non-latched `hit-live` branch) — a bare `'active'` string sticks green. **Flare CONFIRMED lighting on Christian's real DI** ("nailed it") → the `_ptHandle` minigames ear DOES hear his DI. From 190fa5ebdc0dd39476a7e7ab9433140180937de0 Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou <16130099+ChrisBeWithYou@users.noreply.github.com> Date: Tue, 14 Jul 2026 07:50:43 -0500 Subject: [PATCH 13/18] feat(coach): prescriptive coach loop on the results modal (v0.1.17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The results modal already NAMES a run's dominant fault (the miss-heatmap + lean-strip heroes); this adds the FIX — one prescriptive "Practice next" button that loads a real Ladder rung for that fault. Display-only, downstream-only: reads the verdicts already produced (heat.fgMiss/transMiss, leanMs, nearMiss, the bass felt verdict) and re-judges nothing. - coachRxFor(): pure fault->drill mapping (finger / string-crossing / timing lean / near-miss), instrument-aware, never the rung just run (sibling fallback). - Suppressed on a clean clear (the climb CTA is the prescription there) and when the mid-run downshift chip already fired. - Validated by guitar-pedagogy + bass-pedagogy + learning-design: crossing -> pick_alternate (not string-skip); bass timing -> bass_rh_pulse (above the sub-70 Hz detector floor); pinky copy matches the legato drill it routes to. - smoke-coach-rx.mjs unit-tests the pure mapping via the __virtuosoCoach hook — immune to the "smoke mocks the verifier" blind spot. gap-roadmap PR 1.3. No HOST CHECK: extends already-shipped shell display code. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01MS2YFb6UUSwJVV6CmEa25i --- .claude/skills/run-virtuoso/package.json | 3 +- .../skills/run-virtuoso/smoke-coach-rx.mjs | 86 +++++++++++++ plugin.json | 2 +- screen.html | 10 ++ screen.js | 119 +++++++++++++++++- 5 files changed, 216 insertions(+), 4 deletions(-) create mode 100644 .claude/skills/run-virtuoso/smoke-coach-rx.mjs diff --git a/.claude/skills/run-virtuoso/package.json b/.claude/skills/run-virtuoso/package.json index 4edb3f5..28120c3 100644 --- a/.claude/skills/run-virtuoso/package.json +++ b/.claude/skills/run-virtuoso/package.json @@ -23,8 +23,9 @@ "smoke:contained": "node smoke-contained-verifier.mjs", "smoke:level-gate": "node smoke-level-gate-async.mjs", "smoke:host-surface": "node smoke-host-surface.mjs", + "smoke:coach": "node smoke-coach-rx.mjs", "test": "node run-all.mjs", - "test:seq": "node smoke-renderers.mjs && node smoke-generators.mjs && node smoke-highway-settings.mjs && node smoke-strings.mjs && node smoke-meter-subdiv.mjs && node smoke-over-barline.mjs && node smoke-herta.mjs && node smoke-audioctx.mjs && node smoke-connect.mjs && node smoke-variation.mjs && node smoke-session-sync.mjs && node smoke-progress.mjs && node smoke-core-purity.mjs && node smoke-gems.mjs && node smoke-scoring-e2e.mjs && node smoke-backing-engine.mjs && node smoke-contained-verifier.mjs && node smoke-level-gate-async.mjs && node smoke-host-surface.mjs" + "test:seq": "node smoke-renderers.mjs && node smoke-generators.mjs && node smoke-highway-settings.mjs && node smoke-strings.mjs && node smoke-meter-subdiv.mjs && node smoke-over-barline.mjs && node smoke-herta.mjs && node smoke-audioctx.mjs && node smoke-connect.mjs && node smoke-variation.mjs && node smoke-session-sync.mjs && node smoke-progress.mjs && node smoke-core-purity.mjs && node smoke-gems.mjs && node smoke-scoring-e2e.mjs && node smoke-backing-engine.mjs && node smoke-contained-verifier.mjs && node smoke-level-gate-async.mjs && node smoke-host-surface.mjs && node smoke-coach-rx.mjs" }, "keywords": [], "author": "", diff --git a/.claude/skills/run-virtuoso/smoke-coach-rx.mjs b/.claude/skills/run-virtuoso/smoke-coach-rx.mjs new file mode 100644 index 0000000..bce6cf7 --- /dev/null +++ b/.claude/skills/run-virtuoso/smoke-coach-rx.mjs @@ -0,0 +1,86 @@ +#!/usr/bin/env node +// Regression guard for the prescriptive COACH LOOP (PR 1.3; npm run smoke:coach, +// in npm test). The results modal turns the already-computed diagnostic data +// (heat.fgMiss / transMiss, leanMs, nearMiss, the bass felt verdict) into ONE +// prescriptive next-drill. This suite unit-tests the PURE fault→drill mapping via +// the window.__virtuosoCoach.coachRxFor hook — it takes a fault FIXTURE directly, +// so it is immune to the "smoke mocks the verifier" blind spot (no real detector +// needed). It also asserts every prescribed pathway id is a real #virtuoso-pathway +//