// 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 } }