Compare commits

...

2 commits

Author SHA1 Message Date
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
b08411f49b chore(git): ignore *.db* per workflow policy, untrack db/custom.db
The SQLite database is runtime state (presets, settings, sessions)
written constantly by the live app -- it is regenerated per machine via
prisma db push and seeded from embedded presets on first boot, so it
does not belong in version control. The file remains on disk untouched.

Also adds *.sqlite / *.sqlite3 alongside the requested *.db* pattern.
2026-11-06 06:24:04 +00:00
13 changed files with 367 additions and 39 deletions

5
.gitignore vendored
View file

@ -35,6 +35,11 @@ yarn-error.log*
.env*
!.env.example
# databases -- runtime state, regenerated per machine (prisma db push)
*.db*
*.sqlite
*.sqlite3
# vercel
.vercel

Binary file not shown.

View file

@ -15,6 +15,13 @@
//! 4. TAP — the "Set Beat" control (the Space key): taps lock the
//! period + phase from the tap intervals (median), teaching
//! the tracker the song's beat.
//! 5. SILENCE — sustained near-digital quiet places a HOLD (v7.15.1,
//! parity with the web tracker): nothing fires, learning /
//! confidence / phase all freeze; decisive audio releases
//! the hold and the grid continues from the frozen phase
//! (a pause/resume keeps perfect time). The detector input
//! (the smoothed bass level) doubles as the silence signal —
//! same thresholds as the web edition's `SILENCE_*`.
//!
//! Web ↔ native mapping: `update()` is called once per rendered frame with
//! the smoothed bass level, exactly like the web engine's rAF loop.
@ -27,6 +34,17 @@ const MAX_INTERVALS: usize = 10;
pub const MAX_TAPS: usize = 8;
const TAP_RESET_MS: f64 = 2500.0;
/// v7.15.1 silence hold — the detector input doubles as the level signal.
/// Sustained quiet below this floor (digital silence reads ~0) freezes the
/// whole beat machinery; only audio above the (higher) release floor — a
/// decisive signal — lets go. Matches the web tracker's thresholds.
const SILENCE_ENTER_LEVEL: f64 = 0.006;
const SILENCE_RELEASE_LEVEL: f64 = 0.015;
/// Quiet must persist this long before the hold engages (see beat.ts for
/// the derivation: it only has to beat the ~800 ms the flux-domain path
/// once needed before it started faking beats).
const SILENCE_ENTER_MS: f64 = 300.0;
/// Beat detector / predictor configuration — mirrors `BeatConfig` in types.ts.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct BeatConfig {
@ -77,6 +95,9 @@ pub struct BeatFrame {
pub phase: f64,
/// 0..1 confidence of the tempo lock.
pub confidence: f64,
/// v7.15.1 — true while the silence hold is active: detection and
/// prediction are frozen (no beats fire, the phase is held).
pub silent: bool,
}
/// Result of a user tap — mirrors `TapResult` in beat.ts.
@ -128,6 +149,14 @@ pub struct BeatTracker {
last_real_beat_at: f64,
prev_real_beat_at: f64,
beat_count: u64,
/// v7.15.1 silence-hold state — see the module docs. quiet_phase is the
/// accumulator snapshot from the first quiet frame (the audio's position
/// when it went quiet, before the debounce window — mirrors beat.ts);
/// at hold entry the accumulator pins to it, so the release resumes
/// exactly where the music left off.
hold_active: bool,
quiet_since: Option<f64>,
quiet_phase: Option<f64>,
}
impl BeatTracker {
@ -146,6 +175,9 @@ impl BeatTracker {
last_real_beat_at: -1e9,
prev_real_beat_at: -1e9,
beat_count: 0,
hold_active: false,
quiet_since: None,
quiet_phase: None,
}
}
@ -161,6 +193,9 @@ impl BeatTracker {
self.last_real_beat_at = -1e9;
self.prev_real_beat_at = -1e9;
self.beat_count = 0;
self.hold_active = false;
self.quiet_since = None;
self.quiet_phase = None;
}
pub fn beat_count(&self) -> u64 {
@ -187,6 +222,44 @@ impl BeatTracker {
self.cfg = *cfg;
}
// --- silence hold (v7.15.1 — parity with the web tracker) ------------
// SILENCE MEANS NO BEAT. Sustained near-digital quiet freezes the
// whole machinery: nothing fires (real or predicted), learning and
// confidence hold exactly where they are, and the phase accumulator
// stops advancing (pinned to the silence-start snapshot at hold
// entry). The hold is sticky — only a decisive signal above the
// release floor lets go — and the release is immediate.
if bass_att < SILENCE_ENTER_LEVEL {
if self.quiet_since.is_none() {
self.quiet_since = Some(now_ms);
// Snapshot BEFORE this frame's beat/predictor processing —
// mirrors beat.ts: a pause just before a beat boundary must
// anchor "almost a beat" (the pending pulse plays the moment
// the audio returns).
self.quiet_phase = Some(self.phase);
}
if !self.hold_active
&& now_ms - self.quiet_since.unwrap_or(now_ms) >= SILENCE_ENTER_MS
{
self.hold_active = true;
if let Some(qp) = self.quiet_phase {
self.phase = qp;
}
}
} else {
if self.hold_active && bass_att > SILENCE_RELEASE_LEVEL {
// decisive audio: release now; the accumulator was pinned to
// the silence-start phase at hold entry, so the grid simply
// resumes from where the music left off (a pause/resume
// keeps perfect time)
self.hold_active = false;
}
if !self.hold_active {
self.quiet_since = None;
self.quiet_phase = None;
}
}
// --- rolling energy history -------------------------------------------
self.history.push_back(bass_att);
while self.history.len() > HISTORY_SIZE {
@ -198,7 +271,8 @@ impl BeatTracker {
let mut source = BeatSource::None;
// --- real beat detection ----------------------------------------------
if bass_att > self.cfg.floor_level
if !self.hold_active
&& bass_att > self.cfg.floor_level
&& bass_att > avg * self.cfg.threshold
&& now_ms - self.last_beat_at >= self.cfg.min_interval
{
@ -244,6 +318,9 @@ impl BeatTracker {
}
// --- predictor (phase-locked grid) -------------------------------------
// (frozen while the silence hold is active — the accumulator does
// not advance, so the phase carries through the hold untouched)
if !self.hold_active {
if self.period_ms > 50.0 {
self.phase += (dt_sec * 1000.0) / self.period_ms;
}
@ -264,9 +341,14 @@ impl BeatTracker {
self.confidence = (self.confidence - 0.02).max(0.0);
}
}
}
// --- confidence decay when audio beats disappear -----------------------
if now_ms - self.last_real_beat_at > 2000.0 && self.taps.is_empty() {
// (suspended while held — the hold preserves the lock, not questions it)
if !self.hold_active
&& now_ms - self.last_real_beat_at > 2000.0
&& self.taps.is_empty()
{
self.confidence = (self.confidence - dt_sec * 0.06).max(0.0);
}
@ -276,12 +358,20 @@ impl BeatTracker {
bpm: self.bpm,
phase: self.phase,
confidence: self.confidence,
silent: self.hold_active,
}
}
/// "Set Beat" — user taps to the music. Two or more taps lock the tempo
/// (median of tap intervals) and re-phase the predictor to the taps.
pub fn tap(&mut self, now_ms: f64) -> TapResult {
// v7.15.1: a tap is an explicit manual assertion that there IS a beat
// right now — it releases the silence hold outright ("Set Beat" is
// the escape hatch); the detector re-enters the hold only if the
// level stays under the silence floor for the full debounce again.
self.hold_active = false;
self.quiet_since = None;
self.quiet_phase = None;
if now_ms - self.last_tap_at > TAP_RESET_MS {
self.taps.clear();
}

View file

@ -132,6 +132,9 @@ impl Engine {
("bpm", beat_frame.bpm),
("beatphase", beat_frame.phase),
("beatconf", beat_frame.confidence),
// v7.15.1 — 1 while the silence hold is active (no beats
// fire, beatphase frozen; see beat.rs)
("silent", f64::from(beat_frame.silent)),
("beats", self.total_beats as f64),
("w", w as f64),
("h", h as f64),

View file

@ -23,6 +23,10 @@ export function BeatDot({ size = 44, className }: BeatDotProps) {
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)
@ -51,6 +55,8 @@ export function BeatDot({ size = 44, className }: BeatDotProps) {
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' : ''}`
}
>
@ -75,20 +81,26 @@ export function BeatDot({ size = 44, className }: BeatDotProps) {
strokeLinecap="round"
strokeDasharray={c}
strokeDashoffset={c * (1 - Math.min(1, Math.max(0, phase)))}
className="text-emerald-400 transition-[stroke-dashoffset] duration-150 ease-linear"
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) */}
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 bg-emerald-400 transition-all duration-150 ease-out',
'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 opacity-100'
? 'glow-beat-flash bg-emerald-400 opacity-100'
: held
? 'bg-amber-400/80 opacity-50'
: idle
? 'opacity-35'
: 'glow-beat-idle opacity-80',
? 'bg-emerald-400 opacity-35'
: 'glow-beat-idle bg-emerald-400 opacity-80',
)}
style={{
width: dotBase,
@ -117,6 +129,17 @@ export function BeatDot({ size = 44, className }: BeatDotProps) {
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>
)
}

View file

@ -21,6 +21,7 @@ const FRAME_VARS: [string, string][] = [
['beatconf', '0..1 confidence of the tempo lock'],
['beats', 'total beat count since load'],
['beatage', 'seconds since the last beat (0 on the beat frame) -- exp(-beatage*4) is a kick envelope'],
['silent', '1 while silence holds the beat grid (v7.15.1) -- no beats fire and beatphase freezes; releases the moment real audio returns. Waveform / band data still flows, it is just near zero'],
['w / h', 'canvas size in pixels'],
['aspect', 'canvas width / height -- divide x by it for perfect circles'],
['persist', 'motion-trail persistence 0..1, writable per frame -- 1 = the framebuffer never fades (old content then leaves only through a bg* transform or a blend: replace repaint)'],

View file

@ -35,6 +35,15 @@
// the grid glued to the comb without skipping/doubling beats.
// 6. TAP — the "Set Beat" button: taps lock the period + phase from
// the tap intervals (median), teaching the tracker directly.
// 7. SILENCE — the overall level gates the whole grid (v7.15.1):
// sustained near-digital quiet places a HOLD — nothing
// fires, learning/confidence/beatphase all freeze — and
// decisive audio releases it, resuming the grid from the
// phase the audio went QUIET (captured before the debounce
// window, so a pause/resume keeps perfect time: the music
// continues from the exact position it stopped at).
// Waveform and band data are never gated: they still flow,
// and are simply ~0 because the audio is gone.
//
// Validated headlessly against the demo track's PCM (the AnalyserNode chain
// replicated frame-for-frame): "Neural Mecha Pulse" (true tempo ~133 BPM
@ -73,6 +82,22 @@ const SAMPLER_RESET_MS = 500
const MAX_TAPS = 8
const TAP_RESET_MS = 2500
/** v7.15.1 silence hold — thresholds on the OVERALL band level the engine
* feeds alongside the onset envelope (levels.vol, the 3-band mean).
* Digital silence (pause, track gap, stream stall, muted element) reads
* exactly 0; mastered music keeps the mean above ~0.05 even between hits,
* so a floor this low is unambiguous. The release threshold sits above
* the enter floor on purpose: silence is STICKY (faint noise-floor
* wobble never releases it) and only decisive audio does. */
const SILENCE_ENTER_LEVEL = 0.006
const SILENCE_RELEASE_LEVEL = 0.015
/** Quiet must persist this long before the hold engages. It only has to
* beat the ~800 ms it takes the old flux-domain path to start faking
* beats (bassGone = 1.6 periods at 120 BPM), so 300 ms engages the hold
* with margin to spare while inter-attack dips in sparse masters — which
* sit well above the level, or recover fast — never trip it. */
const SILENCE_ENTER_MS = 300
function median(values: number[]): number {
const sorted = [...values].sort((a, b) => a - b)
const mid = sorted.length >> 1
@ -135,6 +160,19 @@ export class BeatTracker {
private pendingPeriod: number | null = null
private pendingCount = 0
private updateCount = 0
/** v7.15.1 silence-hold state: quietSince is when the level first
* dropped under the enter floor (null while audio is present);
* quietPhase is the beatphase snapshot from that same moment — the
* audio's position when it went quiet, captured BEFORE the debounce
* window so a release lands exactly where the music left off (a
* same-frame beat still counts: the snapshot is taken after that
* frame's beat processing). Once quiet has persisted
* SILENCE_ENTER_MS, holdActive freezes the grid: nothing fires, the
* visible phase pins to quietPhase, and the release re-anchors the
* grid from it. */
private quietSince: number | null = null
private quietPhase: number | null = null
private holdActive = false
constructor(cfg: BeatConfig = { ...DEFAULT_BEAT_CONFIG }) {
this.cfg = cfg
@ -160,19 +198,24 @@ export class BeatTracker {
this.pendingPeriod = null
this.pendingCount = 0
this.updateCount = 0
this.quietSince = null
this.quietPhase = null
this.holdActive = false
}
getBeatCount(): number {
return this.beatCount
}
/** Constant frame returned while no audio source is playing/loaded. */
/** Constant frame returned while no audio source is playing/loaded.
* silent: true — with no source there is definitionally no beat. */
private static readonly STANDBY_FRAME: BeatFrame = {
beat: false,
source: 'none',
bpm: 0,
phase: 0,
confidence: 0,
silent: true,
}
/**
@ -342,16 +385,69 @@ export class BeatTracker {
* @param onset the onset (spectral-flux) envelope value for this frame
* @param nowMs monotonic clock in milliseconds (performance.now())
* @param dtSec frame delta in seconds
* @param level the overall band level for this frame (levels.vol — the
* 3-band mean). Drives the v7.15.1 silence hold; defaults
* to "audible" so callers that don't pass one are simply
* never held.
*/
update(onset: number, nowMs: number, dtSec: number, cfg?: BeatConfig): BeatFrame {
update(onset: number, nowMs: number, dtSec: number, cfg?: BeatConfig, level = 1): BeatFrame {
if (cfg) this.cfg = cfg
this.updateCount++
// ---- resample onto the fixed 60 Hz grid (rAF jitter immunity) --------
this.feedSampler(onset, nowMs)
// ---- silence hold (v7.15.1) -------------------------------------------
// SILENCE MEANS NO BEAT. While the overall level stays under the enter
// floor for the debounce window the tracker places a HOLD: nothing
// fires (real or predicted), the tempo estimate and confidence freeze
// exactly where they are, and beatphase freezes for scripts. The hold
// is STICKY — only level above the (higher) release floor lets go —
// and the release is immediate, re-anchoring the grid from the phase
// captured when the audio went QUIET (see quietPhase). The sampler/
// ring above keeps running on purpose: it stays honest with real
// (near-zero) data, so nothing needs unwinding on release.
if (level < SILENCE_ENTER_LEVEL) {
if (this.quietSince === null) {
this.quietSince = nowMs
// Snapshot BEFORE this frame's grid processing. If the pause
// landed just before a beat boundary, the pending beat is IMMINENT
// — the audio plays it the moment it returns, so the anchor must
// keep "almost a beat" as the position. (A beat that fires ON this
// frame from the 80 ms flux memory instead shifts the anchor a
// full period — which lands on the same grid positions; either
// way the comb re-aligns within a pull or two.)
this.quietPhase = this.phase
}
if (!this.holdActive && nowMs - this.quietSince >= SILENCE_ENTER_MS) {
this.holdActive = true
}
} else {
if (this.holdActive && level > SILENCE_RELEASE_LEVEL) {
// decisive audio: release now. The grid resumes from the
// silence-start phase — a pause/resume keeps perfect time because
// the audio continues from the position it stopped at; after a
// hard cut the comb + first real beats re-align it within a couple
// of periods. The release itself proves the signal is live, so
// musicLive gets a fresh stamp and the grid may fire immediately.
this.holdActive = false
const resumePhase = this.quietPhase ?? this.phase
if (this.bpm > 0 && Number.isFinite(this.nextBeatAt)) {
this.nextBeatAt = nowMs + (1 - resumePhase) * this.periodMs
}
this.lastActiveAt = nowMs
}
if (!this.holdActive) {
this.quietSince = null
this.quietPhase = null
}
}
// ---- periodic tempo estimate ------------------------------------------
if (this.updateCount % ESTIMATE_EVERY === 0) {
// (skipped entirely while held — learning from silence is a
// contradiction, and its confidence decay would quietly erase the
// lock the hold is supposed to preserve)
if (this.updateCount % ESTIMATE_EVERY === 0 && !this.holdActive) {
let mean = 0
for (const v of this.ring) mean += v
mean /= this.ring.length
@ -418,7 +514,7 @@ export class BeatTracker {
// the grid can cross the beat between rAF samples — classify as a real
// beat when the onset shows anywhere in the last ~80 ms of grid samples
const envNear = this.recentOnset()
if (this.bpm > 0 && Number.isFinite(this.nextBeatAt) && musicLive) {
if (!this.holdActive && this.bpm > 0 && Number.isFinite(this.nextBeatAt) && musicLive) {
if (nowMs >= this.nextBeatAt) {
// background-tab catch-up: rAF paused for seconds → jump the grid
// to the near future instead of machine-gunning missed beats
@ -445,18 +541,27 @@ export class BeatTracker {
}
}
}
if (nowMs - this.lastRealBeatAt > 2500) {
// (confidence decay is suspended while held — the hold is preserving
// the lock, not questioning it)
if (!this.holdActive && nowMs - this.lastRealBeatAt > 2500) {
this.confidence = Math.max(0, this.confidence - dtSec * 0.06)
}
// beatphase for scripts/UI: 0 right after a beat, →1 approaching the next
if (!beat) {
// beatphase for scripts/UI: 0 right after a beat, →1 approaching the
// next. FROZEN while held — pinned to the silence-start phase so the
// ring holds exactly where the music stopped and resumes from there
// with no jump on release (instead of saturating at 1, which would
// read "beat imminent" through the whole silence).
if (!beat && !this.holdActive) {
this.phase = Number.isFinite(this.nextBeatAt)
? 1 - Math.max(0, Math.min(1, (this.nextBeatAt - nowMs) / this.periodMs))
: 0
}
if (this.holdActive && this.quietPhase !== null) {
this.phase = this.quietPhase
}
return { beat, source, bpm: this.bpm, phase: this.phase, confidence: this.confidence }
return { beat, source, bpm: this.bpm, phase: this.phase, confidence: this.confidence, silent: this.holdActive }
}
/**
@ -497,6 +602,12 @@ export class BeatTracker {
* (median of tap intervals) and re-phase the grid to the taps.
*/
tap(nowMs: number): TapResult {
// v7.15.1: a tap is an explicit manual assertion that there IS a beat
// right now — it releases the silence hold outright ("Set Beat" is
// the escape hatch). The detector re-enters the hold only if the
// level stays under the silence floor for the full debounce again.
this.holdActive = false
this.quietSince = null
if (nowMs - this.lastTapAt > TAP_RESET_MS) this.taps = []
this.lastTapAt = nowMs
this.taps.push(nowMs)

View file

@ -92,7 +92,7 @@ pub fn band_level(mags: &[f32], lo_hz: f32, hi_hz: f32, sample_rate: f32) -> f32
{
title: 'Beat detection & prediction',
body: [
'Identical algorithm to the web edition, ported statement for statement into beat.rs. Detection: the smoothed bass must exceed threshold × the rolling 60-frame average, gated by a minimum interval and an absolute floor. Learning: consecutive beat intervals (200–2500 ms) build a median + spread estimate; when the spread is under 0.3 the period locks with an EMA (0.6/0.4 blend) and confidence rises. Prediction: a phase accumulator advances by dt/period every frame -- when the bass drops out for longer than period × 1.4, the predictor keeps firing beats on the learned grid (confidence-gated, slowly spending confidence at 0.02 per predicted beat and decaying at 0.06/s) so the visuals never lose the beat. Tap tempo: the Space bar taps into an 8-tap median, re-phasing the predictor and teaching the interval learner.',
'Identical algorithm to the web edition, ported statement for statement into beat.rs. Detection: the smoothed bass must exceed threshold × the rolling 60-frame average, gated by a minimum interval and an absolute floor. Learning: consecutive beat intervals (200–2500 ms) build a median + spread estimate; when the spread is under 0.3 the period locks with an EMA (0.6/0.4 blend) and confidence rises. Prediction: a phase accumulator advances by dt/period every frame -- when the bass drops out for longer than period × 1.4, the predictor keeps firing beats on the learned grid (confidence-gated, slowly spending confidence at 0.02 per predicted beat and decaying at 0.06/s) so the visuals never lose the beat. Silence hold (v7.15.1): sustained near-digital quiet (level under 0.006 for 300 ms, sticky until a decisive 0.015) freezes detection, prediction, learning and the phase accumulator outright -- no beats fire during silence -- and decisive audio releases the hold, resuming the grid from the frozen phase. Tap tempo: the Space bar taps into an 8-tap median, re-phasing the predictor and teaching the interval learner.',
],
code: [
{

View file

@ -437,10 +437,15 @@ export class AvaEngine {
// pulses to ghost beats when the music is stopped (v6.4).
// v7.1: the detector input is the ONSET (spectral-flux) envelope, not
// the smoothed bass level — see lib/ava/beat.ts and lib/ava/types.ts.
// v7.15.1: the overall level rides along (5th arg) so the tracker can
// place its SILENCE HOLD — a source that is loaded but quiet (pause,
// track gap, stream stall) freezes the grid instead of faking beats;
// decisive audio releases the hold and the grid resumes from the
// frozen phase. Band/waveform data above is untouched by the hold.
const beatFrame =
this.audio.kind === 'idle'
? this.beat.standby()
: this.beat.update(levels.flux, now, dt, this.beatConfig)
: this.beat.update(levels.flux, now, dt, this.beatConfig, levels.vol)
// ---- environment ------------------------------------------------------
const env = this.env
@ -459,6 +464,10 @@ export class AvaEngine {
env.bpm = beatFrame.bpm
env.beatphase = beatFrame.phase
env.beatconf = beatFrame.confidence
// v7.15.1: 1 while the silence hold is active — no beats fire and
// beatphase is frozen, so scripts can dim their beat reactions (the
// waveform / band data still flows; it is just near zero).
env.silent = beatFrame.silent ? 1 : 0
env.beats = (env.beats ?? 0) + (beatFrame.beat ? 1 : 0)
// seconds since the last beat — 0 on the beat frame itself. Paired with
// exp(-beatage * k) it is the classic kick envelope (no per-script

View file

@ -73,6 +73,12 @@ export interface BeatFrame {
phase: number
/** 0..1 confidence of the tempo lock. */
confidence: number
/** v7.15.1 — true while the silence hold is active: the overall level
* has sat under the silence floor for the debounce window, so
* detection AND prediction are frozen (no beats fire, the phase is
* held). Waveform/band data still flows — it is simply near zero,
* because the audio is gone. */
silent: boolean
}
export const DEFAULT_BAND_CONFIG: BandConfig = {

View file

@ -39,6 +39,20 @@ export interface ReleaseNote {
}
export const RELEASE_NOTES: ReleaseNote[] = [
{
version: '7.15.1',
name: 'Silence holds the beat grid',
changes: [
{
kind: 'fixed',
text: 'When the audio goes silent — a pause, the gap between tracks, a stream stalling — the beat tracker used to keep faking beats for the first couple of seconds. It now places an immediate hold: nothing fires, the phase ring freezes where it is, and the tempo, confidence and grid alignment are kept exactly as they were. Waveform and band data still flow; they are just near zero, because the audio is gone.',
},
{
kind: 'added',
text: 'The hold releases the instant real audio returns, and the beat grid continues from the phase it froze at — a pause and resume keeps perfect time. Scripts get a silent variable (1 while held) and the beat indicator shows an amber HOLD badge while it is active.',
},
],
},
{
version: '7.15.0',
name: 'The complete blend mode collection',

View file

@ -12,6 +12,38 @@
// warns in the console when the two drift apart.
//
// History:
// 7.15.1 — the silence hold (user request): "if silence is detected,
// it needs to place an immediate override and hold on the beat
// detection / prediction ... if silence is no longer detected,
// release the hold ... (the waveforms effects should still come
// through but if there is no beat, the visual should stop acting
// like there is a beat)." Sustained near-digital quiet on the
// OVERALL level (< 0.006 for 300 ms — the 3-band mean the engine
// already computes; digital silence reads exactly 0, mastered
// music stays above ~0.05 even between hits) now places a HOLD:
// nothing fires — real or predicted — the tempo estimate and
// confidence freeze exactly where they are (the old path let the
// predictor fake beats for up to ~2 s into a silence while its
// confidence quietly eroded), and beatphase freezes for scripts
// instead of saturating at "beat imminent". The hold is STICKY
// (only a decisive level above 0.015 releases — noise-floor
// wobble never does) and the release is immediate, re-anchoring
// the grid from the phase captured the frame the audio went QUIET
// (before the debounce window, so a pause/resume keeps perfect
// time — the music continues from the exact position it stopped
// at); after a hard cut the comb + first real beats re-align
// within a couple of periods. The 300 ms debounce only has to
// beat the ~800 ms the old flux-domain path needed before it
// started faking beats
// (bassGone = 1.6 periods at 120 BPM), so inter-attack dips in
// sparse masters never trip it. "Set Beat" taps release the hold
// outright — the manual override. Band/waveform data is NOT
// gated: it still flows (and is simply ~0, because the audio is
// gone). Scripts get a silent variable (1 while held) and the
// beat indicator shows an amber HOLD badge while it is active.
// BeatFrame.silent carries the state; rust-agent/src/beat.rs
// ports the same gate (its bass level doubles as the silence
// signal) with the same thresholds.
// 7.15 — the complete blend mode collection (user request): the blend
// header grew from six keywords to a 41-mode registry
// (src/lib/ava/blend.ts) covering every compositing family in the
@ -500,4 +532,4 @@
export const APP_NAME = 'Open Visualization Engine'
export const APP_SHORT = 'OVE'
export const APP_VERSION = '7.15.0'
export const APP_VERSION = '7.15.1'

View file

@ -2466,3 +2466,37 @@ BROWSER E2E (agent-browser, ALL PASS; pixel analysis via in-page getImageData on
Stage Summary:
- v7.15.0 shipped: blend: takes any of 41 keywords (the full Photoshop set, GIMP grain pair, AE stencil/silhouette masks, plus-lighter/plus-darker, Pegtop exotica), case-insensitive with aliases; 21 modes run through a new WebGL compositor (subtract is now a true clamped subtract); behind and clear work honestly on the new alpha framebuffer; the blend variable switches modes per frame by registry number. Every existing preset renders as before (regression-verified headlessly and in-browser); WebGL-less browsers fall back per mode.
Task: v7.15.1 (user request, beat engine session 5): "Next we need to fix the beat detection & prediction. If silence is detected, it needs to place an immediate override and hold on the beat detection / prediction so that it stops reacting the beat measures when there is silence detected. If silence is no longer detected, release the hold on the beat prediction / detection. This way the beat prediction can then continue moving the script with the beat. (the waveforms effects should still come through but if there is no beat, the visual should stop acting like there is a beat.) Let's fix that and then push the update to the git repository." Delivered on the zai-dev staging branch (remote: gitzcreations/openvisengine).
Approach:
- The defect: during silence the tracker kept firing PREDICTED beats for up to ~2-3 s (the flux-domain musicLive window), while its confidence quietly eroded -- the visual kept acting like there was a beat. Diagnosis from the code paths: 'predicted' needs bassGone (1.6 periods since the last real beat ≈ 800 ms at 120 BPM) and musicLive (lastActiveAt < 3 s old), and the inactive-estimate confidence decay (-0.05 per 100 ms pass) takes another ~1.3 s to fall under the 0.35 prediction gate -- so the fake-beat window is roughly [0.8 s, 2.1 s] into any silence.
- The fix is LEVEL-gated, not flux-gated: sustained near-digital quiet on the OVERALL 3-band mean (levels.vol, fed to the tracker as a new 5th update() argument) is unambiguous (digital silence reads exactly 0; mastered music keeps the mean above ~0.05 even between hits), while a held pad (real music, no onsets) still reads well above it -- prediction keeps bridging those, as designed.
- Hold semantics: nothing fires (real or predicted), the tempo estimate is skipped entirely (learning from silence is a contradiction), confidence decay is suspended, and beatphase FREEZES for scripts instead of saturating at 1 ("beat imminent"). The sampler/ring keeps running on real near-zero data so nothing needs unwinding on release.
- Sticky enter / decisive release: engage after 300 ms under 0.006; only level above 0.015 releases (noise-floor wobble never flaps it). The 300 ms debounce only has to beat the ~800 ms the old path needed before faking beats, so inter-attack dips in sparse masters never trip it.
- Phase-true resume: the release re-anchors the grid from the phase captured at the TOP of the FIRST quiet frame -- BEFORE that frame's grid processing, so a pause that lands just before a beat boundary anchors "pulse imminent" (the pending pulse plays the moment the audio returns; the first harness draft captured after the grid rollover and resumed a full beat late -- caught by the assertion, fixed by moving the capture). A pause/resume keeps perfect time; after a hard cut the comb + real beats re-align within a couple of periods. The visible phase pins to the same snapshot at hold entry, so the ring resumes with no jump.
- Taps release the hold outright (Set Beat is the manual escape hatch); silence re-asserts after the debounce if the level stays quiet. BeatFrame.silent carries the state; scripts get env.silent (0/1); the beat indicator shows an amber HOLD badge + amber phase ring while held; standby() reports silent (no source = definitionally no beat).
Work Log:
- beat.ts: SILENCE_* constants; quietSince/quietPhase/holdActive state; the silence gate at the top of update() (before the estimate/grid blocks); estimate + grid firing + both confidence decays + the phase recompute all gated on !holdActive; the phase pin; release re-anchor nextBeatAt = now + (1 - quietPhase) * periodMs + lastActiveAt stamp; tap() hold release; standby silent:true; header doc step 7.
- types.ts: BeatFrame.silent (+ doc). engine.ts: passes levels.vol as the 5th update() arg; env.silent = beatFrame.silent ? 1 : 0 (comment block updated).
- beat-dot.tsx: held state (silent && !idle) -- amber phase ring, dim amber dot, HOLD badge (mirrors the PRED badge), aria-label "held (silence), N BPM frozen".
- help-panel.tsx: silent row in the FRAME_VARS table. version.ts: APP_VERSION 7.15.1 + full history entry. release-notes.ts: 7.15.1 "Silence holds the beat grid" (1 fixed + 1 added, first-person voice). blueprint.ts: silence-hold sentence in the beat.rs blueprint card.
- rust-agent parity (NOT compile-verified -- no cargo in this sandbox, ported by hand statement-for-statement): beat.rs SILENCE_* constants, hold_active/quiet_since/quiet_phase fields, the gate (detector input bass_att doubles as the level signal), detection/predictor/decay gated, accumulator pinned to the silence-start snapshot at hold entry (freezing the dt-driven advance IS the phase hold), tap release, BeatFrame.silent; engine.rs passes ("silent", f64::from(beat_frame.silent)) into the script env.
HEADLESS VALIDATION (/home/z/tmp-validate/hold/validate.mts, REAL BeatTracker, 60 fps synthetic timeline: 12 s music -> 4 s silence (audio clock FREEZES -- true pause semantics) -> 4 s in-phase resume -> 1 s silence -> 2 s onsetless pad (level 0.05) -> silence + tap; 26/26 PASS):
- Lock: 120 BPM ±2.5, confidence > 0.5, ~2 beats/s classified 'audio' (8-12 s).
- First hold: ZERO beats 12.35-16.0 s; at most one in-flight beat inside the debounce window; silent flag up by 12.5 s; beatphase frozen (13/14/15.5 s identical, 0.966 < 0.99 -- not saturated); confidence frozen to 1e-9; bpm frozen.
- Release: silent=false on the very first audio frame (16.0 s); first resumed beat at ~16.01 s == the pause position (the audio's pending pulse, within 0.1 s); 3+ beats in the next 2 s; bpm/confidence survive the hold.
- Pad: hold releases at level 0.05; prediction CONTINUES on the held grid (predicted beats at 21.5/22.0/22.5 s through the onsetless pad) -- "the beat prediction can then continue moving the script with the beat".
- Tap: releases the hold immediately; silence re-asserts after the debounce (by 24.9 s).
- Old-defect proof: the pre-fix fake-beat window 12.8-15.5 s is EMPTY.
- standby(): silent, no beat, bpm 0.
BROWSER E2E (agent-browser, ALL PASS; probe track = generated 13 s WAV, 8 s of 120 BPM kicks + 5 s digital silence, looping as a file source):
- Fresh load: title "OVE -- Open Visualization Engine (v7.15.1)". File upload -> locks 119.5-120.4 BPM.
- Two full loop cycles captured by polling the indicator: music ("120.x BPM", no badge) -> silence ("held (silence), 120.1 BPM frozen" + HOLD badge, NO PRED pulses) -> loop wrap (hold releases on the first frames of music, beats resume, BPM intact) -> second silence (HOLD again) -> second release. The HOLD windows track the file's 5 s silent spans exactly.
- What's New: 7.15.1 "Silence holds the beat grid" present. Help panel: the silent variable row present in the variables table. Console: 0 errors / 0 warnings; dev.log clean (200s only). Test browser closed and the page reloaded afterwards.
Stage Summary:
- v7.15.1 shipped: silence places an immediate HOLD on beat detection + prediction (level < 0.006 for 300 ms; sticky until 0.015) -- nothing fires, learning/confidence/beatphase freeze; decisive audio releases instantly and the grid resumes from the phase the audio went quiet (pause/resume keeps perfect time); taps override; waveform/band data never gated; scripts get silent; the indicator shows HOLD. Headless 26/26 + browser E2E green; pushed to zai-dev.