feat(jamguide): stations render authored piano packs when a style has one (task L-24)
Piano branch prefers kb[style].instruments.piano.plays[prog][0], resolving recipe degrees via a validator-mirrored resolveDegree + strict-ascending stacking into MiniPiano's [0,36] space; per-station fallback to computed pianoVoicingChain (reachable: 4 wide gospel stations in key B); pack-less styles byte-identical to the computed path. Critic PASS (0 drift cells vs the validator's resolveDegree; 390-cell sweep; key-B fallback confirmed rendering, not dead). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+104
-8
@@ -54,6 +54,85 @@ const SECTIONS = [
|
|||||||
{ id: 'licks', label: 'Licks & Techniques' },
|
{ id: 'licks', label: 'Licks & Techniques' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// ─── Authored piano recipes → MiniPiano voicings (L-24) ───────────────────────
|
||||||
|
//
|
||||||
|
// A style may ship an authored piano pack (SCHEMA.md "Piano play"): per-chord
|
||||||
|
// degree recipes like { LH: ['3','5','7','9'] }. When the matched style has one
|
||||||
|
// for the matched progression, the piano tab prefers the FIRST play's recipes
|
||||||
|
// over the computed `pianoVoicingChain` — the recipes already encode the play's
|
||||||
|
// voice-leading choices per station, so they are NOT re-threaded. Styles without
|
||||||
|
// a piano pack (and any station whose recipe fails to resolve) fall back to the
|
||||||
|
// computed chain, per-station — malformed data must never crash the panel.
|
||||||
|
|
||||||
|
// Resolve a degree string ('3', 'b9', '13'…) to a pitch-class offset from the
|
||||||
|
// chord root, through the quality's intervals where the degree is quality-
|
||||||
|
// dependent ('3' → ♭3 for min7, '7' → the chord's actual 7th…). This mirrors
|
||||||
|
// `resolveDegree` in scripts/validate-kb.mjs — the KB contract's reference
|
||||||
|
// implementation — replicated here because src/ must not import from scripts/.
|
||||||
|
// Keep the two in sync by hand.
|
||||||
|
function resolveDegree(deg, quality) {
|
||||||
|
const iv = CHORD_TYPES[quality]?.intervals
|
||||||
|
if (!iv) return null
|
||||||
|
const fixed = { 1: 0, b9: 1, 9: 2, '#9': 3, 11: 5, '#11': 6, b5: 6, b13: 8, 13: 9, 6: 9, b3: 3, b7: 10 }
|
||||||
|
if (deg === '3') return iv.find(i => i === 3 || i === 4) ?? iv.find(i => i === 2 || i === 5) ?? null
|
||||||
|
if (deg === '5') return iv.find(i => i === 6 || i === 7 || i === 8) ?? null
|
||||||
|
if (deg === '7') return iv.find(i => i === 9 || i === 10 || i === 11) ?? null
|
||||||
|
return fixed[deg] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert one authored recipe into the MiniPiano `voicing` shape
|
||||||
|
// ({notes, pcs, bass, label, rootPc}). Placement follows the documented recipe
|
||||||
|
// convention (jazz/piano.js header): the order inside each hand IS the voicing
|
||||||
|
// order, low → high — so each note lands strictly above the previous one, in the
|
||||||
|
// nearest octave; the RH stacks on above the LH's top note (lh below rh). The
|
||||||
|
// octave anchor puts the bass in the first octave of MiniPiano's absolute-note
|
||||||
|
// space ([0,36], 0 = C3) — the first octave where the whole voicing fits — so
|
||||||
|
// the stack sits centrally in the rendered window. Returns null on ANY problem
|
||||||
|
// (missing/malformed recipe, unresolvable degree, span past the window) so the
|
||||||
|
// caller can fall back to the computed voicing for that station.
|
||||||
|
function recipeVoicing(recipe, rootPc, quality) {
|
||||||
|
try {
|
||||||
|
if (!recipe || typeof recipe !== 'object') return null
|
||||||
|
// Semitone offsets above the chord root, stacked strictly ascending.
|
||||||
|
const rel = []
|
||||||
|
const handLabels = []
|
||||||
|
let prev = null
|
||||||
|
for (const hand of ['LH', 'RH']) {
|
||||||
|
const degs = recipe[hand]
|
||||||
|
if (degs === undefined) continue
|
||||||
|
if (!Array.isArray(degs) || degs.length === 0) return null
|
||||||
|
for (const d of degs) {
|
||||||
|
const off = resolveDegree(String(d), quality)
|
||||||
|
if (off === null || off === undefined) return null
|
||||||
|
if (prev === null) {
|
||||||
|
prev = off // the bass voice sits at its plain offset above the root
|
||||||
|
} else {
|
||||||
|
let step = (((off - prev) % 12) + 12) % 12
|
||||||
|
if (step === 0) step = 12 // same pitch class → the next octave up
|
||||||
|
prev += step
|
||||||
|
}
|
||||||
|
rel.push(prev)
|
||||||
|
}
|
||||||
|
handLabels.push(`${hand} ${degs.join('-')}`)
|
||||||
|
}
|
||||||
|
if (rel.length === 0) return null
|
||||||
|
// Anchor: bass pitch class in the first octave; everything stacks above it.
|
||||||
|
const bass = (((rootPc + rel[0]) % 12) + 12) % 12
|
||||||
|
const notes = rel.map(r => bass + (r - rel[0]))
|
||||||
|
if (notes[notes.length - 1] > 36) return null // doesn't fit the keyboard window
|
||||||
|
return {
|
||||||
|
notes,
|
||||||
|
pcs: [...new Set(notes.map(n => ((n % 12) + 12) % 12))],
|
||||||
|
bass,
|
||||||
|
style: 'authored',
|
||||||
|
label: handLabels.join(' · '), // honest per-chord degrees, e.g. "LH 3-5-7-9"
|
||||||
|
rootPc,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function JamGuide({ detectedProgression, keyInfo, chordHistory = [], bpm, currentChord, onFocusChord, onChordClick }) {
|
export default function JamGuide({ detectedProgression, keyInfo, chordHistory = [], bpm, currentChord, onFocusChord, onChordClick }) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
@@ -144,10 +223,15 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
|
|||||||
// the tap-to-fretboard contract) plus an instrument-specific payload:
|
// the tap-to-fretboard contract) plus an instrument-specific payload:
|
||||||
// guitar → `shape`: from the recommended KB guitar play (the first play);
|
// guitar → `shape`: from the recommended KB guitar play (the first play);
|
||||||
// its `chords` array is canonical order too. No shape → graceful gap.
|
// its `chords` array is canonical order too. No shape → graceful gap.
|
||||||
// piano → `voicing`: COMPUTED via pianoVoicingChain over the whole loop in
|
// piano → `voicing`: an AUTHORED pack's recipes when the matched style ships
|
||||||
// canonical order, so each station's register threads from the
|
// piano plays for this progression (L-24 — first play, converted via
|
||||||
// previous one (minimal movement between stations). The station's
|
// recipeVoicing; the recipes carry their own voice-leading, so no
|
||||||
// rootPc is attached so MiniPiano marks the root key ("R") reliably.
|
// re-threading); otherwise COMPUTED via pianoVoicingChain over the
|
||||||
|
// whole loop in canonical order, so each station's register threads
|
||||||
|
// from the previous one (minimal movement between stations). A
|
||||||
|
// station whose recipe fails to resolve falls back to the computed
|
||||||
|
// chain individually. 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' && instrument !== 'piano')) return []
|
if (!match.matched || (instrument !== 'guitar' && instrument !== 'piano')) return []
|
||||||
const prog = match.progression
|
const prog = match.progression
|
||||||
@@ -174,9 +258,21 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
|
|||||||
stations[i].shape = chords[i]?.shape ?? null
|
stations[i].shape = chords[i]?.shape ?? null
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const chain = pianoVoicingChain(stations.map(({ rootPc, quality }) => ({ rootPc, quality })))
|
// Authored piano pack first (L-24): the matched style's first piano play
|
||||||
|
// for this progression, per-chord recipes resolved via recipeVoicing.
|
||||||
|
const pianoPlays = kb[match.style]?.instruments?.piano?.plays?.[prog?.id]
|
||||||
|
const play = Array.isArray(pianoPlays) && pianoPlays.length ? pianoPlays[0] : null
|
||||||
|
const authored = play
|
||||||
|
? stations.map((st, i) => recipeVoicing(play.chords?.[i]?.recipe, st.rootPc, st.quality))
|
||||||
|
: null
|
||||||
|
// Computed fallback — only built when needed (no pack, or a recipe that
|
||||||
|
// failed to resolve). Identical to the pre-L-24 computed path.
|
||||||
|
const chain = (!authored || authored.some(v => !v))
|
||||||
|
? pianoVoicingChain(stations.map(({ rootPc, quality }) => ({ rootPc, quality })))
|
||||||
|
: null
|
||||||
for (let i = 0; i < stations.length; i++) {
|
for (let i = 0; i < stations.length; i++) {
|
||||||
stations[i].voicing = chain[i] ? { ...chain[i], rootPc: stations[i].rootPc } : null
|
stations[i].voicing = authored?.[i]
|
||||||
|
?? (chain?.[i] ? { ...chain[i], rootPc: stations[i].rootPc } : null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return stations
|
return stations
|
||||||
@@ -480,8 +576,8 @@ function LicksSection({ styles, levels, onToggleLevel }) {
|
|||||||
//
|
//
|
||||||
// Composes RoadmapTrack (the improv highway) with a secondary voicing strip
|
// Composes RoadmapTrack (the improv highway) with a secondary voicing strip
|
||||||
// (one thumbnail per station, canonical KB order): ChordDiagram when a station
|
// (one thumbnail per station, canonical KB order): ChordDiagram when a station
|
||||||
// carries a guitar `shape`, MiniPiano when it carries a computed piano `voicing`
|
// carries a guitar `shape`, MiniPiano when it carries a piano `voicing` —
|
||||||
// (L-11 — the piano tab). Tapping a thumbnail enlarges it to a full view inline. The active station auto-scrolls
|
// authored (L-24) or computed (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({
|
||||||
|
|||||||
Reference in New Issue
Block a user