Without an envelope a note set in hard and broke off 10 ms after its end. An instrument now has an Envelope node with attack, decay, sustain and release. On the way of the sound it is the volume. Its output "value" moves a number of the note with the same curve, from "From" to "To", for a filter sweep among others. A new Filter node gives that sweep something to move. - Envelope (instrument only): attack a straight line, decay and release ease out and are 60 dB of the way at their time. A release takes 10 ms at least. Defaults 0.005 / 0.1 / 1 / 0.1. - The end of a note goes to the Envelope instead of the 10 ms fade. A Gate or Loop Sample under it plays on through the release, "Loop, then release" leaves its loop too, and the voice ends with the release. A One-shot under an Envelope of its own plays through. - A note ending before attack and decay releases from the level it has reached. Chrome holds it with cancelAndHoldAtTime, Firefox gets the level worked out from the shape. - New port type "envelope" (coral). It fits only a number input that says it can follow one (PortDef.followsEnvelope): Filter cutoff and resonance, Pitch, Gain, the EQ bands and Mix. Not the Delay, not the Envelope's own numbers. portsMeet() decides it for ConnectCommand, the drag and the drawn edges. - On the audio thread every Envelope of a note is one ConstantSourceNode. A number it moves gets the curve through a WaveShaper, in equal ratios for a cutoff and a gain. A number an Envelope moves is no longer reached by a parameter. - Filter (sound FX and instrument): low-, high- or band-pass, cutoff in Hz, resonance 0 to 1 as a Q. A low- or high-pass is flat at 0 and rises 20 dB at 1, a band-pass goes from Q 0.7 to 20. - Stop sound with "let ring" lets a note with an Envelope fall away in its release. Without it, and with Stop in the tab, it is cut. - The same note again during a release is a voice of its own. - The Envelope's numbers are set when the note starts. They can come from velocity or a parameter, later changes do not bend the note. Tests: - envelope.test.ts (new, 12): curve, ratios, Filter Q, compiling an Envelope, its value into each number, a Delay refused - sound-library +7: attack and decay on the clock, release from the level reached, One-shot held, Stop sound with and without let ring, a cutoff and a pitch moved by the curve - e2e 34: ALL PASSED, 50 checks (+2). Offline in Chrome the curve rendered 0.5 / 1 / 0.5005 at 0.05 / 0.1 / 0.3 s, released at 0.5 s it was 0.0158 at 0.65 s and 0.0005 at 0.8 s, released mid-attack it fell from 0.5 to 0.05 in 0.1 s. A Gain moved 0.25 to 1 was 0.5 halfway. A looped note of 0.2 s with a release of 1 s was gone after 1.32 s. - Five counter-checks (release end, One-shot hold, the follows check, ratios, pitch sum) failed 3, 1, 1, 2 and 2 tests. - Gate on melektaus-atx: CHECK EXIT 0, 245 files, 3812 tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
233 lines
11 KiB
TypeScript
233 lines
11 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 type { BehaviorAsset, InstrumentAsset } from '../../src/core/model/Asset';
|
||
import { compileSoundFx } from '../../src/core/soundfx/compile';
|
||
import { FLAT_Q, fxChainOf, type FxEnvelope, type FxPlan, passQ } 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('has no bump at resonance 0 and rings towards 1; a band-pass gets narrower', () => {
|
||
expect(passQ('lowpass', 0)).toBeCloseTo(FLAT_Q, 9);
|
||
expect(passQ('highpass', 1)).toBe(20);
|
||
expect(passQ('bandpass', 0)).toBeCloseTo(0.7, 9);
|
||
expect(passQ('bandpass', 1)).toBeCloseTo(20, 9);
|
||
expect(passQ('lowpass', 7)).toBe(20);
|
||
});
|
||
|
||
it('is an effect of a sound FX as well, its cutoff in Hz and its resonance as a Q', () => {
|
||
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', { filter: 'highpass', cutoff: 300, resonance: 1 });
|
||
const output = edit.addNode('SoundFxOutput');
|
||
edit.wire([
|
||
[input, 'out', filter, 'in'],
|
||
[filter, 'out', output, 'in'],
|
||
]);
|
||
const plan = compileSoundFx((fx as unknown as { graph: never }).graph).compiled!;
|
||
expect(fxChainOf(plan).stages).toEqual([{ kind: 'pass', filter: 'highpass', cutoff: 300, q: 20 }]);
|
||
});
|
||
});
|
||
|
||
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 resonance as a Q', () => {
|
||
const f = makeInstrument();
|
||
const envelope = f.edit.addNode('InstrumentEnvelope', { from: 200, to: 5000 });
|
||
const sweep = f.edit.addNode('InstrumentEnvelope', { decay: 1, from: 0, to: 1 });
|
||
const filter = f.edit.addNode('SoundFxFilter');
|
||
f.edit.wire([
|
||
[f.sample, 'out', filter, 'in'],
|
||
[envelope, 'value', filter, 'cutoff'],
|
||
[sweep, 'value', filter, 'resonance'],
|
||
[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 }, from: FLAT_Q, to: 20 },
|
||
});
|
||
});
|
||
|
||
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([]);
|
||
});
|
||
});
|