Files
feuerwerk/tests/core/envelope.test.ts
T
adminandClaude Opus 5.5 e1ca6a5f5a Shape a sound with every filter type, a Q and a slope (#318)
The Filter node from #335 only knew low-pass, high-pass and band-pass
and had a Resonance from 0 to 1. It now has every type Web Audio
offers, a Q as a plain factor and a slope up to 48 dB per octave.

- Types: low-pass, high-pass, band-pass, notch, low shelf, high shelf,
  peaking and all-pass. The cutoff row is now labelled Frequency, its
  key stays `cutoff`.
- Q from 0.1 to 30, 0.707 by default, replaces Resonance. Web Audio
  takes the Q of a low- or high-pass in dB, so it is converted when the
  voice is wired. No project used the node.
- Gain in dB is an input only for the shelves and the peak. It is a
  derived port, so the sound FX compiler now resolves derived ports.
- Slope 12, 24 or 48 dB/oct runs 1, 2 or 4 filters in a row. They
  share one cutoff carried by a ConstantSource, only the last has the
  Q. Shelves, peak and all-pass hide the row.
- The Chip Presets get the Q their resonance gave before. Mutate moves
  Q in octaves and gain by up to a dB.
- bindStage in the instrument compiler binds every amount of a stage
  generically, so new stages need no case of their own there.

Finding: FLAT_Q was in dB and the delay damping used it. It is a
factor now, the damping takes it through passQ and is unchanged.

Tests:
- Unit envelope 13, sound-library 96 (2 new), chip-presets 14,
  soundfx 45, all green.
- Counter-checks: the Q on every filter in a row, the gain row for
  every type. Each turns one test red.
- e2e 34 (melektaus-atx): ALL PASSED, 76 checks (1 new). Chrome
  offline, 2 kHz through a low-pass at 1 kHz: -12.37 dB at 12 dB/oct,
  -49.5 dB at 48. A +6 dB peak gives 6.00 dB, a notch -124 dB.
- The gate runs once on the last commit of this series.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:43:49 +02:00

275 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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`;
/** 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] = fxChainOf(plan).stages;
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']);
expect(SoundFxFilter.idleParams!(peak.filter.params)).toEqual(['slope']);
expect(fxChainOf(compileSoundFx(peak.fx.graph).compiled!).stages[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']);
expect(SoundFxFilter.idleParams!(notch.filter.params)).toEqual([]);
// A gain typed in before the type changed does nothing.
expect(fxChainOf(compileSoundFx(notch.fx.graph).compiled!).stages[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] = 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 } },
});
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(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([]);
});
});