From 3c903ad1d9eebd270e8402059a799809d84758bc Mon Sep 17 00:00:00 2001 From: vadimwit Date: Wed, 8 Jul 2026 22:43:21 +0100 Subject: [PATCH] =?UTF-8?q?feat(design):=20VoicingBrowser=20=E2=80=94=20sw?= =?UTF-8?q?itchable,=20playable=20voicings=20per=20chord=20(task=20D-21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guitar row (placeable GUITAR_SHAPES via ChordDiagram) + piano row (4 pianoVoicing styles via MiniPiano), chips + explicit play button per voicing through chordAudio; single shared stop handle; graceful guitar-empty cell (only add9@E across 168 quality x root cells). voicings.js gains an export-only GUITAR_SHAPES line. Critic returned once (rootless voicings badged lowest voice as root — rootPc now passed through), PASS on scoped re-gate. Co-Authored-By: Claude Fable 5 --- src/components/VoicingBrowser.jsx | 294 ++++++++++++++++++++++++++++++ src/lib/voicings.js | 4 + 2 files changed, 298 insertions(+) create mode 100644 src/components/VoicingBrowser.jsx diff --git a/src/components/VoicingBrowser.jsx b/src/components/VoicingBrowser.jsx new file mode 100644 index 0000000..e512ed4 --- /dev/null +++ b/src/components/VoicingBrowser.jsx @@ -0,0 +1,294 @@ +// VoicingBrowser — a standalone, playable voicing browser for ONE chord (task D-21). +// +// For a given { rootPc, quality } it shows every way the KB knows to voice that +// chord, switchable via chips, each auditionable through the speakers: +// +// Guitar row — all `GUITAR_SHAPES[quality]` entries from src/lib/voicings.js +// that are placeable for this root (open shapes only in their native key, +// movable shapes only when the whole grip fits under fret 15), rendered via +// the existing ({rootStr, offsets} / {frets, onlyRoot} + rootPc). +// Piano row — the four src/lib/piano.js `pianoVoicing` styles +// (root / shell / rootlessA / rootlessB) rendered via , +// chips carrying each voicing's honest label (e.g. "rootless A (3-5-7-9)"). +// +// Playback: src/lib/chordAudio.js (L-20). Each row's ▶ plays the SELECTED +// voicing; the previous sound is always stopped via the returned {stop} handle +// before a new one starts (switching chips also stops it), so previews never +// layer. First ▶ click is the user gesture that lazily creates the AudioContext. +// +// Mount points (docs/design/knowledge-center.md §3 — wired by L-21/L-22, NOT here): +// Knowledge Center Voicings section, ChordDetailModal Guitar/Piano tabs, and the +// Jam Guide station-enlarge view. This component stays pure & prop-driven. +// +// 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) + +import { useEffect, useMemo, useRef, useState } from 'react' +import ChordDiagram from './ChordDiagram' +import MiniPiano from './MiniPiano' +import { GUITAR_SHAPES } from '../lib/voicings' +import { pianoVoicing, hasTrueSeventh } 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 ─────────────────────────────────────────────── + +// Selection chip. Active state puts small accent text on bg-surface (#0f0f0f), +// where accent #a855f7 measures ≈4.8:1 — AA for small text (surface-background +// rule); inactive text is gray-300 on surface (AA comfortable). +function Chip({ active, onClick, title, children }) { + return ( + + ) +} + +function PlayButton({ ariaLabel, onClick }) { + return ( + + ) +} + +function SectionHeading({ children }) { + return ( +

+ {children} +

+ ) +} + +// ─── The browser ────────────────────────────────────────────────────────────── + +export default function VoicingBrowser({ rootPc = 0, quality = 'maj' }) { + const pc = mod12(Number.isFinite(rootPc) ? rootPc : 0) + const name = chordName(pc, quality) + const chordKey = `${pc}:${quality}` + + const guitarShapes = useMemo(() => matchingShapes(quality, pc), [quality, pc]) + const pianoOptions = useMemo( + () => + PIANO_STYLES.map((style) => ({ + style, + voicing: pianoVoicing({ rootPc: pc, quality }, { style }), + })), + [pc, quality], + ) + + const [guitarIdx, setGuitarIdx] = useState(0) + const [pianoStyle, setPianoStyle] = useState(hasTrueSeventh(quality) ? 'shell' : 'root') + + // One live playback handle for the whole browser: any new play (or chip + // switch, chord change, 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 + } + + // New chord → reset selections, silence the old preview. + useEffect(() => { + setGuitarIdx(0) + setPianoStyle(hasTrueSeventh(quality) ? 'shell' : 'root') + stopCurrent() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [chordKey]) + + // Unmount → release whatever is still ringing. + useEffect(() => stopCurrent, []) + + // Guard against a stale index during the one render before the reset effect. + const gi = Math.min(guitarIdx, Math.max(0, guitarShapes.length - 1)) + const selectedShape = guitarShapes[gi] ?? null + const selectedPiano = + pianoOptions.find((o) => o.style === pianoStyle) ?? pianoOptions[0] + + // 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 row ── */} +
+
+ Guitar · {name} + {selectedShape && ( + playGuitar(selectedShape)} + /> + )} +
+ + {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) => ( + { + stopCurrent() + setGuitarIdx(i) + }} + > + {shape.label} + + ))} +
+
+ +
+ + )} +
+ + {/* ── Piano row ── */} +
+
+ Piano · {name} + playPiano(selectedPiano.voicing)} + /> +
+ +
+ {pianoOptions.map(({ style, voicing }) => ( + { + stopCurrent() + setPianoStyle(style) + }} + > + {voicing.label} + + ))} +
+ + {/* MiniPiano's SVG has a fixed pixel width (up to ~390px for 3 octaves); + scroll it on narrow columns rather than letting it break the layout. + 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. */} +
+ +
+
+ + {/* Mic-feedback caveat, per the L-20 header + D-20 §3 (microcopy tier). */} +

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

+
+ ) +} diff --git a/src/lib/voicings.js b/src/lib/voicings.js index ae67cd1..0e07e03 100644 --- a/src/lib/voicings.js +++ b/src/lib/voicings.js @@ -561,3 +561,7 @@ export function getPianoTechniques(chordName) { } export { parseChord } + +// D-21: expose the raw shape library for the VoicingBrowser (read-only consumer — +// it renders shapes via ChordDiagram and plays them via chordAudio.guitarShapeToNotes). +export { GUITAR_SHAPES }