// Playback of the part of the signal that exists so far, through Web Audio, and WAV export. export class Player { private ctx: AudioContext | null = null; private gain: GainNode | null = null; private source: AudioBufferSourceNode | null = null; private startedAt = 0; private offset = 0; private length = 0; private fs = 44100; playing = false; onEnded: (() => void) | null = null; private ensure() { if (!this.ctx) { this.ctx = new AudioContext(); this.gain = this.ctx.createGain(); this.gain.connect(this.ctx.destination); } return this.ctx; } setGainDb(db: number) { if (this.gain) this.gain.gain.value = Math.pow(10, db / 20); this.gainDb = db; } private gainDb = -12; /** Play ``samples`` (already scaled to [-1, 1]) from ``fromSec``; the sound stops where the samples end. */ play(samples: Float32Array, fs: number, fromSec: number) { this.stop(); const ctx = this.ensure(); this.setGainDb(this.gainDb); if (ctx.state === "suspended") void ctx.resume(); const from = Math.min(Math.max(0, Math.floor(fromSec * fs)), samples.length); const part = samples.subarray(from); if (part.length === 0) return; const buf = ctx.createBuffer(1, part.length, fs); buf.copyToChannel(Float32Array.from(part), 0); const src = ctx.createBufferSource(); src.buffer = buf; src.connect(this.gain!); src.onended = () => { if (this.source === src) { this.playing = false; this.source = null; this.onEnded?.(); } }; src.start(); this.source = src; this.startedAt = ctx.currentTime; this.offset = from / fs; this.length = samples.length / fs; this.fs = fs; this.playing = true; } stop() { if (this.source) { const s = this.source; this.source = null; try { s.stop(); } catch { /* already stopped */ } } this.playing = false; } /** Current playback position in seconds (or the last position when stopped). */ position(): number { if (!this.playing || !this.ctx) return this.offset; return Math.min(this.offset + this.ctx.currentTime - this.startedAt, this.length); } get sampleRate() { return this.fs; } } /** Scale a pressure signal for playback: peak at 0.9 full scale (normalize) or 1 Pa = 0.05 FS. */ export function scaleForPlayback(p: Float32Array, normalize: boolean): Float32Array { let peak = 0; for (const x of p) peak = Math.max(peak, Math.abs(x)); const s = normalize ? (peak > 0 ? 0.9 / peak : 0) : 0.05; const out = new Float32Array(p.length); for (let i = 0; i < p.length; i++) out[i] = Math.max(-1, Math.min(1, p[i] * s)); return out; } /** 16-bit PCM mono WAV. */ export function encodeWav(samples: Float32Array, fs: number): Blob { const n = samples.length, buf = new ArrayBuffer(44 + 2 * n), v = new DataView(buf); const str = (o: number, s: string) => { for (let i = 0; i < s.length; i++) v.setUint8(o + i, s.charCodeAt(i)); }; str(0, "RIFF"); v.setUint32(4, 36 + 2 * n, true); str(8, "WAVE"); str(12, "fmt "); v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, 1, true); v.setUint32(24, fs, true); v.setUint32(28, fs * 2, true); v.setUint16(32, 2, true); v.setUint16(34, 16, true); str(36, "data"); v.setUint32(40, 2 * n, true); for (let i = 0; i < n; i++) v.setInt16(44 + 2 * i, Math.round(Math.max(-1, Math.min(1, samples[i])) * 32767), true); return new Blob([buf], { type: "audio/wav" }); }