restructure to root, add tuner from jms
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
import { PitchDetector } from 'pitchy'
|
||||
import { NOTES } from '../lib/theory'
|
||||
|
||||
// ─── Why two analysers? ───────────────────────────────────────────────────────
|
||||
//
|
||||
// The Web Audio FFT has linearly-spaced bins: bin width = sampleRate / fftSize.
|
||||
//
|
||||
// fftSize 4096 → ~10.8 Hz/bin (default we were using)
|
||||
// fftSize 16384 → ~2.7 Hz/bin (multi-rate chord analyser)
|
||||
//
|
||||
// On the low guitar strings the gap between adjacent semitones is only ~5-6 Hz.
|
||||
// At 10.8 Hz/bin we literally cannot separate A2 (110 Hz) from A#2 (116 Hz).
|
||||
// That is the single biggest source of wrong chord notes on the low strings.
|
||||
//
|
||||
// Solution: run a second, larger analyser just for chord/chroma detection.
|
||||
// The pitch analyser stays small (4096) so pitchy has a 90ms window — fast
|
||||
// enough for responsive pitch detection. The chord analyser uses 16384 (~370ms
|
||||
// window) — slower to respond but with 2.7 Hz bins that can cleanly separate
|
||||
// every semitone across the guitar's entire range.
|
||||
//
|
||||
// This is an approximation of the Constant-Q Transform (CQT) your friend
|
||||
// mentioned: CQT achieves log-spaced bins mathematically; we approximate it
|
||||
// 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 NOISE_FLOOR = -65 // dB
|
||||
|
||||
// ─── Harmonic summation chroma ────────────────────────────────────────────────
|
||||
// Each FFT bin votes back toward lower fundamentals that could have generated
|
||||
// it as an overtone. This undoes the harmonic contamination that makes minor
|
||||
// chords look like major ones (the 5th harmonic of the root lands on the major
|
||||
// 3rd, which is NOT in the minor chord).
|
||||
const HARMONIC_WEIGHTS = [1.0, 0.5, 0.33, 0.25, 0.2] // h = 1…5
|
||||
|
||||
function computeChroma(freqData, sampleRate, fftSize) {
|
||||
const chroma = new Float32Array(12)
|
||||
const binHz = sampleRate / fftSize
|
||||
const N = freqData.length
|
||||
|
||||
for (let bin = 2; bin < N; bin++) {
|
||||
const freq = bin * binHz
|
||||
if (freq < 80 || freq > 4000) continue
|
||||
const db = freqData[bin]
|
||||
if (db < NOISE_FLOOR) continue
|
||||
|
||||
const amp = Math.sqrt(Math.pow(10, db / 10)) // amplitude, not power
|
||||
|
||||
for (let h = 1; h <= HARMONIC_WEIGHTS.length; h++) {
|
||||
const fundamental = freq / h
|
||||
if (fundamental < 40 || fundamental > 2000) continue
|
||||
const midi = 12 * Math.log2(fundamental / 440) + 69
|
||||
const pc = ((Math.round(midi) % 12) + 12) % 12
|
||||
chroma[pc] += amp * HARMONIC_WEIGHTS[h - 1]
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < 12; i++) chroma[i] = Math.log1p(chroma[i])
|
||||
const max = Math.max(...chroma)
|
||||
if (max > 0) for (let i = 0; i < 12; i++) chroma[i] /= max
|
||||
return chroma
|
||||
}
|
||||
|
||||
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 < 40 || 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 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)
|
||||
|
||||
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 source = ctx.createMediaStreamSource(stream)
|
||||
|
||||
// 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
|
||||
source.connect(pa)
|
||||
timeBufRef.current = new Float32Array(pa.fftSize)
|
||||
detectorRef.current = PitchDetector.forFloat32Array(pa.fftSize)
|
||||
|
||||
// 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
|
||||
source.connect(ca)
|
||||
freqBufRef.current = new Float32Array(ca.frequencyBinCount)
|
||||
|
||||
function tick() {
|
||||
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
|
||||
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 })
|
||||
}
|
||||
|
||||
// Chord chroma from the high-resolution FFT
|
||||
if (onChroma) {
|
||||
const freqBuf = freqBufRef.current
|
||||
ca.getFloatFrequencyData(freqBuf)
|
||||
onChroma(
|
||||
computeChroma(freqBuf, ctx.sampleRate, ca.fftSize),
|
||||
detectBassPC(freqBuf, ctx.sampleRate, ca.fftSize)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
}
|
||||
tick()
|
||||
}, [onNote, onChroma, stop])
|
||||
|
||||
useEffect(() => {
|
||||
if (isListening) start().catch(console.error)
|
||||
else stop()
|
||||
return stop
|
||||
}, [isListening, start, stop])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 2–3 and 3–4) */}
|
||||
{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 (1–12) */}
|
||||
{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>
|
||||
)
|
||||
}
|
||||
@@ -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,122 @@
|
||||
import { useRef, useEffect } from 'react'
|
||||
import { toRomanNumeral } from '../lib/theory'
|
||||
|
||||
const HISTORY_SHOWN = 8 // ~2 bars at 4 chords/bar
|
||||
|
||||
function findLoopPosition(chordHistory, progression) {
|
||||
if (!progression?.length || !chordHistory.length) return -1
|
||||
const last = chordHistory[chordHistory.length - 1]
|
||||
for (let p = progression.length - 1; p >= 0; p--) {
|
||||
if (progression[p] !== last) 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(last)
|
||||
}
|
||||
|
||||
export default function ProgressionBanner({ chordHistory, keyInfo, detectedProgression }) {
|
||||
const { root, mode } = keyInfo ?? {}
|
||||
|
||||
// Newest chord is the last entry; we show the most recent HISTORY_SHOWN
|
||||
const visible = chordHistory.slice(-HISTORY_SHOWN)
|
||||
const current = visible[visible.length - 1]
|
||||
|
||||
// Animate the current chord slot 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: 'scale(0.85)' },
|
||||
{ opacity: 1, transform: 'scale(1)' }],
|
||||
{ duration: 200, 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-5 mb-4 flex items-center justify-center h-28">
|
||||
<p className="text-gray-600">Start listening to detect chords…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-panel border border-border rounded-2xl p-5 mb-4">
|
||||
|
||||
{/* ── Chord history strip: all HISTORY_SHOWN chords at consistent size ── */}
|
||||
<div className="flex items-stretch gap-1 overflow-x-auto pb-1">
|
||||
{visible.map((chord, i) => {
|
||||
const isCurrent = i === visible.length - 1
|
||||
const age = visible.length - 1 - i // 0 = current, higher = older
|
||||
const opacity = Math.max(0.2, 1 - age * 0.1) // fade but stay readable
|
||||
const rn = root ? toRomanNumeral(chord, root, mode) : ''
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
ref={isCurrent ? currentRef : null}
|
||||
style={{ opacity }}
|
||||
className={`
|
||||
flex flex-col items-center justify-end shrink-0 px-3 py-2 rounded-xl
|
||||
transition-colors duration-200
|
||||
${isCurrent
|
||||
? 'bg-accent/10 border border-accent/40 ring-1 ring-accent/20'
|
||||
: 'border border-transparent'}
|
||||
`}
|
||||
>
|
||||
<span className={`font-black leading-none tracking-tight ${
|
||||
isCurrent ? 'text-5xl text-accent' : 'text-3xl text-gray-200'
|
||||
}`}>
|
||||
{chord}
|
||||
</span>
|
||||
<span className={`text-xs font-semibold mt-1 ${
|
||||
isCurrent ? 'text-amber-400' : 'text-gray-500'
|
||||
}`}>
|
||||
{rn || '\u00A0'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ── Detected loop ── */}
|
||||
{detectedProgression && (
|
||||
<div className="mt-4 pt-3 border-t border-border">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-widest mb-2">♻ 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_14px_rgba(168,85,247,0.35)]'
|
||||
: '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>
|
||||
)
|
||||
})}
|
||||
<span className="self-center text-gray-600 text-sm pl-1">→ loop</span>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useAudioTuner } from '../services/audioService'
|
||||
|
||||
// Tuner UI uses pitch data from `useAudioTuner` (autocorrelation handled in the hook)
|
||||
|
||||
export default function Tuner() {
|
||||
const { pitchData, isListening, startListening, stopListening } = useAudioTuner()
|
||||
const [octaveShift, setOctaveShift] = useState(0)
|
||||
const canvasRef = useRef(null)
|
||||
const historyRef = useRef([])
|
||||
|
||||
// smoothing for display to reduce jitter
|
||||
const smoothedRef = useRef({ cents: 0, freq: 0 })
|
||||
const [display, setDisplay] = useState({ cents: 0, freq: 0 })
|
||||
const SMOOTH_ALPHA = 0.25
|
||||
|
||||
// wire pitchData -> smoothing and history
|
||||
useEffect(() => {
|
||||
const newFreq = pitchData?.freq || 0
|
||||
const newCents = pitchData?.cents || 0
|
||||
const prev = smoothedRef.current
|
||||
const sf = prev.freq + (newFreq - prev.freq) * SMOOTH_ALPHA
|
||||
const sc = prev.cents + (newCents - prev.cents) * SMOOTH_ALPHA
|
||||
smoothedRef.current = { freq: sf, cents: sc }
|
||||
setDisplay({ freq: Math.round(sf), cents: Math.round(sc) })
|
||||
|
||||
if (isListening && pitchData) {
|
||||
historyRef.current.push(pitchData.cents)
|
||||
if (historyRef.current.length > 120) historyRef.current.shift()
|
||||
} else if (!isListening) {
|
||||
historyRef.current = []
|
||||
}
|
||||
}, [pitchData, isListening])
|
||||
|
||||
// Draw the scrolling graph on the canvas
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
let rafId
|
||||
|
||||
function resizeCanvas() {
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
canvas.width = Math.floor(rect.width * dpr)
|
||||
canvas.height = Math.floor(rect.height * dpr)
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
}
|
||||
|
||||
resizeCanvas()
|
||||
window.addEventListener('resize', resizeCanvas)
|
||||
|
||||
const draw = () => {
|
||||
const { width, height } = canvas
|
||||
// clear (canvas uses device pixels but ctx scaled)
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// logical width/height in CSS pixels
|
||||
const w = canvas.width / (window.devicePixelRatio || 1)
|
||||
const h = canvas.height / (window.devicePixelRatio || 1)
|
||||
|
||||
// Draw background bands (center green, sides red)
|
||||
ctx.fillStyle = 'rgba(16,185,129,0.12)'
|
||||
ctx.fillRect(w * 0.4, 0, w * 0.2, h)
|
||||
ctx.fillStyle = 'rgba(16,185,129,0.28)'
|
||||
ctx.fillRect(w * 0.48, 0, w * 0.04, h)
|
||||
|
||||
ctx.fillStyle = 'rgba(239,68,68,0.12)'
|
||||
ctx.fillRect(0, 0, w * 0.4, h)
|
||||
ctx.fillRect(w * 0.6, 0, w * 0.4, h)
|
||||
|
||||
const history = historyRef.current
|
||||
if (history.length > 1) {
|
||||
ctx.beginPath()
|
||||
ctx.strokeStyle = 'white'
|
||||
ctx.lineWidth = 2
|
||||
ctx.lineJoin = 'round'
|
||||
|
||||
const step = h / (history.length - 1)
|
||||
history.forEach((cents, i) => {
|
||||
const x = (w / 2) + (cents * (w / 100))
|
||||
const y = h - (i * step)
|
||||
if (i === 0) ctx.moveTo(x, y)
|
||||
else ctx.lineTo(x, y)
|
||||
})
|
||||
ctx.stroke()
|
||||
|
||||
const last = history[history.length - 1]
|
||||
const currentX = (w / 2) + (last * (w / 100))
|
||||
ctx.beginPath()
|
||||
ctx.fillStyle = 'white'
|
||||
ctx.arc(currentX, h - 0, 4, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
rafId = requestAnimationFrame(draw)
|
||||
}
|
||||
|
||||
draw()
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', resizeCanvas)
|
||||
cancelAnimationFrame(rafId)
|
||||
}
|
||||
}, [isListening])
|
||||
|
||||
|
||||
return (
|
||||
<div className="p-6 bg-panel border border-border rounded-xl text-center">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">Tuner</h3>
|
||||
<div>
|
||||
<button
|
||||
onClick={() => (isListening ? stopListening() : startListening())}
|
||||
className={`px-4 py-2 rounded-full text-sm font-semibold ${isListening ? 'bg-red-600' : 'bg-accent'}`}
|
||||
>
|
||||
{isListening ? 'Stop' : 'Start'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex flex-col items-center">
|
||||
<div className="w-full max-w-3xl">
|
||||
<div className="flex items-center justify-between mb-3 px-6">
|
||||
<div className="text-left">
|
||||
<div className="text-2xl font-semibold">{display.freq || '—'}</div>
|
||||
<div className="text-xs text-gray-400">HERTZ</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-6xl font-bold tracking-tight">{pitchData ? `${pitchData.note}${pitchData.octave}` : '—'}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-semibold">{display.cents >= 0 ? `+${display.cents}` : display.cents}</div>
|
||||
<div className="text-xs text-gray-400">CENTS</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative bg-black/40 rounded-md overflow-hidden h-90 mb-3">
|
||||
<canvas ref={canvasRef} className="absolute inset-0 w-full h-full" />
|
||||
|
||||
<div className="absolute inset-y-0 left-1/2 transform -translate-x-1/2 w-1/6 pointer-events-none">
|
||||
<div className="absolute inset-0 bg-green-600/50 mx-auto w-full rounded"></div>
|
||||
</div>
|
||||
<div className="absolute inset-y-0 left-0 w-5/12 bg-red-600/20 pointer-events-none" />
|
||||
<div className="absolute inset-y-0 right-0 w-5/12 bg-red-600/20 pointer-events-none" />
|
||||
|
||||
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-0.5 h-24 bg-white/30 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user