import { useState, useMemo, useRef, useEffect } from 'react' import kb from '../data/kb/index.js' import { buildLoopIndex, matchLoopToProgression, findLoopPosition, chordRootPC } from '../lib/match' import { NOTES, CHORD_TYPES } from '../lib/theory' import RoadmapTrack from './RoadmapTrack' import ChordDiagram from './ChordDiagram' import MiniPiano from './MiniPiano' import { pianoVoicingChain } from '../lib/piano' // ─── JamGuide — the Roadmap bottom dock ─────────────────────────────────────── // // The large bottom panel of JamBuddy. This is the SHELL (task L-02): the // collapsed header bar, instrument + style tabs (derived from the KB registry), // live loop → KB progression resolution, and a clearly-marked placeholder slot // where the Roadmap visualization (RoadmapTrack + ChordDiagram, task D-02) will // be wired in afterwards. // // This component does NOT import RoadmapTrack or ChordDiagram — sibling tasks // build those in parallel; D-02 fills the [data-roadmap-slot] left here. // // Props (the contract D-02 relies on): // detectedProgression : string[] | null — the live detected loop (chord names) // keyInfo : { root, mode, confidence } | null — effective key // chordHistory : string[] — committed chord history (for position) // bpm : number | null — live tempo from the onset pipeline // currentChord : string | undefined — most recent committed chord // Display order for instrument tabs; availability is derived from the KB, not // hardcoded — EXCEPT piano, which is always available: its voicings are COMPUTED // from the progression's degrees+qualities via src/lib/piano.js (L-10/L-11), so // no authored KB piano pack is required. const INSTRUMENTS = [ { id: 'guitar', label: 'Guitar', icon: '🎸' }, { id: 'piano', label: 'Piano', icon: '🎹' }, { id: 'bass', label: 'Bass', icon: '🎵' }, ] const COMPUTED_INSTRUMENTS = new Set(['piano']) 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. const availableInstruments = useMemo(() => { const set = new Set() for (const style of Object.values(kb)) { for (const inst of Object.keys(style?.instruments ?? {})) set.add(inst) } return set }, []) // Style tabs straight from the KB registry, labelled via each style's meta. const styles = useMemo( () => Object.entries(kb).map(([id, style]) => ({ id, label: style?.meta?.label ?? id })), [] ) // Build the rotation-invariant loop index once. const kbIndex = useMemo(() => buildLoopIndex(kb), []) // Resolve the live loop → KB progression + rotation, and the current station. // Keyed on the loop input so this only recomputes when the loop changes. const loopKey = detectedProgression ? detectedProgression.join(',') : '' const match = useMemo( () => matchLoopToProgression(detectedProgression, kbIndex), [loopKey, kbIndex] // eslint-disable-line react-hooks/exhaustive-deps ) const position = useMemo( () => findLoopPosition(chordHistory, detectedProgression), [chordHistory, loopKey] // eslint-disable-line react-hooks/exhaustive-deps ) // Instrument tab: default to guitar (the only packs that exist today). const [instrument, setInstrument] = useState('guitar') // Style tab: follow the matched style, but let the user override. const [styleOverride, setStyleOverride] = useState(null) const activeStyle = styleOverride ?? (match.matched ? match.style : styles[0]?.id) // Header summary: matched progression name, or a listening hint. const matchedName = match.matched ? match.progression?.name : null const headerLabel = matchedName ? matchedName : (detectedProgression?.length ? 'mapping the changes…' : 'listening…') // ── Key root: keyInfo.root is a note NAME (e.g. "C"). RoadmapTrack and // ChordDiagram both want a pitch class 0–11. Convert once; default to C (0) // until a key is known so the roadmap still resolves to *some* spelling. ── const keyRoot = useMemo(() => { const pc = chordRootPC(keyInfo?.root) return pc >= 0 ? pc : 0 }, [keyInfo?.root]) const keyMode = keyInfo?.mode === 'minor' ? 'minor' : 'major' // ── Playhead reconciliation ──────────────────────────────────────────────── // `position` from findLoopPosition is an index into the *detected* loop, which // can start on any rotation of the KB progression. RoadmapTrack renders the // progression in *canonical KB order* (degrees[0] first). They differ by // `match.rotation` — the loop index that aligns with KB degrees[0]. To map a // detected-loop index back to its canonical station: // canonicalPos = ((position − rotation) mod n + n) mod n // Worked example — KB blues-turnaround [I VI ii V] looped as [ii V I VI]: // rotation = 2 (loop index 2 = the "I" = KB degrees[0]). // Playhead on the V (detected index 1) → ((1 − 2) % 4 + 4) % 4 = 3 = the V's // canonical station. NOW lands on the right station. ✓ const canonicalPos = useMemo(() => { if (!match.matched) return -1 const n = match.progression?.degrees?.length ?? 0 if (!n || typeof position !== 'number' || position < 0) return -1 return (((position - match.rotation) % n) + n) % n }, [match.matched, match.progression, match.rotation, position]) // ── Per-station voicings ──────────────────────────────────────────────────── // Stations are canonical KB order — index i aligns 1:1 with // progression.degrees[i] (the same station order RoadmapTrack renders). Each // entry carries the station's chord identity ({rootPc, quality, label, rn} — // the tap-to-fretboard contract) plus an instrument-specific payload: // guitar → `shape`: from the recommended KB guitar play (the first play); // its `chords` array is canonical order too. No shape → graceful gap. // piano → `voicing`: COMPUTED via pianoVoicingChain over the whole loop in // canonical order, so each station's register threads from the // previous one (minimal movement between stations). The station's // rootPc is attached so MiniPiano marks the root key ("R") reliably. const stationVoicings = useMemo(() => { if (!match.matched || (instrument !== 'guitar' && instrument !== 'piano')) return [] const prog = match.progression const degrees = prog?.degrees ?? [] const qualities = prog?.qualities ?? [] const stations = degrees.map((deg, i) => { const rootPc = (((keyRoot + deg) % 12) + 12) % 12 const noteName = NOTES[rootPc] const suffix = CHORD_TYPES[qualities[i]]?.suffix ?? '' return { shape: null, voicing: null, rootPc, quality: qualities[i] ?? 'maj', label: `${noteName}${suffix}`, rn: prog?.rn?.[i] ?? '', } }) if (instrument === 'guitar') { const plays = kb[match.style]?.instruments?.guitar?.plays?.[prog?.id] const play = Array.isArray(plays) ? plays[0] : null const chords = play?.chords ?? [] for (let i = 0; i < stations.length; i++) { stations[i].shape = chords[i]?.shape ?? null } } else { const chain = pianoVoicingChain(stations.map(({ rootPc, quality }) => ({ rootPc, quality }))) for (let i = 0; i < stations.length; i++) { stations[i].voicing = chain[i] ? { ...chain[i], rootPc: stations[i].rootPc } : null } } return stations }, [match.matched, match.progression, match.style, instrument, keyRoot]) // ── Tap-to-enlarge: which station's voicing is expanded (full diagram). ── const [selectedStation, setSelectedStation] = useState(null) // 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 (
{/* ── Collapsed header bar (always visible) ── */} {/* ── Expanded body (~70vh) ── */} {open && (
{/* ── Tab rows ── */}
{/* Instrument tabs */}
{INSTRUMENTS.map(inst => { // Computed instruments (piano) need no KB pack — always selectable. const enabled = COMPUTED_INSTRUMENTS.has(inst.id) || availableInstruments.has(inst.id) const active = enabled && inst.id === instrument return ( ) })}
{/* Style tabs (from the KB registry) */}
{styles.map(style => { const active = style.id === activeStyle const isMatched = match.matched && style.id === match.style return ( ) })}
{/* ── Roadmap slot (D-02 assembly) ── */}
{match.matched ? ( ) : (

Play a few bars — I'll map the changes

{detectedProgression?.length ? `Heard ${detectedProgression.join(' → ')}, but it doesn't match a ${activeStyle} pattern yet.` : 'Roadmap renders here once a repeating loop is detected.'}

)}
)}
) } // ─── RoadmapAssembly — the live panel body ──────────────────────────────────── // // Composes RoadmapTrack (the improv highway) with a secondary voicing strip // (one thumbnail per station, canonical KB order): ChordDiagram when a station // carries a guitar `shape`, MiniPiano when it carries a computed piano `voicing` // (L-11 — the piano tab). Tapping a thumbnail enlarges it to a full view inline. The active station auto-scrolls // into view. Narrow viewports (< ~640px) reflow: the strip wraps and the whole // panel scrolls vertically rather than forcing a wide horizontal layout. function RoadmapAssembly({ progression, keyRoot, keyMode, position, bpm, stationVoicings, selectedStation, onSelectStation, }) { const stripRef = useRef(null) const activeRef = useRef(null) // Auto-scroll the active station's thumbnail into view as the playhead moves. // Prop-driven (off `position`) — no rAF loop tied to the audio thread. useEffect(() => { if (position < 0 || !activeRef.current) return activeRef.current.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest', }) }, [position]) const selected = selectedStation != null ? stationVoicings[selectedStation] : null return (
{/* The improv highway — active-station styling + playhead live inside it. */} {/* Secondary voicing strip: one thumbnail per station, canonical order. */} {stationVoicings.length > 0 && (

Voicings · tap to enlarge

{stationVoicings.map((st, i) => { const isNow = i === position const isSelected = i === selectedStation return ( ) })}
{/* Enlarged view of the tapped station (deferred fretboard cross-link lives here instead — see D-02 return note). */} {selected && (
{selected.voicing ? ( <> {`${selected.label}${selected.rn ? ` · ${selected.rn}` : ''}`} ) : ( )}
)}
)}
) }