170 lines
6.9 KiB
TypeScript
Executable file
170 lines
6.9 KiB
TypeScript
Executable file
// AVA — Beat tab: the beat detection & prediction lab.
|
||
//
|
||
// Large beat indicator + SET BEAT tap-tempo button, live confidence meter,
|
||
// sensitivity / min-interval sliders and the prediction switch (all persisted).
|
||
|
||
'use client'
|
||
|
||
import { motion } from 'framer-motion'
|
||
import { Timer } from 'lucide-react'
|
||
import { Badge } from '@/components/ui/badge'
|
||
import { Label } from '@/components/ui/label'
|
||
import { Separator } from '@/components/ui/separator'
|
||
import { Slider } from '@/components/ui/slider'
|
||
import { Switch } from '@/components/ui/switch'
|
||
import { BeatDot } from '@/components/ava/beat-dot'
|
||
import { tapBeat } from '@/components/ava/audio-source'
|
||
import { useAvaStore } from '@/lib/store'
|
||
import { cn } from '@/lib/utils'
|
||
|
||
const SOURCE_LABELS: Record<string, string> = {
|
||
audio: 'AUDIO',
|
||
predicted: 'PRED',
|
||
tap: 'TAP',
|
||
none: 'NONE',
|
||
}
|
||
|
||
export function BeatTab() {
|
||
const stats = useAvaStore((s) => s.stats)
|
||
const lastTap = useAvaStore((s) => s.lastTap)
|
||
const beatFlash = useAvaStore((s) => s.beatFlash)
|
||
const beatConfig = useAvaStore((s) => s.beatConfig)
|
||
const setBeatValue = useAvaStore((s) => s.setBeatValue)
|
||
|
||
const beat = stats?.beat
|
||
const bpm = beat?.bpm ?? 0
|
||
const confidence = beat?.confidence ?? 0
|
||
const beatCount = stats?.beatCount ?? 0
|
||
const lastSource = beatFlash?.source ?? 'none'
|
||
|
||
const tapFeedback = lastTap
|
||
? lastTap.locked
|
||
? `Locked: ${lastTap.bpm.toFixed(1)} BPM`
|
||
: `Tap ${lastTap.tapCount}… keep tapping`
|
||
: 'Tap along with the music — Space works too'
|
||
|
||
return (
|
||
<div className="space-y-5">
|
||
{/* big dot + readouts */}
|
||
<div className="flex items-center gap-5">
|
||
<BeatDot size={80} />
|
||
<div className="min-w-0">
|
||
<div className="font-mono text-4xl font-bold leading-none tabular-nums text-emerald-400">
|
||
{bpm > 0 ? bpm.toFixed(1) : '—'}
|
||
<span className="ml-1.5 align-baseline font-sans text-sm font-medium text-zinc-500">BPM</span>
|
||
</div>
|
||
<div className="mt-2.5 flex flex-wrap items-center gap-2">
|
||
<Badge
|
||
variant="outline"
|
||
className={cn(
|
||
'border-zinc-700 font-mono text-[10px] tracking-wider',
|
||
lastSource === 'predicted' && 'border-emerald-400/40 text-emerald-300',
|
||
lastSource === 'tap' && 'border-amber-300/40 text-amber-200',
|
||
)}
|
||
>
|
||
{SOURCE_LABELS[lastSource] ?? lastSource.toUpperCase()}
|
||
</Badge>
|
||
<span className="font-mono text-[11px] tabular-nums text-zinc-500">{beatCount} beats</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* SET BEAT */}
|
||
<motion.button
|
||
type="button"
|
||
whileTap={{ scale: 0.965 }}
|
||
onClick={tapBeat}
|
||
className="flex h-14 w-full flex-col items-center justify-center gap-0.5 rounded-xl bg-emerald-400 font-bold text-emerald-950 shadow-[0_0_24px_rgba(52,211,153,0.28)] transition-colors hover:bg-emerald-300"
|
||
title="Tap to set the tempo (or press Space)"
|
||
>
|
||
<span className="flex items-center gap-2 text-sm font-bold tracking-[0.12em]">
|
||
<Timer className="size-4" /> SET BEAT
|
||
</span>
|
||
<span className="text-[10px] font-medium tracking-wide opacity-70">tap to the music · space works too</span>
|
||
</motion.button>
|
||
<p className="min-h-4 text-center font-mono text-xs text-zinc-400" role="status">
|
||
{tapFeedback}
|
||
</p>
|
||
|
||
{/* confidence */}
|
||
<div>
|
||
<div className="mb-1.5 flex items-center justify-between text-xs">
|
||
<span className="text-zinc-400">Tempo lock confidence</span>
|
||
<span className="font-mono tabular-nums text-zinc-300">{Math.round(confidence * 100)}%</span>
|
||
</div>
|
||
<div className="h-2 overflow-hidden rounded-full bg-zinc-800">
|
||
<div
|
||
className="h-full rounded-full bg-emerald-400 transition-[width] duration-200 ease-linear"
|
||
style={{ width: `${Math.round(confidence * 100)}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<Separator className="bg-zinc-800" />
|
||
|
||
{/* detector controls */}
|
||
<div className="space-y-4">
|
||
<div>
|
||
<div className="mb-1.5 flex items-center justify-between">
|
||
<Label className="text-xs text-zinc-400">Sensitivity</Label>
|
||
<span className="font-mono text-xs tabular-nums text-zinc-200">×{beatConfig.threshold.toFixed(2)}</span>
|
||
</div>
|
||
<Slider
|
||
value={[beatConfig.threshold]}
|
||
min={1.1}
|
||
max={2.5}
|
||
step={0.05}
|
||
onValueChange={([v]) => setBeatValue('threshold', v)}
|
||
aria-label="Beat sensitivity (threshold)"
|
||
className="py-1.5"
|
||
/>
|
||
<p className="mt-1.5 text-[11px] leading-relaxed text-zinc-500">
|
||
A beat fires when the bass energy exceeds this multiple of the recent average — lower = more sensitive.
|
||
</p>
|
||
</div>
|
||
|
||
<div>
|
||
<div className="mb-1.5 flex items-center justify-between">
|
||
<Label className="text-xs text-zinc-400">Min beat interval</Label>
|
||
<span className="font-mono text-xs tabular-nums text-zinc-200">{beatConfig.minInterval} ms</span>
|
||
</div>
|
||
<Slider
|
||
value={[beatConfig.minInterval]}
|
||
min={150}
|
||
max={600}
|
||
step={10}
|
||
onValueChange={([v]) => setBeatValue('minInterval', v)}
|
||
aria-label="Minimum beat interval"
|
||
className="py-1.5"
|
||
/>
|
||
<p className="mt-1.5 text-[11px] leading-relaxed text-zinc-500">
|
||
Fastest allowed beat rate — raise it to ignore double-hits and rolls.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex items-center justify-between gap-3 rounded-lg border border-zinc-800/80 bg-zinc-950/40 p-3">
|
||
<div>
|
||
<Label className="text-xs text-zinc-300">Beat prediction</Label>
|
||
<p className="mt-0.5 text-[11px] text-zinc-500">Keeps the beat alive when the bass drops out.</p>
|
||
</div>
|
||
<Switch
|
||
checked={beatConfig.prediction}
|
||
onCheckedChange={(v) => setBeatValue('prediction', v)}
|
||
aria-label="Beat prediction"
|
||
className="data-[state=checked]:bg-emerald-400"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<Separator className="bg-zinc-800" />
|
||
|
||
<p className="text-[11px] leading-relaxed text-zinc-500">
|
||
<span className="font-semibold text-zinc-400">How it works:</span> beats are detected from the smoothed bass
|
||
energy crossing the sensitivity threshold. Consistent intervals lock a tempo (the confidence meter), and a
|
||
phase-locked predictor then keeps firing beats on the learned grid — shown as a hollow ring with a PRED badge —
|
||
whenever the bass disappears. <span className="font-semibold text-zinc-400">Set Beat</span> teaches the tempo
|
||
directly: tap along and two or more taps lock the period and phase to your rhythm.
|
||
</p>
|
||
</div>
|
||
)
|
||
}
|