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>
536 lines
20 KiB
TypeScript
536 lines
20 KiB
TypeScript
/**
|
||
* 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);
|
||
});
|
||
});
|