Compare commits
10 Commits
2c18c71598
...
2072f3674e
| Author | SHA1 | Date | |
|---|---|---|---|
| 2072f3674e | |||
| 1df6fe8e9e | |||
| 8d74e54f5a | |||
| 4b64c41417 | |||
| 4deba7e443 | |||
| 6e8d8f40fc | |||
| d5a45f433a | |||
| e58a7a624c | |||
| 800fd43778 | |||
| 8e7a648660 |
+23
-14
@@ -16,15 +16,15 @@ The shared queue all agents read and write. Lifecycle and rules: [`PROTOCOL.md`]
|
||||
| id | title | domain | status | depends-on | files (lock) | definition of done |
|
||||
|----|-------|--------|--------|-----------|--------------|--------------------|
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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-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-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) |
|
||||
| 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-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 |
|
||||
| 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 |
|
||||
| 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-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. 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). **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. **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 | 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. **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) **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). 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 (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.
|
||||
|
||||
@@ -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 |
|
||||
|----|-------|--------|--------|-----------|--------------|--------------------|
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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. **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 | 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. **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 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.
|
||||
|
||||
@@ -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).
|
||||
- **`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).
|
||||
- **ProgressionBanner strip chips are div-onClick (Muse, small):** pre-existing a11y debt — history/loop chips should be real buttons (L-50 gate 2026-07-11; RelatedProgressions' chips already show the pattern to copy).
|
||||
- **Test-infra note (any agent writing throwaway SSR harnesses):** a bare `import('esbuild')` inside a `data:` module hook dies silently (ERR_MODULE_NOT_FOUND in the hooks thread, exit 1, zero output) — smoke.mjs:1047's interpolated `import.meta.resolve` pattern is load-bearing; prefer esbuild pre-bundle harnesses (confirmed empirically twice, 2026-07-11).
|
||||
- **GUITAR_SHAPES self-audit in smoke (Critic, small — P-62 gate suggestion 2026-07-11):** pc-spell every GUITAR_SHAPES entry against its quality in smoke — the validator audits only KB chord steps, never the shape library itself; would have caught both wrong shapes below and guards future additions.
|
||||
- **Pop piano prose uses absolute note names (Professor, tiny — P-62 gate flag):** ~8 spots (pop/piano.js:88,117,130-133,159,186) say e.g. "the G holds; top C→B" — correct only in the declared reference key; degree-ify before any piano-prose render surface lands (sibling of the bass-tips-surface backlog item).
|
||||
- **Two wrong GUITAR_SHAPES in live data (Luthier, small — P-62 audit finding 2026-07-11):** (1) `half_dim "E Barre"` offsets `[0,1,2,0,0,'x']` sounds {0,3,6,7} — natural 5th clashing the ♭5, NO ♭7; correct E-form ø7 = `[0,1,0,0,'x','x']`. (2) `add9 "Open Dadd9"` (xx0230 = D-A-D-E) has no 3rd — it's Dsus2, duplicating the sus2 open D. Both pre-existing; fix after P-62 commits (additive lock kept P-62 from editing them).
|
||||
- **Matcher blues/jazz disambiguation (Luthier, small):** `['C7','A7','Dm7','G7']` matches `jazz-rhythm-a` (identical I–VI–ii–V degree shape, quality tie → jazz), so `blues-turnaround`'s bass plays are live-unreachable — pre-existing tie-break in match.js; consider quality-profile weighting (all-dom7 → blues). Evidence-verified by the L-42 gate 2026-07-10.
|
||||
- **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).
|
||||
@@ -279,6 +284,10 @@ _(Maestro appends one line per completed iteration: `<date> · <task ids done>
|
||||
- 2026-07-09 · **iterations 6–7/24** · done: L-25 (`e24aa4b` instrument-scoped modal tabs), C-22 (`b4e8f12` validator hand-span rule, smoke 799), P-23 (`564a9e0` gospel piano cell, 140 plays — returned once: one improv-row scale claim false for vi7/iii7, split into exact rows, PASS re-gate; 39/40 first-pass claims held), L-24 (`2165bb5` authored piano packs render at stations — implementer agent hung at verification after ~1 h, killed, completion-pass agent audited + finished; Critic PASS with 0 resolver drift) · incidents: P-23 stalled once mid-authoring (resumed from transcript, no loss); L-24 hung silently (diff intact, completed by fresh agent) · backlog: resolveDegree drift guard filed · **board empty except C-21 → sprint-end sweep + PR**
|
||||
- 2026-07-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-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 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
|
||||
|
||||
@@ -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`
|
||||
(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.
|
||||
Cell wrap at ~576px interior: guitar 5/line, piano two-octave cells alone —
|
||||
same behaviour the D-41 gate already verified for the band. The instrument
|
||||
Cell wrap at ~576px interior: guitar 5/line, piano cells **pair** (a 284px
|
||||
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
|
||||
at every width.
|
||||
|
||||
|
||||
+240
-1
@@ -1193,7 +1193,246 @@ if (existsSync(join(ROOT, 'src/components/PianoLickCard.jsx'))) {
|
||||
warn('PianoLickCard vocab drift guard', 'src/components/PianoLickCard.jsx not yet authored (D-60)')
|
||||
}
|
||||
|
||||
// ─── 8. Summary + exit code ───────────────────────────────────────────────────
|
||||
// ─── 7d. Jam Roulette round-trip sweep (L-60, jam-roulette.md §2.2) ───────────
|
||||
//
|
||||
// The pool gate, protocol VERBATIM: for each of the 56 progressions, seed its
|
||||
// canonical collapsed form in C, fill a 32-commit window with repetitions and
|
||||
// truncate at EVERY partial-cycle offset (0…len−1); it passes iff
|
||||
// detectRepeatingProgression returns exactly that form at ALL offsets, AND the
|
||||
// collapsed length is 2–8. Steady-state-plus-all-offsets is the honest protocol
|
||||
// (a jam is sampled mid-cycle; a naive "2 clean cycles" feed evicts the 2-name
|
||||
// vamps and passes blues-8bar — §2.2). Expected: 52 passers, exactly 4 excluded
|
||||
// (jazz-blues, blues-quickchange, bossa-blue over-length; blues-8bar's
|
||||
// 1-of-7-offsets self-competition). Independent of match.roundTripPasses so a
|
||||
// detector or seed regression turns smoke red; cross-checked against it below.
|
||||
|
||||
console.log('\nJam Roulette round-trip sweep (L-60):')
|
||||
|
||||
const { seedableLoop, roundTripPasses, buildRoulettePool } = match
|
||||
|
||||
// Verbatim §2.2 protocol — deliberately NOT calling match.roundTripPasses.
|
||||
function sweepPasses(form) {
|
||||
if (!Array.isArray(form) || form.length < 2 || form.length > 8) return false
|
||||
const target = form.join(',')
|
||||
for (let offset = 0; offset < form.length; offset++) {
|
||||
const history = []
|
||||
for (let k = 0; k < 32; k++) history.push(form[(offset + k) % form.length])
|
||||
const detected = detectRepeatingProgression(history)
|
||||
if (!detected || detected.join(',') !== target) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const sweepFails = []
|
||||
let sweepPool = 0
|
||||
let sweepTotal = 0
|
||||
for (const style of Object.keys(kb)) {
|
||||
for (const prog of kb[style].progressions ?? []) {
|
||||
sweepTotal++
|
||||
const form = seedableLoop(prog, 0) // key C — key-independent (§3.3.3)
|
||||
if (form && sweepPasses(form)) sweepPool++
|
||||
else sweepFails.push(`${style}/${prog.id}`)
|
||||
}
|
||||
}
|
||||
console.log(` round-trip sweep: ${sweepFails.length} failures / pool ${sweepPool} (of ${sweepTotal})`)
|
||||
|
||||
check('round-trip sweep: exactly 4 failures, pool 52 of 56 (§2.2 steady-state, all offsets)', () => {
|
||||
assert(sweepTotal === 56, `swept ${sweepTotal} progressions, expected 56`)
|
||||
assert(sweepPool === 52, `pool ${sweepPool} ≠ 52`)
|
||||
assert(sweepFails.length === 4, `${sweepFails.length} failures ≠ 4: [${sweepFails.join(', ')}]`)
|
||||
})
|
||||
|
||||
check('round-trip sweep: the 4 excluded are exactly jazz-blues, blues-quickchange, blues-8bar, bossa-blue', () => {
|
||||
const want = ['blues/blues-8bar', 'blues/blues-quickchange', 'bossa/bossa-blue', 'jazz/jazz-blues']
|
||||
assert(JSON.stringify([...sweepFails].sort()) === JSON.stringify(want),
|
||||
`excluded set [${[...sweepFails].sort().join(', ')}] ≠ [${want.join(', ')}]`)
|
||||
})
|
||||
|
||||
check('match.roundTripPasses agrees with the verbatim sweep on all 56 (drift guard)', () => {
|
||||
for (const style of Object.keys(kb)) {
|
||||
for (const prog of kb[style].progressions ?? []) {
|
||||
const form = seedableLoop(prog, 0)
|
||||
const mine = form ? sweepPasses(form) : false
|
||||
const theirs = form ? roundTripPasses(form) : false
|
||||
assert(mine === theirs, `${style}/${prog.id}: verbatim ${mine} ≠ match.roundTripPasses ${theirs}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
check('buildRoulettePool exposes exactly the 52 passers, all collapsed len 2–8', () => {
|
||||
const pool = buildRoulettePool(kb)
|
||||
let n = 0
|
||||
for (const [, members] of pool.byStyle) {
|
||||
for (const mem of members) {
|
||||
n++
|
||||
assert(mem.collapsedLen >= 2 && mem.collapsedLen <= 8, `${mem.id} collapsedLen ${mem.collapsedLen} out of 2–8`)
|
||||
}
|
||||
}
|
||||
assert(n === 52, `pool holds ${n} members ≠ 52`)
|
||||
})
|
||||
|
||||
// Fix (a): a clean LIVE 12-bar commit stream now matches blues-12bar — the
|
||||
// pre-existing live-detection bug pinned fixed (jam-roulette.md §3.3.1). The live
|
||||
// commit stream is ALREADY collapsed (App.jsx dedupes back-to-back commits), so a
|
||||
// real 12-bar in G commits [G7,C7,G7,D7,C7,G7,D7] per chorus. Before fix (a) this
|
||||
// collapsed loop matched NOTHING → an empty JamGuide; now it detects + matches.
|
||||
check('fix (a) live: a clean collapsed 12-bar commit stream detects + matches blues-12bar (was an empty JamGuide)', () => {
|
||||
const G12_COMMITS = ['G7', 'C7', 'G7', 'D7', 'C7', 'G7', 'D7'] // one chorus, as committed
|
||||
const history = [].concat(...Array.from({ length: 5 }, () => G12_COMMITS)) // 5 choruses
|
||||
const detected = detectRepeatingProgression(history)
|
||||
assert(detected, 'detector returned null for a clean 12-bar commit stream')
|
||||
const m = matchLoopToProgression(detected, index)
|
||||
assert(m.matched && m.id === 'blues-12bar' && m.style === 'blues',
|
||||
`detected ${JSON.stringify(detected)} matched ${m.matched ? m.style + '/' + m.id : 'NONE'}, expected blues/blues-12bar`)
|
||||
})
|
||||
|
||||
// ─── 8. RelatedProgressions ranking pins (C-50) ───────────────────────────────
|
||||
//
|
||||
// Pins the L-51-gate-verified ranking outcomes of RelatedProgressions.jsx
|
||||
// (rankRelatedProgressions / collapseChanges, exported for exactly this) so a
|
||||
// scoring-constant tweak, a collapse regression, or a KB addition that
|
||||
// reshuffles the top entries turns smoke red instead of silently changing what
|
||||
// jammers are recommended. Two live pins + one counterfactual:
|
||||
//
|
||||
// (a) collapsed live 12-bar in A → RE-PINNED for L-60 fix (a): the
|
||||
// collapsed-form index now recognizes the loop as blues-12bar, which the
|
||||
// id-only rule excludes from its own related list. The new top is
|
||||
// blues/blues-8bar at score 92, annotation 'shares I7→V7' (0 shape + 40
|
||||
// same-style + 36 transitions Δ{5,7,10} + 16 Jaccard·1.0 − 0 length);
|
||||
// blues-quickchange edges just under at 90 (same terms, |7−9|=2 length
|
||||
// penalty). This is the exact flip the pre-fix note here foretold.
|
||||
// (b) live ii–V–I in C → match jazz/jazz-251-major (quality overlap wins
|
||||
// over jazz-251-minor), which is EXCLUDED from entries; top entry is
|
||||
// jazz/jazz-251-minor at 156 'same changes' (100 same canonical shape +
|
||||
// 40 same style + 0 shared transitions — every quality differs — + 16
|
||||
// Jaccard − 0 length).
|
||||
// (c) no-collapse counterfactual: an INDEPENDENT replica of the §5 scoring
|
||||
// formula (docs/design/one-screen.md), fed blues-12bar's RAW bar-per-bar
|
||||
// degrees, scores 39 (36 transitions + 8 Jaccard·0.5 − 5 length, no
|
||||
// +100) vs 152 with collapse — proving the mandatory collapse step is
|
||||
// what makes the flagship "this 12-bar IS their 12-bar" relation fire.
|
||||
// Post fix (a) blues-12bar is the MATCH (excluded), so the live grounding
|
||||
// moves to the actual top entry: the replica of blues-8bar (same-style
|
||||
// relative) must equal its live component score (92), grounding the
|
||||
// replica so the 39/152 can't drift into fiction.
|
||||
|
||||
console.log('\nRelatedProgressions ranking pins (C-50):')
|
||||
|
||||
const relMod = await load('src/components/RelatedProgressions.jsx')
|
||||
const { rankRelatedProgressions, collapseChanges } = relMod
|
||||
const { loopToDegrees, canonicalDegrees } = match
|
||||
|
||||
const LOOP_12BAR_A = ['A7', 'D7', 'A7', 'E7', 'D7', 'A7', 'E7'] // detection-collapsed 12-bar
|
||||
const LOOP_251_C = ['Dm7', 'G7', 'Cmaj7'] // live ii–V–I
|
||||
|
||||
check('RelatedProgressions exports rankRelatedProgressions + collapseChanges', () => {
|
||||
assert(typeof rankRelatedProgressions === 'function', 'rankRelatedProgressions is not an exported function')
|
||||
assert(typeof collapseChanges === 'function', 'collapseChanges is not an exported function')
|
||||
})
|
||||
|
||||
check('collapseChanges(blues-12bar) → 7 units [0,5,0,7,5,0,7] (raw 12 bars collapse to the detection form)', () => {
|
||||
const prog = kb.blues.progressions.find((p) => p.id === 'blues-12bar')
|
||||
assert(prog, 'blues-12bar missing from the registry')
|
||||
const units = collapseChanges(prog)
|
||||
const degs = units.map((u) => u.deg).join(',')
|
||||
assert(degs === '0,5,0,7,5,0,7', `collapsed degrees [${degs}] ≠ [0,5,0,7,5,0,7]`)
|
||||
assert(units.every((u) => u.quality === 'dom7'), 'collapsed qualities are not all dom7')
|
||||
})
|
||||
|
||||
check('collapseChanges(blues-minor) pops the wrap-around pair → 5 units [0,5,0,8,7]', () => {
|
||||
const prog = kb.blues.progressions.find((p) => p.id === 'blues-minor')
|
||||
assert(prog, 'blues-minor missing from the registry')
|
||||
const degs = collapseChanges(prog).map((u) => u.deg).join(',')
|
||||
assert(degs === '0,5,0,8,7', `collapsed degrees [${degs}] ≠ [0,5,0,8,7] — the cycle wrap (last unit === first unit) must collapse too`)
|
||||
})
|
||||
|
||||
const rank12 = rankRelatedProgressions(LOOP_12BAR_A)
|
||||
check('pin (a): collapsed 12-bar → matcher now recognizes blues-12bar (fix (a)); it self-excludes, blues-8bar tops at exactly 92, "shares I7→V7"', () => {
|
||||
assert(rank12, 'ranking returned null for a parseable loop')
|
||||
// Re-pinned for L-60 fix (a): the collapsed-form index makes the detected
|
||||
// collapsed 12-bar match blues-12bar itself, which the id-only rule excludes
|
||||
// from its own related list (§8's pre-fix note foretold exactly this flip).
|
||||
assert(rank12.match.matched && rank12.match.id === 'blues-12bar',
|
||||
`match is ${rank12.match.matched ? rank12.match.id : 'NONE'} — fix (a) should make the collapsed 12-bar match blues-12bar`)
|
||||
assert(!rank12.entries.some((e) => e.id === 'blues-12bar'),
|
||||
'blues-12bar leaked into its own related list — the id-only exclusion broke')
|
||||
const top = rank12.entries[0]
|
||||
// blues-8bar: same style (+40) + all 3 loop transitions Δ{5,7,10} present (cap 36)
|
||||
// + identical rebased degree-set {0,5,10} (Jaccard 1.0 ×16) − |7−7| length = 92.
|
||||
// Edges blues-quickchange (90; same terms but |7−9|=2 length penalty).
|
||||
assert(top && top.id === 'blues-8bar' && top.style === 'blues',
|
||||
`top entry is ${top?.style}/${top?.id}, expected blues/blues-8bar`)
|
||||
assert(top.score === 92, `blues-8bar scored ${top.score}, pinned 92 (0 shape + 40 style + 36 transitions + 16 Jaccard − 0 length)`)
|
||||
assert(top.annotation === 'shares I7→V7', `annotation '${top.annotation}' ≠ 'shares I7→V7'`)
|
||||
})
|
||||
|
||||
const rank251 = rankRelatedProgressions(LOOP_251_C)
|
||||
check('pin (b): ii–V–I in C → match jazz-251-major (excluded); jazz-251-minor top at exactly 156, "same changes"', () => {
|
||||
assert(rank251, 'ranking returned null for a parseable loop')
|
||||
assert(rank251.match.matched && rank251.match.id === 'jazz-251-major',
|
||||
`match is ${rank251.match.matched ? rank251.match.id : 'NONE'}, expected jazz-251-major (quality-overlap disambiguation)`)
|
||||
assert(!rank251.entries.some((e) => e.id === 'jazz-251-major'),
|
||||
'the matched progression leaked into its own related list — the id-only exclusion broke')
|
||||
const top = rank251.entries[0]
|
||||
assert(top && top.id === 'jazz-251-minor' && top.style === 'jazz',
|
||||
`top entry is ${top?.style}/${top?.id}, expected jazz/jazz-251-minor`)
|
||||
assert(top.score === 156, `jazz-251-minor scored ${top.score}, pinned 156 (100 shape + 40 style + 0 transitions + 16 Jaccard − 0 length)`)
|
||||
assert(top.annotation === 'same changes', `annotation '${top.annotation}' ≠ 'same changes'`)
|
||||
assert(rank251.entries.length <= 5, `${rank251.entries.length} entries > max 5`)
|
||||
})
|
||||
|
||||
// Independent §5-formula replica (expectation-side reimplementation, same
|
||||
// philosophy as §7's pinned truth table — deliberately NOT calling the
|
||||
// component's private helpers). `collapse: false` reproduces the pre-L-51
|
||||
// bug the mandatory collapse step exists to prevent.
|
||||
function replicaScore(loop, prog, sameStyle, { collapse }) {
|
||||
const SUFFIX_Q = { '': 'maj', 'm': 'min', '7': 'dom7', 'maj7': 'maj7', 'm7': 'min7' } // enough for the pinned loops
|
||||
const loopDeg = loopToDegrees(loop)
|
||||
const loopCanon = canonicalDegrees(loopDeg)
|
||||
const trans = (units) => units.map((u, i) => {
|
||||
const b = units[(i + 1) % units.length]
|
||||
return { d: (((b.deg - u.deg) % 12) + 12) % 12, qa: u.quality, qb: b.quality }
|
||||
})
|
||||
const loopT = trans(loop.map((name, i) => ({ deg: loopDeg[i], quality: SUFFIX_Q[name.match(/^[A-G][b#]?(.*)$/)[1]] ?? null })))
|
||||
const units = collapse
|
||||
? collapseChanges(prog)
|
||||
: prog.degrees.map((d, i) => ({ deg: d, quality: prog.qualities[i] ?? null }))
|
||||
const pCanon = canonicalDegrees(units.map((u) => u.deg))
|
||||
const seen = new Set()
|
||||
let shared = 0
|
||||
for (const t of trans(units)) {
|
||||
const k = `${t.d}|${t.qa}|${t.qb}`
|
||||
if (seen.has(k)) continue
|
||||
seen.add(k)
|
||||
if (loopT.some((l) => l.d === t.d && (l.qa == null || t.qa == null || l.qa === t.qa) && (l.qb == null || t.qb == null || l.qb === t.qb))) shared++
|
||||
}
|
||||
const A = new Set(loopCanon.split(',')), B = new Set(pCanon.split(','))
|
||||
const inter = [...A].filter((x) => B.has(x)).length
|
||||
return (pCanon === loopCanon ? 100 : 0) + (sameStyle ? 40 : 0)
|
||||
+ Math.min(shared * 12, 36) + (inter / (A.size + B.size - inter)) * 16
|
||||
- Math.abs(loop.length - units.length)
|
||||
}
|
||||
|
||||
check('pin (c): no-collapse counterfactual — raw blues-12bar would score exactly 39 vs 152 with collapse; replica grounded against the live top blues-8bar (92)', () => {
|
||||
const prog = kb.blues.progressions.find((p) => p.id === 'blues-12bar')
|
||||
// The flagship "this 12-bar IS their 12-bar" relation: 152 with the mandatory
|
||||
// collapse step, 39 without it — proving collapse is what fires the +100 shape
|
||||
// term. blues-12bar is no longer a live *entry* post fix (a) (it's the match,
|
||||
// excluded), so this is a pure-formula check of the replica, not read off the
|
||||
// component. Both remain literal so a scoring-constant tweak turns smoke red.
|
||||
const withCollapse = replicaScore(LOOP_12BAR_A, prog, false, { collapse: true })
|
||||
assert(withCollapse === 152, `replica with collapse scored ${withCollapse} ≠ 152 — the replica drifted from the §5 formula; fix the replica (or the component changed: re-derive BOTH pins)`)
|
||||
const noCollapse = replicaScore(LOOP_12BAR_A, prog, false, { collapse: false })
|
||||
assert(noCollapse === 39, `no-collapse counterfactual scored ${noCollapse} ≠ 39 (36 transitions + 8 half-Jaccard − 5 length) — the raw/collapsed relationship changed; re-derive the counterfactual`)
|
||||
// Ground the replica against the LIVE component via the actual top entry
|
||||
// (blues-8bar, same-style relative): the replica must reproduce its score.
|
||||
const prog8 = kb.blues.progressions.find((p) => p.id === 'blues-8bar')
|
||||
const w8 = replicaScore(LOOP_12BAR_A, prog8, true, { collapse: true })
|
||||
assert(w8 === 92, `replica of blues-8bar scored ${w8} ≠ 92 — the replica drifted from the §5 formula`)
|
||||
assert(rank12.entries[0].score === w8, `replica (${w8}) ≠ live component top score (${rank12.entries[0].score}) — the grounding broke`)
|
||||
})
|
||||
|
||||
// ─── 9. Summary + exit code ───────────────────────────────────────────────────
|
||||
|
||||
const total = passed + failures.length
|
||||
console.log('')
|
||||
|
||||
@@ -499,6 +499,31 @@ for (const style of styleDirs) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Global progression-id uniqueness over the REGISTRY (task C-50) ────────────
|
||||
// RelatedProgressions' ranking excludes the matched progression by ID ALONE
|
||||
// (rankRelatedProgressions: `prog.id !== match.id` — no style comparison, task
|
||||
// L-51), so a progression id repeated anywhere in the registry would silently
|
||||
// drop an unrelated style's entry from the related list. The per-style loop
|
||||
// above walks the FILES (kb/*/progressions.js) into `allIds`; this asserts the
|
||||
// same invariant over kb/index.js — the object the components actually
|
||||
// consume — so a registry wiring mistake the file walk never sees (a style
|
||||
// registered against another style's progressions module, a shared/duplicated
|
||||
// array) cannot reintroduce a duplicate.
|
||||
if (registry) {
|
||||
const seenRegIds = new Map() // id → style it first appeared under
|
||||
for (const style of Object.keys(registry)) {
|
||||
const progs = registry[style]?.progressions
|
||||
if (!Array.isArray(progs)) continue
|
||||
for (const p of progs) {
|
||||
const rw = `kb/index.js [${style}]`
|
||||
if (typeof p?.id !== 'string' || !p.id) { err(rw, 'registry progression with missing/non-string id'); continue }
|
||||
if (seenRegIds.has(p.id))
|
||||
err(rw, `progression id '${p.id}' already registered under style '${seenRegIds.get(p.id)}' — ids must be globally unique across the registry (RelatedProgressions excludes the matched progression by id alone)`)
|
||||
else seenRegIds.set(p.id, style)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length) {
|
||||
console.error(`✗ KB validation failed — ${errors.length} error(s):\n`)
|
||||
for (const e of errors) console.error(' ' + e)
|
||||
|
||||
+254
-24
@@ -1,7 +1,6 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import AudioCapture from './components/AudioCapture'
|
||||
import ProgressionBanner from './components/ProgressionBanner'
|
||||
import ProgressionSuggestions from './components/ProgressionSuggestions'
|
||||
import Fretboard from './components/Fretboard'
|
||||
import BassFretboard from './components/BassFretboard'
|
||||
import Tuner from './components/Tuner'
|
||||
@@ -11,9 +10,12 @@ import DebugView from './components/DebugView'
|
||||
import DrumView from './components/DrumView'
|
||||
import { NOTES, detectKey, detectTopKeys, matchChordFromChroma, detectRepeatingProgression, getChordTones, getChordCandidates, getNoteHistoryAnalysis } from './lib/theory'
|
||||
import ChordDetailModal from './components/ChordDetailModal'
|
||||
import RelatedProgressions from './components/RelatedProgressions'
|
||||
import LoopStation from './components/LoopStation'
|
||||
import JamGuide, { KnowledgeDock } from './components/JamGuide'
|
||||
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'
|
||||
|
||||
const DEFAULTS = {
|
||||
@@ -60,6 +62,36 @@ export default function App() {
|
||||
const [showDrumView, setShowDrumView] = useState(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) ──────────────────────────────────
|
||||
// When a Roadmap station is tapped, JamGuide reports its {rootPc, quality}
|
||||
// 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 [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) ────────────────────────────
|
||||
const [topKeyCandidates, setTopKeyCandidates] = useState([])
|
||||
|
||||
@@ -137,6 +176,13 @@ export default function App() {
|
||||
const progressionVoteRef = useRef(null)
|
||||
const progressionMissRef = useRef(0)
|
||||
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
|
||||
useEffect(() => { effectiveKeyRef.current = effectiveKey }, [effectiveKey])
|
||||
@@ -183,7 +229,11 @@ export default function App() {
|
||||
const vote = progressionVoteRef.current
|
||||
if (!detected) {
|
||||
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)
|
||||
progressionVoteRef.current = null
|
||||
}
|
||||
@@ -197,6 +247,9 @@ export default function App() {
|
||||
setDetectedProgression(detected)
|
||||
vote.candidateKey = null
|
||||
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
|
||||
}
|
||||
|
||||
@@ -204,11 +257,17 @@ export default function App() {
|
||||
if (vote && vote.candidateKey === key) {
|
||||
vote.candidateCount++
|
||||
} 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)) {
|
||||
setDetectedProgression(detected)
|
||||
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])
|
||||
|
||||
@@ -231,6 +290,7 @@ export default function App() {
|
||||
effectiveKeyRef.current = null
|
||||
setChordHistory([])
|
||||
setDetectedProgression(null)
|
||||
setSeedInfo(null) // L-60 (§3.6): the seed clears with the full reset
|
||||
setTopKeyCandidates([])
|
||||
setBpm(null)
|
||||
setMicError(null)
|
||||
@@ -260,6 +320,94 @@ export default function App() {
|
||||
effectiveKeyRef.current = keyInfo
|
||||
}
|
||||
|
||||
// ── Jam Roulette (task L-60, jam-roulette.md §2/§3.1) ────────────────────────
|
||||
// Rolls a random key + interesting KB progression and seeds the EXACT state
|
||||
// live detection writes (lockedKey + detectedProgression + a committed-shape
|
||||
// progressionVoteRef), so the whole dashboard populates as if detected. All
|
||||
// randomness is here (plain Math.random, no audio contact); inputs = the KB
|
||||
// registry only. `styleId === 'surprise'` picks uniformly over the 10 styles.
|
||||
function rollJam(styleId) {
|
||||
const pool = buildRoulettePool(kb) // lazy memo — round-trip passers, len 2–8
|
||||
const sid = styleId === 'surprise'
|
||||
? Object.keys(kb)[Math.floor(Math.random() * Object.keys(kb).length)]
|
||||
: styleId
|
||||
const members = pool.byStyle.get(sid) ?? []
|
||||
if (!members.length) return
|
||||
|
||||
// No-repeat memory (§2.2): exclude the last 6 rolled ids; if that empties the
|
||||
// pool (small styles), fall back to excluding only the immediately previous.
|
||||
const mem = rouletteMemoryRef.current
|
||||
let candidates = members.filter(m => !mem.includes(m.id))
|
||||
if (!candidates.length) {
|
||||
const prev = mem[mem.length - 1]
|
||||
candidates = members.filter(m => m.id !== prev)
|
||||
if (!candidates.length) candidates = members
|
||||
}
|
||||
|
||||
// Weighted draw: weight = levelW × lenW (§2.2). Intermediate leans in (×2),
|
||||
// the collapsed "4-bar-ish" sweet spot 3–7 leans in (×2).
|
||||
const weightOf = (m) => {
|
||||
const levelW = m.progression.level === 'intermediate' ? 2 : 1
|
||||
const lenW = (m.collapsedLen >= 3 && m.collapsedLen <= 7) ? 2 : 1
|
||||
return levelW * lenW
|
||||
}
|
||||
const totalW = candidates.reduce((s, m) => s + weightOf(m), 0)
|
||||
let r = Math.random() * totalW
|
||||
let picked = candidates[candidates.length - 1]
|
||||
for (const m of candidates) { r -= weightOf(m); if (r <= 0) { picked = m; break } }
|
||||
const prog = picked.progression
|
||||
|
||||
// Root: uniform over 12 pcs, SHARP spelling (§2.1 — only sharp names
|
||||
// string-match live detection); don't repeat the previous roll's root.
|
||||
let rolledPc = Math.floor(Math.random() * 12)
|
||||
if (prevRootPcRef.current != null && rolledPc === prevRootPcRef.current) {
|
||||
rolledPc = Math.floor(Math.random() * 12) // re-draw once
|
||||
}
|
||||
const loop = seedableLoop(prog, rolledPc)
|
||||
if (!loop) return // pool guarantees this, but stay defensive
|
||||
prevRootPcRef.current = rolledPc
|
||||
|
||||
// ── The seed writes (§3.1) — clean slate, then key + loop + committed vote ──
|
||||
newSong() // §3.6: roulette = New Song + seed (a stale window poisons handoff)
|
||||
const info = { root: NOTES[rolledPc], mode: prog.mode, confidence: 1 }
|
||||
setLockedKey(info)
|
||||
effectiveKeyRef.current = info // the quickLock precedent — detection uses it NOW
|
||||
chordVotesRef.current = []
|
||||
setDetectedProgression(loop)
|
||||
progressionVoteRef.current = {
|
||||
committedKey: loop.join(','), // the committed shape, L-31's own key
|
||||
candidateKey: null,
|
||||
candidateCount: 0,
|
||||
seeded: true, // the one flag L-60 adds (§3.4)
|
||||
}
|
||||
progressionMissRef.current = 0
|
||||
setSeedInfo({ styleLabel: kb[sid]?.meta?.label ?? sid, name: prog.name, bars: prog.bars })
|
||||
|
||||
// Session bookkeeping (§2.2) — survives New Song deliberately.
|
||||
lastRolledStyleRef.current = sid
|
||||
mem.push(picked.id)
|
||||
while (mem.length > 6) mem.shift()
|
||||
setRouletteMenuOpen(false)
|
||||
}
|
||||
|
||||
// Escape / click-outside closes the roulette menu; focus returns to the button.
|
||||
useEffect(() => {
|
||||
if (!rouletteMenuOpen) return
|
||||
const onKey = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
setRouletteMenuOpen(false)
|
||||
rouletteRef.current?.querySelector('button')?.focus()
|
||||
}
|
||||
}
|
||||
const onDown = (e) => { if (!rouletteRef.current?.contains(e.target)) setRouletteMenuOpen(false) }
|
||||
document.addEventListener('keydown', onKey)
|
||||
document.addEventListener('mousedown', onDown)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey)
|
||||
document.removeEventListener('mousedown', onDown)
|
||||
}
|
||||
}, [rouletteMenuOpen])
|
||||
|
||||
// ── Waveform handler: feeds oscilloscope / drum view ─────────────────────────
|
||||
const handleWaveform = useCallback((data) => {
|
||||
if (showDebugRef.current || showDrumViewRef.current) {
|
||||
@@ -436,7 +584,7 @@ export default function App() {
|
||||
}
|
||||
|
||||
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 className="mb-2 flex items-center justify-between">
|
||||
@@ -576,6 +724,77 @@ export default function App() {
|
||||
</button>
|
||||
</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>
|
||||
|
||||
<AudioCapture
|
||||
@@ -609,29 +828,17 @@ export default function App() {
|
||||
chordHistory={chordHistory}
|
||||
keyInfo={effectiveKey}
|
||||
detectedProgression={detectedProgression}
|
||||
currentChord={currentChord}
|
||||
seedInfo={seedInfo}
|
||||
onChordClick={setSelectedChord}
|
||||
/>
|
||||
|
||||
{/* ── Instrument + progressions row ── */}
|
||||
<div className="flex gap-3 mb-3 items-stretch">
|
||||
<div className="w-full lg:w-[70%] min-w-0">
|
||||
{instrument === 'guitar' && <Fretboard keyInfo={effectiveKey} currentChord={currentChord} pentatonicOnly={false} monoColor={monoColor} jamFocusChord={jamFocusChord} />}
|
||||
{instrument === 'bass' && <BassFretboard keyInfo={effectiveKey} currentChord={currentChord} monoColor={monoColor} />}
|
||||
{instrument === 'piano' && <Piano keyInfo={effectiveKey} currentChord={currentChord} monoColor={monoColor} />}
|
||||
</div>
|
||||
|
||||
<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. ── */}
|
||||
{/* ── The jam dashboard grid (task L-50, one-screen.md §1/§6): JamGuide
|
||||
owns the two-column layout — LEFT: compact instrument view (chosen
|
||||
here, passed as the mainView slot) + licks strip + the related-
|
||||
progressions slot (RelatedProgressions, task L-51); RIGHT: the
|
||||
suggested-voicings rail. ProgressionSuggestions is unmounted (file
|
||||
kept) — its job split into the rail + RelatedProgressions per the
|
||||
user directive. Follows the one global instrument selector. ── */}
|
||||
<JamGuide
|
||||
detectedProgression={detectedProgression}
|
||||
keyInfo={effectiveKey}
|
||||
@@ -639,8 +846,29 @@ export default function App() {
|
||||
currentChord={currentChord}
|
||||
onFocusChord={setJamFocusChord}
|
||||
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 ── */}
|
||||
<LoopStation
|
||||
slots={slots}
|
||||
@@ -719,6 +947,8 @@ export default function App() {
|
||||
onChordClick={setSelectedChord}
|
||||
instrument={instrument}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -33,7 +33,10 @@ function noteColor(isChordTone, isPenta, isScale, mono = false) {
|
||||
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 ?? {}
|
||||
|
||||
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()
|
||||
|
||||
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 (
|
||||
<div className="bg-panel border border-border rounded-2xl p-6">
|
||||
<p className="text-sm text-gray-500 uppercase tracking-widest mb-4">
|
||||
Bass — {root} {mode}
|
||||
{currentChord && <span className="text-amber-400 ml-2">/ {currentChord}</span>}
|
||||
</p>
|
||||
<div className={`bg-panel border border-border rounded-2xl ${compact ? 'p-3' : 'p-6'}`}>
|
||||
{compact ? (
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-x-4 gap-y-1">
|
||||
{heading}
|
||||
{legend}
|
||||
</div>
|
||||
) : (
|
||||
heading
|
||||
)}
|
||||
|
||||
<div>
|
||||
<svg
|
||||
viewBox={`0 0 ${BOARD_W} ${BOARD_H}`}
|
||||
width="100%"
|
||||
height="auto"
|
||||
style={{ display: 'block' }}
|
||||
style={{ display: 'block', ...(compact ? { maxWidth: BOARD_W } : null) }}
|
||||
>
|
||||
{/* Fretboard background */}
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="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>
|
||||
{!compact && legend}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -37,7 +37,11 @@ function noteColor(isChordTone, isPenta, isScale, mono = false) {
|
||||
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 ?? {}
|
||||
|
||||
if (!root) return null
|
||||
@@ -78,20 +82,49 @@ export default function Fretboard({ keyInfo, currentChord, pentatonicOnly = fals
|
||||
const focusLabel = pc =>
|
||||
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 (
|
||||
<div className="bg-panel border border-border rounded-2xl p-6">
|
||||
<p className="text-sm text-gray-500 uppercase tracking-widest 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>
|
||||
<div className={`bg-panel border border-border rounded-2xl ${compact ? 'p-3' : 'p-6'}`}>
|
||||
{compact ? (
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-x-4 gap-y-1">
|
||||
{heading}
|
||||
{legend}
|
||||
</div>
|
||||
) : (
|
||||
heading
|
||||
)}
|
||||
|
||||
<div>
|
||||
<svg
|
||||
viewBox={`0 0 ${BOARD_W} ${BOARD_H}`}
|
||||
width="100%"
|
||||
height="auto"
|
||||
style={{ display: 'block' }}
|
||||
style={{ display: 'block', ...(compact ? { maxWidth: BOARD_W } : null) }}
|
||||
>
|
||||
{/* Fretboard background */}
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="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>
|
||||
{!compact && legend}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
// GlanceRail — ALL loop stations expanded, always (task D-41, per
|
||||
// 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
|
||||
// banner's loop shows after rotation). Every row renders its full voicing
|
||||
// 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` +
|
||||
`${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 }}
|
||||
>
|
||||
{/* ── Header line: identity + the folded roadmap education ── */}
|
||||
@@ -190,11 +201,19 @@ function StationRow({
|
||||
<TransitionChip pair={rail} wraps={wraps} />
|
||||
</div>
|
||||
|
||||
{/* ── Gallery: own-voicing cell + the full dense browser; cells WRAP,
|
||||
never scroll horizontally (D-40 §4). ── */}
|
||||
<div className="flex flex-wrap items-start gap-2">
|
||||
{/* ── Gallery: own-voicing cell FIRST (the recommended cell — full-accent
|
||||
border reads as "the answer", one-screen.md §4; same size, no new
|
||||
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) && (
|
||||
<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">
|
||||
{ownGuitar && (
|
||||
<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>
|
||||
)}
|
||||
<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. */}
|
||||
<VoicingBrowser rootPc={st.rootPc} quality={st.quality} show={instrument} dense />
|
||||
</div>
|
||||
@@ -243,7 +262,7 @@ export default function GlanceRail({
|
||||
|
||||
return (
|
||||
<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"
|
||||
>
|
||||
<h4 className="mb-2 text-[10px] font-semibold uppercase tracking-widest text-gray-500">
|
||||
|
||||
+162
-103
@@ -10,17 +10,24 @@ import { ExploreSection, VoicingsSection, LevelChips } from './ExplorePanel'
|
||||
import { pianoVoicingChain } from '../lib/piano'
|
||||
import { 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
|
||||
// four-section bottom dock in two:
|
||||
// Task L-50 (per docs/design/one-screen.md §6) promoted the default export from
|
||||
// "band" to the two-column jam dashboard grid:
|
||||
//
|
||||
// default export `JamGuide` — the always-open, zero-chrome jam band, mounted
|
||||
// by App directly below the instrument row (CurrentJamPanel's old slot).
|
||||
// Body: GlanceRail voicings + LicksStrip for the matched loop; heard-live
|
||||
// single gallery when no loop is matched; a slim one-line hint when nothing
|
||||
// is heard. The loop itself renders ONCE, in ProgressionBanner (D-40 §2) —
|
||||
// RoadmapTrack is unmounted (file retired in place, deletion backlogged).
|
||||
// default export `JamGuide` — renders the xl: two-column flex region.
|
||||
// LEFT (flex-1): the `mainView` slot (App keeps choosing Fretboard /
|
||||
// BassFretboard / Piano — JamGuide never imports them), the LicksStrip,
|
||||
// and the `relatedSlot` (RelatedProgressions, mounted by App — null until
|
||||
// L-51). RIGHT (500px): the suggested-voicings rail — GlanceRail /
|
||||
// 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:
|
||||
// Explore / Voicings / Licks & Techniques + the shared level filter (the
|
||||
// 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))
|
||||
}
|
||||
|
||||
// ─── JamGuide — the always-open jam band (default export) ─────────────────────
|
||||
// ─── JamGuide — the jam dashboard grid (default export) ───────────────────────
|
||||
//
|
||||
// Props:
|
||||
// detectedProgression : string[] | null — the live detected loop (chord names)
|
||||
@@ -158,12 +165,16 @@ function lickFitsContext(lick, context) {
|
||||
// currentChord : string | undefined — most recent committed chord
|
||||
// onFocusChord : fn({rootPc,quality}|null) — Fretboard guide-tone link (D-03)
|
||||
// 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
|
||||
// level filter lives in the KnowledgeDock only.
|
||||
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.
|
||||
const styles = useMemo(
|
||||
() => 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',
|
||||
label: `${noteName}${suffix}`,
|
||||
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') {
|
||||
@@ -277,7 +292,7 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
|
||||
const play = Array.isArray(plays) ? plays[0] : null
|
||||
const chords = play?.chords ?? []
|
||||
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') {
|
||||
// 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 play = Array.isArray(pianoPlays) && pianoPlays.length ? pianoPlays[0] : null
|
||||
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
|
||||
// Computed fallback — only built when needed (no pack, or a recipe that
|
||||
// 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).
|
||||
const contextStation = stationVoicings[canonicalPos >= 0 ? canonicalPos : 0] ?? null
|
||||
|
||||
return (
|
||||
<section className="mb-3" aria-label="Jam Guide — follows the loop">
|
||||
|
||||
{/* ── 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">
|
||||
Jam Guide — {headerLabel}
|
||||
{match.matched && keyInfo?.root ? ` · in ${keyInfo.root} ${keyInfo.mode}` : ''}
|
||||
</h3>
|
||||
|
||||
{match.matched ? (
|
||||
instrument === 'bass' ? (
|
||||
/* Bass rows (D-40 §3): authored pattern cards when the matched style
|
||||
ships a bass pack (L-42), computed roots/fifths/approaches as the
|
||||
honest fallback otherwise. The licks strip hides either way
|
||||
(guitar tab licks are noise to a bassist mid-jam). */
|
||||
<BassGuideRows
|
||||
stations={stationVoicings}
|
||||
activeIndex={canonicalPos}
|
||||
keyMode={keyInfo?.mode}
|
||||
plays={bassPlays}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* The voicing rail — ALL stations expanded as vertical rows; the
|
||||
playhead only highlights (D-41, D-40 §4). */}
|
||||
<GlanceRail
|
||||
stations={stationVoicings}
|
||||
activeIndex={canonicalPos}
|
||||
focusedIndex={focusedStation}
|
||||
onFocus={setFocusedStation}
|
||||
instrument={instrument}
|
||||
keyRoot={keyRoot}
|
||||
keyMode={keyInfo?.mode}
|
||||
/>
|
||||
<LicksStrip
|
||||
styleId={activeStyle}
|
||||
levels={ALL_LEVELS}
|
||||
instrument={instrument}
|
||||
context={contextStation}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
) : liveChord ? (
|
||||
/* No loop matched, but chords are committing (D-31 §2.3): a single
|
||||
"heard live" gallery, re-aimed on every chord commit. Auto-follow
|
||||
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
|
||||
root/fifth line (no next chord → no approach) instead. */
|
||||
instrument === 'bass' ? (
|
||||
<BassGuideRows
|
||||
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">
|
||||
// ── The rail (right column at xl / second block stacked): the suggested-
|
||||
// voicings surface — GlanceRail, BassGuideRows, the heard-live gallery, or
|
||||
// the honest idle line (one-screen.md §3, §4). ──
|
||||
const railContent = match.matched ? (
|
||||
instrument === 'bass' ? (
|
||||
/* Bass rows (D-40 §3): authored pattern cards when the matched style
|
||||
ships a bass pack (L-42), computed roots/fifths/approaches as the
|
||||
honest fallback otherwise. The licks strip hides either way
|
||||
(guitar tab licks are noise to a bassist mid-jam). */
|
||||
<BassGuideRows
|
||||
stations={stationVoicings}
|
||||
activeIndex={canonicalPos}
|
||||
keyMode={keyInfo?.mode}
|
||||
plays={bassPlays}
|
||||
/>
|
||||
) : (
|
||||
/* The voicing rail — ALL stations expanded as vertical rows; the
|
||||
playhead only highlights (D-41, D-40 §4). */
|
||||
<GlanceRail
|
||||
stations={stationVoicings}
|
||||
activeIndex={canonicalPos}
|
||||
focusedIndex={focusedStation}
|
||||
onFocus={setFocusedStation}
|
||||
instrument={instrument}
|
||||
keyRoot={keyRoot}
|
||||
keyMode={keyInfo?.mode}
|
||||
/>
|
||||
)
|
||||
) : liveChord ? (
|
||||
/* No loop matched, but chords are committing (D-31 §2.3): a single
|
||||
"heard live" gallery, re-aimed on every chord commit. Auto-follow
|
||||
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
|
||||
root/fifth line (no next chord → no approach) instead. */
|
||||
instrument === 'bass' ? (
|
||||
<BassGuideRows
|
||||
stations={[{ rootPc: liveChord.rootPc, quality: liveChord.type, label: currentChord, rn: '' }]}
|
||||
activeIndex={0}
|
||||
keyMode={keyInfo?.mode}
|
||||
live
|
||||
/>
|
||||
) : (
|
||||
<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; voicings follow the next chord that commits.`
|
||||
: 'Play a few bars — voicings and licks for your loop land here.'}
|
||||
? `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} 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>
|
||||
)
|
||||
}
|
||||
@@ -527,10 +586,10 @@ function BassGuideRows({ stations = [], activeIndex = -1, keyMode, live = false,
|
||||
rootPc={st.rootPc}
|
||||
quality={st.quality}
|
||||
nextRootPc={stations[(i + 1) % n].rootPc}
|
||||
pattern={play?.chords?.[i]?.pattern}
|
||||
pattern={play?.chords?.[st.sourceIndex ?? i]?.pattern}
|
||||
playLabel={play?.label}
|
||||
feel={play?.feel}
|
||||
note={play?.chords?.[i]?.note}
|
||||
note={play?.chords?.[st.sourceIndex ?? i]?.note}
|
||||
chordLabel={st.label}
|
||||
/>
|
||||
))}
|
||||
|
||||
+32
-12
@@ -37,7 +37,10 @@ function keyColor(isChordTone, isPenta, isScale, isBlack, mono = false) {
|
||||
: { 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 ?? {}
|
||||
if (!root) return null
|
||||
|
||||
@@ -51,15 +54,36 @@ export default function Piano({ keyInfo, currentChord, monoColor = false }) {
|
||||
const svgW = totalWhite * KEY_W + 2
|
||||
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 (
|
||||
<div className="bg-panel border border-border rounded-2xl p-6">
|
||||
<p className="text-sm text-gray-500 uppercase tracking-widest mb-4">
|
||||
Piano — {root} {mode}
|
||||
{currentChord && <span className="text-amber-400 ml-2">/ {currentChord}</span>}
|
||||
</p>
|
||||
<div className={`bg-panel border border-border rounded-2xl ${compact ? 'p-3' : 'p-6'}`}>
|
||||
{compact ? (
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-x-4 gap-y-1">
|
||||
{heading}
|
||||
{legend}
|
||||
</div>
|
||||
) : (
|
||||
heading
|
||||
)}
|
||||
|
||||
<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 */}
|
||||
{Array.from({ length: OCTAVES }, (_, oct) =>
|
||||
@@ -132,11 +156,7 @@ export default function Piano({ keyInfo, currentChord, monoColor = false }) {
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="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>
|
||||
{!compact && legend}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,15 +2,29 @@ import { useRef, useEffect } from 'react'
|
||||
import { toRomanNumeral } from '../lib/theory'
|
||||
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 ?? {}
|
||||
|
||||
// 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 current = visible[visible.length - 1]
|
||||
const loopPos = findLoopPosition(chordHistory, detectedProgression)
|
||||
const currentRN = root && current ? toRomanNumeral(current, root, mode) : ''
|
||||
|
||||
const currentRef = useRef(null)
|
||||
const prevChord = useRef(null)
|
||||
@@ -25,71 +39,69 @@ export default function ProgressionBanner({ chordHistory, keyInfo, detectedProgr
|
||||
}, [current])
|
||||
|
||||
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 ── */}
|
||||
<div className="w-full lg:w-[70%] min-w-0 flex flex-col gap-2">
|
||||
|
||||
{/* Key + history on one row */}
|
||||
<div className="flex items-end gap-3">
|
||||
<div className="shrink-0 flex items-baseline gap-1.5">
|
||||
{root ? (
|
||||
<>
|
||||
<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>
|
||||
{/* ── Key chip ── */}
|
||||
<div className="shrink-0 flex items-baseline gap-1.5 self-center">
|
||||
{root ? (
|
||||
<>
|
||||
<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>
|
||||
)}
|
||||
</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 ? (
|
||||
<p className="text-gray-600 text-sm">Start listening…</p>
|
||||
) : (
|
||||
<div className="flex items-end gap-1 overflow-x-auto pb-1">
|
||||
{visible.map((chord, i) => {
|
||||
const isCurrent = i === visible.length - 1
|
||||
const age = visible.length - 1 - i
|
||||
const opacity = Math.max(0.25, 1 - age * 0.09)
|
||||
const rn = root ? toRomanNumeral(chord, root, mode) : ''
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
ref={isCurrent ? currentRef : null}
|
||||
style={{ opacity }}
|
||||
onClick={() => onChordClick?.(chord)}
|
||||
className={`flex flex-col items-center shrink-0 px-2 py-1 rounded-xl transition-colors duration-200 cursor-pointer ${
|
||||
isCurrent
|
||||
? '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'
|
||||
}`}>
|
||||
{chord}
|
||||
</span>
|
||||
<span className={`text-xs font-semibold mt-0.5 ${
|
||||
isCurrent ? 'text-amber-400' : 'text-gray-500'
|
||||
}`}>
|
||||
{rn || '\u00A0'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{/* ── Chord history (the enlarged current chip is the "now") ── */}
|
||||
{!chordHistory.length ? (
|
||||
<p className="text-gray-600 text-sm self-center">Start listening…</p>
|
||||
) : (
|
||||
<div className="flex items-end gap-1 overflow-x-auto">
|
||||
{visible.map((chord, i) => {
|
||||
const isCurrent = i === visible.length - 1
|
||||
const age = visible.length - 1 - i
|
||||
const opacity = Math.max(0.25, 1 - age * 0.09)
|
||||
const rn = root ? toRomanNumeral(chord, root, mode) : ''
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
ref={isCurrent ? currentRef : null}
|
||||
style={{ opacity }}
|
||||
onClick={() => onChordClick?.(chord)}
|
||||
className={`flex flex-col items-center shrink-0 px-2 py-1 rounded-xl transition-colors duration-200 cursor-pointer ${
|
||||
isCurrent
|
||||
? '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'
|
||||
}`}>
|
||||
{chord}
|
||||
</span>
|
||||
<span className={`text-xs font-semibold mt-0.5 ${
|
||||
isCurrent ? 'text-amber-400' : 'text-gray-500'
|
||||
}`}>
|
||||
{rn || ' '}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loop */}
|
||||
{detectedProgression && (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-xs text-gray-500">♻</span>
|
||||
{/* ── Loop — on the same row (divider between); playhead chip = the now ── */}
|
||||
{detectedProgression && (
|
||||
<>
|
||||
<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) => {
|
||||
const isActive = i === loopPos
|
||||
const rn = root ? toRomanNumeral(chord, root, mode) : chord
|
||||
@@ -110,31 +122,17 @@ export default function ProgressionBanner({ chordHistory, keyInfo, detectedProgr
|
||||
</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>
|
||||
|
||||
{/* ── 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>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
import { useMemo } from 'react'
|
||||
import kb from '../data/kb/index.js'
|
||||
import { NOTES, CHORD_TYPES } from '../lib/theory'
|
||||
import {
|
||||
buildLoopIndex,
|
||||
matchLoopToProgression,
|
||||
loopToDegrees,
|
||||
canonicalDegrees,
|
||||
chordRootPC,
|
||||
} from '../lib/match'
|
||||
|
||||
// ─── RelatedProgressions — loop-relative songbook relatives (task L-51) ───────
|
||||
//
|
||||
// 3–5 KB progressions genuinely related to the DETECTED loop, replacing the
|
||||
// retired generic ProgressionSuggestions table. KB-sourced only, loop-relative
|
||||
// by construction. Spec: docs/design/one-screen.md §5 (ranking + render + empty
|
||||
// states) and §6 (component boundary: pure/prop-driven, computes its own match
|
||||
// so it stays file-disjoint from D-51's rail work).
|
||||
//
|
||||
// Props:
|
||||
// loop : string[] | null — the detected repeating progression (chord names)
|
||||
// keyInfo : { root, mode, confidence } | null — effective key (realizes chips)
|
||||
// onChordClick : fn(chordName) — opens ChordDetailModal (App's setSelectedChord)
|
||||
//
|
||||
// The ranking (rankRelatedProgressions) is exported for smoke coverage.
|
||||
|
||||
// Suffix → KB quality token, derived from CHORD_TYPES (the vocabulary
|
||||
// authority — same inverse mapping as match.js's private suffixToQuality; all
|
||||
// 14 suffixes are unique). Unknown suffixes stay unmapped → wildcard (§5:
|
||||
// match on Δ alone).
|
||||
const SUFFIX_TO_QUALITY = Object.fromEntries(
|
||||
Object.entries(CHORD_TYPES).map(([quality, def]) => [def.suffix, quality])
|
||||
)
|
||||
|
||||
// §5 scoring constants.
|
||||
const SCORE_SAME_CHANGES = 100 // same canonical degree shape
|
||||
const SCORE_SAME_STYLE = 40 // same style as the matched progression
|
||||
const SCORE_PER_TRANSITION = 12 // per shared (Δ, quality→quality) transition…
|
||||
const TRANSITION_CAP = 36 // …capped (= 3 distinct transitions)
|
||||
const SCORE_JACCARD = 16 // × rebased degree-set Jaccard
|
||||
export const RELATED_SCORE_FLOOR = 24
|
||||
export const RELATED_MAX_ENTRIES = 5
|
||||
|
||||
// The module-level index (§6: "matchLoopToProgression over a module-level
|
||||
// index is trivially cheap") — built once, shared across renders.
|
||||
const DEFAULT_INDEX = buildLoopIndex(kb)
|
||||
|
||||
// ─── The MANDATORY collapse step (§5 step 2) ──────────────────────────────────
|
||||
// The live detected loop is a COLLAPSED form (detection never commits the same
|
||||
// chord twice in a row), while KB `degrees` are raw, bar-per-bar — blues-12bar
|
||||
// is [0,0,0,0,5,5,0,0,7,5,0,7]. Compared raw, the +100 same-shape term would
|
||||
// never fire for collapse-affected KB progressions. So before
|
||||
// canonicalDegrees(p.degrees) AND before building T(p): collapse consecutive
|
||||
// equal (degree, quality) pairs — including the wrap-around pair (last === first
|
||||
// as a cycle). Each surviving unit keeps the rn of its first bar for the
|
||||
// "shares {rn}→{rn}" annotation.
|
||||
// (L-60 will land a shared collapsed-form index in match.js; this local
|
||||
// collapse is deliberately component-scoped until then — one-screen.md §9.)
|
||||
export function collapseChanges(progression) {
|
||||
const degrees = Array.isArray(progression?.degrees) ? progression.degrees : []
|
||||
const qualities = Array.isArray(progression?.qualities) ? progression.qualities : []
|
||||
const rn = Array.isArray(progression?.rn) ? progression.rn : []
|
||||
const out = []
|
||||
for (let i = 0; i < degrees.length; i++) {
|
||||
const q = qualities[i] ?? null
|
||||
const prev = out[out.length - 1]
|
||||
if (prev && prev.deg === degrees[i] && prev.quality === q) continue
|
||||
out.push({ deg: degrees[i], quality: q, rn: rn[i] ?? '' })
|
||||
}
|
||||
if (out.length > 1) {
|
||||
const first = out[0]
|
||||
const last = out[out.length - 1]
|
||||
if (first.deg === last.deg && first.quality === last.quality) out.pop()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Transition set over units [{deg, quality, rn?}], wrap-around included (§5):
|
||||
// triples (Δ = (deg[i+1] − deg[i]) mod 12, q[i], q[i+1]); rn labels ride along
|
||||
// for the annotation.
|
||||
function transitionsOf(units) {
|
||||
const n = units.length
|
||||
if (n < 2) return []
|
||||
const out = []
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = units[i]
|
||||
const b = units[(i + 1) % n]
|
||||
out.push({
|
||||
d: (((b.deg - a.deg) % 12) + 12) % 12,
|
||||
qa: a.quality,
|
||||
qb: b.quality,
|
||||
rnFrom: a.rn ?? '',
|
||||
rnTo: b.rn ?? '',
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Live-loop units: degree from loopToDegrees, quality from the chord-name
|
||||
// suffix (unmappable → null = wildcard, matches on Δ alone).
|
||||
function loopUnitsOf(loop, loopDeg) {
|
||||
return loop.map((name, i) => {
|
||||
const m = typeof name === 'string' ? name.match(/^[A-G][b#]?(.*)$/) : null
|
||||
const suffix = m ? m[1] : null
|
||||
return { deg: loopDeg[i], quality: suffix != null ? SUFFIX_TO_QUALITY[suffix] ?? null : null }
|
||||
})
|
||||
}
|
||||
|
||||
// Distinct triples of T(p) matched by some loop transition (wildcard-aware),
|
||||
// in p's canonical order — shared[0] names the annotation.
|
||||
function sharedTransitions(loopT, pT) {
|
||||
const seen = new Set()
|
||||
const shared = []
|
||||
for (const t of pT) {
|
||||
const key = `${t.d}|${t.qa}|${t.qb}`
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
const hit = loopT.some(
|
||||
l =>
|
||||
l.d === t.d &&
|
||||
(l.qa == null || t.qa == null || l.qa === t.qa) &&
|
||||
(l.qb == null || t.qb == null || l.qb === t.qb)
|
||||
)
|
||||
if (hit) shared.push(t)
|
||||
}
|
||||
return shared
|
||||
}
|
||||
|
||||
// Rebased degree-set Jaccard over the canonical rotations (both sides rebased
|
||||
// so their canonical first element is 0 — rotation-invariant by construction).
|
||||
function degreeSetJaccard(aCanon, bCanon) {
|
||||
const a = new Set(aCanon.split(','))
|
||||
const b = new Set(bCanon.split(','))
|
||||
let inter = 0
|
||||
for (const x of a) if (b.has(x)) inter++
|
||||
const union = a.size + b.size - inter
|
||||
return union ? inter / union : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* rankRelatedProgressions(loop, kbRegistry?) → { match, entries } | null
|
||||
*
|
||||
* The §5 ranking. Returns null when the loop yields no degrees (no loop /
|
||||
* unparseable chord names) — the caller renders the idle state. `entries` is
|
||||
* score-desc (stable by style, id), floored at RELATED_SCORE_FLOOR, at most
|
||||
* RELATED_MAX_ENTRIES, never padded. Exported for smoke coverage.
|
||||
*/
|
||||
export function rankRelatedProgressions(loop, kbRegistry = kb) {
|
||||
const loopDeg = loopToDegrees(loop)
|
||||
if (!loopDeg || !loopDeg.length) return null
|
||||
const loopCanon = canonicalDegrees(loopDeg)
|
||||
const index = kbRegistry === kb ? DEFAULT_INDEX : buildLoopIndex(kbRegistry)
|
||||
const match = matchLoopToProgression(loop, index)
|
||||
const loopT = transitionsOf(loopUnitsOf(loop, loopDeg))
|
||||
|
||||
const entries = []
|
||||
for (const style of Object.keys(kbRegistry)) {
|
||||
const styleLabel = kbRegistry[style]?.meta?.label ?? style
|
||||
const progs = kbRegistry[style]?.progressions
|
||||
if (!Array.isArray(progs)) continue
|
||||
for (const prog of progs) {
|
||||
if (!Array.isArray(prog.degrees) || !prog.degrees.length) continue
|
||||
if (match.matched && prog.id === match.id) continue // §5: every p ≠ the matched one
|
||||
const collapsed = collapseChanges(prog)
|
||||
if (!collapsed.length) continue
|
||||
const pCanon = canonicalDegrees(collapsed.map(u => u.deg))
|
||||
const shared = sharedTransitions(loopT, transitionsOf(collapsed))
|
||||
const sameChanges = pCanon === loopCanon
|
||||
const sameStyle = match.matched && style === match.style
|
||||
|
||||
let score = 0
|
||||
if (sameChanges) score += SCORE_SAME_CHANGES
|
||||
if (sameStyle) score += SCORE_SAME_STYLE
|
||||
score += Math.min(shared.length * SCORE_PER_TRANSITION, TRANSITION_CAP)
|
||||
score += degreeSetJaccard(loopCanon, pCanon) * SCORE_JACCARD
|
||||
score -= Math.abs(loop.length - collapsed.length)
|
||||
if (score < RELATED_SCORE_FLOOR) continue // never pad with junk
|
||||
|
||||
// §5 annotation: why this entry is here (survivors always have one —
|
||||
// below the floor nothing scores on Jaccard − length alone).
|
||||
const annotation = sameChanges
|
||||
? 'same changes'
|
||||
: shared.length
|
||||
? `shares ${shared[0].rnFrom || '?'}→${shared[0].rnTo || '?'}`
|
||||
: 'same style'
|
||||
|
||||
entries.push({
|
||||
style,
|
||||
styleLabel,
|
||||
id: prog.id,
|
||||
name: prog.name,
|
||||
level: prog.level === 'intermediate' ? 'intermediate' : 'foundation', // untagged counts foundation
|
||||
score,
|
||||
annotation,
|
||||
progression: prog,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
entries.sort(
|
||||
(a, b) => b.score - a.score || a.style.localeCompare(b.style) || a.id.localeCompare(b.id)
|
||||
)
|
||||
return { match, entries: entries.slice(0, RELATED_MAX_ENTRIES) }
|
||||
}
|
||||
|
||||
// ─── Presentational bits ──────────────────────────────────────────────────────
|
||||
|
||||
// Level chip — ExplorePanel's LevelBadge language (amber = the existing
|
||||
// secondary-tone token; foundation stays quiet; untagged counts foundation).
|
||||
function LevelBadge({ level }) {
|
||||
if (level === 'intermediate') {
|
||||
return (
|
||||
<span className="shrink-0 text-[9px] uppercase tracking-wide font-semibold text-amber border border-amber/40 rounded px-1.5 py-px">
|
||||
intermediate
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span className="shrink-0 text-[9px] uppercase tracking-wide font-semibold text-gray-400 border border-border rounded px-1.5 py-px">
|
||||
foundation
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// The chord chain realized in the current key — the raw (bar-per-bar) KB form,
|
||||
// truncated to the first 8 + "…" (§5); each chip taps through to
|
||||
// ChordDetailModal via onChordClick (the banner-chip pattern, smaller).
|
||||
const CHAIN_SHOWN = 8
|
||||
|
||||
function ChordChain({ progression, keyRoot, onChordClick }) {
|
||||
const degrees = progression.degrees ?? []
|
||||
const qualities = progression.qualities ?? []
|
||||
const rn = progression.rn ?? []
|
||||
const shown = degrees.slice(0, CHAIN_SHOWN)
|
||||
return (
|
||||
<div className="mt-1 flex items-end gap-1 flex-wrap">
|
||||
{shown.map((deg, i) => {
|
||||
const rootPc = (((keyRoot + deg) % 12) + 12) % 12
|
||||
const label = `${NOTES[rootPc]}${CHORD_TYPES[qualities[i]]?.suffix ?? ''}`
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => onChordClick?.(label)}
|
||||
aria-label={`${label} — open chord detail`}
|
||||
className="flex flex-col items-center px-1.5 py-0.5 rounded-lg border border-border bg-border hover:border-gray-500 transition-all cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<span className="text-xs font-bold leading-none text-gray-300">{label}</span>
|
||||
<span className="text-[10px] text-gray-500">{rn[i] ?? ''}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{degrees.length > CHAIN_SHOWN && <span className="self-center text-xs text-gray-500">…</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function RelatedProgressions({ loop, keyInfo, onChordClick }) {
|
||||
const loopKey = Array.isArray(loop) ? loop.join(',') : ''
|
||||
const ranked = useMemo(
|
||||
() => (loopKey ? rankRelatedProgressions(loop) : null),
|
||||
[loopKey] // eslint-disable-line react-hooks/exhaustive-deps
|
||||
)
|
||||
|
||||
// keyInfo.root is a note NAME; chips want a pitch class. Default to C (0)
|
||||
// until a key is known, same fallback JamGuide's stations use.
|
||||
const keyRoot = useMemo(() => {
|
||||
const pc = chordRootPC(keyInfo?.root)
|
||||
return pc >= 0 ? pc : 0
|
||||
}, [keyInfo?.root])
|
||||
|
||||
// Idle — no loop (or unparseable chord names, which rank as no loop).
|
||||
if (!ranked) {
|
||||
return (
|
||||
<section
|
||||
className="rounded-2xl border border-dashed border-border p-3"
|
||||
aria-label="Related progressions"
|
||||
>
|
||||
<p className="text-sm text-gray-500">
|
||||
Loop a progression — related changes from the songbook land here.
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className="rounded-2xl border border-border bg-panel p-3"
|
||||
aria-label="Related progressions"
|
||||
>
|
||||
<h4 className="mb-2 text-[10px] font-semibold uppercase tracking-widest text-gray-500">
|
||||
Related progressions · from the songbook
|
||||
{keyInfo?.root ? ` · in ${keyInfo.root}` : ''}
|
||||
</h4>
|
||||
{ranked.entries.length === 0 ? (
|
||||
// Loop, but nothing clears the floor — honest, never padded (§5).
|
||||
<p className="text-sm text-gray-500">
|
||||
Nothing in the songbook genuinely relates to this loop yet.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2.5">
|
||||
{ranked.entries.map(entry => (
|
||||
<li key={`${entry.style}-${entry.id}`} className="min-w-0">
|
||||
<div className="flex flex-wrap items-baseline gap-x-1.5 gap-y-0.5">
|
||||
<span className="text-sm font-semibold text-gray-100">{entry.name}</span>
|
||||
<span className="text-xs text-gray-500">{entry.styleLabel}</span>
|
||||
<LevelBadge level={entry.level} />
|
||||
<span className="text-[10px] text-gray-500">{entry.annotation}</span>
|
||||
</div>
|
||||
<ChordChain
|
||||
progression={entry.progression}
|
||||
keyRoot={keyRoot}
|
||||
onChordClick={onChordClick}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -46,8 +46,13 @@
|
||||
// section chrome (border/panel background/heading — the row header
|
||||
// already names the chord) and suppresses the per-mount
|
||||
// mic-feedback microcopy (the rail shows it ONCE for the whole
|
||||
// rail, D-31 §2.5). Every pre-existing mount renders identically
|
||||
// with no prop.
|
||||
// rail, D-31 §2.5). D-51 (one-screen.md §4) additionally tightens
|
||||
// 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 ChordDiagram from './ChordDiagram'
|
||||
@@ -126,9 +131,11 @@ function SectionHeading({ children }) {
|
||||
// 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;
|
||||
// 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 (
|
||||
<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
|
||||
className="max-w-full break-words text-center text-[11px] font-medium leading-tight text-gray-300"
|
||||
title={label}
|
||||
@@ -228,7 +235,7 @@ export default function VoicingBrowser({ rootPc = 0, quality = 'maj', show = 'bo
|
||||
<div
|
||||
role="group"
|
||||
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) => (
|
||||
<GalleryCell
|
||||
@@ -236,6 +243,7 @@ export default function VoicingBrowser({ rootPc = 0, quality = 'maj', show = 'bo
|
||||
label={shape.label}
|
||||
playLabel={`Play ${name} — ${shape.label} guitar voicing`}
|
||||
onPlay={() => playGuitar(shape)}
|
||||
dense={dense}
|
||||
>
|
||||
<ChordDiagram shape={shape} rootPc={pc} size="thumb" />
|
||||
</GalleryCell>
|
||||
@@ -260,7 +268,7 @@ export default function VoicingBrowser({ rootPc = 0, quality = 'maj', show = 'bo
|
||||
<div
|
||||
role="group"
|
||||
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
|
||||
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}
|
||||
playLabel={`Play ${name} — ${voicing.label} piano voicing`}
|
||||
onPlay={() => playPiano(voicing)}
|
||||
dense={dense}
|
||||
>
|
||||
<MiniPiano voicing={{ ...voicing, rootPc: pc }} size="thumb" />
|
||||
</GalleryCell>
|
||||
|
||||
+192
-7
@@ -11,7 +11,7 @@
|
||||
// This file is matching/position logic only. All music-theory primitives
|
||||
// (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) ─
|
||||
|
||||
@@ -91,7 +91,9 @@ export function findLoopPosition(chordHistory, loop) {
|
||||
// for each rotation, re-base so the first element is 0, then pick the
|
||||
// lexicographically smallest resulting sequence. Same loop → same string,
|
||||
// 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
|
||||
if (n === 0) return ''
|
||||
let best = null
|
||||
@@ -110,7 +112,9 @@ function canonicalDegrees(degrees) {
|
||||
|
||||
// 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.
|
||||
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
|
||||
const pcs = loop.map(chordRootPC)
|
||||
if (pcs.some(pc => pc < 0)) return null
|
||||
@@ -118,25 +122,108 @@ function loopToDegrees(loop) {
|
||||
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).
|
||||
* Returns { byCanonical: Map<canonicalDegrees, entry[]> } where each entry is
|
||||
* { 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) {
|
||||
const byCanonical = new Map()
|
||||
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)) {
|
||||
const progs = kb[style]?.progressions
|
||||
if (!Array.isArray(progs)) continue
|
||||
for (const progression of progs) {
|
||||
if (!Array.isArray(progression.degrees) || !progression.degrees.length) continue
|
||||
const canon = canonicalDegrees(progression.degrees)
|
||||
const entry = { style, id: progression.id, progression }
|
||||
if (!byCanonical.has(canon)) byCanonical.set(canon, [])
|
||||
byCanonical.get(canon).push(entry)
|
||||
add(canonicalDegrees(progression.degrees), { style, id: progression.id, progression })
|
||||
// Collapsed-form entry — only when the collapsed shape differs (a run
|
||||
// collapse strictly shortens length, so a length change ⇔ a real collapse).
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -195,6 +282,10 @@ export function matchLoopToProgression(loop, kbOrIndex) {
|
||||
matched: true,
|
||||
id: best.id,
|
||||
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),
|
||||
progression: best.progression,
|
||||
}
|
||||
@@ -220,6 +311,100 @@ function rotationToCanonicalOrder(loopDegrees, kbDegrees) {
|
||||
return 0
|
||||
}
|
||||
|
||||
// ─── Jam Roulette — seed realization + the seedable pool (task L-60) ──────────
|
||||
//
|
||||
// jam-roulette.md §3.2 / §2.2. The seed writes the exact loop detection would
|
||||
// commit when the band plays this progression in the rolled key, so the L-31
|
||||
// commit layer treats it like any committed loop.
|
||||
|
||||
/**
|
||||
* seedableLoop(progression, keyRootPc) → string[] | null
|
||||
*
|
||||
* Realize → collapse (+ wrap-dedupe) → canonicalize (§3.2):
|
||||
* 1. Realize each station in the rolled key with SHARP spellings (§2.1 — only
|
||||
* sharp names string-match live detection's noteName path).
|
||||
* 2. Collapse consecutive duplicate NAMES, then drop the last if it equals the
|
||||
* first (the cyclic wrap — the loop's tail flows into its head live).
|
||||
* 3. Canonicalize the rotation with theory.js's own rule, so the seeded string
|
||||
* equals detectRepeatingProgression's canonical output exactly (the L-31
|
||||
* agreement branch compares join(',') strings).
|
||||
* Returns null when the collapsed loop has < 2 names (unrepresentable as a
|
||||
* detected loop — detectRepeatingProgression's min pattern length is 2).
|
||||
*/
|
||||
export function seedableLoop(progression, keyRootPc) {
|
||||
const degrees = Array.isArray(progression?.degrees) ? progression.degrees : []
|
||||
const qualities = Array.isArray(progression?.qualities) ? progression.qualities : []
|
||||
if (!degrees.length) return null
|
||||
const names = degrees.map((deg, i) => {
|
||||
const rootPc = (((keyRootPc + deg) % 12) + 12) % 12
|
||||
return NOTES[rootPc] + suffixOfQuality(qualities[i])
|
||||
})
|
||||
const collapsed = names.filter((n, i) => i === 0 || n !== names[i - 1])
|
||||
if (collapsed.length > 1 && collapsed[0] === collapsed[collapsed.length - 1]) collapsed.pop()
|
||||
if (collapsed.length < 2) return null
|
||||
return canonicalize(collapsed)
|
||||
}
|
||||
|
||||
/**
|
||||
* roundTripPasses(canonicalForm) → boolean (jam-roulette.md §2.2)
|
||||
*
|
||||
* The pool gate: a progression is confirmable iff, with a 32-commit window
|
||||
* filled with repetitions of its seeded canonical form (steady state) and
|
||||
* truncated at EVERY partial-cycle offset (0…len−1), detectRepeatingProgression
|
||||
* returns exactly that form at ALL offsets. Steady-state-plus-all-offsets is the
|
||||
* honest protocol — a jam is sampled mid-cycle, not at cycle boundaries, and a
|
||||
* naive "2 clean cycles" feed gets both failure modes wrong (§2.2). Hard length
|
||||
* bounds 2–8 (the detector only sweeps those lengths) are part of the gate.
|
||||
* Key-independent (§3.3.3): the detector consumes only the name stream's
|
||||
* equality structure, so one sweep in any key (the pool builds in C) covers all.
|
||||
*/
|
||||
export function roundTripPasses(canonicalForm) {
|
||||
if (!Array.isArray(canonicalForm)) return false
|
||||
const len = canonicalForm.length
|
||||
if (len < 2 || len > 8) return false
|
||||
const WINDOW = 32
|
||||
const target = canonicalForm.join(',')
|
||||
for (let offset = 0; offset < len; offset++) {
|
||||
const history = []
|
||||
for (let k = 0; k < WINDOW; k++) history.push(canonicalForm[(offset + k) % len])
|
||||
const detected = detectRepeatingProgression(history)
|
||||
if (!detected || detected.join(',') !== target) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Lazy module-level memo (§2.2): one sweep on first roulette open, reused for
|
||||
// every roll. Key-independent, so the pool is computed once in C.
|
||||
let _roulettePoolMemo = null
|
||||
|
||||
/**
|
||||
* buildRoulettePool(kb) → { byStyle: Map<style, member[]> }
|
||||
* member = { style, id, progression, collapsedLen }
|
||||
*
|
||||
* The roulette pool (§2.2): per style, the progressions whose seedable canonical
|
||||
* form passes the round-trip invariant AND lands in the 2–8 length bounds. An
|
||||
* 11th style or a new progression joins automatically. Randomization (weighting,
|
||||
* no-repeat memory, key) lives in the caller (App.rollJam) — this is the pure,
|
||||
* audio-free eligibility set.
|
||||
*/
|
||||
export function buildRoulettePool(kb) {
|
||||
if (_roulettePoolMemo) return _roulettePoolMemo
|
||||
const byStyle = new Map()
|
||||
for (const style of Object.keys(kb ?? {})) {
|
||||
const progs = kb[style]?.progressions
|
||||
if (!Array.isArray(progs)) { byStyle.set(style, []); continue }
|
||||
const eligible = []
|
||||
for (const progression of progs) {
|
||||
const form = seedableLoop(progression, 0) // key C — key-independent (§3.3.3)
|
||||
if (!form || !roundTripPasses(form)) continue
|
||||
eligible.push({ style, id: progression.id, progression, collapsedLen: form.length })
|
||||
}
|
||||
byStyle.set(style, eligible)
|
||||
}
|
||||
_roulettePoolMemo = { byStyle }
|
||||
return _roulettePoolMemo
|
||||
}
|
||||
|
||||
// ─── Roman-numeral helpers (re-exported for callers that only need matching) ───
|
||||
|
||||
/**
|
||||
|
||||
+3
-1
@@ -576,7 +576,9 @@ function matchLoopOccurrence(win, start, cand) {
|
||||
|
||||
// Returns the lexicographically smallest rotation so the same loop always
|
||||
// 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
|
||||
for (let i = 1; i < pattern.length; i++) {
|
||||
const rot = [...pattern.slice(i), ...pattern.slice(0, i)]
|
||||
|
||||
Reference in New Issue
Block a user