A node could only be taken out of a sound FX by unplugging it, and only six of them could be blended with the sound they get. Every effect now has both, in the sound FX graph and in the instrument graph alike. The Mix, the Input and the Output have neither. - Bypass is a checkbox setting. A bypassed node is left out when the graph is compiled, so it costs nothing and holds nothing back. The graph draws it faded, and its body says "Bypass: true". - Wet is a number input like the others. It can be a parameter, be moved by Set sound FX parameters and, where the node allows it for its other numbers, follow an Envelope. - A blend is a stage of its own (`wet`) around what the node adds. The sound as it came in is held back as far as the node is late, so a Compressor, a Limiter or a Vibrato do not comb it out. - The six nodes that had a Mix keep their key `mix` and only call it Wet. Saved values, parameters and edges stay as they were. - The Delay crossfades now: the sound alone at 0, the sound and its echoes in full at 0.5, the echoes alone at 1. Before, the sound was always in full and `mix` was the echoes. - The Pitch takes its semitones by Wet instead of blending, through a share `times` on the amount that an instrument binds as well. Findings: - A Delay saved before this sounds different. It sounds the same at half its old value. The two chip presets with a Delay (Echo Lead, Bell) are halved to keep their sound. - Project cbbf160bb7e8 has two Delays (mix 1 and 0.5). They are left as they are. Tests: - Unit: soundfx 65 (8 new), sound-library 107 (3 new), instrument 35 (1 new), envelope 13. Two delay tests and the filter ports test follow the new numbers. - Counter-checks: 8, each turns its test red. - e2e 34 (melektaus-atx): ALL PASSED, 83 checks (2 new). The ping-pong check at wet 0.25 measures what it did at mix 0.5. - Gate (melektaus-atx): CHECK EXIT 0, 268 files, 4154 tests. - After the gate the derived ports of a blend are asked for once instead of twice: typecheck, lint and the three unit files green again (113 tests). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
275 lines
12 KiB
TypeScript
275 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`;
|
||
|
||
/** 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', 'wet']);
|
||
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', 'wet']);
|
||
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([]);
|
||
});
|
||
});
|