additions formatting
This commit is contained in:
+25
-3
@@ -8,6 +8,7 @@ import Tuner from './components/Tuner'
|
|||||||
import Piano from './components/Piano'
|
import Piano from './components/Piano'
|
||||||
import Settings from './components/Settings'
|
import Settings from './components/Settings'
|
||||||
import DebugView from './components/DebugView'
|
import DebugView from './components/DebugView'
|
||||||
|
import DrumView from './components/DrumView'
|
||||||
import { NOTES, detectKey, detectTopKeys, matchChordFromChroma, detectRepeatingProgression, getChordTones, getChordCandidates, getNoteHistoryAnalysis } from './lib/theory'
|
import { NOTES, detectKey, detectTopKeys, matchChordFromChroma, detectRepeatingProgression, getChordTones, getChordCandidates, getNoteHistoryAnalysis } from './lib/theory'
|
||||||
import settingIcon from './assets/setting-icon.png'
|
import settingIcon from './assets/setting-icon.png'
|
||||||
|
|
||||||
@@ -49,7 +50,8 @@ export default function App() {
|
|||||||
// ── Instrument view + tuner ───────────────────────────────────────────────────
|
// ── Instrument view + tuner ───────────────────────────────────────────────────
|
||||||
const [instrument, setInstrument] = useState('piano') // 'piano' | 'guitar' | 'bass'
|
const [instrument, setInstrument] = useState('piano') // 'piano' | 'guitar' | 'bass'
|
||||||
const [showTuner, setShowTuner] = useState(false)
|
const [showTuner, setShowTuner] = useState(false)
|
||||||
const [showDebug, setShowDebug] = useState(false)
|
const [showDebug, setShowDebug] = useState(false)
|
||||||
|
const [showDrumView, setShowDrumView] = useState(false)
|
||||||
const [monoColor, setMonoColor] = useState(() => loadStored('wtf_monoColor', false))
|
const [monoColor, setMonoColor] = useState(() => loadStored('wtf_monoColor', false))
|
||||||
|
|
||||||
// ── Mic permission error ──────────────────────────────────────────────────────
|
// ── Mic permission error ──────────────────────────────────────────────────────
|
||||||
@@ -63,9 +65,11 @@ export default function App() {
|
|||||||
|
|
||||||
// ── Stable refs for values used inside callbacks ──────────────────────────────
|
// ── Stable refs for values used inside callbacks ──────────────────────────────
|
||||||
const showDebugRef = useRef(showDebug)
|
const showDebugRef = useRef(showDebug)
|
||||||
|
const showDrumViewRef = useRef(showDrumView)
|
||||||
const lockedKeyRef = useRef(null)
|
const lockedKeyRef = useRef(null)
|
||||||
const listenStartRef = useRef(null)
|
const listenStartRef = useRef(null)
|
||||||
useEffect(() => { showDebugRef.current = showDebug }, [showDebug])
|
useEffect(() => { showDebugRef.current = showDebug }, [showDebug])
|
||||||
|
useEffect(() => { showDrumViewRef.current = showDrumView }, [showDrumView])
|
||||||
useEffect(() => { localStorage.setItem('wtf_monoColor', JSON.stringify(monoColor)) }, [monoColor])
|
useEffect(() => { localStorage.setItem('wtf_monoColor', JSON.stringify(monoColor)) }, [monoColor])
|
||||||
useEffect(() => { if (isListening) listenStartRef.current = Date.now() }, [isListening])
|
useEffect(() => { if (isListening) listenStartRef.current = Date.now() }, [isListening])
|
||||||
|
|
||||||
@@ -184,9 +188,11 @@ export default function App() {
|
|||||||
effectiveKeyRef.current = keyInfo
|
effectiveKeyRef.current = keyInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Waveform handler: feeds oscilloscope in debug view ───────────────────────
|
// ── Waveform handler: feeds oscilloscope / drum view ─────────────────────────
|
||||||
const handleWaveform = useCallback((data) => {
|
const handleWaveform = useCallback((data) => {
|
||||||
if (showDebugRef.current) setDebugWaveform(data)
|
if (showDebugRef.current || showDrumViewRef.current) {
|
||||||
|
setDebugWaveform({ ...data, onsets: [...onsetTimestampsRef.current] })
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// ── Note handler: drives key detection (pitch-based) ──────────────────────────
|
// ── Note handler: drives key detection (pitch-based) ──────────────────────────
|
||||||
@@ -569,6 +575,22 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Rhythm / drum analyser — collapsible ── */}
|
||||||
|
<div className="mb-3 bg-panel border border-border rounded-xl overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDrumView(v => !v)}
|
||||||
|
className="w-full flex items-center justify-between px-4 py-2 text-sm text-gray-400 hover:text-gray-200 transition-all"
|
||||||
|
>
|
||||||
|
<span>RHYTHM ANALYSER</span>
|
||||||
|
<span>{showDrumView ? '▲' : '▼'}</span>
|
||||||
|
</button>
|
||||||
|
{showDrumView && (
|
||||||
|
<div className="border-t border-border p-4">
|
||||||
|
<DrumView waveform={debugWaveform} bpm={bpm} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ── Tuner — collapsible ── */}
|
{/* ── Tuner — collapsible ── */}
|
||||||
<div className="bg-panel border border-border rounded-xl overflow-hidden">
|
<div className="bg-panel border border-border rounded-xl overflow-hidden">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { useRef, useEffect } from 'react'
|
||||||
|
|
||||||
|
// Map a frequency (Hz) to a bin index in the 256-bin log spectrum (40–4000 Hz)
|
||||||
|
function freqToBin(freq) {
|
||||||
|
return Math.round(255 * Math.log(freq / 40) / Math.log(4000 / 40))
|
||||||
|
}
|
||||||
|
|
||||||
|
function bandMax(spectrum, lo, hi) {
|
||||||
|
if (!spectrum) return 0
|
||||||
|
const a = freqToBin(lo)
|
||||||
|
const b = Math.min(freqToBin(hi), spectrum.length - 1)
|
||||||
|
let max = 0
|
||||||
|
for (let i = a; i <= b; i++) if (spectrum[i] > max) max = spectrum[i]
|
||||||
|
return max
|
||||||
|
}
|
||||||
|
|
||||||
|
const BANDS = [
|
||||||
|
{ label: 'Kick', lo: 40, hi: 120, color: '#ef4444' },
|
||||||
|
{ label: 'Snare', lo: 120, hi: 300, color: '#f59e0b' },
|
||||||
|
{ label: 'Mid', lo: 300, hi: 1000, color: '#22c55e' },
|
||||||
|
{ label: 'Presence', lo: 1000, hi: 4000, color: '#60a5fa' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const TIMELINE_MS = 4000 // onset timeline window
|
||||||
|
const RMS_HISTORY = 180 // ~3s at 60fps
|
||||||
|
|
||||||
|
export default function DrumView({ waveform, bpm }) {
|
||||||
|
const rmsHistRef = useRef([])
|
||||||
|
const beatCanvasRef = useRef(null)
|
||||||
|
const rmsCanvasRef = useRef(null)
|
||||||
|
|
||||||
|
const spectrum = waveform?.spectrum ?? null
|
||||||
|
const bandLevels = BANDS.map(b => bandMax(spectrum, b.lo, b.hi))
|
||||||
|
|
||||||
|
// Accumulate RMS history
|
||||||
|
useEffect(() => {
|
||||||
|
if (waveform == null) return
|
||||||
|
const h = rmsHistRef.current
|
||||||
|
h.push(Math.min(waveform.rms * 10, 1))
|
||||||
|
if (h.length > RMS_HISTORY) h.shift()
|
||||||
|
}, [waveform])
|
||||||
|
|
||||||
|
// Draw onset / beat timeline
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = beatCanvasRef.current
|
||||||
|
if (!canvas) return
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
const W = canvas.width, H = canvas.height
|
||||||
|
|
||||||
|
ctx.fillStyle = '#0a0a0a'
|
||||||
|
ctx.fillRect(0, 0, W, H)
|
||||||
|
|
||||||
|
const onsets = waveform?.onsets ?? []
|
||||||
|
const now = performance.now()
|
||||||
|
|
||||||
|
// Beat grid aligned to the most recent onset
|
||||||
|
if (bpm) {
|
||||||
|
const beatMs = 60000 / bpm
|
||||||
|
const numBeats = Math.ceil(TIMELINE_MS / beatMs) + 1
|
||||||
|
const latest = onsets[onsets.length - 1]
|
||||||
|
const phase = latest != null ? (now - latest) % beatMs : 0
|
||||||
|
for (let b = 0; b <= numBeats; b++) {
|
||||||
|
const ageMs = b * beatMs - phase
|
||||||
|
if (ageMs < 0 || ageMs > TIMELINE_MS) continue
|
||||||
|
const x = W * (1 - ageMs / TIMELINE_MS)
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,0.07)'
|
||||||
|
ctx.lineWidth = 1
|
||||||
|
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Onset dots + vertical tails
|
||||||
|
const recent = onsets.filter(t => now - t <= TIMELINE_MS)
|
||||||
|
for (const t of recent) {
|
||||||
|
const age = now - t
|
||||||
|
const x = W * (1 - age / TIMELINE_MS)
|
||||||
|
const alpha = Math.pow(1 - age / TIMELINE_MS, 0.4)
|
||||||
|
ctx.strokeStyle = `rgba(168,85,247,${(alpha * 0.35).toFixed(2)})`
|
||||||
|
ctx.lineWidth = 1
|
||||||
|
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke()
|
||||||
|
ctx.fillStyle = `rgba(168,85,247,${alpha.toFixed(2)})`
|
||||||
|
ctx.beginPath(); ctx.arc(x, H / 2, 5, 0, Math.PI * 2); ctx.fill()
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Now" edge
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,0.18)'
|
||||||
|
ctx.lineWidth = 2
|
||||||
|
ctx.beginPath(); ctx.moveTo(W - 1, 0); ctx.lineTo(W - 1, H); ctx.stroke()
|
||||||
|
}, [waveform, bpm])
|
||||||
|
|
||||||
|
// Draw RMS envelope
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = rmsCanvasRef.current
|
||||||
|
if (!canvas) return
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
const W = canvas.width, H = canvas.height
|
||||||
|
const h = rmsHistRef.current
|
||||||
|
|
||||||
|
ctx.fillStyle = '#0a0a0a'
|
||||||
|
ctx.fillRect(0, 0, W, H)
|
||||||
|
if (h.length < 2) return
|
||||||
|
|
||||||
|
const barW = W / RMS_HISTORY
|
||||||
|
for (let i = 0; i < h.length; i++) {
|
||||||
|
const x = W * (i / RMS_HISTORY)
|
||||||
|
const barH = h[i] * H
|
||||||
|
const v = Math.round(h[i] * 160 + 60)
|
||||||
|
ctx.fillStyle = `rgb(${v},30,${v})`
|
||||||
|
ctx.fillRect(x, H - barH, Math.max(barW - 0.5, 1), barH)
|
||||||
|
}
|
||||||
|
}, [waveform])
|
||||||
|
|
||||||
|
const noData = !waveform
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
|
||||||
|
{/* Band meters */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-600 font-mono uppercase tracking-widest mb-2">Frequency Bands</p>
|
||||||
|
<div className="flex gap-3" style={{ height: 96 }}>
|
||||||
|
{BANDS.map((b, i) => (
|
||||||
|
<div key={b.label} className="flex flex-col items-center gap-1 flex-1">
|
||||||
|
<div className="flex-1 w-full bg-gray-900 rounded-sm relative overflow-hidden">
|
||||||
|
{noData ? (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<span className="text-[9px] text-gray-700 font-mono">—</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="absolute bottom-0 left-0 right-0 rounded-sm"
|
||||||
|
style={{
|
||||||
|
height: `${bandLevels[i] * 100}%`,
|
||||||
|
backgroundColor: b.color,
|
||||||
|
transition: 'height 60ms linear',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] font-mono text-gray-500 uppercase">{b.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Onset timeline */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-600 font-mono uppercase tracking-widest mb-2">
|
||||||
|
Onset Timeline{bpm ? ` · ${bpm} BPM` : ''}
|
||||||
|
<span className="ml-2 text-gray-700 normal-case">← 4 seconds</span>
|
||||||
|
</p>
|
||||||
|
<canvas
|
||||||
|
ref={beatCanvasRef}
|
||||||
|
width={800}
|
||||||
|
height={56}
|
||||||
|
className="w-full rounded"
|
||||||
|
style={{ height: 56 }}
|
||||||
|
/>
|
||||||
|
{noData && (
|
||||||
|
<p className="text-xs text-gray-700 font-mono mt-1 text-center">Start listening to see hits</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Volume envelope */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-600 font-mono uppercase tracking-widest mb-2">
|
||||||
|
Volume Envelope
|
||||||
|
<span className="ml-2 text-gray-700 normal-case">← ~3 seconds</span>
|
||||||
|
</p>
|
||||||
|
<canvas
|
||||||
|
ref={rmsCanvasRef}
|
||||||
|
width={800}
|
||||||
|
height={56}
|
||||||
|
className="w-full rounded"
|
||||||
|
style={{ height: 56 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user