type DuckReason = 'qte' | 'choice' | 'hotspot' export class AudioSystem { private ctx: AudioContext | null = null private gainNode: GainNode | null = null private currentSource: AudioBufferSourceNode | null = null private currentUrl = '' private currentVolume = 0.8 private bufferCache: Map = new Map() private maxCache = 3 private duckCount: Map = new Map() private duckLevel = 0.35 private duckFade = 0.5 private getCtx(): AudioContext { if (!this.ctx) { this.ctx = new AudioContext() this.gainNode = this.ctx.createGain() this.gainNode.connect(this.ctx.destination) } if (this.ctx.state === 'suspended') { this.ctx.resume() } return this.ctx } async play( url: string, volume: number, crossFade: number, duckLvl?: number, duckFd?: number, ) { if (duckLvl !== undefined) this.duckLevel = duckLvl if (duckFd !== undefined) this.duckFade = duckFd this.currentVolume = volume if (url === this.currentUrl) { const target = this.duckTarget() if (this.gainNode) { this.gainNode.gain.cancelScheduledValues(this.getCtx().currentTime) this.gainNode.gain.setValueAtTime(this.gainNode.gain.value, this.getCtx().currentTime) this.gainNode.gain.exponentialRampToValueAtTime( Math.max(0.001, target), this.getCtx().currentTime + 0.3, ) } return } const prevSource = this.currentSource this.currentUrl = url const buffer = await this.loadBuffer(url) if (!buffer) return const ctx = this.getCtx() const gain = this.gainNode! const target = this.duckTarget() if (prevSource) { gain.gain.cancelScheduledValues(ctx.currentTime) gain.gain.setValueAtTime(gain.gain.value, ctx.currentTime) gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + crossFade) try { prevSource.stop(ctx.currentTime + crossFade + 0.1) } catch {} } const source = ctx.createBufferSource() source.buffer = buffer source.loop = true source.connect(gain) gain.gain.cancelScheduledValues(ctx.currentTime) gain.gain.setValueAtTime(0.001, ctx.currentTime) gain.gain.exponentialRampToValueAtTime( Math.max(0.001, target), ctx.currentTime + crossFade, ) source.start(0) this.currentSource = source } async stop(fadeOut: number) { if (!this.currentSource || !this.gainNode) return const ctx = this.getCtx() this.currentUrl = '' const gain = this.gainNode try { gain.gain.cancelScheduledValues(ctx.currentTime) gain.gain.setValueAtTime(gain.gain.value, ctx.currentTime) gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + fadeOut) const src = this.currentSource setTimeout(() => { try { src.stop() } catch {} }, (fadeOut + 0.1) * 1000) } catch {} this.currentSource = null } duckOn(reason: DuckReason) { this.duckCount.set(reason, (this.duckCount.get(reason) ?? 0) + 1) this.applyDuck() } duckOff(reason: DuckReason) { const cur = this.duckCount.get(reason) ?? 0 if (cur <= 1) { this.duckCount.delete(reason) } else { this.duckCount.set(reason, cur - 1) } this.applyDuck() } private applyDuck() { if (!this.gainNode || !this.currentSource) return const target = this.duckTarget() const ctx = this.getCtx() const dt = this.duckCount.size > 0 ? this.duckFade : this.duckFade this.gainNode.gain.cancelScheduledValues(ctx.currentTime) this.gainNode.gain.setValueAtTime(this.gainNode.gain.value, ctx.currentTime) this.gainNode.gain.exponentialRampToValueAtTime( Math.max(0.001, target), ctx.currentTime + dt, ) } private duckTarget(): number { if (this.duckCount.size > 0) { return this.currentVolume * this.duckLevel } return this.currentVolume } setVolumeRaw(v: number) { if (!this.gainNode) return const ctx = this.getCtx() this.gainNode.gain.cancelScheduledValues(ctx.currentTime) this.gainNode.gain.setValueAtTime(v, ctx.currentTime) } private async loadBuffer(url: string): Promise { const cached = this.bufferCache.get(url) if (cached) return cached try { const resp = await fetch(url) const arrayBuf = await resp.arrayBuffer() const ctx = this.getCtx() const buffer = await ctx.decodeAudioData(arrayBuf) this.bufferCache.set(url, buffer) // LRU eviction if (this.bufferCache.size > this.maxCache) { const firstKey = this.bufferCache.keys().next().value if (firstKey) this.bufferCache.delete(firstKey) } return buffer } catch { return null } } destroy() { if (this.currentSource) { try { this.currentSource.stop() } catch {} this.currentSource = null } this.bufferCache.clear() this.duckCount.clear() this.currentUrl = '' if (this.ctx) { this.ctx.close() this.ctx = null this.gainNode = null } } }