openvisengine/src/lib/ava/beat.ts
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

656 lines
28 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 detection, tempo tracking & prediction (v7.1)
//
// The detector is AUTO CORRELATION ON ONSETS — how every serious beat
// tracker works (aubio, librosa, …) — not a level threshold:
//
// 1. ONSET — the engine feeds a full-band spectral-flux envelope (mean
// positive frame-to-frame FFT change). Kicks/snares/hats are
// ATTACKS: they spike the flux; sustained sub-bass reads ~0.
// A level detector cannot see a kick through mastered club
// sub-bass (peak-to-mean caps at ~1.2× → sporadic fires,
// phantom tempi — the 6.x behavior on real music).
// 2. RESAMPLE — the envelope is resampled onto a FIXED 60 Hz time grid
// (linear interpolation between rAF samples) before entering
// the ring. rAF cadence is not uniform in practice — a
// headless/GPU-less browser renders at a jittery 17-83 ms
// cadence — and a frame-indexed ring would smear the
// autocorrelation lags into noise (measured: BPM wandering
// 105-122 on a 133 BPM track, confidence 30%). Uniform grid
// → identical behavior at any render rate or jitter.
// 3. ESTIMATE — every ~100 ms the ring (~4 s) is autocorrelated over
// 300–1000 ms lags (= 60–200 BPM). The best LOCAL MAXIMUM of
// AC × tempo-prior × lock-hysteresis (parabolic-refined) is
// the period candidate; the normalized coefficient r must
// clear the sensitivity gate (noise reads ≈ ±0.07, music
// 0.25–0.8). Two agreeing candidates lock the tempo (three
// once confidently locked — outro noise can't steal it).
// 4. PHASE — a comb filter aligns the grid: the phase whose beat
// positions sample the most onset energy. Dance music
// interleaves two onset trains (kick vs off-beat bass) half a
// period apart and the raw comb flaps between them, so phases
// near the established grid score a 25% bonus (hysteresis);
// a genuinely better phase still wins outright.
// 5. GRID — beats fire at absolute nextBeatAt timestamps; a PLL-style
// pull (≤3% of the period per estimate, 5% deadband) keeps
// 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
// with drift, dense industrial mix) locks in ~2 s, holds the tempo through
// 96% of the track at 92% beat-gap regularity, confidence 0.99; techno and
// hip-hop patterns lock exactly; silence produces zero beats. Known
// limitation: sparse kick-vs-snare backbeats (dnb) may lock at half tempo —
// stable and musically defensible; "Set Beat" corrects it in two taps.
import type { BeatConfig, BeatFrame, BeatSource } from './types'
import { DEFAULT_BEAT_CONFIG } from './types'
/** The fixed analysis grid: 60 samples/s regardless of the render rate. */
const GRID_STEP_MS = 1000 / 60
/** Onset envelope ring: ~4 s of grid samples. */
const RING_SAMPLES = 240
/** Candidate beat periods (ms) — the foldPeriod range (60–200 BPM). */
const LAG_MIN_MS = 300
const LAG_MAX_MS = 1000
/** Re-estimate the period every ~100 ms of grid time. */
const ESTIMATE_EVERY = 6
/** Required autocorrelation coefficient: 0.1 + 0.125 × threshold. */
const acQualityRequired = (threshold: number): number => 0.1 + 0.125 * threshold
/** Lock hysteresis: the current period's AC gets this boost — a section-
* level competitor (683/900 ms metrical levels in dense mixes) needs ~2×
* the AC to steal a confident lock, while a genuine tempo change dominates
* within a few seconds. */
const LOCK_BOOST = 1.25
/** Consecutive agreeing candidates required to (re)lock the tempo. */
const LOCK_AGREE = 0.1
/** Comb phase-hysteresis bonus for phases near the established grid. */
const COMB_BONUS = 0.25
/** Max tolerated sampler gap before resetting the grid (tab-background). */
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
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2
}
/** Fold a period into the 60–200 BPM equivalent range. */
function foldPeriod(ms: number): number {
let p = ms
while (p > LAG_MAX_MS) p /= 2
while (p < LAG_MIN_MS) p *= 2
return p
}
/**
* Gentle log-BPM prior around 125 (σ = 0.45 octaves): prefers the beat
* level over half-tempo (a 900 ms peak needs ~9× the AC of a 450 ms peak
* to win) and over nearby metrical levels (550 ms needs ~10% more) without
* blocking genuinely slow (75 BPM) or fast (200 BPM) tracks. This is a
* deliberate genre bet for a beat-music visualizer — odd-meter genres have
* "Set Beat" as the escape hatch.
*/
function tempoPrior(lagMs: number): number {
const bpm = 60000 / lagMs
return Math.exp(-0.5 * Math.pow(Math.log2(bpm / 125) / 0.45, 2))
}
export interface TapResult {
bpm: number
phase: number
confidence: number
tapCount: number
/** true when enough taps have accumulated to lock a tempo */
locked: boolean
}
export class BeatTracker {
cfg: BeatConfig
/** Uniformly-spaced (GRID_STEP_MS) onset envelope, oldest first. */
private ring: number[] = []
/** Sampler state: the last rAF sample (time + value) feeding the grid,
* plus the ABSOLUTE grid anchor — ticks live at gridOrigin + index × step
* (integer index, no cumulative float drift). */
private prevSampleMs: number | null = null
private prevSampleVal = 0
private gridOrigin = 0
private gridIndex = 0
private periodMs = 500
private nextBeatAt = Number.POSITIVE_INFINITY
private phase = 0
private bpm = 0
private confidence = 0
private lastBeatAt = -1e9
private lastRealBeatAt = -1e9
/** Last grid time where the envelope was live (music present). */
private lastActiveAt = -1e9
private taps: number[] = []
private lastTapAt = -1e9
private beatCount = 0
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
}
reset(): void {
this.ring = []
this.prevSampleMs = null
this.prevSampleVal = 0
this.gridOrigin = 0
this.gridIndex = 0
this.periodMs = 500
this.nextBeatAt = Number.POSITIVE_INFINITY
this.phase = 0
this.bpm = 0
this.confidence = 0
this.lastBeatAt = -1e9
this.lastRealBeatAt = -1e9
this.lastActiveAt = -1e9
this.taps = []
this.lastTapAt = -1e9
this.beatCount = 0
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.
* 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,
}
/**
* Called by the engine every frame while NO audio source is playing or
* loaded (v6.4). Beat detection, the prediction grid and any tapped tempo
* all stop: the indicator goes dark, the BPM readout resets and everything
* learned is dropped — so a stale tempo can never leak ghost beats while
* idle (or into the next track once audio starts again).
*/
standby(): BeatFrame {
this.reset()
return BeatTracker.STANDBY_FRAME
}
getBpm(): number {
return this.bpm
}
/** Envelope (grid-indexed) at `tAgo` grid steps before the newest sample. */
private envelopeAt(tAgo: number): number {
const idx = this.ring.length - 1 - tAgo
const i0 = Math.floor(idx)
const frac = idx - i0
if (i0 < 0) return this.ring[0] ?? 0
if (i0 >= this.ring.length - 1) return this.ring[this.ring.length - 1] ?? 0
return this.ring[i0] * (1 - frac) + this.ring[i0 + 1] * frac
}
/** Newest envelope activity: max over the last few grid steps (~80 ms). */
private recentOnset(): number {
let m = 0
for (let k = this.ring.length - 1; k >= Math.max(0, this.ring.length - 5); k--) {
if (this.ring[k] > m) m = this.ring[k]
}
return m
}
/**
* Resample the incoming rAF-paced envelope samples onto the fixed grid.
* Ticks are anchored at ABSOLUTE grid times (gridOrigin + i × step) and
* pushed by integer index the moment an update's timestamp passes them —
* over any window the tick count is exactly window/step, regardless of
* rAF jitter or floating-point gap noise. (An earlier version anchored
* ticks at prevTime + k×step and tested `<= now`; with real rAF gaps the
* comparison under-fills the ring — missing ticks are never recovered —
* which time-compresses the ring and makes every period estimate read
* short: a 133 BPM track wandering 105-122.) Linear interpolation between
* the previous and current sample; a sampler gap > SAMPLER_RESET_MS (tab
* background) re-anchors the grid instead of back-filling stale ticks.
*/
private feedSampler(onset: number, nowMs: number): void {
if (this.prevSampleMs === null || nowMs - this.prevSampleMs > SAMPLER_RESET_MS) {
this.prevSampleMs = nowMs
this.prevSampleVal = onset
this.gridOrigin = nowMs
this.gridIndex = 0
return
}
const t0 = this.prevSampleMs
const t1 = nowMs
const v0 = this.prevSampleVal
const span = Math.max(1e-6, t1 - t0)
while (this.gridOrigin + this.gridIndex * GRID_STEP_MS <= t1) {
const tickT = this.gridOrigin + this.gridIndex * GRID_STEP_MS
const frac = Math.min(1, Math.max(0, (tickT - t0) / span))
this.ring.push(v0 + (onset - v0) * frac)
this.gridIndex++
}
while (this.ring.length > RING_SAMPLES) this.ring.shift()
this.prevSampleMs = t1
this.prevSampleVal = onset
}
/**
* Autocorrelation of the ring over the candidate lag range. Returns the
* best period (parabolic-refined local maximum of AC × tempo-prior ×
* lock-hysteresis), its normalized coefficient r = AC(peak)/Var(env)
* (scale-free; noise ≈ ±1/√n, periodic music 0.25–0.8), and the raw AC
* curve for the subdivision test below.
*/
private estimatePeriod(): { lagMs: number; quality: number; vals: number[]; lagMin: number } {
const n = this.ring.length
if (n < 90) return { lagMs: 0, quality: 0, vals: [], lagMin: 0 }
let mean = 0
for (const v of this.ring) mean += v
mean /= n
let envVar = 0
for (const v of this.ring) envVar += (v - mean) * (v - mean)
envVar /= n
const lagMin = Math.max(2, Math.round(LAG_MIN_MS / GRID_STEP_MS))
const lagMax = Math.round(LAG_MAX_MS / GRID_STEP_MS)
const vals: number[] = []
for (let lag = lagMin; lag <= lagMax && lag < n - 8; lag++) {
let s = 0
for (let i = lag; i < n; i++) s += (this.ring[i] - mean) * (this.ring[i - lag] - mean)
s /= n - lag
vals.push(s)
}
if (vals.length < 4) return { lagMs: 0, quality: 0, vals, lagMin }
let max = Number.NEGATIVE_INFINITY
for (const v of vals) max = Math.max(max, v)
const quality = envVar > 1e-12 ? max / envVar : 0
if (quality <= 0) return { lagMs: 0, quality: 0, vals, lagMin }
const curLag = this.periodMs / GRID_STEP_MS
let bestIdx = -1
let bestScore = Number.NEGATIVE_INFINITY
for (let i = 1; i < vals.length - 1; i++) {
const isMax = vals[i] >= vals[i - 1] && vals[i] >= vals[i + 1]
if (!isMax) continue
const lag = lagMin + i
let score = vals[i] * tempoPrior(lag * GRID_STEP_MS)
if (Math.abs(lag - curLag) / curLag < LOCK_AGREE) score *= LOCK_BOOST
if (score > bestScore) {
bestScore = score
bestIdx = i
}
}
if (bestIdx < 0 || vals[bestIdx] <= 0) return { lagMs: 0, quality: 0, vals, lagMin }
// parabolic refinement around the chosen peak
const y0 = vals[bestIdx - 1]
const y1 = vals[bestIdx]
const y2 = vals[bestIdx + 1]
const denom = y0 - 2 * y1 + y2
const delta = denom !== 0 ? (0.5 * (y0 - y2)) / denom : 0
const lagRefined = lagMin + bestIdx + Math.max(-0.5, Math.min(0.5, delta))
return { lagMs: lagRefined * GRID_STEP_MS, quality, vals, lagMin }
}
/**
* Comb-filter phase: the beat phase (fraction of the period since the
* last beat) whose grid positions sample the most onset energy. Phases
* near the current grid alignment get a bonus — see COMB_BONUS.
*/
private estimatePhaseFrac(preferPhase?: number): number {
const pFrames = this.periodMs / GRID_STEP_MS
const beats = Math.min(24, Math.floor(this.ring.length / pFrames) - 1)
if (beats < 3) return 0
let bestPhase = 0
let bestVal = Number.NEGATIVE_INFINITY
for (let ph = 0; ph < pFrames; ph += 0.5) {
let s = 0
let cnt = 0
for (let b = 1; b <= beats; b++) {
const tAgo = b * pFrames - ph
if (tAgo >= 0 && tAgo < this.ring.length - 1) {
s += this.envelopeAt(tAgo)
cnt++
}
}
if (cnt < 3) continue
s /= cnt
if (preferPhase !== undefined) {
let d = Math.abs(ph / pFrames - preferPhase)
if (d > 0.5) d = 1 - d
s *= 1 + COMB_BONUS * Math.max(0, 1 - 2 * d)
}
if (s > bestVal) {
bestVal = s
bestPhase = ph
}
}
return bestPhase / pFrames
}
/**
* Advance the tracker one frame.
* @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, 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 ------------------------------------------
// (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
const active = this.ring.length >= 90 && mean > this.cfg.floorLevel
if (active) this.lastActiveAt = nowMs
if (active) {
const est = this.estimatePeriod()
if (est.lagMs > 0 && est.quality >= acQualityRequired(this.cfg.threshold)) {
let cand = foldPeriod(est.lagMs)
// Subdivision preference — the metrical-level resolver. Dense mix
// sections compete between the beat and its multiples: a dominant
// 2× level (900 ms) whose half (450 ms) carries ≥75% of its AC is
// the SAME beat at half tempo → take the half (it then AGREES with
// the beat-level lock instead of fighting it). Mid-level (683 ms)
// competitors halve to a non-agreeing lag, which breaks the
// agreement chain and blocks the steal. The prior gate (≥0.5)
// keeps a true slow song's beat (550 → 272 ms, prior 0.13) from
// ever being mangled.
if (cand > 500) {
const halfLag = Math.round(cand / 2 / GRID_STEP_MS) - est.lagMin
const halfAc = halfLag >= 1 && halfLag < est.vals.length ? est.vals[halfLag] : 0
const fullAc = est.vals[Math.round(cand / GRID_STEP_MS) - est.lagMin] ?? 1
if (halfAc >= 0.75 * fullAc && tempoPrior(cand / 2) >= 0.5) cand = cand / 2
}
if (
this.pendingPeriod !== null &&
Math.abs(cand - this.pendingPeriod) / this.pendingPeriod < LOCK_AGREE
) {
this.pendingCount++
} else {
this.pendingPeriod = cand
this.pendingCount = 1
}
// a confident lock needs extra evidence to jump to a different
// tempo (outros and fills otherwise steal it for a few seconds)
const bigChange = this.bpm > 0 && Math.abs(cand - this.periodMs) / this.periodMs > LOCK_AGREE
const need = bigChange ? 3 : 2
if (this.pendingCount >= need) {
const firstLock = this.bpm === 0
this.periodMs = firstLock ? cand : this.periodMs * 0.75 + cand * 0.25
this.bpm = Math.round((60000 / this.periodMs) * 10) / 10
this.confidence = Math.min(1, this.confidence + 0.2)
// a tempo CHANGE is provisional: cap confidence so that if the
// new period is wrong (a metrical-level competitor stole the
// lock in a dense section), the hysteresis boost decays quickly
// and the true period wins it back — instead of a 13 s wrong
// lock that self-reinforces at confidence 1.0
if (bigChange) this.confidence = Math.min(this.confidence, 0.6)
this.applyCombAlignment(nowMs, firstLock)
this.pendingCount = 0
}
} else {
this.confidence = Math.max(0, this.confidence - 0.05)
}
} else {
this.confidence = Math.max(0, this.confidence - 0.05)
}
}
// ---- grid firing ------------------------------------------------------
let beat = false
let source: BeatSource = 'none'
const musicLive = nowMs - this.lastActiveAt < 3000
// 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.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
if (nowMs - this.nextBeatAt > this.periodMs * 2) {
this.nextBeatAt = nowMs + this.periodMs * 0.5
} else {
this.nextBeatAt += this.periodMs
const bassGone = nowMs - this.lastRealBeatAt > this.periodMs * 1.6
if (nowMs - this.lastBeatAt >= this.cfg.minInterval) {
if (envNear > this.cfg.floorLevel) {
beat = true
source = 'audio'
this.lastRealBeatAt = nowMs
} else if (this.cfg.prediction && this.confidence > 0.35 && bassGone) {
beat = true
source = 'predicted'
}
if (beat) {
this.lastBeatAt = nowMs
this.phase = 0
this.beatCount++
}
}
}
}
}
// (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. 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, silent: this.holdActive }
}
/**
* Anchor/pull the beat grid toward the comb alignment. First lock sets
* the grid outright; afterwards a PLL-style pull (≤3% of the period per
* estimate, 5% deadband) keeps it glued — a hard re-anchor every estimate
* would skip/double beats, and a big pull lets comb noise wobble them.
*/
private applyCombAlignment(nowMs: number, firstLock: boolean): void {
const pFrames = this.periodMs / GRID_STEP_MS
if (this.ring.length / pFrames < 4) {
if (firstLock) this.nextBeatAt = nowMs + this.periodMs
return
}
const gridPhase = Number.isFinite(this.nextBeatAt)
? 1 - Math.max(0, Math.min(1, (this.nextBeatAt - nowMs) / this.periodMs))
: undefined
const phFrac = this.estimatePhaseFrac(gridPhase)
// phFrac = fraction of the period since the LAST beat → the next beat
// is exactly one phFrac away. (Mirror trap: computing now − ph + P
// instead anchors the grid at a reflected, arbitrary phase.)
const combNext = nowMs + phFrac * this.periodMs
if (firstLock || !Number.isFinite(this.nextBeatAt)) {
this.nextBeatAt = combNext
} else {
let d = combNext - this.nextBeatAt
while (d > this.periodMs / 2) d -= this.periodMs
while (d < -this.periodMs / 2) d += this.periodMs
if (Math.abs(d) > this.periodMs * 0.05) {
this.nextBeatAt += Math.max(-this.periodMs * 0.03, Math.min(this.periodMs * 0.03, d))
}
}
if (this.nextBeatAt <= nowMs) this.nextBeatAt += this.periodMs
}
/**
* "Set Beat" — user taps to the music. Two or more taps lock the tempo
* (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)
if (this.taps.length > MAX_TAPS) this.taps.shift()
// immediate visual feedback + re-phase
this.beatCount++
this.lastBeatAt = nowMs
this.lastRealBeatAt = nowMs
this.lastActiveAt = nowMs
this.phase = 0
if (this.taps.length >= 2) {
const gaps: number[] = []
for (let k = 1; k < this.taps.length; k++) gaps.push(this.taps[k] - this.taps[k - 1])
const med = median(gaps)
if (med >= 250 && med <= 1500) {
this.periodMs = foldPeriod(med)
this.bpm = Math.round((60000 / this.periodMs) * 10) / 10
this.confidence = Math.min(1, 0.45 + 0.11 * this.taps.length)
// re-anchor the grid one period out (next tapped beat position);
// the comb pull refines it from the audio
this.nextBeatAt = nowMs + this.periodMs
// seed the pending latch so the next audio estimate that agrees
// commits immediately instead of restarting from scratch
this.pendingPeriod = this.periodMs
this.pendingCount = 1
}
} else {
this.confidence = Math.max(this.confidence, 0.25)
}
return {
bpm: this.bpm,
phase: this.phase,
confidence: this.confidence,
tapCount: this.taps.length,
locked: this.taps.length >= 2 && this.bpm > 0,
}
}
/** Current phase (0..1) — for UI indicators. */
getPhase(): number {
return this.phase
}
}