class PCM16PlaybackProcessor extends AudioWorkletProcessor { constructor(options) { super(); const config = options.processorOptions || {}; this.sourceSampleRate = Number(config.sourceSampleRate || 16000); this.prebufferSamples = Math.max(1, Math.round(this.sourceSampleRate * Number(config.prebufferMs || 120) / 1000)); this.capacity = Math.max(this.sourceSampleRate * 30, this.prebufferSamples * 4); this.ring = new Float32Array(this.capacity); this.readPosition = 0; this.writePosition = 0; this.playing = false; this.ended = false; this.drained = false; this.lastOutput = 0; this.fadeInRemaining = 0; this.fadeFrames = Math.max(1, Math.round(sampleRate * 0.005)); this.underflows = 0; this.port.onmessage = (message) => this.onMessage(message.data || {}); } onMessage(message) { if (message.type === "reset") { this.reset(); return; } if (message.type === "end") { this.ended = true; return; } if (message.type !== "push" || !(message.samples instanceof ArrayBuffer)) return; const samples = new Float32Array(message.samples); if (this.drained) this.reset(); this.ended = false; for (let index = 0; index < samples.length; index += 1) { if (this.writePosition - Math.floor(this.readPosition) >= this.capacity - 1) { this.readPosition = this.writePosition - this.capacity + 2; } this.ring[this.writePosition % this.capacity] = samples[index]; this.writePosition += 1; } } reset() { this.readPosition = 0; this.writePosition = 0; this.playing = false; this.ended = false; this.drained = false; this.lastOutput = 0; this.fadeInRemaining = 0; } available() { return this.writePosition - this.readPosition; } startWhenReady() { if (this.playing) return true; const available = this.available(); if (available >= this.prebufferSamples || (this.ended && available > 1)) { this.playing = true; this.fadeInRemaining = this.fadeFrames; this.port.postMessage({ type: "started", bufferedSamples: Math.floor(available) }); return true; } return false; } finish(output, fromIndex, eventType) { const remaining = Math.min(this.fadeFrames, output.length - fromIndex); for (let index = 0; index < remaining; index += 1) { output[fromIndex + index] = this.lastOutput * (1 - (index + 1) / remaining); } output.fill(0, fromIndex + remaining); this.lastOutput = 0; this.playing = false; if (eventType === "drained") { if (!this.drained) this.port.postMessage({ type: "drained" }); this.drained = true; } else { this.underflows += 1; this.port.postMessage({ type: "underflow", count: this.underflows }); } } process(_inputs, outputs) { const output = outputs[0]?.[0]; if (!output) return true; output.fill(0); if (!this.startWhenReady()) return true; const sourceStep = this.sourceSampleRate / sampleRate; for (let index = 0; index < output.length; index += 1) { const base = Math.floor(this.readPosition); if (base + 1 >= this.writePosition) { this.finish(output, index, this.ended ? "drained" : "underflow"); break; } const fraction = this.readPosition - base; const first = this.ring[base % this.capacity]; const second = this.ring[(base + 1) % this.capacity]; let value = first + (second - first) * fraction; if (this.fadeInRemaining > 0) { value *= 1 - this.fadeInRemaining / this.fadeFrames; this.fadeInRemaining -= 1; } output[index] = value; this.lastOutput = value; this.readPosition += sourceStep; } return true; } } registerProcessor("pcm16-playback", PCM16PlaybackProcessor);