/** * 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 type { BehaviorAsset, InstrumentAsset } from '../../src/core/model/Asset'; import { compileSoundFx } from '../../src/core/soundfx/compile'; import { FLAT_Q, fxChainOf, type FxEnvelope, type FxPlan, passQ } from '../../src/core/soundfx/nodes'; import { graphEditor, makeProject } from '../helpers'; const FILE = `${'c3'.repeat(32)}.wav`; /** 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('has no bump at resonance 0 and rings towards 1; a band-pass gets narrower', () => { expect(passQ('lowpass', 0)).toBeCloseTo(FLAT_Q, 9); expect(passQ('highpass', 1)).toBe(20); expect(passQ('bandpass', 0)).toBeCloseTo(0.7, 9); expect(passQ('bandpass', 1)).toBeCloseTo(20, 9); expect(passQ('lowpass', 7)).toBe(20); }); it('is an effect of a sound FX as well, its cutoff in Hz and its resonance as a Q', () => { 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', { filter: 'highpass', cutoff: 300, resonance: 1 }); const output = edit.addNode('SoundFxOutput'); edit.wire([ [input, 'out', filter, 'in'], [filter, 'out', output, 'in'], ]); const plan = compileSoundFx((fx as unknown as { graph: never }).graph).compiled!; expect(fxChainOf(plan).stages).toEqual([{ kind: 'pass', filter: 'highpass', cutoff: 300, q: 20 }]); }); }); 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 resonance as a Q', () => { const f = makeInstrument(); const envelope = f.edit.addNode('InstrumentEnvelope', { from: 200, to: 5000 }); const sweep = f.edit.addNode('InstrumentEnvelope', { decay: 1, from: 0, to: 1 }); const filter = f.edit.addNode('SoundFxFilter'); f.edit.wire([ [f.sample, 'out', filter, 'in'], [envelope, 'value', filter, 'cutoff'], [sweep, 'value', filter, 'resonance'], [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] = fxChainOf(build.layers[0]!).stages; 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 }, from: FLAT_Q, to: 20 }, }); }); 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(chain.stages[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 band 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 eq = f.edit.addNode('SoundFxEq'); 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', eq, 'in'], [envelope, 'value', eq, 'mid'], [eq, '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 } }, { kind: 'filter', frequency: 1000, mod: { from: -12, 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([]); }); });