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