feat(jamguide): piano tab live — per-station voice-led MiniPiano voicings (task L-11)

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>
This commit is contained in:
vadimwit
2026-07-08 17:01:55 +01:00
parent 4b0f0c84a7
commit 7b669f8c11
+62 -17
View File
@@ -4,6 +4,8 @@ import { buildLoopIndex, matchLoopToProgression, findLoopPosition, chordRootPC }
import { NOTES, CHORD_TYPES } from '../lib/theory' import { NOTES, CHORD_TYPES } from '../lib/theory'
import RoadmapTrack from './RoadmapTrack' import RoadmapTrack from './RoadmapTrack'
import ChordDiagram from './ChordDiagram' import ChordDiagram from './ChordDiagram'
import MiniPiano from './MiniPiano'
import { pianoVoicingChain } from '../lib/piano'
// ─── JamGuide — the Roadmap bottom dock ─────────────────────────────────────── // ─── JamGuide — the Roadmap bottom dock ───────────────────────────────────────
// //
@@ -23,12 +25,16 @@ import ChordDiagram from './ChordDiagram'
// bpm : number | null — live tempo from the onset pipeline // bpm : number | null — live tempo from the onset pipeline
// currentChord : string | undefined — most recent committed chord // currentChord : string | undefined — most recent committed chord
// Display order for instrument tabs; availability is derived from the KB, not hardcoded. // 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 = [ const INSTRUMENTS = [
{ id: 'guitar', label: 'Guitar', icon: '🎸' }, { id: 'guitar', label: 'Guitar', icon: '🎸' },
{ id: 'piano', label: 'Piano', icon: '🎹' }, { id: 'piano', label: 'Piano', icon: '🎹' },
{ id: 'bass', label: 'Bass', icon: '🎵' }, { id: 'bass', label: 'Bass', icon: '🎵' },
] ]
const COMPUTED_INSTRUMENTS = new Set(['piano'])
export default function JamGuide({ detectedProgression, keyInfo, chordHistory = [], bpm, currentChord, onFocusChord }) { export default function JamGuide({ detectedProgression, keyInfo, chordHistory = [], bpm, currentChord, onFocusChord }) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
@@ -103,32 +109,49 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
return (((position - match.rotation) % n) + n) % n return (((position - match.rotation) % n) + n) % n
}, [match.matched, match.progression, match.rotation, position]) }, [match.matched, match.progression, match.rotation, position])
// ── Per-station voicing shapes from the KB ────────────────────────────────── // ── Per-station voicings ────────────────────────────────────────────────────
// For the matched style + progression id, pull the recommended guitar play // Stations are canonical KB order — index i aligns 1:1 with
// (the first play). Its `chords` array is in canonical KB order — chords[i] // progression.degrees[i] (the same station order RoadmapTrack renders). Each
// aligns 1:1 with progression.degrees[i] (the same station order RoadmapTrack // entry carries the station's chord identity ({rootPc, quality, label, rn} —
// renders). Each entry: { shape, note }. A station with no shape → graceful gap. // 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(() => { const stationVoicings = useMemo(() => {
if (!match.matched || instrument !== 'guitar') return [] if (!match.matched || (instrument !== 'guitar' && instrument !== 'piano')) return []
const prog = match.progression 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 degrees = prog?.degrees ?? []
const qualities = prog?.qualities ?? [] const qualities = prog?.qualities ?? []
return degrees.map((deg, i) => { const stations = degrees.map((deg, i) => {
const rootPc = (((keyRoot + deg) % 12) + 12) % 12 const rootPc = (((keyRoot + deg) % 12) + 12) % 12
const noteName = NOTES[rootPc] const noteName = NOTES[rootPc]
const suffix = CHORD_TYPES[qualities[i]]?.suffix ?? '' const suffix = CHORD_TYPES[qualities[i]]?.suffix ?? ''
return { return {
shape: chords[i]?.shape ?? null, shape: null,
voicing: null,
rootPc, rootPc,
quality: qualities[i] ?? 'maj', quality: qualities[i] ?? 'maj',
label: `${noteName}${suffix}`, label: `${noteName}${suffix}`,
rn: prog?.rn?.[i] ?? '', 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]) }, [match.matched, match.progression, match.style, instrument, keyRoot])
// ── Tap-to-enlarge: which station's voicing is expanded (full diagram). ── // ── Tap-to-enlarge: which station's voicing is expanded (full diagram). ──
@@ -184,7 +207,8 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
{/* Instrument tabs */} {/* Instrument tabs */}
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{INSTRUMENTS.map(inst => { {INSTRUMENTS.map(inst => {
const enabled = availableInstruments.has(inst.id) // 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 const active = enabled && inst.id === instrument
return ( return (
<button <button
@@ -267,9 +291,10 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
// ─── RoadmapAssembly — the live panel body ──────────────────────────────────── // ─── RoadmapAssembly — the live panel body ────────────────────────────────────
// //
// Composes RoadmapTrack (the improv highway) with a secondary voicing strip of // Composes RoadmapTrack (the improv highway) with a secondary voicing strip
// ChordDiagram thumbnails (one per station, canonical KB order). Tapping a // (one thumbnail per station, canonical KB order): ChordDiagram when a station
// thumbnail enlarges it to a full diagram inline. The active station auto-scrolls // 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 // into view. Narrow viewports (< ~640px) reflow: the strip wraps and the whole
// panel scrolls vertically rather than forcing a wide horizontal layout. // panel scrolls vertically rather than forcing a wide horizontal layout.
function RoadmapAssembly({ function RoadmapAssembly({
@@ -339,6 +364,16 @@ function RoadmapAssembly({
} }
style={{ opacity: isNow ? 1 : 0.85 }} 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 <ChordDiagram
shape={st.shape} shape={st.shape}
keyRoot={keyRoot} keyRoot={keyRoot}
@@ -346,6 +381,7 @@ function RoadmapAssembly({
size="thumb" size="thumb"
label={st.label} label={st.label}
/> />
)}
{st.rn && ( {st.rn && (
<span className="text-[9px] font-medium uppercase tracking-wide text-gray-500"> <span className="text-[9px] font-medium uppercase tracking-wide text-gray-500">
{st.rn} {st.rn}
@@ -360,6 +396,14 @@ function RoadmapAssembly({
lives here instead — see D-02 return note). */} lives here instead — see D-02 return note). */}
{selected && ( {selected && (
<div className="mt-3 flex flex-col items-center gap-2 border-t border-border pt-3"> <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 <ChordDiagram
shape={selected.shape} shape={selected.shape}
keyRoot={keyRoot} keyRoot={keyRoot}
@@ -367,6 +411,7 @@ function RoadmapAssembly({
size="full" size="full"
label={`${selected.label}${selected.rn ? ` · ${selected.rn}` : ''}`} label={`${selected.label}${selected.rn ? ` · ${selected.rn}` : ''}`}
/> />
)}
<button <button
type="button" type="button"
onClick={() => onSelectStation(null)} onClick={() => onSelectStation(null)}