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).
656 lines
28 KiB
TypeScript
Executable file
656 lines
28 KiB
TypeScript
Executable file
// 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
|
||
}
|
||
}
|