/** * One-shot playback of a sample range with a playhead callback, shared by * the explorer (runs) and the clip editor (recorded clips). Only one * range plays per Player; starting again stops what was playing. */ export class Player { private ctx: () => AudioContext; private cur: { src: AudioBufferSourceNode; ctxT0: number; startT: number; endT: number; raf: number } | null = null; /** Playhead position in seconds while playing, then null once stopped. */ onTick: ((t: number | null) => void) | null = null; /** Fired on every start and stop, for button labels. */ onStateChange: (() => void) | null = null; constructor(ctx: () => AudioContext) { this.ctx = ctx; } get playing(): boolean { return this.cur !== null; } stop(): void { if (!this.cur) return; cancelAnimationFrame(this.cur.raf); try { this.cur.src.stop(); } catch { /* already ended */ } this.cur = null; this.onTick?.(null); this.onStateChange?.(); } /** Play samples[startT*sr .. endT*sr); times are in seconds. */ start(samples: Float32Array, sr: number, startT: number, endT: number): void { this.stop(); const s0 = Math.max(0, Math.round(startT * sr)); const s1 = Math.min(samples.length, Math.round(endT * sr)); if (s1 - s0 < 1) return; const ctx = this.ctx(); const buf = ctx.createBuffer(1, s1 - s0, sr); buf.getChannelData(0).set(samples.subarray(s0, s1)); const src = ctx.createBufferSource(); src.buffer = buf; src.connect(ctx.destination); src.onended = () => { if (this.cur?.src === src) this.stop(); }; const begin = () => { src.start(); this.cur = { src, ctxT0: ctx.currentTime, startT, endT, raf: 0 }; const tick = () => { if (this.cur?.src !== src) return; this.onTick?.(Math.min(this.cur.endT, this.cur.startT + (ctx.currentTime - this.cur.ctxT0))); this.cur.raf = requestAnimationFrame(tick); }; this.cur.raf = requestAnimationFrame(tick); this.onStateChange?.(); }; if (ctx.state === 'suspended') void ctx.resume().then(begin); else begin(); } }