openvisengine/src/components/ava/beat-tab.tsx
2026-11-04 05:50:06 +00:00

178 lines
7.5 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 source = useAvaStore((s) => s.source)
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 =
source === 'idle'
? 'No audio playing -- start a source, then tap along'
: 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 glow-setbeat 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">
How strongly periodic the music's attacks must be before the tempo locks -- lower = locks easier.
</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> the detector listens to the music's{' '}
<span className="text-zinc-400">attacks</span> -- the frame-to-frame jump of energy across the whole
spectrum (onsets). Every ~100 ms those onsets are autocorrelated over 300–1000 ms lags: a strong, consistent
periodicity locks the tempo (the confidence meter), and a comb filter aligns a beat grid to it. Beats fire on
that grid -- <span className="text-zinc-400">PRED</span> when the grid carries the beat through a passage with
no clear attacks. This works on real mastered music, where a loud sustained sub-bass hides the kick from any
level-based detector. Detection only runs while a source is playing -- it pauses (and the BPM resets) whenever
no music is loaded. <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>
)
}