openvisengine/src/components/ava/beat-dot.tsx
zai-bot b08490e80e v7.15.1 — silence hold: beat detection & prediction freeze during silence
Sustained near-digital quiet on the overall level (< 0.006 for 300 ms,
sticky until a decisive 0.015) now places an immediate HOLD on the beat
tracker: nothing fires (real or predicted -- previously the predictor
kept faking beats for up to ~2 s into any silence), the tempo estimate
and confidence freeze exactly where they are, and beatphase freezes for
scripts instead of saturating at 'beat imminent'. Decisive audio
releases the hold instantly and the grid resumes from the phase the
audio went quiet (captured before the debounce window, so a
pause/resume keeps perfect time); after a hard cut the comb + first
real beats re-align within a couple of periods. 'Set Beat' taps release
the hold outright. Waveform/band data is never gated -- it still flows
(it is simply ~0, because the audio is gone).

- BeatFrame.silent carries the state; scripts get the silent variable
  (1 while held); the beat indicator shows an amber HOLD badge + amber
  phase ring while held.
- rust-agent/src/beat.rs + engine.rs: same gate ported for parity (its
  bass level doubles as the silence signal; NOT compile-verified -- no
  cargo in this sandbox).
- Verified: headless 26/26 assertions on the real BeatTracker (music ->
  silence -> in-phase resume -> onsetless pad -> tap; the pre-fix
  fake-beat window 12.8-15.5 s is empty) and browser E2E on the live
  app with a generated 120 BPM + silence looping WAV (HOLD badge tracks
  the silent spans exactly across two loop cycles; console clean).
2026-11-06 06:49:46 +00:00

145 lines
5.4 KiB
TypeScript
Executable file

// AVA — the green beat indicator.
//
// A circular component: emerald center dot + thin SVG phase ring that fills
// with beat.phase over each beat period. On every beat pulse the dot scales up
// and glows (~180 ms). When the pulse came from the *predictor* the flash is
// rendered as an expanding outline ring + "PRED" badge instead of a filled
// dot, so prediction is visible at a glance.
'use client'
import { useEffect, useState } from 'react'
import { motion } from 'framer-motion'
import { useAvaStore } from '@/lib/store'
import { cn } from '@/lib/utils'
interface BeatDotProps {
size?: number
className?: string
}
export function BeatDot({ size = 44, className }: BeatDotProps) {
const phase = useAvaStore((s) => s.stats?.beat.phase ?? 0)
const beatFlash = useAvaStore((s) => s.beatFlash)
const bpm = useAvaStore((s) => s.stats?.beat.bpm ?? 0)
const idle = useAvaStore((s) => s.source) === 'idle'
// v7.15.1 — the silence hold: a source is loaded but quiet, so the beat
// grid is frozen (no beats, phase held). Rendered as an amber ring + HOLD
// badge so "no music = no beats" is visible at a glance.
const held = useAvaStore((s) => s.stats?.beat.silent ?? false) && !idle
// flash state is derived: a flash is "on" until its seq is cleared by the
// hold timeout (setState only ever happens inside the timeout callback)
const [clearedSeq, setClearedSeq] = useState(-1)
useEffect(() => {
if (!beatFlash) return
const hold = beatFlash.source === 'predicted' ? 450 : 180
const t = setTimeout(() => setClearedSeq(beatFlash.seq), hold)
return () => clearTimeout(t)
}, [beatFlash])
const flashing = beatFlash !== null && beatFlash.seq > clearedSeq
const flashSource = flashing ? beatFlash.source : 'none'
const r = size / 2 - 3
const c = 2 * Math.PI * r
const predicted = flashing && flashSource === 'predicted'
const dotBase = Math.max(8, size * 0.3)
return (
<div
className={cn('relative shrink-0 select-none', className)}
style={{ width: size, height: size }}
role="img"
aria-label={
idle
? 'Beat indicator -- paused, no audio playing'
: held
? `Beat indicator -- held (silence), ${bpm > 0 ? `${bpm.toFixed(1)} BPM frozen` : 'no tempo'}`
: `Beat indicator -- ${bpm > 0 ? `${bpm.toFixed(1)} BPM` : 'no tempo'}${flashSource === 'predicted' ? ', predicted' : ''}`
}
>
{/* phase progress ring */}
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className="absolute inset-0 -rotate-90">
<circle
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
stroke="currentColor"
strokeWidth={Math.max(2, size / 22)}
className="text-zinc-800"
/>
<circle
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
stroke="currentColor"
strokeWidth={Math.max(2, size / 22)}
strokeLinecap="round"
strokeDasharray={c}
strokeDashoffset={c * (1 - Math.min(1, Math.max(0, phase)))}
className={cn(
'transition-[stroke-dashoffset] duration-150 ease-linear',
held ? 'text-amber-400' : 'text-emerald-400',
)}
/>
</svg>
{/* filled pulse dot (audio / tap beats) -- dimmed while no source is
playing/loaded, since beat detection is suspended (v6.4); dim +
amber glow while the silence hold is active (v7.15.1) */}
<div
className={cn(
'absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full transition-all duration-150 ease-out',
flashing && !predicted
? 'glow-beat-flash bg-emerald-400 opacity-100'
: held
? 'bg-amber-400/80 opacity-50'
: idle
? 'bg-emerald-400 opacity-35'
: 'glow-beat-idle bg-emerald-400 opacity-80',
)}
style={{
width: dotBase,
height: dotBase,
transform: `translate(-50%, -50%) scale(${flashing && !predicted ? 1.9 : 1})`,
}}
/>
{/* predicted beats: expanding outline ring instead of the filled dot */}
{predicted && (
<motion.span
initial={{ scale: 0.45, opacity: 0.9 }}
animate={{ scale: 1.3, opacity: 0 }}
transition={{ duration: 0.45, ease: 'easeOut' }}
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-emerald-300"
style={{ width: size * 0.62, height: size * 0.62 }}
/>
)}
{/* PRED badge -- visible while a predicted pulse is showing */}
{predicted && (
<span
className="absolute left-1/2 -translate-x-1/2 rounded border border-emerald-400/40 bg-zinc-950/90 px-1 py-px font-mono text-emerald-300"
style={{ bottom: size >= 64 ? -18 : -13, fontSize: size >= 64 ? 9 : 7, letterSpacing: '0.08em' }}
>
PRED
</span>
)}
{/* HOLD badge -- the silence hold is active: no beats fire, the grid
is frozen until real audio returns (v7.15.1) */}
{held && !predicted && !flashing && (
<span
className="absolute left-1/2 -translate-x-1/2 rounded border border-amber-400/40 bg-zinc-950/90 px-1 py-px font-mono text-amber-300"
style={{ bottom: size >= 64 ? -18 : -13, fontSize: size >= 64 ? 9 : 7, letterSpacing: '0.08em' }}
>
HOLD
</span>
)}
</div>
)
}