Files
feuerwerk/tests/core/envelope.test.ts
T
adminandClaude Opus 5.5 c087993000 Edit a Filter in its node and resize EQ and Filter nodes (#385)
A Filter sounds like one band of the Parametric EQ, but it keeps what
the EQ cannot: linked numbers, the all-pass and +-40 dB. So it stays a
node of its own and gets the EQ's editor with itself as the one band.
The editors also stop running out of their room: what does not fit is
left out instead of being cut.

- The curve of the EQ is now BandCurve with a host the EQ and the
  Filter both fill. CurvePanel holds what their panels share.
- FilterPanel shows the Filter as one band. A drag of its handle sets
  frequency and gain as one undo step. A number fed by a link is
  greyed out and the drag leaves it alone.
- A double-click on the Filter's title opens the same in a tab of its
  own. Delete there does nothing, the node stays.
- The Filter is tapped like the EQ, so its node and its tab draw the
  spectrum of what passes it. A tap is two gain nodes per voice, the
  analysers are made only while someone listens.
- EqFace and FilterFace are one CurveFace now, given the panel.
- EQ and Filter nodes have a corner to resize them, drawn like the
  floating window's. The size is saved on the node as `size`, each
  drag is one undo step, the least is 240 x 120.
- In the node the help line is gone. The row of numbers comes in only
  once the curve keeps 120 px of height. A group of the row that does
  not fit is not drawn, the one on the right first.
- A frequency or dB label that would run into the one before it is
  left out.
- The all-pass is drawn flat. The curve's maths takes every shape a
  Filter has.

Found on the way:
- The instrument presets set nodes 240 apart. A Filter with its
  editor is about 700 wide and covered the Bitcrusher, so a Filter
  now takes four columns.

Tests:
- Unit: 8 new (filter band, resize command and save, fitting rules),
  stage lists with a Filter now hold its taps
- e2e 34 (atx): ALL PASSED, 130 checks (4 new)
- Gate (atx): CHECK EXIT 0, 284 files, 4367 tests

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

279 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 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([]);
});
});