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 (
+
+
+ {/* 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 }