1// Web Audio playback of a Float32Array signal with position reporting.
3export class Player {
4 private ctx: AudioContext | null = null;
5 private source: AudioBufferSourceNode | null = null;
6 private startedAt = 0;
7 private offset = 0;
8 private length = 0;
9 onEnded: (() => void) | null = null;
11 get playing(): boolean {
12 return this.source !== null;
13 }
15 /** Current playback position [s], or null when stopped. */
16 position(): number | null {
17 if (!this.source || !this.ctx) return null;
18 return Math.min(this.length, this.offset + this.ctx.currentTime - this.startedAt);
19 }
21 play(samples: Float32Array, fs: number, offset = 0, peak = 0.9): void {
22 this.stop();
23 if (samples.length === 0) return;
24 if (!this.ctx) this.ctx = new AudioContext({ sampleRate: fs });
25 const ctx = this.ctx;
26 const buffer = ctx.createBuffer(1, samples.length, fs);
27 const ch = buffer.getChannelData(0);
28 let max = 0;
29 for (let k = 0; k < samples.length; k++) max = Math.max(max, Math.abs(samples[k]));
30 const gain = max > 0 ? peak / max : 0;
31 for (let k = 0; k < samples.length; k++) ch[k] = samples[k] * gain;
32 const src = ctx.createBufferSource();
33 src.buffer = buffer;
34 src.connect(ctx.destination);
35 src.onended = () => {
36 if (this.source === src) {
37 this.source = null;
38 this.onEnded?.();
39 }
40 };
41 this.length = samples.length / fs;
42 this.offset = Math.max(0, Math.min(offset, this.length));
43 this.startedAt = ctx.currentTime;
44 this.source = src;
45 src.start(0, this.offset);
46 void ctx.resume();
47 }
49 stop(): void {
50 if (this.source) {
51 const s = this.source;
52 this.source = null;
53 try { s.stop(); } catch { /* already stopped */ }
54 }
55 }
56}