// PianoLickCard — degree-based piano lick renderer (task D-60). // // Renders one structured piano lick from the KB `licks` schema for piano packs // (src/data/kb/SCHEMA.md "Piano licks"): degree-language notes realized over a // LIVE root — the same reasoning as BassPatternCard: the data transposes, the // card shows the actual keys under your hands right now. // // { id, name, level, chordContext, quality, techniques[], source?, tips?, // notes: [{ deg|approach, octave?, beat?, technique? }] } // ordered // // ── Realization (SCHEMA.md rules) ───────────────────────────────────────────── // Pitch space: MiniPiano's absolute-note window, 0 = C3, range [0, 36] — which // is ALSO chordAudio's playVoicing space (its doc: "0 = the C of the low // displayed octave = C3"), so realized notes drop straight into playback. // · The ROOT sits at its pitch class in the bottom octave: rootAbs = rootPc // (0–11). The schema caps every resolved offset at 25 semitones, so the // highest legal note is 11 + 25 = 36 — every lick fits in all 12 keys. // · A `deg` note: abs = rootAbs + resolveDegree(deg, lick.quality) + 12·octave. // `quality` is the lick's own machine truth (NOT the live chord's quality — // the lick states what it resolves through; the caller picks licks that fit). // · An `approach` note derives from the NEXT `deg` note in `notes[]` order // (scanning past intervening approaches): chrom-below = target − 1, // chrom-above = target + 1. Approaches are non-terminal by schema; a lick // ending on an approach (or any unresolvable note) nulls the realization and // the card renders an honest placeholder instead of lying. // // ── Visual design (thumb): pitch-timeline ───────────────────────────────────── // x = beat (proportional columns when every note carries a beat, LickCard-style // even columns otherwise), y = pitch — a piano-roll miniature. Chosen over // order-numbered keyboard dots for the thumb because a lick IS contour + rhythm: // the line shape reads at strip size without decoding numbers. Note markers are // LickCard's surface-pill idiom carrying NOTE NAMES (the actionable info for a // keyboard player), connected by a faint contour line; dashed accent lines mark // the root pitch ("R" gutter label) so "home" is visible at a glance. // // Colour tiers (established language, Fretboard/Piano/BassPatternCard): // chord tone → purple (ACCENT_SOFT text; root itself full ACCENT) // other scale degrees/extensions → gray-200 // approaches (borrowed chromatics) → amber, like BassPatternCard // ghost-note → parenthesised, dimmed (LickCard idiom) // // Technique glyphs (amber): grace-note = slashed mini-note crushed before its // target · slide = thicker amber glissando segment replacing that contour hop · // double-stop = stacked pills at one x (equal beats; or LickCard's column rule // beatless) · ghost-note = the parenthesised dim label. // renders the key ONCE per grid (LickCard's TechniqueLegend contract). // // `size="full"` adds: chordContext chip, an order-numbered keyboard view // (MiniPiano's cropped-window geometry), technique chips, tips, source — // mirroring LickCard's full-size behaviour. // // ── No playback (D-70 §3) ───────────────────────────────────────────────────── // The strip is purely visual — glance over audio (the user's settled call, "then // leave them off, better not"). The ▶ play path (sequencer + PlayButton) was // removed with the rest of the ▶s across the app; the card only renders now. // // ── Wiring contract (LicksStrip integration — Luthier, L-71) ────────────────── // // lick — one entry of a piano pack's top-level `licks` array // rootPc — the LIVE chord root pitch class (from the loop station) // chordLabel — optional display name for aria/captions (falls back to // NOTES[rootPc] + quality) // Pure/presentational; never crashes on bad data (placeholder instead). // // Design tokens (tailwind.config.js) — SVG fills can't read Tailwind classes, // so the constants below mirror the tokens (LickCard/MiniPiano convention). import { NOTES, CHORD_TYPES } from '../lib/theory' import { resolveDegree } from './JamGuide' // ─── Vocabulary (hand-synced with validate-kb.mjs PIANO_LICK_TECHNIQUES; the // smoke §7b-piano guard enforces set-equality — add to BOTH or neither) ──── export const PIANO_TECHNIQUE_VOCAB = ['slide', 'double-stop', 'ghost-note', 'grace-note'] // Chip symbol per technique (technique-tag chips, full size). const TECH_SYMBOL = { slide: '⟋', 'double-stop': '⋮', 'ghost-note': '( )', 'grace-note': '♪', } // ─── Palette (mirrors tailwind.config.js tokens + MiniPiano's key colours) ──── const ACCENT = '#a855f7' // token `accent` — the root (focal tone) const ACCENT_SOFT = '#c084fc' // lighter accent — chord tones (MiniPiano tier) const AMBER = '#f59e0b' // token `amber` — approaches + technique glyphs const NOTE_TEXT = '#e5e7eb' // gray-200 — non-chord-tone degrees const GHOST_TEXT = '#9ca3af' // gray-400 — ghost notes (dim but AA) const CONTOUR = '#3a3a3a' // contour line (decorative, LickCard string idiom) const LABEL = '#6b7280' // gray-500 — beat numbers / gutter microcopy const CARD_BG = '#0f0f0f' // token `surface` — backing pills const WHITE_FILL = '#f5f5f5' // MiniPiano key colours (keyboard view) const BLACK_FILL = '#1f2937' const WHITE_STROKE = '#374151' const BLACK_STROKE = '#111827' const APPROACH_DELTA = { 'chrom-below': -1, 'chrom-above': 1 } const ABS_MAX = 36 // MiniPiano window top key const mod12 = (n) => ((n % 12) + 12) % 12 // abs (0 = C3) → "A3"/"F4"… scientific spelling, for aria pitch lists. const absName = (abs) => `${NOTES[mod12(abs)]}${Math.floor(abs / 12) + 3}` /** * realizePianoLick(lick, rootPc) * → [{ abs, name, beat?, technique?, approach, chordTone, isRoot }] | null * * Pure realization per the header rules. Returns null on ANY malformed or * out-of-window note so the caller renders a placeholder — authored data is * validator-guaranteed, but a renderer must never crash on bad input. */ export function realizePianoLick(lick, rootPc) { if (!lick || typeof lick !== 'object' || !Number.isFinite(rootPc)) return null const notes = lick.notes if (!Array.isArray(notes) || notes.length === 0) return null const quality = lick.quality const intervals = CHORD_TYPES[quality]?.intervals if (!intervals) return null // quality is required machine truth (schema) const root = mod12(rootPc) const chordPcs = new Set(intervals.map(mod12)) // Pass 1: resolve every deg note's absolute pitch. const abs = new Array(notes.length).fill(null) for (let i = 0; i < notes.length; i++) { const n = notes[i] if (!n || typeof n !== 'object') return null const isDeg = n.deg !== undefined const isApp = n.approach !== undefined if (isDeg === isApp) return null // exactly one of deg | approach (schema) if (isDeg) { const off = resolveDegree(String(n.deg), quality) if (off === null || off === undefined) return null const oct = n.octave === undefined ? 0 : n.octave if (![0, 1, 2].includes(oct)) return null abs[i] = root + off + 12 * oct } } // Pass 2: approaches derive from the NEXT deg note (schema: never terminal). for (let i = 0; i < notes.length; i++) { if (abs[i] !== null) continue const delta = APPROACH_DELTA[notes[i].approach] if (delta === undefined) return null const target = abs.slice(i + 1).find((a) => a !== null) if (target === undefined) return null // approach with nothing to target abs[i] = target + delta } if (abs.some((a) => a < 0 || a > ABS_MAX)) return null // outside the window return notes.map((n, i) => ({ abs: abs[i], name: NOTES[mod12(abs[i])], beat: Number.isFinite(n.beat) ? n.beat : undefined, technique: typeof n.technique === 'string' ? n.technique : undefined, approach: n.approach !== undefined, chordTone: !(n.approach !== undefined) && chordPcs.has(mod12(abs[i] - root)), isRoot: !(n.approach !== undefined) && mod12(abs[i] - root) === 0, })) } // ─── Timeline layout (x = beat/column, y = pitch) ───────────────────────────── const PAD_T = 14 // headroom for grace glyphs above the top pill const PAD_B = 8 const PAD_L = 16 // gutter for the "R" root-line label const PAD_R = 12 const COL_W = 26 // beatless column pitch (LickCard's) const BEAT_ROW = 10 const SEMI_MIN = 3 // px per semitone bounds — wide licks compress, const SEMI_MAX = 7 // narrow licks stretch, so contour stays legible function layoutLick(realized, rootPc) { const hasBeats = realized.every((n) => Number.isFinite(n.beat)) && realized.every((n, i) => i === 0 || n.beat >= realized[i - 1].beat) // x: proportional to beat when authored (BassPatternCard), else LickCard // columns — where a double-stop stacks into the previous column. let xs, width, maxBeat = null, unit = null if (hasBeats) { const gaps = realized .map((n, i) => (i > 0 ? n.beat - realized[i - 1].beat : 0)) .filter((g) => g > 0) const minGap = gaps.length ? Math.min(...gaps) : 1 unit = Math.min(COL_W / minGap, COL_W * 4) xs = realized.map((n) => PAD_L + (n.beat - 1) * unit + 10) maxBeat = realized[realized.length - 1].beat width = PAD_L + (maxBeat - 1) * unit + 20 + PAD_R } else { let col = -1 xs = realized.map((n, i) => { const stacks = i > 0 && n.technique === 'double-stop' if (!stacks) col++ return PAD_L + col * COL_W + COL_W / 2 }) width = PAD_L + (col + 1) * COL_W + PAD_R } // y: linear pitch axis over the lick's own range. const lo = Math.min(...realized.map((n) => n.abs)) const hi = Math.max(...realized.map((n) => n.abs)) const range = Math.max(1, hi - lo) const semi = Math.min(SEMI_MAX, Math.max(SEMI_MIN, 66 / range)) const plotH = Math.max(26, range * semi) const yFor = (a) => PAD_T + plotH - (a - lo) * semi return { hasBeats, maxBeat, unit, width, height: PAD_T + plotH + PAD_B + (hasBeats ? BEAT_ROW : 0), plotBottom: PAD_T + plotH, // Root reference lines: every root-pc pitch inside the plotted range. rootYs: Array.from({ length: hi - lo + 1 }, (_, k) => lo + k) .filter((a) => mod12(a) === mod12(rootPc)) .map(yFor), notes: realized.map((n, i) => ({ ...n, x: xs[i], y: yFor(n.abs), label: n.technique === 'ghost-note' ? `(${n.name})` : n.name, ghost: n.technique === 'ghost-note', })), } } function pillColor(n) { if (n.approach) return AMBER if (n.ghost) return GHOST_TEXT if (n.isRoot) return ACCENT if (n.chordTone) return ACCENT_SOFT return NOTE_TEXT } // Grace ornament: slashed mini-note crushed just before its target pill. function GraceGlyph({ note }) { const cx = note.x - 12 const cy = note.y - 8 return ( ) } function TimelineSvg({ layout, ariaLabel }) { const { notes, width, height, hasBeats, maxBeat, unit, plotBottom, rootYs } = layout return ( {/* Root ("home") reference lines + gutter label */} {rootYs.map((y, i) => ( R ))} {/* Beat numbers (only when beats are authored; x mirrors note columns) */} {hasBeats && Array.from({ length: Math.floor(maxBeat + 1e-6) }, (_, b) => ( {b + 1} ))} {/* Contour: connect consecutive notes when x advances (dyad partners float stacked); a slide hop redraws its segment as a thicker amber glissando line. */} {notes.map((n, i) => { const prev = notes[i - 1] if (!prev || n.x <= prev.x) return null const slide = n.technique === 'slide' return ( ) })} {/* Grace ornaments (above the pills) */} {notes.map((n, i) => (n.technique === 'grace-note' ? : null))} {/* Note-name pills at pitch height — LickCard's fret-pill idiom */} {notes.map((n, i) => { const w = n.label.length * 5.2 + 4 return ( {n.label} ) })} ) } // ─── Keyboard view (full size): which keys, in what order ──────────────────── // MiniPiano's cropped-window geometry (whole-octave span + trailing high C), // replicated here because order badges aren't part of MiniPiano's contract. const WW = 22 const WH = 60 const BW = 14 const BH = 38 const WHITE_PCS = [0, 2, 4, 5, 7, 9, 11] const BLACK_OFFSETS = [ { pc: 1, afterWhite: 0 }, { pc: 3, afterWhite: 1 }, { pc: 6, afterWhite: 3 }, { pc: 8, afterWhite: 4 }, { pc: 10, afterWhite: 5 }, ] function LickKeyboard({ realized, ariaLabel }) { // abs → { orders: [1-based strike order…], tier } const hits = new Map() realized.forEach((n, i) => { const h = hits.get(n.abs) ?? { orders: [], isRoot: false, deg: false, approach: false } h.orders.push(i + 1) h.isRoot ||= n.isRoot h.deg ||= !n.approach h.approach ||= n.approach hits.set(n.abs, h) }) const lo = Math.min(...realized.map((n) => n.abs)) const hi = Math.max(...realized.map((n) => n.abs)) const octStart = Math.floor(lo / 12) const OCTAVES = Math.max(1, Math.ceil((hi - octStart * 12) / 12)) const TOTAL_WHITES = WHITE_PCS.length * OCTAVES + 1 const baseW = WW * TOTAL_WHITES + 2 const scale = 0.85 const fillFor = (h) => (h.deg ? (h.isRoot ? ACCENT : ACCENT_SOFT) : AMBER) const whites = [] for (let oct = 0; oct < OCTAVES; oct++) { for (let wi = 0; wi < WHITE_PCS.length; wi++) { const absWi = oct * WHITE_PCS.length + wi whites.push({ x: absWi * WW + 1, absWi, absNote: (octStart + oct) * 12 + WHITE_PCS[wi] }) } } whites.push({ x: OCTAVES * WHITE_PCS.length * WW + 1, absWi: OCTAVES * WHITE_PCS.length, absNote: (octStart + OCTAVES) * 12, }) const blacks = [] for (let oct = 0; oct < OCTAVES; oct++) { for (const { pc, afterWhite } of BLACK_OFFSETS) { blacks.push({ x: (oct * WHITE_PCS.length + afterWhite) * WW + WW - BW / 2, key: `${oct}-${pc}`, absNote: (octStart + oct) * 12 + pc, }) } } return ( {whites.map(({ x, absWi, absNote }) => { const h = hits.get(absNote) return ( {h && ( {h.orders.join('·')} )} ) })} {blacks.map(({ x, key, absNote }) => { const h = hits.get(absNote) return ( {h && ( {h.orders.join('·')} )} ) })} ) } // ─── Card chrome (LickCard's idiom) ─────────────────────────────────────────── function LevelBadge({ level }) { if (level === 'intermediate') { return ( intermediate ) } if (level === 'foundation') { return ( foundation ) } return null } function TechniqueChip({ tech }) { const symbol = TECH_SYMBOL[tech] return ( {symbol && } {tech} ) } function PlaceholderCard({ name, size }) { return (
{name ? `${name} — lick unavailable` : 'lick unavailable'}
) } /** * * Pure/presentational — realizes one KB piano lick over the live chord root; * never crashes on bad data. See the header for the full wiring contract. */ export default function PianoLickCard({ lick, rootPc, chordLabel, size = 'full' }) { const realized = realizePianoLick(lick, rootPc) const name = typeof lick?.name === 'string' && lick.name.trim() ? lick.name : 'Untitled lick' if (!realized) { return } const full = size === 'full' const chordName = typeof chordLabel === 'string' && chordLabel.trim() ? chordLabel : `${NOTES[mod12(rootPc)]}${CHORD_TYPES[lick.quality]?.suffix ?? lick.quality}` const chordContext = typeof lick.chordContext === 'string' && lick.chordContext.trim() ? lick.chordContext : null const techniques = Array.isArray(lick.techniques) ? lick.techniques.filter((t) => typeof t === 'string' && t.trim()) : [] const source = typeof lick.source === 'string' && lick.source.trim() ? lick.source : null const tips = typeof lick.tips === 'string' && lick.tips.trim() ? lick.tips : null const layout = layoutLick(realized, rootPc) const pitchNames = realized.map((n) => absName(n.abs)).join(', ') return (
{/* Header: name + level badge */}
{name}
{/* Where it lands */} {full && chordContext && (
{chordContext} · here: {chordName}
)} {/* The pitch timeline */}
{/* Keyboard view (full): the actual keys, numbered in strike order */} {full && (
)} {/* Technique tags */} {full && techniques.length > 0 && (
{techniques.map((t) => )}
)} {/* Transferable idea + attribution */} {full && tips &&

{tips}

} {full && source && ( {source} )}
) } // ─── Glyph legend — render ONCE per lick grid (LickCard contract) ───────────── function LegendSample({ children, w = 22 }) { return ( ) } export function PianoTechniqueLegend() { const items = [ { key: 'grace-note', label: 'grace note', sample: ( ), }, { key: 'slide', label: 'slide (key-slip)', sample: ( ), }, { key: 'double-stop', label: 'double-stop (stacked)', sample: ( G D ), }, { key: 'ghost-note', label: 'ghost note', sample: ( (E) ), }, ] return (
{items.map((it) => ( {it.sample} {it.label} ))} amber = chromatic approach · purple = chord tone · R line = the root
) } // ─── Dev fixture — SCHEMA.md's worked enclosure (P-60 real data replaces this // in the app; exported so the card can be exercised before P-60 lands) ───── export const DEMO_PIANO_LICK = { id: 'jazz-enclosure-into-3', name: 'Bebop enclosure into the 3rd', level: 'intermediate', chordContext: 'over the ii7', quality: 'min7', techniques: ['grace-note'], source: 'Barry Harris workshop vocabulary', notes: [ { deg: '5', octave: 1, beat: 1 }, { approach: 'chrom-above', beat: 2 }, { approach: 'chrom-below', beat: 2.5 }, { deg: '3', octave: 1, beat: 3, technique: 'grace-note' }, ], }