Files
feuerwerk/tests/core/envelope.test.ts
T
adminandClaude Opus 5.5 8c53ce0cbe
game-maker / gate (push) Successful in 4m13s
game-maker / suite (push) Successful in 16m21s
Replace the 5-Band-EQ with the Parametric EQ and remove it (#395)
The Parametric EQ does all the 5-Band-EQ did and more, so the editor
keeps only one EQ. The old node was used in one project only, with
nothing linked to its band inputs.

- "Garden of Sacrifices" was converted once on the project file, not
  by a load migration (#137). Its 5 nodes are Parametric EQs now with
  the same filters: low shelf 80 Hz, peaks at 250 Hz, 1 kHz and 4 kHz
  with Q 1, high shelf 12 kHz. A band at 0 became no band.
- Two values below -24 dB were held to -24, as the Parametric EQ goes
  no further. Both are in the asset "Delay" (high -60, low -36). The
  user will rework them by ear.
- The write went through the API's own Store (lock, revision 20181 to
  20182), so a tab with the old revision gets a 409 instead of
  overwriting it.
- Removed with the node: its stage kind "filter" and the type
  FxFilter, which nothing else made, and the player's branch for it.
  A Pan is now the last branch of fxNodes, a Flanger that of stagesOf.
- Tests that took the 5-Band-EQ as "some effect" use the Pan or the
  Parametric EQ now. The test for a parameter feeding a band now
  feeds the cutoff of a Filter, which had no such test yet.
- A project that still names the type, such as an old snapshot of
  "Garden of Sacrifices", loads without those nodes. Unknown types
  were always left out on load.

Tests:
- Unit (stein2): soundfx 67, instrument 36, envelope 13, sound-library
  127, all green
- Counter-check: with the cutoff not taken from the parameter, only
  the new Filter test goes red
- The converted project loads and compiles: Delay, Vocals and Drums
  play their bands (3+1, 4 and 5)
- e2e 34 (atx): ALL PASSED, 136 checks, the palette check now looks
  for the Parametric EQ
- Gate (atx): CHECK EXIT 0, 288 files, 4417 tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:46:03 +02:00

280 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`;
/** The stages of a chain that do something to the sound: a Filter is tapped as well (#385). */
const heard = <S extends { kind: string }>(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([]);
});
});