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>
280 lines
12 KiB
TypeScript
280 lines
12 KiB
TypeScript
/**
|
||
* 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([]);
|
||
});
|
||
});
|