Files
feuerwerk/tests/viewport/sound-library.test.ts
T
adminandClaude Opus 5.5 e7a360db73
game-maker / gate (push) Successful in 3m15s
game-maker / suite (push) Successful in 15m36s
Offer the easings of an animation key on Lerp and sound FX ramps
A timed Set sound FX parameters ran only linearly, and a Lerp had four
quadratic eases of its own. An animation key already had the full set.
Now all three offer the same choice: Linear, Step, the ten curves from
Sine to Bounce with In, Out or In-out, and a drawn Curve asset.

- easingParams in src/core/graph/nodes/easing.ts gives a node the
  fields Easing, Direction and Curve from the key's own lists, and
  easingOfParams reads them back as a key interpolation.
- There is no migration. A Lerp saved with the old in, out or inOut
  runs linear until its easing is picked again.
- Set sound FX parameters hands the easing to the player. Linear and
  Step stay ramps of the audio clock, every other easing goes to
  setValueCurveAtTime as 200 samples a second, in semitones and dB.
- Step now reaches its end at t 1. A key never asked for that, it
  reads the next key itself, so animations play as before.
- The curve-in-use count and the warning for a gone curve cover the
  sound node too.

Tests:
- node-catalog: Lerp with cubic in, quad out and in-out, a curve
  without a direction, step, an unknown easing as linear. The sound
  node hands on a curve, step and a gone curve as linear.
- sound-library: an eased change becomes a curve in semitones and
  decibels, a step a jump at the end.
- node-catalog and curve after dropping the old values: 399, green.
- e2e 6, 34, 53 on melektaus-atx: ALL PASSED, 198 checks. In Chrome a
  2 s Quad in from depth 7 to -5 read speed 0.864 early and 0.5 at the
  end, a 1 s Step held 1 and then jumped to 0.5.
- Counter-check: with curves sent as linear ramps, the curve test of
  the sound library fails.

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

536 lines
20 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",
* "exponential", "set" for a jump later on or "curve" with the samples' first, middle and last value,
* to what, by when – and how often it was held. It has no cancelAndHoldAtTime, as in Firefox, so the
* hold by hand (a set at the clock's 0) is what runs here.
*/
interface FakeParam {
value: number;
glides: number[];
ramps: [string, number | number[], number][];
holds: number;
setTargetAtTime: (value: number) => void;
cancelScheduledValues: () => void;
setValueAtTime: (value: number, at: number) => void;
setValueCurveAtTime: (values: Float32Array, at: number, time: number) => 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: (to, at) => (at > 0 ? void made.ramps.push(['set', to, at]) : void (made.holds += 1)),
setValueCurveAtTime: (values, at, time) =>
made.ramps.push(['curve', [values[0]!, values[(values.length - 1) / 2]!, values.at(-1)!], at + time]),
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('runs a timed change along an easing as a curve, each number in its own steps', async () => {
const library = new SoundLibrary();
const voice = await play(library, FILE, { owner: 'me' }, { plan: open, params: {} });
const loud = gains[3]!;
const from = loud.value;
library.tune(voice, { depth: 12, loud: -6 }, 1.5, (t) => t * t);
// Half the time is a quarter of the way: a quarter of the octave, a quarter of the decibels.
expect(sources[0]!.playbackRate.ramps).toEqual([['curve', [1, expect.closeTo(2 ** 0.25), 2], 1.5]]);
const quarter = from * (10 ** (-6 / 20) / from) ** 0.25;
expect(loud.ramps).toEqual([
['curve', [from, expect.closeTo(quarter), expect.closeTo(10 ** (-6 / 20))], 1.5],
]);
expect(sources[0]!.playbackRate.holds).toBe(1);
});
it('a step holds the value for the time and then jumps', async () => {
const library = new SoundLibrary();
const voice = await play(library, FILE, { owner: 'me' }, { plan: open, params: {} });
library.tune(voice, { depth: 12 }, 2, 'step');
expect(sources[0]!.playbackRate.ramps).toEqual([['set', 2, 2]]);
expect(sources[0]!.playbackRate.glides).toEqual([]);
});
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);
});
});