UNPKG

@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
/** * 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; } }