From 61abf8a5135c4952d90c5cc06a89561b9b330842 Mon Sep 17 00:00:00 2001 From: vadimwit Date: Mon, 15 Jun 2026 00:40:45 +0100 Subject: [PATCH] =?UTF-8?q?feat(jamguide):=20Roadmap=20Jam=20Guide=20panel?= =?UTF-8?q?=20=E2=80=94=20shell,=20App=20mount,=20live=20assembly=20(tasks?= =?UTF-8?q?=20L-02,=20D-02)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bottom-dock panel: collapsed header -> ~70vh, instrument+style tabs from KB registry, matcher/position via match.js. Assembles RoadmapTrack + per-station ChordDiagram thumbnails; reconciles playhead detected-loop index -> canonical KB station via match.rotation; tap-to-enlarge voicings; auto-scroll active station; narrow reflow. App.jsx change is additive (+10/-0) — audio callbacks/refs untouched. Critic PASS all 4. Co-Authored-By: Claude Fable 5 --- src/App.jsx | 10 + src/components/JamGuide.jsx | 367 ++++++++++++++++++++++++++++++++++++ 2 files changed, 377 insertions(+) create mode 100644 src/components/JamGuide.jsx diff --git a/src/App.jsx b/src/App.jsx index 69f05ae..e9498bb 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -13,6 +13,7 @@ import { NOTES, detectKey, detectTopKeys, matchChordFromChroma, detectRepeatingP import ChordDetailModal from './components/ChordDetailModal' import CurrentJamPanel from './components/CurrentJamPanel' import LoopStation from './components/LoopStation' +import JamGuide from './components/JamGuide' import { useLoopEngine } from './services/loopEngine' import settingIcon from './assets/setting-icon.png' @@ -658,6 +659,15 @@ export default function App() { {showTuner &&
} + + {/* ── Jam Guide — bottom dock (Roadmap) ── */} + ) } diff --git a/src/components/JamGuide.jsx b/src/components/JamGuide.jsx new file mode 100644 index 0000000..f4252f3 --- /dev/null +++ b/src/components/JamGuide.jsx @@ -0,0 +1,367 @@ +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' + +// ─── 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. +const INSTRUMENTS = [ + { id: 'guitar', label: 'Guitar', icon: '🎸' }, + { id: 'piano', label: 'Piano', icon: '🎹' }, + { id: 'bass', label: 'Bass', icon: '🎵' }, +] + +export default function JamGuide({ detectedProgression, keyInfo, chordHistory = [], bpm, currentChord }) { + 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 voicing shapes from the KB ────────────────────────────────── + // For the matched style + progression id, pull the recommended guitar play + // (the first play). Its `chords` array is in canonical KB order — chords[i] + // aligns 1:1 with progression.degrees[i] (the same station order RoadmapTrack + // renders). Each entry: { shape, note }. A station with no shape → graceful gap. + const stationVoicings = useMemo(() => { + if (!match.matched || instrument !== 'guitar') return [] + const prog = match.progression + const styleId = match.style + const plays = kb[styleId]?.instruments?.guitar?.plays?.[prog?.id] + const play = Array.isArray(plays) ? plays[0] : null + const chords = play?.chords ?? [] + const degrees = prog?.degrees ?? [] + const qualities = prog?.qualities ?? [] + return degrees.map((deg, i) => { + const rootPc = (((keyRoot + deg) % 12) + 12) % 12 + const noteName = NOTES[rootPc] + const suffix = CHORD_TYPES[qualities[i]]?.suffix ?? '' + return { + shape: chords[i]?.shape ?? null, + rootPc, + label: `${noteName}${suffix}`, + rn: prog?.rn?.[i] ?? '', + } + }) + }, [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]) + + return ( +
+ + {/* ── Collapsed header bar (always visible) ── */} + + + {/* ── Expanded body (~70vh) ── */} + {open && ( +
+ + {/* ── Tab rows ── */} +
+ + {/* Instrument tabs */} +
+ {INSTRUMENTS.map(inst => { + const enabled = 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 of +// ChordDiagram thumbnails (one per station, canonical KB order). Tapping a +// thumbnail enlarges it to a full diagram 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 && ( +
+ + +
+ )} +
+ )} +
+ ) +}