202 lines
6.6 KiB
TypeScript
202 lines
6.6 KiB
TypeScript
import { readdirSync, readFileSync } from 'node:fs'
|
|
import { join, resolve, sep } from 'node:path'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { AudioManager, SoundManager, sharedAudioManager } from '../src/client/audio/manager.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 60 sound triggers from independent managers', () => {
|
|
const managerA = new AudioManager()
|
|
const managerB = new SoundManager()
|
|
|
|
for (let i = 0; i < 20; i++) {
|
|
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'
|
|
sharedAudioManager.playTone({ type: 'sawtooth', from: 175, to: 90, gain: 0.3, durationS: 0.3, delayS: 0 })
|
|
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')
|
|
sharedAudioManager.playTone({ type: 'sawtooth', from: 175, to: 90, gain: 0.3, durationS: 0.3, delayS: 0 })
|
|
sharedAudioManager.playTone({ type: 'triangle', from: 110, to: 45, gain: 0.2, durationS: 0.1, 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 src/client outside audio/manager.ts', () => {
|
|
const root = resolve(process.cwd(), 'src/client')
|
|
const offenders = (readdirSync(root, { recursive: true }) as string[])
|
|
.filter(p => p.endsWith('.ts') && p.split(sep).join('/') !== 'audio/manager.ts')
|
|
.filter(p => /new\s+(AudioContext|AudioCtx)\b|webkitAudioContext/.test(readFileSync(join(root, p), 'utf8')))
|
|
expect(offenders).toEqual([])
|
|
})
|
|
})
|