A clip was cut from its sound's file, so a game decoded the whole file for it and held it for as long as the page was open. A long recording played through a few short clips cost the recording. Every clip now has a copy of its cut as Opus, with Stream on or off, and a game decodes that instead. - StreamKeeper makes the copy of each clip whether its sound streams or not. Stream only decides whether a sound gets a copy of its own. - clipCopyTable says which cut decodes from which copy. The sound library loads the copy for a clip unless the whole file is decoded already. Then the clip is cut from that as before. - projectFiles decodes the whole file ahead only where something may play the sound itself: no clips, a clip without a fitting copy, or the sound's id anywhere in an asset that can name one. That last test walks the data, not the fields, so no place is missed. - A copy that cannot be had falls back to the whole file. A library that bakes keeps to the whole files, as the bake's fingerprint does. Tests: - Gate: CHECK EXIT 0, 280 files, 4319 tests (7 new: streams +4, sound-library +3). - e2e 34: ALL PASSED, 108 checks (2 new). With Stream off a fresh library decodes the ten-second reversed clip's copy and not the five-minute file. It is the cut made from the original to within 0.0091 a sample and 0.013 dB in any 20 ms. Once a node plays the sound itself, the whole file is decoded ahead again. - e2e 65: all passed with this change. - Counter-checks red as they should be: the library ignoring copies, the whole file always decoded ahead, clip copies only with Stream. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2973 lines
114 KiB
TypeScript
2973 lines
114 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 {
|
||
DRY_VOICE,
|
||
type FxChipPlan,
|
||
type FxEnvelopePlan,
|
||
type FxPlan,
|
||
type FxStage,
|
||
type FxStagePlan,
|
||
type FxVoice,
|
||
} from '../../src/core/soundfx/nodes';
|
||
import type { MixBus } from '../../src/core/music/mixer';
|
||
import type { BakePlay, SequencePlay } from '../../src/core/music/playbacks';
|
||
import type { ScheduledNote } from '../../src/core/music/Scheduler';
|
||
import type { LfoOptions, PitchOptions, StreamOptions } from '../../src/viewport/fxWorklet';
|
||
import { saturationCurve } from '../../src/core/soundfx/saturation';
|
||
import { stageNodes } from '../../src/viewport/fxNodes';
|
||
import { SoundLibrary } from '../../src/viewport/SoundLibrary';
|
||
import { fakeCopy, PRE_SKIP, serveCopies, stubDecoder } from './fakeStream';
|
||
|
||
/** 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;
|
||
/** How long it was told to play, where it was told (#340). */
|
||
startedFor?: number;
|
||
loopStart?: number;
|
||
loopEnd?: number;
|
||
stopped: boolean;
|
||
connect: (target: unknown) => unknown;
|
||
start: (at?: number, offset?: number, duration?: number) => void;
|
||
stop: () => void;
|
||
}
|
||
|
||
/**
|
||
* The pitch worklet of a way (#317) or a Bitcrusher (#322): what it was made with, its pitch and
|
||
* what was posted to it.
|
||
*/
|
||
interface FakeWorklet {
|
||
kind?: string;
|
||
options: {
|
||
outputChannelCount: number[];
|
||
parameterData: Record<string, number>;
|
||
processorOptions: PitchOptions;
|
||
};
|
||
pitch: FakeParam;
|
||
parameters: Map<string, FakeParam>;
|
||
posted: unknown[];
|
||
port: {
|
||
postMessage: (message: unknown) => void;
|
||
onmessage: ((event: { data: unknown }) => void) | null;
|
||
close: () => void;
|
||
closed: boolean;
|
||
};
|
||
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();
|
||
/** Whether a module has loaded: before that a worklet node cannot be made, as in a browser. */
|
||
let moduleLoaded = false;
|
||
|
||
/**
|
||
* The master every voice ends in (#325): made with the first voice that needs it, its input, level
|
||
* and pan out to the speakers, before the voice is connected into it.
|
||
*/
|
||
const INTO_MASTER = ['gain > gain', 'gain > panner', 'panner > destination', 'gain > gain'];
|
||
/** What `busLevels` says of that master while nothing has set it. */
|
||
const MASTER_LEVEL = { name: 'mix', gain: 1, pan: 0, into: null, fx: false, latency: 0 };
|
||
|
||
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[] = [];
|
||
/** Every ConvolverNode made (#328), in order. */
|
||
const convolvers: { buffer: unknown }[] = [];
|
||
/** Every DynamicsCompressor made (#326), in order. */
|
||
const compressors: Record<'threshold' | 'knee' | 'ratio' | 'attack' | 'release', FakeParam>[] = [];
|
||
/** The curve of every WaveShaper made, in order. */
|
||
const shapers: { curve: Float32Array | null }[] = [];
|
||
/** Every AnalyserNode made (#359), with the one sample it hands out among silence. */
|
||
const analysers: { fftSize: number; sample: number }[] = [];
|
||
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,
|
||
copyToChannel: (from: Float32Array) => data.set(from),
|
||
};
|
||
}
|
||
|
||
function fakeContext() {
|
||
return {
|
||
get state() {
|
||
return contextState;
|
||
},
|
||
currentTime: 0,
|
||
sampleRate: 48000,
|
||
audioWorklet: {
|
||
addModule: (url: string) => {
|
||
modules.push(url);
|
||
return loadModule().then(() => {
|
||
moduleLoaded = true;
|
||
});
|
||
},
|
||
},
|
||
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, duration?: number) => {
|
||
source.started = true;
|
||
source.startedAt = at ?? 0;
|
||
source.startedOffset = offset ?? 0;
|
||
if (duration !== undefined) source.startedFor = duration;
|
||
},
|
||
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', {}),
|
||
createAnalyser: () => {
|
||
const analyser = wired('analyser', {
|
||
fftSize: 2048,
|
||
sample: 0,
|
||
getFloatTimeDomainData: (into: Float32Array) => {
|
||
into.fill(0);
|
||
into[into.length >> 1] = analyser.sample;
|
||
},
|
||
});
|
||
analysers.push(analyser);
|
||
return analyser;
|
||
},
|
||
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;
|
||
},
|
||
createConvolver: () => {
|
||
const node = wired('convolver', { buffer: null, normalize: true });
|
||
convolvers.push(node);
|
||
return node;
|
||
},
|
||
createDynamicsCompressor: () => {
|
||
const node = wired('compressor', {
|
||
threshold: param(-24),
|
||
knee: param(30),
|
||
ratio: param(12),
|
||
attack: param(0.003),
|
||
release: param(0.25),
|
||
});
|
||
compressors.push(node);
|
||
return node;
|
||
},
|
||
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();
|
||
moduleLoaded = false;
|
||
wires.length = 0;
|
||
filters.length = 0;
|
||
gains.length = 0;
|
||
delays.length = 0;
|
||
disconnects.length = 0;
|
||
clocks.length = 0;
|
||
shapers.length = 0;
|
||
analysers.length = 0;
|
||
compressors.length = 0;
|
||
convolvers.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']) {
|
||
if (!moduleLoaded) throw new DOMException('no such processor', 'InvalidStateError');
|
||
const parameters = new Map(
|
||
// A stream player (#349) is made without any.
|
||
Object.entries(
|
||
(options.parameterData as FakeWorklet['options']['parameterData'] | undefined) ?? {},
|
||
).map(([key, value]) => [key, param(value)] as const),
|
||
);
|
||
const kind =
|
||
name === 'bit-crusher'
|
||
? 'crush'
|
||
: name === 'chip-player'
|
||
? 'chip'
|
||
: name === 'phaser' || name === 'flanger' || name === 'limiter' || name === 'lfo'
|
||
? name
|
||
: name === 'pitch-shifter'
|
||
? 'shifter'
|
||
: name === 'stream-player'
|
||
? 'stream'
|
||
: 'pitch';
|
||
const node: FakeWorklet = wired(kind, {
|
||
options,
|
||
pitch: parameters.get('pitch') ?? param(0),
|
||
parameters,
|
||
posted: [] as unknown[],
|
||
port: {
|
||
postMessage: (message: unknown) => node.posted.push(message),
|
||
onmessage: null,
|
||
close: () => {
|
||
node.port.closed = true;
|
||
},
|
||
closed: false,
|
||
},
|
||
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 === 'crush')
|
||
return {
|
||
kind: 'crush',
|
||
bits: { value: stage.bits },
|
||
rate: { value: stage.rate },
|
||
mix: { value: stage.mix },
|
||
};
|
||
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(false);
|
||
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('plays the stretch a sound is cut to, made from its file without fetching it again (#343)', async () => {
|
||
const library = new SoundLibrary(false);
|
||
// The file is 1200 samples at 800 Hz: 1.5 s. The cut keeps 0.5 s to 1 s.
|
||
const cut = `${FILE}#0.5~1~0.005~0.005~linear~linear`;
|
||
await play(library, cut);
|
||
expect(library.stats()).toMatchObject({ started: 1, decoded: 1, playing: 1 });
|
||
expect((sources[0]?.buffer as { length: number }).length).toBe(400);
|
||
expect(library.duration(cut)).toBe(0.5);
|
||
expect(library.duration(FILE)).toBe(1.5);
|
||
expect(library.peaks(cut)).toHaveLength(1024);
|
||
// The file itself plays whole beside it, and a stop by the cut's name reaches only the cut.
|
||
await play(library);
|
||
expect((sources[1]?.buffer as { length: number }).length).toBe(1200);
|
||
library.stop(cut);
|
||
expect(library.stats().playing).toBe(1);
|
||
});
|
||
|
||
describe('a clip with a copy of its own (#349)', () => {
|
||
const cut = `${FILE}#0.5~1~0.005~0.005~linear~linear`;
|
||
const copy = `${'a1'.repeat(6)}/${'d4'.repeat(32)}.ogg`;
|
||
|
||
/** Which files are fetched; one ending in `missing` is not there. */
|
||
function fetching(missing = '#'): string[] {
|
||
const urls: string[] = [];
|
||
vi.stubGlobal('fetch', (url: string) => {
|
||
urls.push(url.replace(/^.*\/sounds\//, ''));
|
||
if (url.endsWith(missing)) return Promise.resolve({ ok: false, status: 404 });
|
||
return Promise.resolve({ ok: true, arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)) });
|
||
});
|
||
return urls;
|
||
}
|
||
|
||
it('decodes its copy and plays all of it, the whole file never fetched', async () => {
|
||
const urls = fetching();
|
||
const library = new SoundLibrary(false);
|
||
library.setCopies(new Map([[cut, copy]]));
|
||
await play(library, cut);
|
||
expect(urls).toEqual([copy]);
|
||
expect(library.decoded(FILE)).toBeNull();
|
||
// The copy is the cut already: played as it is, not cut again.
|
||
expect((sources[0]?.buffer as { length: number }).length).toBe(1200);
|
||
expect(library.duration(cut)).toBe(1.5);
|
||
expect(library.stats()).toMatchObject({ started: 1, decoded: 1 });
|
||
});
|
||
|
||
it('is cut from the whole file as before while that is decoded anyway', async () => {
|
||
const urls = fetching();
|
||
const library = new SoundLibrary(false);
|
||
library.setCopies(new Map([[cut, copy]]));
|
||
await play(library);
|
||
await play(library, cut);
|
||
expect(urls).toEqual([FILE]);
|
||
expect((sources[1]?.buffer as { length: number }).length).toBe(400);
|
||
});
|
||
|
||
it('is cut from the whole file when its copy cannot be had', async () => {
|
||
const urls = fetching('.ogg');
|
||
const library = new SoundLibrary(false);
|
||
library.setCopies(new Map([[cut, copy]]));
|
||
await play(library, cut);
|
||
expect(urls).toEqual([copy, FILE]);
|
||
expect((sources[0]?.buffer as { length: number }).length).toBe(400);
|
||
});
|
||
});
|
||
|
||
it('sends the master out through a limiter at -1 dBFS once the worklet is there, late by it (#359)', async () => {
|
||
let load: () => void = () => {};
|
||
loadModule = () => new Promise((resolve) => (load = resolve));
|
||
const library = new SoundLibrary();
|
||
await play(library);
|
||
// Straight out until the worklet is there: nothing waits for it.
|
||
expect(wires).toEqual(expect.arrayContaining(['gain > destination', 'panner > gain']));
|
||
expect(library.latency()).toBe(0);
|
||
expect(library.guardPeaks()).toBeNull();
|
||
load();
|
||
await settle();
|
||
const limiter = worklets.find((w) => w.kind === 'limiter');
|
||
expect(Object.fromEntries([...limiter!.parameters].map(([name, p]) => [name, p.value]))).toEqual({
|
||
threshold: -1,
|
||
ceiling: -1,
|
||
release: 0.1,
|
||
autoGain: 0,
|
||
});
|
||
expect(wires.slice(-2)).toEqual(['gain > limiter', 'limiter > destination']);
|
||
expect(library.latency()).toBeCloseTo(0.005, 12);
|
||
expect(library.stats().playing).toBe(1);
|
||
});
|
||
|
||
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(false);
|
||
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(false);
|
||
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,
|
||
bus: '',
|
||
});
|
||
});
|
||
|
||
it('plays a dry voice straight into its volume, without loading the pitch worklet', async () => {
|
||
await play(new SoundLibrary(false));
|
||
expect(wires).toEqual(['source > gain', ...INTO_MASTER]);
|
||
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(false);
|
||
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', ...INTO_MASTER]);
|
||
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(false);
|
||
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('sends a voice through a Bitcrusher on the worklet, which it loads first, and stops it with the voice (#322)', async () => {
|
||
const library = new SoundLibrary(false);
|
||
const crush: Chain = { pitch: 0, stages: [{ kind: 'crush', bits: 4, rate: 8000, mix: 0.5 }] };
|
||
const voice = await play(library, FILE, {}, crush);
|
||
expect(modules).toHaveLength(1);
|
||
expect(wires).toEqual(['source > crush', 'crush > gain', ...INTO_MASTER]);
|
||
const node = worklets[0]!;
|
||
expect(node.options.parameterData).toEqual({ bits: 4, rate: 8000, mix: 0.5 });
|
||
expect(library.levels(FILE)).toEqual([[4, 8000, 0.5]]);
|
||
library.stopVoice(voice);
|
||
clockTo();
|
||
expect(node.posted).toEqual(['stop']);
|
||
expect(node.disconnected).toBe(true);
|
||
});
|
||
|
||
it('moves the numbers of a Bitcrusher while it plays, its rate in octaves (#322)', async () => {
|
||
const library = new SoundLibrary(false);
|
||
const plan: FxPlan = {
|
||
pitch: [],
|
||
stages: [
|
||
{
|
||
kind: 'crush',
|
||
bits: { value: 8, param: 'b' },
|
||
rate: { value: 8000, param: 'r' },
|
||
mix: { value: 1 },
|
||
},
|
||
],
|
||
};
|
||
const voice = await play(library, FILE, {}, { plan, params: {} });
|
||
expect(library.tune(voice, { b: 3, r: 2000 }, 0.5)).toBe(true);
|
||
const node = worklets[0]!;
|
||
expect(node.parameters.get('bits')!.ramps).toEqual([['linear', 3, 0.5]]);
|
||
expect(node.parameters.get('rate')!.ramps).toEqual([['exponential', 2000, 0.5]]);
|
||
expect(node.parameters.get('mix')!.ramps).toEqual([]);
|
||
});
|
||
|
||
it('sends a voice through a Phaser and a Flanger on the worklet, the sweep from its start (#341)', async () => {
|
||
const library = new SoundLibrary(false);
|
||
const amounts = (values: Record<string, number>) =>
|
||
Object.fromEntries(Object.entries(values).map(([key, value]) => [key, { value }]));
|
||
const plan: FxPlan = {
|
||
pitch: [],
|
||
stages: [
|
||
{
|
||
kind: 'phaser',
|
||
stages: 6,
|
||
...amounts({ rate: 0.5, depth: 2, feedback: 0.4, mix: 1 }),
|
||
frequency: { value: 800, param: 'f' },
|
||
} as FxStagePlan,
|
||
{
|
||
kind: 'flanger',
|
||
...amounts({ sweep: -3, rate: 0, depth: 0, feedback: 0, mix: 1 }),
|
||
offset: { value: 1.5, param: 'o' },
|
||
} as FxStagePlan,
|
||
],
|
||
};
|
||
const voice = await play(library, FILE, {}, { plan, params: {} });
|
||
expect(wires).toEqual(['source > phaser', 'phaser > flanger', 'flanger > gain', ...INTO_MASTER]);
|
||
const [phaser, flanger] = worklets as [FakeWorklet, FakeWorklet];
|
||
expect(phaser.options.parameterData).toEqual({
|
||
frequency: 800,
|
||
rate: 0.5,
|
||
depth: 2,
|
||
feedback: 0.4,
|
||
mix: 1,
|
||
});
|
||
expect(phaser.options.processorOptions).toEqual({ stages: 6 });
|
||
expect(flanger.options.parameterData).toEqual({
|
||
offset: 1.5,
|
||
sweep: -3,
|
||
rate: 0,
|
||
depth: 0,
|
||
feedback: 0,
|
||
mix: 1,
|
||
});
|
||
expect(flanger.options.processorOptions).toEqual({ startAt: library.stats().last!.at });
|
||
expect(library.tune(voice, { f: 1600, o: 3 }, 0.5)).toBe(true);
|
||
expect(phaser.parameters.get('frequency')!.ramps).toEqual([['exponential', 1600, 0.5]]);
|
||
expect(flanger.parameters.get('offset')!.ramps).toEqual([['linear', 3, 0.5]]);
|
||
library.stopVoice(voice);
|
||
clockTo();
|
||
expect([phaser.posted, flanger.posted]).toEqual([['stop'], ['stop']]);
|
||
});
|
||
|
||
it('without the worklet a Bitcrusher lets the sound through as it is (#322)', async () => {
|
||
loadModule = () => Promise.reject(new Error('no worklets here'));
|
||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||
const library = new SoundLibrary(false);
|
||
await play(library, FILE, {}, { pitch: 0, stages: [{ kind: 'crush', bits: 4, rate: 8000, mix: 1 }] });
|
||
expect(worklets).toHaveLength(0);
|
||
expect(wires).toEqual(['source > gain', 'gain > gain', ...INTO_MASTER]);
|
||
expect(library.stats()).toMatchObject({ started: 1, worklet: false });
|
||
});
|
||
|
||
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(false);
|
||
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(false);
|
||
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(false);
|
||
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',
|
||
...INTO_MASTER,
|
||
]);
|
||
expect(gains.map((g) => g.value)).toEqual([1, 1, 0.75, expect.closeTo(0.5), 0.25, 1, 1]);
|
||
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(false);
|
||
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(false);
|
||
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(false);
|
||
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(false);
|
||
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(false);
|
||
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(false);
|
||
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(false);
|
||
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(false);
|
||
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(false);
|
||
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(false);
|
||
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(false);
|
||
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(false);
|
||
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(false);
|
||
// 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(false);
|
||
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(false);
|
||
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(false);
|
||
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(false);
|
||
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(false);
|
||
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, each at its share of wet', async () => {
|
||
await play(new SoundLibrary(false), 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, at its share
|
||
'gain > gain',
|
||
'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',
|
||
...INTO_MASTER,
|
||
]);
|
||
// The voice, the fade made before it, the echo's in, out, sound, feedback and echoes, then the
|
||
// master's two. Wet 0.5 (#354): the sound and the echoes both in full.
|
||
expect(gains.map((g) => g.value)).toEqual([1, 1, 1, 1, 1, 0.4, 1, 1, 1]);
|
||
});
|
||
|
||
it('wires a ping-pong echo into the left only, crossing sides on every round', async () => {
|
||
await play(new SoundLibrary(false), 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(false);
|
||
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(false);
|
||
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(false);
|
||
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(false);
|
||
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(false);
|
||
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(false);
|
||
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(false);
|
||
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(false);
|
||
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(false);
|
||
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]]);
|
||
// The echoes, made before the master's two: wet 0.2 has them at 0.4, the sound stays in full.
|
||
expect(gains.at(-3)!.ramps).toEqual([['linear', 0.4, 2]]);
|
||
expect(gains.at(-5)!.ramps).toEqual([]);
|
||
expect(library.levels(FILE)).toEqual([[0.25, 0.4, 20000, 1, 1]]);
|
||
});
|
||
});
|
||
});
|
||
|
||
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(false);
|
||
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, then the master's two.
|
||
expect(gains.map((gain) => gain.value)).toEqual([1, 0.5, 0.5, 1, 1]);
|
||
expect(wires).toEqual(['source > gain', 'gain > gain', 'source > gain', 'gain > gain', ...INTO_MASTER]);
|
||
});
|
||
|
||
it('a release fades out the ways that end with the note and lets a one-shot play on', async () => {
|
||
const library = new SoundLibrary(false);
|
||
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(false);
|
||
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(false);
|
||
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('starts a note with a Limiter as much earlier as it is late, its other layer held back to it (#326)', async () => {
|
||
const library = new SoundLibrary(false);
|
||
const limit: FxStagePlan = {
|
||
kind: 'limit',
|
||
threshold: { value: -6 },
|
||
ceiling: { value: -1 },
|
||
release: { value: 0.1 },
|
||
autoGain: { value: 1 },
|
||
};
|
||
const limited = { ...sample(FILE), stages: [limit] } as FxPlan;
|
||
library.playNote('instrument:i', [limited, sample(OTHER)], {}, 1, '', 0.5, { at: 2 });
|
||
await settle();
|
||
await arrive();
|
||
// 240 samples at 48 kHz.
|
||
expect(sources.map((source) => source.startedAt)).toEqual([1.995, 1.995]);
|
||
expect(delays.map((delay) => delay.delayTime.value)).toEqual([0.005]);
|
||
expect(worklets.map((worklet) => worklet.kind)).toEqual(['limiter']);
|
||
expect(gains[1]!.ramps).toEqual([
|
||
['set', 0.5, 2.495],
|
||
['linear', 0, 2.505],
|
||
]);
|
||
// What the Limiter holds back is heard after the sources end, before the voice is let go.
|
||
expect(library.tail(1)).toBeCloseTo(0.005, 9);
|
||
});
|
||
|
||
it('tells a pitched note planned ahead when to start, the worklet having no start of its own (#332)', async () => {
|
||
const library = new SoundLibrary(false);
|
||
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(false);
|
||
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(false);
|
||
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(false);
|
||
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(false);
|
||
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(false);
|
||
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.
|
||
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(false);
|
||
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(false);
|
||
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(false);
|
||
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; then the master's.
|
||
expect(gains.map((gain) => gain.value)).toEqual([1, 0.5, 0, 1, 1]);
|
||
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(false);
|
||
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(false);
|
||
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(false);
|
||
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(false);
|
||
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);
|
||
// After the first note's three and the master's two.
|
||
expect(gains[5]!.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(false);
|
||
const sweep: FxStagePlan = {
|
||
kind: 'pass',
|
||
filter: 'lowpass',
|
||
stages: 1,
|
||
cutoff: { value: 200, mod: { envelope: PLANNED, from: { value: 200 }, to: { value: 5000 } } },
|
||
q: { value: Math.SQRT1_2 },
|
||
gain: { 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), 0]]);
|
||
});
|
||
|
||
it('ends once its decay is over where the sustain is 0, however long the note is held (#329)', async () => {
|
||
const library = new SoundLibrary(false);
|
||
const plan: FxPlan = {
|
||
...way(true, false),
|
||
stages: [{ kind: 'envelope', envelope: { ...PLANNED, sustain: { value: 0 } } }],
|
||
};
|
||
const voice = await note(library, plan);
|
||
// Attack 0.1 and decay 0.2: faded out from 0.3 on.
|
||
expect(gains[1]!.ramps).toEqual([
|
||
['set', 0.5, expect.closeTo(0.3, 9)],
|
||
['linear', 0, expect.closeTo(0.31, 9)],
|
||
]);
|
||
clockTo(0.3);
|
||
expect(library.playing(voice)).toBe(true);
|
||
clockTo();
|
||
expect(sources[0]!.stopped).toBe(true);
|
||
expect(library.playing(voice)).toBe(false);
|
||
});
|
||
|
||
it('moves a pitch on top of the one its way has, through the worklet', async () => {
|
||
const library = new SoundLibrary(false);
|
||
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('a Chip (#329)', () => {
|
||
const CHIP: FxChipPlan = {
|
||
wave: 'square',
|
||
frequency: { value: 440, param: 'hz', min: 1, max: 20000 },
|
||
duty: { value: 0.5 },
|
||
dutySweep: { value: 0 },
|
||
slide: { value: 12 },
|
||
deltaSlide: { value: 0 },
|
||
minFrequency: { value: 0 },
|
||
vibratoDepth: { value: 0 },
|
||
vibratoSpeed: { value: 6 },
|
||
arpeggio: { value: 0 },
|
||
arpeggioTime: { value: 0.1 },
|
||
repeat: { value: 0 },
|
||
};
|
||
const pitched: FxPlan = { pitch: [{ value: 3 }], stages: [], chip: CHIP };
|
||
|
||
it('plays a note without any file on a worklet of its own, its frequency, duty and pitch knobs', async () => {
|
||
const library = new SoundLibrary(false);
|
||
const voice = library.playNote('instrument:i', [pitched], {}, 1, '', 0, { at: 0.5 });
|
||
await settle();
|
||
expect(modules).toHaveLength(1);
|
||
expect(library.stats()).toMatchObject({ started: 1, playing: 1, decoded: 0 });
|
||
expect(worklets[0]!.kind).toBe('chip');
|
||
expect(worklets[0]!.options.outputChannelCount).toEqual([1]);
|
||
expect(worklets[0]!.options.parameterData).toEqual({ frequency: 440, duty: 0.5, pitch: 3 });
|
||
expect(worklets[0]!.options.processorOptions).toMatchObject({ wave: 'square', slide: 12, startAt: 0.5 });
|
||
expect(library.levels('instrument:i')).toEqual([[3, 440, 0.5]]);
|
||
library.tune(voice, { hz: 880 });
|
||
expect(worklets[0]!.parameters.get('frequency')!.glides).toEqual([880]);
|
||
});
|
||
|
||
it('ends with its note, faded out, or by itself once it has slid below its lowest frequency', async () => {
|
||
const library = new SoundLibrary(false);
|
||
const released = library.playNote('instrument:i', [pitched], {}, 1, '', 0);
|
||
await settle();
|
||
library.release(released);
|
||
clockTo();
|
||
expect(worklets[0]!.posted).toEqual(['stop']);
|
||
expect(library.playing(released)).toBe(false);
|
||
const sliding = library.playNote('instrument:i', [pitched], {}, 1, '', 0);
|
||
worklets[1]!.port.onmessage?.({ data: 'ended' });
|
||
expect(library.playing(sliding)).toBe(false);
|
||
});
|
||
|
||
it('is silent without the worklet, and ends at once', async () => {
|
||
loadModule = () => Promise.reject(new Error('no worklets here'));
|
||
const library = new SoundLibrary(false);
|
||
const voice = library.playNote('instrument:i', [pitched], {}, 1, '', 0);
|
||
await settle();
|
||
expect(worklets).toHaveLength(0);
|
||
expect(library.levels('instrument:i')).toEqual([[3, 440, 0.5]]);
|
||
sources[0]!.onended?.();
|
||
expect(library.playing(voice)).toBe(false);
|
||
});
|
||
});
|
||
|
||
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(false);
|
||
// 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('plays a clip turned up past unity as loud as +12 dB, and no louder', async () => {
|
||
const library = new SoundLibrary(false);
|
||
library.playClip(FILE, { at: 2, offset: 0, duration: 3, volume: 2, bus: '' });
|
||
await settle();
|
||
await arrive();
|
||
expect(library.stats().last?.volume).toBe(2);
|
||
library.playClip(FILE, { at: 2, offset: 0, duration: 3, volume: 50, bus: '' });
|
||
await settle();
|
||
await arrive();
|
||
expect(library.stats().last?.volume).toBeCloseTo(10 ** (12 / 20), 6);
|
||
});
|
||
|
||
it('fades a clip out at its end and lets it go, a source only once', async () => {
|
||
const library = new SoundLibrary(false);
|
||
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(false);
|
||
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 input, level, then its pan, then out", async () => {
|
||
const library = new SoundLibrary(false);
|
||
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');
|
||
// The first gain is where the bus is fed, the second its level.
|
||
expect(gains[1]!.glides).toEqual([0.5]);
|
||
expect(panners[0]!.glides).toEqual([-0.25]);
|
||
library.setBus('song:s:t', 0, 1);
|
||
// The track's and the master's it goes into (#325); no new one for the same track.
|
||
expect(panners).toHaveLength(2);
|
||
expect(wires).toContain('panner > gain');
|
||
expect([gains[1]!.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(false);
|
||
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);
|
||
});
|
||
|
||
it('plays and draws a reversed sound from its end (#321)', async () => {
|
||
const library = new SoundLibrary(false);
|
||
const reversed = `${FILE}#0~0~0~0~linear~linear~reverse`;
|
||
void library.load(FILE);
|
||
await settle();
|
||
await arrive();
|
||
// The file rises from silence to full over its 1200 samples.
|
||
library
|
||
.decoded(FILE)!
|
||
.getChannelData(0)
|
||
.forEach((_, i, data) => (data[i] = i / 1199));
|
||
await play(library, reversed);
|
||
const heard = (sources[0]?.buffer as { getChannelData: () => Float32Array }).getChannelData();
|
||
expect([heard[0], heard[1199]]).toEqual([1, 0]);
|
||
expect(library.duration(reversed)).toBe(1.5);
|
||
const peaks = library.peaks(reversed)!;
|
||
expect(peaks[0]).toBeCloseTo(1, 2);
|
||
expect(peaks[1023]).toBeLessThan(0.01);
|
||
});
|
||
});
|
||
|
||
describe('the patterns and songs a game plays (#336)', () => {
|
||
/** A sequence of a note on a track and one on a track the song does not have, both at its start. */
|
||
function sequence(
|
||
strike: (note: ScheduledNote) => number,
|
||
fields: Partial<SequencePlay> = {},
|
||
): SequencePlay {
|
||
const note = { tick: 0, length: 480, velocity: 1, instrument: 'i' };
|
||
return {
|
||
sequence: {
|
||
notes: [
|
||
{ ...note, note: 60, bus: 'song:s:t' },
|
||
{ ...note, note: 62, bus: 'song:s:gone' },
|
||
],
|
||
},
|
||
tempo: 120,
|
||
length: 1920,
|
||
loop: null,
|
||
needs: { layers: [], files: [] },
|
||
volume: 0.5,
|
||
fadeIn: 2,
|
||
buses: [{ name: 'song:s:t', gain: 0.25, pan: -1 }],
|
||
strike,
|
||
...fields,
|
||
};
|
||
}
|
||
|
||
it('hears each playing through a master of its own, silent until it starts, its tracks into it', async () => {
|
||
const library = new SoundLibrary(false);
|
||
const struck: ScheduledNote[] = [];
|
||
const id = library.playSequence(
|
||
'song:s',
|
||
sequence((note) => (struck.push(note), 0)),
|
||
'logic',
|
||
);
|
||
// The master of the project is made on the way, the one all of it is heard through (#325).
|
||
expect(library.busLevels()).toEqual([
|
||
MASTER_LEVEL,
|
||
{ name: `play:${id}`, gain: 0, pan: 0, into: 'mix', fx: false, latency: 0 },
|
||
{ name: `play:${id}:song:s:t`, gain: 0.25, pan: -1, into: `play:${id}`, fx: false, latency: 0 },
|
||
]);
|
||
expect(wires).toEqual([
|
||
'gain > gain',
|
||
'gain > panner',
|
||
'gain > gain',
|
||
'gain > panner',
|
||
'panner > destination',
|
||
'panner > gain',
|
||
'gain > gain',
|
||
'gain > panner',
|
||
'panner > gain',
|
||
]);
|
||
await settle();
|
||
expect(struck.map((note) => note.bus)).toEqual([`play:${id}:song:s:t`, `play:${id}`]);
|
||
// It rises from silence with its first note, 50 ms ahead, to its volume over its fade in.
|
||
expect(gains[1]!.ramps).toEqual([
|
||
['set', 0, 0.05],
|
||
['linear', 0.5, 2.05],
|
||
]);
|
||
library.stopSequence('song:', false, 0);
|
||
expect(library.playbacks()).toEqual([]);
|
||
clockTo();
|
||
expect(library.busLevels()).toEqual([MASTER_LEVEL]);
|
||
expect(disconnects.filter((kind) => kind !== 'clock')).toEqual([
|
||
'gain',
|
||
'gain',
|
||
'panner',
|
||
'gain',
|
||
'gain',
|
||
'panner',
|
||
]);
|
||
});
|
||
|
||
it('is gone once played to its end, its last voice gone too – asking after it does not ask itself', async () => {
|
||
const library = new SoundLibrary(false);
|
||
library.playSequence(
|
||
'pattern:p',
|
||
sequence(() => 77, { length: 10, buses: [] }),
|
||
'',
|
||
);
|
||
await settle();
|
||
expect(() => library.playbacks()).not.toThrow();
|
||
expect(library.playbacks()).toEqual([]);
|
||
clockTo();
|
||
expect(library.busLevels()).toEqual([MASTER_LEVEL]);
|
||
});
|
||
|
||
it('a playing without notes or clips is none', () => {
|
||
const library = new SoundLibrary(false);
|
||
const empty = sequence(() => 1, { sequence: { notes: [], clips: [] } });
|
||
expect(library.playSequence('song:s', empty, '')).toBe(0);
|
||
expect(library.busLevels()).toEqual([]);
|
||
});
|
||
|
||
it('nothing that reads the clock wakes it while the toolbar holds it', () => {
|
||
const library = new SoundLibrary(false);
|
||
library.time();
|
||
let resumes = 0;
|
||
(library as unknown as { context: { resume: () => Promise<void> } }).context.resume = () => {
|
||
resumes += 1;
|
||
return Promise.resolve();
|
||
};
|
||
contextState = 'suspended';
|
||
library.setSuspended(true);
|
||
library.time();
|
||
expect(resumes).toBe(0);
|
||
library.setSuspended(false);
|
||
library.time();
|
||
expect(resumes).toBe(2);
|
||
});
|
||
});
|
||
|
||
describe('a bake (#340)', () => {
|
||
const BAKED = 'baked.ogg';
|
||
/** A pattern of one note, baked: `round` null plays it once to `end`. */
|
||
function baked(
|
||
playback: BakePlay['playback'],
|
||
strike: (note: ScheduledNote) => number = () => 0,
|
||
): SequencePlay {
|
||
return {
|
||
sequence: { notes: [{ tick: 0, length: 480, velocity: 1, instrument: 'i', note: 60 }] },
|
||
tempo: 120,
|
||
length: 1920,
|
||
loop: playback.round ? { start: 0, end: 1920 } : null,
|
||
needs: { layers: [], files: [] },
|
||
strike,
|
||
bake: { file: BAKED, gain: 0.5, playback },
|
||
};
|
||
}
|
||
|
||
it('is played in place of the notes: one source round its round, on the master of the playing', async () => {
|
||
const library = new SoundLibrary(false);
|
||
const struck: ScheduledNote[] = [];
|
||
const id = library.playSequence(
|
||
'pattern:p',
|
||
baked({ end: null, until: 2, round: { start: 2, end: 4 } }, (note) => (struck.push(note), 0)),
|
||
'',
|
||
);
|
||
await settle();
|
||
await arrive();
|
||
const [source] = sources;
|
||
expect(struck).toEqual([]);
|
||
expect(sources).toHaveLength(1);
|
||
expect([source!.loop, source!.loopStart, source!.loopEnd]).toEqual([true, 2, 4]);
|
||
expect([source!.startedAt, source!.startedOffset, source!.startedFor]).toEqual([0.05, 0, undefined]);
|
||
expect(library.playbacks().map((p) => [p.id, p.baked, p.planned.length])).toEqual([[id, true, 0]]);
|
||
expect(library.stats().last).toMatchObject({ file: BAKED, volume: 0.5, loop: true, bus: `play:${id}` });
|
||
});
|
||
|
||
it('a head longer than the loop end hands over to a second source at the loop end, to the sample', async () => {
|
||
const library = new SoundLibrary(false);
|
||
library.playSequence('song:s', baked({ end: null, until: 2, round: { start: 3, end: 5 } }), '');
|
||
await settle();
|
||
await arrive();
|
||
expect(sources.map((s) => [s.loop, s.startedAt, s.startedOffset, s.startedFor])).toEqual([
|
||
[false, 0.05, 0, 2],
|
||
[true, 2.05, 3, undefined],
|
||
]);
|
||
expect([sources[1]!.loopStart, sources[1]!.loopEnd]).toEqual([3, 5]);
|
||
});
|
||
|
||
it('played once, it stops at its end and fades out just before; let ring, it fades over 0.3 s', async () => {
|
||
const library = new SoundLibrary(false);
|
||
library.playSequence('pattern:p', baked({ end: 2, until: 2, round: null }), '');
|
||
await settle();
|
||
await arrive();
|
||
expect([sources[0]!.loop, sources[0]!.startedFor]).toEqual([false, 2]);
|
||
const level = gains.at(-1)!;
|
||
expect(level.ramps).toEqual([
|
||
['set', 0.5, 2.04],
|
||
['linear', 0, 2.05],
|
||
]);
|
||
library.stopSequence('pattern:p', true);
|
||
expect(level.ramps.at(-1)).toEqual(['linear', 0, 0.3]);
|
||
});
|
||
|
||
it('one whose file could not be had is no bake: the notes are played', async () => {
|
||
vi.stubGlobal('fetch', () => Promise.resolve({ ok: false, status: 404 }));
|
||
const library = new SoundLibrary(false);
|
||
await library.load(BAKED);
|
||
const struck: ScheduledNote[] = [];
|
||
library.playSequence(
|
||
'pattern:p',
|
||
baked({ end: 2, until: 2, round: null }, (note) => (struck.push(note), 0)),
|
||
'',
|
||
);
|
||
await settle();
|
||
expect(struck.map((note) => note.note)).toEqual([60]);
|
||
expect(library.playbacks()[0]?.baked).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('a sound streamed (#349)', () => {
|
||
const COPY = 'copy.ogg';
|
||
const BAKED = 'baked.ogg';
|
||
/** Long enough that the waits below see the feed settle. */
|
||
const drain = async () => {
|
||
for (let i = 0; i < 200; i++) await settle();
|
||
};
|
||
const streaming = async () => {
|
||
stubDecoder();
|
||
const copy = fakeCopy(10);
|
||
serveCopies({ [COPY]: copy.bytes, [BAKED]: copy.bytes });
|
||
const library = new SoundLibrary(false);
|
||
library.setStreams(
|
||
new Map([
|
||
[FILE, { file: COPY, frames: copy.frames }],
|
||
[BAKED, { file: BAKED, frames: copy.frames }],
|
||
]),
|
||
);
|
||
await drain();
|
||
return library;
|
||
};
|
||
const player = () => worklets.find((w) => w.kind === 'stream')!;
|
||
/** What a player was handed as it was made and sent after, channel 0. */
|
||
const heard = (node: FakeWorklet) =>
|
||
[(node.options.processorOptions as unknown as StreamOptions).first, ...node.posted]
|
||
.filter((chunk): chunk is Float32Array[] => Array.isArray(chunk) && chunk.length > 0)
|
||
.flatMap((chunk) => Array.from(chunk[0]!));
|
||
|
||
it('plays from its copy on a stream player as long as the copy, none of the file decoded', async () => {
|
||
const library = await streaming();
|
||
expect(library.duration(FILE)).toBe(10);
|
||
const voice = library.play(FILE, 1, true);
|
||
await drain();
|
||
expect(player().options).toMatchObject({
|
||
outputChannelCount: [2],
|
||
processorOptions: { channels: 2, startAt: 0 },
|
||
});
|
||
expect(heard(player())[0]).toBe(PRE_SKIP);
|
||
expect((player().options.processorOptions as unknown as StreamOptions).first[0]).toHaveLength(96000);
|
||
expect(wires).toEqual(['stream > gain', ...INTO_MASTER]);
|
||
expect(decodes).toHaveLength(0);
|
||
expect(library.stats()).toMatchObject({ decoded: 0, playing: 1, streaming: { playing: 1, heads: 2 } });
|
||
library.stopVoice(voice);
|
||
clockTo();
|
||
expect(player().posted.at(-1)).toBe('stop');
|
||
expect(library.stats()).toMatchObject({ playing: 0, streaming: { playing: 0 } });
|
||
});
|
||
|
||
it('moves its pitch with a shifter of what it plays, as a bus does', async () => {
|
||
const library = await streaming();
|
||
await play(library, FILE, {}, { pitch: 7, stages: [] });
|
||
await drain();
|
||
expect(wires).toEqual(['stream > shifter', 'shifter > gain', ...INTO_MASTER]);
|
||
expect(worklets.find((w) => w.kind === 'shifter')!.pitch.value).toBe(7);
|
||
expect(library.stats()).toMatchObject({ vocoders: 1, last: { pitches: [7] } });
|
||
});
|
||
|
||
it('starts a clip of a song as far into its copy as its offset is', async () => {
|
||
const library = await streaming();
|
||
library.playClip(FILE, { at: 0, offset: 12.5, duration: 4, volume: 1, bus: '' });
|
||
await drain();
|
||
expect(heard(player())[0]).toBe(PRE_SKIP + 2.5 * 48000);
|
||
});
|
||
|
||
it('plays a sound whose copy cannot be had decoded, as without one', async () => {
|
||
stubDecoder();
|
||
vi.stubGlobal('fetch', (url: string) =>
|
||
Promise.resolve(
|
||
url.endsWith(COPY)
|
||
? { ok: false, status: 404 }
|
||
: { ok: true, arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)) },
|
||
),
|
||
);
|
||
const library = new SoundLibrary(false);
|
||
library.setStreams(new Map([[FILE, { file: COPY, frames: 480000 }]]));
|
||
await drain();
|
||
await play(library);
|
||
expect(worklets.filter((w) => w.kind === 'stream')).toHaveLength(0);
|
||
expect(sources.filter((s) => s.started)).toHaveLength(1);
|
||
});
|
||
|
||
it('prepares a bake by the head of its file and streams it round its round, to the sample', async () => {
|
||
const library = await streaming();
|
||
library.playSequence(
|
||
'pattern:p',
|
||
{
|
||
sequence: { notes: [{ tick: 0, length: 480, velocity: 1, instrument: 'i', note: 60 }] },
|
||
tempo: 120,
|
||
length: 1920,
|
||
loop: { start: 0, end: 1920 },
|
||
needs: { layers: [], files: [] },
|
||
strike: () => 0,
|
||
bake: { file: BAKED, gain: 0.5, playback: { end: null, until: 2, round: { start: 3, end: 5 } } },
|
||
},
|
||
'',
|
||
);
|
||
await drain();
|
||
expect(decodes).toHaveLength(0);
|
||
expect(sources).toHaveLength(0);
|
||
const sent = heard(player());
|
||
expect(sent[2 * 48000 - 1]).toBe(PRE_SKIP + 2 * 48000 - 1);
|
||
expect(sent[2 * 48000]).toBe(PRE_SKIP + 3 * 48000);
|
||
expect(library.playbacks()[0]?.baked).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('a sound FX on a bus (#346)', () => {
|
||
/** One frame of the shifter at the fake context's 48 kHz. */
|
||
const LATE = 2048 / 48000;
|
||
const layer: FxPlan = {
|
||
pitch: [],
|
||
stages: [],
|
||
source: { file: FILE, volume: 1, loop: false, loopStart: 0, loopEnd: 0, gate: false },
|
||
};
|
||
const voice = (plan: FxPlan): FxVoice => ({ plan, params: {}, tempo: 120 });
|
||
const up: FxPlan = { pitch: [{ value: 12 }], stages: [] };
|
||
const crush: FxPlan = planOf({ pitch: 0, stages: [{ kind: 'crush', bits: 4, rate: 8000, mix: 1 }] });
|
||
|
||
/** Two notes, one on track `t` and one on `u`, struck as the scheduler plans them. */
|
||
function playing(library: SoundLibrary, fields: Partial<SequencePlay>) {
|
||
const struck: ScheduledNote[] = [];
|
||
const note = { tick: 0, length: 480, velocity: 1, instrument: 'i' };
|
||
const id = library.playSequence(
|
||
'song:s',
|
||
{
|
||
sequence: {
|
||
notes: [
|
||
{ ...note, note: 60, bus: 't' },
|
||
{ ...note, note: 62, bus: 'u' },
|
||
],
|
||
},
|
||
tempo: 120,
|
||
length: 1920,
|
||
loop: null,
|
||
needs: { layers: [], files: [] },
|
||
buses: [
|
||
{ name: 't', gain: 1, pan: 0 },
|
||
{ name: 'u', gain: 1, pan: 0 },
|
||
],
|
||
strike: (scheduled) => {
|
||
struck.push(scheduled);
|
||
return library.playNote('instrument:i', [layer], {}, 1, '', scheduled.duration, {
|
||
at: scheduled.at,
|
||
...(scheduled.bus && { bus: scheduled.bus }),
|
||
});
|
||
},
|
||
...fields,
|
||
},
|
||
'',
|
||
);
|
||
return { id, struck };
|
||
}
|
||
|
||
it('sends all of a playing through one chain on its master, however many notes it plays', async () => {
|
||
const library = new SoundLibrary(false);
|
||
const { id } = playing(library, { fx: voice(crush) });
|
||
await settle();
|
||
await arrive();
|
||
expect(worklets.filter((w) => w.kind === 'crush')).toHaveLength(1);
|
||
expect(library.busLevels().find((bus) => bus.name === `play:${id}`)).toMatchObject({
|
||
fx: true,
|
||
latency: 0,
|
||
});
|
||
// The chain sits between the master's input and its level, the tracks go into that input.
|
||
expect(wires).toEqual(expect.arrayContaining(['gain > crush', 'crush > gain']));
|
||
});
|
||
|
||
it('shifts the pitch of a bus a frame late, and starts its notes as much earlier', async () => {
|
||
const library = new SoundLibrary(false);
|
||
const { id, struck } = playing(library, { fx: voice(up) });
|
||
await settle();
|
||
await arrive();
|
||
const shifter = worklets.find((w) => w.kind === 'shifter')!;
|
||
expect(shifter.options).toMatchObject({
|
||
channelCount: 2,
|
||
channelCountMode: 'explicit',
|
||
parameterData: { pitch: 12 },
|
||
});
|
||
expect(library.busLatency(`play:${id}`)).toBeCloseTo(LATE, 12);
|
||
// Planned to be heard a lead and a frame from now, started a frame before that.
|
||
expect(struck[0]!.at).toBeCloseTo(0.05 + LATE, 9);
|
||
expect(sources.map((source) => source.startedAt)).toEqual([
|
||
expect.closeTo(0.05, 9),
|
||
expect.closeTo(0.05, 9),
|
||
]);
|
||
});
|
||
|
||
it('holds the other way of a Mix back as far as its Pitch makes one late, so the two stay together', async () => {
|
||
const library = new SoundLibrary(false);
|
||
const doubled: FxPlan = { mix: { value: 0.5 }, a: up, b: { pitch: [], stages: [] }, stages: [] };
|
||
const { id } = playing(library, { fx: voice(doubled) });
|
||
await settle();
|
||
expect(delays.map((delay) => delay.delayTime.value)).toEqual([expect.closeTo(LATE, 9)]);
|
||
expect(library.busLatency(`play:${id}`)).toBeCloseTo(LATE, 12);
|
||
});
|
||
|
||
it('starts each note as much earlier as the buses it is heard through are late, and no more', async () => {
|
||
const library = new SoundLibrary(false);
|
||
const { id, struck } = playing(library, {
|
||
fx: voice(up),
|
||
buses: [
|
||
{ name: 't', gain: 1, pan: 0, fx: voice(up) },
|
||
{ name: 'i', gain: 1, pan: 0, into: 't', fx: voice(up) },
|
||
{ name: 'u', gain: 1, pan: 0 },
|
||
],
|
||
sequence: {
|
||
notes: [
|
||
{ tick: 0, length: 480, velocity: 1, instrument: 'i', note: 60, bus: 'i' },
|
||
{ tick: 0, length: 480, velocity: 1, instrument: 'i', note: 62, bus: 'u' },
|
||
],
|
||
},
|
||
});
|
||
await settle();
|
||
await arrive();
|
||
expect(library.busLatency(`play:${id}:i`)).toBeCloseTo(3 * LATE, 12);
|
||
expect(library.busLatency(`play:${id}:u`)).toBeCloseTo(LATE, 12);
|
||
// Both are heard where they were planned, the latest way three frames after it is struck.
|
||
const heard = 0.05 + 3 * LATE;
|
||
expect(struck.map((note) => note.at)).toEqual([expect.closeTo(heard, 9), expect.closeTo(heard, 9)]);
|
||
expect(sources.map((source) => source.startedAt)).toEqual([
|
||
expect.closeTo(heard - 3 * LATE, 9),
|
||
expect.closeTo(heard - LATE, 9),
|
||
]);
|
||
});
|
||
|
||
it('hands values to the sound FX of a playing by its number, and nothing else by it', async () => {
|
||
const library = new SoundLibrary(false);
|
||
const loud: FxPlan = {
|
||
pitch: [],
|
||
stages: [{ kind: 'gain', db: { value: 0, param: 'loud', min: -120, max: 24 } }],
|
||
};
|
||
const { id } = playing(library, { fx: voice(loud) });
|
||
await settle();
|
||
const before = gains.length;
|
||
expect(library.tune(id, { loud: -6 })).toBe(true);
|
||
expect(gains.length).toBe(before);
|
||
expect(gains.flatMap((gain) => gain.glides)).toContainEqual(expect.closeTo(10 ** (-6 / 20), 9));
|
||
expect(library.stats().tuned).toBe(1);
|
||
});
|
||
|
||
it('lets the chains of a playing go after its last voice, once what they hold has been heard', async () => {
|
||
const library = new SoundLibrary(false);
|
||
playing(library, { fx: voice(up) });
|
||
await settle();
|
||
await arrive();
|
||
library.stopSequence('song:', false, 0);
|
||
const shifter = worklets.find((w) => w.kind === 'shifter')!;
|
||
expect(shifter.posted).toEqual([]);
|
||
clockTo();
|
||
expect(shifter.posted).toEqual(['stop']);
|
||
expect(shifter.disconnected).toBe(true);
|
||
expect(library.busLevels()).toEqual([MASTER_LEVEL]);
|
||
});
|
||
|
||
it('meters a track after its pan, each side apart, and keeps the tap when it goes elsewhere (#359)', async () => {
|
||
const library = new SoundLibrary(false);
|
||
await library.setBuses([{ name: 'song:s:t', gain: 1, pan: 0 }]);
|
||
wires.length = 0;
|
||
expect(library.busPeaks(['song:s:t', 'song:s:gone'])).toEqual(new Map([['song:s:t', [0, 0]]]));
|
||
expect(wires).toEqual(['splitter > analyser', 'splitter > analyser', 'panner > splitter']);
|
||
analysers[0]!.sample = -1.5;
|
||
analysers[1]!.sample = 0.25;
|
||
expect(library.busPeaks(['song:s:t']).get('song:s:t')).toEqual([1.5, 0.25]);
|
||
expect(analysers).toHaveLength(2);
|
||
wires.length = 0;
|
||
await library.setBuses([{ name: 'song:s:t', gain: 1, pan: 0, into: 'song:s:u' }]);
|
||
expect(wires).toEqual(expect.arrayContaining(['panner > splitter']));
|
||
expect(library.metered()).toBe(1);
|
||
disconnects.length = 0;
|
||
library.unmeter();
|
||
expect(library.metered()).toBe(0);
|
||
expect(disconnects).toEqual(['panner', 'splitter']);
|
||
});
|
||
|
||
it('makes the chain of a track in the Song tab once, and again only for another sound FX', async () => {
|
||
const library = new SoundLibrary(false);
|
||
const track = { name: 'song:s:t', gain: 1, pan: 0, fx: voice(crush) };
|
||
await library.setBuses([track]);
|
||
// As the Song tab hands them: a new voice of the same FX every time.
|
||
await library.setBuses([{ ...track, gain: 0.5, fx: voice(crush) }]);
|
||
expect(worklets.filter((w) => w.kind === 'crush')).toHaveLength(1);
|
||
await library.setBuses([{ ...track, fx: voice(up) }]);
|
||
expect(worklets.map((w) => [w.kind, w.posted])).toEqual([
|
||
['crush', ['stop']],
|
||
['shifter', []],
|
||
]);
|
||
expect(library.busLatency('song:s:t')).toBeCloseTo(LATE, 12);
|
||
await library.setBuses([{ ...track, fx: undefined }]);
|
||
expect(library.busLevels()).toEqual([
|
||
MASTER_LEVEL,
|
||
{ name: 'song:s:t', gain: 1, pan: 0, into: 'mix', fx: false, latency: 0 },
|
||
]);
|
||
});
|
||
|
||
it('lets the buses of the Song tab go after a stop, unless the next play takes them up first', async () => {
|
||
const library = new SoundLibrary(false);
|
||
await library.setBuses([{ name: 'song:s:t', gain: 1, pan: 0, fx: voice(up) }]);
|
||
library.releaseBuses(['song:s:t']);
|
||
await library.setBuses([{ name: 'song:s:t', gain: 1, pan: 0, fx: voice(up) }]);
|
||
clockTo();
|
||
expect(library.busLevels().map((bus) => bus.name)).toEqual(['mix', 'song:s:t']);
|
||
library.releaseBuses(['song:s:t']);
|
||
clockTo();
|
||
expect(library.busLevels()).toEqual([MASTER_LEVEL]);
|
||
expect(worklets[0]!.posted).toEqual(['stop']);
|
||
});
|
||
});
|
||
|
||
describe('the sends of the tracks of a song (#350)', () => {
|
||
const LATE = 2048 / 48000;
|
||
const voice = (plan: FxPlan): FxVoice => ({ plan, params: {}, tempo: 120 });
|
||
const up: FxPlan = { pitch: [{ value: 12 }], stages: [] };
|
||
const crush: FxPlan = planOf({ pitch: 0, stages: [{ kind: 'crush', bits: 4, rate: 8000, mix: 1 }] });
|
||
const out = { name: 'song:s:o', gain: 1, pan: 0 };
|
||
const sendsOf = (library: SoundLibrary, name: string) =>
|
||
library.busLevels().find((bus) => bus.name === name)?.sends;
|
||
|
||
it('taps each send where its mode says: after the pan, after the sound FX, or where it comes in', async () => {
|
||
const library = new SoundLibrary(false);
|
||
await library.setBuses([
|
||
out,
|
||
{
|
||
name: 'song:s:t',
|
||
gain: 0.5,
|
||
pan: 0,
|
||
fx: voice(crush),
|
||
sends: [
|
||
{ to: 'song:s:o', gain: 1, tap: 'post' },
|
||
{ to: 'song:s:o', gain: 0.25, tap: 'postFx' },
|
||
{ to: 'song:s:o', gain: 0.5, tap: 'preFx' },
|
||
],
|
||
},
|
||
]);
|
||
expect(wires).toEqual(
|
||
expect.arrayContaining(['panner > delay', 'crush > delay', 'gain > delay', 'delay > gain']),
|
||
);
|
||
expect(sendsOf(library, 'song:s:t')).toEqual([
|
||
{ to: 'song:s:o', tap: 'post', gain: 1, delay: 0 },
|
||
{ to: 'song:s:o', tap: 'postFx', gain: 0.25, delay: 0 },
|
||
{ to: 'song:s:o', tap: 'preFx', gain: 0.5, delay: 0 },
|
||
]);
|
||
// A track with sends is not also sent on as a whole.
|
||
expect(library.busLevels().find((bus) => bus.name === 'song:s:t')?.into).toBeNull();
|
||
});
|
||
|
||
it('holds back the ways that pass a Pitch by, so all of a track is heard as late as its latest way', async () => {
|
||
const library = new SoundLibrary(false);
|
||
await library.setBuses([
|
||
out,
|
||
{
|
||
name: 'song:s:t',
|
||
gain: 1,
|
||
pan: 0,
|
||
fx: voice(up),
|
||
sends: [
|
||
{ to: 'song:s:o', gain: 1, tap: 'post' },
|
||
{ to: 'song:s:o', gain: 1, tap: 'preFx' },
|
||
],
|
||
},
|
||
{
|
||
name: 'song:s:u',
|
||
gain: 1,
|
||
pan: 0,
|
||
sends: [
|
||
{ to: 'song:s:t', gain: 1, tap: 'post' },
|
||
{ to: 'song:s:o', gain: 1, tap: 'post' },
|
||
],
|
||
},
|
||
]);
|
||
expect(library.busLatency('song:s:t')).toBeCloseTo(LATE, 12);
|
||
expect(library.busLatency('song:s:u')).toBeCloseTo(LATE, 12);
|
||
expect(sendsOf(library, 'song:s:t')?.map((send) => send.delay)).toEqual([0, expect.closeTo(LATE, 9)]);
|
||
expect(sendsOf(library, 'song:s:u')?.map((send) => send.delay)).toEqual([0, expect.closeTo(LATE, 9)]);
|
||
// Without its Pitch nothing of the track is late any more, and nothing is held back.
|
||
await library.setBuses([
|
||
out,
|
||
{
|
||
name: 'song:s:t',
|
||
gain: 1,
|
||
pan: 0,
|
||
sends: [
|
||
{ to: 'song:s:o', gain: 1, tap: 'post' },
|
||
{ to: 'song:s:o', gain: 1, tap: 'preFx' },
|
||
],
|
||
},
|
||
]);
|
||
expect(sendsOf(library, 'song:s:u')?.map((send) => send.delay)).toEqual([0, 0]);
|
||
});
|
||
|
||
it('glides the level of a send that only changed its level, and makes the sends anew that go elsewhere', async () => {
|
||
const library = new SoundLibrary(false);
|
||
const track = { name: 'song:s:t', gain: 1, pan: 0 };
|
||
await library.setBuses([out, { ...track, sends: [{ to: 'song:s:o', gain: 1, tap: 'post' as const }] }]);
|
||
const made = delays.length;
|
||
await library.setBuses([out, { ...track, sends: [{ to: 'song:s:o', gain: 0, tap: 'post' as const }] }]);
|
||
expect(delays).toHaveLength(made);
|
||
expect(sendsOf(library, 'song:s:t')).toEqual([{ to: 'song:s:o', tap: 'post', gain: 0, delay: 0 }]);
|
||
await library.setBuses([out, { ...track, sends: [{ to: 'mix', gain: 1, tap: 'postFx' as const }] }]);
|
||
expect(delays).toHaveLength(made + 1);
|
||
expect(disconnects).toEqual(expect.arrayContaining(['delay']));
|
||
// Back to one way out as a whole: no sends left.
|
||
await library.setBuses([out, track]);
|
||
expect(library.busLevels().find((bus) => bus.name === 'song:s:t')).toEqual({
|
||
name: 'song:s:t',
|
||
gain: 1,
|
||
pan: 0,
|
||
into: 'mix',
|
||
fx: false,
|
||
latency: 0,
|
||
});
|
||
});
|
||
|
||
it('sends the tracks of a game’s playing into one another and into its master', () => {
|
||
const library = new SoundLibrary(false);
|
||
const id = library.playSequence(
|
||
'song:s',
|
||
{
|
||
sequence: { notes: [{ tick: 0, length: 480, velocity: 1, instrument: 'i', note: 60, bus: 't' }] },
|
||
tempo: 120,
|
||
length: 1920,
|
||
loop: null,
|
||
needs: { layers: [], files: [] },
|
||
buses: [
|
||
{
|
||
name: 't',
|
||
gain: 1,
|
||
pan: 0,
|
||
sends: [
|
||
{ to: 'u', gain: 0.5, tap: 'post' },
|
||
{ gain: 1, tap: 'preFx' },
|
||
],
|
||
},
|
||
{ name: 'u', gain: 1, pan: 0, sends: [{ gain: 1, tap: 'post' }] },
|
||
],
|
||
strike: () => 0,
|
||
},
|
||
'',
|
||
);
|
||
expect(sendsOf(library, `play:${id}:t`)?.map((send) => [send.to, send.gain, send.tap])).toEqual([
|
||
[`play:${id}:u`, 0.5, 'post'],
|
||
[`play:${id}`, 1, 'preFx'],
|
||
]);
|
||
expect(sendsOf(library, `play:${id}:u`)?.map((send) => send.to)).toEqual([`play:${id}`]);
|
||
});
|
||
});
|
||
|
||
describe('the master and the buses of the project (#325)', () => {
|
||
const bus = (name: string, volume: number, into: string | null, fx: FxVoice | null = null): MixBus => ({
|
||
name,
|
||
volume,
|
||
into,
|
||
fx,
|
||
});
|
||
const mix = [bus('mix', 0.5, null), bus('mix:a', 1, 'mix'), bus('mix:b', 0.25, 'mix:a')];
|
||
const levels = (library: SoundLibrary) =>
|
||
library.busLevels().map(({ name, gain, into }) => ({ name, gain, into }));
|
||
|
||
it('makes nothing before there is a context, then the master and every bus into the one it names', async () => {
|
||
const library = new SoundLibrary(false);
|
||
await library.setMix(mix);
|
||
expect(wires).toEqual([]);
|
||
await play(library);
|
||
expect(levels(library)).toEqual([
|
||
{ name: 'mix', gain: 0.5, into: null },
|
||
{ name: 'mix:a', gain: 1, into: 'mix' },
|
||
{ name: 'mix:b', gain: 0.25, into: 'mix:a' },
|
||
]);
|
||
expect(wires.filter((wire) => wire.endsWith('> destination'))).toEqual(['panner > destination']);
|
||
});
|
||
|
||
it('keeps the mix where the first thing heard is a tab of a song, which names its bus before there is a context', async () => {
|
||
const library = new SoundLibrary(false);
|
||
await library.setMix(mix);
|
||
await library.setBuses([{ name: 'music:1', gain: 1, pan: 0, into: 'mix:b' }]);
|
||
expect(levels(library)).toEqual([
|
||
{ name: 'mix', gain: 0.5, into: null },
|
||
{ name: 'mix:a', gain: 1, into: 'mix' },
|
||
{ name: 'mix:b', gain: 0.25, into: 'mix:a' },
|
||
{ name: 'music:1', gain: 1, into: 'mix:b' },
|
||
]);
|
||
expect(wires.filter((wire) => wire.endsWith('> destination'))).toEqual(['panner > destination']);
|
||
});
|
||
|
||
it('plays a voice on the bus it is given, and a playing too', async () => {
|
||
const library = new SoundLibrary(false);
|
||
await library.setMix(mix);
|
||
library.play(FILE, 1, false, '', DRY_VOICE, 'mix:b');
|
||
await settle();
|
||
await arrive();
|
||
expect(library.stats().last?.bus).toBe('mix:b');
|
||
const id = library.playSequence(
|
||
'pattern:p',
|
||
{
|
||
sequence: { notes: [{ tick: 0, length: 480, velocity: 1, instrument: 'i', note: 60 }] },
|
||
tempo: 120,
|
||
length: 1920,
|
||
loop: null,
|
||
needs: { layers: [], files: [] },
|
||
bus: 'mix:a',
|
||
strike: () => 0,
|
||
},
|
||
'',
|
||
);
|
||
expect(library.busLevels().find((level) => level.name === `play:${id}`)?.into).toBe('mix:a');
|
||
});
|
||
|
||
it('sends a bus on into another one when the mix says so, and lets one go whose asset is gone', async () => {
|
||
const library = new SoundLibrary(false);
|
||
await library.setMix(mix);
|
||
await play(library);
|
||
await library.setMix([mix[0]!, mix[1]!, { ...mix[2]!, into: 'mix' }]);
|
||
expect(levels(library).at(-1)).toEqual({ name: 'mix:b', gain: 0.25, into: 'mix' });
|
||
await library.setMix([mix[0]!, mix[1]!]);
|
||
clockTo();
|
||
expect(levels(library).map((level) => level.name)).toEqual(['mix', 'mix:a']);
|
||
});
|
||
|
||
it('sends a bus of the mix along the links of the master routing, and back into one bus (#351)', async () => {
|
||
const library = new SoundLibrary(false);
|
||
await play(library);
|
||
const sends = [
|
||
{ to: 'mix', gain: 1, tap: 'post' as const },
|
||
{ to: 'mix:a', gain: 0.5, tap: 'preFx' as const },
|
||
];
|
||
await library.setMix([mix[0]!, mix[1]!, { ...mix[2]!, sends }]);
|
||
const b = library.busLevels().find((level) => level.name === 'mix:b');
|
||
expect(b?.sends?.map(({ to, gain, tap }) => [to, gain, tap])).toEqual([
|
||
['mix', 1, 'post'],
|
||
['mix:a', 0.5, 'preFx'],
|
||
]);
|
||
expect(b?.into).toBeNull();
|
||
await library.setMix(mix);
|
||
expect(levels(library).at(-1)).toEqual({ name: 'mix:b', gain: 0.25, into: 'mix:a' });
|
||
});
|
||
|
||
it('sends the master of a playing along the links of its pattern or song (#351)', async () => {
|
||
const library = new SoundLibrary(false);
|
||
await library.setMix(mix);
|
||
const id = library.playSequence(
|
||
'song:s',
|
||
{
|
||
sequence: { notes: [{ tick: 0, length: 480, velocity: 1, instrument: 'i', note: 60 }] },
|
||
tempo: 120,
|
||
length: 1920,
|
||
loop: null,
|
||
needs: { layers: [], files: [] },
|
||
sends: [
|
||
{ to: 'mix', gain: 1, tap: 'post' },
|
||
{ to: 'mix:b', gain: 0.25, tap: 'preFx' },
|
||
],
|
||
strike: () => 0,
|
||
},
|
||
'',
|
||
);
|
||
const master = library.busLevels().find((level) => level.name === `play:${id}`);
|
||
expect(master?.sends?.map(({ to, gain, tap }) => [to, gain, tap])).toEqual([
|
||
['mix', 1, 'post'],
|
||
['mix:b', 0.25, 'preFx'],
|
||
]);
|
||
});
|
||
|
||
it('holds the volume a game sets over that of the asset until it is reset, gliding there over its time', async () => {
|
||
const library = new SoundLibrary(false);
|
||
await library.setMix(mix);
|
||
await play(library);
|
||
library.setMixVolume('mix:b', 0.8, 1.5);
|
||
expect(gains.some((gain) => gain.ramps.some((ramp) => ramp[0] === 'linear' && ramp[1] === 0.8))).toBe(
|
||
true,
|
||
);
|
||
expect(library.mixVolume('mix:b')).toBe(0.8);
|
||
await library.setMix([mix[0]!, mix[1]!, { ...mix[2]!, volume: 0.1 }]);
|
||
expect([library.mixVolume('mix:b'), levels(library).at(-1)!.gain]).toEqual([0.8, 0.8]);
|
||
library.resetMix();
|
||
expect([library.mixVolume('mix:b'), levels(library).at(-1)!.gain]).toEqual([0.1, 0.1]);
|
||
expect(library.mixVolume('mix')).toBe(0.5);
|
||
});
|
||
|
||
it('changes the sound FX of a bus of the mix, and says so only for one that is there', async () => {
|
||
const library = new SoundLibrary(false);
|
||
const plan: FxPlan = {
|
||
pitch: [],
|
||
stages: [{ kind: 'crush', bits: { value: 8, param: 'b' }, rate: { value: 8000 }, mix: { value: 1 } }],
|
||
};
|
||
await play(library);
|
||
await library.setMix([bus('mix', 1, null, { plan, params: {}, tempo: 120 })]);
|
||
expect(library.tuneMix('mix', { b: 3 }, 0.5)).toBe(true);
|
||
expect(worklets[0]!.parameters.get('bits')!.ramps).toEqual([['linear', 3, 0.5]]);
|
||
expect(library.tuneMix('mix:gone', { b: 3 })).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('the stages of #318 to #328', () => {
|
||
it('runs a steeper Filter as several in a row, one cutoff for all, the Q on the last only (#318)', () => {
|
||
const context = fakeContext() as unknown as BaseAudioContext;
|
||
const made = stageNodes(context, {
|
||
kind: 'pass',
|
||
filter: 'lowpass',
|
||
stages: 4,
|
||
cutoff: 800,
|
||
q: 12,
|
||
gain: 0,
|
||
});
|
||
expect(filters.map((f) => f.type)).toEqual(['lowpass', 'lowpass', 'lowpass', 'lowpass']);
|
||
expect(wires.filter((w) => w === 'filter > filter')).toHaveLength(3);
|
||
expect(filters.map((f) => f.frequency.value)).toEqual([0, 0, 0, 0]);
|
||
expect(wires.filter((w) => w === 'clock > ?')).toHaveLength(4);
|
||
expect(clocks[0]!.started).toBe(true);
|
||
expect(clocks[0]!.offset.value).toBe(800);
|
||
expect(filters.slice(0, 3).map((f) => f.Q.value)).toEqual([
|
||
expect.closeTo(-3.01, 2),
|
||
expect.closeTo(-3.01, 2),
|
||
expect.closeTo(-3.01, 2),
|
||
]);
|
||
expect(filters[3]!.Q.value).toBe(12);
|
||
expect(made.knobs.map((k) => k.param)).toEqual([clocks[0]!.offset, filters[3]!.Q, filters[3]!.gain]);
|
||
made.stop!();
|
||
expect(clocks[0]!.stopped).toBe(true);
|
||
});
|
||
|
||
it('gives a shelf its gain, and a single filter its own cutoff (#318)', () => {
|
||
const context = fakeContext() as unknown as BaseAudioContext;
|
||
const made = stageNodes(context, {
|
||
kind: 'pass',
|
||
filter: 'highshelf',
|
||
stages: 1,
|
||
cutoff: 5000,
|
||
q: 1,
|
||
gain: -6,
|
||
});
|
||
expect(filters).toHaveLength(1);
|
||
expect([filters[0]!.type, filters[0]!.frequency.value, filters[0]!.gain.value]).toEqual([
|
||
'highshelf',
|
||
5000,
|
||
-6,
|
||
]);
|
||
expect(clocks).toHaveLength(0);
|
||
expect(made.knobs.map((k) => k.even)).toEqual(['ratio', 'ratio', 'value']);
|
||
expect(made.stop).toBeUndefined();
|
||
});
|
||
|
||
it('pans with a StereoPanner whose pan glides like any knob (#319)', () => {
|
||
const context = fakeContext() as unknown as BaseAudioContext;
|
||
const made = stageNodes(context, { kind: 'pan', pan: -0.4 });
|
||
expect(panners.map((p) => p.value)).toEqual([-0.4]);
|
||
expect(made.knobs).toEqual([{ param: panners[0], target: -0.4, even: 'value' }]);
|
||
});
|
||
|
||
it('compresses with the node of Web Audio and a gain after it for makeup and auto-gain (#326)', () => {
|
||
const context = fakeContext() as unknown as BaseAudioContext;
|
||
const made = stageNodes(context, {
|
||
kind: 'compress',
|
||
threshold: -12,
|
||
knee: 0,
|
||
ratio: 4,
|
||
attack: 0.01,
|
||
release: 0.2,
|
||
gain: 2,
|
||
});
|
||
const node = compressors[0]!;
|
||
expect([node.threshold, node.knee, node.ratio, node.attack, node.release].map((p) => p.value)).toEqual([
|
||
-12, 0, 4, 0.01, 0.2,
|
||
]);
|
||
expect(wires).toEqual(['compressor > gain']);
|
||
expect(made.knobs.map((k) => [k.target, k.even])).toEqual([
|
||
[-12, 'value'],
|
||
[0, 'value'],
|
||
[4, 'ratio'],
|
||
[0.01, 'ratio'],
|
||
[0.2, 'ratio'],
|
||
[2, 'ratio'],
|
||
]);
|
||
});
|
||
|
||
it('holds back the other way of a Mix as far as a Compressor makes one late, on a voice and a bus (#326)', async () => {
|
||
const library = new SoundLibrary(false);
|
||
const compress: FxStagePlan = {
|
||
kind: 'compress',
|
||
threshold: { value: -24 },
|
||
knee: { value: 6 },
|
||
ratio: { value: 4 },
|
||
attack: { value: 0.003 },
|
||
release: { value: 0.25 },
|
||
makeup: { value: 0 },
|
||
autoGain: { value: 0 },
|
||
};
|
||
const plan: FxPlan = {
|
||
mix: { value: 0.5 },
|
||
a: { pitch: [], stages: [compress] },
|
||
b: { pitch: [], stages: [] },
|
||
stages: [],
|
||
};
|
||
await play(library, FILE, {}, { plan, params: {} });
|
||
expect(delays.map((delay) => delay.delayTime.value)).toEqual([0.006]);
|
||
// Each way has a source of its own: one through the Compressor, one through the delay line.
|
||
expect(wires).toEqual(expect.arrayContaining(['source > compressor', 'source > delay', 'delay > gain']));
|
||
expect(library.startedAt(1)).toBeCloseTo(0, 9);
|
||
|
||
await library.setMix([
|
||
{ name: 'mix', volume: 1, into: null, fx: null },
|
||
{ name: 'mix:c', volume: 1, into: 'mix', fx: { plan, params: {} } },
|
||
]);
|
||
expect(library.busLatency('mix:c')).toBeCloseTo(0.006, 9);
|
||
});
|
||
|
||
it('saturates through a curve oversampled 4x, the sound itself held back beside it (#323)', () => {
|
||
const context = fakeContext() as unknown as BaseAudioContext;
|
||
const made = stageNodes(context, {
|
||
kind: 'saturate',
|
||
curve: 'tube',
|
||
drive: 12,
|
||
tone: 5000,
|
||
level: 0.5,
|
||
mix: 0.25,
|
||
});
|
||
expect(shapers[0]!.curve).toBe(saturationCurve('tube'));
|
||
expect((shapers[0] as unknown as { oversample: string }).oversample).toBe('4x');
|
||
expect(filters.map((f) => [f.type, f.frequency.value])).toEqual([
|
||
['highpass', 10],
|
||
['lowpass', 5000],
|
||
]);
|
||
expect(delays.map((d) => d.delayTime.value)).toEqual([0.004]);
|
||
expect(made.knobs.map((k) => [k.target, k.even])).toEqual([
|
||
[10 ** (12 / 20) / 256, 'ratio'],
|
||
[5000, 'ratio'],
|
||
[0.5, 'ratio'],
|
||
[0.25, 'value'],
|
||
[0.75, 'value'],
|
||
]);
|
||
});
|
||
|
||
it('starts the LFO of a Vibrato with its voice and delays around its center, the voice that much earlier (#320)', async () => {
|
||
const library = new SoundLibrary(false);
|
||
const vibrato: FxStagePlan = {
|
||
kind: 'vibrato',
|
||
shape: 'square',
|
||
rate: { value: 0.5 },
|
||
depth: { value: 4 },
|
||
};
|
||
library.playNote(
|
||
'instrument:i',
|
||
[
|
||
{
|
||
pitch: [],
|
||
stages: [vibrato],
|
||
source: {
|
||
file: FILE,
|
||
volume: 1,
|
||
loop: false,
|
||
loopStart: 0,
|
||
loopEnd: 0,
|
||
gate: true,
|
||
},
|
||
},
|
||
],
|
||
{},
|
||
1,
|
||
'',
|
||
0,
|
||
{ at: 2 },
|
||
);
|
||
await settle();
|
||
await arrive();
|
||
const center = ((2 ** (4 / 12) - 1) * 0.25) / 0.5;
|
||
const options = worklets[0]!.options.processorOptions as unknown as LfoOptions & { vibrato: boolean };
|
||
expect(options).toMatchObject({ shape: 2, startAt: 2 - center, vibrato: true });
|
||
expect(options.center).toBeCloseTo(center, 4);
|
||
expect(sources[0]!.startedAt).toBeCloseTo(2 - center, 4);
|
||
expect([...worklets[0]!.parameters.entries()].map(([name, p]) => [name, p.value])).toEqual([
|
||
['rate', 0.5],
|
||
['depth', 4],
|
||
]);
|
||
});
|
||
|
||
it('makes the impulse response of a Reverb once, and waits its pre-delay before it (#328)', () => {
|
||
const context = fakeContext() as unknown as BaseAudioContext;
|
||
const stage = { kind: 'reverb', decay: 0.75, damping: 0.25, predelay: 0.02, mix: 0.4 } as const;
|
||
const made = stageNodes(context, stage);
|
||
stageNodes(context, stage);
|
||
const buffer = convolvers[0]!.buffer as { length: number };
|
||
expect(buffer.length).toBe(0.75 * 48000);
|
||
expect(convolvers[1]!.buffer).toBe(buffer);
|
||
expect(delays.map((d) => d.delayTime.value)).toEqual([0.02, 0.02]);
|
||
expect(made.knobs.map((k) => [k.param === null, k.target])).toEqual([
|
||
[true, 0.75],
|
||
[true, 0.25],
|
||
[true, 0.02],
|
||
[false, 0.4],
|
||
[false, 0.6],
|
||
]);
|
||
});
|
||
|
||
it('loads the sound a Reverb takes as its impulse response with the voice, and rings as long (#328)', async () => {
|
||
const library = new SoundLibrary(false);
|
||
const plan: FxPlan = {
|
||
pitch: [],
|
||
stages: [
|
||
{
|
||
kind: 'reverb',
|
||
impulse: 'room.wav',
|
||
decay: { value: 1 },
|
||
damping: { value: 0 },
|
||
predelay: { value: 0.1 },
|
||
mix: { value: 1 },
|
||
},
|
||
],
|
||
};
|
||
const voice = await play(library, FILE, {}, { plan, params: {} });
|
||
expect(library.playing(voice)).toBe(true);
|
||
expect(library.stats().decoded).toBe(2);
|
||
expect(convolvers[0]!.buffer).toMatchObject({ length: 1200, sampleRate: 800 });
|
||
// 1200 samples at 800 Hz, after a pre-delay of 0.1 s.
|
||
expect(library.tail(voice)).toBeCloseTo(1.6, 9);
|
||
});
|
||
});
|
||
|
||
describe('Wet (#354)', () => {
|
||
const compress: FxStage = {
|
||
kind: 'compress',
|
||
threshold: -24,
|
||
knee: 6,
|
||
ratio: 4,
|
||
attack: 0.003,
|
||
release: 0.25,
|
||
gain: 1,
|
||
};
|
||
|
||
it('blends what a node adds with the sound as it came in, held back as far as the node is late', () => {
|
||
const context = fakeContext() as unknown as BaseAudioContext;
|
||
const made = stageNodes(context, { kind: 'wet', wet: 0.3, stages: [compress] });
|
||
expect(delays.map((delay) => delay.delayTime.value)).toEqual([0.006]);
|
||
expect(wires).toEqual([
|
||
'compressor > gain', // the node, made first
|
||
'gain > compressor', // in, through it
|
||
'gain > gain', // at its wet
|
||
'gain > gain', // and out
|
||
'gain > delay', // the sound as it came in, held back
|
||
'delay > gain', // at the rest
|
||
'gain > gain',
|
||
]);
|
||
expect(made.knobs.slice(-2).map((knob) => [knob.target, knob.even])).toEqual([
|
||
[0.3, 'value'],
|
||
[expect.closeTo(0.7, 9), 'value'],
|
||
]);
|
||
expect(made.knobs).toHaveLength(8);
|
||
});
|
||
|
||
it('an Envelope moving the wet moves both shares, the rest as 1 - wet', () => {
|
||
const context = fakeContext() as unknown as BaseAudioContext;
|
||
const followed: [AudioParam, number | undefined][] = [];
|
||
const envelope = { id: 'e', attack: 0.1, decay: 0.1, sustain: 0.5, release: 0.1 };
|
||
const made = stageNodes(
|
||
context,
|
||
{ kind: 'wet', wet: 0, wetMod: { envelope, from: 0, to: 1 }, stages: [{ kind: 'pan', pan: 1 }] },
|
||
(_mod, param, _ratio, map) => followed.push([param, map?.(0.25)]),
|
||
);
|
||
const [wet, dry] = made.knobs.slice(-2);
|
||
expect(wet!.param).toBeNull();
|
||
expect(dry!.param).toBeNull();
|
||
expect(followed.map(([, mapped]) => mapped)).toEqual([undefined, 0.75]);
|
||
expect(followed.map(([param]) => param.value)).toEqual([0, 0]);
|
||
});
|
||
|
||
it('a wet from a parameter glides both shares while the voice plays', async () => {
|
||
const library = new SoundLibrary(false);
|
||
const plan: FxPlan = {
|
||
pitch: [],
|
||
stages: [
|
||
{
|
||
kind: 'wet',
|
||
wet: { value: 1, param: 'w', min: 0, max: 1 },
|
||
stages: [{ kind: 'pan', pan: { value: 1 } }],
|
||
},
|
||
],
|
||
};
|
||
const voice = await play(library, FILE, {}, { plan, params: {} });
|
||
expect(library.levels(FILE)).toEqual([[1, 1, 0]]);
|
||
library.tune(voice, { w: 0.25 }, 2);
|
||
const ramps = gains.flatMap((gain) => gain.ramps);
|
||
expect(ramps).toEqual([
|
||
['linear', 0.25, 2],
|
||
['linear', 0.75, 2],
|
||
]);
|
||
});
|
||
});
|
||
|
||
describe('what has ended is let go', () => {
|
||
it('closes the port of a pitched way once it ends or is stopped, so its voice can be collected', async () => {
|
||
const library = new SoundLibrary(false);
|
||
await play(library, FILE, {}, { pitch: 3, stages: [] });
|
||
await play(library, FILE, {}, { pitch: 5, stages: [] });
|
||
const [ended, stopped] = worklets;
|
||
ended!.port.onmessage!({ data: 'ended' });
|
||
expect(ended!.port).toMatchObject({ closed: true, onmessage: null });
|
||
expect(stopped!.port.closed).toBe(false);
|
||
library.stop(FILE);
|
||
clockTo();
|
||
expect(stopped!.posted).toEqual(['stop']);
|
||
expect(stopped!.port).toMatchObject({ closed: true, onmessage: null });
|
||
});
|
||
|
||
it('takes a voice without echoes off its bus as soon as its sound has ended', async () => {
|
||
const library = new SoundLibrary(false);
|
||
await play(library);
|
||
const before = disconnects.length;
|
||
sources[0]!.onended!();
|
||
expect(library.stats().playing).toBe(0);
|
||
expect(disconnects.slice(before)).toEqual(['gain']);
|
||
});
|
||
|
||
it('cuts the oldest pitched voices past twelve, one already let go before one still held', async () => {
|
||
const library = new SoundLibrary(false);
|
||
const oneShot: FxPlan = {
|
||
pitch: [{ value: 3 }],
|
||
stages: [],
|
||
source: { file: FILE, volume: 1, loop: false, loopStart: 0, loopEnd: 0, gate: false },
|
||
};
|
||
const voices: number[] = [];
|
||
const strike = async () => {
|
||
voices.push(library.playNote('instrument:i', [oneShot], {}, 1));
|
||
await settle();
|
||
await arrive();
|
||
};
|
||
for (let i = 0; i < 12; i++) await strike();
|
||
expect(voices.every((voice) => library.playing(voice))).toBe(true);
|
||
library.release(voices[4]!);
|
||
await strike();
|
||
expect(voices.map((voice) => library.playing(voice))).toEqual(voices.map((_, i) => i !== 4));
|
||
await strike();
|
||
expect(voices.map((voice) => library.playing(voice))).toEqual(voices.map((_, i) => i !== 0 && i !== 4));
|
||
expect(library.stats().playing).toBe(12);
|
||
expect(worklets).toHaveLength(14);
|
||
});
|
||
});
|