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