additions for live chroma and sound improvements
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 202 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
@@ -7,9 +7,10 @@ function createWindow() {
|
||||
const win = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 900,
|
||||
minWidth: 900,
|
||||
minWidth: 620,
|
||||
minHeight: 600,
|
||||
title: 'WhatTheFlat',
|
||||
icon: path.join(__dirname, '../assets/whattheflat-logo.png'),
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.cjs'),
|
||||
contextIsolation: true,
|
||||
|
||||
@@ -46,15 +46,15 @@
|
||||
},
|
||||
"win": {
|
||||
"target": "nsis",
|
||||
"icon": "assets/icon.ico"
|
||||
"icon": "assets/whattheflat-logo.png"
|
||||
},
|
||||
"mac": {
|
||||
"target": "dmg",
|
||||
"icon": "assets/icon.icns"
|
||||
"icon": "assets/whattheflat-logo.png"
|
||||
},
|
||||
"linux": {
|
||||
"target": "AppImage",
|
||||
"icon": "assets/icon.png"
|
||||
"icon": "assets/whattheflat-logo.png"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
|
||||
@@ -5,63 +5,106 @@ import ProgressionSuggestions from './components/ProgressionSuggestions'
|
||||
import Fretboard from './components/Fretboard'
|
||||
import Tuner from './components/Tuner'
|
||||
import Piano from './components/Piano'
|
||||
import { NOTES, detectKey, detectTopKeys, matchChordFromChroma, detectRepeatingProgression, getChordTones } from './lib/theory'
|
||||
import Settings from './components/Settings'
|
||||
import DebugView from './components/DebugView'
|
||||
import { NOTES, detectKey, detectTopKeys, matchChordFromChroma, detectRepeatingProgression, getChordTones, getChordCandidates, getNoteHistoryAnalysis } from './lib/theory'
|
||||
import settingIcon from './assets/setting-icon.png'
|
||||
import viewIcon from './assets/view.png'
|
||||
|
||||
// Key detection tuning
|
||||
const NOTE_HISTORY_SIZE = 160 // larger = chord boosts dominate over melody runs
|
||||
const KEY_VOTE_WINDOW = 12
|
||||
const KEY_VOTE_THRESHOLD = 9 // out of 12
|
||||
const CHORD_NOTE_BOOST = 3 // confirmed chord tones injected N× into note history
|
||||
|
||||
// Chord detection tuning
|
||||
const CHROMA_SMOOTH = 8 // frames to average — more responsive
|
||||
const CHORD_VOTE_THRESHOLD = 2 // consecutive identical detections required
|
||||
const CHORD_MIN_SCORE = 0.38 // lower = more chord types detected
|
||||
const DEFAULTS = {
|
||||
// Key detection
|
||||
noteHistorySize: 12000, // ~whole session until New Song
|
||||
keyVoteWindow: 40, // larger window → needs sustained evidence to shift
|
||||
keyVoteThreshold: 32, // 80% of window must agree
|
||||
chordNoteBoost: 3,
|
||||
// Chord detection
|
||||
chromaSmooth: 14, // more frames averaged → transient chords invisible
|
||||
chordVoteThreshold: 4, // 4 consecutive identical reads → ~600ms sustained
|
||||
chordMinScore: 0.40, // slightly stricter match quality
|
||||
// Audio input
|
||||
minClarity: 0.80,
|
||||
minVolume: 0.01,
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
// ── Listening state ──────────────────────────────────────────────────────
|
||||
// ── Config ───────────────────────────────────────────────────────────────────
|
||||
const [config, setConfig] = useState(DEFAULTS)
|
||||
const configRef = useRef(DEFAULTS)
|
||||
useEffect(() => { configRef.current = 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 ───────────────────────────────────────────────
|
||||
// ── Instrument view + tuner ───────────────────────────────────────────────────
|
||||
const [instrument, setInstrument] = useState('guitar') // 'guitar' | 'piano'
|
||||
const [showTuner, setShowTuner] = useState(false)
|
||||
const [showDebug, setShowDebug] = useState(false)
|
||||
|
||||
// ── BPM estimation from chord-change intervals ────────────────────────────
|
||||
// ── Mic permission error ──────────────────────────────────────────────────────
|
||||
const [micError, setMicError] = useState(null)
|
||||
|
||||
// ── Debug data ────────────────────────────────────────────────────────────────
|
||||
const [debugChroma, setDebugChroma] = useState(null)
|
||||
const [debugCandidates, setDebugCandidates] = useState([])
|
||||
const [debugNoteAnalysis, setDebugNoteAnalysis] = useState(null)
|
||||
|
||||
// ── Stable refs for values used inside callbacks ──────────────────────────────
|
||||
const showDebugRef = useRef(showDebug)
|
||||
const lockedKeyRef = useRef(null)
|
||||
useEffect(() => { showDebugRef.current = showDebug }, [showDebug])
|
||||
|
||||
// ── BPM estimation from chord-change intervals ────────────────────────────────
|
||||
const [bpm, setBpm] = useState(null)
|
||||
const chordTimestampsRef = useRef([])
|
||||
const chordTimestampsRef = useRef([])
|
||||
const bpmSmoothRef = useRef(null) // exponentially smoothed BPM
|
||||
|
||||
// ── Key: auto-detected + optional lock ───────────────────────────────────
|
||||
// ── 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')
|
||||
|
||||
// Effective key used by all components
|
||||
const effectiveKey = lockedKey ?? keyInfo
|
||||
|
||||
// ── Chord state ───────────────────────────────────────────────────────────
|
||||
// ── Chord state ───────────────────────────────────────────────────────────────
|
||||
const [chordHistory, setChordHistory] = useState([])
|
||||
const [detectedProgression, setDetectedProgression] = useState(null)
|
||||
|
||||
// ── Top key candidates (shown as quick-lock chips) ────────────────────────
|
||||
// ── Top key candidates (shown as quick-lock chips) ────────────────────────────
|
||||
const [topKeyCandidates, setTopKeyCandidates] = useState([])
|
||||
|
||||
// ── Internal refs ─────────────────────────────────────────────────────────
|
||||
// ── Internal refs ─────────────────────────────────────────────────────────────
|
||||
const noteHistoryRef = useRef([])
|
||||
const keyVotesRef = useRef([])
|
||||
const effectiveKeyRef = useRef(null) // mirror for use inside callbacks
|
||||
const chromaRingRef = useRef(
|
||||
Array.from({ length: CHROMA_SMOOTH }, () => new Float32Array(12))
|
||||
const effectiveKeyRef = useRef(null)
|
||||
const chromaRingRef = useRef(
|
||||
Array.from({ length: DEFAULTS.chromaSmooth }, () => new Float32Array(12))
|
||||
)
|
||||
const chromaIdxRef = useRef(0)
|
||||
const chromaIdxRef = useRef(0)
|
||||
const chordVotesRef = useRef([])
|
||||
const progressionVoteRef = useRef(null) // stabilise loop display
|
||||
const pendingKeyRef = useRef(null) // proposed key change — require 2 consecutive wins
|
||||
const progressionVoteRef = useRef(null)
|
||||
const pendingKeyRef = useRef(null)
|
||||
|
||||
// Keep ref in sync
|
||||
// Keep refs in sync
|
||||
useEffect(() => { effectiveKeyRef.current = effectiveKey }, [effectiveKey])
|
||||
|
||||
// ── Detect progression — require 2 consecutive identical results to commit ─
|
||||
// Re-init chroma ring when chromaSmooth changes
|
||||
useEffect(() => {
|
||||
chromaRingRef.current = Array.from(
|
||||
{ length: config.chromaSmooth },
|
||||
() => new Float32Array(12)
|
||||
)
|
||||
chromaIdxRef.current = 0
|
||||
}, [config.chromaSmooth])
|
||||
|
||||
// ── Detect progression — require 2 consecutive identical results to commit ────
|
||||
useEffect(() => {
|
||||
const detected = detectRepeatingProgression(chordHistory)
|
||||
if (!detected) return
|
||||
@@ -73,26 +116,32 @@ export default function App() {
|
||||
}
|
||||
}, [chordHistory])
|
||||
|
||||
// ── New song — full reset ─────────────────────────────────────────────────
|
||||
// ── New song — full reset ─────────────────────────────────────────────────────
|
||||
function newSong() {
|
||||
noteHistoryRef.current = []
|
||||
keyVotesRef.current = []
|
||||
chordVotesRef.current = []
|
||||
const cfg = configRef.current
|
||||
noteHistoryRef.current = []
|
||||
keyVotesRef.current = []
|
||||
chordVotesRef.current = []
|
||||
progressionVoteRef.current = null
|
||||
pendingKeyRef.current = null
|
||||
chromaIdxRef.current = 0
|
||||
chromaRingRef.current = Array.from({ length: CHROMA_SMOOTH }, () => new Float32Array(12))
|
||||
pendingKeyRef.current = null
|
||||
chromaIdxRef.current = 0
|
||||
chromaRingRef.current = Array.from({ length: cfg.chromaSmooth }, () => new Float32Array(12))
|
||||
chordTimestampsRef.current = []
|
||||
bpmSmoothRef.current = null
|
||||
setKeyInfo(null)
|
||||
setLockedKey(null)
|
||||
effectiveKeyRef.current = null
|
||||
effectiveKeyRef.current = null
|
||||
setChordHistory([])
|
||||
setDetectedProgression(null)
|
||||
setTopKeyCandidates([])
|
||||
setBpm(null)
|
||||
setMicError(null)
|
||||
setDebugChroma(null)
|
||||
setDebugCandidates([])
|
||||
setDebugNoteAnalysis(null)
|
||||
}
|
||||
|
||||
// ── Key lock handlers ─────────────────────────────────────────────────────
|
||||
// ── Key lock handlers ─────────────────────────────────────────────────────────
|
||||
function applyLock() {
|
||||
const info = { root: lockRoot, mode: lockMode, confidence: 1 }
|
||||
setLockedKey(info)
|
||||
@@ -112,27 +161,29 @@ export default function App() {
|
||||
effectiveKeyRef.current = keyInfo
|
||||
}
|
||||
|
||||
// ── Note handler: drives key detection (pitch-based) ──────────────────────
|
||||
// ── 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 > NOTE_HISTORY_SIZE) history.shift()
|
||||
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) setDebugNoteAnalysis(getNoteHistoryAnalysis(history))
|
||||
if (result.confidence < 0.5) return
|
||||
|
||||
const votes = keyVotesRef.current
|
||||
votes.push(`${result.root}_${result.mode}`)
|
||||
if (votes.length > KEY_VOTE_WINDOW) votes.shift()
|
||||
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 >= KEY_VOTE_THRESHOLD) {
|
||||
if (count >= cfg.keyVoteThreshold) {
|
||||
const [root, mode] = winner.split('_')
|
||||
const candidateKey = `${root}_${mode}`
|
||||
|
||||
@@ -140,89 +191,109 @@ export default function App() {
|
||||
const currentKey = prev ? `${prev.root}_${prev.mode}` : null
|
||||
|
||||
if (currentKey === candidateKey) {
|
||||
// Same key — just refresh confidence, clear pending
|
||||
pendingKeyRef.current = null
|
||||
return { root, mode, confidence: result.confidence }
|
||||
}
|
||||
|
||||
// Different key — require a second consecutive win before committing
|
||||
if (pendingKeyRef.current === candidateKey) {
|
||||
pendingKeyRef.current = null
|
||||
if (!lockedKey) chordVotesRef.current = []
|
||||
if (!lockedKeyRef.current) chordVotesRef.current = []
|
||||
return { root, mode, confidence: result.confidence }
|
||||
}
|
||||
|
||||
pendingKeyRef.current = candidateKey
|
||||
return prev // hold current key until next confirmation
|
||||
return prev
|
||||
})
|
||||
}
|
||||
}, [lockedKey])
|
||||
}, [])
|
||||
|
||||
// ── Chroma handler: drives chord detection ────────────────────────────────
|
||||
// ── Chroma handler: drives chord detection ────────────────────────────────────
|
||||
const handleChroma = useCallback((chroma, bassPC) => {
|
||||
const cfg = configRef.current
|
||||
const ring = chromaRingRef.current
|
||||
ring[chromaIdxRef.current % CHROMA_SMOOTH] = chroma
|
||||
ring[chromaIdxRef.current % cfg.chromaSmooth] = chroma
|
||||
chromaIdxRef.current++
|
||||
if (chromaIdxRef.current % CHROMA_SMOOTH !== 0) return
|
||||
if (chromaIdxRef.current % cfg.chromaSmooth !== 0) return
|
||||
|
||||
const key = effectiveKeyRef.current
|
||||
if (!key) return
|
||||
|
||||
// Average ring buffer
|
||||
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] /= CHROMA_SMOOTH
|
||||
for (let i = 0; i < 12; i++) avg[i] /= cfg.chromaSmooth
|
||||
|
||||
const chord = matchChordFromChroma(avg, key, bassPC, false, CHORD_MIN_SCORE)
|
||||
if (showDebugRef.current) {
|
||||
setDebugChroma([...avg])
|
||||
setDebugCandidates(getChordCandidates(avg, key, bassPC))
|
||||
}
|
||||
|
||||
const chord = matchChordFromChroma(avg, key, bassPC, false, cfg.chordMinScore)
|
||||
if (!chord) {
|
||||
// Ambiguous moment (transition, silence) — reset streak, history is untouched
|
||||
chordVotesRef.current = []
|
||||
return
|
||||
}
|
||||
|
||||
const votes = chordVotesRef.current
|
||||
votes.push(chord)
|
||||
if (votes.length > CHORD_VOTE_THRESHOLD) votes.shift()
|
||||
if (votes.length > cfg.chordVoteThreshold) votes.shift()
|
||||
|
||||
// All last N detections must agree — one wrong reading resets the streak
|
||||
if (votes.length >= CHORD_VOTE_THRESHOLD && votes.every(v => v === votes[0])) {
|
||||
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(-30), winner]
|
||||
})
|
||||
|
||||
// BPM: track chord commit timestamps and estimate tempo
|
||||
// BPM: track chord commit timestamps, trim outliers, smooth result
|
||||
const now = performance.now()
|
||||
const ts = chordTimestampsRef.current
|
||||
ts.push(now)
|
||||
if (ts.length > 8) ts.shift()
|
||||
if (ts.length >= 3) {
|
||||
if (ts.length > 32) ts.shift()
|
||||
if (ts.length >= 4) {
|
||||
const intervals = []
|
||||
for (let i = 1; i < ts.length; i++) intervals.push(ts[i] - ts[i - 1])
|
||||
const avg = intervals.reduce((a, b) => a + b) / intervals.length
|
||||
let estimated = Math.round(60000 / avg)
|
||||
// Chord changes are often every 2 beats — normalise to 55–220 BPM range
|
||||
while (estimated < 55) estimated *= 2
|
||||
while (estimated > 220) estimated /= 2
|
||||
setBpm(estimated)
|
||||
|
||||
// Trim the most extreme 25% on each side to remove held/rushed chords
|
||||
const sorted = [...intervals].sort((a, b) => a - b)
|
||||
const trim = Math.max(1, Math.floor(sorted.length * 0.25))
|
||||
const trimmed = sorted.slice(trim, sorted.length - trim)
|
||||
const avgMs = trimmed.reduce((a, b) => a + b) / trimmed.length
|
||||
|
||||
let raw = 60000 / avgMs
|
||||
while (raw < 55) raw *= 2
|
||||
while (raw > 220) raw /= 2
|
||||
|
||||
// Exponential smoothing — blend toward new estimate gradually
|
||||
const prev = bpmSmoothRef.current
|
||||
bpmSmoothRef.current = prev === null ? raw : 0.25 * raw + 0.75 * prev
|
||||
setBpm(Math.round(bpmSmoothRef.current))
|
||||
}
|
||||
|
||||
// Inject chord tones into note history with boost weight so key detection
|
||||
// anchors to confirmed chords rather than transient melody notes
|
||||
// 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 < CHORD_NOTE_BOOST; j++) {
|
||||
for (let j = 0; j < cfg.chordNoteBoost; j++) {
|
||||
for (const pc of chordPCs) history.push(pc)
|
||||
}
|
||||
while (history.length > NOTE_HISTORY_SIZE) history.shift()
|
||||
while (history.length > cfg.noteHistorySize) history.shift()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const currentChord = chordHistory[chordHistory.length - 1]
|
||||
|
||||
if (showSettings) {
|
||||
return (
|
||||
<Settings
|
||||
config={config}
|
||||
onChange={updateConfig}
|
||||
onClose={() => setShowSettings(false)}
|
||||
onReset={() => setConfig(DEFAULTS)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-surface text-white p-3">
|
||||
|
||||
@@ -234,16 +305,30 @@ export default function App() {
|
||||
</h1>
|
||||
<p className="text-xs text-gray-600">Real-time key detection for live jams</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-2 items-center">
|
||||
<button
|
||||
onClick={newSong}
|
||||
className="px-3 py-2 rounded-full text-sm border border-border text-gray-500 hover:text-gray-300 hover:border-gray-400 transition-all leading-tight text-center"
|
||||
onClick={() => setShowDebug(v => !v)}
|
||||
className={`p-2 rounded-full border transition-all ${showDebug ? 'border-accent bg-accent/10' : 'border-border hover:border-gray-400'}`}
|
||||
title="Behind the scenes"
|
||||
>
|
||||
<span className="block font-semibold">New Song</span>
|
||||
<span className="block text-xs opacity-60">clear history</span>
|
||||
<img src={viewIcon} alt="Debug view" className="w-5 h-5" style={{ filter: 'invert(1) opacity(0.75)' }} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsListening(l => !l)}
|
||||
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'
|
||||
@@ -257,12 +342,53 @@ export default function App() {
|
||||
|
||||
{/* ── 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="guitar">Guitar</option>
|
||||
<option value="piano">Piano</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">
|
||||
<span className="px-3 py-1 bg-accent/20 border border-accent text-accent rounded-full text-sm font-semibold">
|
||||
🔒 {lockedKey.root} {lockedKey.mode}
|
||||
</span>
|
||||
<button onClick={removeLock} className="text-xs text-gray-500 hover:text-gray-300 underline">
|
||||
<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>
|
||||
@@ -275,39 +401,66 @@ export default function App() {
|
||||
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-400 hover:text-gray-200'
|
||||
: '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>}
|
||||
<select
|
||||
value={lockRoot}
|
||||
onChange={e => setLockRoot(e.target.value)}
|
||||
className="bg-surface border border-border rounded-lg px-2 py-1 text-sm text-gray-300"
|
||||
>
|
||||
{NOTES.map(n => <option key={n}>{n}</option>)}
|
||||
</select>
|
||||
<select
|
||||
value={lockMode}
|
||||
onChange={e => setLockMode(e.target.value)}
|
||||
className="bg-surface border border-border rounded-lg px-2 py-1 text-sm text-gray-300"
|
||||
>
|
||||
<option value="major">Major</option>
|
||||
<option value="minor">Minor</option>
|
||||
</select>
|
||||
<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-border hover:bg-accent/20 border border-border hover:border-accent text-sm rounded-lg transition-all"
|
||||
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
|
||||
Lock key
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AudioCapture onNote={handleNote} onChroma={handleChroma} isListening={isListening} />
|
||||
<AudioCapture
|
||||
onNote={handleNote}
|
||||
onChroma={handleChroma}
|
||||
isListening={isListening}
|
||||
minClarity={config.minClarity}
|
||||
minVolume={config.minVolume}
|
||||
onPermissionError={() => {
|
||||
setMicError(true)
|
||||
setIsListening(false)
|
||||
}}
|
||||
/>
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* ── Progression banner ── */}
|
||||
<ProgressionBanner
|
||||
@@ -315,36 +468,37 @@ export default function App() {
|
||||
keyInfo={effectiveKey}
|
||||
detectedProgression={detectedProgression}
|
||||
currentChord={currentChord}
|
||||
bpm={bpm}
|
||||
/>
|
||||
|
||||
{/* ── Instrument + progressions row ── */}
|
||||
<div className="flex gap-3 mb-3">
|
||||
{/* Left: fretboard / piano */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-1 px-1">
|
||||
<span className="text-xs text-gray-500 uppercase tracking-widest">Instrument</span>
|
||||
<select
|
||||
value={instrument}
|
||||
onChange={e => setInstrument(e.target.value)}
|
||||
className="bg-surface border border-border rounded-lg px-2 py-1 text-sm text-gray-300"
|
||||
>
|
||||
<option value="guitar">Guitar</option>
|
||||
<option value="piano">Piano</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-3 mb-3 items-stretch">
|
||||
<div className="w-full lg:w-[70%] min-w-0">
|
||||
{instrument === 'guitar'
|
||||
? <Fretboard keyInfo={effectiveKey} currentChord={currentChord} pentatonicOnly={false} />
|
||||
: <Piano keyInfo={effectiveKey} currentChord={currentChord} />
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Right: genre progression suggestions */}
|
||||
<div className="w-56 shrink-0">
|
||||
<ProgressionSuggestions keyInfo={effectiveKey} />
|
||||
<div className="hidden lg:block w-[30%] min-w-0 relative">
|
||||
<div className="absolute inset-0">
|
||||
<ProgressionSuggestions keyInfo={effectiveKey} currentChord={currentChord} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Behind the scenes debug view ── */}
|
||||
{showDebug && (
|
||||
<div className="mb-3">
|
||||
<DebugView
|
||||
chroma={debugChroma}
|
||||
chordCandidates={debugCandidates}
|
||||
noteAnalysis={debugNoteAnalysis}
|
||||
keyInfo={effectiveKey}
|
||||
currentChord={currentChord}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Tuner — collapsible ── */}
|
||||
<div>
|
||||
<button
|
||||
|
||||
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 202 KiB |
@@ -24,10 +24,8 @@ import { NOTES } from '../lib/theory'
|
||||
// by simply using a much larger FFT window.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const PITCH_FFT = 4096 // ~90ms window — good temporal resolution for pitch
|
||||
const CHORD_FFT = 16384 // ~370ms window — 2.7 Hz/bin, separates low semitones
|
||||
const MIN_CLARITY = 0.80
|
||||
const MIN_VOLUME = 0.01
|
||||
const PITCH_FFT = 4096 // ~90ms window — good temporal resolution for pitch
|
||||
const CHORD_FFT = 16384 // ~370ms window — 2.7 Hz/bin, separates low semitones
|
||||
const NOISE_FLOOR = -65 // dB
|
||||
|
||||
// ─── Harmonic summation chroma ────────────────────────────────────────────────
|
||||
@@ -83,27 +81,45 @@ function detectBassPC(freqData, sampleRate, fftSize) {
|
||||
return ((bestMidi % 12) + 12) % 12
|
||||
}
|
||||
|
||||
export default function AudioCapture({ onNote, onChroma, isListening }) {
|
||||
const audioCtxRef = useRef(null)
|
||||
const pitchAnalyser = useRef(null)
|
||||
const chordAnalyser = useRef(null)
|
||||
const timeBufRef = useRef(null)
|
||||
const freqBufRef = useRef(null)
|
||||
const detectorRef = useRef(null)
|
||||
const rafRef = useRef(null)
|
||||
const streamRef = useRef(null)
|
||||
export default function AudioCapture({ onNote, onChroma, isListening, minClarity = 0.80, minVolume = 0.01, onPermissionError }) {
|
||||
const audioCtxRef = useRef(null)
|
||||
const timeBufRef = useRef(null)
|
||||
const freqBufRef = useRef(null)
|
||||
const detectorRef = useRef(null)
|
||||
const rafRef = useRef(null)
|
||||
const streamRef = useRef(null)
|
||||
const activeRef = useRef(false) // guards against stale tick callbacks
|
||||
|
||||
// All callbacks and thresholds read via refs — so start/stop never need to recreate
|
||||
const onNoteRef = useRef(onNote)
|
||||
const onChromaRef = useRef(onChroma)
|
||||
const onPermissionErrorRef = useRef(onPermissionError)
|
||||
const minClarityRef = useRef(minClarity)
|
||||
const minVolumeRef = useRef(minVolume)
|
||||
useEffect(() => { onNoteRef.current = onNote }, [onNote])
|
||||
useEffect(() => { onChromaRef.current = onChroma }, [onChroma])
|
||||
useEffect(() => { onPermissionErrorRef.current = onPermissionError }, [onPermissionError])
|
||||
useEffect(() => { minClarityRef.current = minClarity }, [minClarity])
|
||||
useEffect(() => { minVolumeRef.current = minVolume }, [minVolume])
|
||||
|
||||
const stop = useCallback(() => {
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current)
|
||||
if (streamRef.current) streamRef.current.getTracks().forEach(t => t.stop())
|
||||
if (audioCtxRef.current) audioCtxRef.current.close()
|
||||
audioCtxRef.current = null
|
||||
activeRef.current = false
|
||||
if (rafRef.current) { cancelAnimationFrame(rafRef.current); rafRef.current = null }
|
||||
if (streamRef.current) { streamRef.current.getTracks().forEach(t => t.stop()); streamRef.current = null }
|
||||
if (audioCtxRef.current) { audioCtxRef.current.close(); audioCtxRef.current = null }
|
||||
}, [])
|
||||
|
||||
const start = useCallback(async () => {
|
||||
stop()
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
let stream
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
} catch (err) {
|
||||
onPermissionErrorRef.current?.(err)
|
||||
return
|
||||
}
|
||||
streamRef.current = stream
|
||||
activeRef.current = true
|
||||
|
||||
const ctx = new AudioContext()
|
||||
audioCtxRef.current = ctx
|
||||
@@ -112,8 +128,7 @@ export default function AudioCapture({ onNote, onChroma, isListening }) {
|
||||
// Small analyser — pitch detection needs fast time-domain data
|
||||
const pa = ctx.createAnalyser()
|
||||
pa.fftSize = PITCH_FFT
|
||||
pa.smoothingTimeConstant = 0.0 // no smoothing: pitchy needs clean waveform
|
||||
pitchAnalyser.current = pa
|
||||
pa.smoothingTimeConstant = 0.0
|
||||
source.connect(pa)
|
||||
timeBufRef.current = new Float32Array(pa.fftSize)
|
||||
detectorRef.current = PitchDetector.forFloat32Array(pa.fftSize)
|
||||
@@ -121,30 +136,29 @@ export default function AudioCapture({ onNote, onChroma, isListening }) {
|
||||
// Large analyser — chord detection needs fine frequency resolution
|
||||
const ca = ctx.createAnalyser()
|
||||
ca.fftSize = CHORD_FFT
|
||||
ca.smoothingTimeConstant = 0.65 // smooth over time for stable chord reading
|
||||
chordAnalyser.current = ca
|
||||
ca.smoothingTimeConstant = 0.65
|
||||
source.connect(ca)
|
||||
freqBufRef.current = new Float32Array(ca.frequencyBinCount)
|
||||
|
||||
function tick() {
|
||||
if (!activeRef.current) return // stop() was called — bail immediately
|
||||
|
||||
const timeBuf = timeBufRef.current
|
||||
pa.getFloatTimeDomainData(timeBuf)
|
||||
|
||||
const rms = Math.sqrt(timeBuf.reduce((s, v) => s + v * v, 0) / timeBuf.length)
|
||||
if (rms >= MIN_VOLUME) {
|
||||
// Pitch via McLeod (autocorrelation) — unaffected by FFT bin size
|
||||
if (rms >= minVolumeRef.current) {
|
||||
const [freq, clarity] = detectorRef.current.findPitch(timeBuf, ctx.sampleRate)
|
||||
if (clarity >= MIN_CLARITY && freq > 60 && freq < 4200) {
|
||||
if (clarity >= minClarityRef.current && freq > 60 && freq < 4200) {
|
||||
const midi = Math.round(12 * Math.log2(freq / 440) + 69)
|
||||
const pitchClass = ((midi % 12) + 12) % 12
|
||||
onNote({ noteName: NOTES[pitchClass], pitchClass, freq, midi, clarity })
|
||||
onNoteRef.current({ noteName: NOTES[pitchClass], pitchClass, freq, midi, clarity })
|
||||
}
|
||||
|
||||
// Chord chroma from the high-resolution FFT
|
||||
if (onChroma) {
|
||||
if (onChromaRef.current) {
|
||||
const freqBuf = freqBufRef.current
|
||||
ca.getFloatFrequencyData(freqBuf)
|
||||
onChroma(
|
||||
onChromaRef.current(
|
||||
computeChroma(freqBuf, ctx.sampleRate, ca.fftSize),
|
||||
detectBassPC(freqBuf, ctx.sampleRate, ca.fftSize)
|
||||
)
|
||||
@@ -154,7 +168,7 @@ export default function AudioCapture({ onNote, onChroma, isListening }) {
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
}
|
||||
tick()
|
||||
}, [onNote, onChroma, stop])
|
||||
}, [stop])
|
||||
|
||||
useEffect(() => {
|
||||
if (isListening) start().catch(console.error)
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
|
||||
export default function ChatAssistant({ keyInfo, currentChord }) {
|
||||
const [messages, setMessages] = useState([])
|
||||
const [input, setInput] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const bottomRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages])
|
||||
|
||||
async function send(e) {
|
||||
e.preventDefault()
|
||||
if (!input.trim() || loading) return
|
||||
|
||||
const userMsg = { role: 'user', content: input.trim() }
|
||||
const next = [...messages, userMsg]
|
||||
setMessages(next)
|
||||
setInput('')
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const context = {}
|
||||
if (keyInfo?.root) context.key = `${keyInfo.root} ${keyInfo.mode}`
|
||||
if (currentChord) context.chord = currentChord
|
||||
|
||||
const res = await fetch('/api/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ messages: next, context }),
|
||||
})
|
||||
const data = await res.json()
|
||||
setMessages(prev => [...prev, { role: 'assistant', content: data.reply }])
|
||||
} catch (err) {
|
||||
setMessages(prev => [...prev, {
|
||||
role: 'assistant',
|
||||
content: 'Could not reach the AI assistant. Is the backend running?',
|
||||
}])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-panel border border-border rounded-2xl p-6 flex flex-col h-80">
|
||||
<p className="text-sm text-gray-500 uppercase tracking-widest mb-3">Theory Assistant</p>
|
||||
<div className="flex-1 overflow-y-auto space-y-3 pr-1">
|
||||
{messages.length === 0 && (
|
||||
<p className="text-gray-600 text-sm">Ask anything: "What lick works over this chord?" or "Why does the IV sound so resolved?"</p>
|
||||
)}
|
||||
{messages.map((m, i) => (
|
||||
<div key={i} className={`text-sm ${m.role === 'user' ? 'text-right' : 'text-left'}`}>
|
||||
<span className={`inline-block px-3 py-2 rounded-xl max-w-[85%] ${
|
||||
m.role === 'user'
|
||||
? 'bg-accent text-white'
|
||||
: 'bg-border text-gray-200'
|
||||
}`}>
|
||||
{m.content}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{loading && (
|
||||
<div className="text-left">
|
||||
<span className="inline-block px-3 py-2 rounded-xl bg-border text-gray-400 text-sm animate-pulse">
|
||||
Thinking…
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
<form onSubmit={send} className="mt-3 flex gap-2">
|
||||
<input
|
||||
className="flex-1 bg-surface border border-border rounded-lg px-3 py-2 text-sm outline-none focus:border-accent"
|
||||
placeholder="Ask about music theory…"
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !input.trim()}
|
||||
className="px-4 py-2 bg-accent text-white rounded-lg text-sm font-medium disabled:opacity-40"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
export default function ChordDisplay({ history }) {
|
||||
const current = history[history.length - 1]
|
||||
const past = history.slice(-8, -1)
|
||||
|
||||
return (
|
||||
<div className="bg-panel border border-border rounded-2xl p-4">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-widest mb-2">Chord</p>
|
||||
<p className="text-4xl font-bold text-amber-400">
|
||||
{current ?? '—'}
|
||||
</p>
|
||||
{past.length > 0 && (
|
||||
<div className="mt-2 flex gap-2 flex-wrap">
|
||||
{past.map((chord, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="text-sm px-2 py-1 bg-border rounded text-gray-400"
|
||||
>
|
||||
{chord}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { getScale, getChordTones, NOTES } from '../lib/theory'
|
||||
|
||||
// ─── Piano keyboard constants ─────────────────────────────────────────────────
|
||||
const WHITE_PCS = [0, 2, 4, 5, 7, 9, 11] // C D E F G A B
|
||||
const BLACK_KEYS = [
|
||||
{ pc: 1, left: '10%' }, // C#
|
||||
{ pc: 3, left: '24.3%' }, // D#
|
||||
{ pc: 6, left: '52.9%' }, // F#
|
||||
{ pc: 8, left: '67.1%' }, // G#
|
||||
{ pc: 10, left: '81.4%' }, // A#
|
||||
]
|
||||
|
||||
// ─── Piano keyboard component ─────────────────────────────────────────────────
|
||||
function PianoKeyboard({ values, keyNotes, chordNotes, height = 'h-24' }) {
|
||||
const max = Math.max(...values, 0.01)
|
||||
|
||||
return (
|
||||
<div className={`relative ${height} select-none`}>
|
||||
{/* White keys */}
|
||||
<div className="flex gap-px h-full">
|
||||
{WHITE_PCS.map(pc => {
|
||||
const name = NOTES[pc]
|
||||
const energy = values[pc] / max
|
||||
const inChord = chordNotes?.has(pc)
|
||||
const inKey = keyNotes?.has(pc)
|
||||
|
||||
const glow = inChord
|
||||
? `rgba(167,139,250,${0.25 + energy * 0.75})`
|
||||
: inKey
|
||||
? `rgba(251,191,36,${0.15 + energy * 0.55})`
|
||||
: `rgba(200,200,210,${0.06 + energy * 0.18})`
|
||||
|
||||
return (
|
||||
<div
|
||||
key={pc}
|
||||
className="flex-1 rounded-b border border-gray-700 relative overflow-hidden flex flex-col justify-end"
|
||||
style={{
|
||||
background: `linear-gradient(to top, ${glow} 0%, rgba(22,22,28,1) ${Math.max(5, energy * 75)}%)`,
|
||||
boxShadow: energy > 0.35 && inChord
|
||||
? '0 -6px 16px rgba(167,139,250,0.35) inset'
|
||||
: energy > 0.35 && inKey
|
||||
? '0 -4px 10px rgba(251,191,36,0.2) inset'
|
||||
: 'none',
|
||||
}}
|
||||
>
|
||||
<span className="text-center text-[8px] text-gray-600 pb-1 leading-none">{name}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Black keys */}
|
||||
{BLACK_KEYS.map(({ pc, left }) => {
|
||||
const name = NOTES[pc]
|
||||
const energy = values[pc] / max
|
||||
const inChord = chordNotes?.has(pc)
|
||||
const inKey = keyNotes?.has(pc)
|
||||
|
||||
const bg = inChord
|
||||
? `rgba(139,92,246,${0.45 + energy * 0.55})`
|
||||
: inKey
|
||||
? `rgba(180,140,10,${0.4 + energy * 0.45})`
|
||||
: `rgba(12,12,16,${0.88 + energy * 0.12})`
|
||||
|
||||
return (
|
||||
<div
|
||||
key={pc}
|
||||
className="absolute top-0 z-10 rounded-b"
|
||||
style={{
|
||||
left,
|
||||
width: '8.5%',
|
||||
height: '62%',
|
||||
background: bg,
|
||||
border: '1px solid rgba(255,255,255,0.06)',
|
||||
boxShadow: energy > 0.4
|
||||
? inChord
|
||||
? '0 0 10px rgba(139,92,246,0.5)'
|
||||
: '0 0 4px rgba(255,255,255,0.08)'
|
||||
: 'none',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main component ───────────────────────────────────────────────────────────
|
||||
export default function DebugView({ chroma, chordCandidates, noteAnalysis, keyInfo, currentChord }) {
|
||||
const keyPCs = new Set(keyInfo ? getScale(keyInfo.root, keyInfo.mode).map(n => NOTES.indexOf(n)) : [])
|
||||
const chordPCs = new Set(currentChord ? getChordTones(currentChord).map(n => NOTES.indexOf(n)) : [])
|
||||
|
||||
const chromaArr = chroma ? [...chroma] : new Array(12).fill(0)
|
||||
const histFreq = noteAnalysis ? noteAnalysis.freq : new Array(12).fill(0)
|
||||
const topKeys = noteAnalysis ? noteAnalysis.topKeys : []
|
||||
const topScore = chordCandidates[0]?.score ?? 1
|
||||
const topKeyScore = topKeys[0]?.score ?? 1
|
||||
|
||||
return (
|
||||
<div className="bg-panel border border-border rounded-2xl p-4 flex flex-col gap-4">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-widest shrink-0">Behind the Scenes</p>
|
||||
|
||||
{/* ── Large live chroma keyboard ── */}
|
||||
<div>
|
||||
<p className="text-xs text-gray-600 uppercase tracking-widest mb-2">Live chroma — what the engine hears right now</p>
|
||||
<PianoKeyboard values={chromaArr} keyNotes={keyPCs} chordNotes={chordPCs} height="h-28" />
|
||||
</div>
|
||||
|
||||
{/* ── Bottom three columns ── */}
|
||||
<div className="grid grid-cols-[2fr_1.5fr_1fr] gap-5">
|
||||
|
||||
{/* Col 1: Chord candidates */}
|
||||
<div>
|
||||
<p className="text-xs text-gray-600 uppercase tracking-widest mb-2">Chord candidates</p>
|
||||
<div className="flex flex-col gap-1">
|
||||
{chordCandidates.length === 0 && (
|
||||
<p className="text-gray-700 text-xs">No signal detected</p>
|
||||
)}
|
||||
{chordCandidates.map((c, i) => (
|
||||
<div
|
||||
key={c.name}
|
||||
className={`flex items-center gap-2 px-2 py-1.5 rounded-lg ${
|
||||
i === 0 ? 'bg-accent/10 border border-accent/25' : 'border border-transparent'
|
||||
}`}
|
||||
>
|
||||
<span className="text-xs text-gray-600 w-3 shrink-0">{i + 1}</span>
|
||||
<span className={`text-sm font-bold w-14 shrink-0 ${i === 0 ? 'text-white' : 'text-gray-400'}`}>
|
||||
{c.name}
|
||||
</span>
|
||||
<div className="flex-1 h-1.5 bg-gray-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-300 ${i === 0 ? 'bg-accent' : 'bg-gray-600'}`}
|
||||
style={{ width: `${(c.score / topScore) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 w-8 text-right tabular-nums">{c.score.toFixed(2)}</span>
|
||||
<div className="flex gap-1 w-14 justify-end">
|
||||
{c.diatonic && <span className="text-[9px] px-1 rounded bg-green-900/50 text-green-400">key</span>}
|
||||
{c.bassBonus > 0 && <span className="text-[9px] px-1 rounded bg-blue-900/50 text-blue-400">bass</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Col 2: Note history keyboard */}
|
||||
<div>
|
||||
<p className="text-xs text-gray-600 uppercase tracking-widest mb-2">Note history — key evidence</p>
|
||||
<PianoKeyboard values={histFreq} keyNotes={keyPCs} chordNotes={chordPCs} height="h-20" />
|
||||
<div className="flex mt-2 gap-px">
|
||||
{NOTES.map((name, pc) => {
|
||||
const pct = Math.round(histFreq[pc] * 100)
|
||||
const inKey = keyPCs.has(pc)
|
||||
const isBlack = [1, 3, 6, 8, 10].includes(pc)
|
||||
return (
|
||||
<div key={pc} className="flex-1 flex flex-col items-center gap-0.5">
|
||||
<span className={`text-[8px] tabular-nums ${inKey ? 'text-amber-400' : 'text-gray-600'}`}>
|
||||
{pct > 0 ? `${pct}%` : ''}
|
||||
</span>
|
||||
<span className={`text-[7px] ${inKey ? 'text-gray-400' : isBlack ? 'text-gray-700' : 'text-gray-600'}`}>
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Col 3: Key candidates */}
|
||||
<div>
|
||||
<p className="text-xs text-gray-600 uppercase tracking-widest mb-2">Key match scores</p>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{topKeys.length === 0 && (
|
||||
<p className="text-gray-700 text-xs">Not enough history</p>
|
||||
)}
|
||||
{topKeys.map((k, i) => (
|
||||
<div key={`${k.root}-${k.mode}`} className="flex items-center gap-2">
|
||||
<span className={`text-xs w-16 shrink-0 ${i === 0 ? 'text-white font-semibold' : 'text-gray-500'}`}>
|
||||
{k.root} {k.mode === 'major' ? 'maj' : 'min'}
|
||||
</span>
|
||||
<div className="flex-1 h-1 bg-gray-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${i === 0 ? 'bg-amber-400' : 'bg-gray-600'}`}
|
||||
style={{ width: `${Math.max(0, (k.score / topKeyScore) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] text-gray-600 tabular-nums w-8 text-right">{k.score.toFixed(2)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -31,8 +31,8 @@ const fretX = f => NUT_X + (f - 0.5) * FRET_W
|
||||
const stringY = si => PAD_T + si * STRING_H
|
||||
|
||||
function noteColor(isChordTone, isPenta, isScale) {
|
||||
if (isChordTone) return { fill: '#f59e0b', text: '#000' } // amber
|
||||
if (isPenta) return { fill: '#a855f7', text: '#fff' } // purple
|
||||
if (isChordTone) return { fill: '#a855f7', text: '#fff' } // purple
|
||||
if (isPenta) return { fill: '#f59e0b', text: '#000' } // amber
|
||||
if (isScale) return { fill: '#374151', text: '#d1d5db' } // grey
|
||||
return null
|
||||
}
|
||||
@@ -57,11 +57,12 @@ export default function Fretboard({ keyInfo, currentChord, pentatonicOnly = fals
|
||||
{currentChord && <span className="text-amber-400 ml-2">/ {currentChord}</span>}
|
||||
</p>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<div>
|
||||
<svg
|
||||
width={BOARD_W}
|
||||
height={BOARD_H}
|
||||
style={{ display: 'block', minWidth: BOARD_W }}
|
||||
viewBox={`0 0 ${BOARD_W} ${BOARD_H}`}
|
||||
width="100%"
|
||||
height="auto"
|
||||
style={{ display: 'block' }}
|
||||
>
|
||||
{/* Fretboard background */}
|
||||
<rect x={NUT_X} y={PAD_T - 6} width={BOARD_W - NUT_X - 4} height={5 * STRING_H + 12}
|
||||
@@ -145,8 +146,8 @@ export default function Fretboard({ keyInfo, currentChord, pentatonicOnly = fals
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex gap-5 text-xs text-gray-500">
|
||||
<span><span className="text-amber-400">●</span> Chord tone</span>
|
||||
<span><span className="text-accent">●</span> Pentatonic</span>
|
||||
<span><span className="text-accent">●</span> Chord tone</span>
|
||||
<span><span className="text-amber-400">●</span> Pentatonic</span>
|
||||
<span><span className="text-gray-500">●</span> Scale</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
export default function KeyDisplay({ keyInfo, locked = false }) {
|
||||
const { root, mode, confidence } = keyInfo ?? {}
|
||||
const pct = confidence ? Math.round(confidence * 100) : 0
|
||||
|
||||
return (
|
||||
<div className="bg-panel border border-border rounded-2xl p-4 text-center">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-widest mb-1">
|
||||
{locked ? '🔒 Key (locked)' : 'Detected Key'}
|
||||
</p>
|
||||
{root ? (
|
||||
<>
|
||||
<p className="text-5xl font-bold text-accent leading-none">
|
||||
{root}
|
||||
<span className="text-3xl text-gray-400 ml-2">{mode}</span>
|
||||
</p>
|
||||
{!locked && (
|
||||
<div className="mt-3 flex items-center justify-center gap-2">
|
||||
<div className="h-1.5 w-32 bg-border rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-accent rounded-full transition-all duration-500"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">{pct}% confident</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-2xl text-gray-600 mt-2">Listening…</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -29,8 +29,8 @@ const LABEL_Y = KEY_H - 10 // y of note label on white key
|
||||
const BLACK_LABEL_Y = BLACK_H - 8
|
||||
|
||||
function keyColor(isChordTone, isScale, isBlack) {
|
||||
if (isChordTone) return { fill: '#f59e0b', text: '#000' }
|
||||
if (isScale) return { fill: '#a855f7', text: '#fff' }
|
||||
if (isChordTone) return { fill: '#a855f7', text: '#fff' }
|
||||
if (isScale) return { fill: '#f59e0b', text: '#000' }
|
||||
return isBlack
|
||||
? { fill: '#1f1f1f', text: '#6b7280' }
|
||||
: { fill: '#f5f5f5', text: '#6b7280' }
|
||||
@@ -56,8 +56,8 @@ export default function Piano({ keyInfo, currentChord }) {
|
||||
{currentChord && <span className="text-amber-400 ml-2">/ {currentChord}</span>}
|
||||
</p>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<svg width={svgW} height={svgH} style={{ display: 'block', minWidth: svgW }}>
|
||||
<div>
|
||||
<svg viewBox={`0 0 ${svgW} ${svgH}`} width="100%" height="auto" style={{ display: 'block' }}>
|
||||
|
||||
{/* White keys */}
|
||||
{Array.from({ length: OCTAVES }, (_, oct) =>
|
||||
@@ -125,7 +125,7 @@ export default function Piano({ keyInfo, currentChord }) {
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex gap-5 text-xs text-gray-500">
|
||||
<span><span className="text-amber-400">●</span> Chord tone</span>
|
||||
<span><span className="text-accent">●</span> Chord tone</span>
|
||||
<span><span className="text-accent">●</span> Scale note</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,7 @@ function findLoopPosition(chordHistory, progression) {
|
||||
return progression.indexOf(last)
|
||||
}
|
||||
|
||||
export default function ProgressionBanner({ chordHistory, keyInfo, detectedProgression, currentChord, bpm }) {
|
||||
export default function ProgressionBanner({ chordHistory, keyInfo, detectedProgression, currentChord }) {
|
||||
const { root, mode, confidence } = keyInfo ?? {}
|
||||
|
||||
const visible = chordHistory.slice(-HISTORY_SHOWN)
|
||||
@@ -41,7 +41,7 @@ export default function ProgressionBanner({ chordHistory, keyInfo, detectedProgr
|
||||
<div className="bg-panel border border-border rounded-2xl p-4 mb-3 flex gap-4">
|
||||
|
||||
{/* ── Left: key + chord history + loop ── */}
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-2">
|
||||
<div className="w-full lg:w-[70%] min-w-0 flex flex-col gap-2">
|
||||
|
||||
{/* Key + history on one row */}
|
||||
<div className="flex items-end gap-3">
|
||||
@@ -127,20 +127,15 @@ export default function ProgressionBanner({ chordHistory, keyInfo, detectedProgr
|
||||
</div>
|
||||
|
||||
{/* ── Divider ── */}
|
||||
<div className="w-px bg-border shrink-0" />
|
||||
<div className="hidden lg:block w-px bg-border shrink-0" />
|
||||
|
||||
{/* ── Right: big chord + BPM ── */}
|
||||
<div className="w-36 shrink-0 flex flex-col items-center justify-center gap-1">
|
||||
{/* ── Right: big chord ── */}
|
||||
<div className="hidden lg:flex w-[30%] flex-col items-center justify-center gap-1">
|
||||
{current ? (
|
||||
<>
|
||||
<p className="text-xs text-gray-600 uppercase tracking-widest">Now Playing</p>
|
||||
<div className="text-6xl font-black text-amber-400 leading-none">{current}</div>
|
||||
<div className="text-sm text-gray-500">{currentRN}</div>
|
||||
{bpm && (
|
||||
<div className="mt-2 text-center">
|
||||
<div className="text-2xl font-bold text-gray-300">{bpm}</div>
|
||||
<div className="text-xs text-gray-600 uppercase tracking-widest">BPM ~</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-gray-600 text-xs text-center">Play a chord</p>
|
||||
|
||||
@@ -1,32 +1,186 @@
|
||||
import { getSuggestedProgressions } from '../lib/theory'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { getSuggestedProgressions, getChordsInKey, toRomanNumeral, NOTES, NOTES_FLAT } from '../lib/theory'
|
||||
|
||||
export default function ProgressionSuggestions({ keyInfo }) {
|
||||
// ─── Mood map ─────────────────────────────────────────────────────────────────
|
||||
const MOOD = {
|
||||
'I': 'Resolved', 'i': 'Settled',
|
||||
'II': 'Lifted', 'ii': 'Yearning',
|
||||
'III': 'Hopeful', 'iii': 'Tender',
|
||||
'IV': 'Uplifting', 'iv': 'Longing',
|
||||
'V': 'Tense', 'v': 'Unsettled',
|
||||
'VI': 'Bright', 'vi': 'Melancholic',
|
||||
'VII': 'Driving', 'vii': 'Uneasy',
|
||||
'♭VII': 'Bluesy', 'bVII': 'Bluesy',
|
||||
'♭VI': 'Dramatic', 'bVI': 'Dramatic',
|
||||
'♭III': 'Epic', '♭II': 'Mysterious',
|
||||
}
|
||||
|
||||
function getMood(rn) {
|
||||
return MOOD[rn] ?? MOOD[rn?.replace(/[0-9]/g, '')] ?? 'Adventurous'
|
||||
}
|
||||
|
||||
// ─── Genre accent colors ──────────────────────────────────────────────────────
|
||||
const GENRE_COLOR = {
|
||||
'Pop': 'text-pink-400',
|
||||
'Blues': 'text-blue-400',
|
||||
'Folk': 'text-green-400',
|
||||
'Jazz': 'text-yellow-400',
|
||||
'Rock': 'text-red-400',
|
||||
"'50s": 'text-orange-400',
|
||||
'Flamenco': 'text-rose-400',
|
||||
'Circle ↑': 'text-cyan-400',
|
||||
'Circle ↓': 'text-teal-400',
|
||||
'Relative': 'text-violet-400',
|
||||
'Thirds': 'text-indigo-400',
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
function noteIndex(note) {
|
||||
const i = NOTES.indexOf(note)
|
||||
return i >= 0 ? i : NOTES_FLAT.indexOf(note)
|
||||
}
|
||||
|
||||
function chordRoot(chord) {
|
||||
const m = chord.match(/^([A-G][b#]?)/)
|
||||
return m ? m[1] : null
|
||||
}
|
||||
|
||||
// ─── Circle-of-fifths padding ─────────────────────────────────────────────────
|
||||
// Moves tried in order to fill up to 6 total suggestions:
|
||||
// +7 perfect 5th up (dominant direction — most common resolution)
|
||||
// +5 perfect 4th up (subdominant direction)
|
||||
// +9 major/minor 6th (relative minor/major feel)
|
||||
// +3 minor 3rd up (mediant movement, dark → light)
|
||||
const COF_MOVES = [
|
||||
{ semitones: 7, genre: 'Circle ↑' },
|
||||
{ semitones: 5, genre: 'Circle ↓' },
|
||||
{ semitones: 9, genre: 'Relative' },
|
||||
{ semitones: 3, genre: 'Thirds' },
|
||||
]
|
||||
|
||||
function cofSuggestions(currentChord, root, mode, exclude) {
|
||||
const rootPc = noteIndex(chordRoot(currentChord) ?? '')
|
||||
if (rootPc < 0) return []
|
||||
|
||||
const diatonic = getChordsInKey(root, mode)
|
||||
const results = []
|
||||
|
||||
for (const { semitones, genre } of COF_MOVES) {
|
||||
const targetPc = ((rootPc + semitones) % 12 + 12) % 12
|
||||
const match = diatonic.find(c => {
|
||||
const r = chordRoot(c)
|
||||
return r !== null && noteIndex(r) === targetPc
|
||||
})
|
||||
if (!match || exclude.has(match)) continue
|
||||
const rn = toRomanNumeral(match, root, mode)
|
||||
results.push({ genre, chord: match, rn, mood: getMood(rn) })
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// ─── Main suggestion builder ──────────────────────────────────────────────────
|
||||
function buildSuggestions(currentChord, root, mode) {
|
||||
if (!currentChord || !root) return []
|
||||
|
||||
const genreSuggestions = getSuggestedProgressions(root, mode)
|
||||
.map(prog => {
|
||||
const idx = prog.chords.indexOf(currentChord)
|
||||
if (idx < 0) return null
|
||||
const next = (n) => prog.chords[(idx + n) % prog.chords.length]
|
||||
const rnAt = (n) => prog.rn[(idx + n) % prog.chords.length]
|
||||
const c1 = next(1), c2 = next(2), c3 = next(3)
|
||||
const chord2 = c2 !== c1 ? c2 : null
|
||||
const chord3 = chord2 && c3 !== c2 && c3 !== c1 ? c3 : null
|
||||
return {
|
||||
genre: prog.genre,
|
||||
chord: c1, rn: rnAt(1), mood: getMood(rnAt(1)),
|
||||
chord2, rn2: chord2 ? rnAt(2) : null, mood2: chord2 ? getMood(rnAt(2)) : null,
|
||||
chord3, rn3: chord3 ? rnAt(3) : null, mood3: chord3 ? getMood(rnAt(3)) : null,
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
if (genreSuggestions.length >= 4) return genreSuggestions.slice(0, 5)
|
||||
|
||||
// Pad with circle-of-fifths moves not already covered
|
||||
const used = new Set(genreSuggestions.map(s => s.chord))
|
||||
const padded = cofSuggestions(currentChord, root, mode, used)
|
||||
|
||||
return [...genreSuggestions, ...padded].slice(0, 5)
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
export default function ProgressionSuggestions({ keyInfo, currentChord }) {
|
||||
const { root, mode } = keyInfo ?? {}
|
||||
const [suggestions, setSuggestions] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
setSuggestions(buildSuggestions(currentChord, root, mode))
|
||||
}, [currentChord, root, mode])
|
||||
|
||||
if (!root) return null
|
||||
|
||||
const progressions = getSuggestedProgressions(root, mode)
|
||||
|
||||
return (
|
||||
<div className="bg-panel border border-border rounded-2xl p-4">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-widest mb-2">
|
||||
Progressions in {root} {mode}
|
||||
<div className="bg-panel border border-border rounded-2xl p-3 flex flex-col h-full gap-1.5 overflow-hidden">
|
||||
|
||||
<p className="text-xs text-gray-500 uppercase tracking-widest shrink-0">
|
||||
Suggested Progression
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{progressions.map(prog => (
|
||||
<div key={prog.genre} className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500 w-10 shrink-0">{prog.genre}</span>
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{prog.chords.map((chord, i) => (
|
||||
<span key={i} className="px-2 py-0.5 bg-border rounded text-sm font-medium">
|
||||
{chord}
|
||||
<span className="ml-1 text-gray-600 text-xs">({prog.rn[i]})</span>
|
||||
</span>
|
||||
))}
|
||||
|
||||
{!currentChord || suggestions.length === 0 ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<p className="text-gray-600 text-sm text-center">
|
||||
{currentChord ? 'No suggestions' : 'Play a chord…'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col justify-start gap-1 overflow-y-auto min-h-0">
|
||||
{suggestions.map((s, i) => (
|
||||
<div
|
||||
key={`${s.genre}-${i}`}
|
||||
className="flex items-center gap-2 px-2.5 py-1.5 rounded-xl border border-border bg-surface/40 hover:border-gray-600 transition-colors duration-200"
|
||||
>
|
||||
<span className={`text-xs font-bold w-12 shrink-0 ${GENRE_COLOR[s.genre] ?? 'text-gray-400'}`}>
|
||||
{s.genre}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="text-lg font-black text-white leading-none">{s.chord}</div>
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
<span className="text-xs font-semibold text-amber-400">{s.rn}</span>
|
||||
<span className="text-gray-700 text-xs">·</span>
|
||||
<span className="text-xs text-gray-500 truncate">{s.mood}</span>
|
||||
</div>
|
||||
</div>
|
||||
{s.chord2 && (
|
||||
<>
|
||||
<span className="text-gray-600 text-xs shrink-0">|</span>
|
||||
<div className="min-w-0">
|
||||
<div className="text-lg font-black text-white/70 leading-none">{s.chord2}</div>
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
<span className="text-xs font-semibold text-amber-400/70">{s.rn2}</span>
|
||||
<span className="text-gray-700 text-xs">·</span>
|
||||
<span className="text-xs text-gray-500 truncate">{s.mood2}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{s.chord3 && (
|
||||
<>
|
||||
<span className="text-gray-600 text-xs shrink-0">|</span>
|
||||
<div className="min-w-0">
|
||||
<div className="text-lg font-black text-white/50 leading-none">{s.chord3}</div>
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
<span className="text-xs font-semibold text-amber-400/50">{s.rn3}</span>
|
||||
<span className="text-gray-700 text-xs">·</span>
|
||||
<span className="text-xs text-gray-500 truncate">{s.mood3}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { getPentatonicScale, getFullScale, getChordTones } from '../lib/theory'
|
||||
|
||||
const ALL_NOTES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
|
||||
|
||||
export default function SafeNotes({ keyInfo, currentChord }) {
|
||||
const { root, mode } = keyInfo ?? {}
|
||||
|
||||
if (!root) return null
|
||||
|
||||
const penta = getPentatonicScale(root, mode)
|
||||
const full = getFullScale(root, mode)
|
||||
const chordTones = currentChord ? getChordTones(currentChord) : []
|
||||
|
||||
return (
|
||||
<div className="bg-panel border border-border rounded-2xl p-4">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-widest mb-2">Safe Notes</p>
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{ALL_NOTES.map(note => {
|
||||
const isChordTone = chordTones.includes(note)
|
||||
const isPenta = penta.includes(note)
|
||||
const isScale = full.includes(note)
|
||||
|
||||
let cls = 'px-2.5 py-1.5 rounded-lg text-sm font-semibold border transition-all '
|
||||
if (isChordTone) {
|
||||
cls += 'bg-accent text-white border-accent scale-105'
|
||||
} else if (isPenta) {
|
||||
cls += 'bg-accent/20 text-accent border-accent/40'
|
||||
} else if (isScale) {
|
||||
cls += 'bg-border text-gray-300 border-border'
|
||||
} else {
|
||||
cls += 'bg-transparent text-gray-700 border-transparent'
|
||||
}
|
||||
|
||||
return (
|
||||
<span key={note} className={cls}>{note}</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-2 flex gap-4 text-xs text-gray-500">
|
||||
<span><span className="text-accent">■</span> Chord tone</span>
|
||||
<span><span className="text-accent/60">■</span> Pentatonic</span>
|
||||
<span><span className="text-gray-500">■</span> Scale</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
const SETTINGS = [
|
||||
{
|
||||
section: 'Chord Detection',
|
||||
items: [
|
||||
{
|
||||
key: 'chromaSmooth',
|
||||
label: 'Chroma Smoothing',
|
||||
min: 1, max: 20, step: 1,
|
||||
desc: 'Frames to average for chord chroma. More = smoother but slower to react to chord changes.',
|
||||
},
|
||||
{
|
||||
key: 'chordVoteThreshold',
|
||||
label: 'Chord Vote Threshold',
|
||||
min: 1, max: 8, step: 1,
|
||||
desc: 'Consecutive identical detections required to confirm a chord. Higher = more stable, slower.',
|
||||
},
|
||||
{
|
||||
key: 'chordMinScore',
|
||||
label: 'Chord Min Score',
|
||||
min: 0.10, max: 0.80, step: 0.01,
|
||||
desc: 'Minimum coverage score to accept a chord match. Lower = more chord types detected (may add false positives).',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
section: 'Key Detection',
|
||||
items: [
|
||||
{
|
||||
key: 'noteHistorySize',
|
||||
label: 'Note History Size',
|
||||
min: 20, max: 400, step: 10,
|
||||
desc: 'Pitch readings kept in memory for key detection. Larger = slower to change, but chord boosts dominate more.',
|
||||
},
|
||||
{
|
||||
key: 'keyVoteWindow',
|
||||
label: 'Key Vote Window',
|
||||
min: 4, max: 30, step: 1,
|
||||
desc: 'Rolling window of key votes. Larger = more inertia — key changes need sustained evidence.',
|
||||
},
|
||||
{
|
||||
key: 'keyVoteThreshold',
|
||||
label: 'Key Vote Threshold',
|
||||
min: 1, max: 30, step: 1,
|
||||
desc: 'Votes needed within the window to confirm a key. Higher = stricter consensus required.',
|
||||
},
|
||||
{
|
||||
key: 'chordNoteBoost',
|
||||
label: 'Chord Note Boost',
|
||||
min: 0, max: 10, step: 1,
|
||||
desc: 'Times confirmed chord tones are injected into note history. Higher = chords dominate over transient melody notes.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
section: 'Audio Input',
|
||||
items: [
|
||||
{
|
||||
key: 'minClarity',
|
||||
label: 'Min Pitch Clarity',
|
||||
min: 0.50, max: 0.99, step: 0.01,
|
||||
desc: 'Autocorrelation clarity threshold to accept a pitch reading. Higher = only clean, in-tune notes count.',
|
||||
},
|
||||
{
|
||||
key: 'minVolume',
|
||||
label: 'Min Volume (RMS)',
|
||||
min: 0.001, max: 0.05, step: 0.001,
|
||||
desc: 'Minimum signal level before processing. Increase to cut through room noise.',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export default function Settings({ config, onChange, onClose, onReset }) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-surface z-50 overflow-y-auto">
|
||||
<div className="max-w-2xl mx-auto px-6 py-8">
|
||||
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-accent">Detection Settings</h2>
|
||||
<p className="text-xs text-gray-500 mt-0.5">Tune chord and key recognition sensitivity in real time</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onReset}
|
||||
className="px-4 py-2 rounded-lg text-sm border border-border text-gray-500 hover:text-gray-300 hover:border-gray-400 transition-all"
|
||||
>
|
||||
Reset defaults
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-5 py-2 rounded-lg text-sm bg-accent hover:bg-purple-600 text-white font-semibold transition-all"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-8">
|
||||
{SETTINGS.map(section => (
|
||||
<div key={section.section}>
|
||||
<h3 className="text-xs uppercase tracking-widest text-gray-500 mb-4 border-b border-border pb-2">
|
||||
{section.section}
|
||||
</h3>
|
||||
<div className="space-y-6">
|
||||
{section.items.map(item => (
|
||||
<div key={item.key}>
|
||||
<div className="flex items-baseline justify-between mb-1.5">
|
||||
<label className="text-sm font-semibold text-gray-200">{item.label}</label>
|
||||
<span className="text-sm font-mono text-accent w-20 text-right">
|
||||
{Number.isInteger(config[item.key])
|
||||
? config[item.key]
|
||||
: config[item.key].toFixed(item.step < 0.01 ? 3 : 2)}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={item.min}
|
||||
max={item.max}
|
||||
step={item.step}
|
||||
value={config[item.key]}
|
||||
onChange={e => {
|
||||
const val = item.step < 1
|
||||
? parseFloat(e.target.value)
|
||||
: parseInt(e.target.value, 10)
|
||||
onChange(item.key, val)
|
||||
}}
|
||||
className="w-full accent-purple-500"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-700 mt-0.5">
|
||||
<span>{item.min}</span>
|
||||
<span className="text-gray-600 text-center flex-1 px-2">{item.desc}</span>
|
||||
<span>{item.max}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -65,23 +65,46 @@ const CHORD_MATCH_MIN_SCORE = 0.42
|
||||
// Minimum margin over second-best for a match to be considered unambiguous
|
||||
const CHORD_MATCH_MIN_MARGIN = 0.04
|
||||
|
||||
// Chord quality for each scale degree in major and minor
|
||||
// Chord quality for each scale degree, per mode
|
||||
const DEGREE_QUALITIES = {
|
||||
major: ['', 'm', 'm', '', '', 'm', 'dim'],
|
||||
minor: ['m', 'dim', '', 'm', 'm', '', '' ],
|
||||
major: ['', 'm', 'm', '', '', 'm', 'dim'],
|
||||
minor: ['m', 'dim', '', 'm', 'm', '', '' ],
|
||||
dorian: ['m', 'm', '', '', 'm', 'dim', '' ],
|
||||
phrygian: ['m', '', '', 'm', 'dim','', 'm' ],
|
||||
lydian: ['', '', 'm', 'dim', '', 'm', 'm' ],
|
||||
mixolydian: ['', 'm', 'dim', '', 'm', 'm', '' ],
|
||||
}
|
||||
|
||||
const ROMAN_NUMERALS = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII']
|
||||
|
||||
// Common chord progressions by genre, expressed as semitone offsets from the root
|
||||
const PROGRESSIONS = {
|
||||
pop: { name: 'Pop', rn: ['I', 'V', 'vi', 'IV'], degrees: [0, 7, 9, 5] },
|
||||
blues: { name: 'Blues', rn: ['I', 'IV', 'V'], degrees: [0, 5, 7] },
|
||||
folk: { name: 'Folk', rn: ['I', 'IV', 'I', 'V'], degrees: [0, 5, 0, 7] },
|
||||
jazz: { name: 'Jazz', rn: ['ii', 'V', 'I'], degrees: [2, 7, 0] },
|
||||
rock: { name: 'Rock', rn: ['I', 'bVII', 'IV', 'I'], degrees: [0, 10, 5, 0] },
|
||||
'50s': { name: "'50s", rn: ['I', 'vi', 'IV', 'V'], degrees: [0, 9, 5, 7] },
|
||||
flamen: { name: 'Flamenco', rn: ['i', 'bVII', 'bVI', 'V'], degrees: [0, 10, 8, 7] },
|
||||
// Pop
|
||||
pop: { name: 'Pop', rn: ['I', 'V', 'vi', 'IV'], degrees: [0, 7, 9, 5] },
|
||||
pop2: { name: 'Pop', rn: ['I', 'IV', 'vi', 'V'], degrees: [0, 5, 9, 7] },
|
||||
pop3: { name: 'Pop', rn: ['I', 'vi', 'ii', 'V'], degrees: [0, 9, 2, 7] },
|
||||
// Blues
|
||||
blues: { name: 'Blues', rn: ['I', 'IV', 'V'], degrees: [0, 5, 7] },
|
||||
blues2: { name: 'Blues', rn: ['I', 'IV', 'V', 'IV'], degrees: [0, 5, 7, 5] },
|
||||
blues3: { name: 'Blues', rn: ['I', 'I', 'IV', 'V'], degrees: [0, 0, 5, 7] },
|
||||
// Folk
|
||||
folk: { name: 'Folk', rn: ['I', 'IV', 'I', 'V'], degrees: [0, 5, 0, 7] },
|
||||
folk2: { name: 'Folk', rn: ['I', 'V', 'IV', 'I'], degrees: [0, 7, 5, 0] },
|
||||
folk3: { name: 'Folk', rn: ['I', 'ii', 'IV', 'V'], degrees: [0, 2, 5, 7] },
|
||||
// Jazz
|
||||
jazz: { name: 'Jazz', rn: ['ii', 'V', 'I'], degrees: [2, 7, 0] },
|
||||
jazz2: { name: 'Jazz', rn: ['I', 'vi', 'ii', 'V'], degrees: [0, 9, 2, 7] },
|
||||
jazz3: { name: 'Jazz', rn: ['iii', 'vi', 'ii', 'V'], degrees: [4, 9, 2, 7] },
|
||||
// Rock
|
||||
rock: { name: 'Rock', rn: ['I', 'bVII', 'IV', 'I'], degrees: [0, 10, 5, 0] },
|
||||
rock2: { name: 'Rock', rn: ['I', 'IV', 'V', 'I'], degrees: [0, 5, 7, 0] },
|
||||
rock3: { name: 'Rock', rn: ['I', 'bVII', 'bVI', 'bVII'], degrees: [0, 10, 8, 10] },
|
||||
// '50s
|
||||
'50s': { name: "'50s", rn: ['I', 'vi', 'IV', 'V'], degrees: [0, 9, 5, 7] },
|
||||
'50s2': { name: "'50s", rn: ['I', 'V', 'vi', 'iii'], degrees: [0, 7, 9, 4] },
|
||||
// Flamenco
|
||||
flamen: { name: 'Flamenco', rn: ['i', 'bVII', 'bVI', 'V'], degrees: [0, 10, 8, 7] },
|
||||
flamen2: { name: 'Flamenco', rn: ['i', 'bVI', 'bVII', 'i'], degrees: [0, 8, 10, 0] },
|
||||
}
|
||||
|
||||
// ─── Internal helpers ────────────────────────────────────────────────────────
|
||||
@@ -369,6 +392,64 @@ export function detectRepeatingProgression(history) {
|
||||
return best
|
||||
}
|
||||
|
||||
// ─── Debug / analysis helpers ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns top N chord candidates with full score breakdown for the given chroma.
|
||||
*/
|
||||
export function getChordCandidates(chroma, keyInfo, bassPC = null, topN = 8) {
|
||||
if (!keyInfo?.root || !chroma) return []
|
||||
const diatonicSet = new Set(getChordsInKey(keyInfo.root, keyInfo.mode))
|
||||
const candidates = []
|
||||
|
||||
for (let r = 0; r < 12; r++) {
|
||||
for (const typeKey of MATCH_CHORD_TYPES) {
|
||||
const type = CHORD_TYPES[typeKey]
|
||||
const chordName = noteName(r) + type.suffix
|
||||
const tones = new Set(type.intervals.map(i => (r + i) % 12))
|
||||
|
||||
let inEnergy = 0, outEnergy = 0
|
||||
for (let pc = 0; pc < 12; pc++) {
|
||||
if (pc === r) inEnergy += chroma[pc] * 2
|
||||
else if (tones.has(pc)) inEnergy += chroma[pc]
|
||||
else outEnergy += chroma[pc]
|
||||
}
|
||||
if (inEnergy + outEnergy < 0.05) continue
|
||||
|
||||
const coverage = inEnergy / (inEnergy + outEnergy * 0.5)
|
||||
const bassBonus = bassPC !== null && r === bassPC ? 0.15 : 0
|
||||
const diatBonus = diatonicSet.has(chordName) ? 0.15 : 0
|
||||
const score = coverage + bassBonus + diatBonus
|
||||
candidates.push({ name: chordName, score, coverage, bassBonus, diatBonus, diatonic: diatonicSet.has(chordName) })
|
||||
}
|
||||
}
|
||||
return candidates.sort((a, b) => b.score - a.score).slice(0, topN)
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyses note history: returns normalised pitch-class frequencies and
|
||||
* top K-S key candidates with correlation scores.
|
||||
*/
|
||||
export function getNoteHistoryAnalysis(noteHistory) {
|
||||
const freq = new Array(12).fill(0)
|
||||
if (!noteHistory?.length) return { freq, topKeys: [] }
|
||||
|
||||
for (const note of noteHistory) freq[((note % 12) + 12) % 12]++
|
||||
const total = noteHistory.length
|
||||
const normalized = freq.map(f => f / total)
|
||||
|
||||
const candidates = []
|
||||
for (let root = 0; root < 12; root++) {
|
||||
const rotated = Array.from({ length: 12 }, (_, i) => normalized[(i + root) % 12])
|
||||
candidates.push({ root: noteName(root), mode: 'major', score: pearsonCorrelation(rotated, KS_MAJOR) })
|
||||
candidates.push({ root: noteName(root), mode: 'minor', score: pearsonCorrelation(rotated, KS_MINOR) })
|
||||
}
|
||||
return {
|
||||
freq: normalized,
|
||||
topKeys: candidates.sort((a, b) => b.score - a.score).slice(0, 5),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Utilities ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function intervalName(semitones) {
|
||||
|
||||