Fix extra sound fetching
This commit is contained in:
parent
d3750fde3c
commit
38e3f8cf7a
4 changed files with 266 additions and 35 deletions
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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
186
test/sound-handler.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue