// Mini piano keyboard. // // TWO render modes, chosen by props (additive — legacy path is the default): // // 1. LEGACY // Used by ChordDetailModal + ExplorePanel. `lh`/`rh` are semitone intervals // ABOVE the root (left hand shown blue, right hand shown purple), spanning a // 2-octave keyboard. Behaviour here is UNCHANGED — byte-for-byte the same // output the existing consumers have always rendered. // // 2. VOICING // Renders the output of src/lib/piano.js `pianoVoicing({rootPc,quality},opts)`: // voicing.notes — ABSOLUTE semitone key positions, 0 = C of the low octave, // range [0,36]. A note value `n` maps to the key `n` // semitones above the low C (octave = ⌊n/12⌋, pc = n%12). // voicing.pcs — pitch classes sounding. // voicing.bass — lowest absolute note (the LH anchor) — marked distinctly. // voicing.style / voicing.label — captions (shown in `full` size). // The root pitch class lights in accent purple; the bass key is ringed as the // LH anchor; the other voicing tones light in a lighter purple. // // Keyboard window (D-24): `size="thumb"` crops to the voicing's whole-octave // span — from the octave-start at-or-below the lowest sounded key to the // octave-end at-or-above the highest (plus the closing high C) — so a // ≤1-octave shell renders ONE octave (~140px) instead of 2–3 (~266–390px) // and Jam Guide station thumbs sit level with guitar ChordDiagram thumbs. // `size="full"` keeps the wide C-anchored 2–3-octave window: the enlarged // view benefits from register context, and its output stays byte-identical // to the D-10 behaviour. // // Design tokens (tailwind.config.js): accent #a855f7. The SVG also uses the // established Piano/Fretboard note language (accent purple for the focal tone). const WW = 22 // white key width const WH = 60 // white key height const BW = 14 // black key width const BH = 38 // black key height // White key pitch classes within an octave, in order const WHITE_PCS = [0, 2, 4, 5, 7, 9, 11] // C D E F G A B const WHITE_NAMES = ['C','D','E','F','G','A','B'] // Black key offsets (x position relative to white key 0) and pitch classes const BLACK_OFFSETS = [ { pc: 1, afterWhite: 0 }, // C# { pc: 3, afterWhite: 1 }, // D# { pc: 6, afterWhite: 3 }, // F# { pc: 8, afterWhite: 4 }, // G# { pc: 10, afterWhite: 5 }, // A# ] // ─── Design tokens (literal — SVG fills can't read Tailwind classes) ─────────── const ACCENT = '#a855f7' // text-accent — root pitch class (focal tone) const ACCENT_SOFT = '#c084fc' // lighter accent — non-root voicing tones const LH_BLUE = '#3b82f6' // legacy left-hand colour (unchanged) const WHITE_FILL = '#f5f5f5' const BLACK_FILL = '#1f2937' const WHITE_STROKE = '#374151' const BLACK_STROKE = '#111827' const BASS_RING = '#fbbf24' // amber ring marking the LH bass anchor (AA on keys) function legacyNoteColor(hand) { return hand === 'L' ? LH_BLUE : ACCENT } function handLabel(hand) { return hand === 'L' ? 'LH' : 'RH' } // ════════════════════════════════════════════════════════════════════════════ // LEGACY render path — {rootPc, lh, rh}. UNCHANGED from the original component. // ════════════════════════════════════════════════════════════════════════════ function LegacyPiano({ rootPc, lh = [], rh = [] }) { const OCTAVES = 2 const TOTAL_WHITES = WHITE_PCS.length * OCTAVES // 14 const SVG_W = WW * TOTAL_WHITES + 2 const SVG_H = WH + 24 // Build a set of highlighted notes: pc → { hand, interval } // We span 2 octaves (semitones 0…23 above root), mapped to absolute pitch classes const highlights = new Map() // absIdx → { color, label } function addNotes(intervals, hand) { for (const iv of intervals) { const octave = Math.floor(iv / 12) const pc = (rootPc + iv) % 12 highlights.set(`${octave}-${pc}`, { color: legacyNoteColor(hand), label: handLabel(hand) }) } } addNotes(lh, 'L') addNotes(rh, 'R') function isHighlighted(octave, pc) { return highlights.get(`${octave}-${pc}`) } // White keys const whites = [] for (let oct = 0; oct < OCTAVES; oct++) { for (let wi = 0; wi < WHITE_PCS.length; wi++) { const pc = WHITE_PCS[wi] const absWi = oct * WHITE_PCS.length + wi const x = absWi * WW + 1 const hl = isHighlighted(oct, pc) whites.push({ x, pc, oct, wi, absWi, hl, name: WHITE_NAMES[wi] + (oct + 4) }) } } // Black keys const blacks = [] for (let oct = 0; oct < OCTAVES; oct++) { for (const { pc, afterWhite } of BLACK_OFFSETS) { const absWi = oct * WHITE_PCS.length + afterWhite const x = absWi * WW + WW - BW / 2 const hl = isHighlighted(oct, pc) blacks.push({ x, pc, oct, hl }) } } return ( {/* White keys */} {whites.map(({ x, hl, name, absWi }) => ( {hl && ( {hl.label} )} ))} {/* Black keys */} {blacks.map(({ x, pc, oct, hl }, i) => ( {hl && ( {hl.label} )} ))} {/* Root label at bottom */} {whites.map(({ x, pc, oct, name, absWi }) => { const isRoot = pc === rootPc && oct === 0 if (!isRoot) return null return ( R ) })} ) } // ════════════════════════════════════════════════════════════════════════════ // VOICING render path — renders a pianoVoicing({notes,pcs,bass,style,label}). // `notes` are absolute semitone positions, 0 = C of the low octave, range [0,36]. // ════════════════════════════════════════════════════════════════════════════ function VoicingPiano({ voicing, size }) { const isFull = size === 'full' const notes = Array.isArray(voicing?.notes) ? voicing.notes : [] const rootPc = ((voicing?.rootPc ?? (notes.length ? notes[0] : 0)) % 12 + 12) % 12 const bass = typeof voicing?.bass === 'number' ? voicing.bass : (notes.length ? Math.min(...notes) : null) // ── Keyboard window (D-24) ────────────────────────────────────────────────── // `full`: the original wide window — C-anchored, 2–3 octaves, enough to contain // the highest note (byte-identical to the pre-D-24 output). // `thumb`: crop to the voicing's whole-octave span. The window starts at the // octave C at-or-below the lowest sounded key (octStart) and runs whole // octaves until the closing C at-or-above the highest key, so white-key // geometry stays simple and a ≤1-octave voicing renders ONE octave. // The trailing high C is always drawn (window note octStart*12 + // OCTAVES*12), so a top note landing exactly on the octave boundary // still shows without adding a whole extra octave. const maxNote = notes.length ? Math.max(...notes) : 0 const minNote = notes.length ? Math.min(...notes) : 0 const octStart = isFull ? 0 : Math.floor(minNote / 12) const OCTAVES = isFull ? Math.min(3, Math.max(2, Math.ceil((maxNote + 1) / 12))) : Math.max(1, Math.ceil((maxNote - octStart * 12) / 12)) // White keys: OCTAVES full octaves + 1 trailing C closing the top octave. const TOTAL_WHITES = WHITE_PCS.length * OCTAVES + 1 const scale = isFull ? 1 : 0.8 const baseW = WW * TOTAL_WHITES + 2 const SVG_W = baseW * scale const SVG_H = (WH + (isFull ? 26 : 4)) * scale // A highlighted absolute note → its render style. Keyed by absolute note value. const noteSet = new Set(notes) function styleFor(absNote) { if (!noteSet.has(absNote)) return null const pc = ((absNote % 12) + 12) % 12 return { isRoot: pc === rootPc, isBass: absNote === bass, color: pc === rootPc ? ACCENT : ACCENT_SOFT, } } // White keys across OCTAVES octaves + trailing C. `oct` is window-relative // (drives x geometry); absNote adds octStart so highlights land on the right // keys inside a cropped window. const whites = [] for (let oct = 0; oct < OCTAVES; oct++) { for (let wi = 0; wi < WHITE_PCS.length; wi++) { const pc = WHITE_PCS[wi] const absWi = oct * WHITE_PCS.length + wi const absNote = (octStart + oct) * 12 + pc // absolute semitone of this white key const x = absWi * WW + 1 whites.push({ x, absWi, absNote, hl: styleFor(absNote) }) } } // Trailing high C (top of the renderable window, e.g. note 36 when OCTAVES=3). { const absWi = OCTAVES * WHITE_PCS.length const absNote = (octStart + OCTAVES) * 12 whites.push({ x: absWi * WW + 1, absWi, absNote, hl: styleFor(absNote) }) } // Black keys across OCTAVES octaves. const blacks = [] for (let oct = 0; oct < OCTAVES; oct++) { for (const { pc, afterWhite } of BLACK_OFFSETS) { const absWi = oct * WHITE_PCS.length + afterWhite const absNote = (octStart + oct) * 12 + pc const x = absWi * WW + WW - BW / 2 blacks.push({ x, pc, oct, absNote, hl: styleFor(absNote) }) } } const ariaLabel = `Piano voicing${voicing?.label ? `: ${voicing.label}` : ''}` return ( {/* White keys */} {whites.map(({ x, hl, absWi }) => ( {hl?.isBass && ( )} {hl?.isRoot && ( R )} ))} {/* Black keys */} {blacks.map(({ x, pc, oct, hl }) => ( {hl?.isBass && ( )} {hl?.isRoot && ( R )} ))} {/* Caption (full size only): label + style */} {isFull && voicing?.label && ( {voicing.label} )} ) } // ════════════════════════════════════════════════════════════════════════════ // Public component — dispatches on whether `voicing` is supplied (additive). // ════════════════════════════════════════════════════════════════════════════ export default function MiniPiano({ rootPc, lh = [], rh = [], voicing, size = 'thumb' }) { if (voicing) { return } return }