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;