// Reactive wrapper around the (framework-agnostic) Metronome. Exposes $state for // bpm / isPlaying / currentStep so Svelte components stay in sync, and drives the // visual beat via requestAnimationFrame draining the engine's scheduled queue. import { Metronome, type MetroPattern } from './Metronome' import { audio } from './AudioEngine' export const BPM_MIN = 30 export const BPM_MAX = 300 export function clampBpm(n: number): number { return Math.max(BPM_MIN, Math.min(BPM_MAX, Math.round(n))) } export class MetronomeController { private metro = new Metronome() bpm = $state(90) isPlaying = $state(false) /** Current step index within the bar, or -1 when stopped. */ currentStep = $state(-1) private raf = 0 constructor(bpm: number) { this.bpm = clampBpm(bpm) this.metro.setBpm(this.bpm) } setPattern(p: MetroPattern): void { this.metro.setPattern(p) } setBpm(n: number): void { this.bpm = clampBpm(n) this.metro.setBpm(this.bpm) } nudgeBpm(delta: number): void { this.setBpm(this.bpm + delta) } async play(): Promise { if (this.isPlaying) return await audio.unlock() this.metro.start() this.isPlaying = true this.tick() } stop(): void { this.metro.stop() this.isPlaying = false cancelAnimationFrame(this.raf) this.currentStep = -1 } async toggle(): Promise { if (this.isPlaying) this.stop() else await this.play() } private tick = (): void => { const q = this.metro.scheduledQueue const now = audio.currentTime while (q.length && q[0].time <= now) { const s = q.shift() if (s) this.currentStep = s.step } if (this.isPlaying) this.raf = requestAnimationFrame(this.tick) } destroy(): void { this.stop() } }