diff --git a/src/App.jsx b/src/App.jsx index e9498bb..c8ea22f 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -61,6 +61,13 @@ export default function App() { const [showDrumView, setShowDrumView] = useState(false) const [monoColor, setMonoColor] = useState(() => loadStored('wtf_monoColor', false)) + // ── Jam Guide → Fretboard cross-link (D-03) ────────────────────────────────── + // When a Roadmap station is tapped, JamGuide reports its {rootPc, quality} + // here and the main Fretboard highlights that chord's guide tones (3rd/7th). + // null = no station focused (Fretboard renders normally). Purely UI state — + // NOT read by any audio callback, so it stays out of the ref-sync contract. + const [jamFocusChord, setJamFocusChord] = useState(null) // { rootPc, quality } | null + // ── Mic permission error ────────────────────────────────────────────────────── const [micError, setMicError] = useState(null) @@ -569,7 +576,7 @@ export default function App() { {/* ── Instrument + progressions row ── */}
- {instrument === 'guitar' && } + {instrument === 'guitar' && } {instrument === 'bass' && } {instrument === 'piano' && }
@@ -667,6 +674,7 @@ export default function App() { chordHistory={chordHistory} bpm={bpm} currentChord={currentChord} + onFocusChord={setJamFocusChord} />
) diff --git a/src/components/Fretboard.jsx b/src/components/Fretboard.jsx index 72eb399..4b31ee4 100644 --- a/src/components/Fretboard.jsx +++ b/src/components/Fretboard.jsx @@ -1,4 +1,4 @@ -import { getPentatonicScale, getFullScale, getChordTones, NOTES } from '../lib/theory' +import { getPentatonicScale, getFullScale, getChordTones, guideTones, NOTES } from '../lib/theory' // Standard tuning: pitch classes of open strings, high-E first (top of diagram) const STRINGS = [ @@ -37,7 +37,7 @@ function noteColor(isChordTone, isPenta, isScale, mono = false) { return null } -export default function Fretboard({ keyInfo, currentChord, pentatonicOnly = false, monoColor = false }) { +export default function Fretboard({ keyInfo, currentChord, pentatonicOnly = false, monoColor = false, jamFocusChord = null }) { const { root, mode } = keyInfo ?? {} if (!root) return null @@ -50,11 +50,40 @@ export default function Fretboard({ keyInfo, currentChord, pentatonicOnly = fals ? new Set(getChordTones(currentChord).map(n => NOTES.indexOf(n))) : new Set() + // ── Jam Guide focus: guide tones of the tapped Roadmap station ────────────── + // `guideTones` returns { third, seventh, hasSeventh }. We emphasise the 3rd + // (the quality-defining tone) and the secondary anchor — the 7th when present, + // else the 5th for a triad (hasSeventh:false). These pitch classes get a halo + // ring + a small tag so they read as a distinct "target" tier on top of the + // normal chord/penta/scale colouring. + let focusThird = -1, focusSeventh = -1, focusRootPc = 0 + if (jamFocusChord && typeof jamFocusChord.rootPc === 'number') { + const gt = guideTones(jamFocusChord.rootPc, jamFocusChord.quality) + focusThird = gt.third + focusSeventh = gt.seventh + focusRootPc = gt.root + } + const hasFocus = focusThird >= 0 + // Defense-in-depth: label the secondary anchor from its ACTUAL interval above + // the chord root, so a wrong `hasSeventh` boolean could never mislabel a 5th + // or 6th as a "7". 10/11 → "7", 9 → "6", 8 → "♭6"(#5), 7 → "5", 6 → "♭5". + const focusSeventhLabel = (() => { + const iv = ((focusSeventh - focusRootPc) % 12 + 12) % 12 + if (iv === 10 || iv === 11) return '7' + if (iv === 9) return '6' + if (iv === 8) return '♭6' + if (iv === 6) return '♭5' + return '5' + })() + const focusLabel = pc => + pc === focusThird ? '3' : pc === focusSeventh ? focusSeventhLabel : null + return (

Fretboard — {root} {mode} {currentChord && / {currentChord}} + {hasFocus && ◎ guide tones}

@@ -121,23 +150,51 @@ export default function Fretboard({ keyInfo, currentChord, pentatonicOnly = fals Array.from({ length: NUM_FRETS }, (_, fi) => { const pc = (str.root + fi) % 12 const color = noteColor(chordSet.has(pc), pentaSet.has(pc), scaleSet.has(pc), monoColor) - if (!color) return null + const tag = hasFocus ? focusLabel(pc) : null + // A guide tone outside the current scale still gets emphasised: + // draw a faint base dot so the halo has something to sit on. + if (!color && !tag) return null const cx = fi === 0 ? OPEN_X : fretX(fi) const cy = stringY(si) + const baseFill = color ? color.fill : '#2a2a2a' + const baseText = color ? color.text : '#a855f7' return ( - + {/* Guide-tone halo: a purple ring around the dot, clearly + distinct from the solid chord-tone fill (a "target" marker). */} + {tag && ( + + )} + {NOTES[pc]} + {/* Degree badge (3 / 7 / 5) on the halo's upper-right. */} + {tag && ( + <> + + + {tag} + + + )} ) }) @@ -145,10 +202,18 @@ export default function Fretboard({ keyInfo, currentChord, pentatonicOnly = fals
-
+
Chord tone Pentatonic Scale + {hasFocus && ( + + + Guide tones (3 / {focusSeventhLabel}) + + )}
) diff --git a/src/components/JamGuide.jsx b/src/components/JamGuide.jsx index f4252f3..f795864 100644 --- a/src/components/JamGuide.jsx +++ b/src/components/JamGuide.jsx @@ -30,7 +30,7 @@ const INSTRUMENTS = [ { id: 'bass', label: 'Bass', icon: '🎵' }, ] -export default function JamGuide({ detectedProgression, keyInfo, chordHistory = [], bpm, currentChord }) { +export default function JamGuide({ detectedProgression, keyInfo, chordHistory = [], bpm, currentChord, onFocusChord }) { const [open, setOpen] = useState(false) // Which instruments have at least one KB pack across the registry. @@ -124,6 +124,7 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory = return { shape: chords[i]?.shape ?? null, rootPc, + quality: qualities[i] ?? 'maj', label: `${noteName}${suffix}`, rn: prog?.rn?.[i] ?? '', } @@ -135,6 +136,21 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory = // Reset the selection whenever the loop or style changes underneath us. useEffect(() => { setSelectedStation(null) }, [match.id, match.style, instrument]) + // ── Cross-link to the main Fretboard (D-03) ───────────────────────────────── + // When a station is selected, report its {rootPc, quality} upward so the + // Fretboard can light that chord's guide tones; clear (null) on deselect. The + // reset effect above sets selectedStation → null on loop/style/instrument + // change, which flows through here and clears the highlight too. Guarded so + // the component still works standalone (onFocusChord optional). + useEffect(() => { + if (!onFocusChord) return + const st = selectedStation != null ? stationVoicings[selectedStation] : null + onFocusChord(st ? { rootPc: st.rootPc, quality: st.quality } : null) + }, [selectedStation, stationVoicings, onFocusChord]) + + // Clear the Fretboard highlight when JamGuide unmounts. + useEffect(() => () => { onFocusChord?.(null) }, [onFocusChord]) + return (
diff --git a/src/lib/theory.js b/src/lib/theory.js index 2043246..c6ed177 100644 --- a/src/lib/theory.js +++ b/src/lib/theory.js @@ -260,28 +260,39 @@ function chordTonePcs(rootPc, quality) { /** * guideTones(rootPc, quality) → { third, seventh, root } * - * The guide tones a soloist targets: a chord's 3rd and 7th. By CHORD_TYPES - * interval ordering, index 1 is always the 3rd and (for a 7th chord) the last - * interval is the 7th. For triads with no 7th there is no real guide 7th, so we - * fall back to the 5th (the next most stable anchor) and flag it via - * `hasSeventh: false` so a caller can label it honestly ("5th", not "7th"). + * The guide tones a soloist targets: a chord's 3rd and 7th. Index 1 in every + * CHORD_TYPES interval set is the 3rd. A chord has a TRUE 7th only if its + * interval set contains 10 (m7) or 11 (M7) — NOT merely if it has 4 tones. + * When there is no real 7th (triads, and 4-tone non-7th chords like add9 + * [0,2,4,7] or maj6/min6 [0,4,7,9]) we fall back to the 5th as the secondary + * anchor and flag `hasSeventh: false` so a caller labels it honestly ("5th", + * not "7th"). dim/dim7/aug have no perfect 5th, so they anchor on their ♭5/#5. * * Returns pitch classes (0–11) so the Roadmap TARGET lane can place dots in any * key. `root` is included as the third anchor the design's badges reference. * - * Sanity (C major): guideTones(0,'maj7') → third 4 (E), seventh 11 (B). - * guideTones(7,'dom7') → third 11 (B), seventh 5 (F). - * guideTones(2,'min7') → third 5 (F), seventh 0 (C). + * Sanity (C): guideTones(0,'maj7') → third 4 (E), seventh 11 (B), hasSeventh:true. + * guideTones(7,'dom7') → third 11 (B), seventh 5 (F), hasSeventh:true. + * guideTones(2,'min7') → third 5 (F), seventh 0 (C), hasSeventh:true. + * guideTones(0,'add9') → third 4 (E), seventh 7 (G=5th), hasSeventh:false. + * guideTones(0,'maj6') / (0,'min6') → seventh 7 (G=5th), hasSeventh:false. */ export function guideTones(rootPc, quality) { const type = CHORD_TYPES[quality] ?? CHORD_TYPES.maj const r = ((rootPc % 12) + 12) % 12 const ints = type.intervals const third = (r + ints[1]) % 12 // index 1 is always the 3rd - const hasSeventh = ints.length >= 4 // CHORD_TYPES 7ths add a 4th tone - // 7th when present, else the 5th as the secondary anchor (index 2 = the 5th - // for every triad in CHORD_TYPES, which is what a triad soloist leans on). - const seventh = (r + ints[hasSeventh ? ints.length - 1 : 2]) % 12 + // A chord has a TRUE 7th only if its interval set contains 10 (m7) or 11 (M7). + // `length >= 4` is wrong: add9 [0,2,4,7] and maj6/min6 [0,4,7,9] are 4-tone + // chords with NO seventh, so their secondary anchor must fall back to the 5th — + // never badge a 5th/6th as a "7". (add9 → hasSeventh:false, anchor=5th.) + const seventhInt = ints.find(i => i === 10 || i === 11) // m7 / M7 + const hasSeventh = seventhInt !== undefined + // Secondary anchor: the true 7th when present; otherwise the perfect 5th (7). + // When no perfect 5th exists either (dim/dim7 carry a ♭5=6, aug carries a #5=8), + // anchor on whichever altered 5th the chord actually contains. + const fifthInt = ints.includes(7) ? 7 : ints.includes(6) ? 6 : ints.includes(8) ? 8 : 7 + const seventh = (r + (hasSeventh ? seventhInt : fifthInt)) % 12 return { third, seventh, root: r, hasSeventh } }