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).
145 lines
5.4 KiB
TypeScript
Executable file
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>
|
|
)
|
|
}
|