fix(audio): reuse shared singleton AudioContext in SoundManager (#528)

Maintain a single lazy-initialized AudioContext instance across playSound calls
with automatic suspended-context resume and clean close/recreation support.

TAG=agy
CONV=4e31689c-063a-4965-968b-59c0f5795f97
This commit is contained in:
troytt 2026-09-29 21:01:49 +00:00
parent 462fdc7516
commit 58cbc88e7c
2 changed files with 511 additions and 57 deletions

View File

@ -1,3 +1,45 @@
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;
@ -11,84 +53,284 @@ export class AudioManager {
// 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<void> {
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 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 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) {
this.ensureContext();
if (!this.ctx || !this.sfxGain) return;
if (this.ctx.state === 'suspended') this.ctx.resume().catch(() => {});
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;
const source = this.ctx.createBufferSource();
source.buffer = buffer;
source.connect(this.sfxGain);
source.start();
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) {
this.ensureContext();
if (!this.ctx || !this.musicGain) return;
if (this.ctx.state === 'suspended') this.ctx.resume().catch(() => {});
playMusic(name: string): void {
const ctx = this.ensureContext();
if (!ctx || !this.musicGain) return;
const buffer = this.buffers.get(name);
if (!buffer) return;
// Stop current music
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 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(() => {});
}
}
const source = this.ctx.createBufferSource();
source.buffer = buffer;
source.loop = true;
source.connect(this.musicGain);
source.start();
static getShared(): AudioManager {
return sharedAudioManager;
}
this.currentMusicSource = source;
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();

View File

@ -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<typeof vi.fn>
exponentialRampToValueAtTime: ReturnType<typeof vi.fn>
}
connect: ReturnType<typeof vi.fn>
disconnect: ReturnType<typeof vi.fn>
start: ReturnType<typeof vi.fn>
stop: ReturnType<typeof vi.fn>
addEventListener: ReturnType<typeof vi.fn>
onended: (() => void) | null
triggerEnded: () => void
}
interface MockGainNode {
gain: {
value: number
setValueAtTime: ReturnType<typeof vi.fn>
linearRampToValueAtTime: ReturnType<typeof vi.fn>
exponentialRampToValueAtTime: ReturnType<typeof vi.fn>
}
connect: ReturnType<typeof vi.fn>
disconnect: ReturnType<typeof vi.fn>
}
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/)
})
})