diff --git a/src/audio/manager.ts b/src/audio/manager.ts index b620a61..5826b00 100644 --- a/src/audio/manager.ts +++ b/src/audio/manager.ts @@ -1,94 +1,336 @@ +export interface ToneSpec { + readonly type: OscillatorType; + readonly from: number; + readonly to: number; + readonly gain: number; + readonly durationS: number; + readonly delayS?: number; +} + +type AudioContextCtor = new () => AudioContext; + +let sharedCtx: AudioContext | null = null; +let sharedCtxCtor: AudioContextCtor | null = null; +let sharedMasterGain: GainNode | null = null; +let sharedSfxGain: GainNode | null = null; +let sharedMusicGain: GainNode | null = null; +let warnedAudioFailure = false; + +function getAudioContextCtor(): AudioContextCtor | undefined { + const g = globalThis as unknown as { + AudioContext?: AudioContextCtor; + webkitAudioContext?: AudioContextCtor; + window?: { + AudioContext?: AudioContextCtor; + webkitAudioContext?: AudioContextCtor; + }; + }; + return ( + g.AudioContext ?? + g.webkitAudioContext ?? + g.window?.AudioContext ?? + g.window?.webkitAudioContext + ); +} + +function warnOnce(err: unknown): void { + if (!warnedAudioFailure) { + warnedAudioFailure = true; + console.warn('[AudioManager] Audio playback failed:', err); + } +} + export class AudioManager { private ctx: AudioContext | null = null; private masterGain: GainNode | null = null; private sfxGain: GainNode | null = null; private musicGain: GainNode | null = null; - + private buffers: Map = new Map(); private currentMusicSource: AudioBufferSourceNode | null = null; - + constructor() { // We defer AudioContext creation until needed due to autoplay policies } - - private ensureContext() { - if (!this.ctx && typeof AudioContext !== 'undefined') { - this.ctx = new AudioContext(); - this.masterGain = this.ctx.createGain(); - this.sfxGain = this.ctx.createGain(); - this.musicGain = this.ctx.createGain(); - - this.masterGain.connect(this.ctx.destination); - this.sfxGain.connect(this.masterGain); - this.musicGain.connect(this.masterGain); + + private ensureContext(): AudioContext | null { + const Ctor = getAudioContextCtor(); + if (!Ctor) { + return null; } + try { + if (!sharedCtx || sharedCtx.state === 'closed' || sharedCtxCtor !== Ctor) { + sharedCtx = new Ctor(); + sharedCtxCtor = Ctor; + sharedMasterGain = typeof sharedCtx.createGain === 'function' ? sharedCtx.createGain() : null; + sharedSfxGain = typeof sharedCtx.createGain === 'function' ? sharedCtx.createGain() : null; + sharedMusicGain = typeof sharedCtx.createGain === 'function' ? sharedCtx.createGain() : null; + + if (sharedMasterGain && sharedCtx.destination) { + sharedMasterGain.connect?.(sharedCtx.destination); + } + if (sharedSfxGain && sharedMasterGain) { + sharedSfxGain.connect?.(sharedMasterGain); + } + if (sharedMusicGain && sharedMasterGain) { + sharedMusicGain.connect?.(sharedMasterGain); + } + } + if (sharedCtx.state === 'suspended' && typeof sharedCtx.resume === 'function') { + void Promise.resolve(sharedCtx.resume()).catch(() => {}); + } + this.ctx = sharedCtx; + this.masterGain = sharedMasterGain; + this.sfxGain = sharedSfxGain; + this.musicGain = sharedMusicGain; + return this.ctx; + } catch (err) { + warnOnce(err); + return null; + } + } + + /** Return the active shared AudioContext if created, or null. */ + getContext(): AudioContext | null { + return this.ctx ?? sharedCtx; } /** Set the global master volume [0.0..1.0] */ - setMasterVolume(value: number) { + setMasterVolume(value: number): void { + const clamped = Math.max(0, Math.min(1, Number.isFinite(value) ? value : 0)); this.ensureContext(); - if (this.masterGain) this.masterGain.gain.value = value; + if (this.masterGain?.gain) this.masterGain.gain.value = clamped; } /** Set the SFX volume [0.0..1.0] */ - setSfxVolume(value: number) { + setSfxVolume(value: number): void { + const clamped = Math.max(0, Math.min(1, Number.isFinite(value) ? value : 0)); this.ensureContext(); - if (this.sfxGain) this.sfxGain.gain.value = value; + if (this.sfxGain?.gain) this.sfxGain.gain.value = clamped; } /** Set the music volume [0.0..1.0] */ - setMusicVolume(value: number) { + setMusicVolume(value: number): void { + const clamped = Math.max(0, Math.min(1, Number.isFinite(value) ? value : 0)); this.ensureContext(); - if (this.musicGain) this.musicGain.gain.value = value; + if (this.musicGain?.gain) this.musicGain.gain.value = clamped; } /** Decode a full WAFF/RIFF MPQ audio member and cache it by name */ - async loadBuffer(name: string, data: Uint8Array) { - this.ensureContext(); - if (!this.ctx) return; - + async loadBuffer(name: string, data: Uint8Array): Promise { + const ctx = this.ensureContext(); + if (!ctx) return; + // Ensure WAFF/RIFF is correctly passed. WebAudio takes an ArrayBuffer - const buffer = await this.ctx.decodeAudioData(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer); + const buffer = await ctx.decodeAudioData( + data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer, + ); this.buffers.set(name, buffer); } - - /** Play a sound effect one-shot */ - playSfx(name: string) { - this.ensureContext(); - if (!this.ctx || !this.sfxGain) return; - if (this.ctx.state === 'suspended') this.ctx.resume().catch(() => {}); - - const buffer = this.buffers.get(name); - if (!buffer) return; - - const source = this.ctx.createBufferSource(); - source.buffer = buffer; - source.connect(this.sfxGain); - source.start(); + + /** Play a synthesized tone using the shared AudioContext and currentTime scheduling */ + playTone(spec: ToneSpec): void { + const ctx = this.ensureContext(); + if (!ctx) return; + try { + const now = typeof ctx.currentTime === 'number' ? ctx.currentTime : 0; + const t0 = now + (spec.delayS ?? 0); + const t1 = t0 + spec.durationS; + + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = spec.type; + osc.frequency?.setValueAtTime?.(spec.from, t0); + osc.frequency?.exponentialRampToValueAtTime?.(Math.max(0.01, spec.to), t1); + gain.gain?.setValueAtTime?.(spec.gain, t0); + gain.gain?.exponentialRampToValueAtTime?.(0.01, t1); + + osc.connect?.(gain); + const targetNode = this.sfxGain ?? ctx.destination; + if (targetNode) { + gain.connect?.(targetNode); + } + + const cleanup = () => { + try { + osc.disconnect?.(); + } catch { + // ignore disconnect errors on already-disconnected nodes + } + try { + gain.disconnect?.(); + } catch { + // ignore disconnect errors on already-disconnected nodes + } + }; + if (typeof osc.addEventListener === 'function') { + osc.addEventListener('ended', cleanup, { once: true }); + } else { + osc.onended = cleanup; + } + + osc.start?.(t0); + osc.stop?.(t1); + } catch (err) { + warnOnce(err); + } } - - /** Play music (loops, switches without crossfade) */ - playMusic(name: string) { - this.ensureContext(); - if (!this.ctx || !this.musicGain) return; - if (this.ctx.state === 'suspended') this.ctx.resume().catch(() => {}); - + + /** Play a sound effect by name or synthesized ToneSpec using the shared AudioContext */ + playSound(soundOrSpec?: string | ToneSpec): void { + if (typeof soundOrSpec === 'object' && soundOrSpec !== null) { + this.playTone(soundOrSpec); + return; + } + if (typeof soundOrSpec === 'string' && this.buffers.has(soundOrSpec)) { + this.playSfx(soundOrSpec); + return; + } + this.playTone({ + type: 'triangle', + from: 160, + to: 80, + gain: 0.25, + durationS: 0.12, + delayS: 0, + }); + } + + /** Play an item drop sound effect by name using the shared AudioContext */ + playItemDropSound(soundName?: string): void { + this.playSound(soundName); + } + + /** Play a sound effect one-shot */ + playSfx(name: string): void { + if (name === 'inventory_full' && !this.buffers.has(name)) { + this.playTone({ + type: 'sawtooth', + from: 175, + to: 90, + gain: 0.3, + durationS: 0.3, + delayS: 0, + }); + this.playTone({ + type: 'triangle', + from: 110, + to: 45, + gain: 0.2, + durationS: 0.1, + delayS: 0.35, + }); + return; + } + const ctx = this.ensureContext(); + if (!ctx || !this.sfxGain) return; + const buffer = this.buffers.get(name); if (!buffer) return; - - // Stop current music + + try { + const source = ctx.createBufferSource(); + source.buffer = buffer; + source.connect(this.sfxGain); + const cleanup = () => { + try { + source.disconnect?.(); + } catch { + // ignore + } + }; + if (typeof source.addEventListener === 'function') { + source.addEventListener('ended', cleanup, { once: true }); + } else { + source.onended = cleanup; + } + source.start(); + } catch (err) { + warnOnce(err); + } + } + + /** Play music (loops, switches without crossfade) */ + playMusic(name: string): void { + const ctx = this.ensureContext(); + if (!ctx || !this.musicGain) return; + + const buffer = this.buffers.get(name); + if (!buffer) return; + + try { + // Stop current music + if (this.currentMusicSource) { + this.currentMusicSource.stop(); + this.currentMusicSource.disconnect(); + this.currentMusicSource = null; + } + + const source = ctx.createBufferSource(); + source.buffer = buffer; + source.loop = true; + source.connect(this.musicGain); + source.start(); + + this.currentMusicSource = source; + } catch (err) { + warnOnce(err); + } + } + + /** Close the shared AudioContext and release audio resources */ + dispose(): void { if (this.currentMusicSource) { - this.currentMusicSource.stop(); - this.currentMusicSource.disconnect(); + try { + this.currentMusicSource.stop(); + this.currentMusicSource.disconnect(); + } catch { + // ignore + } this.currentMusicSource = null; } - - const source = this.ctx.createBufferSource(); - source.buffer = buffer; - source.loop = true; - source.connect(this.musicGain); - source.start(); - - this.currentMusicSource = source; + const ctxToClose = this.ctx ?? sharedCtx; + this.ctx = null; + this.masterGain = null; + this.sfxGain = null; + this.musicGain = null; + sharedCtx = null; + sharedCtxCtor = null; + sharedMasterGain = null; + sharedSfxGain = null; + sharedMusicGain = null; + warnedAudioFailure = false; + if (ctxToClose && ctxToClose.state !== 'closed' && typeof ctxToClose.close === 'function') { + void Promise.resolve(ctxToClose.close()).catch(() => {}); + } + } + + static getShared(): AudioManager { + return sharedAudioManager; + } + + static playTone(spec: ToneSpec): void { + sharedAudioManager.playTone(spec); + } + + static playSound(soundOrSpec?: string | ToneSpec): void { + sharedAudioManager.playSound(soundOrSpec); + } + + static playSfx(name: string): void { + sharedAudioManager.playSfx(name); + } + + static dispose(): void { + sharedAudioManager.dispose(); + } + + static resetSharedContextForTesting(): void { + sharedAudioManager.dispose(); } } + +export class SoundManager extends AudioManager {} + +export const sharedAudioManager = new AudioManager(); + diff --git a/tests/p0-528-audio-singleton.test.ts b/tests/p0-528-audio-singleton.test.ts new file mode 100644 index 0000000..a022d94 --- /dev/null +++ b/tests/p0-528-audio-singleton.test.ts @@ -0,0 +1,212 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AudioManager, SoundManager, sharedAudioManager } from '../src/audio/manager.ts' +import { playStashOpenSound } from '../src/scene/frontend/mouse-controller.ts' +import { playInventoryFullFeedback } from '../src/scene/frontend/overhead-labels.ts' + +interface MockOscillatorNode { + type: string + frequency: { + value: number + setValueAtTime: ReturnType + exponentialRampToValueAtTime: ReturnType + } + connect: ReturnType + disconnect: ReturnType + start: ReturnType + stop: ReturnType + addEventListener: ReturnType + onended: (() => void) | null + triggerEnded: () => void +} + +interface MockGainNode { + gain: { + value: number + setValueAtTime: ReturnType + linearRampToValueAtTime: ReturnType + exponentialRampToValueAtTime: ReturnType + } + connect: ReturnType + disconnect: ReturnType +} + +describe('P0 #528: Shared Singleton AudioContext & Web Audio Lifecycle', () => { + let ctorCount = 0 + let resumeCalls = 0 + let closeCalls = 0 + let createdOscillators: MockOscillatorNode[] = [] + let createdGains: MockGainNode[] = [] + let mockState: 'running' | 'suspended' | 'closed' = 'running' + + beforeEach(() => { + ctorCount = 0 + resumeCalls = 0 + closeCalls = 0 + createdOscillators = [] + createdGains = [] + mockState = 'running' + AudioManager.resetSharedContextForTesting() + + class MockAudioContext { + currentTime = 10.0 + sampleRate = 22050 + destination = {} + get state() { + return mockState + } + constructor() { + ctorCount += 1 + } + resume = vi.fn(async () => { + resumeCalls += 1 + mockState = 'running' + }) + close = vi.fn(async () => { + closeCalls += 1 + mockState = 'closed' + }) + createOscillator = vi.fn((): MockOscillatorNode => { + const listeners: Array<() => void> = [] + const osc: MockOscillatorNode = { + type: 'sine', + frequency: { + value: 440, + setValueAtTime: vi.fn(), + exponentialRampToValueAtTime: vi.fn(), + }, + connect: vi.fn(), + disconnect: vi.fn(), + start: vi.fn(), + stop: vi.fn(), + addEventListener: vi.fn((event: string, cb: () => void) => { + if (event === 'ended') listeners.push(cb) + }), + onended: null, + triggerEnded: () => { + for (const cb of listeners) cb() + if (osc.onended) osc.onended() + }, + } + createdOscillators.push(osc) + return osc + }) + createGain = vi.fn((): MockGainNode => { + const gain: MockGainNode = { + gain: { + value: 1, + setValueAtTime: vi.fn(), + linearRampToValueAtTime: vi.fn(), + exponentialRampToValueAtTime: vi.fn(), + }, + connect: vi.fn(), + disconnect: vi.fn(), + } + createdGains.push(gain) + return gain + }) + createBuffer = vi.fn((_channels: number, length: number, sampleRate: number) => ({ + length, + sampleRate, + getChannelData: () => new Float32Array(length), + })) + createBufferSource = vi.fn(() => { + const listeners: Array<() => void> = [] + return { + buffer: null, + connect: vi.fn(), + disconnect: vi.fn(), + start: vi.fn(), + addEventListener: vi.fn((event: string, cb: () => void) => { + if (event === 'ended') listeners.push(cb) + }), + onended: null, + } + }) + } + + vi.stubGlobal('AudioContext', MockAudioContext) + vi.stubGlobal('window', { AudioContext: MockAudioContext }) + }) + + afterEach(() => { + AudioManager.resetSharedContextForTesting() + vi.unstubAllGlobals() + }) + + it('constructs at most 1 AudioContext across 50 sound and feedback triggers', () => { + const managerA = new AudioManager() + const managerB = new SoundManager() + + for (let i = 0; i < 20; i++) { + playInventoryFullFeedback() + playStashOpenSound(managerA) + managerA.playItemDropSound('item_ring_1') + managerB.playSound('item_gold_1') + SoundManager.playTone({ type: 'sine', from: 220, to: 180, durationS: 0.1, gain: 0.1 }) + } + + expect(ctorCount).toBe(1) + }) + + it('automatically calls resume() when the shared AudioContext is suspended', () => { + mockState = 'suspended' + playInventoryFullFeedback() + expect(ctorCount).toBe(1) + expect(resumeCalls).toBeGreaterThanOrEqual(1) + expect(mockState).toBe('running') + }) + + it('schedules delayed tones on ctx.currentTime without setTimeout and disconnects nodes on ended', () => { + const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout') + playInventoryFullFeedback() + + // playInventoryFullFeedback plays two tones: delayS = 0 and delayS = 0.35 + expect(setTimeoutSpy).not.toHaveBeenCalled() + expect(createdOscillators.length).toBe(2) + + const firstOsc = createdOscillators[0]! + const secondOsc = createdOscillators[1]! + expect(firstOsc.start).toHaveBeenCalledWith(10.0) + expect(secondOsc.start).toHaveBeenCalledWith(10.35) + + // Trigger ended on both oscillators and verify disconnect() cleanup + const toneGains = createdGains.slice(-2) + firstOsc.triggerEnded() + secondOsc.triggerEnded() + expect(firstOsc.disconnect).toHaveBeenCalled() + expect(secondOsc.disconnect).toHaveBeenCalled() + expect(toneGains[0]!.disconnect).toHaveBeenCalled() + expect(toneGains[1]!.disconnect).toHaveBeenCalled() + + setTimeoutSpy.mockRestore() + }) + + it('closes the shared AudioContext on dispose() and recreates cleanly if used again', () => { + sharedAudioManager.playItemDropSound('item_potion_1') + expect(ctorCount).toBe(1) + + sharedAudioManager.dispose() + expect(closeCalls).toBe(1) + + // Subsequent sound after dispose creates a fresh context + sharedAudioManager.playItemDropSound('item_potion_1') + expect(ctorCount).toBe(2) + }) + + it('has zero raw new AudioContext instantiations in overhead-labels.ts and mouse-controller.ts', () => { + const overheadSrc = readFileSync( + resolve(process.cwd(), 'src/scene/frontend/overhead-labels.ts'), + 'utf8', + ) + const mouseSrc = readFileSync( + resolve(process.cwd(), 'src/scene/frontend/mouse-controller.ts'), + 'utf8', + ) + + expect(overheadSrc).not.toMatch(/new\s+AudioCtx|webkitAudioContext/) + expect(overheadSrc).not.toContain('I cannot carry anymore') + expect(mouseSrc).not.toMatch(/new\s+AudioCtx|webkitAudioContext/) + }) +})