Files
JamBuddy/src/App.jsx
T
vadimwit 038fdfafc8 feat(dashboard): rotating Try-this card — one fresh substitution per loop pass (task L-74)
A compact TryThis card follows the playhead chord and shows ONE
suggestion at a time, cycling to the next valid substitution each time
the loop completes a pass (playhead wraps to a lower station). Over
Am-C-F the F cycles Dm -> Fm -> Fmaj7 -> E7 across passes, so the app
keeps offering a new idea and eventually teaches every honest move,
never a wrong one. Chip taps into ChordDetailModal; 0 subs -> no card,
1 sub -> static. Mounted App.jsx-only above RelatedProgressions in the
related slot; audio contract grep-clean; rotation logic exported pure
and StrictMode-safe. Critic PASS (2-pass rotation trace verified).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 12:23:18 +01:00

964 lines
43 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.
import { useState, useCallback, useRef, useEffect } from 'react'
import AudioCapture from './components/AudioCapture'
import ProgressionBanner from './components/ProgressionBanner'
import Fretboard from './components/Fretboard'
import BassFretboard from './components/BassFretboard'
import Tuner from './components/Tuner'
import Piano from './components/Piano'
import Settings from './components/Settings'
import DebugView from './components/DebugView'
import DrumView from './components/DrumView'
import { NOTES, detectKey, detectTopKeys, matchChordFromChroma, detectRepeatingProgression, getChordTones, getChordCandidates, getNoteHistoryAnalysis } from './lib/theory'
import ChordDetailModal from './components/ChordDetailModal'
import RelatedProgressions from './components/RelatedProgressions'
import TryThis from './components/TryThis'
import LoopStation from './components/LoopStation'
import JamGuide, { KnowledgeDock } from './components/JamGuide'
import { useLoopEngine } from './services/loopEngine'
import kb from './data/kb/index.js'
import { seedableLoop, buildRoulettePool } from './lib/match'
import settingIcon from './assets/setting-icon.png'
const DEFAULTS = {
// Key detection
noteHistorySize: 2000, // ~60s of notes — stable across a song section
keyVoteWindow: 30, // rolling window of key votes
keyVoteThreshold: 20, // 67% consensus — locks in after a few bars
chordNoteBoost: 3,
// Chord detection
chromaSmooth: 8, // 8 frames ≈ 130ms window, checks chord at ~7.5 Hz
chordVoteThreshold: 2, // 2 consecutive matches ≈ 260ms — works at any BPM
chordMinScore: 0.35, // lenient enough for live guitar signal
// Audio input
minClarity: 0.80,
minVolume: 0.01,
// Selected device (null = system default)
audioDeviceId: null,
}
function loadStored(key, fallback) {
try { const v = localStorage.getItem(key); return v !== null ? JSON.parse(v) : fallback }
catch { return fallback }
}
export default function App() {
// ── Config ───────────────────────────────────────────────────────────────────
const [config, setConfig] = useState(() => ({ ...DEFAULTS, ...loadStored('wtf_config', {}) }))
const configRef = useRef(config)
useEffect(() => { configRef.current = config; localStorage.setItem('wtf_config', JSON.stringify(config)) }, [config])
const [showSettings, setShowSettings] = useState(false)
function updateConfig(key, val) {
setConfig(prev => ({ ...prev, [key]: val }))
}
// ── Listening state ──────────────────────────────────────────────────────────
const [isListening, setIsListening] = useState(false)
// ── Instrument view + tuner ───────────────────────────────────────────────────
const [instrument, setInstrument] = useState('piano') // 'piano' | 'guitar' | 'bass'
const [showTuner, setShowTuner] = useState(false)
const [showDebug, setShowDebug] = useState(false)
const [showDrumView, setShowDrumView] = useState(false)
const [monoColor, setMonoColor] = useState(() => loadStored('wtf_monoColor', false))
// ── Jam view (task L-50, one-screen.md §1.1) — pure UI/layout state ──────────
// Layer 1: CSS lock — at xl the page root becomes h-screen overflow-hidden and
// everything below the dashboard is unmounted. Layer 2: best-effort browser
// fullscreen (Promise-caught — a refusal leaves layer 1 fully working). The
// toggle never starts/stops listening and never touches audio state.
const [jamView, setJamView] = useState(false)
function enterJamView() {
setJamView(true)
document.documentElement.requestFullscreen?.()?.catch(() => {})
}
function exitJamView() {
setJamView(false)
if (document.fullscreenElement) document.exitFullscreen?.()?.catch(() => {})
}
// Escape and the native fullscreen exit (any means) both restore normal flow —
// one state, never half-exited. Listeners active only while jamView.
useEffect(() => {
if (!jamView) return
const onKey = (e) => { if (e.key === 'Escape') exitJamView() }
const onFsChange = () => { if (!document.fullscreenElement) setJamView(false) }
window.addEventListener('keydown', onKey)
document.addEventListener('fullscreenchange', onFsChange)
return () => {
window.removeEventListener('keydown', onKey)
document.removeEventListener('fullscreenchange', onFsChange)
}
}, [jamView]) // eslint-disable-line react-hooks/exhaustive-deps
// ── Jam Guide → Fretboard cross-link (D-03) ──────────────────────────────────
// When a Roadmap station is tapped, JamGuide reports its {rootPc, quality}
// here and the main Fretboard highlights that chord's guide tones (3rd/7th).
// null = no station focused (Fretboard renders normally). Purely UI state —
// NOT read by any audio callback, so it stays out of the ref-sync contract.
const [jamFocusChord, setJamFocusChord] = useState(null) // { rootPc, quality } | null
// ── Mic permission error ──────────────────────────────────────────────────────
const [micError, setMicError] = useState(null)
// ── Debug data ────────────────────────────────────────────────────────────────
const [debugChroma, setDebugChroma] = useState(null)
const [debugCandidates, setDebugCandidates] = useState([])
const [debugNoteAnalysis, setDebugNoteAnalysis] = useState(null)
const [debugWaveform, setDebugWaveform] = useState(null)
// ── Stable refs for values used inside callbacks ──────────────────────────────
const showDebugRef = useRef(showDebug)
const showDrumViewRef = useRef(showDrumView)
const lockedKeyRef = useRef(null)
const listenStartRef = useRef(null)
useEffect(() => { showDebugRef.current = showDebug }, [showDebug])
useEffect(() => { showDrumViewRef.current = showDrumView }, [showDrumView])
useEffect(() => { localStorage.setItem('wtf_monoColor', JSON.stringify(monoColor)) }, [monoColor])
useEffect(() => { if (isListening) listenStartRef.current = Date.now() }, [isListening])
// ── BPM estimation from onset timestamps ─────────────────────────────────────
const [bpm, setBpm] = useState(null)
const onsetTimestampsRef = useRef([])
const bpmSmoothRef = useRef(null)
// ── Loop station ─────────────────────────────────────────────────────────────
const {
slots,
masterLen,
setStream: loopSetStream,
handleSlotClick,
commitTrim,
cancelRecord,
retrimSlot,
deleteSlot,
setVolume: loopSetVolume,
addSlot: loopAddSlot,
audioCtxRef: loopAudioCtxRef,
masterStartRef: loopMasterStartRef,
masterLenRef: loopMasterLenRef,
} = useLoopEngine(bpm)
// ── Key: auto-detected + optional lock ───────────────────────────────────────
const [keyInfo, setKeyInfo] = useState(null) // auto-detected
const [lockedKey, setLockedKey] = useState(null) // { root, mode } or null
useEffect(() => { lockedKeyRef.current = lockedKey }, [lockedKey])
const [lockRoot, setLockRoot] = useState('A')
const [lockMode, setLockMode] = useState('minor')
const effectiveKey = lockedKey ?? keyInfo
// ── Chord state ───────────────────────────────────────────────────────────────
const [chordHistory, setChordHistory] = useState([])
const [detectedProgression, setDetectedProgression] = useState(null)
const [selectedChord, setSelectedChord] = useState(null)
// ── Jam Roulette (task L-60, jam-roulette.md) — pure UI/display state ─────────
// seedInfo: provenance of an unconfirmed rolled loop { styleLabel, name, bars }
// (null once live detection confirms or replaces it). Invariant (§3.5):
// seedInfo !== null ⇔ progressionVoteRef.current?.seeded === true.
const [seedInfo, setSeedInfo] = useState(null)
const [rouletteMenuOpen, setRouletteMenuOpen] = useState(false)
// ── Top key candidates (shown as quick-lock chips) ────────────────────────────
const [topKeyCandidates, setTopKeyCandidates] = useState([])
// ── Internal refs ─────────────────────────────────────────────────────────────
const noteHistoryRef = useRef([])
const keyVotesRef = useRef([])
const effectiveKeyRef = useRef(null)
const chromaRingRef = useRef(
Array.from({ length: DEFAULTS.chromaSmooth }, () => new Float32Array(12))
)
const chromaIdxRef = useRef(0)
const chordVotesRef = useRef([])
const progressionVoteRef = useRef(null)
const progressionMissRef = useRef(0)
const pendingKeyRef = useRef(null)
// Roulette session memory (UI-state refs, never read by audio code): the last
// 6 rolled progression ids (no-repeat), the last rolled root pc (§2), and the
// last resolved style id (for the "Re-roll — {style}" row).
const rouletteMemoryRef = useRef([])
const prevRootPcRef = useRef(null)
const lastRolledStyleRef = useRef(null)
const rouletteRef = useRef(null)
// Keep refs in sync
useEffect(() => { effectiveKeyRef.current = effectiveKey }, [effectiveKey])
// Re-init chroma ring when chromaSmooth changes
useEffect(() => {
chromaRingRef.current = Array.from(
{ length: config.chromaSmooth },
() => new Float32Array(12)
)
chromaIdxRef.current = 0
}, [config.chromaSmooth])
// ── Progression commit layer (task L-31) ─────────────────────────────────────
// Runs once per chord commit. progressionVoteRef holds
// { committedKey, candidateKey, candidateCount } — null until first evidence.
//
// Thresholds are in chord-commits (one detector run each):
// · COMMIT_VOTES = 2 — first commit needs 2 consecutive identical detections
// (≈ one bar). Post-L-30 detection is stable on clean loops so this lands
// immediately; a noisy 2-rep history flaps through data-faithful sub-cycles
// that never repeat twice in a row, so noise can't commit early.
// · REPLACE_VOTES = 3 — replacing a committed loop needs 3 consecutive
// detections of the SAME new loop: one transient detection (or an
// alternating flap) must never displace the loop the musician is still in;
// a genuine section change is detected consistently and just lands one
// commit later than a first commit would.
// · NULL_CLEAR = 6 — the detector only returns null once the loop has
// aged out of its 32-commit window (it needs 2 exact in-window
// occurrences): traced, that is ~28 commits of foreign material after
// the loop last played. Fills, turnarounds and window-boundary resumes
// yield non-null sub-cycle detections rather than nulls (traced), so
// they can NEVER clear a committed loop — a new established loop
// replaces via REPLACE_VOTES instead. A null run therefore means the
// jam truly left loop-land ~30 commits ago; 6 more (≈ two bars of
// structureless playing) confirms it wasn't a flicker before the
// display goes dark. (The old value 4 was sized as if nulls happened
// during fills — post-L-30 they don't.)
const COMMIT_VOTES = 2
const REPLACE_VOTES = 3
const NULL_CLEAR = 6
useEffect(() => {
const detected = detectRepeatingProgression(chordHistory)
const vote = progressionVoteRef.current
if (!detected) {
progressionMissRef.current++
// L-60 flag 1 (§3.4): a SEEDED card is an instruction, not an observation —
// ramp-up nulls (every seed's own effect run is miss 1, and no detection can
// land before commit 6) must NOT wipe it. It stays until confirmed, replaced
// by a consistently-detected different loop, re-rolled, or New Song.
if (progressionMissRef.current >= NULL_CLEAR && !vote?.seeded) {
setDetectedProgression(null)
progressionVoteRef.current = null
}
return
}
progressionMissRef.current = 0 // any detected structure keeps the committed loop alive
const key = detected.join(',')
if (vote && vote.committedKey === key) {
// Agreement with the committed loop — refresh it, drop any pending rival.
setDetectedProgression(detected)
vote.candidateKey = null
vote.candidateCount = 0
// L-60 (§3.4): the seed is now a normal committed loop — the first agreeing
// live detection confirms it; drop the provenance flag + chip.
if (vote.seeded) { vote.seeded = false; setSeedInfo(null) }
return
}
const committedKey = vote ? vote.committedKey : null
if (vote && vote.candidateKey === key) {
vote.candidateCount++
} else {
// L-60 flag 3 (§3.4): preserve `seeded` across candidate rebuilds — a
// transient ghost sub-cycle must not strip the flag and resurrect flag 1's
// ramp-up kill through the side door.
progressionVoteRef.current = { committedKey, candidateKey: key, candidateCount: 1, seeded: vote?.seeded ?? false }
}
if (progressionVoteRef.current.candidateCount >= (committedKey ? REPLACE_VOTES : COMMIT_VOTES)) {
setDetectedProgression(detected)
progressionVoteRef.current = { committedKey: key, candidateKey: null, candidateCount: 0 }
// L-60 (§3.4/§3.5): a genuinely different loop replaced the seed — the new
// vote is detection-owned; end the seed provenance.
setSeedInfo(null)
}
}, [chordHistory])
// ── New song — full reset ─────────────────────────────────────────────────────
function newSong() {
const cfg = configRef.current
noteHistoryRef.current = []
keyVotesRef.current = []
chordVotesRef.current = []
progressionVoteRef.current = null
progressionMissRef.current = 0
pendingKeyRef.current = null
chromaIdxRef.current = 0
chromaRingRef.current = Array.from({ length: cfg.chromaSmooth }, () => new Float32Array(12))
onsetTimestampsRef.current = []
bpmSmoothRef.current = null
listenStartRef.current = Date.now()
setKeyInfo(null)
setLockedKey(null)
effectiveKeyRef.current = null
setChordHistory([])
setDetectedProgression(null)
setSeedInfo(null) // L-60 (§3.6): the seed clears with the full reset
setTopKeyCandidates([])
setBpm(null)
setMicError(null)
setDebugChroma(null)
setDebugCandidates([])
setDebugNoteAnalysis(null)
setDebugWaveform(null)
}
// ── Key lock handlers ─────────────────────────────────────────────────────────
function applyLock() {
const info = { root: lockRoot, mode: lockMode, confidence: 1 }
setLockedKey(info)
effectiveKeyRef.current = info
chordVotesRef.current = []
}
function quickLock({ root, mode, confidence }) {
const info = { root, mode, confidence }
setLockedKey(info)
effectiveKeyRef.current = info
chordVotesRef.current = []
}
function removeLock() {
setLockedKey(null)
effectiveKeyRef.current = keyInfo
}
// ── Jam Roulette (task L-60, jam-roulette.md §2/§3.1) ────────────────────────
// Rolls a random key + interesting KB progression and seeds the EXACT state
// live detection writes (lockedKey + detectedProgression + a committed-shape
// progressionVoteRef), so the whole dashboard populates as if detected. All
// randomness is here (plain Math.random, no audio contact); inputs = the KB
// registry only. `styleId === 'surprise'` picks uniformly over the 10 styles.
function rollJam(styleId) {
const pool = buildRoulettePool(kb) // lazy memo — round-trip passers, len 28
const sid = styleId === 'surprise'
? Object.keys(kb)[Math.floor(Math.random() * Object.keys(kb).length)]
: styleId
const members = pool.byStyle.get(sid) ?? []
if (!members.length) return
// No-repeat memory (§2.2): exclude the last 6 rolled ids; if that empties the
// pool (small styles), fall back to excluding only the immediately previous.
const mem = rouletteMemoryRef.current
let candidates = members.filter(m => !mem.includes(m.id))
if (!candidates.length) {
const prev = mem[mem.length - 1]
candidates = members.filter(m => m.id !== prev)
if (!candidates.length) candidates = members
}
// Weighted draw: weight = levelW × lenW (§2.2). Intermediate leans in (×2),
// the collapsed "4-bar-ish" sweet spot 37 leans in (×2).
const weightOf = (m) => {
const levelW = m.progression.level === 'intermediate' ? 2 : 1
const lenW = (m.collapsedLen >= 3 && m.collapsedLen <= 7) ? 2 : 1
return levelW * lenW
}
const totalW = candidates.reduce((s, m) => s + weightOf(m), 0)
let r = Math.random() * totalW
let picked = candidates[candidates.length - 1]
for (const m of candidates) { r -= weightOf(m); if (r <= 0) { picked = m; break } }
const prog = picked.progression
// Root: uniform over 12 pcs, SHARP spelling (§2.1 — only sharp names
// string-match live detection); don't repeat the previous roll's root.
let rolledPc = Math.floor(Math.random() * 12)
if (prevRootPcRef.current != null && rolledPc === prevRootPcRef.current) {
rolledPc = Math.floor(Math.random() * 12) // re-draw once
}
const loop = seedableLoop(prog, rolledPc)
if (!loop) return // pool guarantees this, but stay defensive
prevRootPcRef.current = rolledPc
// ── The seed writes (§3.1) — clean slate, then key + loop + committed vote ──
newSong() // §3.6: roulette = New Song + seed (a stale window poisons handoff)
const info = { root: NOTES[rolledPc], mode: prog.mode, confidence: 1 }
setLockedKey(info)
effectiveKeyRef.current = info // the quickLock precedent — detection uses it NOW
chordVotesRef.current = []
setDetectedProgression(loop)
progressionVoteRef.current = {
committedKey: loop.join(','), // the committed shape, L-31's own key
candidateKey: null,
candidateCount: 0,
seeded: true, // the one flag L-60 adds (§3.4)
}
progressionMissRef.current = 0
setSeedInfo({ styleLabel: kb[sid]?.meta?.label ?? sid, name: prog.name, bars: prog.bars })
// Session bookkeeping (§2.2) — survives New Song deliberately.
lastRolledStyleRef.current = sid
mem.push(picked.id)
while (mem.length > 6) mem.shift()
setRouletteMenuOpen(false)
}
// Escape / click-outside closes the roulette menu; focus returns to the button.
useEffect(() => {
if (!rouletteMenuOpen) return
const onKey = (e) => {
if (e.key === 'Escape') {
setRouletteMenuOpen(false)
rouletteRef.current?.querySelector('button')?.focus()
}
}
const onDown = (e) => { if (!rouletteRef.current?.contains(e.target)) setRouletteMenuOpen(false) }
document.addEventListener('keydown', onKey)
document.addEventListener('mousedown', onDown)
return () => {
document.removeEventListener('keydown', onKey)
document.removeEventListener('mousedown', onDown)
}
}, [rouletteMenuOpen])
// ── Waveform handler: feeds oscilloscope / drum view ─────────────────────────
const handleWaveform = useCallback((data) => {
if (showDebugRef.current || showDrumViewRef.current) {
setDebugWaveform({ ...data, onsets: [...onsetTimestampsRef.current] })
}
}, [])
// ── Note handler: drives key detection (pitch-based) ──────────────────────────
const handleNote = useCallback(({ pitchClass }) => {
const cfg = configRef.current
const history = noteHistoryRef.current
history.push(pitchClass)
if (history.length > cfg.noteHistorySize) history.shift()
if (history.length < 10) return
if (history.length % 5 !== 0) return
const result = detectKey(history)
setTopKeyCandidates(detectTopKeys(history))
if (showDebugRef.current) {
const analysis = getNoteHistoryAnalysis(history)
analysis.sessionSecs = listenStartRef.current ? Math.floor((Date.now() - listenStartRef.current) / 1000) : 0
setDebugNoteAnalysis(analysis)
}
if (result.confidence < 0.5) return
const votes = keyVotesRef.current
votes.push(`${result.root}_${result.mode}`)
if (votes.length > cfg.keyVoteWindow) votes.shift()
const counts = {}
for (const v of votes) counts[v] = (counts[v] || 0) + 1
const [winner, count] = Object.entries(counts).sort((a, b) => b[1] - a[1])[0]
if (count >= cfg.keyVoteThreshold) {
const [root, mode] = winner.split('_')
const candidateKey = `${root}_${mode}`
setKeyInfo(prev => {
const currentKey = prev ? `${prev.root}_${prev.mode}` : null
if (currentKey === candidateKey) {
pendingKeyRef.current = null
return { root, mode, confidence: result.confidence }
}
if (pendingKeyRef.current === candidateKey) {
pendingKeyRef.current = null
if (!lockedKeyRef.current) chordVotesRef.current = []
return { root, mode, confidence: result.confidence }
}
pendingKeyRef.current = candidateKey
return prev
})
}
}, [])
// ── Chroma handler: drives chord detection ────────────────────────────────────
const handleChroma = useCallback((chroma, bassPC) => {
const cfg = configRef.current
const ring = chromaRingRef.current
ring[chromaIdxRef.current % cfg.chromaSmooth] = chroma
chromaIdxRef.current++
if (chromaIdxRef.current % cfg.chromaSmooth !== 0) return
const key = effectiveKeyRef.current
if (!key) return
const avg = new Float32Array(12)
for (const frame of ring) for (let i = 0; i < 12; i++) avg[i] += frame[i]
for (let i = 0; i < 12; i++) avg[i] /= cfg.chromaSmooth
if (showDebugRef.current) {
setDebugChroma([...avg])
setDebugCandidates(getChordCandidates(avg, key, bassPC, 5))
}
// Stability gate — if chroma is still changing across frames, we're mid-transition.
// Compute per-bin variance across the ring; bail if any bin is fluctuating heavily.
let maxVar = 0
for (let i = 0; i < 12; i++) {
let v = 0
for (const frame of ring) { const d = frame[i] - avg[i]; v += d * d }
if (v / cfg.chromaSmooth > maxVar) maxVar = v / cfg.chromaSmooth
}
if (maxVar > 0.05) return
const chord = matchChordFromChroma(avg, key, bassPC, false, cfg.chordMinScore)
if (!chord) {
chordVotesRef.current = []
return
}
const votes = chordVotesRef.current
votes.push(chord)
if (votes.length > cfg.chordVoteThreshold) votes.shift()
if (votes.length >= cfg.chordVoteThreshold && votes.every(v => v === votes[0])) {
const winner = votes[0]
setChordHistory(prev => {
if (prev[prev.length - 1] === winner) return prev
return [...prev.slice(-48), winner]
})
// Inject chord tones into note history to anchor key detection
const chordPCs = getChordTones(winner)
.map(n => NOTES.indexOf(n))
.filter(i => i >= 0)
const history = noteHistoryRef.current
for (let j = 0; j < cfg.chordNoteBoost; j++) {
for (const pc of chordPCs) history.push(pc)
}
while (history.length > cfg.noteHistorySize) history.shift()
}
}, [])
// ── Onset handler: drives BPM estimation via tempo histogram ────────────────
// Pairwise inter-onset intervals are folded into 55-220 BPM and vote in a
// histogram. Works with drums, guitar, piano, or mixed — whatever fires most
// consistently wins. Only updates when there's a clear peak (≥20% of votes).
const handleOnset = useCallback(() => {
const ts = onsetTimestampsRef.current
ts.push(performance.now())
if (ts.length > 64) ts.shift()
if (ts.length < 4) return
const recent = ts.slice(-24)
const bins = new Float32Array(221) // index = BPM (55220)
for (let i = 0; i < recent.length - 1; i++) {
for (let j = i + 1; j < recent.length && j < i + 8; j++) {
const ms = recent[j] - recent[i]
if (ms < 140 || ms > 6000) continue
// Fold interval into 55-220 BPM range (handles subdivisions & half-time)
let beatMs = ms
while (beatMs > 1091) beatMs /= 2
while (beatMs < 273) beatMs *= 2
if (beatMs < 273 || beatMs > 1091) continue
const bpm = Math.round(60000 / beatMs)
if (bpm >= 55 && bpm <= 220) bins[bpm] += 1 / (j - i) // weight closer pairs more
}
}
// Find peak with ±1 BPM smoothing
let best = 0, bestBpm = 0
for (let b = 56; b <= 219; b++) {
const s = bins[b - 1] + bins[b] + bins[b + 1]
if (s > best) { best = s; bestBpm = b }
}
const total = bins.reduce((a, v) => a + v, 0)
if (total < 1 || best / total < 0.2) return // no clear consensus yet
const prev = bpmSmoothRef.current
bpmSmoothRef.current = prev === null ? bestBpm : 0.25 * bestBpm + 0.75 * prev
setBpm(Math.round(bpmSmoothRef.current))
}, [])
const currentChord = chordHistory[chordHistory.length - 1]
if (showSettings) {
return (
<Settings
config={config}
onChange={updateConfig}
onClose={() => setShowSettings(false)}
onReset={() => { setConfig(DEFAULTS); setMonoColor(false) }}
monoColor={monoColor}
onMonoColorChange={setMonoColor}
/>
)
}
return (
<div className={`min-h-screen bg-surface text-white p-3${jamView ? ' xl:h-screen xl:overflow-hidden xl:flex xl:flex-col' : ''}`}>
{/* ── Header ── */}
<header className="mb-2 flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-accent">
WhatTheFlat <span className="text-gray-600">&#9837;?</span> <span className="text-amber-400">- JamBuddy</span>
</h1>
<p className="text-xs text-gray-600">Real-time key detection for live jams</p>
</div>
<div className="flex gap-2 items-center">
<button
onClick={() => setShowSettings(true)}
className="p-2 rounded-full border border-border hover:border-gray-400 transition-all"
title="Settings"
>
<img src={settingIcon} alt="Settings" className="w-5 h-5" style={{ filter: 'invert(1) opacity(0.75)' }} />
</button>
<button
onClick={newSong}
className="group px-5 py-2 rounded-full text-sm font-semibold border border-border text-gray-400 hover:text-gray-200 hover:border-gray-400 transition-all"
>
<span className="group-hover:hidden">New Song</span>
<span className="hidden group-hover:inline">Clear History</span>
</button>
<button
onClick={() => { setMicError(null); setIsListening(l => !l) }}
className={`px-5 py-2 rounded-full font-semibold text-sm transition-all ${
isListening
? 'bg-red-600 hover:bg-red-700 text-white'
: 'bg-accent hover:bg-purple-600 text-white'
}`}
>
{isListening ? 'Stop' : 'Start Listening'}
</button>
</div>
</header>
{/* ── Controls bar ── */}
<div className="mb-2 flex flex-wrap gap-2 items-center p-2 bg-panel border border-border rounded-xl">
{/* Instrument select */}
<div className="relative">
<select
value={instrument}
onChange={e => setInstrument(e.target.value)}
className="appearance-none bg-surface border border-border hover:border-gray-500 focus:border-accent focus:outline-none rounded-lg pl-3 pr-7 py-1 text-sm text-gray-200 cursor-pointer transition-colors"
>
<option value="piano">Piano</option>
<option value="guitar">Guitar</option>
<option value="bass">Bass</option>
</select>
<span className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 text-xs"></span>
</div>
{/* BPM badge */}
{bpm && (
<span className="px-3 py-1 bg-accent/10 border border-accent/30 rounded-lg text-sm text-accent font-mono tabular-nums">
<span className="inline-block w-[3ch] text-right">{Math.round(bpm)}</span> <span className="text-accent/50 text-xs">BPM</span>
</span>
)}
<div className="w-px h-5 bg-border shrink-0" />
{lockedKey ? (
<div className="flex items-center gap-2 px-3 py-1 bg-accent/20 border border-accent rounded-full">
<span className="text-accent text-sm font-semibold shrink-0">🔒 {lockedKey.root}</span>
<div className="relative">
<select
value={lockedKey.mode}
onChange={e => {
const info = { ...lockedKey, mode: e.target.value }
setLockedKey(info)
effectiveKeyRef.current = info
chordVotesRef.current = []
}}
className="appearance-none bg-transparent text-accent text-sm font-semibold border-none outline-none cursor-pointer pr-4"
>
<option value="major">Major</option>
<option value="minor">Minor</option>
<option value="dorian">Dorian</option>
<option value="mixolydian">Mixolydian</option>
<option value="phrygian">Phrygian</option>
<option value="lydian">Lydian</option>
</select>
<span className="pointer-events-none absolute right-0 top-1/2 -translate-y-1/2 text-accent/60 text-xs"></span>
</div>
<button onClick={removeLock} className="text-xs text-accent/50 hover:text-accent transition-colors">
unlock
</button>
</div>
) : (
<div className="flex flex-wrap gap-2 items-center">
{topKeyCandidates.map((k, i) => (
<button
key={i}
onClick={() => quickLock(k)}
className={`px-3 py-1 rounded-full text-sm font-semibold border transition-all ${
i === 0
? 'border-accent text-accent hover:bg-accent/10'
: 'border-border text-gray-400 hover:border-gray-500 hover:text-gray-200'
}`}
>
{k.root} {k.mode === 'major' ? 'maj' : 'min'} · {Math.round(k.confidence * 100)}%
</button>
))}
{topKeyCandidates.length > 0 && <span className="text-gray-600 text-xs">or</span>}
<div className="relative">
<select
value={lockRoot}
onChange={e => setLockRoot(e.target.value)}
className="appearance-none bg-surface border border-border hover:border-gray-500 focus:border-accent focus:outline-none rounded-lg pl-3 pr-7 py-1 text-sm text-gray-200 cursor-pointer transition-colors"
>
{NOTES.map(n => <option key={n}>{n}</option>)}
</select>
<span className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 text-xs"></span>
</div>
<div className="relative">
<select
value={lockMode}
onChange={e => setLockMode(e.target.value)}
className="appearance-none bg-surface border border-border hover:border-gray-500 focus:border-accent focus:outline-none rounded-lg pl-3 pr-7 py-1 text-sm text-gray-200 cursor-pointer transition-colors"
>
<option value="major">Major</option>
<option value="minor">Minor</option>
<option value="dorian">Dorian</option>
<option value="mixolydian">Mixolydian</option>
<option value="phrygian">Phrygian</option>
<option value="lydian">Lydian</option>
</select>
<span className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 text-xs"></span>
</div>
<button
onClick={applyLock}
className="px-3 py-1 bg-accent/10 hover:bg-accent/20 border border-accent/40 hover:border-accent text-accent text-sm rounded-lg transition-all"
>
Lock key
</button>
</div>
)}
{/* ── Jam roulette (L-60, jam-roulette.md §1.1/§1.2) ── */}
<div className="relative ml-auto" ref={rouletteRef}>
<button
type="button"
onClick={() => setRouletteMenuOpen(o => !o)}
aria-haspopup="menu"
aria-expanded={rouletteMenuOpen}
aria-pressed={seedInfo ? true : undefined}
title="Jam roulette — roll a random key + progression to jam on"
className={`min-h-[32px] px-3 py-1 rounded-lg border text-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-accent ${
seedInfo
? 'border-accent/40 text-accent bg-accent/10 font-semibold'
: 'border-border text-gray-200 hover:border-gray-500'
}`}
>
🎲 Jam roulette
</button>
{rouletteMenuOpen && (
<div
role="menu"
aria-label="Pick a genre to roll"
className="absolute right-0 mt-1 w-56 bg-panel border border-border rounded-xl shadow-lg p-1 z-20"
>
{seedInfo && (
<button
type="button"
role="menuitem"
onClick={() => rollJam(lastRolledStyleRef.current)}
className="w-full text-left px-3 py-1.5 rounded-lg text-sm text-gray-200 hover:bg-accent/10 border-b border-border outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
Re-roll {seedInfo.styleLabel}
</button>
)}
<button
type="button"
role="menuitem"
onClick={() => rollJam('surprise')}
className="w-full text-left px-3 py-1.5 rounded-lg text-sm text-gray-200 hover:bg-accent/10 border-b border-border outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
Surprise me
</button>
{Object.entries(kb).map(([id, s]) => (
<button
key={id}
type="button"
role="menuitem"
onClick={() => rollJam(id)}
className="w-full text-left px-3 py-1.5 rounded-lg text-sm text-gray-200 hover:bg-accent/10 outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
{s.meta?.label ?? id}
</button>
))}
</div>
)}
</div>
{/* ── Jam view toggle (L-50, one-screen.md §1.1) — layout-only ── */}
<button
type="button"
onClick={jamView ? exitJamView : enterJamView}
aria-pressed={jamView}
title={jamView ? 'Exit jam view' : 'Jam view — the one-screen dashboard, fullscreen'}
className={`ml-auto min-h-[32px] px-3 py-1 rounded-lg text-sm border transition-all outline-none focus-visible:ring-2 focus-visible:ring-accent ${
jamView
? 'bg-accent/20 border-accent text-accent font-semibold'
: 'border-border text-gray-400 hover:border-gray-500 hover:text-gray-200'
}`}
>
{jamView ? '✕ Exit' : '⛶ Jam view'}
</button>
</div>
<AudioCapture
onNote={handleNote}
onChroma={handleChroma}
onOnset={handleOnset}
onWaveform={handleWaveform}
isListening={isListening}
minClarity={config.minClarity}
minVolume={config.minVolume}
audioDeviceId={config.audioDeviceId}
onPermissionError={() => {
setMicError(true)
setIsListening(false)
}}
onStreamReady={loopSetStream}
/>
{micError && (
<div className="mb-2 px-4 py-3 rounded-xl border border-red-800 bg-red-900/20 text-sm text-red-400 flex items-center justify-between">
<span>Microphone permission denied. Please allow microphone access in your browser or OS settings and try again.</span>
<button onClick={() => setMicError(null)} className="ml-4 text-red-600 hover:text-red-400 text-lg leading-none">×</button>
</div>
)}
{/* ── Chord detail modal ── */}
<ChordDetailModal chord={selectedChord} onClose={() => setSelectedChord(null)} onChordClick={setSelectedChord} keyInfo={effectiveKey} chordHistory={chordHistory} />
{/* ── Progression banner ── */}
<ProgressionBanner
chordHistory={chordHistory}
keyInfo={effectiveKey}
detectedProgression={detectedProgression}
seedInfo={seedInfo}
onChordClick={setSelectedChord}
/>
{/* ── The jam dashboard grid (task L-50, one-screen.md §1/§6): JamGuide
owns the two-column layout — LEFT: compact instrument view (chosen
here, passed as the mainView slot) + licks strip + the related-
progressions slot (RelatedProgressions, task L-51); RIGHT: the
suggested-voicings rail. ProgressionSuggestions is unmounted (file
kept) — its job split into the rail + RelatedProgressions per the
user directive. Follows the one global instrument selector. ── */}
<JamGuide
detectedProgression={detectedProgression}
keyInfo={effectiveKey}
chordHistory={chordHistory}
currentChord={currentChord}
onFocusChord={setJamFocusChord}
instrument={instrument}
mainView={
<>
{instrument === 'guitar' && <Fretboard keyInfo={effectiveKey} currentChord={currentChord} pentatonicOnly={false} monoColor={monoColor} jamFocusChord={jamFocusChord} compact />}
{instrument === 'bass' && <BassFretboard keyInfo={effectiveKey} currentChord={currentChord} monoColor={monoColor} compact />}
{instrument === 'piano' && <Piano keyInfo={effectiveKey} currentChord={currentChord} monoColor={monoColor} compact />}
</>
}
relatedSlot={
<div className="flex flex-col gap-3">
<TryThis
loop={detectedProgression}
keyInfo={effectiveKey}
currentChord={currentChord}
onChordClick={setSelectedChord}
/>
<RelatedProgressions
loop={detectedProgression}
keyInfo={effectiveKey}
onChordClick={setSelectedChord}
/>
</div>
}
fill={jamView}
/>
{/* ── Below the dashboard — the learning / behind-the-scenes area (page
scroll in normal mode; UNMOUNTED in jam view, one-screen.md §1.1/§3:
conditional mount, not `hidden`, so collapsed chrome can't leak
height). ── */}
{!jamView && (
<>
{/* ── Loop station ── */}
<LoopStation
slots={slots}
bpm={bpm}
masterLen={masterLen}
audioCtxRef={loopAudioCtxRef}
masterStartRef={loopMasterStartRef}
masterLenRef={loopMasterLenRef}
onSlotClick={handleSlotClick}
onCommitTrim={commitTrim}
onCancelRecord={cancelRecord}
onRetrim={retrimSlot}
onDelete={deleteSlot}
onVolumeChange={loopSetVolume}
onAddSlot={loopAddSlot}
/>
{/* ── Behind the scenes — collapsible ── */}
<div className="mb-3 bg-panel border border-border rounded-xl overflow-hidden">
<button
onClick={() => setShowDebug(v => !v)}
className="w-full flex items-center justify-between px-4 py-2 text-sm text-gray-400 hover:text-gray-200 transition-all"
>
<span>BEHIND THE SCENES</span>
<span>{showDebug ? '▲' : '▼'}</span>
</button>
{showDebug && (
<div className="border-t border-border p-4">
<DebugView
chroma={debugChroma}
chordCandidates={debugCandidates}
noteAnalysis={debugNoteAnalysis}
waveform={debugWaveform}
keyInfo={effectiveKey}
currentChord={currentChord}
instrument={instrument}
monoColor={monoColor}
/>
</div>
)}
</div>
{/* ── Rhythm / drum analyser — collapsible ── */}
<div className="mb-3 bg-panel border border-border rounded-xl overflow-hidden">
<button
onClick={() => setShowDrumView(v => !v)}
className="w-full flex items-center justify-between px-4 py-2 text-sm text-gray-400 hover:text-gray-200 transition-all"
>
<span>RHYTHM ANALYSER</span>
<span>{showDrumView ? '▲' : '▼'}</span>
</button>
{showDrumView && (
<div className="border-t border-border p-4">
<DrumView waveform={debugWaveform} bpm={bpm} />
</div>
)}
</div>
{/* ── Tuner — collapsible ── */}
<div className="bg-panel border border-border rounded-xl overflow-hidden">
<button
onClick={() => setShowTuner(v => !v)}
className="w-full flex items-center justify-between px-4 py-2 text-sm text-gray-400 hover:text-gray-200 transition-all"
>
<span>TUNER</span>
<span>{showTuner ? '▲' : '▼'}</span>
</button>
{showTuner && <div className="border-t border-border"><Tuner /></div>}
</div>
{/* ── Knowledge Center — bottom browse & study dock (L-40, D-40 §5) ── */}
<KnowledgeDock
keyInfo={effectiveKey}
chordHistory={chordHistory}
currentChord={currentChord}
onChordClick={setSelectedChord}
instrument={instrument}
/>
</>
)}
</div>
)
}