Patterns could only be heard one at a time, in their own tab or from a behavior. A song lays patterns and sounds out on tracks along one time axis and plays them through the scheduler of #332, the third piece of the music tools of #330. - New asset type song under the heading Songs, with four empty tracks, 120 BPM in 4/4 and a grid of a bar. Tempo, meter and grid are rows in the inspector. Tracks, items and the loop are the asset's data, held to their rules on load and in every command. - A track has a name, volume, pan, mute and solo. Each is heard on a bus of its own, so a change reaches what is sounding at once. - A pattern item repeats its pattern for as long as it is, with a transpose and another instrument if wanted. A sound item plays its sound as a clip at its own speed, round and round, through a sound FX if wanted. Both have a volume on top of their track's. - Cut back at the left end or split, an item starts as far into what it plays as the cut is: ticks into a pattern, seconds into a sound. - The tab shows the heads with M, S, volume and pan, the ruler of the Pattern tab and the items, a pattern with its notes, a sound with its waveform, a missing asset red. Patterns and sounds are dragged in from the Assets panel. Body, right and left end move, lengthen and cut back, Alt leaves the grid. Ctrl+C/V/D/A, Ctrl+E splits where playing starts, Delete, arrows. A double-click opens a pattern item in its tab. - What is picked is a new selection of its own, so the inspector shows the item or the track, with a button to delete it. - The scheduler plans clips beside notes. Where the playhead starts or jumps back inside a clip, it is taken up that far into its sound. A clip fades out at the end of its item or of the loop. - The editor plays one piece at a time: a pattern started stops the song and the other way round. - The rules are pure functions in core/music/song, editor/song/ songView and songEdit. Findings: - The suites of4ea752fand2a3b90bwere red at e2e 54: the Pattern pushed Struct below the fold of the + menu. The module now scrolls to it, as e2e 47 does for Vector art. It also left the menu open, which made e2e 55 fail behind it. - The Assets panel only dragged assets a scene can place. Patterns and sounds are dragged now as well, and a scene says why it does not take one instead of calling every asset a scene. Tests: - song.test.ts (new, 14) and song-edit.test.ts (new, 9): model, round trip, sequence of patterns and clips, mute and solo, cuts, clips on the scheduler, moving, sizing, trimming, splitting, duplicating, hits, head parts. sound-library.test.ts +5: clip offset, end fade, pitched clip, track bus, peaks. - e2e 65 (new) on melektaus-atx: ALL PASSED, 18 checks. e2e 54 and 55 with the fix: ALL PASSED, 33 checks. e2e 4 with 30 asset types: ALL PASSED, 62 checks. - Gate on melektaus-atx: CHECK EXIT 0, 250 test files, 3884 tests. - Counter-checks: no taking up of a clip, no pattern offset, no clip end each fail 3, 1 and 1 tests. - Probe on stein2: the tab with four tracks, before and while playing, looked at in two screenshots. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1416 lines
54 KiB
TypeScript
1416 lines
54 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 { FxEnvelopePlan, FxPlan, FxStage, FxStagePlan, FxVoice } from '../../src/core/soundfx/nodes';
|
||
import type { PitchOptions } from '../../src/viewport/pitchWorklet';
|
||
import { SoundLibrary } from '../../src/viewport/SoundLibrary';
|
||
|
||
/** What a test wants to hear: a chain whose pitch, where it is not 0, is a Pitch typed in. */
|
||
type Chain =
|
||
| { pitch: number; stages: readonly FxStage[] }
|
||
| { mix: number; a: Chain; b: Chain; stages: readonly FxStage[] };
|
||
|
||
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;
|
||
/** The second of the audio clock it was started at, and how far into its sound (#334). */
|
||
startedAt: number;
|
||
startedOffset: number;
|
||
stopped: boolean;
|
||
connect: (target: unknown) => unknown;
|
||
start: (at?: number, offset?: number) => void;
|
||
stop: () => void;
|
||
}
|
||
|
||
/** The pitch worklet of a way (#317): what it was made with, its pitch and what was posted to it. */
|
||
interface FakeWorklet {
|
||
options: { outputChannelCount: number[]; parameterData: { pitch: number }; processorOptions: PitchOptions };
|
||
pitch: FakeParam;
|
||
parameters: Map<string, FakeParam>;
|
||
posted: unknown[];
|
||
port: { postMessage: (message: unknown) => void; onmessage: ((event: { data: unknown }) => void) | null };
|
||
disconnected: boolean;
|
||
connect: (target: unknown) => unknown;
|
||
disconnect: () => void;
|
||
}
|
||
|
||
const worklets: FakeWorklet[] = [];
|
||
/** Every module loaded into a context, and what the next load does. */
|
||
const modules: string[] = [];
|
||
let loadModule: () => Promise<void> = () => Promise.resolve();
|
||
|
||
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: FakeParam;
|
||
gain: FakeParam;
|
||
Q: { value: number };
|
||
}[] = [];
|
||
/** Every delay line, with the longest time it was made for. */
|
||
const delays: { max: number; delayTime: FakeParam }[] = [];
|
||
/** The kinds of node that were let go, in order. */
|
||
const disconnects: string[] = [];
|
||
/**
|
||
* The silent sources the library times things on the audio clock with (#324): when each was told to
|
||
* stop, and whether it has. `clockTo` lets the clock run.
|
||
*/
|
||
const clocks: {
|
||
offset: FakeParam;
|
||
stopAt: number;
|
||
fired: boolean;
|
||
started: boolean;
|
||
stopped: boolean;
|
||
onended: (() => void) | null;
|
||
}[] = [];
|
||
/** The pan of every track's panner made (#334), in order. */
|
||
const panners: FakeParam[] = [];
|
||
/** The curve of every WaveShaper made, in order. */
|
||
const shapers: { curve: Float32Array | null }[] = [];
|
||
let contextState = 'running';
|
||
|
||
/** Runs the audio clock up to `until`, ending each timer on the way in order, also ones set meanwhile. */
|
||
function clockTo(until = Infinity): void {
|
||
for (;;) {
|
||
const next = clocks.filter((c) => !c.fired && c.stopAt <= until).sort((a, b) => a.stopAt - b.stopAt)[0];
|
||
if (!next) return;
|
||
next.fired = true;
|
||
next.onended?.();
|
||
}
|
||
}
|
||
|
||
/** 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; disconnect: () => void } {
|
||
return {
|
||
disconnect: () => void disconnects.push(kind),
|
||
...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 {
|
||
get state() {
|
||
return contextState;
|
||
},
|
||
currentTime: 0,
|
||
sampleRate: 48000,
|
||
audioWorklet: {
|
||
addModule: (url: string) => {
|
||
modules.push(url);
|
||
return loadModule();
|
||
},
|
||
},
|
||
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,
|
||
startedAt: -1,
|
||
startedOffset: 0,
|
||
stopped: false,
|
||
start: (at?: number, offset?: number) => {
|
||
source.started = true;
|
||
source.startedAt = at ?? 0;
|
||
source.startedOffset = offset ?? 0;
|
||
},
|
||
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: param(0),
|
||
gain: param(0),
|
||
Q: { value: 0 },
|
||
});
|
||
filters.push(filter);
|
||
return filter;
|
||
},
|
||
createDelay: (max: number) => {
|
||
const delay = wired('delay', { max, delayTime: param(0) });
|
||
delays.push(delay);
|
||
return delay;
|
||
},
|
||
createChannelMerger: () => wired('merger', {}),
|
||
createStereoPanner: () => {
|
||
const panner = wired('panner', { pan: param(0) });
|
||
panners.push(panner.pan);
|
||
return panner;
|
||
},
|
||
createChannelSplitter: () => wired('splitter', {}),
|
||
createConstantSource: () => {
|
||
const clock = wired('clock', {
|
||
offset: param(1),
|
||
stopAt: Infinity,
|
||
fired: false,
|
||
started: false,
|
||
stopped: false,
|
||
onended: null as (() => void) | null,
|
||
start: () => {
|
||
clock.started = true;
|
||
},
|
||
stop: (at?: number) => {
|
||
clock.stopAt = at ?? Infinity;
|
||
clock.stopped = true;
|
||
},
|
||
});
|
||
clocks.push(clock);
|
||
return clock;
|
||
},
|
||
createWaveShaper: () => {
|
||
const shaper = wired('shaper', { curve: null as Float32Array | null });
|
||
shapers.push(shaper);
|
||
return shaper;
|
||
},
|
||
};
|
||
}
|
||
|
||
/** 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;
|
||
worklets.length = 0;
|
||
modules.length = 0;
|
||
loadModule = () => Promise.resolve();
|
||
wires.length = 0;
|
||
filters.length = 0;
|
||
gains.length = 0;
|
||
delays.length = 0;
|
||
disconnects.length = 0;
|
||
clocks.length = 0;
|
||
shapers.length = 0;
|
||
panners.length = 0;
|
||
contextState = 'running';
|
||
decodes = [];
|
||
vi.stubGlobal(
|
||
'AudioContext',
|
||
class {
|
||
constructor() {
|
||
return fakeContext();
|
||
}
|
||
},
|
||
);
|
||
vi.stubGlobal(
|
||
'AudioWorkletNode',
|
||
class {
|
||
constructor(_context: unknown, _name: string, options: FakeWorklet['options']) {
|
||
const pitch = param(options.parameterData.pitch);
|
||
const node: FakeWorklet = wired('pitch', {
|
||
options,
|
||
pitch,
|
||
parameters: new Map([['pitch', pitch]]),
|
||
posted: [] as unknown[],
|
||
port: {
|
||
postMessage: (message: unknown) => node.posted.push(message),
|
||
onmessage: null,
|
||
},
|
||
disconnected: false,
|
||
disconnect: () => {
|
||
node.disconnected = true;
|
||
},
|
||
});
|
||
worklets.push(node);
|
||
return node;
|
||
}
|
||
},
|
||
);
|
||
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: Chain): FxPlan {
|
||
const stages = chain.stages.map((stage): FxStagePlan => {
|
||
if (stage.kind === 'gain') return { kind: 'gain', db: { value: 20 * Math.log10(stage.gain) } };
|
||
if (stage.kind === 'filter') return { ...stage, gain: { value: stage.gain } };
|
||
if (stage.kind !== 'delay') throw new Error(`no plan for a ${stage.kind} here`);
|
||
return {
|
||
...stage,
|
||
time: { value: stage.time },
|
||
feedback: { value: stage.feedback },
|
||
// The cutoff back to the damping that makes it: 0 is 20 kHz, every 1/5.32 an octave less.
|
||
damping: { value: Math.log(stage.damping / 20000) / Math.log(500 / 20000) },
|
||
mix: { value: stage.mix },
|
||
};
|
||
});
|
||
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 };
|
||
}
|
||
|
||
/** Pitch on a, a gain on b, the share between them – all three open – and a band after the Mix. */
|
||
function open(): FxPlan {
|
||
return {
|
||
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 }],
|
||
};
|
||
}
|
||
|
||
/** 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?: Chain | 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,
|
||
layers: 1,
|
||
pitches: [0],
|
||
stages: 0,
|
||
duration: 1.5,
|
||
at: 0,
|
||
envelopes: 0,
|
||
});
|
||
});
|
||
|
||
it('plays a dry voice straight into its volume, without loading the pitch worklet', async () => {
|
||
await play(new SoundLibrary());
|
||
expect(wires).toEqual(['source > gain', 'gain > destination']);
|
||
expect(sources[0]?.playbackRate.value).toBe(1);
|
||
expect(modules).toEqual([]);
|
||
});
|
||
|
||
it('sends a voice through its sound FX in order (#311)', async () => {
|
||
const library = new SoundLibrary();
|
||
const fx: Chain = {
|
||
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(['pitch > 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('plays a pitched way through the worklet at the length of the sound, its samples sent once (#317)', async () => {
|
||
const library = new SoundLibrary();
|
||
await play(library);
|
||
await play(library, FILE, {}, { pitch: 7, stages: [] });
|
||
await play(library, FILE, {}, { pitch: -3.5, stages: [] });
|
||
await play(library, OTHER, {}, { pitch: 7, stages: [] });
|
||
expect(modules).toHaveLength(1);
|
||
expect(sources).toHaveLength(1);
|
||
expect(worklets.map((w) => w.options.parameterData.pitch)).toEqual([7, -3.5, 7]);
|
||
expect(worklets.map((w) => w.options.processorOptions.file)).toEqual([FILE, FILE, OTHER]);
|
||
// The fake decodes at 800 Hz into a context at 48 kHz.
|
||
expect(worklets[0]!.options).toMatchObject({
|
||
outputChannelCount: [1],
|
||
processorOptions: { step: 800 / 48000, loop: false },
|
||
});
|
||
expect(worklets.map((w) => w.options.processorOptions.channels?.[0]?.length)).toEqual([
|
||
1200,
|
||
undefined,
|
||
1200,
|
||
]);
|
||
expect(library.stats().last).toMatchObject({ pitches: [7], duration: 1.5 });
|
||
});
|
||
|
||
it('a play that needs the worklet waits for it to load, as it would for its bytes', async () => {
|
||
let loaded = () => {};
|
||
loadModule = () => new Promise((resolve) => (loaded = resolve));
|
||
const library = new SoundLibrary();
|
||
await play(library);
|
||
await play(library, FILE, {}, { pitch: 7, stages: [] });
|
||
expect(library.stats()).toMatchObject({ started: 1 });
|
||
loaded();
|
||
await settle();
|
||
expect(library.stats()).toMatchObject({ started: 2, last: { pitches: [7] } });
|
||
expect(worklets).toHaveLength(1);
|
||
});
|
||
|
||
it('without the worklet a pitched way plays at its own pitch rather than not at all', async () => {
|
||
loadModule = () => Promise.reject(new Error('no worklets here'));
|
||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||
const library = new SoundLibrary();
|
||
const voice = await play(library, FILE, {}, { plan: open(), params: {} });
|
||
expect(worklets).toHaveLength(0);
|
||
expect(sources).toHaveLength(2);
|
||
expect(library.tune(voice, { depth: 12 })).toBe(true);
|
||
expect(library.levels(FILE)).toEqual([[12, 0.75, 1, 0.25, 6]]);
|
||
expect(console.error).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
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([
|
||
'pitch > 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]);
|
||
expect(library.stats()).toMatchObject({ playing: 1, started: 1, last: { pitches: [12, 0], stages: 2 } });
|
||
|
||
worklets[0]!.port.onmessage!({ data: 'ended' });
|
||
expect(library.stats().playing).toBe(1);
|
||
library.stop(FILE);
|
||
expect(library.stats()).toMatchObject({ playing: 0, stopped: 1 });
|
||
// Both ways go on for the fade, then both stop.
|
||
expect(sources.map((s) => s.stopped)).toEqual([false]);
|
||
expect(worklets[0]).toMatchObject({ posted: [], disconnected: false });
|
||
clockTo();
|
||
expect(sources.map((s) => s.stopped)).toEqual([true]);
|
||
expect(worklets[0]).toMatchObject({ posted: ['stop'], disconnected: true });
|
||
});
|
||
|
||
it('a pitched voice that ends by itself leaves the count when the worklet says so', async () => {
|
||
const library = new SoundLibrary();
|
||
await play(library, FILE, {}, { pitch: 3, stages: [] });
|
||
worklets[0]!.port.onmessage!({ data: 'something else' });
|
||
expect(library.stats().playing).toBe(1);
|
||
worklets[0]!.port.onmessage!({ data: 'ended' });
|
||
expect(library.stats()).toMatchObject({ playing: 0, stopped: 0 });
|
||
});
|
||
|
||
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 Mix with the curve "Both at center"', () => {
|
||
/** Two plain ways, the share open as `blend`. */
|
||
const both = (): FxVoice => ({
|
||
plan: {
|
||
mix: { value: 0.25, param: 'blend', min: 0, max: 1 },
|
||
curve: 'both',
|
||
a: { pitch: [], stages: [] },
|
||
b: { pitch: [], stages: [] },
|
||
stages: [],
|
||
},
|
||
params: {},
|
||
});
|
||
|
||
it('shapes both shares from one source carrying the mix, so a glide follows the curve', async () => {
|
||
const library = new SoundLibrary();
|
||
const voice = await play(library, FILE, {}, both());
|
||
// The voice, the Mix, share of a, share of b: the shares hold nothing of their own.
|
||
const [, , shareA, shareB] = gains;
|
||
expect([shareA!.value, shareB!.value]).toEqual([0, 0]);
|
||
expect(shapers.map((s) => [...s.curve!])).toEqual([
|
||
[1, 1, 1, 1, 0],
|
||
[0, 0, 0, 1, 1],
|
||
]);
|
||
expect(wires.filter((w) => w.startsWith('clock') || w.startsWith('shaper'))).toEqual([
|
||
'clock > shaper',
|
||
'shaper > ?',
|
||
'clock > shaper',
|
||
'shaper > ?',
|
||
]);
|
||
const [driver] = clocks;
|
||
expect(driver).toMatchObject({ started: true, stopped: false, offset: { value: 0.25 } });
|
||
expect(library.levels(FILE)).toEqual([[0.25]]);
|
||
|
||
expect(library.tune(voice, { blend: 0.75 })).toBe(true);
|
||
expect(driver!.offset.glides).toEqual([0.75]);
|
||
expect([shareA!.glides, shareB!.glides]).toEqual([[], []]);
|
||
});
|
||
|
||
it('stops that source with the voice, cut or ended by itself', async () => {
|
||
const library = new SoundLibrary();
|
||
await play(library, FILE, {}, both());
|
||
library.stop(FILE);
|
||
expect(clocks[0]!.stopped).toBe(false);
|
||
clockTo();
|
||
expect(clocks[0]!.stopped).toBe(true);
|
||
|
||
await play(library, FILE, {}, both());
|
||
const driver = clocks.find((c) => !c.stopped && c.started && c.offset.value === 0.25)!;
|
||
for (const source of sources.slice(-2)) source.onended?.();
|
||
expect(library.stats().playing).toBe(0);
|
||
expect(driver.stopped).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('a parameter changed while the voice plays (#315)', () => {
|
||
it('glides every knob it moves, a pitch in semitones, 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(worklets[0]!.pitch.glides).toEqual([12]);
|
||
expect(sources[0]!.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(worklets[0]!.pitch.glides).toEqual([12]);
|
||
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 });
|
||
expect(worklets.map((w) => w.pitch.glides.length)).toEqual([0, 1, 0, 1]);
|
||
|
||
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(worklets[0]!.options.parameterData.pitch).toBe(7);
|
||
expect(worklets[0]!.pitch.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);
|
||
const pitch = worklets[0]!.pitch;
|
||
expect(pitch.ramps).toEqual([['linear', 12, 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([pitch.glides, loud!.glides, shareA!.glides]).toEqual([[], [], []]);
|
||
// Each from where it is now: what was planned before is held, not queued behind.
|
||
expect([pitch.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(pitch.holds).toBe(2);
|
||
expect(pitch.glides).toEqual([0]);
|
||
});
|
||
|
||
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(worklets[0]!.pitch.ramps).toEqual([['curve', [0, expect.closeTo(3), 12], 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(worklets[0]!.pitch.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(worklets[0]!.pitch.ramps).toEqual([['set', 12, 2]]);
|
||
expect(worklets[0]!.pitch.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(worklets[0]!.pitch.ramps).toEqual([['linear', -64, 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 });
|
||
clockTo();
|
||
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);
|
||
});
|
||
describe('the end of a voice (#324)', () => {
|
||
/** One echo after a quarter second, at half the volume: a tail of 0.25 s. */
|
||
const echo = (feedback = 0, pingPong = false): FxStage => ({
|
||
kind: 'delay',
|
||
time: 0.25,
|
||
feedback,
|
||
damping: 20000,
|
||
mix: 0.5,
|
||
pingPong,
|
||
});
|
||
|
||
it('fades a stopped voice out instead of cutting it, then lets its nodes go', async () => {
|
||
const library = new SoundLibrary();
|
||
await play(library);
|
||
library.stop(FILE);
|
||
const level = gains[0]!;
|
||
expect(level.ramps).toEqual([['linear', 0, 0.01]]);
|
||
expect(level.holds).toBe(1);
|
||
expect(sources[0]!.stopped).toBe(false);
|
||
expect(disconnects).toEqual([]);
|
||
clockTo(0.005);
|
||
expect(sources[0]!.stopped).toBe(false);
|
||
clockTo(0.01);
|
||
expect(sources[0]!.stopped).toBe(true);
|
||
expect(disconnects.filter((kind) => kind !== 'clock')).toEqual(['gain']);
|
||
});
|
||
|
||
it('cuts at once where the clock stands still, so a paused run cannot sound in the next', async () => {
|
||
const library = new SoundLibrary();
|
||
await play(library);
|
||
contextState = 'suspended';
|
||
library.stopAll();
|
||
expect(sources[0]!.stopped).toBe(true);
|
||
expect(clocks).toHaveLength(0);
|
||
});
|
||
|
||
it('wires an echo: the sound goes on, the line feeds back through the damping, added at mix', async () => {
|
||
await play(new SoundLibrary(), FILE, {}, { pitch: 0, stages: [echo(0.4)] });
|
||
expect(delays).toMatchObject([{ max: 2, delayTime: { value: 0.25 } }]);
|
||
expect(filters.map((f) => [f.type, f.frequency.value])).toEqual([['lowpass', 20000]]);
|
||
expect(filters[0]!.Q.value).toBeCloseTo(-3.0103, 3);
|
||
expect(wires).toEqual([
|
||
'source > gain', // the source through its fade
|
||
'gain > gain', // the sound on, as it is
|
||
'delay > filter',
|
||
'filter > gain', // into the mix
|
||
'gain > gain', // and out
|
||
'gain > delay', // the sound into the line
|
||
'filter > gain', // round again,
|
||
'gain > delay', // at the feedback
|
||
'gain > gain', // the fade into the echo
|
||
'gain > gain',
|
||
'gain > destination',
|
||
]);
|
||
// The voice, the fade made before it, the echo's in, out, feedback and mix.
|
||
expect(gains.map((g) => g.value)).toEqual([1, 1, 1, 1, 0.4, 0.5]);
|
||
});
|
||
|
||
it('wires a ping-pong echo into the left only, crossing sides on every round', async () => {
|
||
await play(new SoundLibrary(), FILE, {}, { pitch: 0, stages: [echo(0.4, true)] });
|
||
expect(wires).toEqual(
|
||
expect.arrayContaining(['gain > merger', 'merger > delay', 'filter > splitter', 'splitter > merger']),
|
||
);
|
||
expect(wires.filter((w) => w === 'splitter > merger')).toHaveLength(2);
|
||
expect(wires.filter((w) => w.endsWith('> delay'))).toEqual(['merger > delay', 'gain > delay']);
|
||
});
|
||
|
||
it('rings on after its sound has ended, still reached, and goes when the echoes have faded', async () => {
|
||
const library = new SoundLibrary();
|
||
const voice = await play(library, FILE, {}, { pitch: 0, stages: [echo()] });
|
||
expect(library.tail(voice)).toBe(0.25);
|
||
sources[0]!.onended!();
|
||
expect(library.stats()).toMatchObject({ playing: 1, stopped: 0 });
|
||
expect(library.tune(voice, {})).toBe(true);
|
||
clockTo(0.24);
|
||
expect(library.stats().playing).toBe(1);
|
||
clockTo(0.25);
|
||
expect(library.stats()).toMatchObject({ playing: 0, stopped: 0 });
|
||
expect(library.tune(voice, {})).toBe(false);
|
||
expect(library.tail(voice)).toBe(0);
|
||
// An echo is a loop: it is let go, where a dry voice is simply left to end.
|
||
expect(disconnects.filter((kind) => kind !== 'clock')).toEqual(['gain']);
|
||
});
|
||
|
||
it('a dry voice ends with its sound, as before', async () => {
|
||
const library = new SoundLibrary();
|
||
const voice = await play(library);
|
||
expect(library.tail(voice)).toBe(0);
|
||
sources[0]!.onended!();
|
||
expect(library.stats().playing).toBe(0);
|
||
expect(clocks).toHaveLength(0);
|
||
});
|
||
|
||
it('counts a tail again when a change makes it longer', async () => {
|
||
const library = new SoundLibrary();
|
||
const plan: FxPlan = {
|
||
pitch: [],
|
||
stages: [
|
||
{
|
||
kind: 'delay',
|
||
time: { value: 0.25 },
|
||
feedback: { value: 0, param: 'back', min: 0, max: 0.95 },
|
||
damping: { value: 0 },
|
||
mix: { value: 1 },
|
||
pingPong: false,
|
||
},
|
||
],
|
||
};
|
||
const voice = await play(library, FILE, {}, { plan, params: {} });
|
||
sources[0]!.onended!();
|
||
// Half as loud each round: ten echoes are heard, ending 2.5 s on – from where the change ends.
|
||
library.tune(voice, { back: 0.5 }, 1);
|
||
expect(library.tail(voice)).toBe(2.5);
|
||
clockTo(3.4);
|
||
expect(library.stats().playing).toBe(1);
|
||
clockTo(3.5);
|
||
expect(library.stats().playing).toBe(0);
|
||
});
|
||
|
||
it('counts an echo synced to beats by the tempo of the voice, never past the line (#332)', async () => {
|
||
const library = new SoundLibrary();
|
||
const synced = (quarters: number): FxPlan => ({
|
||
pitch: [],
|
||
stages: [
|
||
{
|
||
kind: 'delay',
|
||
time: { value: quarters },
|
||
beats: true,
|
||
feedback: { value: 0 },
|
||
damping: { value: 0 },
|
||
mix: { value: 1 },
|
||
pingPong: false,
|
||
},
|
||
],
|
||
});
|
||
const voice = await play(library, FILE, {}, { plan: synced(1), params: {}, tempo: 90 });
|
||
expect(delays[0]!.delayTime.value).toBeCloseTo(60 / 90);
|
||
expect(library.tail(voice)).toBeCloseTo(60 / 90);
|
||
// Four quarters at 120 BPM are 2 s, which is as long as the line is; eight would be 4 s.
|
||
await play(library, FILE, {}, { plan: synced(8), params: {} });
|
||
expect(delays[1]!.delayTime.value).toBe(2);
|
||
});
|
||
|
||
it('lets the echoes ring when asked to: only the sound fades, the voice stays until they are gone', async () => {
|
||
const library = new SoundLibrary();
|
||
await play(library, FILE, {}, { pitch: 0, stages: [echo()] });
|
||
library.stop(FILE, true);
|
||
const [level, fade] = gains;
|
||
expect(fade!.ramps).toEqual([['linear', 0, 0.01]]);
|
||
expect(level!.ramps).toEqual([]);
|
||
expect(library.stats()).toMatchObject({ playing: 1, stopped: 1 });
|
||
clockTo(0.01);
|
||
expect(sources[0]!.stopped).toBe(true);
|
||
// Its own end afterwards changes nothing: it is ringing already.
|
||
sources[0]!.onended?.();
|
||
expect(library.stats().playing).toBe(1);
|
||
clockTo(0.26);
|
||
expect(library.stats()).toMatchObject({ playing: 0, stopped: 1 });
|
||
});
|
||
|
||
it('a stop without letting ring cuts a ringing voice short, and asking again changes nothing', async () => {
|
||
const library = new SoundLibrary();
|
||
await play(library, FILE, {}, { pitch: 0, stages: [echo()] });
|
||
library.stop(FILE, true);
|
||
library.stop(FILE, true);
|
||
expect(library.stats()).toMatchObject({ playing: 1, stopped: 1 });
|
||
library.stop(FILE);
|
||
expect(library.stats()).toMatchObject({ playing: 0, stopped: 2 });
|
||
expect(gains[0]!.ramps).toEqual([['linear', 0, 0.01]]);
|
||
});
|
||
|
||
it('a voice without echoes is simply faded out when asked to let ring', async () => {
|
||
const library = new SoundLibrary();
|
||
await play(library);
|
||
library.stop(FILE, true);
|
||
expect(library.stats()).toMatchObject({ playing: 0, stopped: 1 });
|
||
expect(gains[0]!.ramps).toEqual([['linear', 0, 0.01]]);
|
||
});
|
||
|
||
it('stops one voice by its number, also one still waiting on its bytes', async () => {
|
||
const library = new SoundLibrary();
|
||
const first = await play(library);
|
||
const second = await play(library);
|
||
library.stopVoice(first);
|
||
expect(library.stats()).toMatchObject({ playing: 1, stopped: 1 });
|
||
expect(library.tune(second, {})).toBe(true);
|
||
|
||
const waiting = await play(library, OTHER, { hold: true });
|
||
library.stopVoice(waiting);
|
||
await arrive();
|
||
expect(library.stats()).toMatchObject({ started: 2, playing: 1 });
|
||
});
|
||
|
||
it('moves the numbers of an echo while it plays, the damping evenly in octaves', async () => {
|
||
const library = new SoundLibrary();
|
||
const plan: FxPlan = {
|
||
pitch: [],
|
||
stages: [
|
||
{
|
||
kind: 'delay',
|
||
time: { value: 0.25, param: 'time', min: 0.01, max: 2 },
|
||
feedback: { value: 0.4 },
|
||
damping: { value: 0, param: 'dull', min: 0, max: 1 },
|
||
mix: { value: 0.5, param: 'wet', min: 0, max: 1 },
|
||
pingPong: false,
|
||
},
|
||
],
|
||
};
|
||
const voice = await play(library, FILE, {}, { plan, params: {} });
|
||
library.tune(voice, { time: 0.5, dull: 1, wet: 0.2 }, 2);
|
||
expect(delays[0]!.delayTime.ramps).toEqual([['linear', 0.5, 2]]);
|
||
expect(filters[0]!.frequency.ramps).toEqual([['exponential', expect.closeTo(500), 2]]);
|
||
expect(gains.at(-1)!.ramps).toEqual([['linear', 0.2, 2]]);
|
||
expect(library.levels(FILE)).toEqual([[0.25, 0.4, 20000, 0.5]]);
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('notes of an instrument (#331)', () => {
|
||
/** A way that plays its own file, as a Sample makes it. */
|
||
const sample = (
|
||
file: string,
|
||
extra: Partial<NonNullable<Extract<FxPlan, { pitch: unknown }>['source']>> = {},
|
||
) =>
|
||
({
|
||
pitch: [],
|
||
stages: [],
|
||
source: { file, volume: 0.5, loop: false, loopStart: 0, loopEnd: 0, gate: true, ...extra },
|
||
}) as FxPlan;
|
||
|
||
async function note(library: SoundLibrary, layers: FxPlan[], duration = 0): Promise<number> {
|
||
const voice = library.playNote('instrument:i', layers, {}, 1, '', duration);
|
||
await settle();
|
||
await arrive();
|
||
return voice;
|
||
}
|
||
|
||
it('plays its layers side by side as one voice, each way its own file behind its own volume', async () => {
|
||
const library = new SoundLibrary();
|
||
const voice = await note(library, [sample(FILE), sample(OTHER, { gate: false })]);
|
||
expect(voice).toBe(1);
|
||
expect(library.stats()).toMatchObject({ started: 1, playing: 1, decoded: 2 });
|
||
expect(library.stats().last).toMatchObject({ file: 'instrument:i', layers: 2, duration: 1.5 });
|
||
expect(sources.map((source) => source.started)).toEqual([true, true]);
|
||
// Voice gain first, then a fade per way at the sound's volume.
|
||
expect(gains.map((gain) => gain.value)).toEqual([1, 0.5, 0.5]);
|
||
expect(wires).toEqual([
|
||
'source > gain',
|
||
'gain > gain',
|
||
'source > gain',
|
||
'gain > gain',
|
||
'gain > destination',
|
||
]);
|
||
});
|
||
|
||
it('a release fades out the ways that end with the note and lets a one-shot play on', async () => {
|
||
const library = new SoundLibrary();
|
||
const voice = await note(library, [sample(FILE), sample(OTHER, { gate: false })]);
|
||
library.release(voice);
|
||
expect(gains[1]!.ramps).toEqual([['linear', 0, 0.01]]);
|
||
expect(gains[2]!.ramps).toEqual([]);
|
||
clockTo();
|
||
expect(sources.map((source) => source.stopped)).toEqual([true, false]);
|
||
expect(library.stats().playing).toBe(1);
|
||
// The one-shot ends by itself: now the voice is gone.
|
||
sources[1]!.onended?.();
|
||
expect(library.stats().playing).toBe(0);
|
||
});
|
||
|
||
it('a note with a duration is released that long after it starts, its fade set on the audio clock at once (#332)', async () => {
|
||
const library = new SoundLibrary();
|
||
await note(library, [sample(FILE)], 0.5);
|
||
expect(gains[1]!.ramps).toEqual([
|
||
['set', 0.5, 0.5],
|
||
['linear', 0, 0.51],
|
||
]);
|
||
expect(clocks.map((clock) => clock.stopAt)).toEqual([0.51]);
|
||
expect(sources[0]!.stopped).toBe(false);
|
||
clockTo();
|
||
expect(sources[0]!.stopped).toBe(true);
|
||
expect(library.stats().playing).toBe(0);
|
||
});
|
||
|
||
it('a note planned ahead starts on that second of the audio clock and is released from there (#332)', async () => {
|
||
const library = new SoundLibrary();
|
||
library.playNote('instrument:i', [sample(FILE)], {}, 1, '', 0.5, { at: 2 });
|
||
await settle();
|
||
await arrive();
|
||
expect(sources[0]!.startedAt).toBe(2);
|
||
expect(library.stats().last?.at).toBe(2);
|
||
expect(gains[1]!.ramps).toEqual([
|
||
['set', 0.5, 2.5],
|
||
['linear', 0, 2.51],
|
||
]);
|
||
expect(clocks.map((clock) => clock.stopAt)).toEqual([2.51]);
|
||
// One whose second has passed by the time its bytes are there starts now.
|
||
library.playNote('instrument:i', [sample(FILE)], {}, 1, '', 0, { at: -1 });
|
||
expect(sources[1]!.startedAt).toBe(0);
|
||
});
|
||
|
||
it('tells a pitched note planned ahead when to start, the worklet having no start of its own (#332)', async () => {
|
||
const library = new SoundLibrary();
|
||
const pitched = { ...sample(FILE), pitch: [{ value: 7 }] } as FxPlan;
|
||
library.playNote('instrument:i', [pitched], {}, 1, '', 0, { at: 1.25 });
|
||
await settle();
|
||
await arrive();
|
||
expect(worklets[0]!.options.processorOptions.startAt).toBe(1.25);
|
||
});
|
||
|
||
it('a note planned to end later leaves its loop then, not now (#332)', async () => {
|
||
const library = new SoundLibrary();
|
||
await note(library, [sample(FILE, { loop: true, gate: false })], 1);
|
||
expect(sources[0]!.loop).toBe(true);
|
||
clockTo(0.99);
|
||
expect(sources[0]!.loop).toBe(true);
|
||
clockTo(1);
|
||
expect(sources[0]!.loop).toBe(false);
|
||
});
|
||
|
||
it('a note released before its bytes arrived still starts, and is released at once', async () => {
|
||
const library = new SoundLibrary();
|
||
const voice = library.playNote(
|
||
'instrument:i',
|
||
[sample(FILE), sample(OTHER, { gate: false })],
|
||
{},
|
||
1,
|
||
'',
|
||
0,
|
||
);
|
||
library.release(voice);
|
||
await settle();
|
||
await arrive();
|
||
expect(library.stats().started).toBe(1);
|
||
clockTo();
|
||
expect(sources.map((source) => source.stopped)).toEqual([true, false]);
|
||
});
|
||
|
||
it('loops a way between its loop start and end', async () => {
|
||
const library = new SoundLibrary();
|
||
await note(library, [sample(FILE, { loop: true, loopStart: 0.25, loopEnd: 1 })]);
|
||
expect(sources[0]).toMatchObject({ loop: true, loopStart: 0.25, loopEnd: 1 });
|
||
});
|
||
|
||
it('hands a pitched way its loop in samples of the sound', async () => {
|
||
const library = new SoundLibrary();
|
||
const pitched = { ...sample(FILE, { loop: true, loopStart: 0.25, loopEnd: 0 }), pitch: [{ value: 3 }] };
|
||
await note(library, [pitched]);
|
||
// 800 samples a second, 1200 of them: from 200 to the end.
|
||
expect(worklets[0]!.options.processorOptions).toMatchObject({
|
||
loop: true,
|
||
loopStart: 200,
|
||
loopEnd: 1200,
|
||
});
|
||
});
|
||
|
||
it('a release lets a loop that does not end with the note leave its loop and play out', async () => {
|
||
const library = new SoundLibrary();
|
||
const voice = await note(library, [
|
||
sample(FILE, { loop: true, loopEnd: 1, gate: false }),
|
||
sample(OTHER, { loop: true, loopEnd: 1 }),
|
||
]);
|
||
library.release(voice);
|
||
// The first leaves its loop and is not faded; the second, a plain Loop, fades out as before.
|
||
expect(sources.map((source) => source.loop)).toEqual([false, true]);
|
||
expect(gains[1]!.ramps).toEqual([]);
|
||
expect(gains[2]!.ramps).toEqual([['linear', 0, 0.01]]);
|
||
clockTo();
|
||
expect(sources.map((source) => source.stopped)).toEqual([false, true]);
|
||
expect(library.playing(voice)).toBe(true);
|
||
sources[0]!.onended?.();
|
||
expect(library.playing(voice)).toBe(false);
|
||
});
|
||
|
||
it('tells the pitch worklet to leave its loop on a release', async () => {
|
||
const library = new SoundLibrary();
|
||
const pitched = { ...sample(FILE, { loop: true, loopEnd: 1, gate: false }), pitch: [{ value: 3 }] };
|
||
const voice = await note(library, [pitched]);
|
||
library.release(voice);
|
||
expect(worklets[0]!.posted).toEqual(['leave']);
|
||
});
|
||
|
||
it('plays nothing where no way has a file', () => {
|
||
const library = new SoundLibrary();
|
||
expect(library.playNote('instrument:i', [], {}, 1, '', 0)).toBe(0);
|
||
expect(library.playNote('instrument:i', [{ pitch: [], stages: [] }], {}, 1, '', 0)).toBe(0);
|
||
});
|
||
});
|
||
|
||
describe('notes with an Envelope (#335)', () => {
|
||
const PLANNED: FxEnvelopePlan = {
|
||
id: 'env',
|
||
attack: { value: 0.1 },
|
||
decay: { value: 0.2 },
|
||
sustain: { value: 0.5 },
|
||
release: { value: 0.3 },
|
||
};
|
||
|
||
/** A Sample's way, under the Envelope where `shaped`, with more stages after it. */
|
||
const way = (gate: boolean, shaped = true, stages: FxStagePlan[] = []): FxPlan => ({
|
||
pitch: [],
|
||
stages: [...(shaped ? [{ kind: 'envelope' as const, envelope: PLANNED }] : []), ...stages],
|
||
source: { file: FILE, volume: 0.5, loop: false, loopStart: 0, loopEnd: 0, gate },
|
||
});
|
||
|
||
async function note(library: SoundLibrary, plan: FxPlan, duration = 0): Promise<number> {
|
||
const voice = library.playNote('instrument:i', [plan], {}, 1, '', duration);
|
||
await settle();
|
||
await arrive();
|
||
return voice;
|
||
}
|
||
|
||
/** What carries the curve: the first silent source made, before any timer. */
|
||
const driver = () => clocks[0]!;
|
||
|
||
it('starts its curve with the note: straight up in the attack, then easing towards the sustain', async () => {
|
||
const library = new SoundLibrary();
|
||
await note(library, way(true));
|
||
expect(library.stats().last?.envelopes).toBe(1);
|
||
expect(driver().started).toBe(true);
|
||
expect(driver().offset.ramps).toEqual([['linear', 1, 0.1]]);
|
||
expect(driver().offset.glides).toEqual([0.5]);
|
||
// Voice gain, the Sample's volume, and the Envelope's gain, which is all the curve.
|
||
expect(gains.map((gain) => gain.value)).toEqual([1, 0.5, 0]);
|
||
expect(wires).toContain('clock > shaper');
|
||
expect([shapers[0]!.curve![512], shapers[0]!.curve![1024]]).toEqual([0, 1]);
|
||
});
|
||
|
||
it('a gated source plays on through the release, and the note ends with it', async () => {
|
||
const library = new SoundLibrary();
|
||
const voice = await note(library, way(true));
|
||
library.release(voice);
|
||
// Released at 0, in the attack still: from 0, falling to silence in 0.3 s.
|
||
expect(driver().offset.ramps).toEqual([
|
||
['linear', 1, 0.1],
|
||
['linear', 0, 0],
|
||
]);
|
||
expect(driver().offset.glides).toEqual([0.5, 0]);
|
||
expect(gains[1]!.ramps).toEqual([
|
||
['set', 0.5, 0.3],
|
||
['linear', 0, 0.31],
|
||
]);
|
||
clockTo(0.3);
|
||
expect(sources[0]!.stopped).toBe(false);
|
||
expect(library.playing(voice)).toBe(true);
|
||
clockTo();
|
||
expect(sources[0]!.stopped).toBe(true);
|
||
expect(library.playing(voice)).toBe(false);
|
||
expect(driver().stopped).toBe(true);
|
||
});
|
||
|
||
it('a note ending mid-decay releases from the level its curve has by then', async () => {
|
||
const library = new SoundLibrary();
|
||
await note(library, way(true), 0.2);
|
||
const level = 0.5 + 0.5 * Math.exp((-0.1 * Math.log(1000)) / 0.2);
|
||
expect(driver().offset.ramps).toEqual([
|
||
['linear', 1, 0.1],
|
||
['set', level, 0.2],
|
||
]);
|
||
expect(gains[1]!.ramps).toEqual([
|
||
['set', 0.5, 0.5],
|
||
['linear', 0, 0.51],
|
||
]);
|
||
});
|
||
|
||
it('a one-shot under an Envelope alone plays through, its curve held', async () => {
|
||
const library = new SoundLibrary();
|
||
const voice = await note(library, way(false));
|
||
library.release(voice);
|
||
expect(driver().offset.glides).toEqual([0.5]);
|
||
clockTo();
|
||
expect(sources[0]!.stopped).toBe(false);
|
||
sources[0]!.onended?.();
|
||
expect(library.playing(voice)).toBe(false);
|
||
});
|
||
|
||
it('Stop sound lets it fall away in its release with "let ring", and cuts it without', async () => {
|
||
const library = new SoundLibrary();
|
||
const ringing = await note(library, way(false));
|
||
library.stopVoice(ringing, true);
|
||
expect(driver().offset.glides).toEqual([0.5, 0]);
|
||
expect(gains[1]!.ramps).toEqual([
|
||
['set', 0.5, 0.3],
|
||
['linear', 0, 0.31],
|
||
]);
|
||
expect(gains[0]!.ramps).toEqual([]);
|
||
expect(library.stats().stopped).toBe(1);
|
||
clockTo();
|
||
expect(library.playing(ringing)).toBe(false);
|
||
|
||
const cut = await note(library, way(true));
|
||
library.stopVoice(cut);
|
||
expect(gains[3]!.ramps).toEqual([['linear', 0, 0.01]]);
|
||
expect(library.playing(cut)).toBe(false);
|
||
});
|
||
|
||
it('one that only moves a cutoff ends nothing: the gated source fades with the note as before', async () => {
|
||
const library = new SoundLibrary();
|
||
const sweep: FxStagePlan = {
|
||
kind: 'pass',
|
||
filter: 'lowpass',
|
||
cutoff: { value: 200, mod: { envelope: PLANNED, from: { value: 200 }, to: { value: 5000 } } },
|
||
resonance: { value: 0 },
|
||
};
|
||
const voice = await note(library, way(true, false, [sweep]));
|
||
// The cutoff is all the curve; the Q stays as it is.
|
||
expect(filters[0]!.frequency.value).toBe(0);
|
||
expect(filters[0]!.Q.value).toBeCloseTo(20 * Math.log10(Math.SQRT1_2), 9);
|
||
const curve = shapers[0]!.curve!;
|
||
expect([curve[0], curve[512], curve[768], curve[1024]]).toEqual([
|
||
200,
|
||
200,
|
||
expect.closeTo(1000, 1),
|
||
expect.closeTo(5000, 1),
|
||
]);
|
||
library.release(voice);
|
||
expect(gains[1]!.ramps).toEqual([['linear', 0, 0.01]]);
|
||
expect(driver().offset.glides).toEqual([0.5, 0]);
|
||
// A parameter does not reach a number an Envelope moves.
|
||
expect(library.levels('instrument:i')).toEqual([[200, expect.closeTo(-3.01, 2)]]);
|
||
});
|
||
|
||
it('moves a pitch on top of the one its way has, through the worklet', async () => {
|
||
const library = new SoundLibrary();
|
||
const plan: FxPlan = {
|
||
...way(true, false),
|
||
pitch: [
|
||
{ value: 3 },
|
||
{ value: -12, mod: { envelope: PLANNED, from: { value: -12 }, to: { value: 0 } } },
|
||
],
|
||
};
|
||
await note(library, plan);
|
||
expect(worklets[0]!.pitch.value).toBe(3);
|
||
expect(wires).toContain('shaper > ?');
|
||
expect([shapers[0]!.curve![512], shapers[0]!.curve![1024]]).toEqual([-12, 0]);
|
||
});
|
||
});
|
||
|
||
describe('clips and tracks of a song (#334)', () => {
|
||
it('starts a clip at its second, as far into its sound as it says, round and round', async () => {
|
||
const library = new SoundLibrary();
|
||
// The sound is 1.5 s long: 2 s in is half a second into its second round.
|
||
library.playClip(FILE, { at: 2, offset: 2, duration: 3, volume: 0.5, bus: '' });
|
||
await settle();
|
||
await arrive();
|
||
expect(sources[0]).toMatchObject({ startedAt: 2, startedOffset: 0.5, loop: true });
|
||
expect(library.stats().last).toMatchObject({ at: 2, volume: 0.5, loop: true });
|
||
});
|
||
|
||
it('fades a clip out at its end and lets it go, a source only once', async () => {
|
||
const library = new SoundLibrary();
|
||
library.playClip(FILE, { at: 2, offset: 0, duration: 3, volume: 1, bus: '' });
|
||
await settle();
|
||
await arrive();
|
||
// The voice's gain first, then the fade of its source.
|
||
expect(gains[1]!.ramps).toEqual([
|
||
['set', 1, 5],
|
||
['linear', 0, 5.01],
|
||
]);
|
||
expect(clocks.map((clock) => clock.stopAt)).toEqual([5.01]);
|
||
expect(sources[0]!.stopped).toBe(false);
|
||
clockTo();
|
||
expect(sources[0]!.stopped).toBe(true);
|
||
expect(library.stats().playing).toBe(0);
|
||
});
|
||
|
||
it('starts a pitched clip in its worklet as many samples in as the offset is', async () => {
|
||
const library = new SoundLibrary();
|
||
const pitched = { pitch: [{ value: 5 }], stages: [] } as FxPlan;
|
||
library.playClip(
|
||
FILE,
|
||
{ at: 1, offset: 1.75, duration: 1, volume: 1, bus: '' },
|
||
{ plan: pitched, params: {} },
|
||
);
|
||
await settle();
|
||
await arrive();
|
||
// 0.25 s into the second round, at the sound's own 800 samples a second.
|
||
expect(worklets[0]!.options.processorOptions).toMatchObject({ startAt: 1, loop: true, skip: 200 });
|
||
});
|
||
|
||
it("sends a song's voices through the bus of their track: its level, then its pan, then out", async () => {
|
||
const library = new SoundLibrary();
|
||
library.setBus('song:s:t', 0.5, -0.25);
|
||
library.playNote(
|
||
'instrument:i',
|
||
[
|
||
{
|
||
pitch: [],
|
||
stages: [],
|
||
source: { file: FILE, volume: 1, loop: false, loopStart: 0, loopEnd: 0, gate: false },
|
||
},
|
||
],
|
||
{},
|
||
1,
|
||
'',
|
||
0,
|
||
{ bus: 'song:s:t' },
|
||
);
|
||
await settle();
|
||
await arrive();
|
||
expect(wires).toEqual(expect.arrayContaining(['gain > panner', 'panner > destination']));
|
||
expect(wires.filter((wire) => wire === 'gain > gain').length).toBeGreaterThan(0);
|
||
expect(wires).not.toContain('gain > destination');
|
||
expect(gains[0]!.glides).toEqual([0.5]);
|
||
expect(panners[0]!.glides).toEqual([-0.25]);
|
||
library.setBus('song:s:t', 0, 1);
|
||
expect(panners).toHaveLength(1);
|
||
expect([gains[0]!.glides, panners[0]!.glides]).toEqual([
|
||
[0.5, 0],
|
||
[-0.25, 1],
|
||
]);
|
||
});
|
||
|
||
it('says how loud each stretch of a loaded sound is, and nothing before its bytes are there', async () => {
|
||
const library = new SoundLibrary();
|
||
expect(library.peaks(FILE)).toBeNull();
|
||
void library.load(FILE);
|
||
await settle();
|
||
await arrive();
|
||
const peaks = library.peaks(FILE);
|
||
expect(peaks).toHaveLength(1024);
|
||
expect(library.peaks(FILE)).toBe(peaks);
|
||
});
|
||
});
|