12 Commits

Author SHA1 Message Date
vadimwit 245d9f6fb2 ledger: L-74 done (rotating Try-this card); duplicate-name-loop rotation caveat backlogged 2026-07-13 12:23:42 +01:00
vadimwit 038fdfafc8 feat(dashboard): rotating Try-this card — one fresh substitution per loop pass (task L-74)
A compact TryThis card follows the playhead chord and shows ONE
suggestion at a time, cycling to the next valid substitution each time
the loop completes a pass (playhead wraps to a lower station). Over
Am-C-F the F cycles Dm -> Fm -> Fmaj7 -> E7 across passes, so the app
keeps offering a new idea and eventually teaches every honest move,
never a wrong one. Chip taps into ChordDetailModal; 0 subs -> no card,
1 sub -> static. Mounted App.jsx-only above RelatedProgressions in the
related slot; audio contract grep-clean; rotation logic exported pure
and StrictMode-safe. Critic PASS (2-pass rotation trace verified).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 12:23:18 +01:00
vadimwit 3469bafa0d feat(theory): suggestSubstitutions engine for the Try-this feature (task L-73)
Additive suggestSubstitutions({rootPc,quality}, keyInfo, opts) returns
up to 4 honest, correctly-spelled chord alternatives in the detected
key: relative/diatonic-third, borrowed-minor iv (flat-spelled b6),
diatonic extension colour, and the secondary dominant of the next
chord — each with a plain teaching why. A sabotage-proven smoke
truth-table pins the tables (Am-C-F both readings, the Rule-D blues
moves). Critic returned once (Rule C self-suggested the sounding chord
on 7th inputs: Dm7->Dm7, plus a latent add9-on-minor mis-spelling),
fixed (skip self-quality extension; omit category C when nothing new
to add), PASS on scoped re-gate. Smoke 903/903; theory.js purely
additive.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 12:04:34 +01:00
vadimwit 0b75ebdce1 ledger: L-73 returned (Rule C self-suggestion); L-74 = vary/rotate display (user decision) 2026-07-13 11:58:37 +01:00
vadimwit a311895d50 feat(related): same-style-first variations when a style is active (task L-72)
When the loop matches a KB style (rolled or live-detected),
RelatedProgressions leads with that style's other progressions under a
"Try these in {style}" header, labelled by data-derived character
(minor version / shorter form / extended form / reharmonized) computed
from mode/bars/qualities — no cross-style jumping. The role phrase is
suppressed unless the sibling is genuinely related (same changes or a
shared transition), so no false "variation" claim. When no style is
locked, today's cross-style list is preserved byte-for-byte. Smoke
893/893: §8 re-pinned (scores stable 92/156, labels moved) plus two
gate-added durable assertions (finding-B suppression + no-match
fallback). Critic PASS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 11:37:37 +01:00
vadimwit a95ad147f7 ledger: D-73 done (try-this design PASS after 3 copy fixes) 2026-07-13 11:31:37 +01:00
vadimwit c2207ef7b6 docs(design): Try this chord-substitution feature — 4 curated moves with plain why-copy (task D-73)
For a loop chord in the detected key, suggest up to 4 alternatives:
relative/diatonic-third sub, borrowed-minor iv colour, extension
colour, and the secondary dominant of the next chord — each with a
one-line teaching why, ranked softest to boldest. Worked through the
Am-C-F case under both the A-minor and C-major readings; circle-of-
fifths tie-in kept honest (relative + secondary-dominant are circle
moves, borrowed + extension are not). Mounts as a compact TryThis card
above RelatedProgressions in App's relatedSlot (file-disjoint). Critic
returned once (chordRootPC circular-import trap, sharp-spelled b6, one
wrong mediant claim — all copy/plumbing, rules verified correct),
fixed, re-gate waived (gate-prescribed fixes verified directly).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 11:31:18 +01:00
vadimwit a13c8fecc0 ledger: no song forms; L-72 unblocked (findings A/B resolved); new Try-this substitution feature (D-73/L-73/L-74) 2026-07-13 11:15:54 +01:00
vadimwit bb802db2b6 ledger: D-72 done (design PASS); L-72 held pending user song-forms decision + finding-A/B
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 11:08:11 +01:00
vadimwit 952d8076df docs(design): same-style related-progressions — variations by structural difference (task D-72)
When a style is active (rolled or detected), RelatedProgressions leads
with same-style siblings reframed by data-derived character (minor
version / shorter form / extended form / reharmonized), computed from
mode/bars/qualities — no new KB content, no matcher change. Cross-style
same-changes entries demoted to a secondary section. Critic PASS (role
labels recomputed honest for blues + jazz; smoke-pin shift is
label-only). Deliberately avoids verse/chorus/bridge labels the KB has
no data for.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 11:07:32 +01:00
vadimwit c6c6427f50 ledger: user answer folded — related-progressions = reuse existing KB same-style siblings; D-72 unblocked, runs parallel
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 10:52:15 +01:00
vadimwit 4d562d758e ledger: seed sprint-dashboard-polish (M-08) — 4-guitar cap, 2x2 piano, no play buttons, dark scrollbars, licks follow instrument, same-style related
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 10:48:16 +01:00
8 changed files with 1067 additions and 42 deletions
+31
View File
@@ -56,6 +56,36 @@ Standing principles (memory): scroll > click; nothing duplicated; one global ins
--- ---
## Active sprint: `sprint-dashboard-polish` (branch: `sprint-jamguide-piano` — continued; commits extend PR #3)
**Goal (user directive 2026-07-13, after running the one-screen build):** "this looks amazing" + six refinements to the live dashboard:
1. **Guitar: 4 options max** — "only have 4 guitar options visible so it would fit the screen without scrolling." Cap each chord's guitar gallery to ≤4 shapes (a musically-sensible top-4 selection rule; fewer is fine).
2. **Hidden-but-scrollable dark scrollbars** — "the scroll bars are not visible but it can scroll if we need to (make them black or something)." Thin/dark styled scrollbars, overlay feel; content still scrolls.
3. **Piano: 2×2 smaller keyboards** — "smaller keyboards so there would be 2×2 for each chord." The 4 pianoVoicing styles in a 2-col × 2-row grid of compact MiniPianos per chord.
4. **Remove play buttons** — "leave off the PLAY buttons, they take up a lot of space for no reason, no need to hear it." Drop every ▶ from the voicings rail (and the licks strip — Maestro extension, matching the stated glance-over-audio preference; also removes the shared-sequencer blocker so piano licks can wire in cleanly).
5. **Licks: uniform size + follow the instrument** — "the licks section should all have the same size and transform into piano when selected." Uniform card size; the strip follows the global GUITAR/PIANO/BASS selector — wiring the deferred PianoLickCard into the strip (bass → no licks / honest note).
6. **Related progressions: same-style bridge/chorus/modifications** — "i would also want bridge/chorus/modifications in the same style… say i select jam roulette with blues, then i want for that progression other options and not necessarily go into other styles." Same-style variations for the active/rolled loop. **User decided 2026-07-13: reuse existing KB same-style progressions (lightest option) — NO computed modifications, NO new authored sections, NO song forms (verse/chorus/bridge — KB has no section data and most progressions are complete forms; Maestro + user agreed not to fake it).** So L-72 ships the honest same-style variations. **NEW feature added same day** (user: "imagine we play Am C F, i'd like an alternative to that F… an option that says 'try this'… i want musicians to learn how they can make the jam more interesting"): a **per-chord "Try this" chord-substitution** surface — for a loop chord in the detected key/style, suggest a small curated set of alternative chords (relative sub, borrowed-minor colour, extension colour, a circle-of-fifths / secondary-dominant move) each with a plain-language WHY. Circle-of-fifths-informed where it applies (honest that not all subs are circle-adjacent). Keep it simple/learnable, not a reharm engine. → tasks D-73/L-73/L-74 below.
Standing principles (memory): scroll > click; nothing duplicated; one global instrument selector; playhead highlights; glance over audio. User is PRESENT — notification-driven, no cron. Weights: Muse 3, Luthier 3, Critic gate.
| id | title | domain | status | depends-on | files (lock) | definition of done |
|----|-------|--------|--------|-----------|--------------|--------------------|
| M-08 | Seed `sprint-dashboard-polish` | maestro | done | — | `docs/agents/LEDGER.md` | seeded |
| D-70 | Rail + licks layout concept doc: (a) guitar ≤4 shapes with the selection rule (open + common movable, lowest-position-first — name it); (b) piano 4 voicings as a 2×2 grid of compact MiniPianos — pick the MiniPiano thumb scale that fits two side-by-side in the ~456px column interior and two rows within a sane row height, honest math; (c) all ▶ removed from the rail's guitar+piano cells AND the licks strip (name every removal site); (d) hidden-but-scrollable scrollbars — the mechanism (webkit ::-webkit-scrollbar thin + dark thumb, and Firefox scrollbar-width/color; overlay where supported) and WHERE it applies (the rail's overflow-y column, any inner scrollers); (e) licks: uniform card footprint (thumb size parity between LickCard and PianoLickCard) + the strip follows the global instrument (guitar→piano licks on PIANO; bass honest empty); (f) recompute the row heights + the 500px column budget with the new smaller cells; migration order with bounded L-70/L-71 scopes (keep them file-disjoint or serialize on JamGuide.jsx). No user gate: pick strongest, record rationale + ≥2 rejected alternatives | design | claimed | — | `docs/design/dashboard-polish.md` | every change specced with honest numbers; the 2×2 piano scale chosen + proven to fit; bounded impl scopes |
| L-70 | Implement the voicings rail per D-70: guitar ≤4, 2×2 smaller piano, ▶ removed, dark hidden scrollbars | engineering | backlog | D-70 | `src/components/GlanceRail.jsx`, `src/components/VoicingBrowser.jsx`, `src/index.css` (scrollbar CSS) (+ per doc — re-lock at promotion) | rail matches the spec; no ▶; guitar ≤4; piano 2×2; scrollbars hidden+dark+functional; build + smoke green |
| L-71 | Implement the licks strip per D-70: uniform card size, follow the global instrument (wire PianoLickCard), ▶ removed | engineering | backlog | D-70, L-70 | `src/components/JamGuide.jsx` (LicksStrip), `src/components/LickCard.jsx`, `src/components/PianoLickCard.jsx` (+ per doc — re-lock at promotion) | licks uniform; piano licks show under PIANO; no ▶; bass honest; build + smoke green |
| D-72 | Same-style related-progressions design (user answer = reuse existing KB): decide the presentation — when a style is active, RelatedProgressions leads with same-style siblings reframed as "variations / sections to try in {style}" (labels/section framing that reads as bridge/chorus/variation without new content); how the active style is known (roulette seed carries it; live detection's match yields it — name the prop/source); whether cross-style entries stay as a secondary "other styles with these changes" section or are dropped when a style is locked; empty/edge states. Files disjoint from the rail/licks chain (RelatedProgressions.jsx only) → runs in PARALLEL | design | done | — | `docs/design/related-same-style.md` | presentation spec'd against the real RelatedProgressions/match.js; active-style source named; L-72 bounded |
| L-72 | Implement D-72: RelatedProgressions leads with same-style variations when a style is active; reframed labels; cross-style demoted/dropped per the doc. **Unblocked 2026-07-13** (no song forms). Resolutions: (finding-A) when a style IS locked, DROP the secondary cross-style section — same-style only, per goal #6 + the user's "not necessarily other styles"; keep cross-style ONLY when no style is locked (match.matched===false). (finding-B) null the role phrase when a same-style sibling shares NO transitions with the loop (no false "variation" claim — just name + level). Re-pin smoke §8 labels ('shares I7→V7'→'shorter form' etc.; scores stable) | engineering | claimed | D-72 | `src/components/RelatedProgressions.jsx`, `scripts/smoke.mjs` (§8 re-pin) | same-style-only when locked; honest cross-style only when unlocked; role phrase honest (finding-B); build + smoke green — **PASS** (`see commit` — findings A/B verified; gate added 2 durable smoke assertions, 893/893) |
| D-73 | **"Try this" chord-substitution feature** design (engine rules + UI): for a loop chord `{rootPc,quality}` in the detected key/style, a SMALL curated set (~3-4) of alternative chords — recommend the categories (relative/diatonic-third sub, borrowed-minor colour e.g. IV→iv, extension/colour e.g. maj7/add9/sus, a circle-of-fifths / secondary-dominant move) — each with a plain one-line WHY that teaches. Concrete worked examples for the user's AmCF case (both A-minor and C-major readings), arithmetically correct (the gate WILL recompute). The UI surface: where it mounts in the jam dashboard (per-station in the rail? a "Try this" line under the loop? — decide, keep it glanceable, tappable→ChordDetailModal), honest circle-of-fifths tie-in (a lens for the relative/neighbour subs, NOT claimed for borrowed/extension), edge states (no key / atonal), keep-it-simple (learnable, not a reharm engine). Use a SEPARATE component (disjoint from RelatedProgressions.jsx). No user gate: pick strongest, ≥2 rejected alternatives | design | done (returned once — 3 copy/plumbing fixes verified: chordRootPC→noteIndex circular-import, ♭6 flat spelling, mediant claim; rules all recomputed correct; `c2207ef`) | — | `docs/design/try-this-subs.md` | substitution categories + rules + why-copy spec'd and arithmetically honest; AmCF worked; UI surface + mount decided; L-73/L-74 bounded |
| L-73 | Substitution engine: `suggestSubstitutions({rootPc,quality}, keyInfo, opts?)` in `src/lib/theory.js` (additive) → `[{rootPc, quality, label, why, category}]` per D-73's rules + a smoke truth-table (expected subs for known chords/keys, sabotage-proven like the resolveDegree guard) | engineering | backlog | D-73 | `src/lib/theory.js` (additive), `scripts/smoke.mjs` | engine returns musically-correct subs for all 14 qualities in-key; smoke truth-table bites; build green | ← claimed 2026-07-13; **returned once** — Rule C self-suggested the sounding chord on 7th-chord inputs (Dm7→Dm7) + latent add9-on-minor mis-spelling; fixed + re-gate PASS (Dm7→[F], min7 sweep no add9, triad path intact, sabotage bites, 903/903); **done** `see commit`
| L-74 | "Try this" UI per D-73, with VARY/ROTATE (user decided 2026-07-13: "more surprising, more jam-like, keeps offering new ideas"): a new `TryThis.jsx` that, for the current playhead chord, shows ONE suggestion at a time (matches "try this" = a single nudge) and CYCLES to the next valid substitution each time the loop completes a pass (playhead wraps to station 0 — watch the position prop). Sequential cycling through the engine's ordered valid set (softest-first on first appearance) so the player eventually learns all options and never repeats until exhausted; a small "1 of N" / dot indicator; 0 subs → no card; 1 sub → static (no rotation). Tappable→ChordDetailModal; tokens/AA; glanceable. Supersedes D-73's static ≤4-chip display for the UI (engine/rules unchanged). Mount App.jsx-only in relatedSlot above RelatedProgressions (disjoint) | engineering | backlog | D-73, L-73 | `src/components/TryThis.jsx` (new), `src/App.jsx` (mount) | one fresh honest sub per chord, cycling each loop pass; zero-click; empties honest; build + smoke green | **done** `038fdfa` (Critic PASS — 2-pass rotation trace Dm→Fm verified, contract-clean)
| C-70 | Sprint-end sweep + PR #3 update (covers rail/licks + related + try-this) | quality | backlog | L-70, L-71, L-72, L-74 | (none — verification) | all green; PR updated |
> Sequencing (three parallel chains, file-disjoint): (A) D-70 → L-70 → L-71 (rail/licks); (B) D-72✓ → L-72 (related, same-style); (C) D-73 → L-73 → L-74 (try-this subs — theory.js + new TryThis.jsx, disjoint from A/B). C-70 closes after all. Critic gates every task.
---
## Shipped sprint: `sprint-integrated-glance` (branch: `sprint-jamguide-piano` — complete 2026-07-10, 4 iterations, PR #3 updated) ## Shipped sprint: `sprint-integrated-glance` (branch: `sprint-jamguide-piano` — complete 2026-07-10, 4 iterations, PR #3 updated)
**Goal (user directive 2026-07-10, after testing the glance-mode sprint — "its already a lot better, but"):** **Goal (user directive 2026-07-10, after testing the glance-mode sprint — "its already a lot better, but"):**
@@ -244,6 +274,7 @@ Emphasis this sprint: **ship the Jam Guide MVP** (put the 8 guitar style packs o
- **`pianoVoicing` should return `rootPc` (Luthier, tiny):** callers currently must attach it themselves (JamGuide and VoicingBrowser both do); returning it at the source removes the false-"R" foot-gun for future consumers (D-24 gate observation — NOT a live bug, both call sites verified correct 2026-07-08). - **`pianoVoicing` should return `rootPc` (Luthier, tiny):** callers currently must attach it themselves (JamGuide and VoicingBrowser both do); returning it at the source removes the false-"R" foot-gun for future consumers (D-24 gate observation — NOT a live bug, both call sites verified correct 2026-07-08).
- **`docs/kb-backlog.md` is stale (Professor, tiny):** still lists gospel/pop guitar + jazz/gospel piano as "todo" though shipped; P-30 (rnb piano) AND P-31 (blues piano, both 2026-07-10) need done entries — refresh next content iteration (flagged by P-30 + C-31 sweep, outside their locks). - **`docs/kb-backlog.md` is stale (Professor, tiny):** still lists gospel/pop guitar + jazz/gospel piano as "todo" though shipped; P-30 (rnb piano) AND P-31 (blues piano, both 2026-07-10) need done entries — refresh next content iteration (flagged by P-30 + C-31 sweep, outside their locks).
- **`scripts/loop-fixtures.mjs` header comment drift (tiny):** cites "App.jsx:322-324" for adjacent-dup suppression; L-31 shifted it to ~line 364 — comment-only, fix when the file is next touched (C-31 sweep finding). - **`scripts/loop-fixtures.mjs` header comment drift (tiny):** cites "App.jsx:322-324" for adjacent-dup suppression; L-31 shifted it to ~line 364 — comment-only, fix when the file is next touched (C-31 sweep finding).
- **TryThis duplicate-name-loop rotation (Luthier, small — L-74 gate 2026-07-13):** `loop.indexOf(currentChord)` returns the FIRST index, so a loop with a repeated chord name (e.g. a collapsed 12-bar where I7 recurs) double-advances the rotation (~2× pacing) and can mislabel Rule D's "next" chord. Subs for the current chord stay correct. Honest fix needs App to pass a true playhead station index (beyond L-74's mount-only lock) — wire it if TryThis ever couples to the loop engine.
- **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). - **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). - **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. - **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.
+219
View File
@@ -0,0 +1,219 @@
# Related progressions — same-style-first (D-72)
Concept doc for **L-72**. Design-only; no code here. Scope is one bounded edit to
`src/components/RelatedProgressions.jsx` (match.js untouched — see §6).
## The user's ask (2026-07-13, verbatim intent)
> "for the related progressions this is good also, but i would also want
> bridge/chorus/modifications in the same style … say i select jam roulette with
> blues, then i want for that progression other options and not necessarily go
> into other styles."
**Chosen scope:** reuse the KB's *existing* same-style progressions. No computed
modifications, no new authored content, no new KB fields. When a style is active
(rolled via Jam Roulette **or** live-detected), the panel leads with the OTHER
progressions of that same style, reframed as variations/sections to try — instead
of jumping to other styles.
The component today already computes a match, ranks every *other* KB progression
by musical proximity, and prints a flat cross-style list. This doc changes only
**how the list is partitioned, floored, ordered, and labelled** once a style is
known. When no style is known, behaviour is unchanged.
---
## 1. How the active style is known — reuse the component's own match
`rankRelatedProgressions(loop)` already calls
`matchLoopToProgression(loop, index)`, whose result is
`{ matched, id, style, rotation, progression }`. **`match.style` IS the active
style** — for both entry points:
- **Jam Roulette:** `rollJam` seeds `detectedProgression = seedableLoop(prog, key)`
(the collapsed canonical form). The L-60 collapsed-form index makes the
component re-match that loop back to the rolled progression → `match.style ==
the rolled style`, `match.id == the rolled progression's id`. That id is already
excluded from `entries` (the `prog.id === match.id` guard), so the surviving
same-style progressions are exactly "other options for the style I rolled."
- **Live detection:** the detected repeating loop is what App passes as
`loop={detectedProgression}`; the same match resolves the live style/id.
**Recommendation — use the internally-computed `match.style`; do NOT add a prop.**
Naming it explicitly: inside `rankRelatedProgressions`, after the existing
`const match = matchLoopToProgression(...)`, take
```
const activeStyle = match.matched ? match.style : null
```
Rejected: threading a `matchedStyle` prop down from App (roulette knows it via
`lastRolledStyleRef`; live detection could expose its own match). It duplicates
state that the component already derives identically from the same `loop`, and
introduces a divergence risk (App's match vs the component's match drifting).
The single source of truth is the loop → its match. Keep it in one place.
**No-match fallback.** When `match.matched === false` (the loop matches no KB
progression — an off-book jam), `activeStyle` is `null`; the panel renders exactly
today's cross-style list (flat, floor `RELATED_SCORE_FLOOR`, existing
annotations). Nothing about the current behaviour changes when no style is locked.
---
## 2. Same-style-first presentation — two sections
When `activeStyle != null`, partition the scored candidates by
`entry.style === activeStyle`:
**Primary — "Try these in {styleLabel}"** (same-style siblings).
All same-style progressions except the one being played, in scorer order (§4),
capped at `RELATED_MAX_ENTRIES` (5). The score floor is **relaxed to 0 for this
section** — a sibling of your own style is never "junk"; it is exactly the "other
options" the user asked for. Styles hold ≤7 progressions, so this shows all of
them (blues → 4 siblings).
**Secondary — "Same changes, other styles"** (cross-style), demoted, small.
Only progressions whose canonical changes are *identical* to the loop
(`sameChanges === true`), capped at **2**, floor kept. This preserves a genuinely
valuable, rare relative — "this exact turnaround also lives in jazz and gospel" —
without "going into other styles" for merely-similar material. If none qualify,
the section is omitted entirely.
**Recommendation: ship both sections (option b).** It honours "not necessarily go
into other styles" (same-style leads and dominates the panel) while not hiding an
exact-match cousin elsewhere. Dropping the secondary later is a one-line change
(don't render it) if the user wants pure same-style — noted as the toggle.
Rejected: a hard same-style-only filter that *never* shows cross-style. It throws
away the exact-match cousin (musically the most useful cross-style pointer we
have) and would also have to special-case the no-match path. Kept only as the
one-line fallback if the user insists on zero cross-style.
---
## 3. The reframe — "bridge/chorus/modifications" with NO new content
Same-style siblings must read as *sections/variations to try*, not a flat list.
We label each with a short **role phrase** derived only from data already in the
KB — comparing the sibling to the active (matched) progression. Fields used:
`mode`, `bars` (summed = the form length), `qualities` (the colour set),
`name`, `level`. No new fields.
`siblingRole(sibling, active)` → a short phrase or `null`, first rule that fires:
1. **mode differs**`"{mode} version"` — minor→`"minor version"`,
major→`"major version"`, else the mode name (`"dorian version"`, …).
2. **same mode, fewer total bars**`"shorter form"`.
3. **same mode, more total bars**`"extended form"`.
4. **same mode & length, a quality the active lacks**`"reharmonized"`.
5. **otherwise**`null` (honest: just the name + level badge, no role line).
The active progression's `mode`/`bars` come from looking the raw KB entry up by
`match.id` in `kbRegistry[activeStyle].progressions` (avoids any collapsed-
projection subtlety; the raw entry is authoritative).
### Concrete — active = blues **Standard 12-bar** (major, 12 bars)
| sibling | mode | bars | role phrase | reads as |
|---|---|---|---|---|
| Quick-change 12-bar | major | 12 | `null` | name + `foundation` (name already says "quick-change") |
| 8-bar blues | major | 8 | **shorter form** | "the compact take" |
| Minor blues | minor | 12 | **minor version** | "the minor cousin" |
| Turnaround cycle | major | 4 | **shorter form** | name already says "Turnaround cycle" |
Every phrase is honest and re-derivable from `mode`/`bars`/`qualities`. Where no
character is derivable (Quick-change: same mode, same length, same all-dom7 colour
set) we print **nothing** beyond the name and level badge — the name carries it.
The two "shorter form"s are fine: their *names* (`8-bar blues`, `Turnaround
cycle`) already distinguish them, and the scorer orders them by proximity (§4).
This is where the "bridge/chorus/modification" feel comes from: the KB already
holds the minor version, the short form, the turnaround, the quick-change — we are
just *reframing existing siblings* with a one-line role, not synthesising sections.
---
## 4. Ranking within same-style — keep the scorer order
The existing scorer still runs over every candidate. Within the same-style group
the `+40 SCORE_SAME_STYLE` term is constant, so it cancels — ordering is driven by
`same changes (+100)``shared transitions``Jaccard`` length`, i.e.
**musical proximity to the loop you're playing.** That is more useful mid-jam than
alphabetical, so:
**Recommendation: keep the scorer sort within same-style. Do NOT re-sort by
level/name.** The closest variation to what your hands are already doing surfaces
first. The only change is relaxing the floor to 0 for this section (§2) so no
sibling is silently dropped for being "only" a distant relative — the user
explicitly wants *all* the style's options.
---
## 5. Edge / empty states
- **Style with only 1 progression** (0 same-style siblings after excluding the
played one). No current style hits this (all have ≥5), but handle it: render an
honest primary line — *"You're on the only {styleLabel} loop in the songbook."*
then fall through to the cross-style secondary (kept at floor). Never pad.
- **Secondary empty** (no exact cross-style cousin) → omit the secondary section
silently; the primary stands alone.
- **No match at all** (`activeStyle == null`) → today's single flat list, unchanged.
- **Instrument-agnostic — confirmed.** `RelatedProgressions` reads only
`progressions` (`degrees`/`qualities`/`rn`/`level`/`name`/`mode`/`bars`), never
the `instruments` cells. It behaves identically under guitar / piano / bass; the
global instrument selector does not touch it.
---
## 6. L-72 change list (RelatedProgressions.jsx only)
`match.js` needs **no change**`matchLoopToProgression` already returns
`{ style, id, progression }`. Reuse it.
In `rankRelatedProgressions`:
1. After computing `match`, derive `const activeStyle = match.matched ? match.style
: null` and look up the raw active progression (`kbRegistry[activeStyle]
?.progressions.find(p => p.id === match.id)`) for its `mode` + summed `bars`.
2. Keep the existing scoring loop. Change the floor test: skip the
`score < RELATED_SCORE_FLOOR → continue` **only when** `style === activeStyle`
(same-style siblings bypass the floor); cross-style keeps the floor.
3. Add a `siblingRole(sibling, active)` helper (§3) and attach `role` to each
same-style entry; leave cross-style entries' existing `annotation` intact.
4. Partition the sorted entries into `primary` (`style === activeStyle`, cap 5)
and `secondary` (`style !== activeStyle && sameChanges`, cap 2). Return
`{ match, activeStyle, activeStyleLabel, primary, secondary }`. Keep
`entries` (= `primary.concat(secondary)`) on the return so any existing
`entries[0]` / `entries.length` reads still resolve during the transition.
When `activeStyle == null`, return today's shape (`primary = entries`,
`secondary = []`) so the render path collapses to the current flat list.
In the render:
5. Two `<section>`-internal blocks: primary headed *"Try these in
{activeStyleLabel}"*, secondary headed *"Same changes, elsewhere"* (rendered
only when non-empty). Same-style rows swap the `annotation` line for the `role`
phrase (omit the line when `role == null`). Reuse the existing `LevelBadge` /
`ChordChain` — no new tokens, no new colours (all within `bg-panel` /
`border-border` / `text-gray-*` / `text-amber`, already in use).
**Smoke pins (C-50, `scripts/smoke.mjs` §8) will shift — re-derive, coordinate
with C-70:**
- Pin (a) collapsed 12-bar in A: top stays **blues/blues-8bar, score 92**
(blues is now the active style; 8-bar is a same-style sibling; the +40 is still
in its score, order unchanged). Its **annotation label changes** from
`'shares I7→V7'` to the role `'shorter form'` — the assertion must be re-pinned.
- Pin (b) iiVI in C: top stays **jazz/jazz-251-minor, score 156** (same-style,
same changes). Its label changes from `'same changes'` to the role
`'minor version'` (mode differs) — re-pin.
- Scores and top ids are stable; only the annotation strings and the return shape
move. Add coverage for the primary/secondary split and the no-match fallback.
### Rejected alternatives (recap)
- **Computed modifications** (synthesise a bridge/chorus by transposing or
reharmonising) — rejected per the user's explicit choice to reuse existing KB
content; also risks inventing non-idiomatic changes we can't vouch for.
- **Hard same-style-only, no cross-style ever** — rejected: loses the exact-match
cousin in another style and complicates the no-match path. Retained only as the
one-line "drop the secondary" toggle if the user later wants zero cross-style.
- **An explicit `matchedStyle` prop from App** — rejected as redundant (§1): the
component derives the same style from the same loop; a prop only adds a
divergence surface.
+170
View File
@@ -0,0 +1,170 @@
# "Try this" — chord-substitution nudge (task D-73)
**Sprint:** `sprint-dashboard-polish` · **Owner:** Muse · **Impl tasks:** L-73 (engine, `theory.js`) + L-74 (UI, new `TryThis.jsx`)
**User ask (2026-07-13, verbatim):** *"imagine we play a simple Am C F then i'd like to have an alternative to that F that would be in the similar style … potentially based on the circle of fifths? i'd like an option that says: 'try this' … i want musicians to learn how they can make the jam more interesting."* Plus the guard-rail: *"we dont have to create something too difficult."*
So this is a **small, curated, learnable nudge** — not a reharmonisation engine. For the chord under the playhead, in the detected key, show 34 alternative chords, each with one plain sentence that *teaches why it works*. Tap a suggestion → the existing `ChordDetailModal` to study it.
> **Not** the existing `getChordSubstitutions` (education.js). That returns **context-free, same-root colour swaps** (`maj → maj7, add9, maj6…`) and never looks at the key. The new engine is **key-aware** and, crucially, **changes the root** (relative sub, secondary dominant) with a *why* framed against the live key. They coexist; the modal keeps its colour-swap grid, the dashboard gets the new nudge.
---
## 1. The reading model — which key/mode the engine trusts
**Recommendation: take the app's `effectiveKey` (`lockedKey ?? keyInfo`) as the single reading. Do not compute both readings at once.**
- `effectiveKey` is `{ root, mode, confidence }`. The **mode disambiguates the i-vs-vi ambiguity** that makes `Am C F` read two ways: if the app committed **A minor**, `F` is `♭VI`; if **C major**, `F` is `IV`. The engine frames the *why* against whichever one is live, and gates the moves that need a specific reading (borrowed `iv` needs a major reading — see Rule B).
- The user can already flip the mode in the key dropdown (the app's intended workflow, per project memory — e.g. A minor → A Dorian). When they do, the *why* copy and the applicable moves change with it. That is the honest way to "see the other framing" — one reading on screen at a time, driven by the user's own mode choice.
- **Edge — no key / atonal / low confidence:** `suggestSubstitutions` returns `[]` when `!keyInfo?.root`. `TryThis` then renders a quiet idle line ("Lock a key to see substitutions") — never a fabricated suggestion. This mirrors `RelatedProgressions`/`CircleOfFifths` idle states.
The worked examples in §4 show **both** readings only so the gate can verify the arithmetic under each; at runtime exactly one is shown.
---
## 2. The curated set — 4 categories, ranked softest → boldest
All rules operate on `{ rootPc, quality }` (a pitch class 011 + a `CHORD_TYPES` key) — the same shape the KB/JamGuide stations already use — and read `keyInfo {root, mode}`. `keyRootPc = noteIndex(keyInfo.root)` (theory.js's own in-module note-name→pc helper, line 133 — **not** match.js's `chordRootPC`, which would make theory.js import from a module that imports it back = circular). Candidates are returned as `{ rootPc, quality, label, why, category }` where `label = NOTES[rootPc] + CHORD_TYPES[quality].suffix`. **Cap the output at 4**, in the order below (softest first, so the glance reads top-down by boldness).
Notation: pc arithmetic is mod 12. `NOTES = [C,C#,D,D#,E,F,F#,G,G#,A,A#,B]` (C=0 … B=11).
### A. Relative / diatonic-third sub — *the softest, most universal*
Swap a chord for the diatonic chord a third away that **shares two of three tones**.
- **major-family chord** (`maj, maj7, maj6, add9`): candidate = `{ (rootPc + 9) % 12, 'min' }` — the **relative minor** (a minor 3rd below). *Shared tones:* the original root and 3rd become the relative's 3rd and 5th.
- **minor-family chord** (`min, min7, min6`): candidate = `{ (rootPc + 3) % 12, 'maj' }` — the **relative major** (a minor 3rd above).
- **Gate:** emit only if the candidate is **diatonic in `keyInfo`** (`getChordsInKey(root,mode)` contains it). This keeps the swap "safe/soft" and never forces an out-of-key relative. (Non-diatonic relatives are out of MVP scope.)
- **Circle tie-in: yes** — the relative minor/major is the circle's *inner ring* (see `CircleOfFifths.jsx`). The *why* may say so.
- **Why template:** `"{cand} is {orig}'s relative {minor|major} — shares {t1} & {t2}. In this key it's the {rn(cand)}: {softer|brighter} pull, same family."`
### B. Borrowed minor colour — *the "blue"/"Creep" move* (conditional)
Major `IV → iv` (same root, major → minor) — lowers the 6th of the key to the ♭6.
- **Gate (strict, honest):** emit **only** when `keyInfo.mode` is **major-ish** AND the chord is the **IV** (`rootPc === (keyRootPc + 5) % 12`) AND quality is major-family. Under a **minor reading it is suppressed** (in A minor, `F` is a diatonic major `♭VI`; `Fm` would be a chromatic `♭vi` with no honest function — we do not fake it).
- Candidate = `{ rootPc, 'min' }`.
- **Circle tie-in: no** — this is a modal borrowing, not a circle step. The *why* must not claim the circle.
- **Why template:** `"Borrow {cand} (the iv) from the parallel minor — {n6}→{nb6} adds that wistful pull home. The 'Creep' move."` where `nb6 = noteName((keyRootPc + 8) % 12, /*preferFlat*/true)` (the ♭6). **Spell it flat** — this is a flatward modal borrow (A→A♭), never the sharp `NOTES[8]='G#'`. (The *ascending* leading tone in Rule D stays sharp — see §3.)
### C. Extension / colour — *same function, more colour*
Keep the root and function; add one diatonically-honest colour tone.
- Pick the extension whose **added tone is diatonic** in `keyInfo` (prefer, in order): major-family → `maj7` if `(rootPc+11)` diatonic, else `add9` if `(rootPc+2)` diatonic, else `maj6`; minor-family → `min7` if `(rootPc+10)` diatonic, else `add9`; `dom7``sus4` (the 9sus-ish suspension). Same root, so `label` = `NOTES[rootPc] + suffix`.
- **Gate:** the chosen added tone must be in `getScale(root,mode)`; if none qualifies, omit category C rather than add a clashing tone.
- **Circle tie-in: no** — vertical colour, not a circle step.
- **Why template:** `"Add the {intervalName} ({addedNote}) — same {rn}, lusher. {addedNote} is the key's own {degreeWord}, so it stays in the family."`
### D. Secondary dominant of the next chord — *the circle move*, boldest (conditional)
Approach the **next loop chord** by its own `V7` — the circle-of-fifths, dominant-direction pull.
- **Gate:** requires `opts.nextRootPc` (the next station's root pc). Candidate = `{ (nextRootPc + 7) % 12, 'dom7' }`. Emit only when a loop/next chord is known and the candidate root ≠ current root.
- **Circle tie-in: yes** — the candidate root is **one wedge clockwise from the next chord** on the circle (its dominant). Its 3rd is the **leading tone** into the next root.
- **Why template:** `"Swap for {cand}, the V7 of {next} — its 3rd ({leadingTone}) leans a half-step into {next}, pulling the loop around. One step clockwise on the circle."`
> **Honest circle summary:** A and D **are** circle relationships (inner ring; dominant step) — name the circle in their copy. B and C are **not** — never claim the circle for them. We do **not** require the D-61 circle widget on the dashboard; the *why* copy carries the lesson.
---
## 3. Worked examples — `Am C F`, both readings (gate: recompute me)
Loop wraps `Am → C → F → Am`. Target = **F** = `{ rootPc: 5, quality: 'maj' }`, tones `{F=5, A=9, C=0}`. Next chord after F = **Am** (`nextRootPc = 9`).
### Reading (i) — **A minor** (`i · III · ♭VI`) → 3 subs (borrowed iv suppressed)
`A-minor scale = {9,11,0,2,4,5,7}` = A B C D E F G. `getChordsInKey(A,minor) = [Am, B°, C, Dm, Em, F, G]`.
| # | Cat | Candidate (pc) | label | Diatonic check | WHY copy |
|---|-----|----------------|-------|----------------|----------|
| A | relative | (5+9)=**2**, min | **Dm** | Dm ∈ A-min = `iv` ✓ | "Dm is F's relative minor — shares **F & A**. In A minor it's the **iv**: a darker, more grounded step than the bright ♭VI. (Circle: F's inner-ring relative.)" |
| B | borrowed | — | — | mode = minor → **suppressed** | *(not shown — F is ♭VI here, not IV; Fm would be chromatic. Honest omission.)* |
| C | extension | 5, add 11→**E(4)** | **Fmaj7** | E ∈ A-min (the 5th) ✓ | "Add the major 7th (**E**) — ♭VI becomes Fmaj7, dreamy and floating. E is A minor's own 5th, so it stays in the family." |
| D | 2nd-dom | (9+7)=**4**, dom7 | **E7** | leads to Am | "Swap for **E7**, the V7 of Am — its 3rd (**G♯**) leans a half-step into A, pulling the loop back around. One step clockwise on the circle." |
*Arithmetic:* Dm={2,5,9}∩F{5,9,0}={5,9}=F,A ✓. Fmaj7={5,9,0,4}, all ∈ A-min ✓. E7={4,8,11,2}; G♯=8→A=9 ✓; E is a fifth above A (9+7=4) ✓.
### Reading (ii) — **C major** (`vi · I · IV`) → 4 subs (cap)
`C-major scale = {0,2,4,5,7,9,11}` = C D E F G A B. `getChordsInKey(C,major) = [C, Dm, Em, F, G, Am, B°]`.
| # | Cat | Candidate (pc) | label | Diatonic check | WHY copy |
|---|-----|----------------|-------|----------------|----------|
| A | relative | (5+9)=**2**, min | **Dm** | Dm ∈ C-maj = `ii` ✓ | "Dm is F's relative minor — shares **F & A**. In C it's the **ii**: trades IV's brightness for a softer, more forward pull. (Circle: F's inner-ring relative.)" |
| B | borrowed | 5, **min** | **Fm** | mode major **and** F = IV (0+5=5) ✓ | "Borrow **Fm** (the iv) from C minor — lowering A to **A♭** adds that wistful 'Creep' pull home. The classic blue move." |
| C | extension | 5, add 11→**E(4)** | **Fmaj7** | E ∈ C-maj (the 3rd) ✓ | "Add the major 7th (**E**) — same IV, lusher and static. E is C's own 3rd (the mediant), so it glues the chord to the key." |
| D | 2nd-dom | (9+7)=**4**, dom7 | **E7** | leads to Am | "Swap for **E7**, the V7 of Am — its 3rd (**G♯**) leans into A, pulling the loop around. One step clockwise on the circle." |
*Arithmetic:* Fm={5,8,0}; A(9)→A♭(8) ✓; A♭=8=(0+8)=♭6 of C ✓. Everything else as above.
**Payoff:** the *same* candidate chord (Dm, Fmaj7, E7) is right under both readings — only its role-name and *why* change with the mode. Borrowed `Fm` appears **only** under the major reading. That is the honesty the feature promises.
**Bonus — the same rules over the whole loop** (feeds the smoke truth-table): current **Am**→next C ⇒ D = **G7** (V7/C, B→C); current **C**→next F ⇒ D = **C7** (V7/F, E→F — the classic bluesy `I7→IV`). Both musically gold, both from the one rule.
---
## 4. Which chord gets suggestions — the playhead chord
**Decision: one `TryThis` card that follows the playhead — subs for the *currently sounding* chord, updated as the loop turns.**
- Rejected: one static sub for the whole loop (misses the point — the user asked specifically about *F*), and a per-station sub grid across the rail (too dense, collides with the rail — see §6).
- Target selection: `currentChord` when present → fall back to the committed loop's active/first station when silent → else idle. `opts.nextRootPc` = the following loop station's root (so Rule D can fire); when there is no loop, D is simply omitted.
- Keep it tiny: **≤4 chips, one row.** It is a nudge, not a panel.
- *(Optional nicety, not required):* the UI may drop a candidate that is already a loop chord (e.g. relative of Am = C, which is already in `Am C F`) to avoid a redundant suggestion. Engine stays pure; dedup lives in `TryThis`.
---
## 5. The UI surface — new `TryThis.jsx`, left column
A compact card, visually a sibling of `RelatedProgressions`/`CircleOfFifths` (same micro-header + chip language):
```
Try this instead of F · in A minor ← text-[10px] uppercase tracking-widest text-gray-500
[ Dm ] relative minor — softer, same family ← chip + one-line why, per row
[ Fmaj7 ] add the maj7 (E) — dreamy, in-key
[ E7 ] V7 of Am — pulls the loop around ↻
```
- **Chip = tappable**`onChordClick(label)` = App's `setSelectedChord``ChordDetailModal` (the established tap target; reuse verbatim). Each chip is a `<button>`, keyboard-reachable, `focus-visible:ring-2 focus-visible:ring-accent`.
- **Layout:** chip on the left (bold, `text-gray-100`), *why* to the right (`text-[11px] text-gray-400`), category label optional as a faint tag. `Category D` gets a small `↻` glyph hinting the circle (no widget dependency).
- **Tokens only** (`tailwind.config.js`): `bg-panel`, `border-border`, `text-accent`, `text-gray-100/400/500`, `hover:border-accent/50 hover:text-accent`. **No new colour** — nothing to flag to Maestro. Accent-on-panel meets AA (same usage cleared in D-61).
- **Mount — App.jsx only (file-disjoint from the rail/licks/related chains):** compose `TryThis` **above** `RelatedProgressions` inside App's existing `relatedSlot` prop:
```jsx
relatedSlot={
<div className="flex flex-col gap-3">
<TryThis
chord={currentChord} {/* App parses to {rootPc,quality} */}
nextRootPc={/* next station root of the committed loop, or undefined */}
keyInfo={effectiveKey}
onChordClick={setSelectedChord}
/>
<RelatedProgressions loop={detectedProgression} keyInfo={effectiveKey} onChordClick={setSelectedChord} />
</div>
}
```
This edits **App.jsx only** (mount) + the new file — it does **not** touch `JamGuide.jsx` (locked by L-71), `GlanceRail/VoicingBrowser/index.css` (L-70), or `RelatedProgressions.jsx` (L-72). The left column already reflows to one-per-row narrow (JamGuide `order-4` wrapper); `TryThis` inherits that. Glanceable in the ≤4-chip footprint at 1280×900 and stacks cleanly narrow.
---
## 6. Circle-of-fifths tie-in — honest
- **Rule A (relative)** and **Rule D (secondary dominant)** ARE circle relationships — the inner ring and the clockwise/dominant step respectively (both visible in `CircleOfFifths.jsx`). Their *why* copy names the circle.
- **Rule B (borrowed iv)** and **Rule C (extension)** are **not** circle-adjacent — modal/vertical colour. Their copy must **never** invoke the circle.
- We **do not** require the D-61 circle widget on the dashboard (it lives in the Knowledge Center). The lesson travels entirely in the one-line *why*. Optional future polish: a tiny `↻` affordance on circle-derived chips.
---
## 7. Impl scopes (bounded)
### L-73 — engine (`src/lib/theory.js`, additive; `scripts/smoke.mjs`)
- Add `export function suggestSubstitutions({ rootPc, quality }, keyInfo, opts = {})``[{ rootPc, quality, label, why, category }]`, categories in order `relative, borrowed, extension, secondary_dominant`, capped at 4, `[]` when `!keyInfo?.root`. Reuse `NOTES`, `NOTES_FLAT`, `noteName`, `noteIndex`, `CHORD_TYPES`, `getChordsInKey`, `getScale`, `getChordTones`, `intervalName`, `toRomanNumeral` — all **in-module** to theory.js (do **not** import `chordRootPC` from match.js — circular). **Never re-derive** intervals or diatonic sets. `opts.nextRootPc` gates Rule D.
- **Smoke truth-table** in `smoke.mjs` (sabotage-proven, like the `resolveDegree` guard): pin the §3 tables — `F/maj` in **A minor**`[Dm, Fmaj7, E7]` (no Fm); in **C major**`[Dm, Fm, Fmaj7, E7]`; plus `Am/min`→next C ⇒ D=`G7`, `C/maj`→next F ⇒ D=`C7`; and a no-key case → `[]`. Flip one rule constant → smoke must go red.
- **⚠ Sequencing flag for Maestro:** L-73 and **L-72 both lock `scripts/smoke.mjs`** — they cannot be claimed concurrently. Serialize them (L-73 appends a new smoke section to minimise merge friction). `theory.js` is the shared Professor+Luthier file → single-task lock + Critic + non-owning-domain review, per PROTOCOL §3.
### L-74 — UI (`src/components/TryThis.jsx` new; `src/App.jsx` mount)
- Pure/presentational component consuming `suggestSubstitutions`; props `{ chord, nextRootPc?, keyInfo, onChordClick }`; renders header + ≤4 chip/why rows; tap → `onChordClick`. Idle line when the engine returns `[]`.
- Mount per §5 (App composes it into `relatedSlot` above `RelatedProgressions`). **Files disjoint** from L-70/L-71/L-72; App.jsx is unlocked by all three. Audio contract untouched (mount/UI-state only — the App.jsx grep gate applies).
---
## 8. Rejected alternatives (≥2)
1. **Full reharmonisation engine** (chord-scale subs, iiV insertion, Coltrane changes, tritone-on-everything). **Rejected** per the user's explicit *"we dont have to create something too difficult"* + "not a reharm engine." Un-glanceable mid-jam and un-learnable. The curated 4-move set is the whole point.
2. **Blanket tritone sub** (`root+6` dom7 on every chord). **Rejected as a general rule:** the tritone sub is only functionally honest on a *dominant resolving down a fifth*; on a static major IV like `F` it yields `B7` — jarring, no diatonic footing, no *why* that teaches. The **secondary-dominant** move (Rule D) captures the same circle-of-fifths energy but is functionally grounded in the *actual next chord*. (Tritone could return later as a dom7-only opt-in.)
3. **Per-station subs plastered across the voicings rail.** **Rejected:** clutters the glance-critical rail (owned by L-70), 34 chips × N stations is too dense, and it competes with the voicings the user is reading to *play*. One playhead-following card is the nudge.
4. **Show both key readings at once.** **Rejected:** doubles the surface, contradicts "keep it small," and the app already commits to one `effectiveKey` (the mode disambiguates). The user flips the mode dropdown to see the other framing — one reading on screen at a time.
</content>
</invoke>
+145 -5
View File
@@ -1319,7 +1319,7 @@ check('fix (a) live: a clean collapsed 12-bar commit stream detects + matches bl
console.log('\nRelatedProgressions ranking pins (C-50):') console.log('\nRelatedProgressions ranking pins (C-50):')
const relMod = await load('src/components/RelatedProgressions.jsx') const relMod = await load('src/components/RelatedProgressions.jsx')
const { rankRelatedProgressions, collapseChanges } = relMod const { rankRelatedProgressions, collapseChanges, siblingRole } = relMod
const { loopToDegrees, canonicalDegrees } = match const { loopToDegrees, canonicalDegrees } = match
const LOOP_12BAR_A = ['A7', 'D7', 'A7', 'E7', 'D7', 'A7', 'E7'] // detection-collapsed 12-bar const LOOP_12BAR_A = ['A7', 'D7', 'A7', 'E7', 'D7', 'A7', 'E7'] // detection-collapsed 12-bar
@@ -1347,13 +1347,18 @@ check('collapseChanges(blues-minor) pops the wrap-around pair → 5 units [0,5,0
}) })
const rank12 = rankRelatedProgressions(LOOP_12BAR_A) 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"', () => { check('pin (a): collapsed 12-bar → matcher recognizes blues-12bar (fix (a)); it self-excludes, activeStyle=blues, blues-8bar tops the same-style primary at exactly 92, role "shorter form"', () => {
assert(rank12, 'ranking returned null for a parseable loop') assert(rank12, 'ranking returned null for a parseable loop')
// Re-pinned for L-60 fix (a): the collapsed-form index makes the detected // 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 // 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). // from its own related list (§8's pre-fix note foretold exactly this flip).
assert(rank12.match.matched && rank12.match.id === 'blues-12bar', 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`) `match is ${rank12.match.matched ? rank12.match.id : 'NONE'} — fix (a) should make the collapsed 12-bar match blues-12bar`)
// L-72 same-style-first: the match's style is the active style; the panel
// leads with SAME-STYLE ONLY siblings (finding-A → secondary dropped).
assert(rank12.activeStyle === 'blues', `activeStyle is ${rank12.activeStyle}, expected blues`)
assert(rank12.secondary.length === 0, 'secondary is non-empty — finding-A drops cross-style when a style is locked')
assert(rank12.primary.every((e) => e.style === 'blues'), 'a non-blues entry leaked into the same-style primary')
assert(!rank12.entries.some((e) => e.id === 'blues-12bar'), assert(!rank12.entries.some((e) => e.id === 'blues-12bar'),
'blues-12bar leaked into its own related list — the id-only exclusion broke') 'blues-12bar leaked into its own related list — the id-only exclusion broke')
const top = rank12.entries[0] const top = rank12.entries[0]
@@ -1363,21 +1368,30 @@ check('pin (a): collapsed 12-bar → matcher now recognizes blues-12bar (fix (a)
assert(top && top.id === 'blues-8bar' && top.style === 'blues', assert(top && top.id === 'blues-8bar' && top.style === 'blues',
`top entry is ${top?.style}/${top?.id}, expected blues/blues-8bar`) `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.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'`) // L-72 §3: same-mode (major), 8 bars < 12 → role "shorter form" (was the §5
// annotation 'shares I7→V7'). Genuine relationship (shares Δ{5,7,10}), so a
// role IS emitted (finding-B gate passes).
assert(top.role === 'shorter form', `role '${top.role}' ≠ 'shorter form'`)
}) })
const rank251 = rankRelatedProgressions(LOOP_251_C) const rank251 = rankRelatedProgressions(LOOP_251_C)
check('pin (b): iiVI in C → match jazz-251-major (excluded); jazz-251-minor top at exactly 156, "same changes"', () => { check('pin (b): iiVI in C → match jazz-251-major (excluded); activeStyle=jazz, jazz-251-minor top at exactly 156, role "minor version"', () => {
assert(rank251, 'ranking returned null for a parseable loop') assert(rank251, 'ranking returned null for a parseable loop')
assert(rank251.match.matched && rank251.match.id === 'jazz-251-major', 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)`) `match is ${rank251.match.matched ? rank251.match.id : 'NONE'}, expected jazz-251-major (quality-overlap disambiguation)`)
assert(rank251.activeStyle === 'jazz', `activeStyle is ${rank251.activeStyle}, expected jazz`)
assert(rank251.secondary.length === 0, 'secondary is non-empty — finding-A drops cross-style when a style is locked')
assert(rank251.primary.every((e) => e.style === 'jazz'), 'a non-jazz entry leaked into the same-style primary')
assert(!rank251.entries.some((e) => e.id === 'jazz-251-major'), assert(!rank251.entries.some((e) => e.id === 'jazz-251-major'),
'the matched progression leaked into its own related list — the id-only exclusion broke') 'the matched progression leaked into its own related list — the id-only exclusion broke')
const top = rank251.entries[0] const top = rank251.entries[0]
assert(top && top.id === 'jazz-251-minor' && top.style === 'jazz', assert(top && top.id === 'jazz-251-minor' && top.style === 'jazz',
`top entry is ${top?.style}/${top?.id}, expected jazz/jazz-251-minor`) `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.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'`) // L-72 §3: mode differs (minor vs the active major) → role "minor version"
// (was the §5 annotation 'same changes'). Genuine relationship (same changes),
// so the finding-B gate passes even though every quality differs.
assert(top.role === 'minor version', `role '${top.role}' ≠ 'minor version'`)
assert(rank251.entries.length <= 5, `${rank251.entries.length} entries > max 5`) assert(rank251.entries.length <= 5, `${rank251.entries.length} entries > max 5`)
}) })
@@ -1432,6 +1446,132 @@ check('pin (c): no-collapse counterfactual — raw blues-12bar would score exact
assert(rank12.entries[0].score === w8, `replica (${w8}) ≠ live component top score (${rank12.entries[0].score}) — the grounding broke`) assert(rank12.entries[0].score === w8, `replica (${w8}) ≠ live component top score (${rank12.entries[0].score}) — the grounding broke`)
}) })
// L-72 finding-B (Critic gate hardening): the `genuine` gate must SUPPRESS the
// role phrase on a same-style sibling that shares NO transitions with the loop
// and is not same-changes — otherwise we'd print a false "variation" claim.
// A7E7 matches funk; funk-smooth-loop surfaces via the floor-0 same-style rule
// but is a distant sibling (0 shared, not same-changes). siblingRole() ALONE
// would still label it (mode/bars differ) — the assertion below proves the GATE
// is what nulls it. Removing `&& genuine` at the call site turns this red.
check('finding-B: a distant same-style sibling (0 shared transitions, not same-changes) carries NO role, even though siblingRole() alone would label it', () => {
const rf = rankRelatedProgressions(['A7', 'E7'])
assert(rf && rf.activeStyle === 'funk', `activeStyle is ${rf?.activeStyle}, expected funk (A7E7)`)
const activeProg = kb.funk.progressions.find((p) => p.id === rf.match.id)
assert(activeProg, `could not resolve the active funk progression ${rf.match.id}`)
const distant = rf.primary.find((e) => e.id === 'funk-smooth-loop')
assert(distant, 'funk-smooth-loop should surface as a same-style sibling (floor relaxed to 0)')
assert(distant.sameChanges === false, 'precondition: funk-smooth-loop must NOT be same-changes vs A7E7')
assert(siblingRole(distant.progression, activeProg) !== null,
'precondition: siblingRole() ALONE would label funk-smooth-loop — the genuine gate is what must suppress it')
assert(distant.role === null,
`finding-B breached: distant sibling carries role '${distant.role}' — the \`genuine\` gate was dropped (false variation claim)`)
})
// L-72 finding-A / no-match fallback (Critic gate hardening, design §6): when the
// loop matches NO KB progression, activeStyle is null and the panel is the
// pre-L-72 flat cross-style list — secondary always empty, cross-style rows keep
// their §5 annotation, and NO role phrases appear. This 8-chord A blues loop is
// not a canonical collapsed form, so it stays unmatched while still drawing
// cross-style relatives. Bites if the null path grows an activeStyle default or
// leaks roles onto cross-style rows.
check('no-match fallback: unmatched loop → activeStyle null, secondary empty, flat cross-style list with annotations (no roles)', () => {
const rNo = rankRelatedProgressions(['A7', 'D7', 'A7', 'A7', 'E7', 'D7', 'A7', 'E7'])
assert(rNo, 'ranking returned null for a parseable loop')
assert(rNo.match.matched === false, `precondition: expected NO match, got ${rNo.match.id}`)
assert(rNo.activeStyle === null, `no-match: activeStyle must be null, got ${rNo.activeStyle}`)
assert(rNo.secondary.length === 0, 'no-match: secondary must be empty')
assert(rNo.primary.length > 0, 'precondition: expected cross-style relatives above the floor')
assert(rNo.primary.every((e) => e.role === null), 'no-match: cross-style rows must carry NO role phrase')
assert(rNo.primary.every((e) => typeof e.annotation === 'string' && e.annotation.length > 0),
'no-match: cross-style rows must keep their §5 annotation')
})
// ─── 8b. "Try this" substitution engine truth-table (task L-73) ───────────────
//
// Pins docs/design/try-this-subs.md §3 (the AmCF worked tables) against the
// REAL theory.suggestSubstitutions. Asserts the FULL ordered candidate list
// (label + category), not just counts — so flipping any rule constant (e.g. the
// relative offset 9→8, the borrowed-iv +5 gate, the 2nd-dom +7) turns this red
// and names it. Categories order: relative · borrowed · extension · 2nd-dom, cap 4.
console.log('\n"Try this" substitution engine (§3 truth-tables):')
const { suggestSubstitutions } = theory
// Compact "Label[category]" projection — the shape the pins compare.
const subShape = (arr) => arr.map((s) => `${s.label}[${s.category}]`)
const F_MAJ = { rootPc: 5, quality: 'maj' } // the F under the playhead
const AM_MIN = { rootPc: 9, quality: 'min' }
const C_MAJ = { rootPc: 0, quality: 'maj' }
check('F/maj in A MINOR (next Am) → [Dm(rel), Fmaj7(ext), E7(2nd-dom)] — borrowed iv SUPPRESSED', () => {
const got = subShape(suggestSubstitutions(F_MAJ, { root: 'A', mode: 'minor' }, { nextRootPc: 9 }))
const want = ['Dm[relative]', 'Fmaj7[extension]', 'E7[secondary_dominant]']
assert(JSON.stringify(got) === JSON.stringify(want), `got ${JSON.stringify(got)}`)
assert(!got.some((s) => s.startsWith('Fm[')), 'Fm must NOT appear under a minor reading (F is ♭VI, not IV)')
})
check('F/maj in C MAJOR (next Am) → [Dm, Fm(borrowed), Fmaj7, E7] — cap 4', () => {
const got = subShape(suggestSubstitutions(F_MAJ, { root: 'C', mode: 'major' }, { nextRootPc: 9 }))
const want = ['Dm[relative]', 'Fm[borrowed]', 'Fmaj7[extension]', 'E7[secondary_dominant]']
assert(JSON.stringify(got) === JSON.stringify(want), `got ${JSON.stringify(got)}`)
})
check('Rule D: Am/min → next C(0) yields G7 (V7 of C)', () => {
const got = subShape(suggestSubstitutions(AM_MIN, { root: 'C', mode: 'major' }, { nextRootPc: 0 }))
const want = ['C[relative]', 'Am7[extension]', 'G7[secondary_dominant]']
assert(JSON.stringify(got) === JSON.stringify(want), `got ${JSON.stringify(got)}`)
})
check('Rule D: C/maj → next F(5) yields C7 (V7 of F — the bluesy I7→IV, same-root but different chord)', () => {
const got = subShape(suggestSubstitutions(C_MAJ, { root: 'C', mode: 'major' }, { nextRootPc: 5 }))
const want = ['Am[relative]', 'Cmaj7[extension]', 'C7[secondary_dominant]']
assert(JSON.stringify(got) === JSON.stringify(want), `got ${JSON.stringify(got)}`)
})
check('borrowed-iv why spells the ♭6 FLAT (A→Ab), never the sharp G#', () => {
const subs = suggestSubstitutions(F_MAJ, { root: 'C', mode: 'major' }, { nextRootPc: 9 })
const fm = subs.find((s) => s.category === 'borrowed')
assert(fm && /A→Ab/.test(fm.why), `borrowed why must contain the flat A→Ab, got: ${fm && fm.why}`)
assert(fm && !/G#/.test(fm.why), 'borrowed why must not spell the ♭6 as the sharp G#')
})
check('extension why names the added tone by scale degree ("E is C\'s 3rd (the mediant)")', () => {
const subs = suggestSubstitutions(F_MAJ, { root: 'C', mode: 'major' }, { nextRootPc: 9 })
const ext = subs.find((s) => s.category === 'extension')
assert(ext && /3rd \(the mediant\)/.test(ext.why), `extension why: ${ext && ext.why}`)
})
check('Rule C never re-emits the input chord: Dm7 in C major → [F(rel)] (min7 extension self-skipped, NO add9-on-minor)', () => {
const chord = { rootPc: 2, quality: 'min7' }
const subs = suggestSubstitutions(chord, { root: 'C', mode: 'major' }, {})
assert(JSON.stringify(subShape(subs)) === JSON.stringify(['F[relative]']), `got ${JSON.stringify(subShape(subs))}`)
assert(!subs.some((s) => s.category === 'extension'), 'extension must be OMITTED when the only fit is the chord already sounding')
assert(!subs.some((s) => s.rootPc === chord.rootPc && s.quality === chord.quality),
'no suggestion may equal the input chord {rootPc,quality}')
assert(!subs.some((s) => s.quality === 'add9'), 'a MAJOR add9 must never be suggested for a minor-family chord (would raise the 3rd)')
})
check('Rule C on a 7th input adds a DIFFERENT colour: Cmaj7 in C major → [Am(rel), Cadd9(ext)] (not Cmaj7)', () => {
const chord = { rootPc: 0, quality: 'maj7' }
const subs = suggestSubstitutions(chord, { root: 'C', mode: 'major' }, {})
assert(JSON.stringify(subShape(subs)) === JSON.stringify(['Am[relative]', 'Cadd9[extension]']), `got ${JSON.stringify(subShape(subs))}`)
const ext = subs.find((s) => s.category === 'extension')
assert(ext && !(ext.rootPc === chord.rootPc && ext.quality === chord.quality),
'the extension must not be the input chord itself')
})
check('no key (!keyInfo.root) → [] (idle, never a fabricated suggestion)', () => {
assert(JSON.stringify(suggestSubstitutions(F_MAJ, {}, {})) === '[]', 'expected [] with empty keyInfo')
assert(JSON.stringify(suggestSubstitutions(F_MAJ, null, {})) === '[]', 'expected [] with null keyInfo')
})
check('Rule D omitted when no next chord is known (opts.nextRootPc absent)', () => {
const got = subShape(suggestSubstitutions(F_MAJ, { root: 'C', mode: 'major' }, {}))
assert(!got.some((s) => s.endsWith('[secondary_dominant]')), `2nd-dom must be absent, got ${JSON.stringify(got)}`)
})
// ─── 9. Summary + exit code ─────────────────────────────────────────────────── // ─── 9. Summary + exit code ───────────────────────────────────────────────────
const total = passed + failures.length const total = passed + failures.length
+14 -5
View File
@@ -11,6 +11,7 @@ import DrumView from './components/DrumView'
import { NOTES, detectKey, detectTopKeys, matchChordFromChroma, detectRepeatingProgression, getChordTones, getChordCandidates, getNoteHistoryAnalysis } from './lib/theory' import { NOTES, detectKey, detectTopKeys, matchChordFromChroma, detectRepeatingProgression, getChordTones, getChordCandidates, getNoteHistoryAnalysis } from './lib/theory'
import ChordDetailModal from './components/ChordDetailModal' import ChordDetailModal from './components/ChordDetailModal'
import RelatedProgressions from './components/RelatedProgressions' import RelatedProgressions from './components/RelatedProgressions'
import TryThis from './components/TryThis'
import LoopStation from './components/LoopStation' import LoopStation from './components/LoopStation'
import JamGuide, { KnowledgeDock } from './components/JamGuide' import JamGuide, { KnowledgeDock } from './components/JamGuide'
import { useLoopEngine } from './services/loopEngine' import { useLoopEngine } from './services/loopEngine'
@@ -854,11 +855,19 @@ export default function App() {
</> </>
} }
relatedSlot={ relatedSlot={
<RelatedProgressions <div className="flex flex-col gap-3">
loop={detectedProgression} <TryThis
keyInfo={effectiveKey} loop={detectedProgression}
onChordClick={setSelectedChord} keyInfo={effectiveKey}
/> currentChord={currentChord}
onChordClick={setSelectedChord}
/>
<RelatedProgressions
loop={detectedProgression}
keyInfo={effectiveKey}
onChordClick={setSelectedChord}
/>
</div>
} }
fill={jamView} fill={jamView}
/> />
+158 -32
View File
@@ -137,13 +137,69 @@ function degreeSetJaccard(aCanon, bCanon) {
return union ? inter / union : 0 return union ? inter / union : 0
} }
// Summed bar count of a progression (the total form length), or null when the
// KB entry carries no `bars` array. Used only by siblingRole (§3 rules 24).
function totalBars(prog) {
const bars = Array.isArray(prog?.bars) ? prog.bars : null
if (!bars) return null
return bars.reduce((sum, n) => sum + (typeof n === 'number' ? n : 0), 0)
}
/** /**
* rankRelatedProgressions(loop, kbRegistry?) { match, entries } | null * siblingRole(sibling, active) role phrase | null (same-style-first §3)
* *
* The §5 ranking. Returns null when the loop yields no degrees (no loop / * A short character phrase framing a same-style `sibling` against the `active`
* unparseable chord names) the caller renders the idle state. `entries` is * (matched) progression, derived ONLY from KB `mode` / `bars` / `qualities`.
* score-desc (stable by style, id), floored at RELATED_SCORE_FLOOR, at most * First rule that fires:
* RELATED_MAX_ENTRIES, never padded. Exported for smoke coverage. * 1. mode differs "{mode} version" (minor/major, else the name)
* 2. same mode, fewer bars "shorter form"
* 3. same mode, more bars "extended form"
* 4. same mode & length, a quality the active lacks "reharmonized"
* 5. otherwise null (honest: name + level only)
* Returns null when `active` is unresolved. The finding-B gate (a sibling that
* shares NO genuine relationship with the played loop) is applied at the call
* site see the `genuine` guard in rankRelatedProgressions.
*/
export function siblingRole(sibling, active) {
if (!sibling || !active) return null
const sMode = sibling.mode ?? null
const aMode = active.mode ?? null
// 1. mode differs the mode-flavoured version.
if (sMode && aMode && sMode !== aMode) {
if (sMode === 'minor') return 'minor version'
if (sMode === 'major') return 'major version'
return `${sMode} version`
}
// 24 only compare within a shared mode (or when both modes are absent).
if (sMode !== aMode) return null
const sBars = totalBars(sibling)
const aBars = totalBars(active)
if (sBars != null && aBars != null) {
if (sBars < aBars) return 'shorter form'
if (sBars > aBars) return 'extended form'
}
// 4. same mode & length: a colour the active progression lacks.
const aQ = new Set(Array.isArray(active.qualities) ? active.qualities : [])
const sQ = Array.isArray(sibling.qualities) ? sibling.qualities : []
if (aQ.size && sQ.some(q => !aQ.has(q))) return 'reharmonized'
return null
}
/**
* rankRelatedProgressions(loop, kbRegistry?)
* { match, activeStyle, activeStyleLabel, primary, secondary, entries } | null
*
* The §5 ranking, extended for same-style-first (L-72, docs/design/related-
* same-style.md). Returns null when the loop yields no degrees. When the loop
* matches a KB progression, `activeStyle = match.style` and the panel leads with
* that style's OTHER progressions (same-style siblings) floor relaxed to 0,
* scorer order kept, each carrying a `role` phrase (siblingRole, §3). Per the
* Maestro finding-A resolution, when a style is locked the panel shows SAME-STYLE
* ONLY (`secondary` stays empty no cross-style section). When `!match.matched`
* (`activeStyle == null`) the pre-existing cross-style flat list is returned
* unchanged (floor RELATED_SCORE_FLOOR, annotations). `entries` =
* `primary.concat(secondary)` for back-compat with `entries[0]` reads.
* Exported for smoke coverage.
*/ */
export function rankRelatedProgressions(loop, kbRegistry = kb) { export function rankRelatedProgressions(loop, kbRegistry = kb) {
const loopDeg = loopToDegrees(loop) const loopDeg = loopToDegrees(loop)
@@ -153,6 +209,17 @@ export function rankRelatedProgressions(loop, kbRegistry = kb) {
const match = matchLoopToProgression(loop, index) const match = matchLoopToProgression(loop, index)
const loopT = transitionsOf(loopUnitsOf(loop, loopDeg)) const loopT = transitionsOf(loopUnitsOf(loop, loopDeg))
// §1: the active style IS the component's own match (roulette seed + live
// detection both route through the L-60 collapsed index). No prop needed.
const activeStyle = match.matched ? match.style : null
const activeStyleLabel = activeStyle
? kbRegistry[activeStyle]?.meta?.label ?? activeStyle
: null
// The raw active KB entry authoritative mode/bars for siblingRole (§3).
const activeProg = activeStyle
? kbRegistry[activeStyle]?.progressions?.find(p => p.id === match.id) ?? null
: null
const entries = [] const entries = []
for (const style of Object.keys(kbRegistry)) { for (const style of Object.keys(kbRegistry)) {
const styleLabel = kbRegistry[style]?.meta?.label ?? style const styleLabel = kbRegistry[style]?.meta?.label ?? style
@@ -166,7 +233,7 @@ export function rankRelatedProgressions(loop, kbRegistry = kb) {
const pCanon = canonicalDegrees(collapsed.map(u => u.deg)) const pCanon = canonicalDegrees(collapsed.map(u => u.deg))
const shared = sharedTransitions(loopT, transitionsOf(collapsed)) const shared = sharedTransitions(loopT, transitionsOf(collapsed))
const sameChanges = pCanon === loopCanon const sameChanges = pCanon === loopCanon
const sameStyle = match.matched && style === match.style const sameStyle = activeStyle != null && style === activeStyle
let score = 0 let score = 0
if (sameChanges) score += SCORE_SAME_CHANGES if (sameChanges) score += SCORE_SAME_CHANGES
@@ -174,16 +241,26 @@ export function rankRelatedProgressions(loop, kbRegistry = kb) {
score += Math.min(shared.length * SCORE_PER_TRANSITION, TRANSITION_CAP) score += Math.min(shared.length * SCORE_PER_TRANSITION, TRANSITION_CAP)
score += degreeSetJaccard(loopCanon, pCanon) * SCORE_JACCARD score += degreeSetJaccard(loopCanon, pCanon) * SCORE_JACCARD
score -= Math.abs(loop.length - collapsed.length) score -= Math.abs(loop.length - collapsed.length)
if (score < RELATED_SCORE_FLOOR) continue // never pad with junk // §2/§4: same-style siblings bypass the floor (never "junk" they are the
// "other options" the user asked for); cross-style keeps the floor.
const floor = sameStyle ? 0 : RELATED_SCORE_FLOOR
if (score < floor) continue
// §5 annotation: why this entry is here (survivors always have one // §5 annotation (cross-style rows): why this entry is here.
// below the floor nothing scores on Jaccard length alone).
const annotation = sameChanges const annotation = sameChanges
? 'same changes' ? 'same changes'
: shared.length : shared.length
? `shares ${shared[0].rnFrom || '?'}${shared[0].rnTo || '?'}` ? `shares ${shared[0].rnFrom || '?'}${shared[0].rnTo || '?'}`
: 'same style' : 'same style'
// §3 role (same-style rows only). finding-B gate: emit a phrase only when
// there is a genuine relationship to the played loop identical changes
// OR at least one shared (Δ, qualityquality) transition. A distantly-
// related same-style sibling (neither) gets no false "extended form" /
// "reharmonized" label just its name + level.
const genuine = sameChanges || shared.length > 0
const role = sameStyle && genuine ? siblingRole(prog, activeProg) : null
entries.push({ entries.push({
style, style,
styleLabel, styleLabel,
@@ -191,7 +268,10 @@ export function rankRelatedProgressions(loop, kbRegistry = kb) {
name: prog.name, name: prog.name,
level: prog.level === 'intermediate' ? 'intermediate' : 'foundation', // untagged counts foundation level: prog.level === 'intermediate' ? 'intermediate' : 'foundation', // untagged counts foundation
score, score,
sameChanges,
sameStyle,
annotation, annotation,
role,
progression: prog, progression: prog,
}) })
} }
@@ -200,7 +280,25 @@ export function rankRelatedProgressions(loop, kbRegistry = kb) {
entries.sort( entries.sort(
(a, b) => b.score - a.score || a.style.localeCompare(b.style) || a.id.localeCompare(b.id) (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) }
// §2 + finding-A: with a style locked, primary = same-style siblings only
// (cap 5), secondary dropped. Without a lock, the flat cross-style list.
let primary, secondary
if (activeStyle != null) {
primary = entries.filter(e => e.sameStyle).slice(0, RELATED_MAX_ENTRIES)
secondary = [] // finding-A: no cross-style section when a style is locked
} else {
primary = entries.slice(0, RELATED_MAX_ENTRIES)
secondary = []
}
return {
match,
activeStyle,
activeStyleLabel,
primary,
secondary,
entries: primary.concat(secondary),
}
} }
// Presentational bits // Presentational bits
@@ -283,6 +381,8 @@ export default function RelatedProgressions({ loop, keyInfo, onChordClick }) {
) )
} }
const { activeStyle, activeStyleLabel, primary } = ranked
return ( return (
<section <section
className="rounded-2xl border border-border bg-panel p-3" className="rounded-2xl border border-border bg-panel p-3"
@@ -292,29 +392,55 @@ export default function RelatedProgressions({ loop, keyInfo, onChordClick }) {
Related progressions · from the songbook Related progressions · from the songbook
{keyInfo?.root ? ` · in ${keyInfo.root}` : ''} {keyInfo?.root ? ` · in ${keyInfo.root}` : ''}
</h4> </h4>
{ranked.entries.length === 0 ? ( {primary.length === 0 ? (
// Loop, but nothing clears the floor honest, never padded (§5). activeStyle != null ? (
<p className="text-sm text-gray-500"> // Locked to a style with no siblings (§5 edge). Honest, never padded.
Nothing in the songbook genuinely relates to this loop yet. <p className="text-sm text-gray-500">
</p> You&rsquo;re on the only {activeStyleLabel} loop in the songbook.
</p>
) : (
// 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 => ( {/* §2/§3: when a style is locked, lead with its OTHER progressions
<li key={`${entry.style}-${entry.id}`} className="min-w-0"> reframed as variations to try same-style only (finding-A). */}
<div className="flex flex-wrap items-baseline gap-x-1.5 gap-y-0.5"> {activeStyle != null && (
<span className="text-sm font-semibold text-gray-100">{entry.name}</span> <p className="mb-2 text-xs font-medium text-gray-300">
<span className="text-xs text-gray-500">{entry.styleLabel}</span> Try these in {activeStyleLabel}
<LevelBadge level={entry.level} /> </p>
<span className="text-[10px] text-gray-500">{entry.annotation}</span> )}
</div> <ul className="flex flex-col gap-2.5">
<ChordChain {primary.map(entry => (
progression={entry.progression} <li key={`${entry.style}-${entry.id}`} className="min-w-0">
keyRoot={keyRoot} <div className="flex flex-wrap items-baseline gap-x-1.5 gap-y-0.5">
onChordClick={onChordClick} <span className="text-sm font-semibold text-gray-100">{entry.name}</span>
/> {/* Same-style rows share the header's style hide the redundant
</li> label; cross-style rows keep it. */}
))} {activeStyle == null && (
</ul> <span className="text-xs text-gray-500">{entry.styleLabel}</span>
)}
<LevelBadge level={entry.level} />
{/* Same-style rows show the role phrase (omitted when null,
finding-B); cross-style rows keep the §5 annotation. */}
{activeStyle != null
? entry.role && (
<span className="text-[10px] text-gray-500">{entry.role}</span>
)
: <span className="text-[10px] text-gray-500">{entry.annotation}</span>}
</div>
<ChordChain
progression={entry.progression}
keyRoot={keyRoot}
onChordClick={onChordClick}
/>
</li>
))}
</ul>
</>
)} )}
</section> </section>
) )
+160
View File
@@ -0,0 +1,160 @@
import { useEffect, useRef, useState } from 'react'
import { CHORD_TYPES, suggestSubstitutions } from '../lib/theory'
import { chordRootPC } from '../lib/match'
// TryThis the rotating "Try this" substitution nudge (task L-74)
//
// For the chord under the playhead, in the detected key, show ONE curated
// substitution at a time (from theory.js `suggestSubstitutions`, the L-73 engine)
// and CYCLE to the next valid idea each time the loop completes a pass. The user
// asked for something "more surprising, more jam-like, keeps offering new ideas"
// (2026-07-13) so a single nudge that rotates, not a static 4-chip grid.
// Spec: docs/design/try-this-subs.md §4/§5 (superseded to rotation by L-74's
// ledger row). Sibling of RelatedProgressions; same micro-header + chip idiom.
//
// Props:
// loop : string[] | null the detected repeating progression (chord names)
// keyInfo : { root, mode, confidence } | null effective key (frames the why)
// currentChord : string the chord sounding NOW ("F", "Dm7")
// onChordClick : fn(label) opens ChordDetailModal (App's setSelectedChord)
// Invert CHORD_TYPES suffix quality the app idiom (mirrors
// RelatedProgressions' SUFFIX_TO_QUALITY). All 14 suffixes are unique.
const SUFFIX_TO_QUALITY = Object.fromEntries(
Object.entries(CHORD_TYPES).map(([quality, def]) => [def.suffix, quality])
)
// Parse a chord-name string { rootPc, quality } (a CHORD_TYPES key) using the
// established helpers chordRootPC for the root pc, the CHORD_TYPES suffix
// inversion for the quality. Never re-derived. Returns null when unparseable.
export function parseChordName(name) {
if (typeof name !== 'string') return null
const rootPc = chordRootPC(name)
if (rootPc < 0) return null
const m = name.match(/^[A-G][b#]?(.*)$/)
const quality = m ? SUFFIX_TO_QUALITY[m[1]] : undefined
if (!quality) return null
return { rootPc, quality }
}
// A loop pass completes when the playhead position wraps from a later station
// back toward 0 i.e. the new position is lower than the previous one. On that
// wrap, advance the rotation by one idea. Pure + exported so the rotation can be
// proven independently of React effect timing.
export function advanceOnWrap(cycle, prevPos, pos) {
if (pos >= 0 && prevPos != null && pos < prevPos) return cycle + 1
return cycle
}
// Pick the sub shown for a given monotonic cycle counter (softest-first order
// preserved; modulo keeps it in range as the chord and its sub set changes).
export function pickSub(subs, cycle) {
if (!subs || !subs.length) return null
const total = subs.length
const idx = ((cycle % total) + total) % total
return { sub: subs[idx], idx, total }
}
// Circle-of-fifths categories (relative = inner ring, secondary_dominant =
// clockwise step). Only these earn the glyph borrowed/extension are modal /
// vertical colour and must NOT claim the circle (docs §6).
const CIRCLE_CATEGORIES = new Set(['relative', 'secondary_dominant'])
const CATEGORY_TAG = {
relative: 'relative',
borrowed: 'borrowed',
extension: 'colour',
secondary_dominant: 'V7',
}
export default function TryThis({ loop, keyInfo, currentChord, onChordClick }) {
const [cycle, setCycle] = useState(0)
const lastPosRef = useRef(null)
const lastChordRef = useRef(null)
const target = parseChordName(currentChord)
const loopArr = Array.isArray(loop) && loop.length ? loop : null
const position = loopArr && target ? loopArr.indexOf(currentChord) : -1
// Next station's root pc gates Rule D (secondary dominant of the next chord).
let nextRootPc
if (loopArr && position >= 0) {
const pc = chordRootPC(loopArr[(position + 1) % loopArr.length])
if (pc >= 0) nextRootPc = pc
}
const subs = target ? suggestSubstitutions(target, keyInfo, { nextRootPc }) : []
// Rotation: advance one idea each loop pass (playhead position wraps toward 0).
// With no loop (position 1), advance on each genuine currentChord change so
// the nudge still refreshes as the player moves. Detection watches the previous
// position/chord across renders via refs (no side effects during render).
useEffect(() => {
const prevPos = lastPosRef.current
const prevChord = lastChordRef.current
lastPosRef.current = position
lastChordRef.current = currentChord
if (position >= 0) {
setCycle(c => advanceOnWrap(c, prevPos, position))
} else if (prevChord != null && prevChord !== currentChord) {
setCycle(c => c + 1)
}
}, [position, currentChord])
// 0 subs no card (no key, atonal, or a chord with no honest sub).
const picked = pickSub(subs, cycle)
if (!picked) return null
const { sub, idx, total } = picked
const showIndicator = total > 1
const isCircle = CIRCLE_CATEGORIES.has(sub.category)
const tag = CATEGORY_TAG[sub.category] ?? sub.category
return (
<section
className="rounded-2xl border border-border bg-panel p-3"
aria-label={`Try this instead of ${currentChord}`}
>
<h4 className="mb-2 flex items-baseline justify-between gap-2 text-[10px] font-semibold uppercase tracking-widest text-gray-500">
<span>
Try this instead of {currentChord}
{keyInfo?.root ? ` · in ${keyInfo.root} ${keyInfo.mode ?? 'major'}` : ''}
</span>
{showIndicator && (
<span className="shrink-0 normal-case tracking-normal text-gray-500">
{idx + 1} of {total}
</span>
)}
</h4>
<div className="flex items-baseline gap-2">
<button
type="button"
onClick={() => onChordClick?.(sub.label)}
aria-label={`${sub.label}${sub.why}`}
className="shrink-0 rounded-lg border border-border bg-border px-2 py-1 text-sm font-bold leading-none text-gray-100 outline-none transition-all cursor-pointer hover:border-accent/50 hover:text-accent focus-visible:ring-2 focus-visible:ring-accent"
>
{sub.label}
</button>
<p className="min-w-0 text-[11px] leading-snug text-gray-400">
<span className="mr-1 align-baseline text-[9px] uppercase tracking-wide text-gray-500">
{tag}
{isCircle && <span className="ml-0.5 text-accent" aria-hidden="true"></span>}
</span>
{sub.why}
</p>
</div>
{showIndicator && (
<div className="mt-2 flex items-center gap-1" aria-hidden="true">
{subs.map((_, i) => (
<span
key={i}
className={`h-1 w-1 rounded-full ${i === idx ? 'bg-accent' : 'bg-border'}`}
/>
))}
</div>
)}
</section>
)
}
+170
View File
@@ -746,3 +746,173 @@ export function transposeChord(chordName, semitones) {
export function transposeProgression(chords, semitones) { export function transposeProgression(chords, semitones) {
return chords.map(c => transposeChord(c, semitones)) return chords.map(c => transposeChord(c, semitones))
} }
// ─── "Try this" — key-aware substitution nudge (task L-73) ────────────────────
//
// Curated, learnable chord-substitution engine for the Jam Guide dashboard.
// Spec: docs/design/try-this-subs.md §2 (the 4 category rules) + §3 (the worked
// AmCF truth-tables). For the chord under the playhead, in the detected key,
// returns up to 4 alternatives — each with one plain sentence that teaches WHY
// it works. This is NOT the context-free colour-swap grid in education.js; this
// one is key-aware and changes the root (relative / secondary dominant).
//
// All-in-module: reuses NOTES, NOTES_FLAT, noteName, noteIndex, CHORD_TYPES,
// getChordsInKey, getScale, intervalName, toRomanNumeral, chordTonePcs. It does
// NOT import match.js's chordRootPC (that would be circular) — it uses theory's
// own noteIndex(keyInfo.root) for the key root pc.
// Chord-quality families the rules branch on.
const SUB_MAJOR_FAMILY = ['maj', 'maj7', 'maj6', 'add9']
const SUB_MINOR_FAMILY = ['min', 'min7', 'min6']
// Key modes with a major tonic ("major-ish") — the only readings under which the
// borrowed-iv (Rule B) is honest. A minor/dorian/phrygian reading suppresses it.
const SUB_MAJORISH_MODES = ['major', 'lydian', 'mixolydian']
// Scale-degree vocabulary for the extension why-copy ("E is C's 3rd (the mediant)").
const DEGREE_ORDINALS = ['root', '2nd', '3rd', '4th', '5th', '6th', '7th']
const DEGREE_NAMES = ['tonic', 'supertonic', 'mediant', 'subdominant', 'dominant', 'submediant', 'leading tone']
// Name a pitch class by its scale degree in the key, e.g. "3rd (the mediant)".
// Returns null if the pc is not diatonic (Rule C never calls it off-scale).
function subDegreeWord(pc, keyRootPc, mode) {
const scale = SCALES[mode] ?? SCALES.major
const semi = ((((pc - keyRootPc) % 12) + 12) % 12)
const idx = scale.indexOf(semi)
if (idx < 0) return null
return `${DEGREE_ORDINALS[idx]} (the ${DEGREE_NAMES[idx]})`
}
/**
* suggestSubstitutions({ rootPc, quality }, keyInfo, opts = {})
* [{ rootPc, quality, label, why, category }]
*
* Categories, always in this order (softest boldest), capped at 4:
* relative · borrowed · extension · secondary_dominant
* Returns [] when there is no key (`!keyInfo?.root`) the UI shows an idle line.
*
* `label = NOTES[rootPc] + CHORD_TYPES[quality].suffix`. `opts.nextRootPc` (the
* next loop station's root pc) gates Rule D. See docs/design/try-this-subs.md §2.
*
* Sanity (AmCF loop, §3):
* F/maj (5) in A minor, next Am (9) [Dm(rel), Fmaj7(ext), E7(2nd-dom)] (no Fm)
* F/maj (5) in C major, next Am (9) [Dm, Fm(borrowed), Fmaj7, E7] (cap 4)
* Am/min(9) next C (0): Rule D = G7 ; C/maj(0) next F (5): Rule D = C7
*/
export function suggestSubstitutions({ rootPc, quality } = {}, keyInfo, opts = {}) {
if (!keyInfo?.root) return []
const keyRootPc = noteIndex(keyInfo.root)
if (keyRootPc < 0 || rootPc == null || quality == null) return []
const mode = keyInfo.mode ?? 'major'
const r = ((rootPc % 12) + 12) % 12
const origLabel = NOTES[r] + (CHORD_TYPES[quality]?.suffix ?? '')
const isMajorFam = SUB_MAJOR_FAMILY.includes(quality)
const isMinorFam = SUB_MINOR_FAMILY.includes(quality)
// Diatonic chord-name set + scale pitch-class set for the gates.
const diatonic = new Set(getChordsInKey(keyInfo.root, mode))
const scaleInts = SCALES[mode] ?? SCALES.major
const scalePcs = new Set(scaleInts.map(i => (keyRootPc + i) % 12))
const mk = (candRootPc, candQuality, why, category) => {
const pc = ((candRootPc % 12) + 12) % 12
return {
rootPc: pc,
quality: candQuality,
label: NOTES[pc] + (CHORD_TYPES[candQuality]?.suffix ?? ''),
why,
category,
}
}
const out = []
// ── Rule A — relative / diatonic-third sub (softest). Circle: inner ring. ──
// major-family → relative minor (root+9); minor-family → relative major (root+3).
// Emit only if the candidate is diatonic in the key.
{
let candRootPc = null, candQuality = null
if (isMajorFam) { candRootPc = (r + 9) % 12; candQuality = 'min' }
else if (isMinorFam) { candRootPc = (r + 3) % 12; candQuality = 'maj' }
if (candRootPc !== null) {
const label = NOTES[candRootPc] + (CHORD_TYPES[candQuality].suffix)
if (diatonic.has(label)) {
// Shared tones = intersection of the two triads (root & 3rd of the original).
const origTones = new Set(chordTonePcs(r, quality))
const shared = chordTonePcs(candRootPc, candQuality).filter(t => origTones.has(t))
const sharedNames = shared.map(t => noteName(t)).join(' & ')
const relWord = isMajorFam ? 'minor' : 'major'
const pull = isMajorFam ? 'softer' : 'brighter'
const rn = toRomanNumeral(label, keyInfo.root, mode)
out.push(mk(candRootPc, candQuality,
`${label} is ${origLabel}'s relative ${relWord} — shares ${sharedNames}. `
+ `In this key it's the ${rn}: a ${pull} pull, same family. `
+ `(Circle: its inner-ring relative.)`,
'relative'))
}
}
}
// ── Rule B — borrowed iv (the "Creep" move), conditional. NOT a circle step. ──
// Only under a major-ish reading, on the IV (root === keyRoot+5), major-family.
if (isMajorFam && SUB_MAJORISH_MODES.includes(mode) && r === (keyRootPc + 5) % 12) {
const n6 = noteName(keyRootPc + 9) // natural 6th of the key
const nb6 = noteName(keyRootPc + 8, true) // ♭6 — spelled FLAT (A→A♭, never G#)
out.push(mk(r, 'min',
`Borrow ${NOTES[r]}m (the iv) from the parallel minor — ${n6}${nb6} adds that `
+ `wistful pull home. The 'Creep' move.`,
'borrowed'))
}
// ── Rule C — extension / colour (same function, one diatonic colour tone). ──
// Vertical colour, NOT a circle step. Pick the first extension whose added tone
// is diatonic; omit the category if none qualifies.
//
// Two guards keep the suggestion an honest ALTERNATIVE, not the chord already
// sounding (the common case — jazz stations are min7/maj7/dom7):
// (a) skip any candidate whose quality === the input quality — the chord
// already carries that colour (a min7 input never re-emits min7).
// (b) minor-family offers ONLY min7. CHORD_TYPES.add9 = [0,2,4,7] is a MAJOR
// add9 (interval 4 = major 3rd), so applying it to a minor chord would
// raise the 3rd (Dm → D, F♮→F♯) = a wrong-note suggestion. Never do it.
{
let opts2 = null
if (isMajorFam) opts2 = [['maj7', 11], ['add9', 2], ['maj6', 9]]
else if (isMinorFam) opts2 = [['min7', 10]]
else if (quality === 'dom7') opts2 = [['sus4', 5]]
if (opts2) {
const pick = opts2.find(([q, interval]) =>
q !== quality && scalePcs.has((r + interval) % 12))
if (pick) {
const [extQuality, interval] = pick
const addedPc = (r + interval) % 12
const addedNote = noteName(addedPc)
const rn = toRomanNumeral(origLabel, keyInfo.root, mode)
const dw = subDegreeWord(addedPc, keyRootPc, mode)
out.push(mk(r, extQuality,
`Add the ${intervalName(interval).toLowerCase()} (${addedNote}) — same ${rn}, lusher. `
+ `${addedNote} is ${keyInfo.root}'s own ${dw}, so it stays in the family.`,
'extension'))
}
}
}
// ── Rule D — secondary dominant of the next chord (boldest). Circle move. ──
// V7 of the next loop chord = (nextRootPc+7) dom7. Requires a known next chord;
// omit when the candidate is the identical chord already sounding.
if (opts.nextRootPc != null) {
const nextPc = ((opts.nextRootPc % 12) + 12) % 12
const candRootPc = (nextPc + 7) % 12
const isSameChord = candRootPc === r && quality === 'dom7'
if (!isSameChord) {
const leadingTone = noteName(candRootPc + 4) // dom7's 3rd = ascending leading tone (SHARP)
const nextName = noteName(nextPc)
out.push(mk(candRootPc, 'dom7',
`Swap for ${NOTES[candRootPc]}7, the V7 of ${nextName} — its 3rd (${leadingTone}) leans `
+ `a half-step into ${nextName}, pulling the loop around. One step clockwise on the circle.`,
'secondary_dominant'))
}
}
return out.slice(0, 4)
}