// VoicingBrowser — a playable voicing GALLERY for ONE chord (task D-30; was the // chip-switched browser of D-21/D-23). // // For a given { rootPc, quality } it shows every way the KB knows to voice that // chord — ALL AT ONCE, no chips, no selection state (user directive 2026-07-10: // "see all the variations G shape, C shape, etc in one view without having to // push a button. so they all line up next to each other"): // // Guitar section — every placeable `GUITAR_SHAPES[quality]` entry from // src/lib/voicings.js (open shapes only in their native key, movable shapes // only when the whole grip fits under fret 15), each cell = shape label + // + its own ▶. // Piano section — all four src/lib/piano.js `pianoVoicing` styles // (root / shell / rootlessA / rootlessB), each cell = the voicing's honest // label (e.g. "rootless A (3-5-7-9)") + + // its own ▶. // // Playback: src/lib/chordAudio.js (L-20). ONE live {stop} handle for the whole // gallery — any ▶ stops the previous sound before starting (chord change and // unmount also stop it), so previews never layer. First ▶ click is the user // gesture that lazily creates the AudioContext. // // Mount points (wired by L-21/L-22, NOT here): Knowledge Center Voicings // section, ChordDetailModal Guitar/Piano tabs (show="guitar"/"piano", L-25), // and the Jam Guide station-enlarge view. This component stays pure & prop-driven. // // Layout: each instrument section is a flex-wrap gallery of fixed-content-width // cells, so it reflows to fewer columns (down to one cell per row) inside a // narrow modal or the Jam Guide dock — no horizontal scroll needed except the // per-cell guard around the widest MiniPiano thumbs (~266px for 2-octave // rootless voicings). // // Props: // rootPc — chord root pitch class 0–11 (default 0 = C) // quality — CHORD_TYPES key; unknown values fall back to 'maj' // (matching voicings.js / piano.js behaviour) // show — 'guitar' | 'piano' | 'both' (default 'both', task D-23): which // instrument section(s) to render. 'bass' (task D-41, D-40 §3) // renders NEITHER gallery — guitar shapes are not bass patterns and // pianoVoicing is piano, so showing either under the global BASS // selector would lie; an honest one-liner renders instead. Any // OTHER value still falls back to both, so every pre-existing // mount renders identically with no prop. // dense — boolean (default false, task L-33 — additive per D-31 §5; D-41 // restyled it for the all-expanded GlanceRail rows): drops the // section chrome (border/panel background/heading — the row header // already names the chord) and suppresses the per-mount // mic-feedback microcopy (the rail shows it ONCE for the whole // rail, D-31 §2.5). Every pre-existing mount renders identically // with no prop. import { useEffect, useMemo, useRef } from 'react' import ChordDiagram from './ChordDiagram' import MiniPiano from './MiniPiano' import { GUITAR_SHAPES } from '../lib/voicings' import { pianoVoicing } from '../lib/piano' import { playVoicing, guitarShapeToNotes } from '../lib/chordAudio' import { NOTES, CHORD_TYPES } from '../lib/theory' // Standard-tuning open-string pitch classes, low-E first (mirrors ChordDiagram). const OPEN_PCS = [4, 9, 2, 7, 11, 4] const PIANO_STYLES = ['root', 'shell', 'rootlessA', 'rootlessB'] const mod12 = (n) => ((n % 12) + 12) % 12 // The shapes of `quality` that can actually be shown for this root: // - open shapes only when their native root matches (onlyRoot === rootPc); // - movable shapes only when every fretted string lands in 0–15 under // ChordDiagram's placement convention (root-at-open-string → fret-12 barre). // Unknown quality falls back to maj (same fallback voicings.js itself uses). function matchingShapes(quality, rootPc) { const shapes = GUITAR_SHAPES[quality] ?? GUITAR_SHAPES.maj return shapes.filter((shape) => { if (Array.isArray(shape.frets)) { // Open shape: fixed grip, valid only in its native key. return shape.onlyRoot === undefined || shape.onlyRoot === rootPc } if (!Array.isArray(shape.offsets) || !Number.isFinite(shape.rootStr)) return false const idx = 6 - shape.rootStr // rootStr 6 = low E → low-E-first index 0 let baseFret = mod12(rootPc - (OPEN_PCS[idx] ?? 4)) if (baseFret === 0) baseFret = 12 // ChordDiagram's octave-barre placement const abs = shape.offsets.filter((o) => typeof o === 'number').map((o) => baseFret + o) if (abs.length === 0) return false return Math.min(...abs) >= 0 && Math.max(...abs) <= 15 }) } // "C", "Cm7", "Cmaj7"… — display name from the app's canonical chord model. function chordName(rootPc, quality) { const q = CHORD_TYPES[quality] ? quality : 'maj' return `${NOTES[mod12(rootPc)]}${CHORD_TYPES[q].suffix}` } // ─── Small presentational atoms ─────────────────────────────────────────────── // Per-cell ▶. Small accent text sits on bg-surface (#0f0f0f), where accent // #a855f7 measures ≈4.8:1 — AA for small text (surface-background rule). function PlayButton({ ariaLabel, onClick }) { return ( ) } function SectionHeading({ children }) { return (

{children}

) } // One gallery cell: label on top, diagram thumb, its own ▶ underneath. // bg-surface inside the bg-panel section gives the cells a quiet inlay border; // label is gray-300 on surface (AA comfortable at 11px semibold). function GalleryCell({ label, playLabel, onPlay, children }) { return (
{label}
{/* Scroll guard: MiniPiano's SVG has a fixed pixel width (up to ~266px for a 2-octave thumb window); scroll inside the cell on very narrow viewports rather than letting it break the wrap layout. */}
{children}
) } // ─── The gallery ────────────────────────────────────────────────────────────── export default function VoicingBrowser({ rootPc = 0, quality = 'maj', show = 'both', dense = false }) { const pc = mod12(Number.isFinite(rootPc) ? rootPc : 0) const name = chordName(pc, quality) const chordKey = `${pc}:${quality}` // Section gating (D-23; 'bass' added by D-41 per D-40 §3). 'guitar' hides // the piano section, 'piano' hides the guitar section, 'bass' hides BOTH // (neither gallery is honest for a bassist — the one-liner below renders // instead, so the dock's VoicingsSection under the global BASS selector // stops showing guitar+piano). Anything else (incl. the 'both' default) // shows both. Hooks stay unconditional; the shared stop-handle discipline // (stop on chord change / unmount) is untouched by hiding a section. const showGuitar = show !== 'piano' && show !== 'bass' const showPiano = show !== 'guitar' && show !== 'bass' const guitarShapes = useMemo(() => matchingShapes(quality, pc), [quality, pc]) const pianoOptions = useMemo( () => PIANO_STYLES.map((style) => ({ style, voicing: pianoVoicing({ rootPc: pc, quality }, { style }), })), [pc, quality], ) // One live playback handle for the whole gallery: any new play (or chord // change, or unmount) stops the previous sound first — the L-20 {stop} // contract, so previews never layer or leak. const handleRef = useRef(null) const stopCurrent = () => { handleRef.current?.stop() handleRef.current = null } // Chord change → cleanup silences the old preview; same cleanup covers unmount. useEffect(() => stopCurrent, [chordKey]) // Known advisory (L-20 gate): when a movable shape's root lands on an open // string (base fret 0), ChordDiagram draws the fret-12 octave barre while // guitarShapeToNotes places the grip at the open position — the SAME chord, // one octave lower than drawn. Deliberately left as-is on both sides. function playGuitar(shape) { stopCurrent() handleRef.current = playVoicing(guitarShapeToNotes(shape, { rootPc: pc }), { strumMs: 45, // a light strum reads "guitar" durMs: 1800, }) } function playPiano(voicing) { stopCurrent() handleRef.current = playVoicing(voicing?.notes ?? [], { strumMs: 15, // near-block chord reads "piano" durMs: 1800, }) } return (
{/* ── Guitar section: every placeable shape, side by side ── */} {showGuitar && (
{/* dense (a GlanceRail row): the row header already names the chord and the global selector names the instrument — no repeated heading. */} {!dense && (
Guitar · {name}
)} {guitarShapes.length === 0 ? ( // Graceful: nothing placeable for this root/quality — say so, no crash.

No guitar shape sits comfortably for {name} — try the piano voicings.

) : (
{guitarShapes.map((shape, i) => ( playGuitar(shape)} > ))}
)}
)} {/* ── Piano section: all four voicing styles, side by side ── */} {showPiano && (
{!dense && (
Piano · {name}
)}
{/* pianoVoicing() output carries no rootPc, and without it VoicingPiano falls back to the LOWEST voice for its "R" badge — wrong for rootless voicings, whose bass is the 3rd (A) or 7th (B). Supply the chord root. */} {pianoOptions.map(({ style, voicing }) => ( playPiano(voicing)} > ))}
)} {/* ── show='bass' (D-41, D-40 §3): no gallery would be honest — say so in one line instead of rendering guitar+piano under BASS. ── */} {!showGuitar && !showPiano && (

No bass voicings for {name} yet — authored bass patterns are on the way (blues first). Guitar and piano voicings live under those instruments.

)} {/* Mic-feedback caveat, per the L-20 header + D-20 §3 (microcopy tier). Skipped under `dense`, where the GlanceRail shows the SAME microcopy once for the whole rail (D-31 §2.5) instead of per gallery — and under 'bass', where there is no ▶ to caveat. */} {!dense && (showGuitar || showPiano) && (

Previews play through your speakers — while the mic is live, detection may hear them.

)}
) }