// Web Audio playback of a mono Float32Array signal with position reporting. // The AudioContext is created on the first play, at the signal's sample rate // so that no resampling happens in the browser; if a later signal has a // different rate the context is replaced. export class Player { private ctx: AudioContext | null = null; private source: AudioBufferSourceNode | null = null; private startedAt = 0; // context time at which the source was started private offset = 0; // signal time [s] at which playback started private length = 0; // signal duration [s] private lastPosition = 0; private isPlaying = false; get playing(): boolean { return this.isPlaying; } /** Current position in seconds while playing (from the AudioContext clock), else the last position. */ position(): number { if (this.isPlaying && this.ctx) { const t = this.offset + (this.ctx.currentTime - this.startedAt); return Math.max(0, Math.min(this.length, t)); } return this.lastPosition; } /** Starts playback of the mono signal at `fs` from `startSeconds`; `onEnded` fires when it finishes on its own (not on stop). */ play(samples: Float32Array, fs: number, startSeconds: number, onEnded?: () => void): void { this.stop(); if (samples.length === 0 || !(fs > 0)) return; const ctx = this.context(fs); const buffer = ctx.createBuffer(1, samples.length, fs); buffer.getChannelData(0).set(samples); const src = ctx.createBufferSource(); src.buffer = buffer; src.connect(ctx.destination); this.length = samples.length / fs; this.offset = Math.max(0, Math.min(this.length, startSeconds || 0)); src.onended = () => { if (this.source !== src) return; this.source = null; this.isPlaying = false; this.lastPosition = this.length; onEnded?.(); }; this.source = src; this.isPlaying = true; if (ctx.state === "suspended") void ctx.resume(); this.startedAt = ctx.currentTime; src.start(0, this.offset); } stop(): void { const src = this.source; if (!src) return; this.lastPosition = this.position(); this.source = null; this.isPlaying = false; src.onended = null; try { src.stop(); } catch { // Already stopped. } src.disconnect(); } private context(fs: number): AudioContext { if (this.ctx && this.ctx.sampleRate !== fs) { void this.ctx.close(); this.ctx = null; } if (!this.ctx) { try { this.ctx = new AudioContext({ sampleRate: fs }); } catch { // The browser refused the rate; fall back to its default and let Web Audio resample. this.ctx = new AudioContext(); } } return this.ctx; } }