Phase 1: scaffold + metronome engine + R1
Svelte 5 + Vite static SPA with absolute base and History-API path routing. Core lookahead-scheduler metronome (no drift), AudioContext unlock-on-gesture, synthesized 3-voice clicks, rAF-synced beat highlight. StrumGrid renderer and R1 wired end-to-end with variants, BPM/variant persistence, query overrides, and keyboard shortcuts (space, arrow up/down). Dark amp-light theme tokens. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user