Files
feuerwerk/tests/viewport/sound-library.test.ts
T
adminandClaude Opus 5.5 501a1524ed
game-maker / gate (push) Successful in 3m21s
game-maker / suite (push) Successful in 15m26s
Ramp sound FX parameters over a time on the audio clock
A pitch sweep counted down in On update or On fixed update runs in
game time, while the sound runs on the clock of the audio device. A
short sound magnifies any offset between the two, so the sweep came
out different on every play and most of all on the first.

- Set sound FX parameters has a new input time in seconds. The values
  run evenly to their target in that time, scheduled on the audio
  clock with linearRampToValueAtTime or exponentialRampToValueAtTime.
- Evenly in their own unit: a pitch in semitones and a gain in
  decibels, an EQ band and a Mix share linearly in their value.
- A time of 0 or below keeps the quick glide of about 0.1 s.
- Each change starts from where the voice is now. Whatever was still
  planned is dropped, so a change never queues behind an older one.
  Firefox lacks cancelAndHoldAtTime, there the value is held by hand.
- A voice still waiting on its bytes starts a timed change with the
  sound, so the whole sweep is heard.

Tests:
- node-catalog: time is handed on, a time below 0 is a quick glide.
- sound-library: semitones and decibels ramp in their own steps, a
  timed change on a waiting voice starts with the sound.
- sound-library + node-catalog + soundfx: 419 tests, green.
- e2e 34 on melektaus-atx: ALL PASSED, 31 checks. A 2 s ramp from
  depth 7 to -5 read speed 0.979, 0.926 and 0.778 half a second on.
- Counter-check: folding a timed change on a waiting voice into its
  start values fails the new test.
- The gate did not run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 21:47:13 +02:00

509 lines
18 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 type { FxChain, FxPlan, FxVoice } from '../../src/core/soundfx/nodes';
import { SoundLibrary } from '../../src/viewport/SoundLibrary';
const FILE = 'a.wav';
const OTHER = 'b.wav';
/**
* An AudioParam that remembers where it was sent to glide, where it was sent to ramp – "linear" or
* "exponential", to what, by when – and how often it was held. It has no cancelAndHoldAtTime, as in
* Firefox, so the hold by hand is what runs here.
*/
interface FakeParam {
value: number;
glides: number[];
ramps: [string, number, number][];
holds: number;
setTargetAtTime: (value: number) => void;
cancelScheduledValues: () => void;
setValueAtTime: () => void;
linearRampToValueAtTime: (value: number, at: number) => void;
exponentialRampToValueAtTime: (value: number, at: number) => void;
}
function param(value: number): FakeParam {
const made: FakeParam = {
value,
glides: [],
ramps: [],
holds: 0,
setTargetAtTime: (to) => made.glides.push(to),
cancelScheduledValues: () => {},
setValueAtTime: () => void (made.holds += 1),
linearRampToValueAtTime: (to, at) => made.ramps.push(['linear', to, at]),
exponentialRampToValueAtTime: (to, at) => made.ramps.push(['exponential', to, at]),
};
return made;
}
interface FakeSource {
buffer: unknown;
loop: boolean;
playbackRate: FakeParam;
onended: (() => void) | null;
started: boolean;
stopped: boolean;
connect: (target: unknown) => unknown;
start: () => void;
stop: () => void;
}
const sources: FakeSource[] = [];
/** Every `connect` made, as "from > to" by the kind of node – the order a voice is wired in. */
const wires: string[] = [];
/** The level of every gain node made, in the order they were made. */
const gains: FakeParam[] = [];
const filters: {
type: string;
frequency: { value: number };
gain: FakeParam;
Q: { value: number };
}[] = [];
/** A node that logs where it is connected to and hands the target back, as Web Audio's does. */
function wired<T extends object>(
kind: string,
node: T,
): T & { kind: string; connect: (to: unknown) => unknown } {
return {
...node,
kind,
connect: (to: unknown) => {
wires.push(`${kind} > ${(to as { kind?: string }).kind ?? '?'}`);
return to;
},
};
}
/** Resolves the pending decodes by hand, so a test decides when the bytes arrive. */
let decodes: (() => void)[] = [];
/** A mono buffer as Web Audio hands it out: a length, a rate and the samples. */
function fakeBuffer(_channels: number, length: number, sampleRate: number) {
const data = new Float32Array(length);
return {
numberOfChannels: 1,
length,
sampleRate,
duration: length / sampleRate,
getChannelData: () => data,
};
}
function fakeContext() {
return {
state: 'running',
currentTime: 0,
destination: { kind: 'destination' },
resume: () => Promise.resolve(),
suspend: () => Promise.resolve(),
decodeAudioData: () => new Promise((resolve) => decodes.push(() => resolve(fakeBuffer(1, 1200, 800)))),
createBuffer: fakeBuffer,
createBufferSource: () => {
const source: FakeSource = wired('source', {
buffer: null,
loop: false,
playbackRate: param(1),
onended: null,
started: false,
stopped: false,
start: () => {
source.started = true;
},
stop: () => {
source.stopped = true;
},
});
sources.push(source);
return source;
},
createGain: () => {
const gain = wired('gain', { gain: param(1) });
gains.push(gain.gain);
return gain;
},
createBiquadFilter: () => {
const filter = wired('filter', {
type: '',
frequency: { value: 0 },
gain: param(0),
Q: { value: 0 },
});
filters.push(filter);
return filter;
},
};
}
/** 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;
wires.length = 0;
filters.length = 0;
gains.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();
});
/** A chain as the plan of an FX with nothing left open, so a test can say what it wants to hear. */
function planOf(chain: FxChain): FxPlan {
const stages = chain.stages.map((stage) =>
stage.kind === 'gain'
? { kind: 'gain' as const, db: { value: 20 * Math.log10(stage.gain) } }
: { ...stage, gain: { value: stage.gain } },
);
if ('mix' in chain) return { mix: { value: chain.mix }, a: planOf(chain.a), b: planOf(chain.b), stages };
return { pitch: chain.pitch === 0 ? [] : [{ value: chain.pitch }], stages };
}
/** Plays `file` and lets its bytes arrive, unless `hold` keeps the decode pending; gives the voice. */
async function play(
library: SoundLibrary,
file = FILE,
{ hold = false, owner = '' } = {},
fx?: FxChain | FxVoice,
): Promise<number> {
const voice = library.play(
file,
1,
false,
owner,
fx && ('plan' in fx ? fx : { plan: planOf(fx), params: {} }),
);
await settle();
if (!hold) await arrive();
return voice;
}
async function arrive(): Promise<void> {
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('loads ahead of the first play without opening the device, so that play starts at once', async () => {
const opened: string[] = [];
vi.stubGlobal(
'AudioContext',
class {
constructor() {
opened.push('device');
return fakeContext();
}
},
);
vi.stubGlobal(
'OfflineAudioContext',
class {
constructor() {
opened.push('offline');
return fakeContext();
}
},
);
const library = new SoundLibrary();
library.preload([FILE, OTHER, '']);
await settle();
await arrive();
expect(opened).toEqual(['offline']);
expect(library.stats()).toMatchObject({ decoded: 2, started: 0 });
// No await: the voice runs in the frame that asked for it, not once the bytes are in.
const voice = library.play(FILE, 1, false);
expect(library.stats()).toMatchObject({ started: 1, playing: 1 });
expect(library.tune(voice, {})).toBe(true);
expect(opened).toEqual(['offline', 'device']);
});
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,
pitches: [0],
stages: 0,
duration: 1.5,
});
});
it('plays a dry voice straight into its volume', async () => {
await play(new SoundLibrary());
expect(wires).toEqual(['source > gain', 'gain > destination']);
expect(sources[0]?.playbackRate.value).toBe(1);
});
it('sends a voice through its sound FX in order (#311)', async () => {
const library = new SoundLibrary();
const fx: FxChain = {
pitch: 12,
stages: [
{ kind: 'gain', gain: 0.5 },
{ kind: 'filter', filter: 'lowshelf', frequency: 80, gain: 6, q: 1 },
],
};
await play(library, FILE, {}, fx);
expect(wires).toEqual(['source > gain', 'gain > filter', 'filter > gain', 'gain > destination']);
expect(filters).toMatchObject([
{ type: 'lowshelf', frequency: { value: 80 }, gain: { value: 6 }, Q: { value: 1 } },
]);
expect(library.stats().last).toMatchObject({ pitches: [12], stages: 2 });
});
it('pitches a voice on a copy of its length rather than by speed, and keeps the copy (#312)', async () => {
const library = new SoundLibrary();
await play(library);
await play(library, FILE, {}, { pitch: 7, stages: [] });
await play(library, FILE, {}, { pitch: 7, stages: [] });
await play(library, FILE, {}, { pitch: -3.5, stages: [] });
const [dry, up, again, down] = sources.map((s) => s.buffer as { length: number; duration: number });
expect(sources.map((s) => s.playbackRate.value)).toEqual([1, 1, 1, 1]);
expect(up).not.toBe(dry);
expect(again).toBe(up);
expect(down).not.toBe(up);
expect([up!.duration, down!.duration]).toEqual([dry!.duration, dry!.duration]);
});
it('mixes two sounds by their shares, a source per way, started and stopped together (#316)', async () => {
const library = new SoundLibrary();
await play(
library,
FILE,
{},
{
mix: 0.25,
a: { pitch: 12, stages: [] },
b: { pitch: 0, stages: [{ kind: 'gain', gain: 0.5 }] },
stages: [{ kind: 'filter', filter: 'lowshelf', frequency: 80, gain: 6, q: 1 }],
},
);
expect(wires).toEqual([
'source > gain',
'gain > gain',
'source > gain',
'gain > gain',
'gain > gain',
'gain > filter',
'filter > gain',
'gain > destination',
]);
expect(gains.map((g) => g.value)).toEqual([1, 1, 0.75, expect.closeTo(0.5), 0.25]);
expect(sources.map((s) => s.started)).toEqual([true, true]);
expect(library.stats()).toMatchObject({ playing: 1, started: 1, last: { pitches: [12, 0], stages: 2 } });
sources[0]!.onended!();
expect(library.stats().playing).toBe(1);
library.stop(FILE);
expect(sources.map((s) => s.stopped)).toEqual([true, true]);
expect(library.stats()).toMatchObject({ playing: 0, stopped: 1 });
});
it('gives two ways at the same pitch a source each, so each can glide on its own (#315)', async () => {
const library = new SoundLibrary();
await play(
library,
FILE,
{},
{
mix: 0.5,
a: { pitch: 0, stages: [] },
b: { pitch: 0, stages: [{ kind: 'gain', gain: 0.5 }] },
stages: [],
},
);
expect(sources).toHaveLength(2);
expect(sources[0]!.buffer).toBe(sources[1]!.buffer);
expect(wires.filter((w) => w.startsWith('source'))).toEqual(['source > gain', 'source > gain']);
});
describe('a parameter changed while the voice plays (#315)', () => {
/** Pitch on a, a gain on b, the share between them – all three open – and a band after the Mix. */
const open: FxPlan = {
mix: { value: 0.25, param: 'blend', min: 0, max: 1 },
a: { pitch: [{ value: 0, param: 'depth', min: -64, max: 64 }], stages: [] },
b: { pitch: [], stages: [{ kind: 'gain', db: { value: 0, param: 'loud', min: -120, max: 24 } }] },
stages: [{ kind: 'filter', filter: 'lowshelf', frequency: 80, gain: { value: 6 }, q: 1 }],
};
it('glides every knob it moves, a pitch by speed, and leaves the rest alone', async () => {
const library = new SoundLibrary();
const voice = await play(library, FILE, { owner: 'me' }, { plan: open, params: {} });
// Made in the order: the voice, the Mix, share of a, the gain on b, share of b.
const [, , shareA, loud, shareB] = gains;
expect([shareA!.value, loud!.value, shareB!.value]).toEqual([0.75, 1, 0.25]);
expect(library.tune(voice, { depth: 12, loud: -6, blend: 0.5, nothing: 3 })).toBe(true);
expect(sources[0]!.playbackRate.glides).toEqual([2]);
expect(sources[1]!.playbackRate.glides).toEqual([]);
expect(loud!.glides).toEqual([expect.closeTo(10 ** (-6 / 20))]);
expect([shareA!.glides, shareB!.glides]).toEqual([[0.5], [0.5]]);
expect(filters[0]!.gain.glides).toEqual([]);
expect(library.stats().tuned).toBe(1);
// Only what changed is sent again; one not given keeps its value rather than its default.
library.tune(voice, { depth: 12, blend: 1 });
expect(sources[0]!.playbackRate.glides).toEqual([2]);
expect(loud!.glides).toHaveLength(1);
expect([shareA!.glides, shareB!.glides]).toEqual([
[0.5, 0],
[0.5, 1],
]);
});
it('reaches the one voice it is given – a detached one too – and none that has ended', async () => {
const library = new SoundLibrary();
const voices = [
await play(library, FILE, { owner: 'me' }, { plan: open, params: {} }),
await play(library, FILE, { owner: 'me' }, { plan: open, params: {} }),
await play(library, OTHER, { owner: 'me' }, { plan: open, params: {} }),
await play(library, FILE, {}, { plan: open, params: {} }),
];
expect(new Set(voices).size).toBe(4);
expect(voices).not.toContain(0);
library.tune(voices[1]!, { depth: 12 });
library.tune(voices[3]!, { depth: 12 });
// Two sources a voice, the first of each the pitched way.
expect(sources.map((s) => s.playbackRate.glides.length)).toEqual([0, 0, 1, 0, 0, 0, 1, 0]);
library.stop(FILE);
expect(library.tune(voices[1]!, { depth: 3 })).toBe(false);
expect(library.tune(0, { depth: 3 })).toBe(false);
expect(library.stats().tuned).toBe(2);
});
it('starts a voice still waiting on its bytes with the values handed in meanwhile', async () => {
const library = new SoundLibrary();
const voice = await play(
library,
FILE,
{ hold: true, owner: 'me' },
{ plan: open, params: { blend: 0.5 } },
);
expect(library.tune(voice, { depth: 7 })).toBe(true);
await arrive();
expect(library.stats().last?.pitches).toEqual([7, 0]);
expect(gains.map((g) => g.value)).toContain(0.5);
expect(sources[0]!.playbackRate.glides).toEqual([]);
});
it('runs a timed change evenly on the audio clock: semitones and decibels in their own steps', async () => {
const library = new SoundLibrary();
const voice = await play(library, FILE, { owner: 'me' }, { plan: open, params: {} });
const [, , shareA, loud, shareB] = gains;
library.tune(voice, { depth: 12, loud: -6, blend: 0.5 }, 1.5);
expect(sources[0]!.playbackRate.ramps).toEqual([['exponential', 2, 1.5]]);
expect(loud!.ramps).toEqual([['exponential', expect.closeTo(10 ** (-6 / 20)), 1.5]]);
expect([shareA!.ramps, shareB!.ramps]).toEqual([[['linear', 0.5, 1.5]], [['linear', 0.5, 1.5]]]);
expect([sources[0]!.playbackRate.glides, loud!.glides, shareA!.glides]).toEqual([[], [], []]);
// Each from where it is now: what was planned before is held, not queued behind.
expect([sources[0]!.playbackRate.holds, loud!.holds, shareA!.holds]).toEqual([1, 1, 1]);
// A quick change after it holds the ramp where it is and glides on from there.
library.tune(voice, { depth: 0 });
expect(sources[0]!.playbackRate.holds).toBe(2);
expect(sources[0]!.playbackRate.glides).toEqual([1]);
});
it('starts a timed change to a voice still waiting on its bytes when the voice starts', async () => {
const library = new SoundLibrary();
const voice = await play(library, FILE, { hold: true, owner: 'me' }, { plan: open, params: {} });
library.tune(voice, { depth: -64 }, 1.28);
// A quick change after a timed one keeps its place behind it.
library.tune(voice, { blend: 1 });
expect(library.stats().started).toBe(0);
await arrive();
// The voice is heard at its old pitch and only then sets off, so the whole sweep is heard.
expect(library.stats().last?.pitches).toEqual([0, 0]);
expect(sources[0]!.playbackRate.ramps).toEqual([['exponential', 2 ** (-64 / 12), 1.28]]);
expect(gains[4]!.glides).toEqual([1]);
});
});
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);
});
});