10 Commits

Author SHA1 Message Date
vadimwit 2072f3674e ledger: BOTH SPRINTS COMPLETE — one-screen + roulette-kb; L-60/C-61 done; combined PR summary
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 19:57:11 +01:00
vadimwit 1df6fe8e9e feat(app): Jam Roulette — one-click genre + key + progression to jam over (task L-60, 2/2)
The 🎲 button opens a genre popover (Surprise me + the 10 KB styles);
rollJam seeds a random key + an interesting round-trip-safe
progression as a committed loop, so the whole dashboard fills exactly
as if detection found it — banner chips, voicing rail, licks, related
progressions. Three commit-layer guards keep the seed alive until the
band takes over: the null-clear skips seeded loops (a seed would
otherwise die one commit before the earliest possible confirmation),
the agreement branch confirms it, replacement swaps once real playing
is consistent, New Song clears it. An amber "rolled · style · name ·
bars — play it!" provenance chip flips to the normal loop marker on
first agreeing detection. Audio contract grep: zero hits. Critic PASS
(seed timeline hand-traced; §3.5 invariant verified at every write).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 19:48:14 +01:00
vadimwit 8d74e54f5a fix(match): index collapsed loop forms — real 12-bar blues now matches its KB entry (task L-60, 1/2)
buildLoopIndex additionally indexes each progression's collapsed form
(adjacent (degree,suffix) dedup + wrap), appended after raw entries so
every prior matcher winner is preserved except the enumerated
country-145 self-attribution. Repairs a pre-existing LIVE-detection
bug: a real 12-bar or 8-bar blues stream collapses to fewer names than
the raw KB degrees, so it matched NOTHING and the Jam Guide sat empty.
JamGuide reads authored plays through the collapsed station's
sourceIndex (all four guitar/piano/bass paths). Adds seedableLoop +
the round-trip roulette pool (smoke sweep: 4 failures / pool 52).
theory.js canonicalize exported. Critic PASS (fix-(a) additivity
proven registry-wide; pool reproduced from the spec; bass sourceIndex
station 5 -> raw 10 hand-checked).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 19:48:14 +01:00
vadimwit 4b64c41417 ledger: one-screen COMPLETE (L-50/D-51/L-51/C-50); L-60 re-dispatched on Opus after Fable spend-limit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 19:11:42 +01:00
vadimwit 4deba7e443 test(sweep): one-screen sprint-end sweep — ranking pins + registry id uniqueness (task C-50)
Smoke 886/886: RelatedProgressions ranking pinned (blues-12bar 152,
jazz-251-minor 156, no-collapse counterfactual 39 — replica grounded
against the live component; the 12-bar pin deliberately goes red when
D-62's collapsed indexing lands, message says re-pin). Validator:
registry-level progression-id uniqueness (the file walk missed
registry wiring dups — sabotage-proven with exactly one named error).
Whole-sprint App.jsx audio contract: zero hits since the seed.
Full-App SSR 60/60 across instrument x {normal, jam view}.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 18:27:20 +01:00
vadimwit 6e8d8f40fc feat(related): RelatedProgressions — loop-relative suggestions replace the generic table (task L-51)
KB-sourced ranking per the one-screen spec: same canonical degree
shape +100 (with the mandatory collapse of consecutive equal
degree/quality pairs — a real detector-produced 12-bar fires its blues
relatives despite the raw-index matcher bug), same style +40, shared
transitions +12 capped 36, degree-set Jaccard, length penalty; floor
24, max 5, never padded; entries render name/style/level/annotation +
the chord chain realized in the live key as real buttons ->
ChordDetailModal. match.js: additive exports only. Ride-along: dead
currentChord prop dropped from the banner mount. Critic PASS (both
hand-verified scores recomputed independently; full entry lists
byte-matched on three scenarios; collapse counterfactual = 39).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 16:52:52 +01:00
vadimwit d5a45f433a feat(rail): 500px-column adaptation — margin-hardened cells, wrap-safe basis (task D-51)
Chrome shave (rail p-2, rows p-1.5, dense cells p-1.5) so the
284+160 piano pair fits with real margin even under a classic Windows
scrollbar (was a genuine break: 452 > 439 at old paddings); gallery
basis 300->320 so a piano recommended cell can never share line one
(prominence for free) while guitar keeps 4 cells per line;
recommended-cell border strengthened to full accent. Playhead
highlight-only contract proven intact; non-dense mounts byte-identical
(28/28 at the gate). Critic PASS (arithmetic recomputed from source
AND the built CSS; flex-basis override confirmed in dist).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 16:51:09 +01:00
vadimwit e58a7a624c feat(app): one-screen jam dashboard + jam view (task L-50, commit 2/2)
Two-column grid: LEFT compact instrument view (natural-scale caps
674/562/674, non-compact renders byte-identical) + licks strip +
related-progressions slot (L-51 fills); RIGHT the 500px suggested-
voicings rail, height-bounded with internal scroll (not sticky).
ProgressionSuggestions unmounted (file kept). Jam view: one button,
two layers — CSS h-screen lock with everything below the grid
unmounted, plus best-effort Promise-caught requestFullscreen; Escape
and fullscreenchange stay in sync. Audio contract grep: zero hits.
Critic PASS (SSR 73/73; gate applied a one-class legend restoration
for non-compact byte-identity, re-verified green).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 16:28:25 +01:00
vadimwit 800fd43778 feat(banner): slim loop strip — big Now Playing chord removed (task L-50, commit 1/2)
Key chip + last-5 history + loop chips on one wrapping row; the
playhead loop chip and the enlarged current history chip are the
"now"; p-2 chrome trim. The text-6xl Now Playing column, its divider,
and the dead currentRN computation are gone — the loop shows once.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 16:28:25 +01:00
vadimwit 8e7a648660 ledger: roulette-kb 8/10 done, one-screen D-50 done + L-50 building; iteration log updated
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 16:02:44 +01:00
15 changed files with 1455 additions and 296 deletions
+23 -14
View File
@@ -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 IVIiiV 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 IVIiiV 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 67/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 67/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
+4 -2
View File
@@ -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
View File
@@ -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…len1); it passes iff
// detectRepeatingProgression returns exactly that form at ALL offsets, AND the
// collapsed length is 28. 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 28', () => {
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 28`)
}
}
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, |79|=2 length
// penalty). This is the exact flip the pre-fix note here foretold.
// (b) live iiVI 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 iiVI
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) |77| length = 92.
// Edges blues-quickchange (90; same terms but |79|=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): iiVI 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('')
+25
View File
@@ -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
View File
@@ -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 28
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 37 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>
) )
} }
+32 -12
View File
@@ -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>
) )
} }
+42 -21
View File
@@ -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>
) )
} }
+26 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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>
) )
} }
+84 -86
View File
@@ -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>
) )
+321
View File
@@ -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)
//
// 35 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 (Δ, qualityquality) 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>
)
}
+15 -6
View File
@@ -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
View File
@@ -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 (0len1), 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 28 (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 28 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
View File
@@ -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)]