Files
feuerwerk/tests/viewport/sound-library.test.ts
T
admin 5671a38044
game-maker / suite (push) Successful in 12m1s
A game that stays silent is only half a game, so a sound is an asset like any other (#45)
Sounds follow the road fonts already paved: the file goes to the server
content-addressed under its sha256, the project keeps a named asset that
points at it, and nothing is ever re-encoded. The format is read from the
file's own signature, not from what the browser claims - the same .m4a
arrives as audio/mp4, audio/x-m4a or as nothing at all.

Playing is two nodes. "Play sound" takes the asset and may raise or lower
its volume; every call is its own voice, so two jumps in a row are two
jumps heard. Its loop input is a choice of three - from the asset, on,
off - because a checkbox has to spend one of its two positions on "not
set" and would lose a case: an asset that loops could then never be
played once.

Below the graph core stays browser-free: NodeContext hands out an asset
id, BehaviorSystem resolves it against the project and folds the asset's
own volume and loop into the call, and only viewport/SoundLibrary knows
about Web Audio. A run without a sink plays nothing instead of throwing.

The CI browser is muted, so the ear is no witness: the debug hook counts
started and stopped voices, and step 34 reads that counter.
2026-09-15 07:54:28 +02:00

161 lines
5.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The library between `Play sound` and the audio device. Everything here is about the gap between
* asking for a sound and being able to play it: the bytes are fetched and decoded on first use, so
* a stop can land while a play is still in flight.
*
* The device is stubbed – nothing makes a noise in a unit test, and Web Audio does not exist here.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { SoundLibrary } from '../../src/viewport/SoundLibrary';
const FILE = 'a.wav';
const OTHER = 'b.wav';
interface FakeSource {
buffer: unknown;
loop: boolean;
onended: (() => void) | null;
started: boolean;
stopped: boolean;
connect: (target: unknown) => unknown;
start: () => void;
stop: () => void;
}
const sources: FakeSource[] = [];
/** Resolves the pending decodes by hand, so a test decides when the bytes arrive. */
let decodes: (() => void)[] = [];
function fakeContext() {
return {
state: 'running',
destination: {},
resume: () => Promise.resolve(),
suspend: () => Promise.resolve(),
decodeAudioData: () => new Promise((resolve) => decodes.push(() => resolve({ duration: 1.5 }))),
createBufferSource: () => {
const source: FakeSource = {
buffer: null,
loop: false,
onended: null,
started: false,
stopped: false,
connect: (target: unknown) => target,
start: () => {
source.started = true;
},
stop: () => {
source.stopped = true;
},
};
sources.push(source);
return source;
},
createGain: () => ({ gain: { value: 1 }, connect: (target: unknown) => target }),
};
}
/** Lets the promise chain inside `play` run to the point where it would start a voice. */
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
beforeEach(() => {
sources.length = 0;
decodes = [];
vi.stubGlobal(
'AudioContext',
class {
constructor() {
return fakeContext();
}
},
);
vi.stubGlobal('fetch', () =>
Promise.resolve({ ok: true, arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)) }),
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
/** Plays `file` and lets its bytes arrive, unless `hold` keeps the decode pending. */
async function play(library: SoundLibrary, file = FILE, { hold = false } = {}): Promise<void> {
library.play(file, 1, false);
await settle();
if (hold) return;
for (const resolve of decodes.splice(0)) resolve();
await settle();
}
describe('the sound library', () => {
it('fetches and decodes a file once, however often it is played', async () => {
const library = new SoundLibrary();
await play(library);
await play(library);
expect(library.stats()).toMatchObject({ started: 2, decoded: 1, playing: 2 });
// Two voices of one file, both running: plays overlap rather than replacing each other.
expect(sources.filter((s) => s.started)).toHaveLength(2);
});
it('remembers what it last started, so a muted check can see it at all', async () => {
const library = new SoundLibrary();
library.play(FILE, 0.25, true);
await settle();
for (const resolve of decodes.splice(0)) resolve();
await settle();
expect(library.stats().last).toEqual({ file: FILE, volume: 0.25, loop: true });
});
it('a stop while the bytes are in flight keeps the sound from starting afterwards', async () => {
const library = new SoundLibrary();
await play(library, FILE, { hold: true });
library.stop(FILE);
for (const resolve of decodes.splice(0)) resolve();
await settle();
expect(library.stats()).toMatchObject({ started: 0, playing: 0 });
});
it('but a stop before a play does not: an earlier silence is not a standing veto', async () => {
const library = new SoundLibrary();
// Exactly what ending a run does – and the next run has to be able to play that sound again,
// even though its bytes are still unread at that point.
library.stop('');
library.stop(FILE);
await play(library);
expect(library.stats()).toMatchObject({ started: 1, playing: 1 });
});
it('stopping everything silences every voice, and only the named file otherwise', async () => {
const library = new SoundLibrary();
await play(library, FILE);
await play(library, OTHER);
library.stop(FILE);
expect(library.stats()).toMatchObject({ playing: 1, stopped: 1 });
library.stopAll();
expect(library.stats()).toMatchObject({ playing: 0, stopped: 2 });
expect(sources.every((s) => s.stopped)).toBe(true);
});
it('a voice that ends by itself leaves the count, without being stopped', async () => {
const library = new SoundLibrary();
await play(library);
sources[0]?.onended?.();
expect(library.stats()).toMatchObject({ playing: 0, started: 1, stopped: 0 });
});
it('a file that cannot be loaded fails once and is never asked for again', async () => {
const library = new SoundLibrary();
vi.stubGlobal('fetch', () => Promise.resolve({ ok: false, status: 404 }));
vi.spyOn(console, 'error').mockImplementation(() => {});
expect(await library.load(FILE)).toBeNull();
expect(await library.load(FILE)).toBeNull();
expect(library.stats()).toMatchObject({ failed: 1, started: 0 });
library.play(FILE, 1, false);
await settle();
expect(library.stats().started).toBe(0);
});
});