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