feat(jamguide): tap a Roadmap station -> guide tones on the main Fretboard (task D-03)

Completes the flagship Roadmap feature. App.jsx lifts a jamFocusChord state (additive,
+10/-0, audio callbacks/refs untouched); JamGuide emits the tapped station's {rootPc,
quality} via onFocusChord; Fretboard halos the 3rd/7th with a degree badge.

Also fixes root-cause guideTones: hasSeventh now keys on real m7/M7 (interval 10/11),
not length>=4 -- so add9/maj6/min6 no longer badge their 5th/6th as a '7'. Fretboard
badge derives its label from the actual interval (belt-and-suspenders). RoadmapTrack
lane self-corrects. Critic returned-then-PASS on re-gate; build + smoke + validator green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
vadimwit
2026-06-15 13:05:21 +01:00
parent ddbca3f197
commit 53d78ce30f
4 changed files with 120 additions and 20 deletions
+9 -1
View File
@@ -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 ── */}
<div className="flex gap-3 mb-3 items-stretch">
<div className="w-full lg:w-[70%] min-w-0">
{instrument === 'guitar' && <Fretboard keyInfo={effectiveKey} currentChord={currentChord} pentatonicOnly={false} monoColor={monoColor} />}
{instrument === 'guitar' && <Fretboard keyInfo={effectiveKey} currentChord={currentChord} pentatonicOnly={false} monoColor={monoColor} jamFocusChord={jamFocusChord} />}
{instrument === 'bass' && <BassFretboard keyInfo={effectiveKey} currentChord={currentChord} monoColor={monoColor} />}
{instrument === 'piano' && <Piano keyInfo={effectiveKey} currentChord={currentChord} monoColor={monoColor} />}
</div>
@@ -667,6 +674,7 @@ export default function App() {
chordHistory={chordHistory}
bpm={bpm}
currentChord={currentChord}
onFocusChord={setJamFocusChord}
/>
</div>
)
+71 -6
View File
@@ -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 (
<div className="bg-panel border border-border rounded-2xl p-6">
<p className="text-sm text-gray-500 uppercase tracking-widest mb-4">
Fretboard {root} {mode}
{currentChord && <span className="text-amber-400 ml-2">/ {currentChord}</span>}
{hasFocus && <span className="text-accent ml-2"> guide tones</span>}
</p>
<div>
@@ -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 (
<g key={`${si}-${fi}`}>
<circle cx={cx} cy={cy} r={DOT_R} fill={color.fill} />
{/* Guide-tone halo: a purple ring around the dot, clearly
distinct from the solid chord-tone fill (a "target" marker). */}
{tag && (
<circle
cx={cx} cy={cy} r={DOT_R + 3}
fill="none" stroke="#a855f7" strokeWidth={2.5}
/>
)}
<circle cx={cx} cy={cy} r={DOT_R} fill={baseFill} />
<text
x={cx} y={cy + 4}
textAnchor="middle"
fontSize={9}
fontWeight="600"
fill={color.text}
fill={baseText}
>
{NOTES[pc]}
</text>
{/* Degree badge (3 / 7 / 5) on the halo's upper-right. */}
{tag && (
<>
<circle cx={cx + DOT_R} cy={cy - DOT_R} r={6} fill="#a855f7" />
<text
x={cx + DOT_R} y={cy - DOT_R + 3}
textAnchor="middle"
fontSize={8}
fontWeight="700"
fill="#fff"
>
{tag}
</text>
</>
)}
</g>
)
})
@@ -145,10 +202,18 @@ export default function Fretboard({ keyInfo, currentChord, pentatonicOnly = fals
</svg>
</div>
<div className="mt-3 flex gap-5 text-xs text-gray-500">
<div className="mt-3 flex flex-wrap gap-5 text-xs text-gray-500">
<span><span className="text-accent"></span> Chord tone</span>
<span style={{ color: monoColor ? '#c084fc' : '#f59e0b' }}></span><span> Pentatonic</span>
<span style={{ color: monoColor ? '#e9d5ff' : '#6b7280' }}></span><span> Scale</span>
{hasFocus && (
<span className="flex items-center gap-1">
<span
className="inline-block w-3 h-3 rounded-full border-2 border-accent"
/>
Guide tones (3 / {focusSeventhLabel})
</span>
)}
</div>
</div>
)
+17 -1
View File
@@ -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 (
<div className="mb-3 bg-panel border border-border rounded-xl overflow-hidden">
+23 -12
View File
@@ -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 (011) 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 }
}