Fix extra sound fetching
All checks were successful
Build & deploy / Build & publish server image (pull_request) Has been skipped
Build & deploy / Build & deploy website (pull_request) Successful in 29s

This commit is contained in:
Andras Schmelczer 2026-09-15 21:03:14 +01:00
commit 38e3f8cf7a
4 changed files with 266 additions and 35 deletions

View file

@ -73,7 +73,6 @@ function killConfirmed(victimName?: string, streak = 1, charge = 0) {
} }
} }
function resetFeedbackHud(): void { function resetFeedbackHud(): void {
root?.remove(); root?.remove();
root = undefined; root = undefined;

View file

@ -13,7 +13,6 @@ import {
RemoteCallsForObjects, RemoteCallsForObjects,
UpdatePropertyCommand, UpdatePropertyCommand,
} from 'shared'; } from 'shared';
import { FeedbackHud } from '../feedback-hud';
import type { Game } from '../game'; import type { Game } from '../game';
import { serverTimeline } from '../helper/server-timeline'; import { serverTimeline } from '../helper/server-timeline';
import { localCharacterPredictor } from '../helper/prediction/local-character-predictor'; import { localCharacterPredictor } from '../helper/prediction/local-character-predictor';

View file

@ -8,37 +8,51 @@ import { options } from './options-handler';
export const Sounds = { hit: 'hit', shoot: 'shoot', click: 'click' } as const; export const Sounds = { hit: 'hit', shoot: 'shoot', click: 'click' } as const;
export type Sound = (typeof Sounds)[keyof typeof Sounds]; export type Sound = (typeof Sounds)[keyof typeof Sounds];
let sounds: Record<Sound, HTMLAudioElement>; const sounds: Partial<Record<Sound, AudioBuffer>> = {};
const maxVoices = 16;
const voices = new Set<{ source: AudioBufferSourceNode; gain: GainNode }>();
let context: AudioContext | undefined;
let initialization: Promise<void> | undefined;
let isAmbientPlaying = false; let isAmbientPlaying = false;
const ambient = new Audio(ambientAudio); const ambient = new Audio(ambientAudio);
let initialized = false; let ambientInitialized = false;
async function initializeSound(src: string): Promise<HTMLAudioElement> { async function initializeEffects() {
const snd = new Audio(src); try {
snd.muted = true; context = new AudioContext({ latencyHint: 'interactive' });
await snd.play().catch(() => undefined); } catch {
snd.pause(); return;
snd.muted = false; }
snd.currentTime = 0; const audioContext = context;
return snd; // 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( async function initializeAmbient(
onPlayKeypress: () => unknown = () => null, onPlayKeypress: () => unknown,
onPauseKeypress: () => unknown = () => null, onPauseKeypress: () => unknown,
) { ) {
ambient.muted = true; ambient.muted = true;
ambient.volume = 0.5; ambient.volume = 0.5;
ambient.loop = true; ambient.loop = true;
// Unlock every audio element during the initiating click, before awaiting any one. await ambient.play().catch(() => undefined);
const ambientReady = ambient.play().catch(() => undefined); ambientInitialized = true;
const [hit, shoot, click] = await Promise.all(
[hitSound, shootSound, clickSound].map(initializeSound),
);
sounds = { hit, shoot, click };
await ambientReady;
initialized = true;
ambient.onpause = onPauseKeypress; ambient.onpause = onPauseKeypress;
ambient.onplay = onPlayKeypress; ambient.onplay = onPlayKeypress;
@ -49,32 +63,65 @@ async function initialize(
} }
} }
function initialize(
onPlayKeypress: () => unknown = () => null,
onPauseKeypress: () => unknown = () => null,
): Promise<void> {
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) { 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; return;
} }
const pooled = sounds[snd]; // Delayed network packets can deliver many effects together. Keep their audio work bounded.
const isBusy = !pooled.paused && !pooled.ended; if (voices.size >= maxVoices) {
const audio = isBusy ? (pooled.cloneNode(true) as HTMLAudioElement) : pooled; const oldest = voices.values().next().value!;
if (!isBusy) { oldest.source.stop();
audio.currentTime = 0; oldest.source.disconnect();
oldest.gain.disconnect();
voices.delete(oldest);
} }
audio.volume = clamp01(volume);
audio.playbackRate = playbackRate; const source = context.createBufferSource();
void audio.play().catch(() => undefined); 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() { function playAmbient() {
isAmbientPlaying = true; isAmbientPlaying = true;
if (initialized) { if (ambientInitialized) {
void ambient.play().catch(() => undefined); void ambient.play().catch(() => undefined);
} }
} }
function stopAmbient() { function stopAmbient() {
isAmbientPlaying = false; isAmbientPlaying = false;
if (initialized) { if (ambientInitialized) {
ambient.pause(); ambient.pause();
} }
} }

186
test/sound-handler.test.ts Normal file
View file

@ -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<void>;
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<FakeAudioContext['createGain']>[] = [];
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<typeof vi.fn>;
disconnect: ReturnType<typeof vi.fn>;
} {
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();
});
});