/** * 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 { SequencePlay } from '../../src/core/music/playbacks'; import type { ScheduledNote } from '../../src/core/music/Scheduler'; import type { PitchOptions } from '../../src/viewport/fxWorklet'; import { SoundLibrary, stageNodes } from '../../src/viewport/SoundLibrary'; /** What a test wants to hear: a chain whose pitch, where it is not 0, is a Pitch typed in. */ type Chain = | { pitch: number; stages: readonly FxStage[] } | { mix: number; a: Chain; b: Chain; stages: readonly FxStage[] }; const FILE = 'a.wav'; const OTHER = 'b.wav'; /** * An AudioParam that remembers where it was sent to glide, where it was sent to ramp – "linear", * "exponential", "set" for a jump later on or "curve" with the samples' first, middle and last value, * to what, by when – and how often it was held. It has no cancelAndHoldAtTime, as in Firefox, so the * hold by hand (a set at the clock's 0) is what runs here. */ interface FakeParam { value: number; glides: number[]; ramps: [string, number | number[], number][]; holds: number; setTargetAtTime: (value: number) => void; cancelScheduledValues: () => void; setValueAtTime: (value: number, at: number) => void; setValueCurveAtTime: (values: Float32Array, at: number, time: number) => void; linearRampToValueAtTime: (value: number, at: number) => void; exponentialRampToValueAtTime: (value: number, at: number) => void; } function param(value: number): FakeParam { const made: FakeParam = { value, glides: [], ramps: [], holds: 0, setTargetAtTime: (to) => made.glides.push(to), cancelScheduledValues: () => {}, setValueAtTime: (to, at) => (at > 0 ? void made.ramps.push(['set', to, at]) : void (made.holds += 1)), setValueCurveAtTime: (values, at, time) => made.ramps.push(['curve', [values[0]!, values[(values.length - 1) / 2]!, values.at(-1)!], at + time]), linearRampToValueAtTime: (to, at) => made.ramps.push(['linear', to, at]), exponentialRampToValueAtTime: (to, at) => made.ramps.push(['exponential', to, at]), }; return made; } interface FakeSource { buffer: unknown; loop: boolean; playbackRate: FakeParam; onended: (() => void) | null; started: boolean; /** The second of the audio clock it was started at, and how far into its sound (#334). */ startedAt: number; startedOffset: number; stopped: boolean; connect: (target: unknown) => unknown; start: (at?: number, offset?: number) => void; stop: () => void; } /** * The pitch worklet of a way (#317) 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; processorOptions: PitchOptions; }; pitch: FakeParam; parameters: Map; posted: unknown[]; port: { postMessage: (message: unknown) => void; onmessage: ((event: { data: unknown }) => void) | null }; disconnected: boolean; connect: (target: unknown) => unknown; disconnect: () => void; } const worklets: FakeWorklet[] = []; /** Every module loaded into a context, and what the next load does. */ const modules: string[] = []; let loadModule: () => Promise = () => 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[] = []; /** The curve of every WaveShaper made, in order. */ const shapers: { curve: Float32Array | null }[] = []; let contextState = 'running'; /** Runs the audio clock up to `until`, ending each timer on the way in order, also ones set meanwhile. */ function clockTo(until = Infinity): void { for (;;) { const next = clocks.filter((c) => !c.fired && c.stopAt <= until).sort((a, b) => a.stopAt - b.stopAt)[0]; if (!next) return; next.fired = true; next.onended?.(); } } /** A node that logs where it is connected to and hands the target back, as Web Audio's does. */ function wired( 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) => { source.started = true; source.startedAt = at ?? 0; source.startedOffset = offset ?? 0; }, stop: () => { source.stopped = true; }, }); sources.push(source); return source; }, createGain: () => { const gain = wired('gain', { gain: param(1) }); gains.push(gain.gain); return gain; }, createBiquadFilter: () => { const filter = wired('filter', { type: '', frequency: param(0), gain: param(0), Q: { value: 0 }, }); filters.push(filter); return filter; }, createDelay: (max: number) => { const delay = wired('delay', { max, delayTime: param(0) }); delays.push(delay); return delay; }, createChannelMerger: () => wired('merger', {}), createStereoPanner: () => { const panner = wired('panner', { pan: param(0) }); panners.push(panner.pan); return panner; }, createChannelSplitter: () => wired('splitter', {}), createConstantSource: () => { const clock = wired('clock', { offset: param(1), stopAt: Infinity, fired: false, started: false, stopped: false, onended: null as (() => void) | null, start: () => { clock.started = true; }, stop: (at?: number) => { clock.stopAt = at ?? Infinity; clock.stopped = true; }, }); clocks.push(clock); return clock; }, createWaveShaper: () => { const shaper = wired('shaper', { curve: null as Float32Array | null }); shapers.push(shaper); return shaper; }, }; } /** Lets the promise chain inside `play` run to the point where it would start a voice. */ const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); beforeEach(() => { sources.length = 0; worklets.length = 0; modules.length = 0; loadModule = () => Promise.resolve(); moduleLoaded = false; wires.length = 0; filters.length = 0; gains.length = 0; delays.length = 0; disconnects.length = 0; clocks.length = 0; shapers.length = 0; panners.length = 0; contextState = 'running'; decodes = []; vi.stubGlobal( 'AudioContext', class { constructor() { return fakeContext(); } }, ); vi.stubGlobal( 'AudioWorkletNode', class { constructor(_context: unknown, name: string, options: FakeWorklet['options']) { if (!moduleLoaded) throw new DOMException('no such processor', 'InvalidStateError'); const parameters = new Map( Object.entries(options.parameterData).map(([key, value]) => [key, param(value)] as const), ); const kind = name === 'bit-crusher' ? 'crush' : name === 'chip-player' ? 'chip' : name === 'phaser' || name === 'flanger' ? name : name === 'pitch-shifter' ? 'shifter' : '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, }, 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 { 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 { for (const resolve of decodes.splice(0)) resolve(); await settle(); } describe('the sound library', () => { it('fetches and decodes a file once, however often it is played', async () => { const library = new SoundLibrary(); await play(library); await play(library); expect(library.stats()).toMatchObject({ started: 2, decoded: 1, playing: 2 }); // Two voices of one file, both running: plays overlap rather than replacing each other. expect(sources.filter((s) => s.started)).toHaveLength(2); }); it('plays the stretch a sound is cut to, made from its file without fetching it again (#343)', async () => { const library = new SoundLibrary(); // 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); }); it('loads ahead of the first play without opening the device, so that play starts at once', async () => { const opened: string[] = []; vi.stubGlobal( 'AudioContext', class { constructor() { opened.push('device'); return fakeContext(); } }, ); vi.stubGlobal( 'OfflineAudioContext', class { constructor() { opened.push('offline'); return fakeContext(); } }, ); const library = new SoundLibrary(); library.preload([FILE, OTHER, '']); await settle(); await arrive(); expect(opened).toEqual(['offline']); expect(library.stats()).toMatchObject({ decoded: 2, started: 0 }); // No await: the voice runs in the frame that asked for it, not once the bytes are in. const voice = library.play(FILE, 1, false); expect(library.stats()).toMatchObject({ started: 1, playing: 1 }); expect(library.tune(voice, {})).toBe(true); expect(opened).toEqual(['offline', 'device']); }); it('remembers what it last started, so a muted check can see it at all', async () => { const library = new SoundLibrary(); library.play(FILE, 0.25, true); await settle(); for (const resolve of decodes.splice(0)) resolve(); await settle(); expect(library.stats().last).toEqual({ file: FILE, volume: 0.25, loop: true, layers: 1, pitches: [0], stages: 0, duration: 1.5, at: 0, envelopes: 0, bus: '', }); }); it('plays a dry voice straight into its volume, without loading the pitch worklet', async () => { await play(new SoundLibrary()); 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(); 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(); 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(); 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(); 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(); const amounts = (values: Record) => 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(); 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(); await play(library); await play(library, FILE, {}, { pitch: 7, stages: [] }); expect(library.stats()).toMatchObject({ started: 1 }); loaded(); await settle(); expect(library.stats()).toMatchObject({ started: 2, last: { pitches: [7] } }); expect(worklets).toHaveLength(1); }); it('without the worklet a pitched way plays at its own pitch rather than not at all', async () => { loadModule = () => Promise.reject(new Error('no worklets here')); vi.spyOn(console, 'error').mockImplementation(() => {}); const library = new SoundLibrary(); const voice = await play(library, FILE, {}, { plan: open(), params: {} }); expect(worklets).toHaveLength(0); expect(sources).toHaveLength(2); expect(library.tune(voice, { depth: 12 })).toBe(true); expect(library.levels(FILE)).toEqual([[12, 0.75, 1, 0.25, 6]]); expect(console.error).toHaveBeenCalledTimes(1); }); it('mixes two sounds by their shares, a source per way, started and stopped together (#316)', async () => { const library = new SoundLibrary(); await play( library, FILE, {}, { mix: 0.25, a: { pitch: 12, stages: [] }, b: { pitch: 0, stages: [{ kind: 'gain', gain: 0.5 }] }, stages: [{ kind: 'filter', filter: 'lowshelf', frequency: 80, gain: 6, q: 1 }], }, ); expect(wires).toEqual([ 'pitch > gain', 'gain > gain', 'source > gain', 'gain > gain', 'gain > gain', 'gain > filter', 'filter > gain', ...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(); await play(library, FILE, {}, { pitch: 3, stages: [] }); worklets[0]!.port.onmessage!({ data: 'something else' }); expect(library.stats().playing).toBe(1); worklets[0]!.port.onmessage!({ data: 'ended' }); expect(library.stats()).toMatchObject({ playing: 0, stopped: 0 }); }); it('gives two ways at the same pitch a source each, so each can glide on its own (#315)', async () => { const library = new SoundLibrary(); await play( library, FILE, {}, { mix: 0.5, a: { pitch: 0, stages: [] }, b: { pitch: 0, stages: [{ kind: 'gain', gain: 0.5 }] }, stages: [], }, ); expect(sources).toHaveLength(2); expect(sources[0]!.buffer).toBe(sources[1]!.buffer); expect(wires.filter((w) => w.startsWith('source'))).toEqual(['source > gain', 'source > gain']); }); describe('a Mix with the curve "Both at center"', () => { /** Two plain ways, the share open as `blend`. */ const both = (): FxVoice => ({ plan: { mix: { value: 0.25, param: 'blend', min: 0, max: 1 }, curve: 'both', a: { pitch: [], stages: [] }, b: { pitch: [], stages: [] }, stages: [], }, params: {}, }); it('shapes both shares from one source carrying the mix, so a glide follows the curve', async () => { const library = new SoundLibrary(); const voice = await play(library, FILE, {}, both()); // The voice, the Mix, share of a, share of b: the shares hold nothing of their own. const [, , shareA, shareB] = gains; expect([shareA!.value, shareB!.value]).toEqual([0, 0]); expect(shapers.map((s) => [...s.curve!])).toEqual([ [1, 1, 1, 1, 0], [0, 0, 0, 1, 1], ]); expect(wires.filter((w) => w.startsWith('clock') || w.startsWith('shaper'))).toEqual([ 'clock > shaper', 'shaper > ?', 'clock > shaper', 'shaper > ?', ]); const [driver] = clocks; expect(driver).toMatchObject({ started: true, stopped: false, offset: { value: 0.25 } }); expect(library.levels(FILE)).toEqual([[0.25]]); expect(library.tune(voice, { blend: 0.75 })).toBe(true); expect(driver!.offset.glides).toEqual([0.75]); expect([shareA!.glides, shareB!.glides]).toEqual([[], []]); }); it('stops that source with the voice, cut or ended by itself', async () => { const library = new SoundLibrary(); await play(library, FILE, {}, both()); library.stop(FILE); expect(clocks[0]!.stopped).toBe(false); clockTo(); expect(clocks[0]!.stopped).toBe(true); await play(library, FILE, {}, both()); const driver = clocks.find((c) => !c.stopped && c.started && c.offset.value === 0.25)!; for (const source of sources.slice(-2)) source.onended?.(); expect(library.stats().playing).toBe(0); expect(driver.stopped).toBe(true); }); }); describe('a parameter changed while the voice plays (#315)', () => { it('glides every knob it moves, a pitch in semitones, and leaves the rest alone', async () => { const library = new SoundLibrary(); const voice = await play(library, FILE, { owner: 'me' }, { plan: open(), params: {} }); // Made in the order: the voice, the Mix, share of a, the gain on b, share of b. const [, , shareA, loud, shareB] = gains; expect([shareA!.value, loud!.value, shareB!.value]).toEqual([0.75, 1, 0.25]); expect(library.tune(voice, { depth: 12, loud: -6, blend: 0.5, nothing: 3 })).toBe(true); expect(worklets[0]!.pitch.glides).toEqual([12]); expect(sources[0]!.playbackRate.glides).toEqual([]); expect(loud!.glides).toEqual([expect.closeTo(10 ** (-6 / 20))]); expect([shareA!.glides, shareB!.glides]).toEqual([[0.5], [0.5]]); expect(filters[0]!.gain.glides).toEqual([]); expect(library.stats().tuned).toBe(1); // Only what changed is sent again; one not given keeps its value rather than its default. library.tune(voice, { depth: 12, blend: 1 }); expect(worklets[0]!.pitch.glides).toEqual([12]); expect(loud!.glides).toHaveLength(1); expect([shareA!.glides, shareB!.glides]).toEqual([ [0.5, 0], [0.5, 1], ]); }); it('reaches the one voice it is given – a detached one too – and none that has ended', async () => { const library = new SoundLibrary(); const voices = [ await play(library, FILE, { owner: 'me' }, { plan: open(), params: {} }), await play(library, FILE, { owner: 'me' }, { plan: open(), params: {} }), await play(library, OTHER, { owner: 'me' }, { plan: open(), params: {} }), await play(library, FILE, {}, { plan: open(), params: {} }), ]; expect(new Set(voices).size).toBe(4); expect(voices).not.toContain(0); library.tune(voices[1]!, { depth: 12 }); library.tune(voices[3]!, { depth: 12 }); expect(worklets.map((w) => w.pitch.glides.length)).toEqual([0, 1, 0, 1]); library.stop(FILE); expect(library.tune(voices[1]!, { depth: 3 })).toBe(false); expect(library.tune(0, { depth: 3 })).toBe(false); expect(library.stats().tuned).toBe(2); }); it('starts a voice still waiting on its bytes with the values handed in meanwhile', async () => { const library = new SoundLibrary(); const voice = await play( library, FILE, { hold: true, owner: 'me' }, { plan: open(), params: { blend: 0.5 } }, ); expect(library.tune(voice, { depth: 7 })).toBe(true); await arrive(); expect(library.stats().last?.pitches).toEqual([7, 0]); expect(gains.map((g) => g.value)).toContain(0.5); expect(worklets[0]!.options.parameterData.pitch).toBe(7); expect(worklets[0]!.pitch.glides).toEqual([]); }); it('runs a timed change evenly on the audio clock: semitones and decibels in their own steps', async () => { const library = new SoundLibrary(); const voice = await play(library, FILE, { owner: 'me' }, { plan: open(), params: {} }); const [, , shareA, loud, shareB] = gains; library.tune(voice, { depth: 12, loud: -6, blend: 0.5 }, 1.5); const pitch = worklets[0]!.pitch; expect(pitch.ramps).toEqual([['linear', 12, 1.5]]); expect(loud!.ramps).toEqual([['exponential', expect.closeTo(10 ** (-6 / 20)), 1.5]]); expect([shareA!.ramps, shareB!.ramps]).toEqual([[['linear', 0.5, 1.5]], [['linear', 0.5, 1.5]]]); expect([pitch.glides, loud!.glides, shareA!.glides]).toEqual([[], [], []]); // Each from where it is now: what was planned before is held, not queued behind. expect([pitch.holds, loud!.holds, shareA!.holds]).toEqual([1, 1, 1]); // A quick change after it holds the ramp where it is and glides on from there. library.tune(voice, { depth: 0 }); expect(pitch.holds).toBe(2); expect(pitch.glides).toEqual([0]); }); it('runs a timed change along an easing as a curve, each number in its own steps', async () => { const library = new SoundLibrary(); const voice = await play(library, FILE, { owner: 'me' }, { plan: open(), params: {} }); const loud = gains[3]!; const from = loud.value; library.tune(voice, { depth: 12, loud: -6 }, 1.5, (t) => t * t); // Half the time is a quarter of the way: a quarter of the octave, a quarter of the decibels. expect(worklets[0]!.pitch.ramps).toEqual([['curve', [0, expect.closeTo(3), 12], 1.5]]); const quarter = from * (10 ** (-6 / 20) / from) ** 0.25; expect(loud.ramps).toEqual([ ['curve', [from, expect.closeTo(quarter), expect.closeTo(10 ** (-6 / 20))], 1.5], ]); expect(worklets[0]!.pitch.holds).toBe(1); }); it('a step holds the value for the time and then jumps', async () => { const library = new SoundLibrary(); const voice = await play(library, FILE, { owner: 'me' }, { plan: open(), params: {} }); library.tune(voice, { depth: 12 }, 2, 'step'); expect(worklets[0]!.pitch.ramps).toEqual([['set', 12, 2]]); expect(worklets[0]!.pitch.glides).toEqual([]); }); it('starts a timed change to a voice still waiting on its bytes when the voice starts', async () => { const library = new SoundLibrary(); const voice = await play(library, FILE, { hold: true, owner: 'me' }, { plan: open(), params: {} }); library.tune(voice, { depth: -64 }, 1.28); // A quick change after a timed one keeps its place behind it. library.tune(voice, { blend: 1 }); expect(library.stats().started).toBe(0); await arrive(); // The voice is heard at its old pitch and only then sets off, so the whole sweep is heard. expect(library.stats().last?.pitches).toEqual([0, 0]); expect(worklets[0]!.pitch.ramps).toEqual([['linear', -64, 1.28]]); expect(gains[4]!.glides).toEqual([1]); }); }); it('a stop while the bytes are in flight keeps the sound from starting afterwards', async () => { const library = new SoundLibrary(); await play(library, FILE, { hold: true }); library.stop(FILE); for (const resolve of decodes.splice(0)) resolve(); await settle(); expect(library.stats()).toMatchObject({ started: 0, playing: 0 }); }); it('but a stop before a play does not: an earlier silence is not a standing veto', async () => { const library = new SoundLibrary(); // Exactly what ending a run does – and the next run has to be able to play that sound again, // even though its bytes are still unread at that point. library.stop(''); library.stop(FILE); await play(library); expect(library.stats()).toMatchObject({ started: 1, playing: 1 }); }); it('stopping everything silences every voice, and only the named file otherwise', async () => { const library = new SoundLibrary(); await play(library, FILE); await play(library, OTHER); library.stop(FILE); expect(library.stats()).toMatchObject({ playing: 1, stopped: 1 }); library.stopAll(); expect(library.stats()).toMatchObject({ playing: 0, stopped: 2 }); clockTo(); expect(sources.every((s) => s.stopped)).toBe(true); }); it('a voice that ends by itself leaves the count, without being stopped', async () => { const library = new SoundLibrary(); await play(library); sources[0]?.onended?.(); expect(library.stats()).toMatchObject({ playing: 0, started: 1, stopped: 0 }); }); it('a file that cannot be loaded fails once and is never asked for again', async () => { const library = new SoundLibrary(); vi.stubGlobal('fetch', () => Promise.resolve({ ok: false, status: 404 })); vi.spyOn(console, 'error').mockImplementation(() => {}); expect(await library.load(FILE)).toBeNull(); expect(await library.load(FILE)).toBeNull(); expect(library.stats()).toMatchObject({ failed: 1, started: 0 }); library.play(FILE, 1, false); await settle(); expect(library.stats().started).toBe(0); }); describe('the end of a voice (#324)', () => { /** One echo after a quarter second, at half the volume: a tail of 0.25 s. */ const echo = (feedback = 0, pingPong = false): FxStage => ({ kind: 'delay', time: 0.25, feedback, damping: 20000, mix: 0.5, pingPong, }); it('fades a stopped voice out instead of cutting it, then lets its nodes go', async () => { const library = new SoundLibrary(); await play(library); library.stop(FILE); const level = gains[0]!; expect(level.ramps).toEqual([['linear', 0, 0.01]]); expect(level.holds).toBe(1); expect(sources[0]!.stopped).toBe(false); expect(disconnects).toEqual([]); clockTo(0.005); expect(sources[0]!.stopped).toBe(false); clockTo(0.01); expect(sources[0]!.stopped).toBe(true); expect(disconnects.filter((kind) => kind !== 'clock')).toEqual(['gain']); }); it('cuts at once where the clock stands still, so a paused run cannot sound in the next', async () => { const library = new SoundLibrary(); await play(library); contextState = 'suspended'; library.stopAll(); expect(sources[0]!.stopped).toBe(true); expect(clocks).toHaveLength(0); }); it('wires an echo: the sound goes on, the line feeds back through the damping, added at mix', async () => { await play(new SoundLibrary(), FILE, {}, { pitch: 0, stages: [echo(0.4)] }); expect(delays).toMatchObject([{ max: 2, delayTime: { value: 0.25 } }]); expect(filters.map((f) => [f.type, f.frequency.value])).toEqual([['lowpass', 20000]]); expect(filters[0]!.Q.value).toBeCloseTo(-3.0103, 3); expect(wires).toEqual([ 'source > gain', // the source through its fade 'gain > gain', // the sound on, as it is 'delay > filter', 'filter > gain', // into the mix 'gain > gain', // and out 'gain > delay', // the sound into the line 'filter > gain', // round again, 'gain > delay', // at the feedback 'gain > gain', // the fade into the echo 'gain > gain', ...INTO_MASTER, ]); // The voice, the fade made before it, the echo's in, out, feedback and mix, then the master's two. expect(gains.map((g) => g.value)).toEqual([1, 1, 1, 1, 0.4, 0.5, 1, 1]); }); it('wires a ping-pong echo into the left only, crossing sides on every round', async () => { await play(new SoundLibrary(), FILE, {}, { pitch: 0, stages: [echo(0.4, true)] }); expect(wires).toEqual( expect.arrayContaining(['gain > merger', 'merger > delay', 'filter > splitter', 'splitter > merger']), ); expect(wires.filter((w) => w === 'splitter > merger')).toHaveLength(2); expect(wires.filter((w) => w.endsWith('> delay'))).toEqual(['merger > delay', 'gain > delay']); }); it('rings on after its sound has ended, still reached, and goes when the echoes have faded', async () => { const library = new SoundLibrary(); const voice = await play(library, FILE, {}, { pitch: 0, stages: [echo()] }); expect(library.tail(voice)).toBe(0.25); sources[0]!.onended!(); expect(library.stats()).toMatchObject({ playing: 1, stopped: 0 }); expect(library.tune(voice, {})).toBe(true); clockTo(0.24); expect(library.stats().playing).toBe(1); clockTo(0.25); expect(library.stats()).toMatchObject({ playing: 0, stopped: 0 }); expect(library.tune(voice, {})).toBe(false); expect(library.tail(voice)).toBe(0); // An echo is a loop: it is let go, where a dry voice is simply left to end. expect(disconnects.filter((kind) => kind !== 'clock')).toEqual(['gain']); }); it('a dry voice ends with its sound, as before', async () => { const library = new SoundLibrary(); const voice = await play(library); expect(library.tail(voice)).toBe(0); sources[0]!.onended!(); expect(library.stats().playing).toBe(0); expect(clocks).toHaveLength(0); }); it('counts a tail again when a change makes it longer', async () => { const library = new SoundLibrary(); const plan: FxPlan = { pitch: [], stages: [ { kind: 'delay', time: { value: 0.25 }, feedback: { value: 0, param: 'back', min: 0, max: 0.95 }, damping: { value: 0 }, mix: { value: 1 }, pingPong: false, }, ], }; const voice = await play(library, FILE, {}, { plan, params: {} }); sources[0]!.onended!(); // Half as loud each round: ten echoes are heard, ending 2.5 s on – from where the change ends. library.tune(voice, { back: 0.5 }, 1); expect(library.tail(voice)).toBe(2.5); clockTo(3.4); expect(library.stats().playing).toBe(1); clockTo(3.5); expect(library.stats().playing).toBe(0); }); it('counts an echo synced to beats by the tempo of the voice, never past the line (#332)', async () => { const library = new SoundLibrary(); const synced = (quarters: number): FxPlan => ({ pitch: [], stages: [ { kind: 'delay', time: { value: quarters }, beats: true, feedback: { value: 0 }, damping: { value: 0 }, mix: { value: 1 }, pingPong: false, }, ], }); const voice = await play(library, FILE, {}, { plan: synced(1), params: {}, tempo: 90 }); expect(delays[0]!.delayTime.value).toBeCloseTo(60 / 90); expect(library.tail(voice)).toBeCloseTo(60 / 90); // Four quarters at 120 BPM are 2 s, which is as long as the line is; eight would be 4 s. await play(library, FILE, {}, { plan: synced(8), params: {} }); expect(delays[1]!.delayTime.value).toBe(2); }); it('lets the echoes ring when asked to: only the sound fades, the voice stays until they are gone', async () => { const library = new SoundLibrary(); await play(library, FILE, {}, { pitch: 0, stages: [echo()] }); library.stop(FILE, true); const [level, fade] = gains; expect(fade!.ramps).toEqual([['linear', 0, 0.01]]); expect(level!.ramps).toEqual([]); expect(library.stats()).toMatchObject({ playing: 1, stopped: 1 }); clockTo(0.01); expect(sources[0]!.stopped).toBe(true); // Its own end afterwards changes nothing: it is ringing already. sources[0]!.onended?.(); expect(library.stats().playing).toBe(1); clockTo(0.26); expect(library.stats()).toMatchObject({ playing: 0, stopped: 1 }); }); it('a stop without letting ring cuts a ringing voice short, and asking again changes nothing', async () => { const library = new SoundLibrary(); await play(library, FILE, {}, { pitch: 0, stages: [echo()] }); library.stop(FILE, true); library.stop(FILE, true); expect(library.stats()).toMatchObject({ playing: 1, stopped: 1 }); library.stop(FILE); expect(library.stats()).toMatchObject({ playing: 0, stopped: 2 }); expect(gains[0]!.ramps).toEqual([['linear', 0, 0.01]]); }); it('a voice without echoes is simply faded out when asked to let ring', async () => { const library = new SoundLibrary(); await play(library); library.stop(FILE, true); expect(library.stats()).toMatchObject({ playing: 0, stopped: 1 }); expect(gains[0]!.ramps).toEqual([['linear', 0, 0.01]]); }); it('stops one voice by its number, also one still waiting on its bytes', async () => { const library = new SoundLibrary(); const first = await play(library); const second = await play(library); library.stopVoice(first); expect(library.stats()).toMatchObject({ playing: 1, stopped: 1 }); expect(library.tune(second, {})).toBe(true); const waiting = await play(library, OTHER, { hold: true }); library.stopVoice(waiting); await arrive(); expect(library.stats()).toMatchObject({ started: 2, playing: 1 }); }); it('moves the numbers of an echo while it plays, the damping evenly in octaves', async () => { const library = new SoundLibrary(); const plan: FxPlan = { pitch: [], stages: [ { kind: 'delay', time: { value: 0.25, param: 'time', min: 0.01, max: 2 }, feedback: { value: 0.4 }, damping: { value: 0, param: 'dull', min: 0, max: 1 }, mix: { value: 0.5, param: 'wet', min: 0, max: 1 }, pingPong: false, }, ], }; const voice = await play(library, FILE, {}, { plan, params: {} }); library.tune(voice, { time: 0.5, dull: 1, wet: 0.2 }, 2); expect(delays[0]!.delayTime.ramps).toEqual([['linear', 0.5, 2]]); expect(filters[0]!.frequency.ramps).toEqual([['exponential', expect.closeTo(500), 2]]); // The mix of the echo, made before the master's two. expect(gains.at(-3)!.ramps).toEqual([['linear', 0.2, 2]]); expect(library.levels(FILE)).toEqual([[0.25, 0.4, 20000, 0.5]]); }); }); }); describe('notes of an instrument (#331)', () => { /** A way that plays its own file, as a Sample makes it. */ const sample = ( file: string, extra: Partial['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 { const voice = library.playNote('instrument:i', layers, {}, 1, '', duration); await settle(); await arrive(); return voice; } it('plays its layers side by side as one voice, each way its own file behind its own volume', async () => { const library = new SoundLibrary(); const voice = await note(library, [sample(FILE), sample(OTHER, { gate: false })]); expect(voice).toBe(1); expect(library.stats()).toMatchObject({ started: 1, playing: 1, decoded: 2 }); expect(library.stats().last).toMatchObject({ file: 'instrument:i', layers: 2, duration: 1.5 }); expect(sources.map((source) => source.started)).toEqual([true, true]); // Voice gain first, then a fade per way at the sound's volume, 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(); const voice = await note(library, [sample(FILE), sample(OTHER, { gate: false })]); library.release(voice); expect(gains[1]!.ramps).toEqual([['linear', 0, 0.01]]); expect(gains[2]!.ramps).toEqual([]); clockTo(); expect(sources.map((source) => source.stopped)).toEqual([true, false]); expect(library.stats().playing).toBe(1); // The one-shot ends by itself: now the voice is gone. sources[1]!.onended?.(); expect(library.stats().playing).toBe(0); }); it('a note with a duration is released that long after it starts, its fade set on the audio clock at once (#332)', async () => { const library = new SoundLibrary(); await note(library, [sample(FILE)], 0.5); expect(gains[1]!.ramps).toEqual([ ['set', 0.5, 0.5], ['linear', 0, 0.51], ]); expect(clocks.map((clock) => clock.stopAt)).toEqual([0.51]); expect(sources[0]!.stopped).toBe(false); clockTo(); expect(sources[0]!.stopped).toBe(true); expect(library.stats().playing).toBe(0); }); it('a note planned ahead starts on that second of the audio clock and is released from there (#332)', async () => { const library = new SoundLibrary(); library.playNote('instrument:i', [sample(FILE)], {}, 1, '', 0.5, { at: 2 }); await settle(); await arrive(); expect(sources[0]!.startedAt).toBe(2); expect(library.stats().last?.at).toBe(2); expect(gains[1]!.ramps).toEqual([ ['set', 0.5, 2.5], ['linear', 0, 2.51], ]); expect(clocks.map((clock) => clock.stopAt)).toEqual([2.51]); // One whose second has passed by the time its bytes are there starts now. library.playNote('instrument:i', [sample(FILE)], {}, 1, '', 0, { at: -1 }); expect(sources[1]!.startedAt).toBe(0); }); it('tells a pitched note planned ahead when to start, the worklet having no start of its own (#332)', async () => { const library = new SoundLibrary(); const pitched = { ...sample(FILE), pitch: [{ value: 7 }] } as FxPlan; library.playNote('instrument:i', [pitched], {}, 1, '', 0, { at: 1.25 }); await settle(); await arrive(); expect(worklets[0]!.options.processorOptions.startAt).toBe(1.25); }); it('a note planned to end later leaves its loop then, not now (#332)', async () => { const library = new SoundLibrary(); await note(library, [sample(FILE, { loop: true, gate: false })], 1); expect(sources[0]!.loop).toBe(true); clockTo(0.99); expect(sources[0]!.loop).toBe(true); clockTo(1); expect(sources[0]!.loop).toBe(false); }); it('a note released before its bytes arrived still starts, and is released at once', async () => { const library = new SoundLibrary(); const voice = library.playNote( 'instrument:i', [sample(FILE), sample(OTHER, { gate: false })], {}, 1, '', 0, ); library.release(voice); await settle(); await arrive(); expect(library.stats().started).toBe(1); clockTo(); expect(sources.map((source) => source.stopped)).toEqual([true, false]); }); it('loops a way between its loop start and end', async () => { const library = new SoundLibrary(); await note(library, [sample(FILE, { loop: true, loopStart: 0.25, loopEnd: 1 })]); expect(sources[0]).toMatchObject({ loop: true, loopStart: 0.25, loopEnd: 1 }); }); it('hands a pitched way its loop in samples of the sound', async () => { const library = new SoundLibrary(); const pitched = { ...sample(FILE, { loop: true, loopStart: 0.25, loopEnd: 0 }), pitch: [{ value: 3 }] }; await note(library, [pitched]); // 800 samples a second, 1200 of them: from 200 to the end. expect(worklets[0]!.options.processorOptions).toMatchObject({ loop: true, loopStart: 200, loopEnd: 1200, }); }); it('a release lets a loop that does not end with the note leave its loop and play out', async () => { const library = new SoundLibrary(); const voice = await note(library, [ sample(FILE, { loop: true, loopEnd: 1, gate: false }), sample(OTHER, { loop: true, loopEnd: 1 }), ]); library.release(voice); // The first leaves its loop and is not faded; the second, a plain Loop, fades out as before. expect(sources.map((source) => source.loop)).toEqual([false, true]); expect(gains[1]!.ramps).toEqual([]); expect(gains[2]!.ramps).toEqual([['linear', 0, 0.01]]); clockTo(); expect(sources.map((source) => source.stopped)).toEqual([false, true]); expect(library.playing(voice)).toBe(true); sources[0]!.onended?.(); expect(library.playing(voice)).toBe(false); }); it('tells the pitch worklet to leave its loop on a release', async () => { const library = new SoundLibrary(); const pitched = { ...sample(FILE, { loop: true, loopEnd: 1, gate: false }), pitch: [{ value: 3 }] }; const voice = await note(library, [pitched]); library.release(voice); expect(worklets[0]!.posted).toEqual(['leave']); }); it('plays nothing where no way has a file', () => { const library = new SoundLibrary(); expect(library.playNote('instrument:i', [], {}, 1, '', 0)).toBe(0); expect(library.playNote('instrument:i', [{ pitch: [], stages: [] }], {}, 1, '', 0)).toBe(0); }); }); describe('notes with an Envelope (#335)', () => { const PLANNED: FxEnvelopePlan = { id: 'env', attack: { value: 0.1 }, decay: { value: 0.2 }, sustain: { value: 0.5 }, release: { value: 0.3 }, }; /** A Sample's way, under the Envelope where `shaped`, with more stages after it. */ const way = (gate: boolean, shaped = true, stages: FxStagePlan[] = []): FxPlan => ({ pitch: [], stages: [...(shaped ? [{ kind: 'envelope' as const, envelope: PLANNED }] : []), ...stages], source: { file: FILE, volume: 0.5, loop: false, loopStart: 0, loopEnd: 0, gate }, }); async function note(library: SoundLibrary, plan: FxPlan, duration = 0): Promise { const voice = library.playNote('instrument:i', [plan], {}, 1, '', duration); await settle(); await arrive(); return voice; } /** What carries the curve: the first silent source made, before any timer. */ const driver = () => clocks[0]!; it('starts its curve with the note: straight up in the attack, then easing towards the sustain', async () => { const library = new SoundLibrary(); await note(library, way(true)); expect(library.stats().last?.envelopes).toBe(1); expect(driver().started).toBe(true); expect(driver().offset.ramps).toEqual([['linear', 1, 0.1]]); expect(driver().offset.glides).toEqual([0.5]); // Voice gain, the Sample's volume, and the Envelope's gain, which is all the curve; 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(); const voice = await note(library, way(true)); library.release(voice); // Released at 0, in the attack still: from 0, falling to silence in 0.3 s. expect(driver().offset.ramps).toEqual([ ['linear', 1, 0.1], ['linear', 0, 0], ]); expect(driver().offset.glides).toEqual([0.5, 0]); expect(gains[1]!.ramps).toEqual([ ['set', 0.5, 0.3], ['linear', 0, 0.31], ]); clockTo(0.3); expect(sources[0]!.stopped).toBe(false); expect(library.playing(voice)).toBe(true); clockTo(); expect(sources[0]!.stopped).toBe(true); expect(library.playing(voice)).toBe(false); expect(driver().stopped).toBe(true); }); it('a note ending mid-decay releases from the level its curve has by then', async () => { const library = new SoundLibrary(); await note(library, way(true), 0.2); const level = 0.5 + 0.5 * Math.exp((-0.1 * Math.log(1000)) / 0.2); expect(driver().offset.ramps).toEqual([ ['linear', 1, 0.1], ['set', level, 0.2], ]); expect(gains[1]!.ramps).toEqual([ ['set', 0.5, 0.5], ['linear', 0, 0.51], ]); }); it('a one-shot under an Envelope alone plays through, its curve held', async () => { const library = new SoundLibrary(); const voice = await note(library, way(false)); library.release(voice); expect(driver().offset.glides).toEqual([0.5]); clockTo(); expect(sources[0]!.stopped).toBe(false); sources[0]!.onended?.(); expect(library.playing(voice)).toBe(false); }); it('Stop sound lets it fall away in its release with "let ring", and cuts it without', async () => { const library = new SoundLibrary(); const ringing = await note(library, way(false)); library.stopVoice(ringing, true); expect(driver().offset.glides).toEqual([0.5, 0]); expect(gains[1]!.ramps).toEqual([ ['set', 0.5, 0.3], ['linear', 0, 0.31], ]); expect(gains[0]!.ramps).toEqual([]); expect(library.stats().stopped).toBe(1); clockTo(); expect(library.playing(ringing)).toBe(false); const cut = await note(library, way(true)); library.stopVoice(cut); // 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(); 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(); 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(); 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(); 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(); 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(); 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(); // 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(); 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(); library.playClip(FILE, { at: 2, offset: 0, duration: 3, volume: 1, bus: '' }); await settle(); await arrive(); // The voice's gain first, then the fade of its source. expect(gains[1]!.ramps).toEqual([ ['set', 1, 5], ['linear', 0, 5.01], ]); expect(clocks.map((clock) => clock.stopAt)).toEqual([5.01]); expect(sources[0]!.stopped).toBe(false); clockTo(); expect(sources[0]!.stopped).toBe(true); expect(library.stats().playing).toBe(0); }); it('starts a pitched clip in its worklet as many samples in as the offset is', async () => { const library = new SoundLibrary(); const pitched = { pitch: [{ value: 5 }], stages: [] } as FxPlan; library.playClip( FILE, { at: 1, offset: 1.75, duration: 1, volume: 1, bus: '' }, { plan: pitched, params: {} }, ); await settle(); await arrive(); // 0.25 s into the second round, at the sound's own 800 samples a second. expect(worklets[0]!.options.processorOptions).toMatchObject({ startAt: 1, loop: true, skip: 200 }); }); it("sends a song's voices through the bus of their track: its input, level, then its pan, then out", async () => { const library = new SoundLibrary(); library.setBus('song:s:t', 0.5, -0.25); library.playNote( 'instrument:i', [ { pitch: [], stages: [], source: { file: FILE, volume: 1, loop: false, loopStart: 0, loopEnd: 0, gate: false }, }, ], {}, 1, '', 0, { bus: 'song:s:t' }, ); await settle(); await arrive(); expect(wires).toEqual(expect.arrayContaining(['gain > panner', 'panner > destination'])); expect(wires.filter((wire) => wire === 'gain > gain').length).toBeGreaterThan(0); expect(wires).not.toContain('gain > destination'); // 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(); 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(); 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 { 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(); 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(); 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(); 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(); library.time(); let resumes = 0; (library as unknown as { context: { resume: () => Promise } }).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 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) { 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(); 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(); 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(); 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(); 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(); 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(); 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('makes the chain of a track in the Song tab once, and again only for another sound FX', async () => { const library = new SoundLibrary(); 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(); 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(); 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(); 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(); 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(); 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(); 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('plays a voice on the bus it is given, and a playing too', async () => { const library = new SoundLibrary(); 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(); 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('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(); 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(); 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(); }); });