Compare commits
10 Commits
2c18c71598
...
2072f3674e
| Author | SHA1 | Date | |
|---|---|---|---|
| 2072f3674e | |||
| 1df6fe8e9e | |||
| 8d74e54f5a | |||
| 4b64c41417 | |||
| 4deba7e443 | |||
| 6e8d8f40fc | |||
| d5a45f433a | |||
| e58a7a624c | |||
| 800fd43778 | |||
| 8e7a648660 |
+23
-14
@@ -16,15 +16,15 @@ The shared queue all agents read and write. Lifecycle and rules: [`PROTOCOL.md`]
|
|||||||
| id | title | domain | status | depends-on | files (lock) | definition of done |
|
| id | title | domain | status | depends-on | files (lock) | definition of done |
|
||||||
|----|-------|--------|--------|-----------|--------------|--------------------|
|
|----|-------|--------|--------|-----------|--------------|--------------------|
|
||||||
| M-07 | Seed `sprint-roulette-kb`; schedule hourly cron | maestro | done | — | `docs/agents/LEDGER.md` | seeded, cron created |
|
| M-07 | Seed `sprint-roulette-kb`; schedule hourly cron | maestro | done | — | `docs/agents/LEDGER.md` | seeded, cron created |
|
||||||
| C-60 | **Piano lick schema** (additive): licks today are guitar-tab-only — design the piano-lick shape reusing the bass-pattern language (per-note `deg` XOR typed `approach`, `beat`, `octave`, technique from a PIANO-appropriate vocab subset — decide which of the 8 words apply to keys and whether grace-note/roll style additions are needed; keep it validateable like C-41 did) in SCHEMA.md + validate-kb.mjs + smoke bite-test | quality | ready | — | `src/data/kb/SCHEMA.md`, `scripts/validate-kb.mjs`, `scripts/smoke.mjs` | schema documented; validator enforces; existing KB green; smoke proves it bites |
|
| C-60 | **Piano lick schema** (additive): licks today are guitar-tab-only — design the piano-lick shape reusing the bass-pattern language (per-note `deg` XOR typed `approach`, `beat`, `octave`, technique from a PIANO-appropriate vocab subset — decide which of the 8 words apply to keys and whether grace-note/roll style additions are needed; keep it validateable like C-41 did) in SCHEMA.md + validate-kb.mjs + smoke bite-test | quality | done | — | `src/data/kb/SCHEMA.md`, `scripts/validate-kb.mjs`, `scripts/smoke.mjs` | schema documented; validator enforces; existing KB green; smoke proves it bites |
|
||||||
| D-60 | **PianoLickCard** renderer — visual rhyme with LickCard's tab idiom but for keys (timeline + MiniPiano-style pitches or a degree ladder — pick per readability at strip size); playback via chordAudio like BassPatternCard | design | backlog | C-60 | `src/components/PianoLickCard.jsx` (new) + LicksStrip wiring (re-lock at promotion — JamGuide.jsx is one-screen-locked until C-50) | renders every P-60 lick; playable; graceful empty/malformed |
|
| D-60 | **PianoLickCard** renderer — visual rhyme with LickCard's tab idiom but for keys (timeline + MiniPiano-style pitches or a degree ladder — pick per readability at strip size); playback via chordAudio like BassPatternCard. Must export `PIANO_TECHNIQUE_VOCAB` set-equal to the validator's (the dormant C-60 smoke guard activates on this file's existence). **Component only — LicksStrip wiring deferred (JamGuide.jsx reserved for one-screen L-50); wiring folded into C-61-era integration. Wiring DoD must include (D-60 gate): a shared sequencer-stop so BassPatternCard/PianoLickCard previews can't layer (each holds a private currentSeq today — impossible to trigger until both mount together)** | design | done | C-60 | `src/components/PianoLickCard.jsx` (new) ONLY — done `a7dbd6a` | renders every P-60 lick; playable; graceful empty/malformed |
|
||||||
| P-60 | Piano licks pack #1: 3-4 each for **jazz + blues** piano (C-60 schema; arithmetic-verified prose, the P-41 bar) | content | backlog | C-60 | `src/data/kb/jazz/piano.js`, `src/data/kb/blues/piano.js` | validator green; every lick playable as written |
|
| P-60 | Piano licks pack #1: 3-4 each for **jazz + blues** piano (C-60 schema; arithmetic-verified prose, the P-41 bar). **PASS first try** (`70263e2` — gate realized all 8 in two keys, 35/35 prose claims; dom7-heavy blues coverage accepted as musically right) | content | done | C-60 | `src/data/kb/jazz/piano.js`, `src/data/kb/blues/piano.js` | validator green; every lick playable as written |
|
||||||
| P-61 | Piano licks pack #2: 3-4 each for **gospel + rnb** piano | content | backlog | P-60 | `src/data/kb/gospel/piano.js`, `src/data/kb/rnb/piano.js` | validator green |
|
| P-61 | Piano licks pack #2: 3-4 each for **gospel + rnb** piano. **Returned once (2026-07-11):** one false interval claim in rnb-quartal-slip tips ("each move a step" — actually skip-then-step); 42/43 checks passed. Fixed with the gate's own prescribed wording; Maestro verified the line against the gate's computed −3/−2 motion (scoped re-gate waived — the fix IS the gate's pre-approved example) | content | done | P-60 | `src/data/kb/gospel/piano.js`, `src/data/kb/rnb/piano.js` | validator green |
|
||||||
| P-62 | KB voicings expansion: **pop piano cell** (next per kb-backlog) + audit `GUITAR_SHAPES` for qualities with <3 placeable shapes and author additions where idiomatic | content | ready | — | `src/data/kb/pop/piano.js`, `src/data/kb/index.js`, `src/lib/voicings.js` (additive shapes only) | validator green; every new shape hand-verified (L-23 lesson: base-fret convention) |
|
| P-62 | KB voicings expansion: **pop piano cell** (next per kb-backlog) + audit `GUITAR_SHAPES` for qualities with <3 placeable shapes and author additions where idiomatic | content | done | — | `src/data/kb/pop/piano.js`, `src/data/kb/index.js`, `src/lib/voicings.js` (additive shapes only) | validator green; every new shape hand-verified (L-23 lesson: base-fret convention) |
|
||||||
| D-61 | **Circle of fifths** in the Knowledge Center: interactive SVG (12 keys around the circle, live/locked key highlighted, relative minor ring, diatonic chords on tap-free hover/inline, key-signature glyphs), mounted inside ExploreSection; educational copy in the app's voice | design | ready | — | `src/components/CircleOfFifths.jsx` (new), `src/components/ExplorePanel.jsx` (ExploreSection integration only) | renders any key incl. none-detected; follows effectiveKey live; tokens + AA; zero clicks for the core view |
|
| D-61 | **Circle of fifths** in the Knowledge Center: interactive SVG (12 keys around the circle, live/locked key highlighted, relative minor ring, diatonic chords on tap-free hover/inline, key-signature glyphs), mounted inside ExploreSection; educational copy in the app's voice. **Returned once (2026-07-11):** modal hub key signature wrong (A dorian showed ♮ while its chips show F♯ — all 4 dropdown modal modes affected; fix = derive the parent-major signature, teaching "A dorian = G major's notes") + preview flat/sharp spelling split; geometry/rings/tiers/a11y all passed 60/60. Fixed + re-gate PASS (`c999c95` — hub computed from SCALES set-equality, 72/72 uniqueness, one spelling authority) | design | done | — | `src/components/CircleOfFifths.jsx` (new), `src/components/ExplorePanel.jsx` (ExploreSection integration only) | renders any key incl. none-detected; follows effectiveKey live; tokens + AA; zero clicks for the core view |
|
||||||
| D-62 | **Jam Roulette UX spec** (doc): button up top ("Jam roulette", controls bar per one-screen's layout), genre picker (the 10 KB styles), randomization rules (random key; "interesting" progression selection — weight intermediate level + variety, avoid repeats within a session), how the seed fills the screen (seeds the committed loop + locked key so the banner/strip, voicing rail, licks, related progressions all populate exactly as if detected — reuse, don't duplicate), and the **detection handoff**: musicians start playing and live detection continues from the seed (spec the interplay with the L-31 commit layer: the seed behaves like a committed loop — replacement needs the same consistent evidence; New Song clears it) | design | ready | — | `docs/design/jam-roulette.md` | mechanism spec'd against the real App state (lockedKey, detectedProgression, progressionVoteRef); no state duplication; L-60 scope bounded |
|
| D-62 | **Jam Roulette UX spec** (doc): button up top ("Jam roulette", controls bar per one-screen's layout), genre picker (the 10 KB styles), randomization rules (random key; "interesting" progression selection — weight intermediate level + variety, avoid repeats within a session), how the seed fills the screen (seeds the committed loop + locked key so the banner/strip, voicing rail, licks, related progressions all populate exactly as if detected — reuse, don't duplicate), and the **detection handoff**: musicians start playing and live detection continues from the seed (spec the interplay with the L-31 commit layer: the seed behaves like a committed loop — replacement needs the same consistent evidence; New Song clears it) **Returned once (2026-07-11):** "matched by construction" false for 10/56 progressions — buildLoopIndex indexes RAW degrees while collapse changes name-sequence length (incl. blues-12bar: LIVE detection of a real 12-bar gets an empty JamGuide — pre-existing bug surfaced!); detector len-cap 8 makes collapsed 9-10 unconfirmable; round-trip empirical not constructional (blues-8bar loses to its own [V7,I7] alternation); funk-one-chord collapses to 2 names not 1; flag-1 timeline off by one (EVERY seed dies unguarded — guard even more necessary). Maestro direction: fix (a) — additively index collapsed forms in match.js (fixes live detection too); pool = empirical round-trip passers ∩ collapsed len 2-8. **Returned twice (2026-07-11):** round 2 verified fix-(a) construction/timeline/proofs sound but found the sweep protocol as written evicts all 8 two-name vamps (needs steady-state + all-offsets), behaviour-additive false in exactly one case (country-145 now self-attributes — an improvement, enumerate it), census 11 not 10, and the BASS plays path (BassGuideRows :530/:533) missed by the remap. Round-3 PASS (`see commit` — literal protocol implementation reproduced 4/56 failures + 52/56 pool + 11-census exactly; sourceIndex mechanism verified at current line numbers) | design | done | — | `docs/design/jam-roulette.md` | mechanism spec'd against the real App state (lockedKey, detectedProgression, progressionVoteRef); no state duplication; L-60 scope bounded |
|
||||||
| L-60 | Implement Jam Roulette per D-62 (App.jsx UI-state + seeding fn; the roulette button + genre menu; 🚨 audio contract grep-gated — seeding touches display state and the progression refs ONLY, never callbacks) | engineering | backlog | D-62, C-50 | `src/App.jsx` (+ per D-62 — re-lock at promotion) | one click → genre → key+progression fills the jam dashboard; detection continues from it; New Song clears; build + smoke green; contract grep clean |
|
| L-60 | Implement Jam Roulette per D-62 (App.jsx UI-state + seeding fn; the roulette button + genre menu; 🚨 audio contract grep-gated — seeding touches display state and the progression refs ONLY, never callbacks). Ride-alongs: pass `dense` to the heard-live VoicingBrowser in the rail (D-51 gate flag — same fragile pair fit in the fallback path); one-screen.md §7 "two-octave cells alone" sentence is stale by a few px (D-51 gate nit) | engineering | done | D-62, C-50 | `src/App.jsx` (+ per D-62 — re-lock at promotion) | one click → genre → key+progression fills the jam dashboard; detection continues from it; New Song clears; build + smoke green; contract grep clean |
|
||||||
| C-61 | Sprint-end sweep + PR #3 update | quality | backlog | L-60, D-60, P-61, P-62, D-61 | (none — verification) | all green; PR updated |
|
| C-61 | Sprint-end sweep + PR #3 update (COMBINED — covers BOTH sprint-one-screen and sprint-roulette-kb in one comment) | quality | done | L-60, D-60, P-61, P-62, D-61 | (none — verification) | all green; PR updated — **BOTH SPRINTS COMPLETE** |
|
||||||
|
|
||||||
> Sequencing: C-60 ‖ P-62 ‖ D-61 ‖ D-62 are file-disjoint and ready now (none touch one-screen's locks). Then C-60 → {D-60, P-60 → P-61}, and D-62 + C-50(one-screen) → L-60. C-61 closes. Critic gates every task.
|
> Sequencing: C-60 ‖ P-62 ‖ D-61 ‖ D-62 are file-disjoint and ready now (none touch one-screen's locks). Then C-60 → {D-60, P-60 → P-61}, and D-62 + C-50(one-screen) → L-60. C-61 closes. Critic gates every task.
|
||||||
|
|
||||||
@@ -46,11 +46,11 @@ Standing principles (memory): scroll > click; nothing duplicated; one global ins
|
|||||||
| id | title | domain | status | depends-on | files (lock) | definition of done |
|
| id | title | domain | status | depends-on | files (lock) | definition of done |
|
||||||
|----|-------|--------|--------|-----------|--------------|--------------------|
|
|----|-------|--------|--------|-----------|--------------|--------------------|
|
||||||
| M-06 | Seed `sprint-one-screen` | maestro | done | — | `docs/agents/LEDGER.md` | seeded |
|
| M-06 | Seed `sprint-one-screen` | maestro | done | — | `docs/agents/LEDGER.md` | seeded |
|
||||||
| D-50 | One-screen dashboard concept doc: full-viewport grid at 1280×900 — LEFT: slim loop strip (banner without the big now-playing chord; playhead chip is the "now"), compact instrument view (decide the mechanism: height cap / reduced fret range / scale — honest about legibility), licks strip, related-progressions card; RIGHT column: the suggested-voicings rail (GlanceRail adapted to ~420-520px — decide cell strategy: recommended voicing prominent + gallery wrapped, sizes recomputed honestly per the D-40 corrected math); where LoopStation/collapsibles/KnowledgeDock land; what "related progressions" means computationally (KB-sourced: same style + level + degree-shape overlap with the loop — spec the ranking); narrow-viewport plan (columns stack); migration order with bounded L-50/D-51/L-51 scopes; ≥2 rejected alternatives. No user gate: pick strongest, record rationale | design | claimed | — | `docs/design/one-screen.md` | every component named incl. what's removed (big chord display, ProgressionSuggestions); honest space math (everything visible at 1280×900 with at most the licks/progressions row below a fold-flick); bounded scopes |
|
| D-50 | One-screen dashboard concept doc: full-viewport grid at 1280×900 — LEFT: slim loop strip (banner without the big now-playing chord; playhead chip is the "now"), compact instrument view (decide the mechanism: height cap / reduced fret range / scale — honest about legibility), licks strip, related-progressions card; RIGHT column: the suggested-voicings rail (GlanceRail adapted to ~420-520px — decide cell strategy: recommended voicing prominent + gallery wrapped, sizes recomputed honestly per the D-40 corrected math); where LoopStation/collapsibles/KnowledgeDock land; what "related progressions" means computationally (KB-sourced: same style + level + degree-shape overlap with the loop — spec the ranking); narrow-viewport plan (columns stack); migration order with bounded L-50/D-51/L-51 scopes; ≥2 rejected alternatives. No user gate: pick strongest, record rationale. **Returned once (2026-07-11):** strip really ~92-96px (fix: spec p-4→p-2 trim); `sticky` rail geometrically inert in the flex row (zero travel — flick claims deleted, rationale re-grounded); §4 double-counted cell padding (piano pair actually fits TODAY by ~4px; D-51 premise → margin-hardening); +100 ranking term inherits the raw-vs-collapsed mismatch (collapse pairs before canonicalDegrees/T(p), coordinate with D-62 fix (a)). Layout/jam-view/scopes all held. Fixed + re-gate PASS (`d24bf35` — all budgets recomputed clean) | design | done | — | `docs/design/one-screen.md` | every component named incl. what's removed (big chord display, ProgressionSuggestions); honest space math (everything visible at 1280×900 with at most the licks/progressions row below a fold-flick); bounded scopes |
|
||||||
| L-50 | App restructure per D-50: grid columns, banner slimmed (big now-playing chord removed — ProgressionBanner edit), JamGuide rail moves to the right column, ProgressionSuggestions unmounted (file kept), instrument view compacted per doc, licks strip placed per doc. 🚨 App.jsx = layout/mounts/UI-state only, audio contract grep-gated | engineering | backlog | D-50 | `src/App.jsx`, `src/components/ProgressionBanner.jsx`, `src/components/JamGuide.jsx` (+ per doc — re-lock at promotion) | one-screen layout live; no duplicated info; build + smoke green; contract grep clean |
|
| L-50 | App restructure per D-50: grid columns, banner slimmed (big now-playing chord removed — ProgressionBanner edit), JamGuide rail moves to the right column, ProgressionSuggestions unmounted (file kept), instrument view compacted per doc, licks strip placed per doc. 🚨 App.jsx = layout/mounts/UI-state only, audio contract grep-gated. **PASS** (`800fd43`+`e58a7a6` — SSR 73/73; gate applied a sanctioned one-class legend restoration for non-compact byte-identity). Nits filed: dead currentChord prop on the banner mount (ride-along for L-51); strip chips are div-onClick (pre-existing a11y debt → backlog) | engineering | done | D-50 | `src/App.jsx`, `src/components/ProgressionBanner.jsx`, `src/components/JamGuide.jsx` (+ per doc — re-lock at promotion) | one-screen layout live; no duplicated info; jam view works both layers; build + smoke green; contract grep clean |
|
||||||
| D-51 | Rail right-column adaptation per D-50: GlanceRail/VoicingBrowser sizing for the narrow column (recommended-first cell strategy per doc), playhead highlight preserved, wrap never horizontal-scroll | design | backlog | D-50, L-50 | `src/components/GlanceRail.jsx`, `src/components/VoicingBrowser.jsx` (+ per doc) | rail readable at column width for guitar/piano/bass; zero clicks; build + smoke green |
|
| D-51 | Rail right-column adaptation per D-50: GlanceRail/VoicingBrowser sizing for the narrow column (recommended-first cell strategy per doc), playhead highlight preserved, wrap never horizontal-scroll | design | done | D-50, L-50 | `src/components/GlanceRail.jsx`, `src/components/VoicingBrowser.jsx` (+ per doc) | rail readable at column width for guitar/piano/bass; zero clicks; build + smoke green — **PASS** (`see commit` — arithmetic verified to the built CSS; heard-live dense-pass flag folded into L-60's ride-alongs) |
|
||||||
| L-51 | RelatedProgressions component per D-50: KB-sourced, loop-relative (style + level + degree-shape overlap ranking per the doc's spec), replaces the old generic suggestions; mounts in the D-50 slot | engineering | backlog | D-50, L-50 | `src/components/RelatedProgressions.jsx` (new) + mount slot per doc | shows genuinely related KB progressions for a live loop; honest empty state; build + smoke green |
|
| L-51 | RelatedProgressions component per D-50: KB-sourced, loop-relative (style + level + degree-shape overlap ranking per the doc's spec), replaces the old generic suggestions; mounts in the D-50 slot. **PASS** (`see commit` — scores recomputed independently, collapse counterfactual proven). Taste flag for the user: "same changes" links quality-mismatched same-root-motion relatives (ii-V-I ≡ I-IV-V) — spec-conformant; a "same motion" wording tweak is a one-word option | engineering | done | D-50, L-50 | `src/components/RelatedProgressions.jsx` (new) + mount slot per doc | shows genuinely related KB progressions for a live loop; honest empty state; build + smoke green |
|
||||||
| C-50 | Sprint-end sweep + PR #3 update per PROTOCOL §6 | quality | backlog | L-50, D-51, L-51 | (none — verification) | all green; PR updated |
|
| C-50 | Sprint-end sweep (PR comment consolidated into C-61's combined summary — Maestro call). Folded (L-51 gate): pin the two verified ranking scores + the no-collapse counterfactual as smoke checks; add a global-id-uniqueness assertion to validate-kb.mjs | quality | done | L-50, D-51, L-51 | `scripts/smoke.mjs`, `scripts/validate-kb.mjs` (additions only) | all green; ranking pinned; ids asserted unique — **PASS** (`see commit` — smoke 886, SSR 60/60, whole-sprint contract clean). **SPRINT sprint-one-screen COMPLETE** |
|
||||||
|
|
||||||
> Sequencing: D-50 first (everything hangs off it) → L-50 → D-51 ‖ L-51 (re-lock at promotion to enforce disjointness) → C-50. Critic gates every task.
|
> Sequencing: D-50 first (everything hangs off it) → L-50 → D-51 ‖ L-51 (re-lock at promotion to enforce disjointness) → C-50. Critic gates every task.
|
||||||
|
|
||||||
@@ -244,6 +244,11 @@ Emphasis this sprint: **ship the Jam Guide MVP** (put the 8 guitar style packs o
|
|||||||
- **`pianoVoicing` should return `rootPc` (Luthier, tiny):** callers currently must attach it themselves (JamGuide and VoicingBrowser both do); returning it at the source removes the false-"R" foot-gun for future consumers (D-24 gate observation — NOT a live bug, both call sites verified correct 2026-07-08).
|
- **`pianoVoicing` should return `rootPc` (Luthier, tiny):** callers currently must attach it themselves (JamGuide and VoicingBrowser both do); returning it at the source removes the false-"R" foot-gun for future consumers (D-24 gate observation — NOT a live bug, both call sites verified correct 2026-07-08).
|
||||||
- **`docs/kb-backlog.md` is stale (Professor, tiny):** still lists gospel/pop guitar + jazz/gospel piano as "todo" though shipped; P-30 (rnb piano) AND P-31 (blues piano, both 2026-07-10) need done entries — refresh next content iteration (flagged by P-30 + C-31 sweep, outside their locks).
|
- **`docs/kb-backlog.md` is stale (Professor, tiny):** still lists gospel/pop guitar + jazz/gospel piano as "todo" though shipped; P-30 (rnb piano) AND P-31 (blues piano, both 2026-07-10) need done entries — refresh next content iteration (flagged by P-30 + C-31 sweep, outside their locks).
|
||||||
- **`scripts/loop-fixtures.mjs` header comment drift (tiny):** cites "App.jsx:322-324" for adjacent-dup suppression; L-31 shifted it to ~line 364 — comment-only, fix when the file is next touched (C-31 sweep finding).
|
- **`scripts/loop-fixtures.mjs` header comment drift (tiny):** cites "App.jsx:322-324" for adjacent-dup suppression; L-31 shifted it to ~line 364 — comment-only, fix when the file is next touched (C-31 sweep finding).
|
||||||
|
- **ProgressionBanner strip chips are div-onClick (Muse, small):** pre-existing a11y debt — history/loop chips should be real buttons (L-50 gate 2026-07-11; RelatedProgressions' chips already show the pattern to copy).
|
||||||
|
- **Test-infra note (any agent writing throwaway SSR harnesses):** a bare `import('esbuild')` inside a `data:` module hook dies silently (ERR_MODULE_NOT_FOUND in the hooks thread, exit 1, zero output) — smoke.mjs:1047's interpolated `import.meta.resolve` pattern is load-bearing; prefer esbuild pre-bundle harnesses (confirmed empirically twice, 2026-07-11).
|
||||||
|
- **GUITAR_SHAPES self-audit in smoke (Critic, small — P-62 gate suggestion 2026-07-11):** pc-spell every GUITAR_SHAPES entry against its quality in smoke — the validator audits only KB chord steps, never the shape library itself; would have caught both wrong shapes below and guards future additions.
|
||||||
|
- **Pop piano prose uses absolute note names (Professor, tiny — P-62 gate flag):** ~8 spots (pop/piano.js:88,117,130-133,159,186) say e.g. "the G holds; top C→B" — correct only in the declared reference key; degree-ify before any piano-prose render surface lands (sibling of the bass-tips-surface backlog item).
|
||||||
|
- **Two wrong GUITAR_SHAPES in live data (Luthier, small — P-62 audit finding 2026-07-11):** (1) `half_dim "E Barre"` offsets `[0,1,2,0,0,'x']` sounds {0,3,6,7} — natural 5th clashing the ♭5, NO ♭7; correct E-form ø7 = `[0,1,0,0,'x','x']`. (2) `add9 "Open Dadd9"` (xx0230 = D-A-D-E) has no 3rd — it's Dsus2, duplicating the sus2 open D. Both pre-existing; fix after P-62 commits (additive lock kept P-62 from editing them).
|
||||||
- **Matcher blues/jazz disambiguation (Luthier, small):** `['C7','A7','Dm7','G7']` matches `jazz-rhythm-a` (identical I–VI–ii–V degree shape, quality tie → jazz), so `blues-turnaround`'s bass plays are live-unreachable — pre-existing tie-break in match.js; consider quality-profile weighting (all-dom7 → blues). Evidence-verified by the L-42 gate 2026-07-10.
|
- **Matcher blues/jazz disambiguation (Luthier, small):** `['C7','A7','Dm7','G7']` matches `jazz-rhythm-a` (identical I–VI–ii–V degree shape, quality tie → jazz), so `blues-turnaround`'s bass plays are live-unreachable — pre-existing tie-break in match.js; consider quality-profile weighting (all-dom7 → blues). Evidence-verified by the L-42 gate 2026-07-10.
|
||||||
- **Bass play `tips` have no render surface (Muse/Luthier, small):** the authored teaching prose in bass packs renders nowhere (card shows label/feel/position/note only; the dock has no bass section) — real content-surface gap, L-42 gate 2026-07-10.
|
- **Bass play `tips` have no render surface (Muse/Luthier, small):** the authored teaching prose in bass packs renders nowhere (card shows label/feel/position/note only; the dock has no bass section) — real content-surface gap, L-42 gate 2026-07-10.
|
||||||
- **TransitionChip "2 step down" wording (Muse, one line):** whole-step rails render as "2 step down/up" (52 of 268 real KB transitions) — verbatim port of RoadmapTrack.jsx:59; "whole step down" is the musician's phrase (D-41 gate note 2026-07-10).
|
- **TransitionChip "2 step down" wording (Muse, one line):** whole-step rails render as "2 step down/up" (52 of 268 real KB transitions) — verbatim port of RoadmapTrack.jsx:59; "whole step down" is the musician's phrase (D-41 gate note 2026-07-10).
|
||||||
@@ -279,6 +284,10 @@ _(Maestro appends one line per completed iteration: `<date> · <task ids done>
|
|||||||
- 2026-07-09 · **iterations 6–7/24** · done: L-25 (`e24aa4b` instrument-scoped modal tabs), C-22 (`b4e8f12` validator hand-span rule, smoke 799), P-23 (`564a9e0` gospel piano cell, 140 plays — returned once: one improv-row scale claim false for vi7/iii7, split into exact rows, PASS re-gate; 39/40 first-pass claims held), L-24 (`2165bb5` authored piano packs render at stations — implementer agent hung at verification after ~1 h, killed, completion-pass agent audited + finished; Critic PASS with 0 resolver drift) · incidents: P-23 stalled once mid-authoring (resumed from transcript, no loss); L-24 hung silently (diff intact, completed by fresh agent) · backlog: resolveDegree drift guard filed · **board empty except C-21 → sprint-end sweep + PR**
|
- 2026-07-09 · **iterations 6–7/24** · done: L-25 (`e24aa4b` instrument-scoped modal tabs), C-22 (`b4e8f12` validator hand-span rule, smoke 799), P-23 (`564a9e0` gospel piano cell, 140 plays — returned once: one improv-row scale claim false for vi7/iii7, split into exact rows, PASS re-gate; 39/40 first-pass claims held), L-24 (`2165bb5` authored piano packs render at stations — implementer agent hung at verification after ~1 h, killed, completion-pass agent audited + finished; Critic PASS with 0 resolver drift) · incidents: P-23 stalled once mid-authoring (resumed from transcript, no loss); L-24 hung silently (diff intact, completed by fresh agent) · backlog: resolveDegree drift guard filed · **board empty except C-21 → sprint-end sweep + PR**
|
||||||
- 2026-07-08/09 · **iteration 5/24** · done: D-23 (`db397f6` VoicingBrowser show prop + LickCard stack-guard), D-24 (`e2110ae` MiniPiano thumbs crop to voicing span, 266→142 px), P-24 (11 licks rock/country/reggae — KB now **23 licks across 6 styles**, every note pitch-verified) — Critic PASS all three, zero returns · D-24 gate raised a false alarm on the D-21 rootPc fix (its harness bypassed VoicingBrowser; Maestro verified line 297 intact — no regression); hardening idea backlogged (`pianoVoicing` return rootPc) · promoted L-25 · next ready (all file-disjoint): L-24 ‖ L-25 ‖ C-22 ‖ P-23, then C-21 closes the sprint
|
- 2026-07-08/09 · **iteration 5/24** · done: D-23 (`db397f6` VoicingBrowser show prop + LickCard stack-guard), D-24 (`e2110ae` MiniPiano thumbs crop to voicing span, 266→142 px), P-24 (11 licks rock/country/reggae — KB now **23 licks across 6 styles**, every note pitch-verified) — Critic PASS all three, zero returns · D-24 gate raised a false alarm on the D-21 rootPc fix (its harness bypassed VoicingBrowser; Maestro verified line 297 intact — no regression); hardening idea backlogged (`pianoVoicing` return rootPc) · promoted L-25 · next ready (all file-disjoint): L-24 ‖ L-25 ‖ C-22 ‖ P-23, then C-21 closes the sprint
|
||||||
- 2026-07-10 · **sprint-integrated-glance iteration 4/24 — SPRINT COMPLETE** · done: L-42 (`af7c3cc` **BASS real end-to-end** — BassPatternCard tab cards with playback; gate re-realized all 912 placements exact, byte-identity on untouched paths, import cycle proven safe), C-42 (`218c0ba` smoke §7 sweeps BOTH resolveDegree copies, 866/866 — the sweep agent authored the wiring then died on the ACCOUNT MONTHLY SPEND LIMIT; Maestro completed the sweep inline: build/validator/smoke green at HEAD, sabotage-proved the new guard [b13 perturbation → red naming the cell → restored green], whole-sprint App.jsx contract grep = 0 hits; per-instrument SSR relied on the L-42 gate's run minutes earlier — only smoke.mjs changed since) · backlogged: matcher blues/jazz tie-break (turnaround bass plays live-unreachable), bass tips surface · all 11 sprint tasks done, 2 returned-then-fixed (D-40, and D-41's predecessor D-31 pattern held: zero returns this sprint besides D-40), 0 unresolved · PR #3 updated; cron deleted (4 of 24 iterations) · ⚠️ NOTE: the Anthropic monthly spend limit is HIT — no further subagent work possible until raised
|
- 2026-07-10 · **sprint-integrated-glance iteration 4/24 — SPRINT COMPLETE** · done: L-42 (`af7c3cc` **BASS real end-to-end** — BassPatternCard tab cards with playback; gate re-realized all 912 placements exact, byte-identity on untouched paths, import cycle proven safe), C-42 (`218c0ba` smoke §7 sweeps BOTH resolveDegree copies, 866/866 — the sweep agent authored the wiring then died on the ACCOUNT MONTHLY SPEND LIMIT; Maestro completed the sweep inline: build/validator/smoke green at HEAD, sabotage-proved the new guard [b13 perturbation → red naming the cell → restored green], whole-sprint App.jsx contract grep = 0 hits; per-instrument SSR relied on the L-42 gate's run minutes earlier — only smoke.mjs changed since) · backlogged: matcher blues/jazz tie-break (turnaround bass plays live-unreachable), bass tips surface · all 11 sprint tasks done, 2 returned-then-fixed (D-40, and D-41's predecessor D-31 pattern held: zero returns this sprint besides D-40), 0 unresolved · PR #3 updated; cron deleted (4 of 24 iterations) · ⚠️ NOTE: the Anthropic monthly spend limit is HIT — no further subagent work possible until raised
|
||||||
|
- 2026-07-11 · **sprint-roulette-kb iterations 1-2/12** (notification-driven; hourly cron as fallback) · done: M-07, C-60 (`5f4e2b7` piano lick schema — enclosures native, 25-cap proven tight, dormant renderer guard), P-62 (`ffc1bb1` pop piano cell 182 plays + 8 shapes; found 2 WRONG pre-existing shapes → backlog), D-61 (`c999c95` circle of fifths — returned once on modal hub signature, now teaches parent-major relation), P-60 (`70263e2` first piano licks, jazz+blues — PASS first try 35/35), D-60 (`a7dbd6a` PianoLickCard piano-roll renderer — PASS, 41/41 realization audit), D-62 (`e87b2bb` jam-roulette spec — returned TWICE, round-3 literal-implementation test passed; surfaced the pre-existing live bug: 10 progressions incl. blues-12bar never match detection; fix (a) specced into L-60), P-61 (`2c18c71` gospel+rnb licks, 39 total — returned once on one interval claim, gate-prescribed fix verified) · KB today: 172→182 plays, 23→39 licks, 6th piano style, circle of fifths · remaining: L-60 (blocked on one-screen C-50) → C-61
|
||||||
|
- 2026-07-11 · **BOTH SPRINTS COMPLETE** · sprint-roulette-kb: L-60 (`8d74e54`+`1df6fe8` **Jam Roulette LIVE** + the matcher collapse fix repairing the pre-existing live 12-bar/8-bar empty-JamGuide bug — Critic PASS on Opus: pool sweep reproduced from spec 4/52, fix-(a) additivity proven registry-wide, seed timeline hand-traced), C-61 (combined sweep — build/validator 182·39/smoke 891 all green, full-App SSR ×6 combos, whole-branch audio contract clean; PASS) · gate stats across both sprints: 9 first-try passes, 5 returns across 4 tasks (D-50, P-61, D-61, D-62×2) all re-gated to PASS · cron deleted · PR #3 updated with the combined summary · backlog: PianoLickCard LicksStrip wiring (needs shared sequencer-stop), 2 wrong GUITAR_SHAPES, strip-chip a11y, matcher blues/jazz tie-break, bass tips surface, 6 pre-existing orphan components
|
||||||
|
- 2026-07-11 · **sprint-one-screen iterations 2-3 + roulette-kb progress** · one-screen COMPLETE: L-50 (`800fd43`+`e58a7a6` dashboard grid, slim strip, jam view), D-51 (`d5a45f4` rail column adaptation — verified to the built CSS), L-51 (`6e8d8f4` RelatedProgressions — collapse step proven load-bearing), C-50 (`4deba7e` sweep, smoke 886, ranking pinned + registry id-uniqueness) — all Critic PASS · roulette-kb: P-61 (`2c18c71` gospel+rnb licks, 39 total) · **incident:** the L-60 (Jam Roulette) dispatch hit the account MONTHLY SPEND LIMIT on Fable 5 mid-read (no files written, clean tree); user switched the session to Opus 4.8 and L-60 re-dispatched fresh on Opus — proceeding · remaining: L-60 → C-61 closes both sprints
|
||||||
|
- 2026-07-11 · **sprint-one-screen iteration 1** (notification-driven) · done: M-06, D-50 (`d24bf35` one-screen dashboard + jam-view concept — returned once: strip height, geometrically-inert sticky, double-counted padding, collapse-blind ranking; all fixed, re-gate PASS with recomputed budgets) · L-50 (the build) in flight · then D-51 ‖ L-51 → C-50 → unlocks L-60
|
||||||
- 2026-07-10 · **sprint-integrated-glance iteration 3/24** · done: D-41 (`677d7b9` **all-expanded rail LIVE** — every station a permanently expanded row with education header [solo-scale, aim dots, voice-leading chips incl. wrap-around] + full gallery; playhead highlight-only proven byte-equal modulo highlight artifacts; focus toggle keeps the onFocusChord contract; show='bass' fall-through fixed; dense byte-identity proven across 336 SSR combos; black-on-accent 5.31:1 beats the old sub-AA white) — gate PASS, 19/19 SSR incl. independent theory recompute of a row's education · backlogged: TransitionChip "2 step down"→"whole step" wording (52 of 268 transitions) · L-42 (bass pattern renderer) dispatched — then C-42 closes
|
- 2026-07-10 · **sprint-integrated-glance iteration 3/24** · done: D-41 (`677d7b9` **all-expanded rail LIVE** — every station a permanently expanded row with education header [solo-scale, aim dots, voice-leading chips incl. wrap-around] + full gallery; playhead highlight-only proven byte-equal modulo highlight artifacts; focus toggle keeps the onFocusChord contract; show='bass' fall-through fixed; dense byte-identity proven across 336 SSR combos; black-on-accent 5.31:1 beats the old sub-AA white) — gate PASS, 19/19 SSR incl. independent theory recompute of a row's education · backlogged: TransitionChip "2 step down"→"whole step" wording (52 of 268 transitions) · L-42 (bass pattern renderer) dispatched — then C-42 closes
|
||||||
- 2026-07-10 · **sprint-integrated-glance iteration 2/24** · done: P-41 (`01a7c32` **the KB's first bass cell** — blues, 8 plays/172 total; gate machine-realized all 76 patterns in C AND A, 12 identities hold key-agnostically; PASS first try), L-40 (`c899132` **the restructure is live** — band below the main module, banner = the single loop display, RoadmapTrack/CurrentJamPanel unmounted, App's one GUITAR/PIANO/BASS selector drives band + dock, honest bass rows, GlanceRail yank-effect deleted; gate SSR 22/22, audio contract grep-clean, deletion sweep clean) · folded: smoke §7 resolveDegree wiring → C-42; VoicingBrowser show='bass' fall-through fix → D-41 (lock extended) · D-41 (all-rows-expanded rail) dispatched — then L-42 → C-42 closes
|
- 2026-07-10 · **sprint-integrated-glance iteration 2/24** · done: P-41 (`01a7c32` **the KB's first bass cell** — blues, 8 plays/172 total; gate machine-realized all 76 patterns in C AND A, 12 identities hold key-agnostically; PASS first try), L-40 (`c899132` **the restructure is live** — band below the main module, banner = the single loop display, RoadmapTrack/CurrentJamPanel unmounted, App's one GUITAR/PIANO/BASS selector drives band + dock, honest bass rows, GlanceRail yank-effect deleted; gate SSR 22/22, audio contract grep-clean, deletion sweep clean) · folded: smoke §7 resolveDegree wiring → C-42; VoicingBrowser show='bass' fall-through fix → D-41 (lock extended) · D-41 (all-rows-expanded rail) dispatched — then L-42 → C-42 closes
|
||||||
- 2026-07-10 · **sprint-integrated-glance iteration 1/24** · done: M-05, P-40 (`6f59add` kb-backlog refreshed — gate audited all 14 cells + 10 dates), C-40 (`090d3a9` drift guards, smoke 836 — gate recomputed all 224 truth-table cells), D-40 (`7d1f64c` integrated-glance concept — returned once: Dm7-specific piano math + 3 factual fixes incl. the interim page-yank, fixed, PASS re-gate with pixel-exact recompute), C-41 (`996218f` bass play schema, smoke 849 — degree-based, typed approaches, PASS with 19-semitone bound proven exactly maximal) · in flight: L-40 (App restructure — band up top, single loop display, one selector) ‖ P-41 (blues bass, first bass cell) · then D-41 → L-42 → C-42 closes
|
- 2026-07-10 · **sprint-integrated-glance iteration 1/24** · done: M-05, P-40 (`6f59add` kb-backlog refreshed — gate audited all 14 cells + 10 dates), C-40 (`090d3a9` drift guards, smoke 836 — gate recomputed all 224 truth-table cells), D-40 (`7d1f64c` integrated-glance concept — returned once: Dm7-specific piano math + 3 factual fixes incl. the interim page-yank, fixed, PASS re-gate with pixel-exact recompute), C-41 (`996218f` bass play schema, smoke 849 — degree-based, typed approaches, PASS with 19-semitone bound proven exactly maximal) · in flight: L-40 (App restructure — band up top, single loop display, one selector) ‖ P-41 (blues bass, first bass cell) · then D-41 → L-42 → C-42 closes
|
||||||
|
|||||||
@@ -506,8 +506,10 @@ flow below `xl` (JamGuide reorders its own children with responsive classes or
|
|||||||
conditional order — no duplicate mounts). The rail **unbounds** below `xl`
|
conditional order — no duplicate mounts). The rail **unbounds** below `xl`
|
||||||
(the max-h/overflow classes are `xl:`-prefixed) and lays out at natural height
|
(the max-h/overflow classes are `xl:`-prefixed) and lays out at natural height
|
||||||
in page flow — a nested scroller inside a scrolling page is a trap on touch.
|
in page flow — a nested scroller inside a scrolling page is a trap on touch.
|
||||||
Cell wrap at ~576px interior: guitar 5/line, piano two-octave cells alone —
|
Cell wrap at ~576px interior: guitar 5/line, piano cells **pair** (a 284px
|
||||||
same behaviour the D-41 gate already verified for the band. The instrument
|
two-octave + a 160px one-octave = 452, or two two-octaves at the 576 boundary) —
|
||||||
|
the same margin-hardened pairing D-51 verified for the bounded column (§4),
|
||||||
|
with more room here, not the "cells ride alone" of the narrower bounded width. The instrument
|
||||||
selector never moves: it lives in the controls bar, global, above everything
|
selector never moves: it lives in the controls bar, global, above everything
|
||||||
at every width.
|
at every width.
|
||||||
|
|
||||||
|
|||||||
+240
-1
@@ -1193,7 +1193,246 @@ if (existsSync(join(ROOT, 'src/components/PianoLickCard.jsx'))) {
|
|||||||
warn('PianoLickCard vocab drift guard', 'src/components/PianoLickCard.jsx not yet authored (D-60)')
|
warn('PianoLickCard vocab drift guard', 'src/components/PianoLickCard.jsx not yet authored (D-60)')
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 8. Summary + exit code ───────────────────────────────────────────────────
|
// ─── 7d. Jam Roulette round-trip sweep (L-60, jam-roulette.md §2.2) ───────────
|
||||||
|
//
|
||||||
|
// The pool gate, protocol VERBATIM: for each of the 56 progressions, seed its
|
||||||
|
// canonical collapsed form in C, fill a 32-commit window with repetitions and
|
||||||
|
// truncate at EVERY partial-cycle offset (0…len−1); it passes iff
|
||||||
|
// detectRepeatingProgression returns exactly that form at ALL offsets, AND the
|
||||||
|
// collapsed length is 2–8. Steady-state-plus-all-offsets is the honest protocol
|
||||||
|
// (a jam is sampled mid-cycle; a naive "2 clean cycles" feed evicts the 2-name
|
||||||
|
// vamps and passes blues-8bar — §2.2). Expected: 52 passers, exactly 4 excluded
|
||||||
|
// (jazz-blues, blues-quickchange, bossa-blue over-length; blues-8bar's
|
||||||
|
// 1-of-7-offsets self-competition). Independent of match.roundTripPasses so a
|
||||||
|
// detector or seed regression turns smoke red; cross-checked against it below.
|
||||||
|
|
||||||
|
console.log('\nJam Roulette round-trip sweep (L-60):')
|
||||||
|
|
||||||
|
const { seedableLoop, roundTripPasses, buildRoulettePool } = match
|
||||||
|
|
||||||
|
// Verbatim §2.2 protocol — deliberately NOT calling match.roundTripPasses.
|
||||||
|
function sweepPasses(form) {
|
||||||
|
if (!Array.isArray(form) || form.length < 2 || form.length > 8) return false
|
||||||
|
const target = form.join(',')
|
||||||
|
for (let offset = 0; offset < form.length; offset++) {
|
||||||
|
const history = []
|
||||||
|
for (let k = 0; k < 32; k++) history.push(form[(offset + k) % form.length])
|
||||||
|
const detected = detectRepeatingProgression(history)
|
||||||
|
if (!detected || detected.join(',') !== target) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const sweepFails = []
|
||||||
|
let sweepPool = 0
|
||||||
|
let sweepTotal = 0
|
||||||
|
for (const style of Object.keys(kb)) {
|
||||||
|
for (const prog of kb[style].progressions ?? []) {
|
||||||
|
sweepTotal++
|
||||||
|
const form = seedableLoop(prog, 0) // key C — key-independent (§3.3.3)
|
||||||
|
if (form && sweepPasses(form)) sweepPool++
|
||||||
|
else sweepFails.push(`${style}/${prog.id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(` round-trip sweep: ${sweepFails.length} failures / pool ${sweepPool} (of ${sweepTotal})`)
|
||||||
|
|
||||||
|
check('round-trip sweep: exactly 4 failures, pool 52 of 56 (§2.2 steady-state, all offsets)', () => {
|
||||||
|
assert(sweepTotal === 56, `swept ${sweepTotal} progressions, expected 56`)
|
||||||
|
assert(sweepPool === 52, `pool ${sweepPool} ≠ 52`)
|
||||||
|
assert(sweepFails.length === 4, `${sweepFails.length} failures ≠ 4: [${sweepFails.join(', ')}]`)
|
||||||
|
})
|
||||||
|
|
||||||
|
check('round-trip sweep: the 4 excluded are exactly jazz-blues, blues-quickchange, blues-8bar, bossa-blue', () => {
|
||||||
|
const want = ['blues/blues-8bar', 'blues/blues-quickchange', 'bossa/bossa-blue', 'jazz/jazz-blues']
|
||||||
|
assert(JSON.stringify([...sweepFails].sort()) === JSON.stringify(want),
|
||||||
|
`excluded set [${[...sweepFails].sort().join(', ')}] ≠ [${want.join(', ')}]`)
|
||||||
|
})
|
||||||
|
|
||||||
|
check('match.roundTripPasses agrees with the verbatim sweep on all 56 (drift guard)', () => {
|
||||||
|
for (const style of Object.keys(kb)) {
|
||||||
|
for (const prog of kb[style].progressions ?? []) {
|
||||||
|
const form = seedableLoop(prog, 0)
|
||||||
|
const mine = form ? sweepPasses(form) : false
|
||||||
|
const theirs = form ? roundTripPasses(form) : false
|
||||||
|
assert(mine === theirs, `${style}/${prog.id}: verbatim ${mine} ≠ match.roundTripPasses ${theirs}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
check('buildRoulettePool exposes exactly the 52 passers, all collapsed len 2–8', () => {
|
||||||
|
const pool = buildRoulettePool(kb)
|
||||||
|
let n = 0
|
||||||
|
for (const [, members] of pool.byStyle) {
|
||||||
|
for (const mem of members) {
|
||||||
|
n++
|
||||||
|
assert(mem.collapsedLen >= 2 && mem.collapsedLen <= 8, `${mem.id} collapsedLen ${mem.collapsedLen} out of 2–8`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert(n === 52, `pool holds ${n} members ≠ 52`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Fix (a): a clean LIVE 12-bar commit stream now matches blues-12bar — the
|
||||||
|
// pre-existing live-detection bug pinned fixed (jam-roulette.md §3.3.1). The live
|
||||||
|
// commit stream is ALREADY collapsed (App.jsx dedupes back-to-back commits), so a
|
||||||
|
// real 12-bar in G commits [G7,C7,G7,D7,C7,G7,D7] per chorus. Before fix (a) this
|
||||||
|
// collapsed loop matched NOTHING → an empty JamGuide; now it detects + matches.
|
||||||
|
check('fix (a) live: a clean collapsed 12-bar commit stream detects + matches blues-12bar (was an empty JamGuide)', () => {
|
||||||
|
const G12_COMMITS = ['G7', 'C7', 'G7', 'D7', 'C7', 'G7', 'D7'] // one chorus, as committed
|
||||||
|
const history = [].concat(...Array.from({ length: 5 }, () => G12_COMMITS)) // 5 choruses
|
||||||
|
const detected = detectRepeatingProgression(history)
|
||||||
|
assert(detected, 'detector returned null for a clean 12-bar commit stream')
|
||||||
|
const m = matchLoopToProgression(detected, index)
|
||||||
|
assert(m.matched && m.id === 'blues-12bar' && m.style === 'blues',
|
||||||
|
`detected ${JSON.stringify(detected)} matched ${m.matched ? m.style + '/' + m.id : 'NONE'}, expected blues/blues-12bar`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── 8. RelatedProgressions ranking pins (C-50) ───────────────────────────────
|
||||||
|
//
|
||||||
|
// Pins the L-51-gate-verified ranking outcomes of RelatedProgressions.jsx
|
||||||
|
// (rankRelatedProgressions / collapseChanges, exported for exactly this) so a
|
||||||
|
// scoring-constant tweak, a collapse regression, or a KB addition that
|
||||||
|
// reshuffles the top entries turns smoke red instead of silently changing what
|
||||||
|
// jammers are recommended. Two live pins + one counterfactual:
|
||||||
|
//
|
||||||
|
// (a) collapsed live 12-bar in A → RE-PINNED for L-60 fix (a): the
|
||||||
|
// collapsed-form index now recognizes the loop as blues-12bar, which the
|
||||||
|
// id-only rule excludes from its own related list. The new top is
|
||||||
|
// blues/blues-8bar at score 92, annotation 'shares I7→V7' (0 shape + 40
|
||||||
|
// same-style + 36 transitions Δ{5,7,10} + 16 Jaccard·1.0 − 0 length);
|
||||||
|
// blues-quickchange edges just under at 90 (same terms, |7−9|=2 length
|
||||||
|
// penalty). This is the exact flip the pre-fix note here foretold.
|
||||||
|
// (b) live ii–V–I in C → match jazz/jazz-251-major (quality overlap wins
|
||||||
|
// over jazz-251-minor), which is EXCLUDED from entries; top entry is
|
||||||
|
// jazz/jazz-251-minor at 156 'same changes' (100 same canonical shape +
|
||||||
|
// 40 same style + 0 shared transitions — every quality differs — + 16
|
||||||
|
// Jaccard − 0 length).
|
||||||
|
// (c) no-collapse counterfactual: an INDEPENDENT replica of the §5 scoring
|
||||||
|
// formula (docs/design/one-screen.md), fed blues-12bar's RAW bar-per-bar
|
||||||
|
// degrees, scores 39 (36 transitions + 8 Jaccard·0.5 − 5 length, no
|
||||||
|
// +100) vs 152 with collapse — proving the mandatory collapse step is
|
||||||
|
// what makes the flagship "this 12-bar IS their 12-bar" relation fire.
|
||||||
|
// Post fix (a) blues-12bar is the MATCH (excluded), so the live grounding
|
||||||
|
// moves to the actual top entry: the replica of blues-8bar (same-style
|
||||||
|
// relative) must equal its live component score (92), grounding the
|
||||||
|
// replica so the 39/152 can't drift into fiction.
|
||||||
|
|
||||||
|
console.log('\nRelatedProgressions ranking pins (C-50):')
|
||||||
|
|
||||||
|
const relMod = await load('src/components/RelatedProgressions.jsx')
|
||||||
|
const { rankRelatedProgressions, collapseChanges } = relMod
|
||||||
|
const { loopToDegrees, canonicalDegrees } = match
|
||||||
|
|
||||||
|
const LOOP_12BAR_A = ['A7', 'D7', 'A7', 'E7', 'D7', 'A7', 'E7'] // detection-collapsed 12-bar
|
||||||
|
const LOOP_251_C = ['Dm7', 'G7', 'Cmaj7'] // live ii–V–I
|
||||||
|
|
||||||
|
check('RelatedProgressions exports rankRelatedProgressions + collapseChanges', () => {
|
||||||
|
assert(typeof rankRelatedProgressions === 'function', 'rankRelatedProgressions is not an exported function')
|
||||||
|
assert(typeof collapseChanges === 'function', 'collapseChanges is not an exported function')
|
||||||
|
})
|
||||||
|
|
||||||
|
check('collapseChanges(blues-12bar) → 7 units [0,5,0,7,5,0,7] (raw 12 bars collapse to the detection form)', () => {
|
||||||
|
const prog = kb.blues.progressions.find((p) => p.id === 'blues-12bar')
|
||||||
|
assert(prog, 'blues-12bar missing from the registry')
|
||||||
|
const units = collapseChanges(prog)
|
||||||
|
const degs = units.map((u) => u.deg).join(',')
|
||||||
|
assert(degs === '0,5,0,7,5,0,7', `collapsed degrees [${degs}] ≠ [0,5,0,7,5,0,7]`)
|
||||||
|
assert(units.every((u) => u.quality === 'dom7'), 'collapsed qualities are not all dom7')
|
||||||
|
})
|
||||||
|
|
||||||
|
check('collapseChanges(blues-minor) pops the wrap-around pair → 5 units [0,5,0,8,7]', () => {
|
||||||
|
const prog = kb.blues.progressions.find((p) => p.id === 'blues-minor')
|
||||||
|
assert(prog, 'blues-minor missing from the registry')
|
||||||
|
const degs = collapseChanges(prog).map((u) => u.deg).join(',')
|
||||||
|
assert(degs === '0,5,0,8,7', `collapsed degrees [${degs}] ≠ [0,5,0,8,7] — the cycle wrap (last unit === first unit) must collapse too`)
|
||||||
|
})
|
||||||
|
|
||||||
|
const rank12 = rankRelatedProgressions(LOOP_12BAR_A)
|
||||||
|
check('pin (a): collapsed 12-bar → matcher now recognizes blues-12bar (fix (a)); it self-excludes, blues-8bar tops at exactly 92, "shares I7→V7"', () => {
|
||||||
|
assert(rank12, 'ranking returned null for a parseable loop')
|
||||||
|
// Re-pinned for L-60 fix (a): the collapsed-form index makes the detected
|
||||||
|
// collapsed 12-bar match blues-12bar itself, which the id-only rule excludes
|
||||||
|
// from its own related list (§8's pre-fix note foretold exactly this flip).
|
||||||
|
assert(rank12.match.matched && rank12.match.id === 'blues-12bar',
|
||||||
|
`match is ${rank12.match.matched ? rank12.match.id : 'NONE'} — fix (a) should make the collapsed 12-bar match blues-12bar`)
|
||||||
|
assert(!rank12.entries.some((e) => e.id === 'blues-12bar'),
|
||||||
|
'blues-12bar leaked into its own related list — the id-only exclusion broke')
|
||||||
|
const top = rank12.entries[0]
|
||||||
|
// blues-8bar: same style (+40) + all 3 loop transitions Δ{5,7,10} present (cap 36)
|
||||||
|
// + identical rebased degree-set {0,5,10} (Jaccard 1.0 ×16) − |7−7| length = 92.
|
||||||
|
// Edges blues-quickchange (90; same terms but |7−9|=2 length penalty).
|
||||||
|
assert(top && top.id === 'blues-8bar' && top.style === 'blues',
|
||||||
|
`top entry is ${top?.style}/${top?.id}, expected blues/blues-8bar`)
|
||||||
|
assert(top.score === 92, `blues-8bar scored ${top.score}, pinned 92 (0 shape + 40 style + 36 transitions + 16 Jaccard − 0 length)`)
|
||||||
|
assert(top.annotation === 'shares I7→V7', `annotation '${top.annotation}' ≠ 'shares I7→V7'`)
|
||||||
|
})
|
||||||
|
|
||||||
|
const rank251 = rankRelatedProgressions(LOOP_251_C)
|
||||||
|
check('pin (b): ii–V–I in C → match jazz-251-major (excluded); jazz-251-minor top at exactly 156, "same changes"', () => {
|
||||||
|
assert(rank251, 'ranking returned null for a parseable loop')
|
||||||
|
assert(rank251.match.matched && rank251.match.id === 'jazz-251-major',
|
||||||
|
`match is ${rank251.match.matched ? rank251.match.id : 'NONE'}, expected jazz-251-major (quality-overlap disambiguation)`)
|
||||||
|
assert(!rank251.entries.some((e) => e.id === 'jazz-251-major'),
|
||||||
|
'the matched progression leaked into its own related list — the id-only exclusion broke')
|
||||||
|
const top = rank251.entries[0]
|
||||||
|
assert(top && top.id === 'jazz-251-minor' && top.style === 'jazz',
|
||||||
|
`top entry is ${top?.style}/${top?.id}, expected jazz/jazz-251-minor`)
|
||||||
|
assert(top.score === 156, `jazz-251-minor scored ${top.score}, pinned 156 (100 shape + 40 style + 0 transitions + 16 Jaccard − 0 length)`)
|
||||||
|
assert(top.annotation === 'same changes', `annotation '${top.annotation}' ≠ 'same changes'`)
|
||||||
|
assert(rank251.entries.length <= 5, `${rank251.entries.length} entries > max 5`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Independent §5-formula replica (expectation-side reimplementation, same
|
||||||
|
// philosophy as §7's pinned truth table — deliberately NOT calling the
|
||||||
|
// component's private helpers). `collapse: false` reproduces the pre-L-51
|
||||||
|
// bug the mandatory collapse step exists to prevent.
|
||||||
|
function replicaScore(loop, prog, sameStyle, { collapse }) {
|
||||||
|
const SUFFIX_Q = { '': 'maj', 'm': 'min', '7': 'dom7', 'maj7': 'maj7', 'm7': 'min7' } // enough for the pinned loops
|
||||||
|
const loopDeg = loopToDegrees(loop)
|
||||||
|
const loopCanon = canonicalDegrees(loopDeg)
|
||||||
|
const trans = (units) => units.map((u, i) => {
|
||||||
|
const b = units[(i + 1) % units.length]
|
||||||
|
return { d: (((b.deg - u.deg) % 12) + 12) % 12, qa: u.quality, qb: b.quality }
|
||||||
|
})
|
||||||
|
const loopT = trans(loop.map((name, i) => ({ deg: loopDeg[i], quality: SUFFIX_Q[name.match(/^[A-G][b#]?(.*)$/)[1]] ?? null })))
|
||||||
|
const units = collapse
|
||||||
|
? collapseChanges(prog)
|
||||||
|
: prog.degrees.map((d, i) => ({ deg: d, quality: prog.qualities[i] ?? null }))
|
||||||
|
const pCanon = canonicalDegrees(units.map((u) => u.deg))
|
||||||
|
const seen = new Set()
|
||||||
|
let shared = 0
|
||||||
|
for (const t of trans(units)) {
|
||||||
|
const k = `${t.d}|${t.qa}|${t.qb}`
|
||||||
|
if (seen.has(k)) continue
|
||||||
|
seen.add(k)
|
||||||
|
if (loopT.some((l) => l.d === t.d && (l.qa == null || t.qa == null || l.qa === t.qa) && (l.qb == null || t.qb == null || l.qb === t.qb))) shared++
|
||||||
|
}
|
||||||
|
const A = new Set(loopCanon.split(',')), B = new Set(pCanon.split(','))
|
||||||
|
const inter = [...A].filter((x) => B.has(x)).length
|
||||||
|
return (pCanon === loopCanon ? 100 : 0) + (sameStyle ? 40 : 0)
|
||||||
|
+ Math.min(shared * 12, 36) + (inter / (A.size + B.size - inter)) * 16
|
||||||
|
- Math.abs(loop.length - units.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
check('pin (c): no-collapse counterfactual — raw blues-12bar would score exactly 39 vs 152 with collapse; replica grounded against the live top blues-8bar (92)', () => {
|
||||||
|
const prog = kb.blues.progressions.find((p) => p.id === 'blues-12bar')
|
||||||
|
// The flagship "this 12-bar IS their 12-bar" relation: 152 with the mandatory
|
||||||
|
// collapse step, 39 without it — proving collapse is what fires the +100 shape
|
||||||
|
// term. blues-12bar is no longer a live *entry* post fix (a) (it's the match,
|
||||||
|
// excluded), so this is a pure-formula check of the replica, not read off the
|
||||||
|
// component. Both remain literal so a scoring-constant tweak turns smoke red.
|
||||||
|
const withCollapse = replicaScore(LOOP_12BAR_A, prog, false, { collapse: true })
|
||||||
|
assert(withCollapse === 152, `replica with collapse scored ${withCollapse} ≠ 152 — the replica drifted from the §5 formula; fix the replica (or the component changed: re-derive BOTH pins)`)
|
||||||
|
const noCollapse = replicaScore(LOOP_12BAR_A, prog, false, { collapse: false })
|
||||||
|
assert(noCollapse === 39, `no-collapse counterfactual scored ${noCollapse} ≠ 39 (36 transitions + 8 half-Jaccard − 5 length) — the raw/collapsed relationship changed; re-derive the counterfactual`)
|
||||||
|
// Ground the replica against the LIVE component via the actual top entry
|
||||||
|
// (blues-8bar, same-style relative): the replica must reproduce its score.
|
||||||
|
const prog8 = kb.blues.progressions.find((p) => p.id === 'blues-8bar')
|
||||||
|
const w8 = replicaScore(LOOP_12BAR_A, prog8, true, { collapse: true })
|
||||||
|
assert(w8 === 92, `replica of blues-8bar scored ${w8} ≠ 92 — the replica drifted from the §5 formula`)
|
||||||
|
assert(rank12.entries[0].score === w8, `replica (${w8}) ≠ live component top score (${rank12.entries[0].score}) — the grounding broke`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── 9. Summary + exit code ───────────────────────────────────────────────────
|
||||||
|
|
||||||
const total = passed + failures.length
|
const total = passed + failures.length
|
||||||
console.log('')
|
console.log('')
|
||||||
|
|||||||
@@ -499,6 +499,31 @@ for (const style of styleDirs) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Global progression-id uniqueness over the REGISTRY (task C-50) ────────────
|
||||||
|
// RelatedProgressions' ranking excludes the matched progression by ID ALONE
|
||||||
|
// (rankRelatedProgressions: `prog.id !== match.id` — no style comparison, task
|
||||||
|
// L-51), so a progression id repeated anywhere in the registry would silently
|
||||||
|
// drop an unrelated style's entry from the related list. The per-style loop
|
||||||
|
// above walks the FILES (kb/*/progressions.js) into `allIds`; this asserts the
|
||||||
|
// same invariant over kb/index.js — the object the components actually
|
||||||
|
// consume — so a registry wiring mistake the file walk never sees (a style
|
||||||
|
// registered against another style's progressions module, a shared/duplicated
|
||||||
|
// array) cannot reintroduce a duplicate.
|
||||||
|
if (registry) {
|
||||||
|
const seenRegIds = new Map() // id → style it first appeared under
|
||||||
|
for (const style of Object.keys(registry)) {
|
||||||
|
const progs = registry[style]?.progressions
|
||||||
|
if (!Array.isArray(progs)) continue
|
||||||
|
for (const p of progs) {
|
||||||
|
const rw = `kb/index.js [${style}]`
|
||||||
|
if (typeof p?.id !== 'string' || !p.id) { err(rw, 'registry progression with missing/non-string id'); continue }
|
||||||
|
if (seenRegIds.has(p.id))
|
||||||
|
err(rw, `progression id '${p.id}' already registered under style '${seenRegIds.get(p.id)}' — ids must be globally unique across the registry (RelatedProgressions excludes the matched progression by id alone)`)
|
||||||
|
else seenRegIds.set(p.id, style)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (errors.length) {
|
if (errors.length) {
|
||||||
console.error(`✗ KB validation failed — ${errors.length} error(s):\n`)
|
console.error(`✗ KB validation failed — ${errors.length} error(s):\n`)
|
||||||
for (const e of errors) console.error(' ' + e)
|
for (const e of errors) console.error(' ' + e)
|
||||||
|
|||||||
+254
-24
@@ -1,7 +1,6 @@
|
|||||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||||
import AudioCapture from './components/AudioCapture'
|
import AudioCapture from './components/AudioCapture'
|
||||||
import ProgressionBanner from './components/ProgressionBanner'
|
import ProgressionBanner from './components/ProgressionBanner'
|
||||||
import ProgressionSuggestions from './components/ProgressionSuggestions'
|
|
||||||
import Fretboard from './components/Fretboard'
|
import Fretboard from './components/Fretboard'
|
||||||
import BassFretboard from './components/BassFretboard'
|
import BassFretboard from './components/BassFretboard'
|
||||||
import Tuner from './components/Tuner'
|
import Tuner from './components/Tuner'
|
||||||
@@ -11,9 +10,12 @@ import DebugView from './components/DebugView'
|
|||||||
import DrumView from './components/DrumView'
|
import DrumView from './components/DrumView'
|
||||||
import { NOTES, detectKey, detectTopKeys, matchChordFromChroma, detectRepeatingProgression, getChordTones, getChordCandidates, getNoteHistoryAnalysis } from './lib/theory'
|
import { NOTES, detectKey, detectTopKeys, matchChordFromChroma, detectRepeatingProgression, getChordTones, getChordCandidates, getNoteHistoryAnalysis } from './lib/theory'
|
||||||
import ChordDetailModal from './components/ChordDetailModal'
|
import ChordDetailModal from './components/ChordDetailModal'
|
||||||
|
import RelatedProgressions from './components/RelatedProgressions'
|
||||||
import LoopStation from './components/LoopStation'
|
import LoopStation from './components/LoopStation'
|
||||||
import JamGuide, { KnowledgeDock } from './components/JamGuide'
|
import JamGuide, { KnowledgeDock } from './components/JamGuide'
|
||||||
import { useLoopEngine } from './services/loopEngine'
|
import { useLoopEngine } from './services/loopEngine'
|
||||||
|
import kb from './data/kb/index.js'
|
||||||
|
import { seedableLoop, buildRoulettePool } from './lib/match'
|
||||||
import settingIcon from './assets/setting-icon.png'
|
import settingIcon from './assets/setting-icon.png'
|
||||||
|
|
||||||
const DEFAULTS = {
|
const DEFAULTS = {
|
||||||
@@ -60,6 +62,36 @@ export default function App() {
|
|||||||
const [showDrumView, setShowDrumView] = useState(false)
|
const [showDrumView, setShowDrumView] = useState(false)
|
||||||
const [monoColor, setMonoColor] = useState(() => loadStored('wtf_monoColor', false))
|
const [monoColor, setMonoColor] = useState(() => loadStored('wtf_monoColor', false))
|
||||||
|
|
||||||
|
// ── Jam view (task L-50, one-screen.md §1.1) — pure UI/layout state ──────────
|
||||||
|
// Layer 1: CSS lock — at xl the page root becomes h-screen overflow-hidden and
|
||||||
|
// everything below the dashboard is unmounted. Layer 2: best-effort browser
|
||||||
|
// fullscreen (Promise-caught — a refusal leaves layer 1 fully working). The
|
||||||
|
// toggle never starts/stops listening and never touches audio state.
|
||||||
|
const [jamView, setJamView] = useState(false)
|
||||||
|
|
||||||
|
function enterJamView() {
|
||||||
|
setJamView(true)
|
||||||
|
document.documentElement.requestFullscreen?.()?.catch(() => {})
|
||||||
|
}
|
||||||
|
function exitJamView() {
|
||||||
|
setJamView(false)
|
||||||
|
if (document.fullscreenElement) document.exitFullscreen?.()?.catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape and the native fullscreen exit (any means) both restore normal flow —
|
||||||
|
// one state, never half-exited. Listeners active only while jamView.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!jamView) return
|
||||||
|
const onKey = (e) => { if (e.key === 'Escape') exitJamView() }
|
||||||
|
const onFsChange = () => { if (!document.fullscreenElement) setJamView(false) }
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
document.addEventListener('fullscreenchange', onFsChange)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', onKey)
|
||||||
|
document.removeEventListener('fullscreenchange', onFsChange)
|
||||||
|
}
|
||||||
|
}, [jamView]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// ── Jam Guide → Fretboard cross-link (D-03) ──────────────────────────────────
|
// ── Jam Guide → Fretboard cross-link (D-03) ──────────────────────────────────
|
||||||
// When a Roadmap station is tapped, JamGuide reports its {rootPc, quality}
|
// When a Roadmap station is tapped, JamGuide reports its {rootPc, quality}
|
||||||
// here and the main Fretboard highlights that chord's guide tones (3rd/7th).
|
// here and the main Fretboard highlights that chord's guide tones (3rd/7th).
|
||||||
@@ -122,6 +154,13 @@ export default function App() {
|
|||||||
const [detectedProgression, setDetectedProgression] = useState(null)
|
const [detectedProgression, setDetectedProgression] = useState(null)
|
||||||
const [selectedChord, setSelectedChord] = useState(null)
|
const [selectedChord, setSelectedChord] = useState(null)
|
||||||
|
|
||||||
|
// ── Jam Roulette (task L-60, jam-roulette.md) — pure UI/display state ─────────
|
||||||
|
// seedInfo: provenance of an unconfirmed rolled loop { styleLabel, name, bars }
|
||||||
|
// (null once live detection confirms or replaces it). Invariant (§3.5):
|
||||||
|
// seedInfo !== null ⇔ progressionVoteRef.current?.seeded === true.
|
||||||
|
const [seedInfo, setSeedInfo] = useState(null)
|
||||||
|
const [rouletteMenuOpen, setRouletteMenuOpen] = useState(false)
|
||||||
|
|
||||||
// ── Top key candidates (shown as quick-lock chips) ────────────────────────────
|
// ── Top key candidates (shown as quick-lock chips) ────────────────────────────
|
||||||
const [topKeyCandidates, setTopKeyCandidates] = useState([])
|
const [topKeyCandidates, setTopKeyCandidates] = useState([])
|
||||||
|
|
||||||
@@ -137,6 +176,13 @@ export default function App() {
|
|||||||
const progressionVoteRef = useRef(null)
|
const progressionVoteRef = useRef(null)
|
||||||
const progressionMissRef = useRef(0)
|
const progressionMissRef = useRef(0)
|
||||||
const pendingKeyRef = useRef(null)
|
const pendingKeyRef = useRef(null)
|
||||||
|
// Roulette session memory (UI-state refs, never read by audio code): the last
|
||||||
|
// 6 rolled progression ids (no-repeat), the last rolled root pc (§2), and the
|
||||||
|
// last resolved style id (for the "Re-roll — {style}" row).
|
||||||
|
const rouletteMemoryRef = useRef([])
|
||||||
|
const prevRootPcRef = useRef(null)
|
||||||
|
const lastRolledStyleRef = useRef(null)
|
||||||
|
const rouletteRef = useRef(null)
|
||||||
|
|
||||||
// Keep refs in sync
|
// Keep refs in sync
|
||||||
useEffect(() => { effectiveKeyRef.current = effectiveKey }, [effectiveKey])
|
useEffect(() => { effectiveKeyRef.current = effectiveKey }, [effectiveKey])
|
||||||
@@ -183,7 +229,11 @@ export default function App() {
|
|||||||
const vote = progressionVoteRef.current
|
const vote = progressionVoteRef.current
|
||||||
if (!detected) {
|
if (!detected) {
|
||||||
progressionMissRef.current++
|
progressionMissRef.current++
|
||||||
if (progressionMissRef.current >= NULL_CLEAR) {
|
// L-60 flag 1 (§3.4): a SEEDED card is an instruction, not an observation —
|
||||||
|
// ramp-up nulls (every seed's own effect run is miss 1, and no detection can
|
||||||
|
// land before commit 6) must NOT wipe it. It stays until confirmed, replaced
|
||||||
|
// by a consistently-detected different loop, re-rolled, or New Song.
|
||||||
|
if (progressionMissRef.current >= NULL_CLEAR && !vote?.seeded) {
|
||||||
setDetectedProgression(null)
|
setDetectedProgression(null)
|
||||||
progressionVoteRef.current = null
|
progressionVoteRef.current = null
|
||||||
}
|
}
|
||||||
@@ -197,6 +247,9 @@ export default function App() {
|
|||||||
setDetectedProgression(detected)
|
setDetectedProgression(detected)
|
||||||
vote.candidateKey = null
|
vote.candidateKey = null
|
||||||
vote.candidateCount = 0
|
vote.candidateCount = 0
|
||||||
|
// L-60 (§3.4): the seed is now a normal committed loop — the first agreeing
|
||||||
|
// live detection confirms it; drop the provenance flag + chip.
|
||||||
|
if (vote.seeded) { vote.seeded = false; setSeedInfo(null) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,11 +257,17 @@ export default function App() {
|
|||||||
if (vote && vote.candidateKey === key) {
|
if (vote && vote.candidateKey === key) {
|
||||||
vote.candidateCount++
|
vote.candidateCount++
|
||||||
} else {
|
} else {
|
||||||
progressionVoteRef.current = { committedKey, candidateKey: key, candidateCount: 1 }
|
// L-60 flag 3 (§3.4): preserve `seeded` across candidate rebuilds — a
|
||||||
|
// transient ghost sub-cycle must not strip the flag and resurrect flag 1's
|
||||||
|
// ramp-up kill through the side door.
|
||||||
|
progressionVoteRef.current = { committedKey, candidateKey: key, candidateCount: 1, seeded: vote?.seeded ?? false }
|
||||||
}
|
}
|
||||||
if (progressionVoteRef.current.candidateCount >= (committedKey ? REPLACE_VOTES : COMMIT_VOTES)) {
|
if (progressionVoteRef.current.candidateCount >= (committedKey ? REPLACE_VOTES : COMMIT_VOTES)) {
|
||||||
setDetectedProgression(detected)
|
setDetectedProgression(detected)
|
||||||
progressionVoteRef.current = { committedKey: key, candidateKey: null, candidateCount: 0 }
|
progressionVoteRef.current = { committedKey: key, candidateKey: null, candidateCount: 0 }
|
||||||
|
// L-60 (§3.4/§3.5): a genuinely different loop replaced the seed — the new
|
||||||
|
// vote is detection-owned; end the seed provenance.
|
||||||
|
setSeedInfo(null)
|
||||||
}
|
}
|
||||||
}, [chordHistory])
|
}, [chordHistory])
|
||||||
|
|
||||||
@@ -231,6 +290,7 @@ export default function App() {
|
|||||||
effectiveKeyRef.current = null
|
effectiveKeyRef.current = null
|
||||||
setChordHistory([])
|
setChordHistory([])
|
||||||
setDetectedProgression(null)
|
setDetectedProgression(null)
|
||||||
|
setSeedInfo(null) // L-60 (§3.6): the seed clears with the full reset
|
||||||
setTopKeyCandidates([])
|
setTopKeyCandidates([])
|
||||||
setBpm(null)
|
setBpm(null)
|
||||||
setMicError(null)
|
setMicError(null)
|
||||||
@@ -260,6 +320,94 @@ export default function App() {
|
|||||||
effectiveKeyRef.current = keyInfo
|
effectiveKeyRef.current = keyInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Jam Roulette (task L-60, jam-roulette.md §2/§3.1) ────────────────────────
|
||||||
|
// Rolls a random key + interesting KB progression and seeds the EXACT state
|
||||||
|
// live detection writes (lockedKey + detectedProgression + a committed-shape
|
||||||
|
// progressionVoteRef), so the whole dashboard populates as if detected. All
|
||||||
|
// randomness is here (plain Math.random, no audio contact); inputs = the KB
|
||||||
|
// registry only. `styleId === 'surprise'` picks uniformly over the 10 styles.
|
||||||
|
function rollJam(styleId) {
|
||||||
|
const pool = buildRoulettePool(kb) // lazy memo — round-trip passers, len 2–8
|
||||||
|
const sid = styleId === 'surprise'
|
||||||
|
? Object.keys(kb)[Math.floor(Math.random() * Object.keys(kb).length)]
|
||||||
|
: styleId
|
||||||
|
const members = pool.byStyle.get(sid) ?? []
|
||||||
|
if (!members.length) return
|
||||||
|
|
||||||
|
// No-repeat memory (§2.2): exclude the last 6 rolled ids; if that empties the
|
||||||
|
// pool (small styles), fall back to excluding only the immediately previous.
|
||||||
|
const mem = rouletteMemoryRef.current
|
||||||
|
let candidates = members.filter(m => !mem.includes(m.id))
|
||||||
|
if (!candidates.length) {
|
||||||
|
const prev = mem[mem.length - 1]
|
||||||
|
candidates = members.filter(m => m.id !== prev)
|
||||||
|
if (!candidates.length) candidates = members
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weighted draw: weight = levelW × lenW (§2.2). Intermediate leans in (×2),
|
||||||
|
// the collapsed "4-bar-ish" sweet spot 3–7 leans in (×2).
|
||||||
|
const weightOf = (m) => {
|
||||||
|
const levelW = m.progression.level === 'intermediate' ? 2 : 1
|
||||||
|
const lenW = (m.collapsedLen >= 3 && m.collapsedLen <= 7) ? 2 : 1
|
||||||
|
return levelW * lenW
|
||||||
|
}
|
||||||
|
const totalW = candidates.reduce((s, m) => s + weightOf(m), 0)
|
||||||
|
let r = Math.random() * totalW
|
||||||
|
let picked = candidates[candidates.length - 1]
|
||||||
|
for (const m of candidates) { r -= weightOf(m); if (r <= 0) { picked = m; break } }
|
||||||
|
const prog = picked.progression
|
||||||
|
|
||||||
|
// Root: uniform over 12 pcs, SHARP spelling (§2.1 — only sharp names
|
||||||
|
// string-match live detection); don't repeat the previous roll's root.
|
||||||
|
let rolledPc = Math.floor(Math.random() * 12)
|
||||||
|
if (prevRootPcRef.current != null && rolledPc === prevRootPcRef.current) {
|
||||||
|
rolledPc = Math.floor(Math.random() * 12) // re-draw once
|
||||||
|
}
|
||||||
|
const loop = seedableLoop(prog, rolledPc)
|
||||||
|
if (!loop) return // pool guarantees this, but stay defensive
|
||||||
|
prevRootPcRef.current = rolledPc
|
||||||
|
|
||||||
|
// ── The seed writes (§3.1) — clean slate, then key + loop + committed vote ──
|
||||||
|
newSong() // §3.6: roulette = New Song + seed (a stale window poisons handoff)
|
||||||
|
const info = { root: NOTES[rolledPc], mode: prog.mode, confidence: 1 }
|
||||||
|
setLockedKey(info)
|
||||||
|
effectiveKeyRef.current = info // the quickLock precedent — detection uses it NOW
|
||||||
|
chordVotesRef.current = []
|
||||||
|
setDetectedProgression(loop)
|
||||||
|
progressionVoteRef.current = {
|
||||||
|
committedKey: loop.join(','), // the committed shape, L-31's own key
|
||||||
|
candidateKey: null,
|
||||||
|
candidateCount: 0,
|
||||||
|
seeded: true, // the one flag L-60 adds (§3.4)
|
||||||
|
}
|
||||||
|
progressionMissRef.current = 0
|
||||||
|
setSeedInfo({ styleLabel: kb[sid]?.meta?.label ?? sid, name: prog.name, bars: prog.bars })
|
||||||
|
|
||||||
|
// Session bookkeeping (§2.2) — survives New Song deliberately.
|
||||||
|
lastRolledStyleRef.current = sid
|
||||||
|
mem.push(picked.id)
|
||||||
|
while (mem.length > 6) mem.shift()
|
||||||
|
setRouletteMenuOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape / click-outside closes the roulette menu; focus returns to the button.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!rouletteMenuOpen) return
|
||||||
|
const onKey = (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setRouletteMenuOpen(false)
|
||||||
|
rouletteRef.current?.querySelector('button')?.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const onDown = (e) => { if (!rouletteRef.current?.contains(e.target)) setRouletteMenuOpen(false) }
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
document.addEventListener('mousedown', onDown)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', onKey)
|
||||||
|
document.removeEventListener('mousedown', onDown)
|
||||||
|
}
|
||||||
|
}, [rouletteMenuOpen])
|
||||||
|
|
||||||
// ── Waveform handler: feeds oscilloscope / drum view ─────────────────────────
|
// ── Waveform handler: feeds oscilloscope / drum view ─────────────────────────
|
||||||
const handleWaveform = useCallback((data) => {
|
const handleWaveform = useCallback((data) => {
|
||||||
if (showDebugRef.current || showDrumViewRef.current) {
|
if (showDebugRef.current || showDrumViewRef.current) {
|
||||||
@@ -436,7 +584,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-surface text-white p-3">
|
<div className={`min-h-screen bg-surface text-white p-3${jamView ? ' xl:h-screen xl:overflow-hidden xl:flex xl:flex-col' : ''}`}>
|
||||||
|
|
||||||
{/* ── Header ── */}
|
{/* ── Header ── */}
|
||||||
<header className="mb-2 flex items-center justify-between">
|
<header className="mb-2 flex items-center justify-between">
|
||||||
@@ -576,6 +724,77 @@ export default function App() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Jam roulette (L-60, jam-roulette.md §1.1/§1.2) ── */}
|
||||||
|
<div className="relative ml-auto" ref={rouletteRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRouletteMenuOpen(o => !o)}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={rouletteMenuOpen}
|
||||||
|
aria-pressed={seedInfo ? true : undefined}
|
||||||
|
title="Jam roulette — roll a random key + progression to jam on"
|
||||||
|
className={`min-h-[32px] px-3 py-1 rounded-lg border text-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-accent ${
|
||||||
|
seedInfo
|
||||||
|
? 'border-accent/40 text-accent bg-accent/10 font-semibold'
|
||||||
|
: 'border-border text-gray-200 hover:border-gray-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
🎲 Jam roulette
|
||||||
|
</button>
|
||||||
|
{rouletteMenuOpen && (
|
||||||
|
<div
|
||||||
|
role="menu"
|
||||||
|
aria-label="Pick a genre to roll"
|
||||||
|
className="absolute right-0 mt-1 w-56 bg-panel border border-border rounded-xl shadow-lg p-1 z-20"
|
||||||
|
>
|
||||||
|
{seedInfo && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => rollJam(lastRolledStyleRef.current)}
|
||||||
|
className="w-full text-left px-3 py-1.5 rounded-lg text-sm text-gray-200 hover:bg-accent/10 border-b border-border outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||||
|
>
|
||||||
|
⟳ Re-roll — {seedInfo.styleLabel}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => rollJam('surprise')}
|
||||||
|
className="w-full text-left px-3 py-1.5 rounded-lg text-sm text-gray-200 hover:bg-accent/10 border-b border-border outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||||
|
>
|
||||||
|
✨ Surprise me
|
||||||
|
</button>
|
||||||
|
{Object.entries(kb).map(([id, s]) => (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => rollJam(id)}
|
||||||
|
className="w-full text-left px-3 py-1.5 rounded-lg text-sm text-gray-200 hover:bg-accent/10 outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||||
|
>
|
||||||
|
{s.meta?.label ?? id}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Jam view toggle (L-50, one-screen.md §1.1) — layout-only ── */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={jamView ? exitJamView : enterJamView}
|
||||||
|
aria-pressed={jamView}
|
||||||
|
title={jamView ? 'Exit jam view' : 'Jam view — the one-screen dashboard, fullscreen'}
|
||||||
|
className={`ml-auto min-h-[32px] px-3 py-1 rounded-lg text-sm border transition-all outline-none focus-visible:ring-2 focus-visible:ring-accent ${
|
||||||
|
jamView
|
||||||
|
? 'bg-accent/20 border-accent text-accent font-semibold'
|
||||||
|
: 'border-border text-gray-400 hover:border-gray-500 hover:text-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{jamView ? '✕ Exit' : '⛶ Jam view'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AudioCapture
|
<AudioCapture
|
||||||
@@ -609,29 +828,17 @@ export default function App() {
|
|||||||
chordHistory={chordHistory}
|
chordHistory={chordHistory}
|
||||||
keyInfo={effectiveKey}
|
keyInfo={effectiveKey}
|
||||||
detectedProgression={detectedProgression}
|
detectedProgression={detectedProgression}
|
||||||
currentChord={currentChord}
|
seedInfo={seedInfo}
|
||||||
onChordClick={setSelectedChord}
|
onChordClick={setSelectedChord}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ── Instrument + progressions row ── */}
|
{/* ── The jam dashboard grid (task L-50, one-screen.md §1/§6): JamGuide
|
||||||
<div className="flex gap-3 mb-3 items-stretch">
|
owns the two-column layout — LEFT: compact instrument view (chosen
|
||||||
<div className="w-full lg:w-[70%] min-w-0">
|
here, passed as the mainView slot) + licks strip + the related-
|
||||||
{instrument === 'guitar' && <Fretboard keyInfo={effectiveKey} currentChord={currentChord} pentatonicOnly={false} monoColor={monoColor} jamFocusChord={jamFocusChord} />}
|
progressions slot (RelatedProgressions, task L-51); RIGHT: the
|
||||||
{instrument === 'bass' && <BassFretboard keyInfo={effectiveKey} currentChord={currentChord} monoColor={monoColor} />}
|
suggested-voicings rail. ProgressionSuggestions is unmounted (file
|
||||||
{instrument === 'piano' && <Piano keyInfo={effectiveKey} currentChord={currentChord} monoColor={monoColor} />}
|
kept) — its job split into the rail + RelatedProgressions per the
|
||||||
</div>
|
user directive. Follows the one global instrument selector. ── */}
|
||||||
|
|
||||||
<div className="hidden lg:block w-[30%] min-w-0 relative">
|
|
||||||
<div className="absolute inset-0">
|
|
||||||
<ProgressionSuggestions keyInfo={effectiveKey} currentChord={currentChord} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
{/* ── Jam Guide band — always open, right below the instrument row (L-40,
|
|
||||||
D-40 §1: CurrentJamPanel's old slot; the loop shows ONCE, in the
|
|
||||||
banner above). Follows the one global instrument selector. ── */}
|
|
||||||
<JamGuide
|
<JamGuide
|
||||||
detectedProgression={detectedProgression}
|
detectedProgression={detectedProgression}
|
||||||
keyInfo={effectiveKey}
|
keyInfo={effectiveKey}
|
||||||
@@ -639,8 +846,29 @@ export default function App() {
|
|||||||
currentChord={currentChord}
|
currentChord={currentChord}
|
||||||
onFocusChord={setJamFocusChord}
|
onFocusChord={setJamFocusChord}
|
||||||
instrument={instrument}
|
instrument={instrument}
|
||||||
|
mainView={
|
||||||
|
<>
|
||||||
|
{instrument === 'guitar' && <Fretboard keyInfo={effectiveKey} currentChord={currentChord} pentatonicOnly={false} monoColor={monoColor} jamFocusChord={jamFocusChord} compact />}
|
||||||
|
{instrument === 'bass' && <BassFretboard keyInfo={effectiveKey} currentChord={currentChord} monoColor={monoColor} compact />}
|
||||||
|
{instrument === 'piano' && <Piano keyInfo={effectiveKey} currentChord={currentChord} monoColor={monoColor} compact />}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
relatedSlot={
|
||||||
|
<RelatedProgressions
|
||||||
|
loop={detectedProgression}
|
||||||
|
keyInfo={effectiveKey}
|
||||||
|
onChordClick={setSelectedChord}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
fill={jamView}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* ── Below the dashboard — the learning / behind-the-scenes area (page
|
||||||
|
scroll in normal mode; UNMOUNTED in jam view, one-screen.md §1.1/§3:
|
||||||
|
conditional mount, not `hidden`, so collapsed chrome can't leak
|
||||||
|
height). ── */}
|
||||||
|
{!jamView && (
|
||||||
|
<>
|
||||||
{/* ── Loop station ── */}
|
{/* ── Loop station ── */}
|
||||||
<LoopStation
|
<LoopStation
|
||||||
slots={slots}
|
slots={slots}
|
||||||
@@ -719,6 +947,8 @@ export default function App() {
|
|||||||
onChordClick={setSelectedChord}
|
onChordClick={setSelectedChord}
|
||||||
instrument={instrument}
|
instrument={instrument}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,10 @@ function noteColor(isChordTone, isPenta, isScale, mono = false) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BassFretboard({ keyInfo, currentChord, monoColor = false }) {
|
// `compact` (task L-50, one-screen.md §2): trimmed card chrome (p-3, legend
|
||||||
|
// merged onto the heading line) + a natural-width cap on the SVG (max-width =
|
||||||
|
// its viewBox width, so it never renders above scale 1.0).
|
||||||
|
export default function BassFretboard({ keyInfo, currentChord, monoColor = false, compact = false }) {
|
||||||
const { root, mode } = keyInfo ?? {}
|
const { root, mode } = keyInfo ?? {}
|
||||||
|
|
||||||
if (!root) return null
|
if (!root) return null
|
||||||
@@ -44,19 +47,40 @@ export default function BassFretboard({ keyInfo, currentChord, monoColor = false
|
|||||||
? new Set(getChordTones(currentChord).map(n => NOTES.indexOf(n)))
|
? new Set(getChordTones(currentChord).map(n => NOTES.indexOf(n)))
|
||||||
: new Set()
|
: new Set()
|
||||||
|
|
||||||
|
const heading = (
|
||||||
|
<p className={`text-sm text-gray-500 uppercase tracking-widest ${compact ? '' : 'mb-4'}`}>
|
||||||
|
Bass — {root} {mode}
|
||||||
|
{currentChord && <span className="text-amber-400 ml-2">/ {currentChord}</span>}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
|
||||||
|
const legend = (
|
||||||
|
// Critic mechanical fix (L-50 gate): non-compact keeps HEAD's exact class
|
||||||
|
// string so the non-compact render stays byte-identical to the committed one.
|
||||||
|
<div className={compact ? 'flex items-center text-xs text-gray-500 flex-wrap gap-3' : 'mt-3 flex gap-5 text-xs text-gray-500'}>
|
||||||
|
<span><span className="text-accent">●</span> Chord tone</span>
|
||||||
|
<span style={{ color: monoColor ? '#c084fc' : '#f59e0b' }}>●</span><span> Pentatonic</span>
|
||||||
|
<span style={{ color: monoColor ? '#e9d5ff' : '#6b7280' }}>●</span><span> Scale</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-panel border border-border rounded-2xl p-6">
|
<div className={`bg-panel border border-border rounded-2xl ${compact ? 'p-3' : 'p-6'}`}>
|
||||||
<p className="text-sm text-gray-500 uppercase tracking-widest mb-4">
|
{compact ? (
|
||||||
Bass — {root} {mode}
|
<div className="mb-2 flex flex-wrap items-center justify-between gap-x-4 gap-y-1">
|
||||||
{currentChord && <span className="text-amber-400 ml-2">/ {currentChord}</span>}
|
{heading}
|
||||||
</p>
|
{legend}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
heading
|
||||||
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<svg
|
<svg
|
||||||
viewBox={`0 0 ${BOARD_W} ${BOARD_H}`}
|
viewBox={`0 0 ${BOARD_W} ${BOARD_H}`}
|
||||||
width="100%"
|
width="100%"
|
||||||
height="auto"
|
height="auto"
|
||||||
style={{ display: 'block' }}
|
style={{ display: 'block', ...(compact ? { maxWidth: BOARD_W } : null) }}
|
||||||
>
|
>
|
||||||
{/* Fretboard background */}
|
{/* Fretboard background */}
|
||||||
<rect x={NUT_X} y={PAD_T - 6} width={BOARD_W - NUT_X - 4} height={3 * STRING_H + 12}
|
<rect x={NUT_X} y={PAD_T - 6} width={BOARD_W - NUT_X - 4} height={3 * STRING_H + 12}
|
||||||
@@ -139,11 +163,7 @@ export default function BassFretboard({ keyInfo, currentChord, monoColor = false
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 flex gap-5 text-xs text-gray-500">
|
{!compact && legend}
|
||||||
<span><span className="text-accent">●</span> Chord tone</span>
|
|
||||||
<span style={{ color: monoColor ? '#c084fc' : '#f59e0b' }}>●</span><span> Pentatonic</span>
|
|
||||||
<span style={{ color: monoColor ? '#e9d5ff' : '#6b7280' }}>●</span><span> Scale</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,11 @@ function noteColor(isChordTone, isPenta, isScale, mono = false) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Fretboard({ keyInfo, currentChord, pentatonicOnly = false, monoColor = false, jamFocusChord = null }) {
|
// `compact` (task L-50, one-screen.md §2): trimmed card chrome (p-3, legend
|
||||||
|
// merged onto the heading line) + a natural-width cap on the SVG (max-width =
|
||||||
|
// its viewBox width, so it never renders above scale 1.0). No fret reduction,
|
||||||
|
// no transform scaling — the notes stay at their designed size.
|
||||||
|
export default function Fretboard({ keyInfo, currentChord, pentatonicOnly = false, monoColor = false, jamFocusChord = null, compact = false }) {
|
||||||
const { root, mode } = keyInfo ?? {}
|
const { root, mode } = keyInfo ?? {}
|
||||||
|
|
||||||
if (!root) return null
|
if (!root) return null
|
||||||
@@ -78,20 +82,49 @@ export default function Fretboard({ keyInfo, currentChord, pentatonicOnly = fals
|
|||||||
const focusLabel = pc =>
|
const focusLabel = pc =>
|
||||||
pc === focusThird ? '3' : pc === focusSeventh ? focusSeventhLabel : null
|
pc === focusThird ? '3' : pc === focusSeventh ? focusSeventhLabel : null
|
||||||
|
|
||||||
|
const heading = (
|
||||||
|
<p className={`text-sm text-gray-500 uppercase tracking-widest ${compact ? '' : 'mb-4'}`}>
|
||||||
|
Fretboard — {root} {mode}
|
||||||
|
{currentChord && <span className="text-amber-400 ml-2">/ {currentChord}</span>}
|
||||||
|
{hasFocus && <span className="text-accent ml-2">◎ guide tones</span>}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
|
||||||
|
const legend = (
|
||||||
|
// Critic mechanical fix (L-50 gate): non-compact keeps HEAD's exact class
|
||||||
|
// string so the non-compact render stays byte-identical to the committed one.
|
||||||
|
<div className={compact ? 'flex flex-wrap items-center text-xs text-gray-500 gap-3' : 'mt-3 flex flex-wrap gap-5 text-xs text-gray-500'}>
|
||||||
|
<span><span className="text-accent">●</span> Chord tone</span>
|
||||||
|
<span style={{ color: monoColor ? '#c084fc' : '#f59e0b' }}>●</span><span> Pentatonic</span>
|
||||||
|
<span style={{ color: monoColor ? '#e9d5ff' : '#6b7280' }}>●</span><span> Scale</span>
|
||||||
|
{hasFocus && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span
|
||||||
|
className="inline-block w-3 h-3 rounded-full border-2 border-accent"
|
||||||
|
/>
|
||||||
|
Guide tones (3 / {focusSeventhLabel})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-panel border border-border rounded-2xl p-6">
|
<div className={`bg-panel border border-border rounded-2xl ${compact ? 'p-3' : 'p-6'}`}>
|
||||||
<p className="text-sm text-gray-500 uppercase tracking-widest mb-4">
|
{compact ? (
|
||||||
Fretboard — {root} {mode}
|
<div className="mb-2 flex flex-wrap items-center justify-between gap-x-4 gap-y-1">
|
||||||
{currentChord && <span className="text-amber-400 ml-2">/ {currentChord}</span>}
|
{heading}
|
||||||
{hasFocus && <span className="text-accent ml-2">◎ guide tones</span>}
|
{legend}
|
||||||
</p>
|
</div>
|
||||||
|
) : (
|
||||||
|
heading
|
||||||
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<svg
|
<svg
|
||||||
viewBox={`0 0 ${BOARD_W} ${BOARD_H}`}
|
viewBox={`0 0 ${BOARD_W} ${BOARD_H}`}
|
||||||
width="100%"
|
width="100%"
|
||||||
height="auto"
|
height="auto"
|
||||||
style={{ display: 'block' }}
|
style={{ display: 'block', ...(compact ? { maxWidth: BOARD_W } : null) }}
|
||||||
>
|
>
|
||||||
{/* Fretboard background */}
|
{/* Fretboard background */}
|
||||||
<rect x={NUT_X} y={PAD_T - 6} width={BOARD_W - NUT_X - 4} height={5 * STRING_H + 12}
|
<rect x={NUT_X} y={PAD_T - 6} width={BOARD_W - NUT_X - 4} height={5 * STRING_H + 12}
|
||||||
@@ -202,19 +235,7 @@ export default function Fretboard({ keyInfo, currentChord, pentatonicOnly = fals
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 flex flex-wrap gap-5 text-xs text-gray-500">
|
{!compact && legend}
|
||||||
<span><span className="text-accent">●</span> Chord tone</span>
|
|
||||||
<span style={{ color: monoColor ? '#c084fc' : '#f59e0b' }}>●</span><span> Pentatonic</span>
|
|
||||||
<span style={{ color: monoColor ? '#e9d5ff' : '#6b7280' }}>●</span><span> Scale</span>
|
|
||||||
{hasFocus && (
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<span
|
|
||||||
className="inline-block w-3 h-3 rounded-full border-2 border-accent"
|
|
||||||
/>
|
|
||||||
Guide tones (3 / {focusSeventhLabel})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
// GlanceRail — ALL loop stations expanded, always (task D-41, per
|
// GlanceRail — ALL loop stations expanded, always (task D-41, per
|
||||||
// docs/design/integrated-glance.md §4; supersedes the L-33 playhead accordion).
|
// docs/design/integrated-glance.md §4; supersedes the L-33 playhead accordion).
|
||||||
//
|
//
|
||||||
|
// D-51 (docs/design/one-screen.md §4/§6.2) adapts the rail to the dashboard's
|
||||||
|
// 500px right column. Margin-hardening arithmetic, worst case = a classic
|
||||||
|
// Windows scrollbar (~17px) inside the column's overflow-y-auto wrapper:
|
||||||
|
// row interior = 500 − 17 (scrollbar) − 18 (section border + p-2)
|
||||||
|
// − 14 (row border + p-1.5) = 451px
|
||||||
|
// guitar cell 89 (75 SVG + p-1.5 + border) → 4/line (374 ≤ 451; a 5th = 469 ✗)
|
||||||
|
// piano 1-octave 156.4 / 2-octave 279.6 → the mixed pair 279.6+6+156.4 = 442
|
||||||
|
// fits with ~9px margin even under the scrollbar (26px without) — the fit the
|
||||||
|
// doc calls fragile at the old paddings is now robust. Without the scrollbar
|
||||||
|
// the interior is 468px; every count above is unchanged.
|
||||||
|
//
|
||||||
// One VERTICAL ROW per loop station, canonical KB order (the same order the
|
// One VERTICAL ROW per loop station, canonical KB order (the same order the
|
||||||
// banner's loop shows after rotation). Every row renders its full voicing
|
// banner's loop shows after rotation). Every row renders its full voicing
|
||||||
// gallery PERMANENTLY — the playhead HIGHLIGHTS the active row (accent ring +
|
// gallery PERMANENTLY — the playhead HIGHLIGHTS the active row (accent ring +
|
||||||
@@ -149,7 +160,7 @@ function StationRow({
|
|||||||
`${st.label}${st.rn ? ` (${st.rn})` : ''} — every ${instrument} voicing` +
|
`${st.label}${st.rn ? ` (${st.rn})` : ''} — every ${instrument} voicing` +
|
||||||
`${isNow ? ', now playing' : ''}${isNext ? ', up next' : ''}`
|
`${isNow ? ', now playing' : ''}${isNext ? ', up next' : ''}`
|
||||||
}
|
}
|
||||||
className={`rounded-lg border p-2 ${stateClass}`}
|
className={`rounded-lg border p-1.5 ${stateClass}`}
|
||||||
style={{ opacity: isNow || isFocused ? 1 : 0.85 }}
|
style={{ opacity: isNow || isFocused ? 1 : 0.85 }}
|
||||||
>
|
>
|
||||||
{/* ── Header line: identity + the folded roadmap education ── */}
|
{/* ── Header line: identity + the folded roadmap education ── */}
|
||||||
@@ -190,11 +201,19 @@ function StationRow({
|
|||||||
<TransitionChip pair={rail} wraps={wraps} />
|
<TransitionChip pair={rail} wraps={wraps} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Gallery: own-voicing cell + the full dense browser; cells WRAP,
|
{/* ── Gallery: own-voicing cell FIRST (the recommended cell — full-accent
|
||||||
never scroll horizontally (D-40 §4). ── */}
|
border reads as "the answer", one-screen.md §4; same size, no new
|
||||||
<div className="flex flex-wrap items-start gap-2">
|
colour) + the full dense browser; cells WRAP, never scroll
|
||||||
|
horizontally (D-40 §4). Browser basis 320px: a guitar own-cell
|
||||||
|
(89px) still shares line one (89+6+320 = 415 ≤ 451) so the first
|
||||||
|
visual line shows play + 3 shapes = 4 across, while a piano
|
||||||
|
own-cell (≥156.4px) can never share it (156.4+6+320 = 482.4 > 468
|
||||||
|
even scrollbar-free) — the recommended voicing sits alone on line
|
||||||
|
one, prominence for free, and the gallery pairs at the FULL row
|
||||||
|
interior below it (the 279.6+156.4 mixed-pair fit needs all 451px). ── */}
|
||||||
|
<div className="flex flex-wrap items-start gap-1.5">
|
||||||
{(ownGuitar || ownPiano) && (
|
{(ownGuitar || ownPiano) && (
|
||||||
<figure className="flex shrink-0 flex-col items-center gap-1.5 rounded-md border border-accent/60 bg-surface p-2">
|
<figure className="flex shrink-0 flex-col items-center gap-1.5 rounded-md border border-accent bg-surface p-1.5">
|
||||||
<figcaption className="flex max-w-full items-center gap-1.5 text-[11px] font-medium leading-tight text-gray-300">
|
<figcaption className="flex max-w-full items-center gap-1.5 text-[11px] font-medium leading-tight text-gray-300">
|
||||||
{ownGuitar && (
|
{ownGuitar && (
|
||||||
<span className="rounded bg-accent px-1 py-0.5 text-[9px] font-bold uppercase tracking-wider text-black">
|
<span className="rounded bg-accent px-1 py-0.5 text-[9px] font-bold uppercase tracking-wider text-black">
|
||||||
@@ -210,7 +229,7 @@ function StationRow({
|
|||||||
)}
|
)}
|
||||||
</figure>
|
</figure>
|
||||||
)}
|
)}
|
||||||
<div className="min-w-0 flex-1 basis-[300px]">
|
<div className="min-w-0 flex-1 basis-[320px]">
|
||||||
{/* dense: the rail shows the mic-feedback microcopy once, below. */}
|
{/* dense: the rail shows the mic-feedback microcopy once, below. */}
|
||||||
<VoicingBrowser rootPc={st.rootPc} quality={st.quality} show={instrument} dense />
|
<VoicingBrowser rootPc={st.rootPc} quality={st.quality} show={instrument} dense />
|
||||||
</div>
|
</div>
|
||||||
@@ -243,7 +262,7 @@ export default function GlanceRail({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className="rounded-2xl border border-border bg-panel p-3"
|
className="rounded-2xl border border-border bg-panel p-2"
|
||||||
aria-label="Voicing variations — every chord of the loop, all expanded"
|
aria-label="Voicing variations — every chord of the loop, all expanded"
|
||||||
>
|
>
|
||||||
<h4 className="mb-2 text-[10px] font-semibold uppercase tracking-widest text-gray-500">
|
<h4 className="mb-2 text-[10px] font-semibold uppercase tracking-widest text-gray-500">
|
||||||
|
|||||||
+162
-103
@@ -10,17 +10,24 @@ import { ExploreSection, VoicingsSection, LevelChips } from './ExplorePanel'
|
|||||||
import { pianoVoicingChain } from '../lib/piano'
|
import { pianoVoicingChain } from '../lib/piano'
|
||||||
import { parseChord } from '../lib/voicings'
|
import { parseChord } from '../lib/voicings'
|
||||||
|
|
||||||
// ─── JamGuide.jsx — the jam BAND + the Knowledge Center DOCK ─────────────────
|
// ─── JamGuide.jsx — the jam-grid OWNER + the Knowledge Center DOCK ───────────
|
||||||
//
|
//
|
||||||
// Task L-40 (per docs/design/integrated-glance.md §5–§6.1) split the old
|
// Task L-50 (per docs/design/one-screen.md §6) promoted the default export from
|
||||||
// four-section bottom dock in two:
|
// "band" to the two-column jam dashboard grid:
|
||||||
//
|
//
|
||||||
// default export `JamGuide` — the always-open, zero-chrome jam band, mounted
|
// default export `JamGuide` — renders the xl: two-column flex region.
|
||||||
// by App directly below the instrument row (CurrentJamPanel's old slot).
|
// LEFT (flex-1): the `mainView` slot (App keeps choosing Fretboard /
|
||||||
// Body: GlanceRail voicings + LicksStrip for the matched loop; heard-live
|
// BassFretboard / Piano — JamGuide never imports them), the LicksStrip,
|
||||||
// single gallery when no loop is matched; a slim one-line hint when nothing
|
// and the `relatedSlot` (RelatedProgressions, mounted by App — null until
|
||||||
// is heard. The loop itself renders ONCE, in ProgressionBanner (D-40 §2) —
|
// L-51). RIGHT (500px): the suggested-voicings rail — GlanceRail /
|
||||||
// RoadmapTrack is unmounted (file retired in place, deletion backlogged).
|
// BassGuideRows / the heard-live fallback — inside the design's ONE
|
||||||
|
// justified internal scroller (height-bounded, NOT sticky, §1). Below xl
|
||||||
|
// the columns stack in jam-following order via display:contents + order
|
||||||
|
// classes: mainView → rail → licks → related (§7; rail unbounds).
|
||||||
|
// The `fill` prop (jam view, §1.1) swaps the rail's viewport-calc bound
|
||||||
|
// for h-full and makes the left column a flex stack whose related slot
|
||||||
|
// absorbs the remainder. The loop itself renders ONCE, in
|
||||||
|
// ProgressionBanner (D-40 §2).
|
||||||
// named export `KnowledgeDock` — the bottom collapsible browse/study area:
|
// named export `KnowledgeDock` — the bottom collapsible browse/study area:
|
||||||
// Explore / Voicings / Licks & Techniques + the shared level filter (the
|
// Explore / Voicings / Licks & Techniques + the shared level filter (the
|
||||||
// old dock minus its jam section, which IS the band now).
|
// old dock minus its jam section, which IS the band now).
|
||||||
@@ -149,7 +156,7 @@ function lickFitsContext(lick, context) {
|
|||||||
return wanted.length > 0 && tokens.some(t => wanted.includes(t))
|
return wanted.length > 0 && tokens.some(t => wanted.includes(t))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── JamGuide — the always-open jam band (default export) ─────────────────────
|
// ─── JamGuide — the jam dashboard grid (default export) ───────────────────────
|
||||||
//
|
//
|
||||||
// Props:
|
// Props:
|
||||||
// detectedProgression : string[] | null — the live detected loop (chord names)
|
// detectedProgression : string[] | null — the live detected loop (chord names)
|
||||||
@@ -158,12 +165,16 @@ function lickFitsContext(lick, context) {
|
|||||||
// currentChord : string | undefined — most recent committed chord
|
// currentChord : string | undefined — most recent committed chord
|
||||||
// onFocusChord : fn({rootPc,quality}|null) — Fretboard guide-tone link (D-03)
|
// onFocusChord : fn({rootPc,quality}|null) — Fretboard guide-tone link (D-03)
|
||||||
// instrument : 'guitar' | 'piano' | 'bass' — App's global selector
|
// instrument : 'guitar' | 'piano' | 'bass' — App's global selector
|
||||||
|
// mainView : JSX slot — the compact instrument view (App-chosen; L-50)
|
||||||
|
// relatedSlot : JSX slot — RelatedProgressions (App-mounted; null until L-51)
|
||||||
|
// fill : boolean — jam view (one-screen.md §1.1): the grid fills
|
||||||
|
// App's h-screen column; rail bound becomes h-full
|
||||||
|
|
||||||
// The band shows ALL levels — a glance surface filters nothing (D-40 §5); the
|
// The band shows ALL levels — a glance surface filters nothing (D-40 §5); the
|
||||||
// level filter lives in the KnowledgeDock only.
|
// level filter lives in the KnowledgeDock only.
|
||||||
const ALL_LEVELS = { foundation: true, intermediate: true }
|
const ALL_LEVELS = { foundation: true, intermediate: true }
|
||||||
|
|
||||||
export default function JamGuide({ detectedProgression, keyInfo, chordHistory = [], currentChord, onFocusChord, instrument = 'guitar' }) {
|
export default function JamGuide({ detectedProgression, keyInfo, chordHistory = [], currentChord, onFocusChord, instrument = 'guitar', mainView = null, relatedSlot = null, fill = false }) {
|
||||||
// Style labels straight from the KB registry, via each style's meta.
|
// Style labels straight from the KB registry, via each style's meta.
|
||||||
const styles = useMemo(
|
const styles = useMemo(
|
||||||
() => Object.entries(kb).map(([id, style]) => ({ id, label: style?.meta?.label ?? id })),
|
() => Object.entries(kb).map(([id, style]) => ({ id, label: style?.meta?.label ?? id })),
|
||||||
@@ -270,6 +281,10 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
|
|||||||
quality: qualities[i] ?? 'maj',
|
quality: qualities[i] ?? 'maj',
|
||||||
label: `${noteName}${suffix}`,
|
label: `${noteName}${suffix}`,
|
||||||
rn: prog?.rn?.[i] ?? '',
|
rn: prog?.rn?.[i] ?? '',
|
||||||
|
// Fix (a) remap (jam-roulette.md §3.3.2): the RAW authored-play index this
|
||||||
|
// (possibly collapsed) station reads from. Raw matches have no sourceIndex
|
||||||
|
// → identity (i); collapsed matches carry the projection's map.
|
||||||
|
sourceIndex: prog?.sourceIndex?.[i] ?? i,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
if (instrument === 'guitar') {
|
if (instrument === 'guitar') {
|
||||||
@@ -277,7 +292,7 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
|
|||||||
const play = Array.isArray(plays) ? plays[0] : null
|
const play = Array.isArray(plays) ? plays[0] : null
|
||||||
const chords = play?.chords ?? []
|
const chords = play?.chords ?? []
|
||||||
for (let i = 0; i < stations.length; i++) {
|
for (let i = 0; i < stations.length; i++) {
|
||||||
stations[i].shape = chords[i]?.shape ?? null
|
stations[i].shape = chords[stations[i].sourceIndex ?? i]?.shape ?? null
|
||||||
}
|
}
|
||||||
} else if (instrument === 'piano') {
|
} else if (instrument === 'piano') {
|
||||||
// Authored piano pack first (L-24): the matched style's first piano play
|
// Authored piano pack first (L-24): the matched style's first piano play
|
||||||
@@ -285,7 +300,7 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
|
|||||||
const pianoPlays = kb[match.style]?.instruments?.piano?.plays?.[prog?.id]
|
const pianoPlays = kb[match.style]?.instruments?.piano?.plays?.[prog?.id]
|
||||||
const play = Array.isArray(pianoPlays) && pianoPlays.length ? pianoPlays[0] : null
|
const play = Array.isArray(pianoPlays) && pianoPlays.length ? pianoPlays[0] : null
|
||||||
const authored = play
|
const authored = play
|
||||||
? stations.map((st, i) => recipeVoicing(play.chords?.[i]?.recipe, st.rootPc, st.quality))
|
? stations.map((st, i) => recipeVoicing(play.chords?.[st.sourceIndex ?? i]?.recipe, st.rootPc, st.quality))
|
||||||
: null
|
: null
|
||||||
// Computed fallback — only built when needed (no pack, or a recipe that
|
// Computed fallback — only built when needed (no pack, or a recipe that
|
||||||
// failed to resolve). Identical to the pre-L-24 computed path.
|
// failed to resolve). Identical to the pre-L-24 computed path.
|
||||||
@@ -343,96 +358,140 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
|
|||||||
// re-sorting with the jam (D-31 §2.4).
|
// re-sorting with the jam (D-31 §2.4).
|
||||||
const contextStation = stationVoicings[canonicalPos >= 0 ? canonicalPos : 0] ?? null
|
const contextStation = stationVoicings[canonicalPos >= 0 ? canonicalPos : 0] ?? null
|
||||||
|
|
||||||
return (
|
// ── The rail (right column at xl / second block stacked): the suggested-
|
||||||
<section className="mb-3" aria-label="Jam Guide — follows the loop">
|
// voicings surface — GlanceRail, BassGuideRows, the heard-live gallery, or
|
||||||
|
// the honest idle line (one-screen.md §3, §4). ──
|
||||||
{/* ── Micro-header — a line, not a button (D-40 §1: zero chrome) ── */}
|
const railContent = match.matched ? (
|
||||||
<h3 className="mb-1.5 px-1 text-[10px] font-semibold uppercase tracking-widest text-gray-500">
|
instrument === 'bass' ? (
|
||||||
Jam Guide — {headerLabel}
|
/* Bass rows (D-40 §3): authored pattern cards when the matched style
|
||||||
{match.matched && keyInfo?.root ? ` · in ${keyInfo.root} ${keyInfo.mode}` : ''}
|
ships a bass pack (L-42), computed roots/fifths/approaches as the
|
||||||
</h3>
|
honest fallback otherwise. The licks strip hides either way
|
||||||
|
(guitar tab licks are noise to a bassist mid-jam). */
|
||||||
{match.matched ? (
|
<BassGuideRows
|
||||||
instrument === 'bass' ? (
|
stations={stationVoicings}
|
||||||
/* Bass rows (D-40 §3): authored pattern cards when the matched style
|
activeIndex={canonicalPos}
|
||||||
ships a bass pack (L-42), computed roots/fifths/approaches as the
|
keyMode={keyInfo?.mode}
|
||||||
honest fallback otherwise. The licks strip hides either way
|
plays={bassPlays}
|
||||||
(guitar tab licks are noise to a bassist mid-jam). */
|
/>
|
||||||
<BassGuideRows
|
) : (
|
||||||
stations={stationVoicings}
|
/* The voicing rail — ALL stations expanded as vertical rows; the
|
||||||
activeIndex={canonicalPos}
|
playhead only highlights (D-41, D-40 §4). */
|
||||||
keyMode={keyInfo?.mode}
|
<GlanceRail
|
||||||
plays={bassPlays}
|
stations={stationVoicings}
|
||||||
/>
|
activeIndex={canonicalPos}
|
||||||
) : (
|
focusedIndex={focusedStation}
|
||||||
<div className="flex flex-col gap-3">
|
onFocus={setFocusedStation}
|
||||||
{/* The voicing rail — ALL stations expanded as vertical rows; the
|
instrument={instrument}
|
||||||
playhead only highlights (D-41, D-40 §4). */}
|
keyRoot={keyRoot}
|
||||||
<GlanceRail
|
keyMode={keyInfo?.mode}
|
||||||
stations={stationVoicings}
|
/>
|
||||||
activeIndex={canonicalPos}
|
)
|
||||||
focusedIndex={focusedStation}
|
) : liveChord ? (
|
||||||
onFocus={setFocusedStation}
|
/* No loop matched, but chords are committing (D-31 §2.3): a single
|
||||||
instrument={instrument}
|
"heard live" gallery, re-aimed on every chord commit. Auto-follow
|
||||||
keyRoot={keyRoot}
|
only — nothing plays by itself. Bass: D-40 §3's prose forbids guitar/
|
||||||
keyMode={keyInfo?.mode}
|
piano galleries under BASS, so the live chord gets the same computed
|
||||||
/>
|
root/fifth line (no next chord → no approach) instead. */
|
||||||
<LicksStrip
|
instrument === 'bass' ? (
|
||||||
styleId={activeStyle}
|
<BassGuideRows
|
||||||
levels={ALL_LEVELS}
|
stations={[{ rootPc: liveChord.rootPc, quality: liveChord.type, label: currentChord, rn: '' }]}
|
||||||
instrument={instrument}
|
activeIndex={0}
|
||||||
context={contextStation}
|
keyMode={keyInfo?.mode}
|
||||||
/>
|
live
|
||||||
</div>
|
/>
|
||||||
)
|
) : (
|
||||||
) : liveChord ? (
|
<section
|
||||||
/* No loop matched, but chords are committing (D-31 §2.3): a single
|
className="rounded-2xl border border-border bg-panel p-3"
|
||||||
"heard live" gallery, re-aimed on every chord commit. Auto-follow
|
aria-label={`Heard live — every ${instrument} voicing of ${currentChord}`}
|
||||||
only — nothing plays by itself. Bass: D-40 §3's prose forbids guitar/
|
>
|
||||||
piano galleries under BASS, so the live chord gets the same computed
|
<h4 className="mb-1 text-[10px] font-semibold uppercase tracking-widest text-gray-500">
|
||||||
root/fifth line (no next chord → no approach) instead. */
|
Heard live · {currentChord} — every voicing
|
||||||
instrument === 'bass' ? (
|
</h4>
|
||||||
<BassGuideRows
|
<p className="mb-2 text-[11px] text-gray-500">
|
||||||
stations={[{ rootPc: liveChord.rootPc, quality: liveChord.type, label: currentChord, rn: '' }]}
|
|
||||||
activeIndex={0}
|
|
||||||
keyMode={keyInfo?.mode}
|
|
||||||
live
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<section
|
|
||||||
className="rounded-2xl border border-border bg-panel p-3"
|
|
||||||
aria-label={`Heard live — every ${instrument} voicing of ${currentChord}`}
|
|
||||||
>
|
|
||||||
<h4 className="mb-1 text-[10px] font-semibold uppercase tracking-widest text-gray-500">
|
|
||||||
Heard live · {currentChord} — every voicing
|
|
||||||
</h4>
|
|
||||||
<p className="mb-2 text-[11px] text-gray-500">
|
|
||||||
{detectedProgression?.length
|
|
||||||
? `Heard ${detectedProgression.join(' → ')} — no ${activeStyle} pattern matched yet; following the chord as it commits.`
|
|
||||||
: 'No repeating loop yet — following the chord as it commits.'}
|
|
||||||
</p>
|
|
||||||
<VoicingBrowser rootPc={liveChord.rootPc} quality={liveChord.type} show={instrument} />
|
|
||||||
</section>
|
|
||||||
{/* No station rn without a loop — context sort falls back to the
|
|
||||||
live chord's quality key (e.g. a "dom7" lick fits a live G7). */}
|
|
||||||
<LicksStrip
|
|
||||||
styleId={activeStyle}
|
|
||||||
levels={ALL_LEVELS}
|
|
||||||
instrument={instrument}
|
|
||||||
context={{ rn: '', quality: liveChord.type, label: currentChord }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
/* Nothing heard yet — one slim line (~40px, D-40 §1): the idle band
|
|
||||||
must not waste main-module space. */
|
|
||||||
<p className="rounded-xl border border-dashed border-border px-3 py-2 text-xs text-gray-500">
|
|
||||||
{detectedProgression?.length
|
{detectedProgression?.length
|
||||||
? `Heard ${detectedProgression.join(' → ')} — no ${activeStyle} pattern matched yet; voicings follow the next chord that commits.`
|
? `Heard ${detectedProgression.join(' → ')} — no ${activeStyle} pattern matched yet; following the chord as it commits.`
|
||||||
: 'Play a few bars — voicings and licks for your loop land here.'}
|
: 'No repeating loop yet — following the chord as it commits.'}
|
||||||
</p>
|
</p>
|
||||||
)}
|
<VoicingBrowser rootPc={liveChord.rootPc} quality={liveChord.type} show={instrument} dense />
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
/* Nothing heard yet — one slim line (~40px): the idle rail must not
|
||||||
|
waste dashboard space. */
|
||||||
|
<p className="rounded-xl border border-dashed border-border px-3 py-2 text-xs text-gray-500">
|
||||||
|
{detectedProgression?.length
|
||||||
|
? `Heard ${detectedProgression.join(' → ')} — no ${activeStyle} pattern matched yet; voicings follow the next chord that commits.`
|
||||||
|
: 'Play a few bars — voicings and licks for your loop land here.'}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── The licks strip (left column). Matched loops sort by the playhead
|
||||||
|
// station; heard-live falls back to the live chord's quality key (e.g. a
|
||||||
|
// "dom7" lick fits a live G7). Bass hides it (guitar tab licks are noise
|
||||||
|
// to a bassist mid-jam); LicksStrip also hides itself when empty. ──
|
||||||
|
const licksStrip = instrument !== 'bass' && match.matched ? (
|
||||||
|
<LicksStrip
|
||||||
|
styleId={activeStyle}
|
||||||
|
levels={ALL_LEVELS}
|
||||||
|
instrument={instrument}
|
||||||
|
context={contextStation}
|
||||||
|
/>
|
||||||
|
) : instrument !== 'bass' && liveChord ? (
|
||||||
|
<LicksStrip
|
||||||
|
styleId={activeStyle}
|
||||||
|
levels={ALL_LEVELS}
|
||||||
|
instrument={instrument}
|
||||||
|
context={{ rn: '', quality: liveChord.type, label: currentChord }}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
|
||||||
|
// ── The grid (one-screen.md §1, §6): left flex-1 / right 500px at xl;
|
||||||
|
// stacked below xl in jam-following order (mainView → rail → licks →
|
||||||
|
// related) via display:contents on the left wrapper + order classes — one
|
||||||
|
// mount per surface, no duplicates (§7). The rail wrapper is the design's
|
||||||
|
// ONE justified internal scroller: height-bounded in normal mode, h-full
|
||||||
|
// in jam view (`fill`), NOT sticky (§1). ──
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={
|
||||||
|
'mb-3 flex flex-col gap-3 xl:flex-row' +
|
||||||
|
(fill ? ' xl:mb-0 xl:flex-1 xl:min-h-0' : ' xl:items-start')
|
||||||
|
}
|
||||||
|
aria-label="Jam Guide — follows the loop"
|
||||||
|
>
|
||||||
|
{/* LEFT — instrument view · licks · related progressions */}
|
||||||
|
<div className={'contents xl:flex xl:flex-col xl:gap-3 xl:flex-1 xl:min-w-0' + (fill ? ' xl:min-h-0' : '')}>
|
||||||
|
{mainView != null && (
|
||||||
|
<div className={'order-1 xl:order-none min-w-0' + (fill ? ' xl:shrink-0' : '')}>
|
||||||
|
{mainView}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{licksStrip != null && (
|
||||||
|
<div className={'order-3 xl:order-none min-w-0' + (fill ? ' xl:shrink-0' : '')}>
|
||||||
|
{licksStrip}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{relatedSlot != null && (
|
||||||
|
<div className={'order-4 xl:order-none min-w-0' + (fill ? ' xl:flex-1 xl:min-h-0 xl:overflow-y-auto' : '')}>
|
||||||
|
{relatedSlot}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RIGHT — the suggested-voicings rail (the one contained scroller) */}
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'order-2 xl:order-none min-w-0 xl:w-[500px] xl:shrink-0 xl:overflow-y-auto ' +
|
||||||
|
(fill ? 'xl:h-full' : 'xl:max-h-[calc(100vh_-_1.5rem)]')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* Micro-header — a line, not a button (D-40 §1: zero chrome) */}
|
||||||
|
<h3 className="mb-1.5 px-1 text-[10px] font-semibold uppercase tracking-widest text-gray-500">
|
||||||
|
Suggested voicings — {headerLabel}
|
||||||
|
{match.matched && keyInfo?.root ? ` · in ${keyInfo.root} ${keyInfo.mode}` : ''}
|
||||||
|
</h3>
|
||||||
|
{railContent}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -527,10 +586,10 @@ function BassGuideRows({ stations = [], activeIndex = -1, keyMode, live = false,
|
|||||||
rootPc={st.rootPc}
|
rootPc={st.rootPc}
|
||||||
quality={st.quality}
|
quality={st.quality}
|
||||||
nextRootPc={stations[(i + 1) % n].rootPc}
|
nextRootPc={stations[(i + 1) % n].rootPc}
|
||||||
pattern={play?.chords?.[i]?.pattern}
|
pattern={play?.chords?.[st.sourceIndex ?? i]?.pattern}
|
||||||
playLabel={play?.label}
|
playLabel={play?.label}
|
||||||
feel={play?.feel}
|
feel={play?.feel}
|
||||||
note={play?.chords?.[i]?.note}
|
note={play?.chords?.[st.sourceIndex ?? i]?.note}
|
||||||
chordLabel={st.label}
|
chordLabel={st.label}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
+32
-12
@@ -37,7 +37,10 @@ function keyColor(isChordTone, isPenta, isScale, isBlack, mono = false) {
|
|||||||
: { fill: '#f5f5f5', text: '#6b7280' }
|
: { fill: '#f5f5f5', text: '#6b7280' }
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Piano({ keyInfo, currentChord, monoColor = false }) {
|
// `compact` (task L-50, one-screen.md §2): trimmed card chrome (p-3, legend
|
||||||
|
// merged onto the heading line) + a natural-width cap on the SVG (max-width =
|
||||||
|
// its viewBox width, so it never renders above scale 1.0).
|
||||||
|
export default function Piano({ keyInfo, currentChord, monoColor = false, compact = false }) {
|
||||||
const { root, mode } = keyInfo ?? {}
|
const { root, mode } = keyInfo ?? {}
|
||||||
if (!root) return null
|
if (!root) return null
|
||||||
|
|
||||||
@@ -51,15 +54,36 @@ export default function Piano({ keyInfo, currentChord, monoColor = false }) {
|
|||||||
const svgW = totalWhite * KEY_W + 2
|
const svgW = totalWhite * KEY_W + 2
|
||||||
const svgH = KEY_H + 20 // +20 for octave labels
|
const svgH = KEY_H + 20 // +20 for octave labels
|
||||||
|
|
||||||
|
const heading = (
|
||||||
|
<p className={`text-sm text-gray-500 uppercase tracking-widest ${compact ? '' : 'mb-4'}`}>
|
||||||
|
Piano — {root} {mode}
|
||||||
|
{currentChord && <span className="text-amber-400 ml-2">/ {currentChord}</span>}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
|
||||||
|
const legend = (
|
||||||
|
// Critic mechanical fix (L-50 gate): non-compact keeps HEAD's exact class
|
||||||
|
// string so the non-compact render stays byte-identical to the committed one.
|
||||||
|
<div className={compact ? 'flex items-center text-xs text-gray-500 flex-wrap gap-3' : 'mt-3 flex gap-5 text-xs text-gray-500'}>
|
||||||
|
<span><span className="text-accent">●</span> Chord tone</span>
|
||||||
|
<span><span style={{ color: monoColor ? '#c084fc' : '#f59e0b' }}>●</span> Pentatonic</span>
|
||||||
|
<span><span style={{ color: monoColor ? '#e9d5ff' : '#6b7280' }}>●</span> Scale</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-panel border border-border rounded-2xl p-6">
|
<div className={`bg-panel border border-border rounded-2xl ${compact ? 'p-3' : 'p-6'}`}>
|
||||||
<p className="text-sm text-gray-500 uppercase tracking-widest mb-4">
|
{compact ? (
|
||||||
Piano — {root} {mode}
|
<div className="mb-2 flex flex-wrap items-center justify-between gap-x-4 gap-y-1">
|
||||||
{currentChord && <span className="text-amber-400 ml-2">/ {currentChord}</span>}
|
{heading}
|
||||||
</p>
|
{legend}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
heading
|
||||||
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<svg viewBox={`0 0 ${svgW} ${svgH}`} width="100%" height="auto" style={{ display: 'block' }}>
|
<svg viewBox={`0 0 ${svgW} ${svgH}`} width="100%" height="auto" style={{ display: 'block', ...(compact ? { maxWidth: svgW } : null) }}>
|
||||||
|
|
||||||
{/* White keys */}
|
{/* White keys */}
|
||||||
{Array.from({ length: OCTAVES }, (_, oct) =>
|
{Array.from({ length: OCTAVES }, (_, oct) =>
|
||||||
@@ -132,11 +156,7 @@ export default function Piano({ keyInfo, currentChord, monoColor = false }) {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 flex gap-5 text-xs text-gray-500">
|
{!compact && legend}
|
||||||
<span><span className="text-accent">●</span> Chord tone</span>
|
|
||||||
<span><span style={{ color: monoColor ? '#c084fc' : '#f59e0b' }}>●</span> Pentatonic</span>
|
|
||||||
<span><span style={{ color: monoColor ? '#e9d5ff' : '#6b7280' }}>●</span> Scale</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,15 +2,29 @@ import { useRef, useEffect } from 'react'
|
|||||||
import { toRomanNumeral } from '../lib/theory'
|
import { toRomanNumeral } from '../lib/theory'
|
||||||
import { findLoopPosition } from '../lib/match'
|
import { findLoopPosition } from '../lib/match'
|
||||||
|
|
||||||
const HISTORY_SHOWN = 8
|
// ─── ProgressionBanner — the SLIM LOOP STRIP (task L-50, one-screen.md §1.2) ──
|
||||||
|
//
|
||||||
|
// One full-width status row: key chip · chord history (last 5, age-faded) ·
|
||||||
|
// ♻ loop chips. The old right-30% "Now Playing" text-6xl chord + its divider
|
||||||
|
// are GONE per the user directive — the enlarged current history chip and the
|
||||||
|
// accent-glowing active loop chip ARE the now-playing display. Chrome trimmed
|
||||||
|
// (p-4 → p-2, history pb-1 dropped) so the strip lands at ~76px.
|
||||||
|
const HISTORY_SHOWN = 5
|
||||||
|
|
||||||
export default function ProgressionBanner({ chordHistory, keyInfo, detectedProgression, currentChord, onChordClick }) {
|
export default function ProgressionBanner({ chordHistory, keyInfo, detectedProgression, seedInfo, onChordClick }) {
|
||||||
const { root, mode, confidence } = keyInfo ?? {}
|
const { root, mode, confidence } = keyInfo ?? {}
|
||||||
|
|
||||||
|
// Jam-roulette provenance (task L-60, jam-roulette.md §1.3): while a rolled
|
||||||
|
// loop is unconfirmed the loop row swaps its ♻ chrome for 🎲 + an amber chip
|
||||||
|
// naming the source; it flips back to normal the moment live detection
|
||||||
|
// confirms it (App clears seedInfo). The loop chips themselves are identical.
|
||||||
|
const seedBars = Array.isArray(seedInfo?.bars)
|
||||||
|
? seedInfo.bars.reduce((a, b) => a + (b ?? 0), 0)
|
||||||
|
: null
|
||||||
|
|
||||||
const visible = chordHistory.slice(-HISTORY_SHOWN)
|
const visible = chordHistory.slice(-HISTORY_SHOWN)
|
||||||
const current = visible[visible.length - 1]
|
const current = visible[visible.length - 1]
|
||||||
const loopPos = findLoopPosition(chordHistory, detectedProgression)
|
const loopPos = findLoopPosition(chordHistory, detectedProgression)
|
||||||
const currentRN = root && current ? toRomanNumeral(current, root, mode) : ''
|
|
||||||
|
|
||||||
const currentRef = useRef(null)
|
const currentRef = useRef(null)
|
||||||
const prevChord = useRef(null)
|
const prevChord = useRef(null)
|
||||||
@@ -25,71 +39,69 @@ export default function ProgressionBanner({ chordHistory, keyInfo, detectedProgr
|
|||||||
}, [current])
|
}, [current])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-panel border border-border rounded-2xl p-4 mb-3 flex gap-4">
|
<div className="bg-panel border border-border rounded-2xl p-2 mb-3 flex flex-wrap items-end gap-x-3 gap-y-1">
|
||||||
|
|
||||||
{/* ── Left: key + chord history + loop ── */}
|
{/* ── Key chip ── */}
|
||||||
<div className="w-full lg:w-[70%] min-w-0 flex flex-col gap-2">
|
<div className="shrink-0 flex items-baseline gap-1.5 self-center">
|
||||||
|
{root ? (
|
||||||
{/* Key + history on one row */}
|
<>
|
||||||
<div className="flex items-end gap-3">
|
<span className="text-2xl font-bold text-accent">{root}</span>
|
||||||
<div className="shrink-0 flex items-baseline gap-1.5">
|
<span className="text-gray-400 text-sm">{mode}</span>
|
||||||
{root ? (
|
{confidence && (
|
||||||
<>
|
<span className="text-xs text-gray-600">{Math.round(confidence * 100)}%</span>
|
||||||
<span className="text-2xl font-bold text-accent">{root}</span>
|
|
||||||
<span className="text-gray-400 text-sm">{mode}</span>
|
|
||||||
{confidence && (
|
|
||||||
<span className="text-xs text-gray-600">{Math.round(confidence * 100)}%</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span className="text-gray-600 text-sm">Detecting key…</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-600 text-sm">Detecting key…</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="w-px h-6 bg-border shrink-0" />
|
<div className="w-px h-6 bg-border shrink-0 self-center" />
|
||||||
|
|
||||||
{!chordHistory.length ? (
|
{/* ── Chord history (the enlarged current chip is the "now") ── */}
|
||||||
<p className="text-gray-600 text-sm">Start listening…</p>
|
{!chordHistory.length ? (
|
||||||
) : (
|
<p className="text-gray-600 text-sm self-center">Start listening…</p>
|
||||||
<div className="flex items-end gap-1 overflow-x-auto pb-1">
|
) : (
|
||||||
{visible.map((chord, i) => {
|
<div className="flex items-end gap-1 overflow-x-auto">
|
||||||
const isCurrent = i === visible.length - 1
|
{visible.map((chord, i) => {
|
||||||
const age = visible.length - 1 - i
|
const isCurrent = i === visible.length - 1
|
||||||
const opacity = Math.max(0.25, 1 - age * 0.09)
|
const age = visible.length - 1 - i
|
||||||
const rn = root ? toRomanNumeral(chord, root, mode) : ''
|
const opacity = Math.max(0.25, 1 - age * 0.09)
|
||||||
return (
|
const rn = root ? toRomanNumeral(chord, root, mode) : ''
|
||||||
<div
|
return (
|
||||||
key={i}
|
<div
|
||||||
ref={isCurrent ? currentRef : null}
|
key={i}
|
||||||
style={{ opacity }}
|
ref={isCurrent ? currentRef : null}
|
||||||
onClick={() => onChordClick?.(chord)}
|
style={{ opacity }}
|
||||||
className={`flex flex-col items-center shrink-0 px-2 py-1 rounded-xl transition-colors duration-200 cursor-pointer ${
|
onClick={() => onChordClick?.(chord)}
|
||||||
isCurrent
|
className={`flex flex-col items-center shrink-0 px-2 py-1 rounded-xl transition-colors duration-200 cursor-pointer ${
|
||||||
? 'bg-accent/10 border border-accent/40 ring-1 ring-accent/20 hover:bg-accent/20'
|
isCurrent
|
||||||
: 'border border-transparent hover:border-border hover:bg-panel'
|
? 'bg-accent/10 border border-accent/40 ring-1 ring-accent/20 hover:bg-accent/20'
|
||||||
}`}
|
: 'border border-transparent hover:border-border hover:bg-panel'
|
||||||
>
|
}`}
|
||||||
<span className={`font-black leading-none tracking-tight ${
|
>
|
||||||
isCurrent ? 'text-3xl text-accent' : 'text-xl text-gray-200'
|
<span className={`font-black leading-none tracking-tight ${
|
||||||
}`}>
|
isCurrent ? 'text-3xl text-accent' : 'text-xl text-gray-200'
|
||||||
{chord}
|
}`}>
|
||||||
</span>
|
{chord}
|
||||||
<span className={`text-xs font-semibold mt-0.5 ${
|
</span>
|
||||||
isCurrent ? 'text-amber-400' : 'text-gray-500'
|
<span className={`text-xs font-semibold mt-0.5 ${
|
||||||
}`}>
|
isCurrent ? 'text-amber-400' : 'text-gray-500'
|
||||||
{rn || '\u00A0'}
|
}`}>
|
||||||
</span>
|
{rn || ' '}
|
||||||
</div>
|
</span>
|
||||||
)
|
</div>
|
||||||
})}
|
)
|
||||||
</div>
|
})}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Loop */}
|
{/* ── Loop — on the same row (divider between); playhead chip = the now ── */}
|
||||||
{detectedProgression && (
|
{detectedProgression && (
|
||||||
<div className="flex items-center gap-1.5 flex-wrap">
|
<>
|
||||||
<span className="text-xs text-gray-500">♻</span>
|
<div className="w-px h-6 bg-border shrink-0 self-center" />
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap self-center">
|
||||||
|
<span className="text-xs text-gray-500">{seedInfo ? '🎲' : '♻'}</span>
|
||||||
{detectedProgression.map((chord, i) => {
|
{detectedProgression.map((chord, i) => {
|
||||||
const isActive = i === loopPos
|
const isActive = i === loopPos
|
||||||
const rn = root ? toRomanNumeral(chord, root, mode) : chord
|
const rn = root ? toRomanNumeral(chord, root, mode) : chord
|
||||||
@@ -110,31 +122,17 @@ export default function ProgressionBanner({ chordHistory, keyInfo, detectedProgr
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
<span className="text-gray-600 text-xs">→ loop</span>
|
{seedInfo ? (
|
||||||
|
<span className="text-amber-400 text-xs">
|
||||||
|
rolled · {seedInfo.styleLabel} · {seedInfo.name}
|
||||||
|
{seedBars != null ? ` · ${seedBars} bars` : ''} — play it!
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-600 text-xs">→ loop</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* ── Divider ── */}
|
|
||||||
<div className="hidden lg:block w-px bg-border shrink-0" />
|
|
||||||
|
|
||||||
{/* ── Right: big chord ── */}
|
|
||||||
<div className="hidden lg:flex w-[30%] flex-col items-center justify-center gap-1">
|
|
||||||
{current ? (
|
|
||||||
<button
|
|
||||||
onClick={() => onChordClick?.(current)}
|
|
||||||
className="flex flex-col items-center gap-1 px-4 py-2 rounded-xl hover:bg-accent/10 transition-colors group"
|
|
||||||
title="Click to see voicings"
|
|
||||||
>
|
|
||||||
<p className="text-xs text-gray-600 uppercase tracking-widest">Now Playing</p>
|
|
||||||
<div className="text-6xl font-black text-amber-400 leading-none group-hover:text-accent transition-colors">{current}</div>
|
|
||||||
<div className="text-sm text-gray-500">{currentRN}</div>
|
|
||||||
<p className="text-[10px] text-gray-700 group-hover:text-gray-500 transition-colors">tap for voicings</p>
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<p className="text-gray-600 text-xs text-center">Play a chord</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,321 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import kb from '../data/kb/index.js'
|
||||||
|
import { NOTES, CHORD_TYPES } from '../lib/theory'
|
||||||
|
import {
|
||||||
|
buildLoopIndex,
|
||||||
|
matchLoopToProgression,
|
||||||
|
loopToDegrees,
|
||||||
|
canonicalDegrees,
|
||||||
|
chordRootPC,
|
||||||
|
} from '../lib/match'
|
||||||
|
|
||||||
|
// ─── RelatedProgressions — loop-relative songbook relatives (task L-51) ───────
|
||||||
|
//
|
||||||
|
// 3–5 KB progressions genuinely related to the DETECTED loop, replacing the
|
||||||
|
// retired generic ProgressionSuggestions table. KB-sourced only, loop-relative
|
||||||
|
// by construction. Spec: docs/design/one-screen.md §5 (ranking + render + empty
|
||||||
|
// states) and §6 (component boundary: pure/prop-driven, computes its own match
|
||||||
|
// so it stays file-disjoint from D-51's rail work).
|
||||||
|
//
|
||||||
|
// Props:
|
||||||
|
// loop : string[] | null — the detected repeating progression (chord names)
|
||||||
|
// keyInfo : { root, mode, confidence } | null — effective key (realizes chips)
|
||||||
|
// onChordClick : fn(chordName) — opens ChordDetailModal (App's setSelectedChord)
|
||||||
|
//
|
||||||
|
// The ranking (rankRelatedProgressions) is exported for smoke coverage.
|
||||||
|
|
||||||
|
// Suffix → KB quality token, derived from CHORD_TYPES (the vocabulary
|
||||||
|
// authority — same inverse mapping as match.js's private suffixToQuality; all
|
||||||
|
// 14 suffixes are unique). Unknown suffixes stay unmapped → wildcard (§5:
|
||||||
|
// match on Δ alone).
|
||||||
|
const SUFFIX_TO_QUALITY = Object.fromEntries(
|
||||||
|
Object.entries(CHORD_TYPES).map(([quality, def]) => [def.suffix, quality])
|
||||||
|
)
|
||||||
|
|
||||||
|
// §5 scoring constants.
|
||||||
|
const SCORE_SAME_CHANGES = 100 // same canonical degree shape
|
||||||
|
const SCORE_SAME_STYLE = 40 // same style as the matched progression
|
||||||
|
const SCORE_PER_TRANSITION = 12 // per shared (Δ, quality→quality) transition…
|
||||||
|
const TRANSITION_CAP = 36 // …capped (= 3 distinct transitions)
|
||||||
|
const SCORE_JACCARD = 16 // × rebased degree-set Jaccard
|
||||||
|
export const RELATED_SCORE_FLOOR = 24
|
||||||
|
export const RELATED_MAX_ENTRIES = 5
|
||||||
|
|
||||||
|
// The module-level index (§6: "matchLoopToProgression over a module-level
|
||||||
|
// index is trivially cheap") — built once, shared across renders.
|
||||||
|
const DEFAULT_INDEX = buildLoopIndex(kb)
|
||||||
|
|
||||||
|
// ─── The MANDATORY collapse step (§5 step 2) ──────────────────────────────────
|
||||||
|
// The live detected loop is a COLLAPSED form (detection never commits the same
|
||||||
|
// chord twice in a row), while KB `degrees` are raw, bar-per-bar — blues-12bar
|
||||||
|
// is [0,0,0,0,5,5,0,0,7,5,0,7]. Compared raw, the +100 same-shape term would
|
||||||
|
// never fire for collapse-affected KB progressions. So before
|
||||||
|
// canonicalDegrees(p.degrees) AND before building T(p): collapse consecutive
|
||||||
|
// equal (degree, quality) pairs — including the wrap-around pair (last === first
|
||||||
|
// as a cycle). Each surviving unit keeps the rn of its first bar for the
|
||||||
|
// "shares {rn}→{rn}" annotation.
|
||||||
|
// (L-60 will land a shared collapsed-form index in match.js; this local
|
||||||
|
// collapse is deliberately component-scoped until then — one-screen.md §9.)
|
||||||
|
export function collapseChanges(progression) {
|
||||||
|
const degrees = Array.isArray(progression?.degrees) ? progression.degrees : []
|
||||||
|
const qualities = Array.isArray(progression?.qualities) ? progression.qualities : []
|
||||||
|
const rn = Array.isArray(progression?.rn) ? progression.rn : []
|
||||||
|
const out = []
|
||||||
|
for (let i = 0; i < degrees.length; i++) {
|
||||||
|
const q = qualities[i] ?? null
|
||||||
|
const prev = out[out.length - 1]
|
||||||
|
if (prev && prev.deg === degrees[i] && prev.quality === q) continue
|
||||||
|
out.push({ deg: degrees[i], quality: q, rn: rn[i] ?? '' })
|
||||||
|
}
|
||||||
|
if (out.length > 1) {
|
||||||
|
const first = out[0]
|
||||||
|
const last = out[out.length - 1]
|
||||||
|
if (first.deg === last.deg && first.quality === last.quality) out.pop()
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transition set over units [{deg, quality, rn?}], wrap-around included (§5):
|
||||||
|
// triples (Δ = (deg[i+1] − deg[i]) mod 12, q[i], q[i+1]); rn labels ride along
|
||||||
|
// for the annotation.
|
||||||
|
function transitionsOf(units) {
|
||||||
|
const n = units.length
|
||||||
|
if (n < 2) return []
|
||||||
|
const out = []
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const a = units[i]
|
||||||
|
const b = units[(i + 1) % n]
|
||||||
|
out.push({
|
||||||
|
d: (((b.deg - a.deg) % 12) + 12) % 12,
|
||||||
|
qa: a.quality,
|
||||||
|
qb: b.quality,
|
||||||
|
rnFrom: a.rn ?? '',
|
||||||
|
rnTo: b.rn ?? '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live-loop units: degree from loopToDegrees, quality from the chord-name
|
||||||
|
// suffix (unmappable → null = wildcard, matches on Δ alone).
|
||||||
|
function loopUnitsOf(loop, loopDeg) {
|
||||||
|
return loop.map((name, i) => {
|
||||||
|
const m = typeof name === 'string' ? name.match(/^[A-G][b#]?(.*)$/) : null
|
||||||
|
const suffix = m ? m[1] : null
|
||||||
|
return { deg: loopDeg[i], quality: suffix != null ? SUFFIX_TO_QUALITY[suffix] ?? null : null }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distinct triples of T(p) matched by some loop transition (wildcard-aware),
|
||||||
|
// in p's canonical order — shared[0] names the annotation.
|
||||||
|
function sharedTransitions(loopT, pT) {
|
||||||
|
const seen = new Set()
|
||||||
|
const shared = []
|
||||||
|
for (const t of pT) {
|
||||||
|
const key = `${t.d}|${t.qa}|${t.qb}`
|
||||||
|
if (seen.has(key)) continue
|
||||||
|
seen.add(key)
|
||||||
|
const hit = loopT.some(
|
||||||
|
l =>
|
||||||
|
l.d === t.d &&
|
||||||
|
(l.qa == null || t.qa == null || l.qa === t.qa) &&
|
||||||
|
(l.qb == null || t.qb == null || l.qb === t.qb)
|
||||||
|
)
|
||||||
|
if (hit) shared.push(t)
|
||||||
|
}
|
||||||
|
return shared
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebased degree-set Jaccard over the canonical rotations (both sides rebased
|
||||||
|
// so their canonical first element is 0 — rotation-invariant by construction).
|
||||||
|
function degreeSetJaccard(aCanon, bCanon) {
|
||||||
|
const a = new Set(aCanon.split(','))
|
||||||
|
const b = new Set(bCanon.split(','))
|
||||||
|
let inter = 0
|
||||||
|
for (const x of a) if (b.has(x)) inter++
|
||||||
|
const union = a.size + b.size - inter
|
||||||
|
return union ? inter / union : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* rankRelatedProgressions(loop, kbRegistry?) → { match, entries } | null
|
||||||
|
*
|
||||||
|
* The §5 ranking. Returns null when the loop yields no degrees (no loop /
|
||||||
|
* unparseable chord names) — the caller renders the idle state. `entries` is
|
||||||
|
* score-desc (stable by style, id), floored at RELATED_SCORE_FLOOR, at most
|
||||||
|
* RELATED_MAX_ENTRIES, never padded. Exported for smoke coverage.
|
||||||
|
*/
|
||||||
|
export function rankRelatedProgressions(loop, kbRegistry = kb) {
|
||||||
|
const loopDeg = loopToDegrees(loop)
|
||||||
|
if (!loopDeg || !loopDeg.length) return null
|
||||||
|
const loopCanon = canonicalDegrees(loopDeg)
|
||||||
|
const index = kbRegistry === kb ? DEFAULT_INDEX : buildLoopIndex(kbRegistry)
|
||||||
|
const match = matchLoopToProgression(loop, index)
|
||||||
|
const loopT = transitionsOf(loopUnitsOf(loop, loopDeg))
|
||||||
|
|
||||||
|
const entries = []
|
||||||
|
for (const style of Object.keys(kbRegistry)) {
|
||||||
|
const styleLabel = kbRegistry[style]?.meta?.label ?? style
|
||||||
|
const progs = kbRegistry[style]?.progressions
|
||||||
|
if (!Array.isArray(progs)) continue
|
||||||
|
for (const prog of progs) {
|
||||||
|
if (!Array.isArray(prog.degrees) || !prog.degrees.length) continue
|
||||||
|
if (match.matched && prog.id === match.id) continue // §5: every p ≠ the matched one
|
||||||
|
const collapsed = collapseChanges(prog)
|
||||||
|
if (!collapsed.length) continue
|
||||||
|
const pCanon = canonicalDegrees(collapsed.map(u => u.deg))
|
||||||
|
const shared = sharedTransitions(loopT, transitionsOf(collapsed))
|
||||||
|
const sameChanges = pCanon === loopCanon
|
||||||
|
const sameStyle = match.matched && style === match.style
|
||||||
|
|
||||||
|
let score = 0
|
||||||
|
if (sameChanges) score += SCORE_SAME_CHANGES
|
||||||
|
if (sameStyle) score += SCORE_SAME_STYLE
|
||||||
|
score += Math.min(shared.length * SCORE_PER_TRANSITION, TRANSITION_CAP)
|
||||||
|
score += degreeSetJaccard(loopCanon, pCanon) * SCORE_JACCARD
|
||||||
|
score -= Math.abs(loop.length - collapsed.length)
|
||||||
|
if (score < RELATED_SCORE_FLOOR) continue // never pad with junk
|
||||||
|
|
||||||
|
// §5 annotation: why this entry is here (survivors always have one —
|
||||||
|
// below the floor nothing scores on Jaccard − length alone).
|
||||||
|
const annotation = sameChanges
|
||||||
|
? 'same changes'
|
||||||
|
: shared.length
|
||||||
|
? `shares ${shared[0].rnFrom || '?'}→${shared[0].rnTo || '?'}`
|
||||||
|
: 'same style'
|
||||||
|
|
||||||
|
entries.push({
|
||||||
|
style,
|
||||||
|
styleLabel,
|
||||||
|
id: prog.id,
|
||||||
|
name: prog.name,
|
||||||
|
level: prog.level === 'intermediate' ? 'intermediate' : 'foundation', // untagged counts foundation
|
||||||
|
score,
|
||||||
|
annotation,
|
||||||
|
progression: prog,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.sort(
|
||||||
|
(a, b) => b.score - a.score || a.style.localeCompare(b.style) || a.id.localeCompare(b.id)
|
||||||
|
)
|
||||||
|
return { match, entries: entries.slice(0, RELATED_MAX_ENTRIES) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Presentational bits ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Level chip — ExplorePanel's LevelBadge language (amber = the existing
|
||||||
|
// secondary-tone token; foundation stays quiet; untagged counts foundation).
|
||||||
|
function LevelBadge({ level }) {
|
||||||
|
if (level === 'intermediate') {
|
||||||
|
return (
|
||||||
|
<span className="shrink-0 text-[9px] uppercase tracking-wide font-semibold text-amber border border-amber/40 rounded px-1.5 py-px">
|
||||||
|
intermediate
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="shrink-0 text-[9px] uppercase tracking-wide font-semibold text-gray-400 border border-border rounded px-1.5 py-px">
|
||||||
|
foundation
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The chord chain realized in the current key — the raw (bar-per-bar) KB form,
|
||||||
|
// truncated to the first 8 + "…" (§5); each chip taps through to
|
||||||
|
// ChordDetailModal via onChordClick (the banner-chip pattern, smaller).
|
||||||
|
const CHAIN_SHOWN = 8
|
||||||
|
|
||||||
|
function ChordChain({ progression, keyRoot, onChordClick }) {
|
||||||
|
const degrees = progression.degrees ?? []
|
||||||
|
const qualities = progression.qualities ?? []
|
||||||
|
const rn = progression.rn ?? []
|
||||||
|
const shown = degrees.slice(0, CHAIN_SHOWN)
|
||||||
|
return (
|
||||||
|
<div className="mt-1 flex items-end gap-1 flex-wrap">
|
||||||
|
{shown.map((deg, i) => {
|
||||||
|
const rootPc = (((keyRoot + deg) % 12) + 12) % 12
|
||||||
|
const label = `${NOTES[rootPc]}${CHORD_TYPES[qualities[i]]?.suffix ?? ''}`
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChordClick?.(label)}
|
||||||
|
aria-label={`${label} — open chord detail`}
|
||||||
|
className="flex flex-col items-center px-1.5 py-0.5 rounded-lg border border-border bg-border hover:border-gray-500 transition-all cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||||
|
>
|
||||||
|
<span className="text-xs font-bold leading-none text-gray-300">{label}</span>
|
||||||
|
<span className="text-[10px] text-gray-500">{rn[i] ?? ''}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{degrees.length > CHAIN_SHOWN && <span className="self-center text-xs text-gray-500">…</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RelatedProgressions({ loop, keyInfo, onChordClick }) {
|
||||||
|
const loopKey = Array.isArray(loop) ? loop.join(',') : ''
|
||||||
|
const ranked = useMemo(
|
||||||
|
() => (loopKey ? rankRelatedProgressions(loop) : null),
|
||||||
|
[loopKey] // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
)
|
||||||
|
|
||||||
|
// keyInfo.root is a note NAME; chips want a pitch class. Default to C (0)
|
||||||
|
// until a key is known, same fallback JamGuide's stations use.
|
||||||
|
const keyRoot = useMemo(() => {
|
||||||
|
const pc = chordRootPC(keyInfo?.root)
|
||||||
|
return pc >= 0 ? pc : 0
|
||||||
|
}, [keyInfo?.root])
|
||||||
|
|
||||||
|
// Idle — no loop (or unparseable chord names, which rank as no loop).
|
||||||
|
if (!ranked) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="rounded-2xl border border-dashed border-border p-3"
|
||||||
|
aria-label="Related progressions"
|
||||||
|
>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Loop a progression — related changes from the songbook land here.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="rounded-2xl border border-border bg-panel p-3"
|
||||||
|
aria-label="Related progressions"
|
||||||
|
>
|
||||||
|
<h4 className="mb-2 text-[10px] font-semibold uppercase tracking-widest text-gray-500">
|
||||||
|
Related progressions · from the songbook
|
||||||
|
{keyInfo?.root ? ` · in ${keyInfo.root}` : ''}
|
||||||
|
</h4>
|
||||||
|
{ranked.entries.length === 0 ? (
|
||||||
|
// Loop, but nothing clears the floor — honest, never padded (§5).
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Nothing in the songbook genuinely relates to this loop yet.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-2.5">
|
||||||
|
{ranked.entries.map(entry => (
|
||||||
|
<li key={`${entry.style}-${entry.id}`} className="min-w-0">
|
||||||
|
<div className="flex flex-wrap items-baseline gap-x-1.5 gap-y-0.5">
|
||||||
|
<span className="text-sm font-semibold text-gray-100">{entry.name}</span>
|
||||||
|
<span className="text-xs text-gray-500">{entry.styleLabel}</span>
|
||||||
|
<LevelBadge level={entry.level} />
|
||||||
|
<span className="text-[10px] text-gray-500">{entry.annotation}</span>
|
||||||
|
</div>
|
||||||
|
<ChordChain
|
||||||
|
progression={entry.progression}
|
||||||
|
keyRoot={keyRoot}
|
||||||
|
onChordClick={onChordClick}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -46,8 +46,13 @@
|
|||||||
// section chrome (border/panel background/heading — the row header
|
// section chrome (border/panel background/heading — the row header
|
||||||
// already names the chord) and suppresses the per-mount
|
// already names the chord) and suppresses the per-mount
|
||||||
// mic-feedback microcopy (the rail shows it ONCE for the whole
|
// mic-feedback microcopy (the rail shows it ONCE for the whole
|
||||||
// rail, D-31 §2.5). Every pre-existing mount renders identically
|
// rail, D-31 §2.5). D-51 (one-screen.md §4) additionally tightens
|
||||||
// with no prop.
|
// the DENSE cell spacing for the 500px right column — cell p-2 →
|
||||||
|
// p-1.5 and gallery gap-2 → gap-1.5 — which hardens the column's
|
||||||
|
// two-octave + one-octave piano pair fit (279.6 + 6 + 156.4 = 442
|
||||||
|
// ≤ 451px row interior even with a classic Windows scrollbar in
|
||||||
|
// the rail's scroller; it was 452 vs 456, a ~4px squeak, before).
|
||||||
|
// Every pre-existing mount renders identically with no prop.
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef } from 'react'
|
import { useEffect, useMemo, useRef } from 'react'
|
||||||
import ChordDiagram from './ChordDiagram'
|
import ChordDiagram from './ChordDiagram'
|
||||||
@@ -126,9 +131,11 @@ function SectionHeading({ children }) {
|
|||||||
// One gallery cell: label on top, diagram thumb, its own ▶ underneath.
|
// One gallery cell: label on top, diagram thumb, its own ▶ underneath.
|
||||||
// bg-surface inside the bg-panel section gives the cells a quiet inlay border;
|
// bg-surface inside the bg-panel section gives the cells a quiet inlay border;
|
||||||
// label is gray-300 on surface (AA comfortable at 11px semibold).
|
// label is gray-300 on surface (AA comfortable at 11px semibold).
|
||||||
function GalleryCell({ label, playLabel, onPlay, children }) {
|
// `dense` (D-51): p-1.5 instead of p-2 — 4px off each cell's box width, part of
|
||||||
|
// the right-column margin-hardening. Non-dense output is byte-identical.
|
||||||
|
function GalleryCell({ label, playLabel, onPlay, dense = false, children }) {
|
||||||
return (
|
return (
|
||||||
<figure className="flex min-w-0 flex-col items-center gap-1.5 rounded-md border border-border bg-surface p-2">
|
<figure className={`flex min-w-0 flex-col items-center gap-1.5 rounded-md border border-border bg-surface ${dense ? 'p-1.5' : 'p-2'}`}>
|
||||||
<figcaption
|
<figcaption
|
||||||
className="max-w-full break-words text-center text-[11px] font-medium leading-tight text-gray-300"
|
className="max-w-full break-words text-center text-[11px] font-medium leading-tight text-gray-300"
|
||||||
title={label}
|
title={label}
|
||||||
@@ -228,7 +235,7 @@ export default function VoicingBrowser({ rootPc = 0, quality = 'maj', show = 'bo
|
|||||||
<div
|
<div
|
||||||
role="group"
|
role="group"
|
||||||
aria-label={`${name} guitar shapes — every shape shown, each playable`}
|
aria-label={`${name} guitar shapes — every shape shown, each playable`}
|
||||||
className="flex flex-wrap items-stretch gap-2"
|
className={`flex flex-wrap items-stretch ${dense ? 'gap-1.5' : 'gap-2'}`}
|
||||||
>
|
>
|
||||||
{guitarShapes.map((shape, i) => (
|
{guitarShapes.map((shape, i) => (
|
||||||
<GalleryCell
|
<GalleryCell
|
||||||
@@ -236,6 +243,7 @@ export default function VoicingBrowser({ rootPc = 0, quality = 'maj', show = 'bo
|
|||||||
label={shape.label}
|
label={shape.label}
|
||||||
playLabel={`Play ${name} — ${shape.label} guitar voicing`}
|
playLabel={`Play ${name} — ${shape.label} guitar voicing`}
|
||||||
onPlay={() => playGuitar(shape)}
|
onPlay={() => playGuitar(shape)}
|
||||||
|
dense={dense}
|
||||||
>
|
>
|
||||||
<ChordDiagram shape={shape} rootPc={pc} size="thumb" />
|
<ChordDiagram shape={shape} rootPc={pc} size="thumb" />
|
||||||
</GalleryCell>
|
</GalleryCell>
|
||||||
@@ -260,7 +268,7 @@ export default function VoicingBrowser({ rootPc = 0, quality = 'maj', show = 'bo
|
|||||||
<div
|
<div
|
||||||
role="group"
|
role="group"
|
||||||
aria-label={`${name} piano voicings — every style shown, each playable`}
|
aria-label={`${name} piano voicings — every style shown, each playable`}
|
||||||
className="flex flex-wrap items-stretch gap-2"
|
className={`flex flex-wrap items-stretch ${dense ? 'gap-1.5' : 'gap-2'}`}
|
||||||
>
|
>
|
||||||
{/* pianoVoicing() output carries no rootPc, and without it VoicingPiano
|
{/* pianoVoicing() output carries no rootPc, and without it VoicingPiano
|
||||||
falls back to the LOWEST voice for its "R" badge — wrong for rootless
|
falls back to the LOWEST voice for its "R" badge — wrong for rootless
|
||||||
@@ -271,6 +279,7 @@ export default function VoicingBrowser({ rootPc = 0, quality = 'maj', show = 'bo
|
|||||||
label={voicing.label}
|
label={voicing.label}
|
||||||
playLabel={`Play ${name} — ${voicing.label} piano voicing`}
|
playLabel={`Play ${name} — ${voicing.label} piano voicing`}
|
||||||
onPlay={() => playPiano(voicing)}
|
onPlay={() => playPiano(voicing)}
|
||||||
|
dense={dense}
|
||||||
>
|
>
|
||||||
<MiniPiano voicing={{ ...voicing, rootPc: pc }} size="thumb" />
|
<MiniPiano voicing={{ ...voicing, rootPc: pc }} size="thumb" />
|
||||||
</GalleryCell>
|
</GalleryCell>
|
||||||
|
|||||||
+192
-7
@@ -11,7 +11,7 @@
|
|||||||
// This file is matching/position logic only. All music-theory primitives
|
// This file is matching/position logic only. All music-theory primitives
|
||||||
// (note names, roman numerals) come read-only from theory.js.
|
// (note names, roman numerals) come read-only from theory.js.
|
||||||
|
|
||||||
import { NOTES, NOTES_FLAT, toRomanNumeral } from './theory'
|
import { NOTES, NOTES_FLAT, CHORD_TYPES, toRomanNumeral, canonicalize, detectRepeatingProgression } from './theory'
|
||||||
|
|
||||||
// ─── Chord-name parsing (local — theory.js does not export a pitch-class helper) ─
|
// ─── Chord-name parsing (local — theory.js does not export a pitch-class helper) ─
|
||||||
|
|
||||||
@@ -91,7 +91,9 @@ export function findLoopPosition(chordHistory, loop) {
|
|||||||
// for each rotation, re-base so the first element is 0, then pick the
|
// for each rotation, re-base so the first element is 0, then pick the
|
||||||
// lexicographically smallest resulting sequence. Same loop → same string,
|
// lexicographically smallest resulting sequence. Same loop → same string,
|
||||||
// regardless of which chord it starts on.
|
// regardless of which chord it starts on.
|
||||||
function canonicalDegrees(degrees) {
|
// Exported additively for RelatedProgressions' ranking (task L-51,
|
||||||
|
// one-screen.md §5) — no behaviour change.
|
||||||
|
export function canonicalDegrees(degrees) {
|
||||||
const n = degrees.length
|
const n = degrees.length
|
||||||
if (n === 0) return ''
|
if (n === 0) return ''
|
||||||
let best = null
|
let best = null
|
||||||
@@ -110,7 +112,9 @@ function canonicalDegrees(degrees) {
|
|||||||
|
|
||||||
// Converts a loop of chord-name strings into semitone offsets from the loop's
|
// Converts a loop of chord-name strings into semitone offsets from the loop's
|
||||||
// own first chord. Returns null if any chord root is unparseable.
|
// own first chord. Returns null if any chord root is unparseable.
|
||||||
function loopToDegrees(loop) {
|
// Exported additively for RelatedProgressions' ranking (task L-51,
|
||||||
|
// one-screen.md §5) — no behaviour change.
|
||||||
|
export function loopToDegrees(loop) {
|
||||||
if (!loop?.length) return null
|
if (!loop?.length) return null
|
||||||
const pcs = loop.map(chordRootPC)
|
const pcs = loop.map(chordRootPC)
|
||||||
if (pcs.some(pc => pc < 0)) return null
|
if (pcs.some(pc => pc < 0)) return null
|
||||||
@@ -118,25 +122,108 @@ function loopToDegrees(loop) {
|
|||||||
return pcs.map(pc => ((pc - tonic) % 12 + 12) % 12)
|
return pcs.map(pc => ((pc - tonic) % 12 + 12) % 12)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Suffix of a KB quality token (the display suffix, '' fallback for out-of-vocab
|
||||||
|
// tokens). Name-collapse compares SUFFIXES, not raw quality tokens: two
|
||||||
|
// out-of-vocab qualities both fall back to '' and realize to equal names, so a
|
||||||
|
// token comparison would under-collapse (jam-roulette.md §3.3.2).
|
||||||
|
function suffixOfQuality(quality) {
|
||||||
|
return CHORD_TYPES[quality]?.suffix ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* collapseProjection(progression) → { degrees, qualities, rn, bars, sourceIndex }
|
||||||
|
*
|
||||||
|
* The key-free collapsed shape of a KB progression (fix (a), jam-roulette.md
|
||||||
|
* §3.3.2 / §3.3.3): dedupe adjacent stations whose (degree, suffix) pairs are
|
||||||
|
* equal, then wrap-dedupe (if the last pair equals the first, drop the last).
|
||||||
|
* The projection mirrors detection's collapsed commit stream:
|
||||||
|
* - rn : the first-of-run roman numeral
|
||||||
|
* - bars : summed per run
|
||||||
|
* - sourceIndex[i] : the first RAW station index of collapsed station i — the
|
||||||
|
* remap every authored-play lookup must go through so a collapsed
|
||||||
|
* match reads the right raw play entry (JamGuide §3.3.2).
|
||||||
|
* Name-collapse is provably key-independent (§3.3.3), so this is computed once
|
||||||
|
* with no key in hand.
|
||||||
|
*/
|
||||||
|
function collapseProjection(progression) {
|
||||||
|
const degrees = Array.isArray(progression?.degrees) ? progression.degrees : []
|
||||||
|
const qualities = Array.isArray(progression?.qualities) ? progression.qualities : []
|
||||||
|
const rn = Array.isArray(progression?.rn) ? progression.rn : []
|
||||||
|
const bars = Array.isArray(progression?.bars) ? progression.bars : []
|
||||||
|
const outDeg = [], outQual = [], outRn = [], outBars = [], sourceIndex = []
|
||||||
|
for (let i = 0; i < degrees.length; i++) {
|
||||||
|
const last = outDeg.length - 1
|
||||||
|
if (last >= 0 && outDeg[last] === degrees[i] && suffixOfQuality(outQual[last]) === suffixOfQuality(qualities[i])) {
|
||||||
|
outBars[last] += bars[i] ?? 0
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
outDeg.push(degrees[i])
|
||||||
|
outQual.push(qualities[i])
|
||||||
|
outRn.push(rn[i] ?? '')
|
||||||
|
outBars.push(bars[i] ?? 0)
|
||||||
|
sourceIndex.push(i)
|
||||||
|
}
|
||||||
|
// Wrap-dedupe — only the boundary pair can merge post-collapse (§3.3.2).
|
||||||
|
if (outDeg.length > 1
|
||||||
|
&& outDeg[outDeg.length - 1] === outDeg[0]
|
||||||
|
&& suffixOfQuality(outQual[outQual.length - 1]) === suffixOfQuality(outQual[0])) {
|
||||||
|
outBars[0] += outBars[outDeg.length - 1]
|
||||||
|
outDeg.pop(); outQual.pop(); outRn.pop(); outBars.pop(); sourceIndex.pop()
|
||||||
|
}
|
||||||
|
return { degrees: outDeg, qualities: outQual, rn: outRn, bars: outBars, sourceIndex }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Precompute a lookup table from a KB registry (kb/index.js default export).
|
* Precompute a lookup table from a KB registry (kb/index.js default export).
|
||||||
* Returns { byCanonical: Map<canonicalDegrees, entry[]> } where each entry is
|
* Returns { byCanonical: Map<canonicalDegrees, entry[]> } where each entry is
|
||||||
* { style, id, progression }. Build once, reuse across matches.
|
* { style, id, progression }. Build once, reuse across matches.
|
||||||
|
*
|
||||||
|
* Fix (a) (jam-roulette.md §3.3.2): each progression is indexed by its RAW
|
||||||
|
* degrees AND — when the collapsed shape differs (11/56 today) — by its
|
||||||
|
* collapsed form, whose `progression` is the collapsed PROJECTION (degrees /
|
||||||
|
* qualities / rn / bars / sourceIndex). Collapsed entries are appended AFTER all
|
||||||
|
* raw entries so matchLoopToProgression's strict-`>` disambiguation keeps every
|
||||||
|
* previously-matching input's winner on ties (behaviour-preserving except the
|
||||||
|
* one enumerated strict win, country-145 → its own collapsed form). This makes
|
||||||
|
* a detected COLLAPSED loop (the live commit stream dedupes back-to-back chords)
|
||||||
|
* match its progression — repairing a pre-existing live-detection miss for real
|
||||||
|
* 12-bar / 8-bar streams and enabling the roulette seed to populate the guide.
|
||||||
*/
|
*/
|
||||||
export function buildLoopIndex(kb) {
|
export function buildLoopIndex(kb) {
|
||||||
const byCanonical = new Map()
|
const byCanonical = new Map()
|
||||||
if (!kb) return { byCanonical }
|
if (!kb) return { byCanonical }
|
||||||
|
const add = (canon, entry) => {
|
||||||
|
if (!byCanonical.has(canon)) byCanonical.set(canon, [])
|
||||||
|
byCanonical.get(canon).push(entry)
|
||||||
|
}
|
||||||
|
const collapsedPending = []
|
||||||
for (const style of Object.keys(kb)) {
|
for (const style of Object.keys(kb)) {
|
||||||
const progs = kb[style]?.progressions
|
const progs = kb[style]?.progressions
|
||||||
if (!Array.isArray(progs)) continue
|
if (!Array.isArray(progs)) continue
|
||||||
for (const progression of progs) {
|
for (const progression of progs) {
|
||||||
if (!Array.isArray(progression.degrees) || !progression.degrees.length) continue
|
if (!Array.isArray(progression.degrees) || !progression.degrees.length) continue
|
||||||
const canon = canonicalDegrees(progression.degrees)
|
add(canonicalDegrees(progression.degrees), { style, id: progression.id, progression })
|
||||||
const entry = { style, id: progression.id, progression }
|
// Collapsed-form entry — only when the collapsed shape differs (a run
|
||||||
if (!byCanonical.has(canon)) byCanonical.set(canon, [])
|
// collapse strictly shortens length, so a length change ⇔ a real collapse).
|
||||||
byCanonical.get(canon).push(entry)
|
const proj = collapseProjection(progression)
|
||||||
|
if (proj.degrees.length && proj.degrees.length !== progression.degrees.length) {
|
||||||
|
const collapsedProg = {
|
||||||
|
...progression,
|
||||||
|
degrees: proj.degrees,
|
||||||
|
qualities: proj.qualities,
|
||||||
|
rn: proj.rn,
|
||||||
|
bars: proj.bars,
|
||||||
|
sourceIndex: proj.sourceIndex,
|
||||||
|
}
|
||||||
|
collapsedPending.push({
|
||||||
|
canon: canonicalDegrees(proj.degrees),
|
||||||
|
entry: { style, id: progression.id, progression: collapsedProg },
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Append collapsed entries after ALL raw entries (the tie-preservation invariant).
|
||||||
|
for (const { canon, entry } of collapsedPending) add(canon, entry)
|
||||||
return { byCanonical }
|
return { byCanonical }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,6 +282,10 @@ export function matchLoopToProgression(loop, kbOrIndex) {
|
|||||||
matched: true,
|
matched: true,
|
||||||
id: best.id,
|
id: best.id,
|
||||||
style: best.style,
|
style: best.style,
|
||||||
|
// For a collapsed-form hit best.progression.degrees IS the collapsed shape
|
||||||
|
// (equal length to the loop by construction, fix (a)), so rotation is
|
||||||
|
// computed against the collapsed degrees — the length-mismatch guard never
|
||||||
|
// silently returns 0 for a genuine collapsed match (jam-roulette.md §3.3.2).
|
||||||
rotation: rotationToCanonicalOrder(degrees, best.progression.degrees),
|
rotation: rotationToCanonicalOrder(degrees, best.progression.degrees),
|
||||||
progression: best.progression,
|
progression: best.progression,
|
||||||
}
|
}
|
||||||
@@ -220,6 +311,100 @@ function rotationToCanonicalOrder(loopDegrees, kbDegrees) {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Jam Roulette — seed realization + the seedable pool (task L-60) ──────────
|
||||||
|
//
|
||||||
|
// jam-roulette.md §3.2 / §2.2. The seed writes the exact loop detection would
|
||||||
|
// commit when the band plays this progression in the rolled key, so the L-31
|
||||||
|
// commit layer treats it like any committed loop.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* seedableLoop(progression, keyRootPc) → string[] | null
|
||||||
|
*
|
||||||
|
* Realize → collapse (+ wrap-dedupe) → canonicalize (§3.2):
|
||||||
|
* 1. Realize each station in the rolled key with SHARP spellings (§2.1 — only
|
||||||
|
* sharp names string-match live detection's noteName path).
|
||||||
|
* 2. Collapse consecutive duplicate NAMES, then drop the last if it equals the
|
||||||
|
* first (the cyclic wrap — the loop's tail flows into its head live).
|
||||||
|
* 3. Canonicalize the rotation with theory.js's own rule, so the seeded string
|
||||||
|
* equals detectRepeatingProgression's canonical output exactly (the L-31
|
||||||
|
* agreement branch compares join(',') strings).
|
||||||
|
* Returns null when the collapsed loop has < 2 names (unrepresentable as a
|
||||||
|
* detected loop — detectRepeatingProgression's min pattern length is 2).
|
||||||
|
*/
|
||||||
|
export function seedableLoop(progression, keyRootPc) {
|
||||||
|
const degrees = Array.isArray(progression?.degrees) ? progression.degrees : []
|
||||||
|
const qualities = Array.isArray(progression?.qualities) ? progression.qualities : []
|
||||||
|
if (!degrees.length) return null
|
||||||
|
const names = degrees.map((deg, i) => {
|
||||||
|
const rootPc = (((keyRootPc + deg) % 12) + 12) % 12
|
||||||
|
return NOTES[rootPc] + suffixOfQuality(qualities[i])
|
||||||
|
})
|
||||||
|
const collapsed = names.filter((n, i) => i === 0 || n !== names[i - 1])
|
||||||
|
if (collapsed.length > 1 && collapsed[0] === collapsed[collapsed.length - 1]) collapsed.pop()
|
||||||
|
if (collapsed.length < 2) return null
|
||||||
|
return canonicalize(collapsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* roundTripPasses(canonicalForm) → boolean (jam-roulette.md §2.2)
|
||||||
|
*
|
||||||
|
* The pool gate: a progression is confirmable iff, with a 32-commit window
|
||||||
|
* filled with repetitions of its seeded canonical form (steady state) and
|
||||||
|
* truncated at EVERY partial-cycle offset (0…len−1), detectRepeatingProgression
|
||||||
|
* returns exactly that form at ALL offsets. Steady-state-plus-all-offsets is the
|
||||||
|
* honest protocol — a jam is sampled mid-cycle, not at cycle boundaries, and a
|
||||||
|
* naive "2 clean cycles" feed gets both failure modes wrong (§2.2). Hard length
|
||||||
|
* bounds 2–8 (the detector only sweeps those lengths) are part of the gate.
|
||||||
|
* Key-independent (§3.3.3): the detector consumes only the name stream's
|
||||||
|
* equality structure, so one sweep in any key (the pool builds in C) covers all.
|
||||||
|
*/
|
||||||
|
export function roundTripPasses(canonicalForm) {
|
||||||
|
if (!Array.isArray(canonicalForm)) return false
|
||||||
|
const len = canonicalForm.length
|
||||||
|
if (len < 2 || len > 8) return false
|
||||||
|
const WINDOW = 32
|
||||||
|
const target = canonicalForm.join(',')
|
||||||
|
for (let offset = 0; offset < len; offset++) {
|
||||||
|
const history = []
|
||||||
|
for (let k = 0; k < WINDOW; k++) history.push(canonicalForm[(offset + k) % len])
|
||||||
|
const detected = detectRepeatingProgression(history)
|
||||||
|
if (!detected || detected.join(',') !== target) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lazy module-level memo (§2.2): one sweep on first roulette open, reused for
|
||||||
|
// every roll. Key-independent, so the pool is computed once in C.
|
||||||
|
let _roulettePoolMemo = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* buildRoulettePool(kb) → { byStyle: Map<style, member[]> }
|
||||||
|
* member = { style, id, progression, collapsedLen }
|
||||||
|
*
|
||||||
|
* The roulette pool (§2.2): per style, the progressions whose seedable canonical
|
||||||
|
* form passes the round-trip invariant AND lands in the 2–8 length bounds. An
|
||||||
|
* 11th style or a new progression joins automatically. Randomization (weighting,
|
||||||
|
* no-repeat memory, key) lives in the caller (App.rollJam) — this is the pure,
|
||||||
|
* audio-free eligibility set.
|
||||||
|
*/
|
||||||
|
export function buildRoulettePool(kb) {
|
||||||
|
if (_roulettePoolMemo) return _roulettePoolMemo
|
||||||
|
const byStyle = new Map()
|
||||||
|
for (const style of Object.keys(kb ?? {})) {
|
||||||
|
const progs = kb[style]?.progressions
|
||||||
|
if (!Array.isArray(progs)) { byStyle.set(style, []); continue }
|
||||||
|
const eligible = []
|
||||||
|
for (const progression of progs) {
|
||||||
|
const form = seedableLoop(progression, 0) // key C — key-independent (§3.3.3)
|
||||||
|
if (!form || !roundTripPasses(form)) continue
|
||||||
|
eligible.push({ style, id: progression.id, progression, collapsedLen: form.length })
|
||||||
|
}
|
||||||
|
byStyle.set(style, eligible)
|
||||||
|
}
|
||||||
|
_roulettePoolMemo = { byStyle }
|
||||||
|
return _roulettePoolMemo
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Roman-numeral helpers (re-exported for callers that only need matching) ───
|
// ─── Roman-numeral helpers (re-exported for callers that only need matching) ───
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+3
-1
@@ -576,7 +576,9 @@ function matchLoopOccurrence(win, start, cand) {
|
|||||||
|
|
||||||
// Returns the lexicographically smallest rotation so the same loop always
|
// Returns the lexicographically smallest rotation so the same loop always
|
||||||
// produces the same string regardless of where in the cycle we currently are.
|
// produces the same string regardless of where in the cycle we currently are.
|
||||||
function canonicalize(pattern) {
|
// Exported additively for match.js's seedableLoop / round-trip pool (task L-60,
|
||||||
|
// jam-roulette.md §3.2) — the seed must canonicalize identically to detection.
|
||||||
|
export function canonicalize(pattern) {
|
||||||
let best = pattern
|
let best = pattern
|
||||||
for (let i = 1; i < pattern.length; i++) {
|
for (let i = 1; i < pattern.length; i++) {
|
||||||
const rot = [...pattern.slice(i), ...pattern.slice(0, i)]
|
const rot = [...pattern.slice(i), ...pattern.slice(0, i)]
|
||||||
|
|||||||
Reference in New Issue
Block a user