additions for live chroma and sound improvements
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user