diff --git a/frontend/src/scripts/feedback-hud.ts b/frontend/src/scripts/feedback-hud.ts index c71836a..63d1058 100644 --- a/frontend/src/scripts/feedback-hud.ts +++ b/frontend/src/scripts/feedback-hud.ts @@ -73,7 +73,6 @@ function killConfirmed(victimName?: string, streak = 1, charge = 0) { } } - function resetFeedbackHud(): void { root?.remove(); root = undefined; diff --git a/frontend/src/scripts/objects/game-object-container.ts b/frontend/src/scripts/objects/game-object-container.ts index 947922e..c6d1c17 100644 --- a/frontend/src/scripts/objects/game-object-container.ts +++ b/frontend/src/scripts/objects/game-object-container.ts @@ -13,7 +13,6 @@ import { RemoteCallsForObjects, UpdatePropertyCommand, } from 'shared'; -import { FeedbackHud } from '../feedback-hud'; import type { Game } from '../game'; import { serverTimeline } from '../helper/server-timeline'; import { localCharacterPredictor } from '../helper/prediction/local-character-predictor'; diff --git a/frontend/src/scripts/sound-handler.ts b/frontend/src/scripts/sound-handler.ts index cdab0d6..fb47b49 100644 --- a/frontend/src/scripts/sound-handler.ts +++ b/frontend/src/scripts/sound-handler.ts @@ -8,37 +8,51 @@ import { options } from './options-handler'; export const Sounds = { hit: 'hit', shoot: 'shoot', click: 'click' } as const; export type Sound = (typeof Sounds)[keyof typeof Sounds]; -let sounds: Record; +const sounds: Partial> = {}; +const maxVoices = 16; +const voices = new Set<{ source: AudioBufferSourceNode; gain: GainNode }>(); +let context: AudioContext | undefined; +let initialization: Promise | undefined; let isAmbientPlaying = false; const ambient = new Audio(ambientAudio); -let initialized = false; +let ambientInitialized = false; -async function initializeSound(src: string): Promise { - const snd = new Audio(src); - snd.muted = true; - await snd.play().catch(() => undefined); - snd.pause(); - snd.muted = false; - snd.currentTime = 0; - return snd; +async function initializeEffects() { + try { + context = new AudioContext({ latencyHint: 'interactive' }); + } catch { + return; + } + const audioContext = context; + // Unlock during the initiating gesture, before any network or decode work. + void audioContext.resume().catch(() => undefined); + await Promise.all( + Object.entries({ hit: hitSound, shoot: shootSound, click: clickSound }).map( + async ([name, src]) => { + try { + const response = await fetch(src); + if (response.ok) { + sounds[name as Sound] = await audioContext.decodeAudioData( + await response.arrayBuffer(), + ); + } + } catch { + // Missing audio must not interrupt gameplay or prevent other effects loading. + } + }, + ), + ); } -async function initialize( - onPlayKeypress: () => unknown = () => null, - onPauseKeypress: () => unknown = () => null, +async function initializeAmbient( + onPlayKeypress: () => unknown, + onPauseKeypress: () => unknown, ) { ambient.muted = true; ambient.volume = 0.5; ambient.loop = true; - // Unlock every audio element during the initiating click, before awaiting any one. - const ambientReady = ambient.play().catch(() => undefined); - const [hit, shoot, click] = await Promise.all( - [hitSound, shootSound, clickSound].map(initializeSound), - ); - sounds = { hit, shoot, click }; - - await ambientReady; - initialized = true; + await ambient.play().catch(() => undefined); + ambientInitialized = true; ambient.onpause = onPauseKeypress; ambient.onplay = onPlayKeypress; @@ -49,32 +63,65 @@ async function initialize( } } +function initialize( + onPlayKeypress: () => unknown = () => null, + onPauseKeypress: () => unknown = () => null, +): Promise { + if (!initialization) { + initialization = initializeEffects(); + // Streaming music can take much longer to load than the short effects. + void initializeAmbient(onPlayKeypress, onPauseKeypress); + } + return initialization; +} + function play(snd: Sound, volume = 1, playbackRate = 1) { - if (!initialized || !options.soundsEnabled) { + const buffer = sounds[snd]; + if (!context || !buffer || !options.soundsEnabled) { + return; + } + if (context.state !== 'running') { + void context.resume().catch(() => undefined); return; } - const pooled = sounds[snd]; - const isBusy = !pooled.paused && !pooled.ended; - const audio = isBusy ? (pooled.cloneNode(true) as HTMLAudioElement) : pooled; - if (!isBusy) { - audio.currentTime = 0; + // Delayed network packets can deliver many effects together. Keep their audio work bounded. + if (voices.size >= maxVoices) { + const oldest = voices.values().next().value!; + oldest.source.stop(); + oldest.source.disconnect(); + oldest.gain.disconnect(); + voices.delete(oldest); } - audio.volume = clamp01(volume); - audio.playbackRate = playbackRate; - void audio.play().catch(() => undefined); + + const source = context.createBufferSource(); + const gain = context.createGain(); + source.buffer = buffer; + source.playbackRate.value = playbackRate; + gain.gain.value = clamp01(volume); + source.connect(gain); + gain.connect(context.destination); + const voice = { source, gain }; + voices.add(voice); + source.onended = () => { + if (voices.delete(voice)) { + source.disconnect(); + gain.disconnect(); + } + }; + source.start(); } function playAmbient() { isAmbientPlaying = true; - if (initialized) { + if (ambientInitialized) { void ambient.play().catch(() => undefined); } } function stopAmbient() { isAmbientPlaying = false; - if (initialized) { + if (ambientInitialized) { ambient.pause(); } } diff --git a/test/sound-handler.test.ts b/test/sound-handler.test.ts new file mode 100644 index 0000000..074dbde --- /dev/null +++ b/test/sound-handler.test.ts @@ -0,0 +1,186 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { options } = vi.hoisted(() => ({ options: { soundsEnabled: true } })); +vi.mock('../frontend/src/scripts/options-handler', () => ({ options })); + +class FakeAudio { + static instances: FakeAudio[] = []; + static ready: Promise; + muted = false; + paused = true; + ended = false; + currentTime = 0; + volume = 1; + playbackRate = 1; + loop = false; + onpause?: () => unknown; + onplay?: () => unknown; + constructor(public src: string) { + FakeAudio.instances.push(this); + } + play = vi.fn(() => { + this.paused = false; + return FakeAudio.ready; + }); + pause = vi.fn(() => { + this.paused = true; + }); + cloneNode() { + return new FakeAudio(this.src); + } +} + +class FakeSource { + buffer: unknown; + playbackRate = { value: 1 }; + onended?: () => void; + connect = vi.fn(); + disconnect = vi.fn(); + start = vi.fn(); + stop = vi.fn(); +} + +class FakeAudioContext { + static instances: FakeAudioContext[] = []; + state = 'running'; + destination = {}; + sources: FakeSource[] = []; + gains: ReturnType[] = []; + resume = vi.fn(async () => undefined); + decodeAudioData = vi.fn(async (data: ArrayBuffer) => ({ data })); + constructor() { + FakeAudioContext.instances.push(this); + } + createBufferSource() { + const source = new FakeSource(); + this.sources.push(source); + return source; + } + createGain(): { + gain: { value: number }; + connect: ReturnType; + disconnect: ReturnType; + } { + const gain = { gain: { value: 1 }, connect: vi.fn(), disconnect: vi.fn() }; + this.gains.push(gain); + return gain; + } +} + +const load = () => import('../frontend/src/scripts/sound-handler'); + +beforeEach(() => { + vi.resetModules(); + options.soundsEnabled = true; + FakeAudio.instances = []; + FakeAudio.ready = Promise.resolve(); + FakeAudioContext.instances = []; + vi.stubGlobal('Audio', FakeAudio); + vi.stubGlobal('AudioContext', FakeAudioContext); + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) })), + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('sound playback under a slow connection', () => { + it('reuses decoded audio without making media players or requests for overlapping shots', async () => { + const { SoundHandler, Sounds } = await load(); + await SoundHandler.initialize(); + const players = FakeAudio.instances.length; + const requests = vi.mocked(fetch).mock.calls.length; + + for (let i = 0; i < 80; i++) SoundHandler.play(Sounds.shoot, 0.7, 0.8); + + expect(FakeAudio.instances).toHaveLength(players); + expect(fetch).toHaveBeenCalledTimes(requests); + const context = FakeAudioContext.instances[0]; + expect(context.decodeAudioData).toHaveBeenCalledTimes(3); + expect(context.sources).toHaveLength(80); + expect(new Set(context.sources.map((s) => s.buffer)).size).toBe(1); + expect(context.sources.every((s) => s.start.mock.calls.length === 1)).toBe(true); + expect(context.sources.every((s) => s.playbackRate.value === 0.8)).toBe(true); + expect(context.gains.every((g) => g.gain.value === 0.7)).toBe(true); + }); + + it('allows loaded effects to play while music or another effect is still loading', async () => { + FakeAudio.ready = new Promise(() => {}); + vi.mocked(fetch).mockImplementation(async (src) => + String(src).includes('hit.mp3') + ? new Promise(() => {}) + : ({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) } as Response), + ); + const { SoundHandler, Sounds } = await load(); + void SoundHandler.initialize(); + await vi.waitFor(() => + expect(FakeAudioContext.instances[0]?.decodeAudioData).toHaveBeenCalledTimes(2), + ); + SoundHandler.play(Sounds.shoot); + expect(FakeAudioContext.instances[0].sources[0].start).toHaveBeenCalledOnce(); + }); + + it('drops effects that are not ready instead of replaying a backlog after loading', async () => { + let resolve!: (response: Response) => void; + vi.mocked(fetch).mockReturnValue(new Promise((r) => (resolve = r))); + const { SoundHandler, Sounds } = await load(); + const ready = SoundHandler.initialize(); + for (let i = 0; i < 80; i++) SoundHandler.play(Sounds.shoot); + expect(FakeAudioContext.instances[0].sources).toHaveLength(0); + + resolve({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) } as Response); + await ready; + expect(FakeAudioContext.instances[0].sources).toHaveLength(0); + SoundHandler.play(Sounds.shoot); + expect(FakeAudioContext.instances[0].sources).toHaveLength(1); + }); + + it('bounds overlapping voices and disconnects both stolen and finished sounds', async () => { + const { SoundHandler, Sounds } = await load(); + await SoundHandler.initialize(); + for (let i = 0; i < 80; i++) SoundHandler.play(Sounds.shoot); + const context = FakeAudioContext.instances[0]; + const stopped = context.sources.filter((s) => s.stop.mock.calls.length > 0); + const active = context.sources.filter((s) => s.stop.mock.calls.length === 0); + expect(active.length).toBeGreaterThan(1); + expect(active.length).toBeLessThanOrEqual(16); + expect(stopped.every((s) => s.disconnect.mock.calls.length === 1)).toBe(true); + + const last = context.sources[context.sources.length - 1]; + last.onended?.(); + expect(last.disconnect).toHaveBeenCalledOnce(); + expect(context.gains[context.gains.length - 1].disconnect).toHaveBeenCalledOnce(); + }); + + it('initializes only once and respects the sound toggle and volume limits', async () => { + const { SoundHandler, Sounds } = await load(); + await Promise.all([SoundHandler.initialize(), SoundHandler.initialize()]); + expect(FakeAudioContext.instances).toHaveLength(1); + expect(fetch).toHaveBeenCalledTimes(3); + const context = FakeAudioContext.instances[0]; + expect(context.resume).toHaveBeenCalledOnce(); + options.soundsEnabled = false; + SoundHandler.play(Sounds.shoot); + expect(context.sources).toHaveLength(0); + options.soundsEnabled = true; + SoundHandler.play(Sounds.hit, 2); + expect(context.gains[0].gain.value).toBe(1); + }); + + it('keeps playback harmless when audio is unavailable or an asset fails', async () => { + vi.mocked(fetch).mockRejectedValue(new Error('offline')); + const { SoundHandler, Sounds } = await load(); + await expect(SoundHandler.initialize()).resolves.toBeUndefined(); + expect(() => SoundHandler.play(Sounds.shoot)).not.toThrow(); + expect(FakeAudioContext.instances[0].sources).toHaveLength(0); + + vi.resetModules(); + vi.stubGlobal('AudioContext', undefined); + const unavailable = await load(); + await expect(unavailable.SoundHandler.initialize()).resolves.toBeUndefined(); + expect(() => unavailable.SoundHandler.play(Sounds.shoot)).not.toThrow(); + }); +});