@hiddentao/clockwork-engine
Version:
A TypeScript/PIXI.js game engine for deterministic, replayable games with built-in rendering
141 lines (140 loc) • 4.01 kB
JavaScript
/**
* Web Audio Layer
*
* Web Audio API-based audio implementation.
*/
import { AudioContextState } from "../AudioLayer";
export class WebAudioLayer {
constructor() {
this.context = null;
this.buffers = new Map();
this.activeSources = new Map();
this.isClosed = false;
this.hasResumed = false;
}
async resumeWithTimeout(maxWaitMs = 2000) {
if (!this.context) {
return;
}
await this.context.resume();
const startTime = Date.now();
while (this.context.state === AudioContextState.SUSPENDED &&
Date.now() - startTime < maxWaitMs) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
}
async initialize() {
if (this.context) {
return;
}
this.context = new AudioContext();
}
destroy() {
if (!this.context) {
this.isClosed = true;
return;
}
this.stopAll();
this.context.close();
this.context = null;
this.buffers.clear();
this.activeSources.clear();
this.isClosed = true;
}
async loadSound(id, data) {
if (!this.context) {
return;
}
if (typeof data === "string") {
const response = await fetch(data);
data = await response.arrayBuffer();
}
if (data.byteLength === 0) {
const emptyBuffer = this.createBuffer(1, 1, 44100);
this.buffers.set(id, emptyBuffer);
return;
}
try {
const audioBuffer = await this.context.decodeAudioData(data);
this.buffers.set(id, audioBuffer);
}
catch (error) {
console.warn(`Failed to decode audio data for ${id}:`, error);
}
}
createBuffer(channels, length, sampleRate) {
if (!this.context) {
throw new Error("AudioContext not initialized");
}
return this.context.createBuffer(channels, length, sampleRate);
}
loadSoundFromBuffer(id, buffer) {
this.buffers.set(id, buffer);
}
playSound(id, volume = 1.0, loop = false) {
if (!this.context) {
return;
}
const buffer = this.buffers.get(id);
if (!buffer) {
return;
}
const source = this.context.createBufferSource();
source.buffer = buffer;
source.loop = loop;
const gainNode = this.context.createGain();
gainNode.gain.value = volume;
source.connect(gainNode);
gainNode.connect(this.context.destination);
source.start();
if (!this.activeSources.has(id)) {
this.activeSources.set(id, []);
}
this.activeSources.get(id).push(source);
source.onended = () => {
const sources = this.activeSources.get(id);
if (sources) {
const index = sources.indexOf(source);
if (index !== -1) {
sources.splice(index, 1);
}
}
};
}
stopSound(id) {
const sources = this.activeSources.get(id);
if (!sources) {
return;
}
for (const source of sources) {
try {
source.stop();
}
catch {
// Ignore errors from already stopped sources
}
}
this.activeSources.delete(id);
}
stopAll() {
for (const id of this.activeSources.keys()) {
this.stopSound(id);
}
}
async tryResumeOnce() {
if (this.hasResumed) {
return;
}
this.hasResumed = true;
await this.resumeWithTimeout();
}
getState() {
if (this.isClosed) {
return AudioContextState.CLOSED;
}
if (!this.context) {
return AudioContextState.SUSPENDED;
}
return this.context.state;
}
}