openvisengine/src/components/ava/beat-tab.tsx

170 lines
6.9 KiB
TypeScript
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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>
)
}