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>
17 KiB
"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 3–4 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.
effectiveKeyis{ root, mode, confidence }. The mode disambiguates the i-vs-vi ambiguity that makesAm C Fread two ways: if the app committed A minor,Fis♭VI; if C major,FisIV. The engine frames the why against whichever one is live, and gates the moves that need a specific reading (borrowedivneeds 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:
suggestSubstitutionsreturns[]when!keyInfo?.root.TryThisthen renders a quiet idle line ("Lock a key to see substitutions") — never a fabricated suggestion. This mirrorsRelatedProgressions/CircleOfFifthsidle 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 0–11 + 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.modeis 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,Fis a diatonic major♭VI;Fmwould be a chromatic♭viwith 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."wherenb6 = noteName((keyRootPc + 8) % 12, /*preferFlat*/true)(the ♭6). Spell it flat — this is a flatward modal borrow (A→A♭), never the sharpNOTES[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 →maj7if(rootPc+11)diatonic, elseadd9if(rootPc+2)diatonic, elsemaj6; minor-family →min7if(rootPc+10)diatonic, elseadd9;dom7→sus4(the 9sus-ish suspension). Same root, solabel=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:
currentChordwhen 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 inTryThis.
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'ssetSelectedChord→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 Dgets 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
TryThisaboveRelatedProgressionsinside App's existingrelatedSlotprop: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), orRelatedProgressions.jsx(L-72). The left column already reflows to one-per-row narrow (JamGuideorder-4wrapper);TryThisinherits 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 orderrelative, borrowed, extension, secondary_dominant, capped at 4,[]when!keyInfo?.root. ReuseNOTES,NOTES_FLAT,noteName,noteIndex,CHORD_TYPES,getChordsInKey,getScale,getChordTones,intervalName,toRomanNumeral— all in-module to theory.js (do not importchordRootPCfrom match.js — circular). Never re-derive intervals or diatonic sets.opts.nextRootPcgates Rule D. - Smoke truth-table in
smoke.mjs(sabotage-proven, like theresolveDegreeguard): pin the §3 tables —F/majin A minor →[Dm, Fmaj7, E7](no Fm); in C major →[Dm, Fm, Fmaj7, E7]; plusAm/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.jsis 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
relatedSlotaboveRelatedProgressions). 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)
- Full reharmonisation engine (chord-scale subs, ii–V 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.
- Blanket tritone sub (
root+6dom7 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 likeFit yieldsB7— 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.) - Per-station subs plastered across the voicings rail. Rejected: clutters the glance-critical rail (owned by L-70), 3–4 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.
- 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.