242 lines
7.8 KiB
TypeScript
Executable file
242 lines
7.8 KiB
TypeScript
Executable file
// AVA — beat detection, tempo tracking & prediction
|
||
//
|
||
// Strategy (mirrors the planned Rust implementation):
|
||
// 1. DETECT — a beat fires when the smoothed bass energy exceeds
|
||
// `threshold ×` the rolling average (last ~60 frames),
|
||
// gated by `minInterval` and an absolute floor.
|
||
// 2. LEARN — consecutive beat intervals build a BPM estimate; when the
|
||
// intervals are consistent (low spread), the period is locked
|
||
// with an exponential moving average and confidence rises.
|
||
// 3. PREDICT — a phase accumulator advances by dt/period every frame. When
|
||
// the bass drops out, the predictor keeps firing beats on the
|
||
// learned grid so the visuals never lose the beat.
|
||
// 4. TAP — the "Set Beat" button: taps lock the period + phase from the
|
||
// tap intervals (median), teaching the tracker the song's beat.
|
||
|
||
import type { BeatConfig, BeatFrame, BeatSource } from './types'
|
||
import { DEFAULT_BEAT_CONFIG } from './types'
|
||
|
||
const HISTORY_SIZE = 60
|
||
const MAX_INTERVALS = 10
|
||
const MAX_TAPS = 8
|
||
const TAP_RESET_MS = 2500
|
||
|
||
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
|
||
}
|
||
|
||
function foldPeriod(ms: number): number {
|
||
// keep the period inside 60..200 BPM equivalent
|
||
let p = ms
|
||
while (p > 1000) p /= 2
|
||
while (p < 300) p *= 2
|
||
return p
|
||
}
|
||
|
||
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
|
||
private history: number[] = []
|
||
private intervals: number[] = []
|
||
private taps: number[] = []
|
||
private lastTapAt = -1e9
|
||
private periodMs = 500
|
||
private phase = 0
|
||
private bpm = 0
|
||
private confidence = 0
|
||
private lastBeatAt = -1e9
|
||
private lastRealBeatAt = -1e9
|
||
private prevRealBeatAt = -1e9
|
||
private beatCount = 0
|
||
|
||
constructor(cfg: BeatConfig = { ...DEFAULT_BEAT_CONFIG }) {
|
||
this.cfg = cfg
|
||
}
|
||
|
||
reset(): void {
|
||
this.history = []
|
||
this.intervals = []
|
||
this.taps = []
|
||
this.periodMs = 500
|
||
this.phase = 0
|
||
this.bpm = 0
|
||
this.confidence = 0
|
||
this.lastBeatAt = -1e9
|
||
this.lastRealBeatAt = -1e9
|
||
this.prevRealBeatAt = -1e9
|
||
this.beatCount = 0
|
||
}
|
||
|
||
getBeatCount(): number {
|
||
return this.beatCount
|
||
}
|
||
|
||
/** Constant frame returned while no audio source is playing/loaded. */
|
||
private static readonly STANDBY_FRAME: BeatFrame = {
|
||
beat: false,
|
||
source: 'none',
|
||
bpm: 0,
|
||
phase: 0,
|
||
confidence: 0,
|
||
}
|
||
|
||
/**
|
||
* 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
|
||
}
|
||
|
||
/**
|
||
* Advance the tracker one frame.
|
||
* @param bassAtt smoothed bass level (the detector input)
|
||
* @param nowMs monotonic clock in milliseconds (performance.now())
|
||
* @param dtSec frame delta in seconds
|
||
*/
|
||
update(bassAtt: number, nowMs: number, dtSec: number, cfg?: BeatConfig): BeatFrame {
|
||
if (cfg) this.cfg = cfg
|
||
|
||
// --- rolling energy history -------------------------------------------
|
||
this.history.push(bassAtt)
|
||
if (this.history.length > HISTORY_SIZE) this.history.shift()
|
||
let sum = 0
|
||
for (let k = 0; k < this.history.length; k++) sum += this.history[k]
|
||
const avg = sum / this.history.length
|
||
|
||
let beat = false
|
||
let source: BeatSource = 'none'
|
||
|
||
// --- real beat detection ----------------------------------------------
|
||
if (
|
||
bassAtt > this.cfg.floorLevel &&
|
||
bassAtt > avg * this.cfg.threshold &&
|
||
nowMs - this.lastBeatAt >= this.cfg.minInterval
|
||
) {
|
||
beat = true
|
||
source = 'audio'
|
||
this.lastBeatAt = nowMs
|
||
this.lastRealBeatAt = nowMs
|
||
this.beatCount++
|
||
|
||
// interval learning
|
||
if (this.prevRealBeatAt > 0) {
|
||
const interval = nowMs - this.prevRealBeatAt
|
||
if (interval >= 200 && interval <= 2500) {
|
||
this.intervals.push(interval)
|
||
if (this.intervals.length > MAX_INTERVALS) this.intervals.shift()
|
||
if (this.intervals.length >= 3) {
|
||
const med = median(this.intervals)
|
||
const mean = this.intervals.reduce((a, b) => a + b, 0) / this.intervals.length
|
||
const variance = this.intervals.reduce((a, b) => a + (b - mean) * (b - mean), 0) / this.intervals.length
|
||
const spread = Math.sqrt(variance) / Math.max(mean, 1)
|
||
if (spread < 0.3) {
|
||
// consistent tempo: lock the period (EMA) and gain confidence
|
||
this.periodMs = this.periodMs * 0.6 + foldPeriod(med) * 0.4
|
||
this.bpm = Math.round((60000 / this.periodMs) * 10) / 10
|
||
this.confidence = Math.min(1, this.confidence + 0.2)
|
||
} else {
|
||
// irregular — trust it less
|
||
this.confidence = Math.max(0, this.confidence - 0.05)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
this.prevRealBeatAt = nowMs
|
||
this.phase = 0
|
||
}
|
||
|
||
// --- predictor (phase-locked grid) -------------------------------------
|
||
if (this.periodMs > 50) {
|
||
this.phase += (dtSec * 1000) / this.periodMs
|
||
}
|
||
if (this.phase >= 1) {
|
||
this.phase -= Math.floor(this.phase)
|
||
const bassGone = nowMs - this.lastRealBeatAt > this.periodMs * 1.4
|
||
if (
|
||
!beat &&
|
||
this.cfg.prediction &&
|
||
this.confidence > 0.35 &&
|
||
bassGone &&
|
||
nowMs - this.lastBeatAt >= this.cfg.minInterval
|
||
) {
|
||
beat = true
|
||
source = 'predicted'
|
||
this.lastBeatAt = nowMs
|
||
this.beatCount++
|
||
// predicted beats slowly spend confidence so a stale lock fades out
|
||
this.confidence = Math.max(0, this.confidence - 0.02)
|
||
}
|
||
}
|
||
|
||
// --- confidence decay when audio beats disappear -----------------------
|
||
if (nowMs - this.lastRealBeatAt > 2000 && this.taps.length === 0) {
|
||
this.confidence = Math.max(0, this.confidence - dtSec * 0.06)
|
||
}
|
||
|
||
return { beat, source, bpm: this.bpm, phase: this.phase, confidence: this.confidence }
|
||
}
|
||
|
||
/**
|
||
* "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.
|
||
*/
|
||
tap(nowMs: number): TapResult {
|
||
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.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)
|
||
// keep the interval learner in sync with the tapped grid
|
||
this.intervals = [...gaps.slice(-MAX_INTERVALS)]
|
||
this.lastRealBeatAt = nowMs
|
||
}
|
||
} 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
|
||
}
|
||
}
|