// Web Audio playback of a Float32Array signal with position reporting. export class Player { private ctx: AudioContext | null = null; private source: AudioBufferSourceNode | null = null; private startedAt = 0; private offset = 0; private length = 0; onEnded: (() => void) | null = null; get playing(): boolean { return this.source !== null; } /** Current playback position [s], or null when stopped. */ position(): number | null { if (!this.source || !this.ctx) return null; return Math.min(this.length, this.offset + this.ctx.currentTime - this.startedAt); } play(samples: Float32Array, fs: number, offset = 0, peak = 0.9): void { this.stop(); if (samples.length === 0) return; if (!this.ctx) this.ctx = new AudioContext({ sampleRate: fs }); const ctx = this.ctx; const buffer = ctx.createBuffer(1, samples.length, fs); const ch = buffer.getChannelData(0); let max = 0; for (let k = 0; k < samples.length; k++) max = Math.max(max, Math.abs(samples[k])); const gain = max > 0 ? peak / max : 0; for (let k = 0; k < samples.length; k++) ch[k] = samples[k] * gain; const src = ctx.createBufferSource(); src.buffer = buffer; src.connect(ctx.destination); src.onended = () => { if (this.source === src) { this.source = null; this.onEnded?.(); } }; this.length = samples.length / fs; this.offset = Math.max(0, Math.min(offset, this.length)); this.startedAt = ctx.currentTime; this.source = src; src.start(0, this.offset); void ctx.resume(); } stop(): void { if (this.source) { const s = this.source; this.source = null; try { s.stop(); } catch { /* already stopped */ } } } }