Files
JamBuddy/src/components/VoicingBrowser.jsx
T
vadimwit 677d7b99d1 feat(rail): all stations expanded, playhead highlights only (task D-41)
Every loop chord is a permanently expanded vertical row: education
header (solo-scale label, guide-tone aim dots with honest 5th
fallback, voice-leading chip incl. the wrap-around loop rail) + the
full voicing gallery for the global instrument; galleries flex-wrap,
zero row-level horizontal scroll; playhead adds highlight classes only
(rows proven byte-equal modulo highlight artifacts); pin simplified to
a focus toggle (onFocusChord contract byte-compatible with L-40).
VoicingBrowser: show='bass' now renders an honest one-liner instead of
falling through to both galleries; dense drops section chrome
(non-dense proven byte-identical across 336 SSR combos). Critic PASS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 19:03:48 +01:00

304 lines
13 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 +
// <ChordDiagram size="thumb"/> + 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)") + <MiniPiano voicing size="thumb"/> +
// 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 011 (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 015 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 (
<button
type="button"
aria-label={ariaLabel}
onClick={onClick}
className={
'inline-flex h-7 shrink-0 items-center gap-1.5 rounded-full border border-accent ' +
'bg-surface px-2.5 text-xs font-semibold text-accent outline-none transition ' +
'hover:bg-accent hover:text-black focus-visible:ring-2 focus-visible:ring-accent'
}
>
<svg aria-hidden="true" viewBox="0 0 12 12" className="h-3 w-3 fill-current">
<path d="M2.5 1.5v9l8-4.5z" />
</svg>
Play
</button>
)
}
function SectionHeading({ children }) {
return (
<h4 className="text-[11px] font-semibold uppercase tracking-wider text-gray-400">
{children}
</h4>
)
}
// 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 (
<figure className="flex min-w-0 flex-col items-center gap-1.5 rounded-md border border-border bg-surface p-2">
<figcaption
className="max-w-full break-words text-center text-[11px] font-medium leading-tight text-gray-300"
title={label}
>
{label}
</figcaption>
{/* 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. */}
<div className="max-w-full overflow-x-auto">{children}</div>
<PlayButton ariaLabel={playLabel} onClick={onPlay} />
</figure>
)
}
// ─── 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 (
<div className="flex w-full min-w-0 flex-wrap gap-2">
{/* ── Guitar section: every placeable shape, side by side ── */}
{showGuitar && (
<section
aria-label={`Guitar voicings for ${name}`}
className={`min-w-[240px] flex-1 basis-[300px] ${dense ? '' : 'rounded-lg border border-border bg-panel p-3'}`}
>
{/* dense (a GlanceRail row): the row header already names the chord and
the global selector names the instrument — no repeated heading. */}
{!dense && (
<div className="mb-2">
<SectionHeading>Guitar · {name}</SectionHeading>
</div>
)}
{guitarShapes.length === 0 ? (
// Graceful: nothing placeable for this root/quality — say so, no crash.
<p className="text-xs text-gray-400">
No guitar shape sits comfortably for {name} try the piano voicings.
</p>
) : (
<div
role="group"
aria-label={`${name} guitar shapes — every shape shown, each playable`}
className="flex flex-wrap items-stretch gap-2"
>
{guitarShapes.map((shape, i) => (
<GalleryCell
key={`${shape.label}-${i}`}
label={shape.label}
playLabel={`Play ${name}${shape.label} guitar voicing`}
onPlay={() => playGuitar(shape)}
>
<ChordDiagram shape={shape} rootPc={pc} size="thumb" />
</GalleryCell>
))}
</div>
)}
</section>
)}
{/* ── Piano section: all four voicing styles, side by side ── */}
{showPiano && (
<section
aria-label={`Piano voicings for ${name}`}
className={`min-w-[240px] flex-1 basis-[300px] ${dense ? '' : 'rounded-lg border border-border bg-panel p-3'}`}
>
{!dense && (
<div className="mb-2">
<SectionHeading>Piano · {name}</SectionHeading>
</div>
)}
<div
role="group"
aria-label={`${name} piano voicings — every style shown, each playable`}
className="flex flex-wrap items-stretch gap-2"
>
{/* 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 }) => (
<GalleryCell
key={style}
label={voicing.label}
playLabel={`Play ${name}${voicing.label} piano voicing`}
onPlay={() => playPiano(voicing)}
>
<MiniPiano voicing={{ ...voicing, rootPc: pc }} size="thumb" />
</GalleryCell>
))}
</div>
</section>
)}
{/* ── 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 && (
<p className="text-xs text-gray-400">
No bass voicings for {name} yet authored bass patterns are on the way
(blues first). Guitar and piano voicings live under those instruments.
</p>
)}
{/* 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) && (
<p className="w-full basis-full text-[11px] text-gray-500">
Previews play through your speakers while the mic is live, detection may
hear them.
</p>
)}
</div>
)
}