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:
+9
-1
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user