feat(bass): BassPatternCard — authored basslines render as 4-string tab in the band (task L-42)

Realizes SCHEMA bass patterns per station (root at lowest playable
position, degs via the shared resolveDegree, typed approaches derived
from the next station with the open-E octave-displacement rule), lays
them out as compact E-A-D-G tab cards (lick-tab visual idiom), all
plays side by side, each with sequential playback through chordAudio
(negative notes documented-legal; one sequence at a time). Computed
root-fifth-approach line remains the fallback for pack-less styles.
BASS is now real end-to-end. Critic PASS (independent re-realization
of all 912 placements exact; untouched instrument paths byte-identical;
import cycle proven safe in both load orders).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
vadimwit
2026-07-10 19:31:21 +01:00
parent 4650494f0d
commit af7c3cc0a7
2 changed files with 538 additions and 48 deletions
+123 -48
View File
@@ -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). */
<BassGuideRows stations={stationVoicings} activeIndex={canonicalPos} keyMode={keyInfo?.mode} />
/* 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). */
<BassGuideRows
stations={stationVoicings}
activeIndex={canonicalPos}
keyMode={keyInfo?.mode}
plays={bassPlays}
/>
) : (
<div className="flex flex-col gap-3">
{/* 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 (
<section
className="rounded-2xl border border-border bg-panel p-3"
aria-label="Bass guide — roots, fifths and approach notes"
aria-label={
hasPack
? 'Bass guide — authored patterns for this loop'
: 'Bass guide — roots, fifths and approach notes'
}
>
<h4 className="mb-2 text-[10px] font-semibold uppercase tracking-widest text-gray-500">
Bass · roots, fifths &amp; approaches{live ? ' · heard live' : ''}
{hasPack ? 'Bass · patterns for this loop' : 'Bass · roots, fifths & approaches'}
{live ? ' · heard live' : ''}
</h4>
<div className="flex flex-col gap-2" role="list">
{stations.map((st, i) => {
@@ -482,30 +516,71 @@ function BassGuideRows({ stations = [], activeIndex = -1, keyMode, live = false
<SoloLabel rootPc={st.rootPc} quality={st.quality} keyMode={keyMode} />
<AimDots rootPc={st.rootPc} quality={st.quality} />
</div>
{/* The computed line — the gallery slot until L-42's pattern card. */}
<p className="mt-1.5 text-xs text-gray-300">
root <span className="font-semibold text-gray-100">{NOTES[st.rootPc]}</span>
<span className="text-gray-600"> · </span>
fifth <span className="font-semibold text-gray-100">{NOTES[fifthPc]}</span>
{next && (
<>
<span className="text-gray-600"> · </span>
approach{' '}
<span className="font-semibold text-gray-100">
{NOTES[approachPc]} {NOTES[next.rootPc]}
</span>
</>
)}
</p>
{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). */
<div className="mt-1.5 flex flex-wrap items-stretch gap-2">
{plays.map((play, p) => (
<BassPatternCard
key={p}
rootPc={st.rootPc}
quality={st.quality}
nextRootPc={stations[(i + 1) % n].rootPc}
pattern={play?.chords?.[i]?.pattern}
playLabel={play?.label}
feel={play?.feel}
note={play?.chords?.[i]?.note}
chordLabel={st.label}
/>
))}
</div>
) : (
/* The computed line — the fallback gallery slot. */
<p className="mt-1.5 text-xs text-gray-300">
root <span className="font-semibold text-gray-100">{NOTES[st.rootPc]}</span>
<span className="text-gray-600"> · </span>
fifth <span className="font-semibold text-gray-100">{NOTES[fifthPc]}</span>
{next && (
<>
<span className="text-gray-600"> · </span>
approach{' '}
<span className="font-semibold text-gray-100">
{NOTES[approachPc]} {NOTES[next.rootPc]}
</span>
</>
)}
</p>
)}
</div>
)
})}
</div>
{/* ONE notice for the whole rail, not per row (D-40 §3). */}
<p className="mt-2 text-[11px] text-gray-500">
Authored bass patterns are on the way (blues first) meanwhile: roots,
fifths, and the approach into the next chord.
</p>
{hasPack ? (
<>
{/* Per-play groove legend — feel is required schema data; position
hint appended when authored. Rendered once, not per row. */}
{plays.map((play, p) => (
<p key={p} className="mt-2 text-[11px] text-gray-500">
<span className="font-medium text-gray-400">{play?.label}</span>
{play?.feel ? <> {play.feel}</> : null}
{play?.position ? <> · {play.position}</> : null}
</p>
))}
{/* Amber + mic microcopy — once for the whole rail (D-31 §2.5). */}
<p className="mt-2 text-[11px] text-gray-500">
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.
</p>
</>
) : (
/* ONE notice for the whole rail, not per row (D-40 §3). */
<p className="mt-2 text-[11px] text-gray-500">
Authored bass patterns are on the way (blues first) meanwhile: roots,
fifths, and the approach into the next chord.
</p>
)}
</section>
)
}