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:
vadimwit
2026-07-09 01:28:22 +01:00
parent 564a9e0a3a
commit 2165bb571c
+104 -8
View File
@@ -54,6 +54,85 @@ const SECTIONS = [
{ 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 }) {
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:
// 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.
// piano → `voicing`: an AUTHORED pack's recipes when the matched style ships
// piano plays for this progression (L-24 — first play, converted via
// recipeVoicing; the recipes carry their own voice-leading, so no
// 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(() => {
if (!match.matched || (instrument !== 'guitar' && instrument !== 'piano')) return []
const prog = match.progression
@@ -174,9 +258,21 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
stations[i].shape = chords[i]?.shape ?? null
}
} 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++) {
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
@@ -480,8 +576,8 @@ function LicksSection({ styles, levels, onToggleLevel }) {
//
// 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
// carries a guitar `shape`, MiniPiano when it carries a piano `voicing`
// 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
// panel scrolls vertically rather than forcing a wide horizontal layout.
function RoadmapAssembly({