/** * An Envelope (#335) shapes a note of an instrument: on the way of the sound it is its volume, and its * curve can move a number on the audio thread – the cutoff of a Filter, a Pitch, a Gain, a Mix, a * band of the EQ – from "from" at rest to "to" at the top. */ import { describe, expect, it } from 'vitest'; import { CreateAssetCommand, CreateSoundCommand } from '../../src/core/commands/assetCommands'; import { ConnectCommand } from '../../src/core/commands/graphCommands'; import { createEmptyGraph, type GraphNode } from '../../src/core/graph/Graph'; import { portsMeet } from '../../src/core/graph/NodeRegistry'; import { compileInstrument, instrumentSourcesOf } from '../../src/core/instrument/compile'; import { envelopeLevel, modCurve, releaseTime, SETTLE } from '../../src/core/instrument/envelope'; import { VELOCITY_PARAM } from '../../src/core/instrument/nodes'; import { NO_LOOKUP } from '../../src/core/graph/lookup'; import type { PropertyBag } from '../../src/core/model/Properties'; import { resolveDef } from '../../src/core/graph/nested'; import type { BehaviorAsset, InstrumentAsset, SoundFxAsset } from '../../src/core/model/Asset'; import { compileSoundFx } from '../../src/core/soundfx/compile'; import { FLAT_Q, fxChainOf, type FxEnvelope, type FxPlan, passQ, passStagesOf, SoundFxFilter, } from '../../src/core/soundfx/nodes'; import { graphEditor, makeProject } from '../helpers'; const FILE = `${'c3'.repeat(32)}.wav`; /** The stages of a chain that do something to the sound: a Filter is tapped as well (#385). */ const heard = (chain: { stages: readonly S[] }): S[] => chain.stages.filter((s) => s.kind !== 'tap'); /** An instrument with nothing in it, a Notes node and a Sample playing `FILE` on every note. */ function makeInstrument() { const f = makeProject(); const command = new CreateAssetCommand('instrument', 'Pad'); f.stack.execute(command); const instrument = command.asset as InstrumentAsset; instrument.graph = createEmptyGraph(); const made = new CreateSoundCommand('Pad', FILE); f.stack.execute(made); const edit = graphEditor(f, instrument as unknown as BehaviorAsset); const notes = edit.addNode('InstrumentNotes'); const sample = edit.addNode('InstrumentSample', { sound: made.asset.id }); const output = edit.addNode('InstrumentOutput'); edit.connect(notes, 'any', sample, 'trigger'); const compile = () => compileInstrument(instrument.graph, 60, instrumentSourcesOf(f.project)); return { ...f, instrument, edit, notes, sample, output, compile }; } const ENVELOPE: FxEnvelope = { id: 'e', attack: 0.1, decay: 0.2, sustain: 0.5, release: 0.3 }; describe('the curve of an Envelope', () => { it('rises in a straight line, then falls towards the sustain, 60 dB of the way there by the end of the decay', () => { expect(envelopeLevel(ENVELOPE, 0)).toBe(0); expect(envelopeLevel(ENVELOPE, 0.05)).toBeCloseTo(0.5, 9); expect(envelopeLevel(ENVELOPE, 0.1)).toBeCloseTo(1, 9); expect(envelopeLevel(ENVELOPE, 0.3)).toBeCloseTo(0.5 + 0.5 / 1000, 9); expect(envelopeLevel(ENVELOPE, 10)).toBeCloseTo(0.5, 9); // Without an attack it starts at the top, without a decay it is at the sustain right after. expect(envelopeLevel({ ...ENVELOPE, attack: 0 }, 0.000001)).toBeCloseTo(1, 3); expect(envelopeLevel({ ...ENVELOPE, decay: 0 }, 0.1)).toBe(0.5); expect(Math.exp(-SETTLE)).toBeCloseTo(0.001, 9); }); it('takes 10 ms to release at least, so a note does not click', () => { expect(releaseTime(ENVELOPE)).toBe(0.3); expect(releaseTime({ ...ENVELOPE, release: 0 })).toBe(0.01); }); it('moves a number evenly, or in equal ratios, and below 0 stays where it rests', () => { const at = (curve: Float32Array, s: number) => curve[Math.round(((s + 1) / 2) * (curve.length - 1))]!; const even = modCurve({ envelope: ENVELOPE, from: 2, to: 10 }, false); expect([at(even, -1), at(even, 0), at(even, 0.5), at(even, 1)]).toEqual([2, 2, 6, 10]); const ratio = modCurve({ envelope: ENVELOPE, from: 200, to: 5000 }, true); expect(at(ratio, 0.5)).toBeCloseTo(1000, 1); // A ratio needs both ends above 0; otherwise it goes evenly. expect(at(modCurve({ envelope: ENVELOPE, from: 0, to: 1 }, true), 0.5)).toBe(0.5); const mapped = modCurve({ envelope: ENVELOPE, from: 0, to: 1 }, false, (mix) => 1 - mix); expect([at(mapped, 0), at(mapped, 1)]).toEqual([1, 0]); }); }); describe('a Filter', () => { it('takes its Q as a factor, and hands it to Web Audio in dB for a low- or high-pass', () => { expect(passQ('lowpass', FLAT_Q)).toBeCloseTo(-3.0103, 4); expect(passQ('highpass', 10)).toBeCloseTo(20, 9); expect(passQ('bandpass', 4)).toBe(4); expect(passQ('peaking', 0.5)).toBe(0.5); }); const filterIn = (params: PropertyBag) => { const f = makeProject(); const made = new CreateAssetCommand('soundfx', 'Muffle'); f.stack.execute(made); const fx = made.asset as unknown as BehaviorAsset; const edit = graphEditor(f, fx); const input = edit.addNode('SoundFxInput'); const filter = edit.addNode('SoundFxFilter', params); const output = edit.addNode('SoundFxOutput'); edit.wire([ [input, 'out', filter, 'in'], [filter, 'out', output, 'in'], ]); return { fx: fx as unknown as SoundFxAsset, filter }; }; it('is an effect of a sound FX as well, its frequency in Hz and its Q held to 0.1 to 30', () => { const { fx } = filterIn({ filter: 'highpass', cutoff: 300, q: 50 }); const plan = compileSoundFx(fx.graph).compiled!; const [stage] = heard(fxChainOf(plan)); expect(stage).toMatchObject({ kind: 'pass', filter: 'highpass', stages: 1, cutoff: 300, gain: 0 }); // 30 as a factor, in dB. expect((stage as { q: number }).q).toBeCloseTo(29.54, 2); }); it('has a gain only as a shelf or a peak, and a slope only where it gets steeper (#318)', () => { const peak = filterIn({ filter: 'peaking', gain: 6, slope: '48' }); const def = (node: GraphNode, fx: SoundFxAsset) => resolveDef(node, fx.graph, NO_LOOKUP)!; expect(def(peak.filter, peak.fx).inputs.map((p) => p.key)).toEqual(['in', 'cutoff', 'q', 'gain', 'wet']); expect(SoundFxFilter.idleParams!(peak.filter.params)).toEqual(['slope']); expect(heard(fxChainOf(compileSoundFx(peak.fx.graph).compiled!))[0]).toMatchObject({ stages: 1, gain: 6, }); const notch = filterIn({ filter: 'notch', gain: 6, slope: '48' }); expect(def(notch.filter, notch.fx).inputs.map((p) => p.key)).toEqual(['in', 'cutoff', 'q', 'wet']); expect(SoundFxFilter.idleParams!(notch.filter.params)).toEqual([]); // A gain typed in before the type changed does nothing. expect(heard(fxChainOf(compileSoundFx(notch.fx.graph).compiled!))[0]).toMatchObject({ stages: 4, gain: 0, }); expect(passStagesOf({ filter: 'lowpass', slope: '24' })).toBe(2); expect(passStagesOf({ filter: 'lowpass' })).toBe(1); }); }); describe('an Envelope in an instrument', () => { it('on the way of the sound is its volume, its numbers held to their rows', () => { const f = makeInstrument(); const envelope = f.edit.addNode('InstrumentEnvelope', { attack: 0.02, sustain: 3, release: -1 }); f.edit.wire([ [f.sample, 'out', envelope, 'in'], [envelope, 'out', f.output, 'in'], ]); const build = f.compile(); expect(build.reason).toBe(''); expect(fxChainOf(build.layers[0]!).stages).toEqual([ { kind: 'envelope', envelope: { id: envelope.id, attack: 0.02, decay: 0.1, sustain: 1, release: 0 } }, ]); }); it('moves the cutoff of a Filter from "from" to "to", and its Q', () => { const f = makeInstrument(); const envelope = f.edit.addNode('InstrumentEnvelope', { from: 200, to: 5000 }); const sweep = f.edit.addNode('InstrumentEnvelope', { decay: 1, from: Math.SQRT1_2, to: 10 }); const filter = f.edit.addNode('SoundFxFilter'); f.edit.wire([ [f.sample, 'out', filter, 'in'], [envelope, 'value', filter, 'cutoff'], [sweep, 'value', filter, 'q'], [filter, 'out', f.output, 'in'], ]); const build = f.compile(); // An Envelope moving numbers only shapes no sound, and nothing is missing for it. expect(build.problems).toEqual({}); const [stage] = heard(fxChainOf(build.layers[0]!)); expect(stage).toMatchObject({ kind: 'pass', filter: 'lowpass', cutoff: 200, cutoffMod: { envelope: { id: envelope.id }, from: 200, to: 5000 }, qMod: { envelope: { id: sweep.id, decay: 1 } }, }); const { qMod } = stage as { qMod: { from: number; to: number } }; expect([qMod.from, qMod.to]).toEqual([expect.closeTo(-3.01, 2), expect.closeTo(20, 9)]); }); it('holds both ends to the row of the number it moves, and fills in what a note hands it', () => { const f = makeInstrument(); const envelope = f.edit.addNode('InstrumentEnvelope', { from: 0, to: 30000 }); const filter = f.edit.addNode('SoundFxFilter'); f.edit.wire([ [f.sample, 'out', filter, 'in'], [envelope, 'value', filter, 'cutoff'], [f.notes, 'velocity', envelope, 'sustain'], [filter, 'out', f.output, 'in'], ]); const chain = fxChainOf(f.compile().layers[0]!, { [VELOCITY_PARAM]: 0.25 }); expect(heard(chain)[0]).toMatchObject({ cutoff: 20, cutoffMod: { from: 20, to: 20000, envelope: { sustain: 0.25 } }, }); }); it('moves a Pitch on top of the one the way has, a Gain in its factor, a Pan and a Mix', () => { const f = makeInstrument(); const envelope = f.edit.addNode('InstrumentEnvelope', { from: -12, to: 0 }); const pitch = f.edit.addNode('SoundFxPitch'); const gain = f.edit.addNode('SoundFxGain'); const pan = f.edit.addNode('SoundFxPan'); const mix = f.edit.addNode('SoundFxMix'); f.edit.wire([ [f.sample, 'out', pitch, 'in'], [envelope, 'value', pitch, 'semitones'], [pitch, 'out', gain, 'in'], [envelope, 'value', gain, 'gain'], [gain, 'out', pan, 'in'], [envelope, 'value', pan, 'pan'], [pan, 'out', mix, 'a'], [f.sample, 'out', mix, 'b'], [envelope, 'value', mix, 'mix'], [mix, 'out', f.output, 'in'], ]); const chain = fxChainOf(f.compile().layers[0]!); if (!('mix' in chain) || 'mix' in chain.a) throw new Error('a Mix of two ways'); // The mix rests at "from", held to 0..1; at 0 it is not taken for "only a". expect(chain.mix).toBe(0); expect(chain.mixMod).toMatchObject({ from: 0, to: 0 }); expect(chain.a.pitch).toBe(0); expect(chain.a.shifted).toBe(true); expect(chain.a.pitchMods).toMatchObject([{ from: -12, to: 0 }]); expect(chain.a.stages).toMatchObject([ { kind: 'gain', mod: { from: 10 ** (-12 / 20), to: 1 } }, // The Pan rests at "from" as well, held to -1..1. { kind: 'pan', mod: { from: -1, to: 0 } }, ]); }); it('cannot move a number the audio thread does not hold, like the time of a Delay', () => { const f = makeInstrument(); const envelope = f.edit.addNode('InstrumentEnvelope'); const delay = f.edit.addNode('SoundFxDelay'); const value = { type: 'envelope' as const }; expect(portsMeet(value, { type: 'number', followsEnvelope: true })).toBe(true); expect(portsMeet(value, { type: 'number' })).toBe(false); expect(portsMeet(value, { type: 'any' })).toBe(false); expect(portsMeet({ type: 'number' }, { type: 'envelope' })).toBe(false); expect(() => f.stack.execute( new ConnectCommand( f.instrument.id, { node: envelope.id, port: 'value' }, { node: delay.id, port: 'time' }, ), ), ).toThrow('Cannot connect envelope to number'); // Saved that way anyhow, it says so rather than playing it. f.instrument.graph.edges.push({ id: 'by-hand', from: { node: envelope.id, port: 'value' }, to: { node: delay.id, port: 'time' }, }); f.edit.wire([ [f.sample, 'out', delay, 'in'], [delay, 'out', f.output, 'in'], ]); expect(f.compile().problems[delay.id]).toBe('"Time" cannot follow an Envelope'); }); it('with its sound way connected on but nothing coming in, says so', () => { const f = makeInstrument(); const envelope: GraphNode = f.edit.addNode('InstrumentEnvelope'); f.edit.connect(envelope, 'out', f.output, 'in'); expect(f.compile().problems[envelope.id]).toBe('Nothing comes in: connect it to a Sample'); }); it('leaves the plan of a note without one as it was', () => { const f = makeInstrument(); f.edit.connect(f.sample, 'out', f.output, 'in'); const plan: FxPlan = f.compile().layers[0]!; expect(plan.stages).toEqual([]); }); });