From 1df6fe8e9ebcfc69b08f8ab5d4c971f1f87cbd2d Mon Sep 17 00:00:00 2001 From: vadimwit Date: Sat, 11 Jul 2026 19:48:14 +0100 Subject: [PATCH] =?UTF-8?q?feat(app):=20Jam=20Roulette=20=E2=80=94=20one-c?= =?UTF-8?q?lick=20genre=20+=20key=20+=20progression=20to=20jam=20over=20(t?= =?UTF-8?q?ask=20L-60,=202/2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ๐ŸŽฒ button opens a genre popover (Surprise me + the 10 KB styles); rollJam seeds a random key + an interesting round-trip-safe progression as a committed loop, so the whole dashboard fills exactly as if detection found it โ€” banner chips, voicing rail, licks, related progressions. Three commit-layer guards keep the seed alive until the band takes over: the null-clear skips seeded loops (a seed would otherwise die one commit before the earliest possible confirmation), the agreement branch confirms it, replacement swaps once real playing is consistent, New Song clears it. An amber "rolled ยท style ยท name ยท bars โ€” play it!" provenance chip flips to the normal loop marker on first agreeing detection. Audio contract grep: zero hits. Critic PASS (seed timeline hand-traced; ยง3.5 invariant verified at every write). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/App.jsx | 179 ++++++++++++++++++++++++++- src/components/ProgressionBanner.jsx | 21 +++- 2 files changed, 195 insertions(+), 5 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index c831e9c..82d10ae 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -14,6 +14,8 @@ import RelatedProgressions from './components/RelatedProgressions' 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 = { @@ -152,6 +154,13 @@ export default function App() { 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([]) @@ -167,6 +176,13 @@ export default function App() { 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]) @@ -213,7 +229,11 @@ export default function App() { const vote = progressionVoteRef.current if (!detected) { progressionMissRef.current++ - if (progressionMissRef.current >= NULL_CLEAR) { + // 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 } @@ -227,6 +247,9 @@ export default function App() { 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 } @@ -234,11 +257,17 @@ export default function App() { if (vote && vote.candidateKey === key) { vote.candidateCount++ } else { - progressionVoteRef.current = { committedKey, candidateKey: key, candidateCount: 1 } + // 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]) @@ -261,6 +290,7 @@ export default function App() { effectiveKeyRef.current = null setChordHistory([]) setDetectedProgression(null) + setSeedInfo(null) // L-60 (ยง3.6): the seed clears with the full reset setTopKeyCandidates([]) setBpm(null) setMicError(null) @@ -290,6 +320,94 @@ export default function App() { 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 2โ€“8 + 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 3โ€“7 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) { @@ -607,6 +725,62 @@ export default function App() { )} + {/* โ”€โ”€ Jam roulette (L-60, jam-roulette.md ยง1.1/ยง1.2) โ”€โ”€ */} +
+ + {rouletteMenuOpen && ( +
+ {seedInfo && ( + + )} + + {Object.entries(kb).map(([id, s]) => ( + + ))} +
+ )} +
+ {/* โ”€โ”€ Jam view toggle (L-50, one-screen.md ยง1.1) โ€” layout-only โ”€โ”€ */}