7b669f8c11
Piano is computed (pianoVoicingChain over canonical stations), no authored KB needed; guitar path value-identical; tap-station guide-tone contract intact; App.jsx untouched. Critic PASS (303/303 empirical station checks across 3 progressions x 3 keys; voice-leading threads). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
429 lines
20 KiB
React
429 lines
20 KiB
React
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 (
|
||
<div className="mb-3 bg-panel border border-border rounded-xl overflow-hidden">
|
||
|
||
{/* ── Collapsed header bar (always visible) ── */}
|
||
<button
|
||
onClick={() => setOpen(v => !v)}
|
||
className="w-full flex items-center justify-between px-4 py-3 text-left hover:bg-accent/5 transition-colors"
|
||
aria-expanded={open}
|
||
>
|
||
<span className="flex items-center gap-2 min-w-0">
|
||
<span className="text-base shrink-0">🎸</span>
|
||
<span className="text-sm font-semibold text-accent shrink-0">Jam Guide</span>
|
||
<span className="text-gray-600 shrink-0">—</span>
|
||
<span className="text-sm text-gray-300 truncate">{headerLabel}</span>
|
||
{match.matched && keyInfo?.root && (
|
||
<span className="text-xs text-gray-500 shrink-0">
|
||
in {keyInfo.root} {keyInfo.mode}
|
||
</span>
|
||
)}
|
||
</span>
|
||
<span className="text-gray-500 shrink-0 ml-3">{open ? '▲' : '▼'}</span>
|
||
</button>
|
||
|
||
{/* ── Expanded body (~70vh) ── */}
|
||
{open && (
|
||
<div className="border-t border-border flex flex-col" style={{ height: '70vh' }}>
|
||
|
||
{/* ── Tab rows ── */}
|
||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 px-4 py-2 border-b border-border">
|
||
|
||
{/* Instrument tabs */}
|
||
<div className="flex items-center gap-1">
|
||
{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 (
|
||
<button
|
||
key={inst.id}
|
||
onClick={() => enabled && setInstrument(inst.id)}
|
||
disabled={!enabled}
|
||
title={enabled ? inst.label : `${inst.label} packs coming soon`}
|
||
className={`px-3 py-1 rounded-lg text-sm font-medium transition-colors flex items-center gap-1.5 ${
|
||
active
|
||
? 'bg-accent/20 border border-accent text-accent'
|
||
: enabled
|
||
? 'border border-border text-gray-300 hover:border-gray-500 hover:text-gray-100'
|
||
: 'border border-border/50 text-gray-600 cursor-not-allowed'
|
||
}`}
|
||
>
|
||
<span>{inst.icon}</span>
|
||
<span>{inst.label}</span>
|
||
{!enabled && <span className="text-[10px] text-gray-700 ml-0.5">soon</span>}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
<div className="w-px h-5 bg-border shrink-0" />
|
||
|
||
{/* Style tabs (from the KB registry) */}
|
||
<div className="flex items-center gap-1 flex-wrap">
|
||
{styles.map(style => {
|
||
const active = style.id === activeStyle
|
||
const isMatched = match.matched && style.id === match.style
|
||
return (
|
||
<button
|
||
key={style.id}
|
||
onClick={() => setStyleOverride(style.id)}
|
||
className={`px-2.5 py-1 rounded-lg text-sm transition-colors ${
|
||
active
|
||
? 'bg-accent/20 border border-accent text-accent font-semibold'
|
||
: 'border border-transparent text-gray-400 hover:text-gray-200 hover:border-border'
|
||
}`}
|
||
>
|
||
{style.label}
|
||
{isMatched && <span className="ml-1 text-accent/70" title="matches your loop">●</span>}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Roadmap slot (D-02 assembly) ── */}
|
||
<div data-roadmap-slot className="flex-1 min-h-0 p-4 overflow-auto">
|
||
{match.matched ? (
|
||
<RoadmapAssembly
|
||
progression={match.progression}
|
||
keyRoot={keyRoot}
|
||
keyMode={keyMode}
|
||
position={canonicalPos}
|
||
bpm={bpm}
|
||
stationVoicings={stationVoicings}
|
||
selectedStation={selectedStation}
|
||
onSelectStation={setSelectedStation}
|
||
/>
|
||
) : (
|
||
<div
|
||
className="h-full min-h-[200px] flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-border text-center"
|
||
>
|
||
<p className="text-sm text-gray-400">Play a few bars — I'll map the changes</p>
|
||
<p className="text-xs text-gray-600">
|
||
{detectedProgression?.length
|
||
? `Heard ${detectedProgression.join(' → ')}, but it doesn't match a ${activeStyle} pattern yet.`
|
||
: 'Roadmap renders here once a repeating loop is detected.'}
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── 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 (
|
||
<div className="flex h-full min-h-0 flex-col gap-4">
|
||
{/* The improv highway — active-station styling + playhead live inside it. */}
|
||
<RoadmapTrack
|
||
progression={progression}
|
||
keyRoot={keyRoot}
|
||
keyMode={keyMode}
|
||
position={position}
|
||
bpm={bpm}
|
||
/>
|
||
|
||
{/* Secondary voicing strip: one thumbnail per station, canonical order. */}
|
||
{stationVoicings.length > 0 && (
|
||
<section
|
||
className="rounded-2xl border border-border bg-panel p-3"
|
||
aria-label="Voicing thumbnails"
|
||
>
|
||
<h4 className="mb-2 text-[10px] font-semibold uppercase tracking-widest text-gray-500">
|
||
Voicings · tap to enlarge
|
||
</h4>
|
||
<div
|
||
ref={stripRef}
|
||
className="flex flex-wrap gap-2 overflow-x-auto sm:flex-nowrap"
|
||
role="list"
|
||
>
|
||
{stationVoicings.map((st, i) => {
|
||
const isNow = i === position
|
||
const isSelected = i === selectedStation
|
||
return (
|
||
<button
|
||
key={i}
|
||
ref={isNow ? activeRef : undefined}
|
||
type="button"
|
||
role="listitem"
|
||
aria-pressed={isSelected}
|
||
aria-current={isNow ? 'true' : undefined}
|
||
onClick={() => onSelectStation(isSelected ? null : i)}
|
||
title={`${st.label} — ${st.rn || `station ${i + 1}`}`}
|
||
className={
|
||
`flex shrink-0 flex-col items-center gap-1 rounded-lg border p-2 outline-none transition ` +
|
||
`focus-visible:ring-2 focus-visible:ring-accent ` +
|
||
(isNow
|
||
? 'border-accent bg-accent/10 ring-1 ring-accent'
|
||
: isSelected
|
||
? 'border-accent/60 bg-accent/5'
|
||
: 'border-border bg-surface hover:border-gray-500')
|
||
}
|
||
style={{ opacity: isNow ? 1 : 0.85 }}
|
||
>
|
||
{st.voicing ? (
|
||
<>
|
||
<MiniPiano voicing={st.voicing} size="thumb" />
|
||
{/* MiniPiano has no built-in chord label; mirror ChordDiagram's
|
||
thumb label (text-gray-300, 9px) so the station stays named. */}
|
||
<span className="text-gray-300 leading-none" style={{ fontSize: 9 }}>
|
||
{st.label}
|
||
</span>
|
||
</>
|
||
) : (
|
||
<ChordDiagram
|
||
shape={st.shape}
|
||
keyRoot={keyRoot}
|
||
rootPc={st.rootPc}
|
||
size="thumb"
|
||
label={st.label}
|
||
/>
|
||
)}
|
||
{st.rn && (
|
||
<span className="text-[9px] font-medium uppercase tracking-wide text-gray-500">
|
||
{st.rn}
|
||
</span>
|
||
)}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
{/* Enlarged view of the tapped station (deferred fretboard cross-link
|
||
lives here instead — see D-02 return note). */}
|
||
{selected && (
|
||
<div className="mt-3 flex flex-col items-center gap-2 border-t border-border pt-3">
|
||
{selected.voicing ? (
|
||
<>
|
||
<MiniPiano voicing={selected.voicing} size="full" />
|
||
<span className="text-gray-300 leading-none" style={{ fontSize: 12 }}>
|
||
{`${selected.label}${selected.rn ? ` · ${selected.rn}` : ''}`}
|
||
</span>
|
||
</>
|
||
) : (
|
||
<ChordDiagram
|
||
shape={selected.shape}
|
||
keyRoot={keyRoot}
|
||
rootPc={selected.rootPc}
|
||
size="full"
|
||
label={`${selected.label}${selected.rn ? ` · ${selected.rn}` : ''}`}
|
||
/>
|
||
)}
|
||
<button
|
||
type="button"
|
||
onClick={() => onSelectStation(null)}
|
||
className="text-[11px] text-gray-500 underline-offset-2 hover:text-gray-300 hover:underline focus-visible:ring-2 focus-visible:ring-accent rounded outline-none"
|
||
>
|
||
close
|
||
</button>
|
||
</div>
|
||
)}
|
||
</section>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|