initial version

This commit is contained in:
vadimwit
2026-03-05 16:51:28 +00:00
commit 5e7954ee24
31 changed files with 4832 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WhatTheFlat</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+2486
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "whattheflat-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"pitchy": "^4.1.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.14",
"vite": "^5.4.10"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+261
View File
@@ -0,0 +1,261 @@
import { useState, useCallback, useRef, useEffect } from 'react'
import AudioCapture from './components/AudioCapture'
import ProgressionBanner from './components/ProgressionBanner'
import KeyDisplay from './components/KeyDisplay'
import ChordDisplay from './components/ChordDisplay'
import SafeNotes from './components/SafeNotes'
import Fretboard from './components/Fretboard'
import ProgressionSuggestions from './components/ProgressionSuggestions'
import ChatAssistant from './components/ChatAssistant'
import { NOTES, detectKey, matchChordFromChroma, detectRepeatingProgression } from './lib/theory'
// Key detection tuning
const NOTE_HISTORY_SIZE = 80
const KEY_VOTE_WINDOW = 12
const KEY_VOTE_THRESHOLD = 9 // out of 12 — very stable
// Chord detection tuning
const CHROMA_SMOOTH = 16 // frames to average (~250ms at 60fps)
const CHORD_VOTE_THRESHOLD = 5 // consecutive agreements before commit
export default function App() {
// ── Listening state ──────────────────────────────────────────────────────
const [isListening, setIsListening] = useState(false)
// ── App mode ─────────────────────────────────────────────────────────────
const [appMode, setAppMode] = useState('beginner') // 'beginner' | 'advanced'
// ── Key: auto-detected + optional lock ───────────────────────────────────
const [keyInfo, setKeyInfo] = useState(null) // auto-detected
const [lockedKey, setLockedKey] = useState(null) // { root, mode } or null
const [lockRoot, setLockRoot] = useState('A')
const [lockMode, setLockMode] = useState('minor')
// Effective key used by all components
const effectiveKey = lockedKey ?? keyInfo
const isStrictMode = appMode === 'beginner' && lockedKey !== null
// ── Chord state ───────────────────────────────────────────────────────────
const [chordHistory, setChordHistory] = useState([])
const [detectedProgression, setDetectedProgression] = useState(null)
// ── 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 chromaIdxRef = useRef(0)
const chordVotesRef = useRef([])
// Keep ref in sync
useEffect(() => { effectiveKeyRef.current = effectiveKey }, [effectiveKey])
// ── Detect progression whenever chord history changes ─────────────────────
useEffect(() => {
setDetectedProgression(detectRepeatingProgression(chordHistory))
}, [chordHistory])
// ── Key lock handlers ─────────────────────────────────────────────────────
function applyLock() {
const info = { root: lockRoot, mode: lockMode, confidence: 1 }
setLockedKey(info)
effectiveKeyRef.current = info
chordVotesRef.current = []
setChordHistory([])
setDetectedProgression(null)
}
function removeLock() {
setLockedKey(null)
effectiveKeyRef.current = keyInfo
}
// ── Note handler: drives key detection (pitch-based) ──────────────────────
const handleNote = useCallback(({ pitchClass }) => {
const history = noteHistoryRef.current
history.push(pitchClass)
if (history.length > NOTE_HISTORY_SIZE) history.shift()
if (history.length < 10) return
if (history.length % 5 !== 0) return
const result = detectKey(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()
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) {
const [root, mode] = winner.split('_')
setKeyInfo(prev => {
if (prev?.root === root && prev?.mode === mode) {
return { root, mode, confidence: result.confidence }
}
// Key changed — clear chord history only if not locked
if (!lockedKey) {
chordVotesRef.current = []
setChordHistory([])
}
return { root, mode, confidence: result.confidence }
})
}
}, [lockedKey])
// ── Chroma handler: drives chord detection ────────────────────────────────
const handleChroma = useCallback((chroma, bassPC) => {
const ring = chromaRingRef.current
ring[chromaIdxRef.current % CHROMA_SMOOTH] = chroma
chromaIdxRef.current++
if (chromaIdxRef.current % CHROMA_SMOOTH !== 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
const chord = matchChordFromChroma(avg, key, bassPC, isStrictMode)
if (!chord) return
const votes = chordVotesRef.current
votes.push(chord)
if (votes.length > CHORD_VOTE_THRESHOLD * 2) votes.shift()
const counts = {}
for (const v of votes) counts[v] = (counts[v] || 0) + 1
const [winner, winCount] = Object.entries(counts).sort((a, b) => b[1] - a[1])[0]
if (winCount >= CHORD_VOTE_THRESHOLD) {
setChordHistory(prev => {
if (prev[prev.length - 1] === winner) return prev
return [...prev.slice(-30), winner]
})
}
}, [isStrictMode])
const currentChord = chordHistory[chordHistory.length - 1]
return (
<div className="min-h-screen bg-surface text-white p-4 md:p-6">
{/* ── Header ── */}
<header className="mb-4 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-accent">
WhatTheFlat <span className="text-gray-600">&#9837;?</span>
</h1>
<p className="text-xs text-gray-600 mt-0.5">Real-time key detection for real humans</p>
</div>
<button
onClick={() => setIsListening(l => !l)}
className={`px-5 py-2.5 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>
</header>
{/* ── Controls bar ── */}
<div className="mb-4 flex flex-wrap gap-3 items-center p-3 bg-panel border border-border rounded-xl">
{/* Mode toggle */}
<div className="flex bg-surface border border-border rounded-full p-0.5 text-sm">
{['beginner', 'advanced'].map(m => (
<button
key={m}
onClick={() => setAppMode(m)}
className={`px-4 py-1 rounded-full capitalize transition-all ${
appMode === m ? 'bg-accent text-white' : 'text-gray-400 hover:text-gray-200'
}`}
>
{m}
</button>
))}
</div>
{/* Key lock */}
{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"
>
unlock
</button>
</div>
) : (
<div className="flex gap-2 items-center">
<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>
<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"
>
Lock Key
</button>
</div>
)}
{/* Auto-detected key badge (advanced mode) */}
{appMode === 'advanced' && keyInfo && !lockedKey && (
<span className="text-xs text-gray-500">
auto: {keyInfo.root} {keyInfo.mode} ({Math.round(keyInfo.confidence * 100)}%)
</span>
)}
</div>
<AudioCapture onNote={handleNote} onChroma={handleChroma} isListening={isListening} />
{/* ── Progression banner — full width ── */}
<ProgressionBanner
chordHistory={chordHistory}
keyInfo={effectiveKey}
detectedProgression={detectedProgression}
/>
{/* ── Main grid ── */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<KeyDisplay keyInfo={effectiveKey} locked={!!lockedKey} />
<ChordDisplay history={chordHistory} />
<SafeNotes keyInfo={effectiveKey} currentChord={currentChord} />
<ProgressionSuggestions keyInfo={effectiveKey} />
<div className="md:col-span-2">
<Fretboard
keyInfo={effectiveKey}
currentChord={currentChord}
pentatonicOnly={appMode === 'beginner'}
/>
</div>
<div className="md:col-span-2">
<ChatAssistant keyInfo={effectiveKey} currentChord={currentChord} />
</div>
</div>
</div>
)
}
+130
View File
@@ -0,0 +1,130 @@
import { useEffect, useRef, useCallback } from 'react'
import { PitchDetector } from 'pitchy'
import { NOTES } from '../lib/theory'
const MIN_CLARITY = 0.85
const MIN_VOLUME = 0.01
const FFT_SIZE = 4096 // larger = better frequency resolution
const NOISE_FLOOR = -60 // dB — ignore bins quieter than this
// Build 12-bin chroma from FFT power spectrum.
// Restricts to guitar fundamental range and applies log compression.
function computeChroma(freqData, sampleRate, fftSize) {
const chroma = new Float32Array(12)
const binHz = sampleRate / fftSize
for (let bin = 2; bin < freqData.length; bin++) {
const freq = bin * binHz
if (freq < 75 || freq > 1400) continue // guitar fundamentals only
const db = freqData[bin]
if (db < NOISE_FLOOR) continue
// Power (db/10) discriminates harmonics better than amplitude (db/20)
const power = Math.pow(10, db / 10)
const midi = 12 * Math.log2(freq / 440) + 69
const pc = ((Math.round(midi) % 12) + 12) % 12
chroma[pc] += power
}
// Log compression reduces dominance of very loud partials
for (let i = 0; i < 12; i++) chroma[i] = Math.log1p(chroma[i] * 100)
const max = Math.max(...chroma)
if (max > 0) for (let i = 0; i < 12; i++) chroma[i] /= max
return chroma
}
// Find the dominant pitch class in the bass range (guitar lowest notes).
// This gives us a strong root-note hint for chord matching.
function detectBassPC(freqData, sampleRate, fftSize) {
const binHz = sampleRate / fftSize
let maxPower = 0, bestMidi = -1
for (let bin = 2; bin < freqData.length; bin++) {
const freq = bin * binHz
if (freq < 75 || freq > 350) continue
const db = freqData[bin]
if (db < NOISE_FLOOR) continue
const power = Math.pow(10, db / 10)
if (power > maxPower) {
maxPower = power
bestMidi = Math.round(12 * Math.log2(freq / 440) + 69)
}
}
if (bestMidi < 0) return null
return ((bestMidi % 12) + 12) % 12
}
export default function AudioCapture({ onNote, onChroma, isListening }) {
const audioCtxRef = useRef(null)
const analyserRef = useRef(null)
const detectorRef = useRef(null)
const timeBufRef = useRef(null)
const freqBufRef = useRef(null)
const rafRef = useRef(null)
const streamRef = useRef(null)
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
}, [])
const start = useCallback(async () => {
stop()
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
streamRef.current = stream
const ctx = new AudioContext()
audioCtxRef.current = ctx
const analyser = ctx.createAnalyser()
analyser.fftSize = FFT_SIZE
analyser.smoothingTimeConstant = 0.6 // smooth FFT over time
analyserRef.current = analyser
ctx.createMediaStreamSource(stream).connect(analyser)
timeBufRef.current = new Float32Array(analyser.fftSize)
freqBufRef.current = new Float32Array(analyser.frequencyBinCount)
detectorRef.current = PitchDetector.forFloat32Array(analyser.fftSize)
function tick() {
const timeBuf = timeBufRef.current
analyser.getFloatTimeDomainData(timeBuf)
const rms = Math.sqrt(timeBuf.reduce((s, v) => s + v * v, 0) / timeBuf.length)
if (rms >= MIN_VOLUME) {
// Pitch — used for key detection
const [freq, clarity] = detectorRef.current.findPitch(timeBuf, ctx.sampleRate)
if (clarity >= MIN_CLARITY && 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 })
}
// Chroma + bass — used for chord detection
if (onChroma) {
const freqBuf = freqBufRef.current
analyser.getFloatFrequencyData(freqBuf)
const chroma = computeChroma(freqBuf, ctx.sampleRate, analyser.fftSize)
const bassPC = detectBassPC(freqBuf, ctx.sampleRate, analyser.fftSize)
onChroma(chroma, bassPC)
}
}
rafRef.current = requestAnimationFrame(tick)
}
tick()
}, [onNote, onChroma, stop])
useEffect(() => {
if (isListening) start().catch(console.error)
else stop()
return stop
}, [isListening, start, stop])
return null
}
+89
View File
@@ -0,0 +1,89 @@
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>
)
}
+25
View File
@@ -0,0 +1,25 @@
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-6">
<p className="text-sm text-gray-500 uppercase tracking-widest mb-3">Chord</p>
<p className="text-5xl font-bold text-amber-400">
{current ?? '—'}
</p>
{past.length > 0 && (
<div className="mt-4 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>
)
}
+154
View File
@@ -0,0 +1,154 @@
import { getPentatonicScale, getFullScale, getChordTones, NOTES } from '../lib/theory'
// Standard tuning: pitch classes of open strings, high-E first (top of diagram)
const STRINGS = [
{ label: 'e', root: 4 }, // high E
{ label: 'B', root: 11 },
{ label: 'G', root: 7 },
{ label: 'D', root: 2 },
{ label: 'A', root: 9 },
{ label: 'E', root: 4 }, // low E
]
const NUM_FRETS = 13 // frets 0 (open) through 12
const FRET_MARKERS = [3, 5, 7, 9]
const DOUBLE_MARKER = 12
// Layout constants
const NUT_X = 40 // x of the nut line
const OPEN_X = 18 // x of open-string dot centres
const FRET_W = 52 // pixels per fret
const STRING_H = 28 // pixels between strings
const PAD_T = 28 // top padding (fret numbers)
const PAD_B = 18 // bottom padding (fret marker dots)
const BOARD_W = NUT_X + (NUM_FRETS - 1) * FRET_W + 10
const BOARD_H = PAD_T + 5 * STRING_H + PAD_B
const DOT_R = 10
// x centre of a fretted note (fret >= 1)
const fretX = f => NUT_X + (f - 0.5) * FRET_W
// y centre of string si (0 = high e, 5 = low E)
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 (isScale) return { fill: '#374151', text: '#d1d5db' } // grey
return null
}
export default function Fretboard({ keyInfo, currentChord, pentatonicOnly = false }) {
const { root, mode } = keyInfo ?? {}
if (!root) return null
const pentaSet = new Set(getPentatonicScale(root, mode).map(n => NOTES.indexOf(n)))
const scaleSet = pentatonicOnly
? pentaSet
: new Set(getFullScale(root, mode).map(n => NOTES.indexOf(n)))
const chordSet = currentChord
? new Set(getChordTones(currentChord).map(n => NOTES.indexOf(n)))
: new Set()
return (
<div className="bg-panel border border-border rounded-2xl p-6">
<p className="text-sm text-gray-500 uppercase tracking-widest mb-4">
Fretboard {root} {mode}
{currentChord && <span className="text-amber-400 ml-2">/ {currentChord}</span>}
</p>
<div className="overflow-x-auto">
<svg
width={BOARD_W}
height={BOARD_H}
style={{ display: 'block', minWidth: BOARD_W }}
>
{/* Fretboard background */}
<rect x={NUT_X} y={PAD_T - 6} width={BOARD_W - NUT_X - 4} height={5 * STRING_H + 12}
fill="#1a120b" rx={2} />
{/* Fret position marker dots (between strings 23 and 34) */}
{FRET_MARKERS.map(f => (
<circle key={f}
cx={fretX(f)} cy={PAD_T + 2.5 * STRING_H}
r={5} fill="#3a2a1a" />
))}
{/* Double dot at 12 */}
<circle cx={fretX(DOUBLE_MARKER)} cy={PAD_T + 1.5 * STRING_H} r={5} fill="#3a2a1a" />
<circle cx={fretX(DOUBLE_MARKER)} cy={PAD_T + 3.5 * STRING_H} r={5} fill="#3a2a1a" />
{/* Fret lines (112) */}
{Array.from({ length: NUM_FRETS - 1 }, (_, i) => i + 1).map(f => (
<line key={f}
x1={NUT_X + f * FRET_W} y1={PAD_T - 6}
x2={NUT_X + f * FRET_W} y2={PAD_T + 5 * STRING_H + 6}
stroke={f === DOUBLE_MARKER ? '#888' : '#4a3a2a'}
strokeWidth={f === DOUBLE_MARKER ? 2 : 1} />
))}
{/* Nut */}
<line x1={NUT_X} y1={PAD_T - 6} x2={NUT_X} y2={PAD_T + 5 * STRING_H + 6}
stroke="#c0b090" strokeWidth={4} />
{/* Strings */}
{STRINGS.map((_, si) => (
<line key={si}
x1={OPEN_X - DOT_R - 2} y1={stringY(si)}
x2={BOARD_W - 8} y2={stringY(si)}
stroke="#9ca3af"
strokeWidth={si < 2 ? 1 : si < 4 ? 1.5 : 2} />
))}
{/* Fret numbers */}
{[3, 5, 7, 9, 12].map(f => (
<text key={f}
x={fretX(f)} y={PAD_T - 10}
textAnchor="middle" fontSize={10} fill="#6b7280"
>{f}</text>
))}
{/* String labels */}
{STRINGS.map((s, si) => (
<text key={si}
x={6} y={stringY(si) + 4}
textAnchor="middle" fontSize={10} fill="#6b7280"
>{s.label}</text>
))}
{/* Note dots */}
{STRINGS.flatMap((str, si) =>
Array.from({ length: NUM_FRETS }, (_, fi) => {
const pc = (str.root + fi) % 12
const color = noteColor(chordSet.has(pc), pentaSet.has(pc), scaleSet.has(pc))
if (!color) return null
const cx = fi === 0 ? OPEN_X : fretX(fi)
const cy = stringY(si)
return (
<g key={`${si}-${fi}`}>
<circle cx={cx} cy={cy} r={DOT_R} fill={color.fill} />
<text
x={cx} y={cy + 4}
textAnchor="middle"
fontSize={9}
fontWeight="600"
fill={color.text}
>
{NOTES[pc]}
</text>
</g>
)
})
)}
</svg>
</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-gray-500"></span> Scale</span>
</div>
</div>
)
}
+33
View File
@@ -0,0 +1,33 @@
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-6 text-center">
<p className="text-sm text-gray-500 uppercase tracking-widest mb-1">
{locked ? '🔒 Key (locked)' : 'Detected Key'}
</p>
{root ? (
<>
<p className="text-6xl 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>
)
}
@@ -0,0 +1,129 @@
import { useRef, useEffect } from 'react'
import { toRomanNumeral } from '../lib/theory'
// Sizes for the chord trail (oldest → current)
const TRAIL_SIZES = [
'text-lg opacity-20',
'text-xl opacity-30',
'text-2xl opacity-45',
'text-3xl opacity-60',
'text-4xl opacity-80',
]
const CURRENT_SIZE = 'text-7xl opacity-100'
function findLoopPosition(chordHistory, progression) {
if (!progression?.length || !chordHistory.length) return -1
const len = progression.length
// Walk backwards through the progression to find where current chord sits
for (let p = len - 1; p >= 0; p--) {
if (progression[p] !== chordHistory[chordHistory.length - 1]) continue
let match = true
for (let i = 1; i < Math.min(p + 1, chordHistory.length); i++) {
if (progression[p - i] !== chordHistory[chordHistory.length - 1 - i]) {
match = false; break
}
}
if (match) return p
}
return progression.indexOf(chordHistory[chordHistory.length - 1])
}
export default function ProgressionBanner({ chordHistory, keyInfo, detectedProgression }) {
const { root, mode } = keyInfo ?? {}
// Show up to 5 previous chords + current
const trail = chordHistory.slice(-6, -1) // up to 5 previous
const current = chordHistory[chordHistory.length - 1]
// Flash the current chord when it changes
const currentRef = useRef(null)
const prevChord = useRef(null)
useEffect(() => {
if (current && current !== prevChord.current && currentRef.current) {
currentRef.current.animate(
[{ opacity: 0, transform: 'translateY(8px) scale(0.9)' },
{ opacity: 1, transform: 'translateY(0) scale(1)' }],
{ duration: 220, easing: 'ease-out', fill: 'forwards' }
)
prevChord.current = current
}
}, [current])
const loopPos = findLoopPosition(chordHistory, detectedProgression)
if (!chordHistory.length) {
return (
<div className="bg-panel border border-border rounded-2xl p-6 mb-4 flex items-center justify-center h-36">
<p className="text-gray-600 text-lg">Start listening to detect chords</p>
</div>
)
}
return (
<div className="bg-panel border border-border rounded-2xl p-6 mb-4">
{/* ── Chord trail ── */}
<div className="flex items-end gap-3 overflow-x-auto pb-1 min-h-[96px]">
{trail.map((chord, i) => {
const sizeClass = TRAIL_SIZES[Math.max(0, i - (trail.length - TRAIL_SIZES.length))]
const rn = root ? toRomanNumeral(chord, root, mode) : ''
return (
<div key={`${chord}-${i}`} className={`flex flex-col items-center shrink-0 transition-all duration-300 ${sizeClass}`}>
<span className="font-bold text-gray-300 leading-none">{chord}</span>
<span className="text-xs text-gray-600 mt-1">{rn}</span>
</div>
)
})}
{/* Arrow between trail and current */}
{trail.length > 0 && (
<span className="text-gray-600 text-2xl mb-2 shrink-0"></span>
)}
{/* Current chord — BIG */}
{current && (
<div ref={currentRef} className={`flex flex-col items-center shrink-0 ${CURRENT_SIZE}`}>
<span className="font-black text-accent leading-none tracking-tight">{current}</span>
<span className="text-base text-amber-400 mt-1 font-semibold">
{root ? toRomanNumeral(current, root, mode) : ''}
</span>
</div>
)}
</div>
{/* ── Detected loop ── */}
{detectedProgression && (
<div className="mt-5 pt-4 border-t border-border">
<p className="text-xs text-gray-500 uppercase tracking-widest mb-3">
Detected loop
</p>
<div className="flex gap-2 flex-wrap">
{detectedProgression.map((chord, i) => {
const isActive = i === loopPos
const rn = root ? toRomanNumeral(chord, root, mode) : chord
return (
<div
key={i}
className={`flex flex-col items-center px-4 py-2 rounded-xl border transition-all duration-200 ${
isActive
? 'bg-accent/20 border-accent shadow-[0_0_12px_rgba(168,85,247,0.4)]'
: 'bg-border border-border'
}`}
>
<span className={`text-2xl font-bold leading-none ${isActive ? 'text-accent' : 'text-gray-200'}`}>
{chord}
</span>
<span className={`text-xs mt-1 font-semibold ${isActive ? 'text-amber-400' : 'text-gray-500'}`}>
{rn}
</span>
</div>
)
})}
<div className="flex items-center text-gray-600 text-sm pl-1">
loop
</div>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,32 @@
import { getSuggestedProgressions } from '../lib/theory'
export default function ProgressionSuggestions({ keyInfo }) {
const { root, mode } = keyInfo ?? {}
if (!root) return null
const progressions = getSuggestedProgressions(root, mode)
return (
<div className="bg-panel border border-border rounded-2xl p-6">
<p className="text-sm text-gray-500 uppercase tracking-widest mb-4">
Progressions in {root} {mode}
</p>
<div className="space-y-3">
{progressions.map(prog => (
<div key={prog.genre} className="flex items-center gap-3">
<span className="text-xs text-gray-500 w-10 shrink-0">{prog.genre}</span>
<div className="flex gap-2 flex-wrap">
{prog.chords.map((chord, i) => (
<span key={i} className="px-3 py-1 bg-border rounded text-sm font-medium">
{chord}
<span className="ml-1 text-gray-600 text-xs">({prog.rn[i]})</span>
</span>
))}
</div>
</div>
))}
</div>
</div>
)
}
+46
View File
@@ -0,0 +1,46 @@
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-6">
<p className="text-sm text-gray-500 uppercase tracking-widest mb-4">Safe Notes</p>
<div className="flex gap-2 flex-wrap">
{ALL_NOTES.map(note => {
const isChordTone = chordTones.includes(note)
const isPenta = penta.includes(note)
const isScale = full.includes(note)
let cls = 'px-3 py-2 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-3 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>
)
}
+9
View File
@@ -0,0 +1,9 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
background-color: #0f0f0f;
color: #f5f5f5;
font-family: system-ui, -apple-system, sans-serif;
}
+262
View File
@@ -0,0 +1,262 @@
// ─── Constants ───────────────────────────────────────────────────────────────
export const NOTES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
export const NOTES_FLAT = ['C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B']
const SCALES = {
major: [0, 2, 4, 5, 7, 9, 11],
minor: [0, 2, 3, 5, 7, 8, 10],
pentatonic_major: [0, 2, 4, 7, 9],
pentatonic_minor: [0, 3, 5, 7, 10],
}
// Krumhansl-Schmuckler key profiles
const KS_MAJOR = [6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88]
const KS_MINOR = [6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17]
const CHORD_TYPES = {
maj: { intervals: [0, 4, 7], suffix: '' },
min: { intervals: [0, 3, 7], suffix: 'm' },
dom7: { intervals: [0, 4, 7, 10], suffix: '7' },
maj7: { intervals: [0, 4, 7, 11], suffix: 'maj7' },
min7: { intervals: [0, 3, 7, 10], suffix: 'm7' },
dim: { intervals: [0, 3, 6], suffix: 'dim' },
sus4: { intervals: [0, 5, 7], suffix: 'sus4' },
sus2: { intervals: [0, 2, 7], suffix: 'sus2' },
}
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] },
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
function noteName(semitone) {
return NOTES[((semitone % 12) + 12) % 12]
}
function correlation(a, b) {
const meanA = a.reduce((s, v) => s + v, 0) / a.length
const meanB = b.reduce((s, v) => s + v, 0) / b.length
let num = 0, denA = 0, denB = 0
for (let i = 0; i < a.length; i++) {
const da = a[i] - meanA, db = b[i] - meanB
num += da * db
denA += da * da
denB += db * db
}
return num / Math.sqrt(denA * denB + 1e-10)
}
// ─── Key Detection ───────────────────────────────────────────────────────────
/**
* detectKey(noteHistory) → { root, mode, confidence }
* noteHistory: array of MIDI note numbers or pitch-class integers (011)
*/
export function detectKey(noteHistory) {
if (!noteHistory || noteHistory.length < 4) {
return { root: 'C', mode: 'major', confidence: 0 }
}
// Build pitch class frequency vector
const freq = new Array(12).fill(0)
for (const note of noteHistory) {
freq[((note % 12) + 12) % 12]++
}
let best = { root: 'C', mode: 'major', score: -Infinity }
for (let root = 0; root < 12; root++) {
// Rotate profile to match root
const rotated = [...Array(12)].map((_, i) => freq[(i + root) % 12])
const scoreMaj = correlation(rotated, KS_MAJOR)
const scoreMin = correlation(rotated, KS_MINOR)
if (scoreMaj > best.score) best = { root, mode: 'major', score: scoreMaj }
if (scoreMin > best.score) best = { root, mode: 'minor', score: scoreMin }
}
// Normalise confidence to 01
const confidence = Math.max(0, Math.min(1, (best.score + 1) / 2))
return { root: noteName(best.root), mode: best.mode, confidence }
}
// ─── Scale helpers ───────────────────────────────────────────────────────────
export function getFullScale(root, mode) {
const rootIdx = NOTES.indexOf(root)
const intervals = SCALES[mode] ?? SCALES.major
return intervals.map(i => noteName(rootIdx + i))
}
export function getPentatonicScale(root, mode) {
const key = mode === 'minor' ? 'pentatonic_minor' : 'pentatonic_major'
const rootIdx = NOTES.indexOf(root)
return SCALES[key].map(i => noteName(rootIdx + i))
}
// ─── Chord helpers ───────────────────────────────────────────────────────────
export function getChordTones(chordName) {
// Parse e.g. "Am", "G", "Fmaj7", "Bdim"
const match = chordName.match(/^([A-G]#?)(.*)$/)
if (!match) return []
const root = NOTES.indexOf(match[1])
const suffix = match[2] || ''
for (const [, type] of Object.entries(CHORD_TYPES)) {
if (type.suffix === suffix) {
return type.intervals.map(i => noteName(root + i))
}
}
// Default to major triad
return CHORD_TYPES.maj.intervals.map(i => noteName(root + i))
}
export function getChordsInKey(root, mode) {
const rootIdx = NOTES.indexOf(root)
const scale = SCALES[mode] ?? SCALES.major
return scale.map((degree, i) => {
const chordRoot = rootIdx + degree
// Determine chord quality from scale degree
let type
if (mode === 'major') {
type = [CHORD_TYPES.maj, CHORD_TYPES.min, CHORD_TYPES.min,
CHORD_TYPES.maj, CHORD_TYPES.maj, CHORD_TYPES.min,
CHORD_TYPES.dim][i]
} else {
type = [CHORD_TYPES.min, CHORD_TYPES.dim, CHORD_TYPES.maj,
CHORD_TYPES.min, CHORD_TYPES.min, CHORD_TYPES.maj,
CHORD_TYPES.maj][i]
}
return noteName(chordRoot) + type.suffix
})
}
// ─── Progression suggestions ─────────────────────────────────────────────────
export function getSuggestedProgressions(root, mode) {
const rootIdx = NOTES.indexOf(root)
const scale = SCALES[mode] ?? SCALES.major
// Chord qualities for each scale degree
const qualities = mode === 'major'
? ['', 'm', 'm', '', '', 'm', 'dim']
: ['m', 'dim', '', 'm', 'm', '', '']
return Object.entries(PROGRESSIONS).map(([genre, prog]) => {
const chords = prog.degrees.map(semitones => {
const noteIdx = (rootIdx + semitones) % 12
const name = noteName(noteIdx)
// Find which scale degree this is to get quality
const degreeIdx = scale.indexOf(semitones)
const quality = degreeIdx >= 0 ? qualities[degreeIdx] : ''
return name + quality
})
return { genre: prog.name, rn: prog.rn, chords }
})
}
// ─── Chroma-based chord matching ─────────────────────────────────────────────
/**
* matchChordFromChroma(chroma, keyInfo, bassPC?, strictDiatonic?)
* chroma: Float32Array[12], normalised 01 energy per pitch class
* bassPC: pitch class of the detected bass/root note, or null
* strictDiatonic: if true, only considers chords in the key (beginner mode)
*/
export function matchChordFromChroma(chroma, keyInfo, bassPC = null, strictDiatonic = false) {
if (!keyInfo?.root) return null
const diatonic = new Set(getChordsInKey(keyInfo.root, keyInfo.mode))
// Only match triads — more reliable for live guitar than extended chords
const triadTypes = [CHORD_TYPES.maj, CHORD_TYPES.min, CHORD_TYPES.dim]
let best = { name: null, score: -Infinity }
for (let r = 0; r < 12; r++) {
for (const type of triadTypes) {
const tones = new Set(type.intervals.map(i => (r + i) % 12))
const chordName = noteName(r) + type.suffix
if (strictDiatonic && !diatonic.has(chordName)) continue
let inEnergy = 0, outEnergy = 0
for (let pc = 0; pc < 12; pc++) {
if (tones.has(pc)) inEnergy += chroma[pc]
else outEnergy += chroma[pc]
}
if (inEnergy + outEnergy < 0.05) continue
// Core score: fraction of energy on chord tones, penalise noise
const coverageScore = inEnergy / (inEnergy + outEnergy * 0.6)
// Bass note matching the chord root is a strong harmonic signal
const bassBonus = (bassPC !== null && r === bassPC) ? 0.4 : 0
const diatonicBonus = diatonic.has(chordName) ? 0.2 : 0
const finalScore = coverageScore + bassBonus + diatonicBonus
if (finalScore > best.score) best = { name: chordName, score: finalScore }
}
}
return best.score > 0.45 ? best.name : null
}
// ─── Roman numeral notation ───────────────────────────────────────────────────
export function toRomanNumeral(chordName, keyRoot, keyMode) {
if (!chordName || !keyRoot) return '?'
const match = chordName.match(/^([A-G]#?)(.*)$/)
if (!match) return '?'
const [, root, quality] = match
const chordRootIdx = NOTES.indexOf(root)
const keyRootIdx = NOTES.indexOf(keyRoot)
if (chordRootIdx < 0 || keyRootIdx < 0) return '?'
const semitones = ((chordRootIdx - keyRootIdx) + 12) % 12
const scale = SCALES[keyMode] ?? SCALES.major
const degreeIdx = scale.indexOf(semitones)
if (degreeIdx < 0) return '♭' + ['I','II','III','IV','V','VI','VII'][0] // chromatic
const ROMAN = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII']
const rn = ROMAN[degreeIdx]
const isMinor = /^m(?!aj)/.test(quality) || quality === 'dim'
return isMinor ? rn.toLowerCase() : rn
}
// ─── Repeating progression detection ─────────────────────────────────────────
/**
* detectRepeatingProgression(history) → chord[] or null
* Returns the most-recently-completed repeating pattern (length 26).
* Requires the pattern to appear at least twice in the last 20 chords.
*/
export function detectRepeatingProgression(history) {
if (!history || history.length < 4) return null
const window = history.slice(-20)
let best = null, bestScore = 0
for (let len = 2; len <= 6; len++) {
if (len * 2 > window.length) break
const candidate = window.slice(-len)
let reps = 0
for (let i = 0; i <= window.length - len; i++) {
if (window.slice(i, i + len).every((c, j) => c === candidate[j])) reps++
}
// Score favours longer patterns that repeat more
const score = reps * len
if (reps >= 2 && score > bestScore) {
bestScore = score
best = candidate
}
}
return best
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
+16
View File
@@ -0,0 +1,16 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,jsx}'],
theme: {
extend: {
colors: {
surface: '#0f0f0f',
panel: '#1a1a1a',
border: '#2a2a2a',
accent: '#a855f7',
amber: '#f59e0b',
},
},
},
plugins: [],
}
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': 'http://localhost:8000',
},
},
})