diff --git a/src/components/BassPatternCard.jsx b/src/components/BassPatternCard.jsx new file mode 100644 index 0000000..2576c0a --- /dev/null +++ b/src/components/BassPatternCard.jsx @@ -0,0 +1,415 @@ +// BassPatternCard — per-station authored bass pattern as compact 4-string tab +// (task L-42, per docs/design/integrated-glance.md §3's gallery-slot contract +// and SCHEMA.md "Bass play"). +// +// Given a station's { rootPc, quality }, one play's per-station `pattern`, and +// the NEXT station's root (approach pitches derive from it — the loop wraps), +// this renders the REALIZED line: the degree/approach data resolved to actual +// pitches and placed on the bass. Visually it rhymes with LickCard's tab SVG +// (same string-line + surface-pill-number language, same technique-glyph +// vocabulary) but with the bass rendering convention from SCHEMA.md: +// +// standard 4-string tuning E–A–D–G, string 1 = G (HIGHEST) rendered on top, +// string 4 = low E at the bottom — the same "1 = highest string" counting as +// lick tab and `rootStr`, so no third scheme exists in this codebase. +// +// ── Realization rules (SCHEMA.md "Bass play") ───────────────────────────────── +// Pitch space: semitones above open low E (E1). Open strings sit at 0 (E), +// 5 (A), 10 (D), 15 (G); the playable ceiling is 30 (G string, fret 15). +// +// · The station ROOT lands on its lowest playable position: +// rootAbs = (rootPc − 4) mod 12 (open E's pitch class is 4). +// · A `deg` note sits at rootAbs + resolveDegree(deg, quality) + 12·octave. +// The schema caps the resolved offset at 19 semitones, so every deg note +// fits ≤ 30 by construction (11 + 19); anything unresolvable nulls the +// whole pattern and the card renders its placeholder instead of lying. +// · An `approach` note's PITCH CLASS derives from the next station's root: +// chrom-below = next−1 · chrom-above = next+1 · fifth-of-next = next+7 +// (mod 12). Its OCTAVE is the placement nearest the previous realized note +// (approaches are terminal, so a previous note always exists), which keeps +// the walkup/walkdown contour the author described — e.g. the 12-bar bar-4 +// walkup C–D–E♭ lands its E right above the E♭, not two octaves away. +// · 🚨 C-41 gate rule — the open-E floor: a chrom-below into a next root at +// open E (abs 0) has NO pitch below the instrument (−1 does not exist). +// When the nearest placement falls below abs 0 it is OCTAVE-DISPLACED up +// (+12): the approach plays a half-step under the OCTAVE root instead — +// e.g. D♯ at abs 11 leading into E. The symmetric ceiling guard (> 30 → +// −12) exists for completeness. This is the displacement rule the ledger +// row mandates; keep it. +// · String/fret: each realized pitch takes the HIGHEST-tuned open string at +// or below it — the minimum-fret assignment. Because the root starts at +// its lowest position (fret ≤ 11 worst case, usually ≤ 8) and strings are +// tuned in fourths, the whole pattern lands in one hand position within +// frets 0–15 (the schema's ≤ 19-semitone span guarantee). +// +// ── Rendering ───────────────────────────────────────────────────────────────── +// Beat spacing when every note carries a `beat` (columns proportional to beat, +// faint beat numbers under the bottom string); plain even columns otherwise. +// Approach notes draw AMBER (the established secondary-tone colour) with a +// small arrow after the final one — they belong to the NEXT chord. Technique +// marks reuse the lick glyph vocabulary in its light form (amber letter/symbol +// above the note; ghost notes parenthesise and dim, as in LickCard). +// +// ── Playback (▶) ────────────────────────────────────────────────────────────── +// Sequential single-note scheduling through chordAudio's playVoicing (one call +// per note — playVoicing itself sorts/dedupes, which is wrong for a melodic +// line, so the ORDER lives here in setTimeout scheduling). Bass register: +// realized abs − 20 puts open E at chordAudio note −20 = E1 ≈ 41.2 Hz +// (chordAudio's space is 0 = C3; negatives are documented-legal input). +// Timing: beats at a fixed preview tempo when present, else even eighths. +// One pattern at a time module-wide: each play stops the previous sequence +// AND calls stopAll() so it never layers over a VoicingBrowser preview. + +import { useEffect } from 'react' +import { NOTES } from '../lib/theory' +import { resolveDegree } from './JamGuide' +import { playVoicing, stopAll } from '../lib/chordAudio' + +// SVG palette — mirrors LickCard's token constants (SVG fills can't read +// Tailwind classes): amber = technique/approach, surface = backing pills. +const AMBER = '#f59e0b' +const FRET_TEXT = '#e5e7eb' // gray-200 +const GHOST_TEXT = '#9ca3af' // gray-400 +const STRING_LINE = '#3a3a3a' +const STRING_LABEL = '#6b7280' // gray-500 +const BEAT_LABEL = '#6b7280' +const CARD_BG = '#0f0f0f' // token `surface` + +// Strings top → bottom: 1 = G (highest), 4 = low E. `open` = semitones above E1. +const STRINGS = [ + { n: 1, name: 'G', open: 15 }, + { n: 2, name: 'D', open: 10 }, + { n: 3, name: 'A', open: 5 }, + { n: 4, name: 'E', open: 0 }, +] +const OPEN_E_PC = 4 // pitch class of the low E string +const ABS_MAX = 30 // G string fret 15 — the playable ceiling + +// Approach-type → semitone delta from the NEXT station's root (SCHEMA.md). +const APPROACH_DELTA = { 'chrom-below': -1, 'chrom-above': 1, 'fifth-of-next': 7 } + +// Technique mark drawn above the note (light form of LickCard's glyphs; the +// full vocabulary is legal per SCHEMA.md — ghost-note is handled by the +// parenthesised dim label, double-stop by equal beats, chromatic-approach and +// unknown words get no mark, exactly like LickCard's graceful default). +const TECH_MARK = { + 'hammer-on': 'h', + 'pull-off': 'p', + slide: '⟋', + bend: '↑', + vibrato: '~', +} + +const mod12 = (n) => ((n % 12) + 12) % 12 + +// abs (semitones above open E1 = MIDI 28) → "E1"/"C2"… scientific spelling. +function absName(abs) { + return `${NOTES[mod12(abs + OPEN_E_PC)]}${Math.floor((abs + 28) / 12) - 1}` +} + +// Min-fret string assignment: highest-tuned open string at or below the pitch. +function placeOnString(abs) { + for (const s of STRINGS) { + if (abs >= s.open && abs - s.open <= 15) return { string: s.n, fret: abs - s.open } + } + return null // abs outside [0, 30] — caller nulls the pattern +} + +/** + * realizeBassPattern(pattern, rootPc, quality, nextRootPc) + * → [{ abs, string, fret, beat?, technique?, approach }] | null + * + * Pure realization per the header rules. Returns null on ANY malformed or + * unplaceable note so the caller can render a placeholder — authored data is + * validator-guaranteed, but a renderer must never crash on bad input. + */ +export function realizeBassPattern(pattern, rootPc, quality, nextRootPc) { + if (!Array.isArray(pattern) || pattern.length === 0) return null + if (!Number.isFinite(rootPc)) return null + const rootAbs = mod12(rootPc - OPEN_E_PC) // lowest playable root position + const out = [] + let prev = rootAbs // approach placement anchor (root until a note lands) + for (const note of pattern) { + if (!note || typeof note !== 'object') return null + let abs + if (note.deg !== undefined && note.approach === undefined) { + const off = resolveDegree(String(note.deg), quality) + if (off === null || off === undefined) return null + abs = rootAbs + off + (note.octave === 1 ? 12 : 0) + } else if (note.approach !== undefined && note.deg === undefined) { + const delta = APPROACH_DELTA[note.approach] + if (delta === undefined || !Number.isFinite(nextRootPc)) return null + const pc = mod12(nextRootPc + delta) + const base = mod12(pc - OPEN_E_PC) + // Nearest octave placement to the previous note (ties resolve upward). + abs = base + 12 * Math.round((prev - base) / 12) + // C-41 open-E floor: chrom-below into a next root at open E resolves to + // abs −1, which does not exist on the instrument — octave-displace the + // approach up (+12: a half-step under the OCTAVE root). Ceiling mirror. + if (abs < 0) abs += 12 + if (abs > ABS_MAX) abs -= 12 + } else { + return null // exactly one of deg | approach per note (schema) + } + const placed = placeOnString(abs) + if (!placed) return null + out.push({ + abs, + string: placed.string, + fret: placed.fret, + beat: Number.isFinite(note.beat) ? note.beat : undefined, + technique: typeof note.technique === 'string' ? note.technique : undefined, + approach: note.approach !== undefined, + }) + prev = abs + } + return out +} + +// ─── Sequential playback (module-level: one pattern at a time, app-wide) ───── + +const PREVIEW_BPM = 96 // relaxed shuffle-ish preview tempo (display is tempo-free) + +let currentSeq = null // { timeouts: number[], handles: {stop}[] } + +function stopPattern() { + if (!currentSeq) return + for (const t of currentSeq.timeouts) clearTimeout(t) + for (const h of currentSeq.handles) h.stop() + currentSeq = null +} + +function playPattern(realized) { + stopPattern() + stopAll() // never layer over a VoicingBrowser (or any other) preview + const beatMs = 60000 / PREVIEW_BPM + const hasBeats = realized.every((n) => Number.isFinite(n.beat)) + // Beats drive onsets when authored; otherwise even eighths. + const times = realized.map((n, i) => (hasBeats ? (n.beat - 1) * beatMs : (i * beatMs) / 2)) + const seq = { timeouts: [], handles: [] } + realized.forEach((n, i) => { + // Ring until the next distinct onset (equal beats = a dyad, same onset); + // the last note gets one beat. Small floor so ghost-short gaps still sound. + const nextT = times.slice(i + 1).find((t) => t > times[i]) + const durMs = Math.max(160, (nextT !== undefined ? nextT - times[i] : beatMs) + 120) + seq.timeouts.push( + setTimeout(() => { + // abs − 20: open E1 in chordAudio's 0 = C3 note space (negatives legal). + seq.handles.push(playVoicing([n.abs - 20], { strumMs: 0, durMs, gain: 0.5 })) + }, times[i]), + ) + }) + currentSeq = seq +} + +// ─── SVG geometry (LickCard's idiom, 4 strings) ─────────────────────────────── + +const STR_GAP = 13 +const PAD_T = 14 +const PAD_B = 6 +const PAD_L = 20 +const PAD_R = 12 +const COL_W = 26 +const BEAT_ROW = 10 // extra bottom room for beat numbers when beats render + +function layoutPattern(realized) { + const hasBeats = + realized.every((n) => Number.isFinite(n.beat)) && + realized.every((n, i) => i === 0 || n.beat >= realized[i - 1].beat) + let xs, width, maxBeat, unit = null + if (hasBeats) { + // Columns proportional to beat; scale so the tightest gap ≥ one column. + const gaps = realized + .map((n, i) => (i > 0 ? n.beat - realized[i - 1].beat : 0)) + .filter((g) => g > 0) + const minGap = gaps.length ? Math.min(...gaps) : 1 + unit = Math.min(COL_W / minGap, COL_W * 4) + xs = realized.map((n) => PAD_L + (n.beat - 1) * unit + 10) + maxBeat = realized[realized.length - 1].beat + width = PAD_L + (maxBeat - 1) * unit + 20 + PAD_R + } else { + xs = realized.map((_, i) => PAD_L + i * COL_W + COL_W / 2) + width = PAD_L + realized.length * COL_W + PAD_R + maxBeat = null + } + return { + hasBeats, + maxBeat, + unit, + width, + height: PAD_T + 3 * STR_GAP + PAD_B + (hasBeats ? BEAT_ROW : 0), + notes: realized.map((n, i) => ({ + ...n, + x: xs[i], + y: PAD_T + (n.string - 1) * STR_GAP, + ghost: n.technique === 'ghost-note', + label: n.technique === 'ghost-note' ? `(${n.fret})` : String(n.fret), + })), + } +} + +function PatternSvg({ layout, ariaLabel }) { + const { notes, width, height, hasBeats, maxBeat, unit } = layout + const bottomY = PAD_T + 3 * STR_GAP + return ( + + {/* String lines — string 1 (G) on top, string 4 (low E) at the bottom. */} + {STRINGS.map((s, i) => { + const y = PAD_T + i * STR_GAP + return ( + + + + {s.name} + + + ) + })} + + {/* Beat numbers under the bottom string (only when beats are authored; + x mirrors the note columns: beat 1 at PAD_L + 10, `unit` px per beat). */} + {hasBeats && + Array.from({ length: Math.floor(maxBeat + 1e-6) }, (_, b) => ( + + {b + 1} + + ))} + + {/* Technique marks (light lick vocabulary) above their notes. */} + {notes.map((n, i) => + TECH_MARK[n.technique] ? ( + + {TECH_MARK[n.technique]} + + ) : null, + )} + + {/* Fret numbers on surface pills; approach notes amber (next chord's). */} + {notes.map((n, i) => { + const w = n.label.length * 5.2 + 3 + const isLast = i === notes.length - 1 + return ( + + + + {n.label} + + {/* The final approach points at the next chord. */} + {n.approach && isLast && ( + + → + + )} + + ) + })} + + ) +} + +// ─── The card ───────────────────────────────────────────────────────────────── + +// Same ▶ pill as VoicingBrowser's gallery cells (its PlayButton is file-local; +// classes mirrored so the two galleries read identically). +function PlayButton({ ariaLabel, onClick }) { + return ( + + ) +} + +/** + * + * + * Pure/presentational gallery cell for one station of one bass play. Renders + * the realized 4-string tab + the authored per-station note + its own ▶. + * Malformed/unplaceable patterns render an honest placeholder, never crash. + */ +export default function BassPatternCard({ + rootPc, + quality, + nextRootPc, + pattern, + playLabel, + feel, + note, + chordLabel, +}) { + const realized = realizeBassPattern(pattern, rootPc, quality, nextRootPc) + + // Unmount (loop/style/instrument change) silences any running sequence — + // module-level state, so this is idempotent across sibling cards. + useEffect(() => () => stopPattern(), []) + + if (!realized) { + return ( +
+ + pattern unavailable +
+ ) + } + + const layout = layoutPattern(realized) + const pitchNames = realized.map((n) => absName(n.abs)).join(', ') + const label = playLabel ?? 'Bass pattern' + + return ( +
+
+ {label} +
+
+ +
+ {note &&

{note}

} + playPattern(realized)} + /> +
+ ) +} diff --git a/src/components/JamGuide.jsx b/src/components/JamGuide.jsx index ecfdb96..78ac67b 100644 --- a/src/components/JamGuide.jsx +++ b/src/components/JamGuide.jsx @@ -3,6 +3,7 @@ import kb from '../data/kb/index.js' import { buildLoopIndex, matchLoopToProgression, findLoopPosition, chordRootPC } from '../lib/match' import { NOTES, CHORD_TYPES } from '../lib/theory' import GlanceRail, { AimDots, SoloLabel } from './GlanceRail' +import BassPatternCard from './BassPatternCard' import VoicingBrowser from './VoicingBrowser' import LickCard, { TechniqueLegend } from './LickCard' import { ExploreSection, VoicingsSection, LevelChips } from './ExplorePanel' @@ -51,9 +52,12 @@ const SECTIONS = [ // dependent ('3' → ♭3 for min7, '7' → the chord's actual 7th…). This mirrors // `resolveDegree` in scripts/validate-kb.mjs — the KB contract's reference // implementation — replicated here because src/ must not import from scripts/. -// Keep the two in sync by hand. Exported for the smoke drift guard ONLY +// Keep the two in sync by hand. Exported for the smoke drift guard // (scripts/smoke.mjs §7 sweeps both copies against a pinned truth table — -// C-40 follow-up landed with L-40); no component imports this. +// C-40 follow-up landed with L-40) and for BassPatternCard, which realizes +// SCHEMA.md bass-pattern degrees through the same contract (L-42; the import +// cycle JamGuide → BassPatternCard → JamGuide is benign — a hoisted function +// used only at render time). export function resolveDegree(deg, quality) { const iv = CHORD_TYPES[quality]?.intervals if (!iv) return null @@ -246,9 +250,10 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory = // station whose recipe fails to resolve falls back to the computed // chain individually. The station's rootPc is attached so MiniPiano // marks the root key ("R") reliably. - // bass → neither payload (both stay null): no authored bass patterns exist - // yet, so the band renders the computed BassGuideRows from the - // station identities alone (L-40 step 3, D-40 §3). + // bass → neither payload (both stay null): authored bass patterns flow + // through the separate `bassPlays` memo below into BassGuideRows + // (L-42) — the station entries carry identity only, exactly as in + // the pre-pack honest state (L-40 step 3, D-40 §3). const stationVoicings = useMemo(() => { if (!match.matched) return [] const prog = match.progression @@ -295,6 +300,18 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory = return stations }, [match.matched, match.progression, match.style, instrument, keyRoot]) + // ── Authored bass plays (L-42) ────────────────────────────────────────────── + // When the matched style ships a bass pack with plays for this progression, + // BassGuideRows renders each play's per-station pattern card in the gallery + // slot (all plays side by side, the D-30 gallery idiom); null keeps the + // computed root·fifth·approach fallback — styles without a bass cell and the + // heard-live path are unchanged. + const bassPlays = useMemo(() => { + if (!match.matched || instrument !== 'bass') return null + const plays = kb[match.style]?.instruments?.bass?.plays?.[match.progression?.id] + return Array.isArray(plays) && plays.length > 0 ? plays : null + }, [match.matched, match.style, match.progression, instrument]) + // ── Focused station (D-41 — the L-33 pin, simplified per D-40 §4: with every // row always expanded there is nothing to hold open, so the gesture collapses // to a focus TOGGLE on the row header). Same JamGuide-owned state, same reset @@ -337,11 +354,16 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory = {match.matched ? ( instrument === 'bass' ? ( - /* Honest bass state (D-40 §3): the KB has no bass patterns yet and - both gallery generators are wrong for bass — computed roots/fifths/ - approaches instead. The licks strip hides too (guitar tab licks - are noise to a bassist mid-jam). */ - + /* Bass rows (D-40 §3): authored pattern cards when the matched style + ships a bass pack (L-42), computed roots/fifths/approaches as the + honest fallback otherwise. The licks strip hides either way + (guitar tab licks are noise to a bassist mid-jam). */ + ) : (
{/* The voicing rail — ALL stations expanded as vertical rows; the @@ -415,37 +437,49 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory = ) } -// ─── BassGuideRows — the honest bass state (L-40 step 3, D-40 §3) ───────────── +// ─── BassGuideRows — the bass rail (L-40 step 3 + L-42, D-40 §3) ────────────── // -// The KB has zero authored bass content (the C-41 → P-41 → L-42 chain builds -// it, blues first) and both gallery generators are wrong for bass: guitar -// shapes are not bass patterns, pianoVoicing is piano. Showing either would -// break the one-selector promise — so each station renders the honest useful -// minimum, PURE ARITHMETIC on data the band already has (no theory.js change): -// the ROOT, the FIFTH (root + 7 semitones), and the chromatic APPROACH into the -// NEXT station's root (one semitone below it — "approach: G♯ → A"). The last -// station approaches the first (the loop wraps). Each row carries the same -// header anatomy as the GlanceRail rows (D-41, D-40 §3/§4 — chord + rn + -// solo-scale label + aim dots, via GlanceRail's exported atoms): guide tones -// ARE the bassist's target notes; none of that education is instrument- -// specific. L-42 replaces the computed line with an authored BassPatternCard -// per station when the matched style ships a bass cell — the row structure, -// highlight, and header need zero changes for it (the D-40 §3 contract). +// Two states, same row structure, header, and highlight (the D-40 §3 contract): +// +// · AUTHORED (`plays` non-empty — the matched style ships a bass pack with +// plays for this progression, L-42): each station's gallery slot renders +// one BassPatternCard per play, side by side (the D-30 gallery idiom — all +// of them visible, each with its own ▶). Approach pitches derive from the +// NEXT station's root; the last station wraps to the first. The "authored +// patterns coming" notice disappears; a per-play feel legend and the rail's +// mic-feedback microcopy (there are ▶s now) render once instead. +// · COMPUTED (`plays` null — styles without a bass cell, and heard-live): +// the honest useful minimum, PURE ARITHMETIC on data the band already has +// (no theory.js change): the ROOT, the FIFTH (root + 7), and the chromatic +// APPROACH into the NEXT station's root ("approach: G♯ → A"), plus the ONE +// rail-level notice. +// +// Both states keep the GlanceRail header anatomy (D-41, D-40 §3/§4 — chord + +// rn + solo-scale label + aim dots via GlanceRail's exported atoms): guide +// tones ARE the bassist's target notes; none of that education is instrument- +// specific. // // stations — [{ rootPc, quality, label, rn }] canonical KB order // activeIndex — playhead station (canonicalPos); -1 = none marked "now" // keyMode — key mode name (soloScale's minor-key dominant nudge) // live — heard-live single chord: no next chord, so no approach line -function BassGuideRows({ stations = [], activeIndex = -1, keyMode, live = false }) { +// plays — authored bass plays for the matched progression, or null +function BassGuideRows({ stations = [], activeIndex = -1, keyMode, live = false, plays = null }) { const n = stations.length if (n === 0) return null + const hasPack = Array.isArray(plays) && plays.length > 0 return (

- Bass · roots, fifths & approaches{live ? ' · heard live' : ''} + {hasPack ? 'Bass · patterns for this loop' : 'Bass · roots, fifths & approaches'} + {live ? ' · heard live' : ''}

{stations.map((st, i) => { @@ -482,30 +516,71 @@ function BassGuideRows({ stations = [], activeIndex = -1, keyMode, live = false
- {/* The computed line — the gallery slot until L-42's pattern card. */} -

- root {NOTES[st.rootPc]} - · - fifth {NOTES[fifthPc]} - {next && ( - <> - · - approach{' '} - - {NOTES[approachPc]} → {NOTES[next.rootPc]} - - - )} -

+ {hasPack ? ( + /* The gallery slot (L-42): one realized pattern card per play, + side by side. The wrap mirrors the GlanceRail cell reflow — + rows wrap, never scroll horizontally (D-40 §4). */ +
+ {plays.map((play, p) => ( + + ))} +
+ ) : ( + /* The computed line — the fallback gallery slot. */ +

+ root {NOTES[st.rootPc]} + · + fifth {NOTES[fifthPc]} + {next && ( + <> + · + approach{' '} + + {NOTES[approachPc]} → {NOTES[next.rootPc]} + + + )} +

+ )}
) })} - {/* ONE notice for the whole rail, not per row (D-40 §3). */} -

- Authored bass patterns are on the way (blues first) — meanwhile: roots, - fifths, and the approach into the next chord. -

+ {hasPack ? ( + <> + {/* Per-play groove legend — feel is required schema data; position + hint appended when authored. Rendered once, not per row. */} + {plays.map((play, p) => ( +

+ {play?.label} + {play?.feel ? <> — {play.feel} : null} + {play?.position ? <> · {play.position} : null} +

+ ))} + {/* Amber + mic microcopy — once for the whole rail (D-31 §2.5). */} +

+ Amber note = the approach into the next chord. ▶ previews play through + your speakers — while the mic is live, detection may hear them. Nothing + plays automatically. +

+ + ) : ( + /* ONE notice for the whole rail, not per row (D-40 §3). */ +

+ Authored bass patterns are on the way (blues first) — meanwhile: roots, + fifths, and the approach into the next chord. +

+ )} ) }