diff --git a/src/components/PianoLickCard.jsx b/src/components/PianoLickCard.jsx new file mode 100644 index 0000000..1c23b01 --- /dev/null +++ b/src/components/PianoLickCard.jsx @@ -0,0 +1,694 @@ +// 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. +// +// ── Playback (▶) ────────────────────────────────────────────────────────────── +// BassPatternCard's exact pattern: sequential single-note playVoicing calls +// (order lives in setTimeout scheduling — playVoicing sorts/dedupes, wrong for +// a melody), beats at a fixed preview tempo (even eighths beatless), ONE +// sequence module-wide + stopAll() so it never layers over other previews, +// unmount silences. Techniques are visual-only in playback (same precedent). +// +// ── Wiring contract (future LicksStrip integration — Luthier) ───────────────── +// +// 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 { useEffect } from 'react' +import { NOTES, CHORD_TYPES } from '../lib/theory' +import { resolveDegree } from './JamGuide' +import { playVoicing, stopAll } from '../lib/chordAudio' + +// ─── 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, + })) +} + +// ─── Sequential playback (module-level: one lick at a time, app-wide) ───────── + +const PREVIEW_BPM = 96 // BassPatternCard's relaxed preview tempo + +let currentSeq = null // { timeouts: number[], handles: {stop}[] } + +function stopLick() { + if (!currentSeq) return + for (const t of currentSeq.timeouts) clearTimeout(t) + for (const h of currentSeq.handles) h.stop() + currentSeq = null +} + +function playLick(realized) { + stopLick() + stopAll() // never layer over a VoicingBrowser (or any other) preview + const beatMs = 60000 / PREVIEW_BPM + const hasBeats = realized.every((n) => Number.isFinite(n.beat)) + const times = realized.map((n, i) => (hasBeats ? (n.beat - 1) * beatMs : (i * beatMs) / 2)) + const seq = { timeouts: [], handles: [] } + realized.forEach((n, i) => { + // Ring until the next distinct onset (equal beats = a dyad, same onset); + // the last note gets one beat. Small floor so tight ornaments still sound. + const nextT = times.slice(i + 1).find((t) => t > times[i]) + const durMs = Math.max(160, (nextT !== undefined ? nextT - times[i] : beatMs) + 120) + seq.timeouts.push( + setTimeout(() => { + // abs is already in chordAudio's note space (0 = C3) — no offset. + seq.handles.push(playVoicing([n.abs], { strumMs: 0, durMs, gain: 0.5 })) + }, times[i]), + ) + }) + currentSeq = seq +} + +// ─── 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} + + ) +} + +// Same ▶ pill as BassPatternCard / VoicingBrowser (classes mirrored so every +// gallery reads identically). +function PlayButton({ ariaLabel, onClick }) { + return ( + + ) +} + +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' + + // Unmount (loop/style/instrument change) silences any running sequence — + // module-level state, so this is idempotent across sibling cards. + useEffect(() => () => stopLick(), []) + + 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} + )} + + playLick(realized)} + /> +
+ ) +} + +// ─── 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' }, + ], +}