const LEAD_SECONDS = 0.06;
const MAX_AHEAD_SECONDS = 0.25;
export function audioSupported() {
return typeof globalThis.AudioContext === "function";
}
export class Speaker {
constructor() {
this.ctx = null;
this.gain = null;
this.playhead = 0;
this.starved = 0;
this.volume = 0.7;
}
get playing() {
return this.ctx !== null && this.ctx.state === "running";
}
get rate() {
return this.ctx ? this.ctx.sampleRate : 0;
}
enable() {
if (!audioSupported()) return false;
if (!this.ctx) {
this.ctx = new AudioContext();
this.gain = this.ctx.createGain();
this.gain.gain.value = this.volume;
this.gain.connect(this.ctx.destination);
}
this.ctx.resume().catch(() => {});
this.playhead = 0;
return true;
}
disable() {
if (!this.ctx) return;
const ctx = this.ctx;
this.ctx = null;
this.gain = null;
this.playhead = 0;
ctx.close().catch(() => {});
}
setVolume(value) {
this.volume = Math.min(1, Math.max(0, value));
if (this.gain) this.gain.gain.value = this.volume;
}
silence() {
this.playhead = 0;
}
push(emu) {
const frames = emu.audioFrames();
if (frames === 0) return 0;
if (!this.playing) {
emu.audioConsume(frames);
return 0;
}
const ctx = this.ctx;
const now = ctx.currentTime;
if (this.playhead > now + MAX_AHEAD_SECONDS) {
this.starved += 1;
emu.audioConsume(frames);
return 0;
}
const channels = Math.max(1, emu.audioChannels);
const block = ctx.createBuffer(channels, frames, this.rate);
const interleaved = emu.audioView(frames);
if (channels === 1) {
block.copyToChannel(interleaved, 0);
} else {
for (let ch = 0; ch < channels; ch++) {
const plane = new Float32Array(frames);
for (let i = 0; i < frames; i++) plane[i] = interleaved[i * channels + ch];
block.copyToChannel(plane, ch);
}
}
emu.audioConsume(frames);
const source = ctx.createBufferSource();
source.buffer = block;
source.connect(this.gain);
if (this.playhead < now + LEAD_SECONDS / 2) this.playhead = now + LEAD_SECONDS;
source.start(this.playhead);
this.playhead += frames / this.rate;
return frames;
}
}