Files
feuerwerk/tests/core/chip-presets.test.ts
T
adminandClaude Opus 5.5 01fd6e6464 Swirl and comb a sound with a Phaser and a Flanger node (#341)
sfxr's "phaser" is a comb filter, the sound plus a copy delayed by up
to 2.9 ms. That is a flanger, not the phaser a guitarist knows. Both
effects come in as nodes of their own, so each name fits its sound and
sfxr is covered in full. Both run on the FX worklet like the
Bitcrusher, because a feedback loop in Web Audio costs a render quantum
per round.

- Phaser: first-order allpasses (Stages 2, 4, 6, 8, 12) around
  Frequency, moved by a sine LFO Depth octaves wide at Rate Hz, with
  Feedback and Mix. An Envelope can move Frequency and Mix.
- Flanger: a delay line of Offset ms. Sweep moves it on in ms/s from
  the start of the voice, an LFO adds up to Depth ms. As in sfxr the
  delay is mirrored at 0, and it never goes past 20 ms. Feedback comes
  back exactly one delay later. An Envelope can move Offset and Mix.
- Mix 0 leaves the sound as it is, 1 is half sound and half copy. The
  notches are deepest there and the peaks stay at the sound's level.
  sfxr adds both at full, so its sound comes out 6 dB louder.
- The Flanger's processor gets the start of its voice and begins the
  sweep on that sample. stageNodes takes it as a new argument `at`.
- Explosion gets a Flanger in one draw out of two, with sfxr's offset
  and sweep and no LFO. Offset is offset^2 * 2.89 ms, Sweep is
  ramp^2 * 125 ms/s, signed so the direction holds.
- Randomize draws a Flanger's offset and sweep like sfxr. Its LFO and
  feedback and all of a Phaser are drawn within bounds of their own.
  Mix and Stages stay, as a Bitcrusher's Mix does. Mutate moves them
  too, and a 0 stays 0.
- New instrument preset Phased Pad: saw, Envelope, low-pass, Phaser of
  six stages. The other 14 are unchanged.
- The Bitcrusher's worklet setup is now shared with both nodes
  (workletNodes), its behaviour is unchanged.

Tests:
- Unit phaser (new): 8 green. Counter-check with clamping at 0 instead
  of mirroring and a flipped allpass coefficient: 3 red.
- Unit soundfx, sound-library, chip-presets: 45 / 76 / 14 green.
  Counter-checks: start time not passed on, phaser frequency eased
  linearly, sweep sign dropped, Phaser left out of Mutate, each red.
- Unit core, viewport, graph: 179 files, 2979 tests green.
- e2e 34 (melektaus-atx): ALL PASSED, 70 checks (1 new), first run.
  Chrome offline: 1 kHz notch RMS 0.0000 with two stages, 0.5657
  with four. Flanger echo after 48 samples, swept echo at 533/534.
  About 47 Phasers (12 stages) or 63 Flangers fill the audio thread.
- Gate (melektaus-atx): CHECK EXIT 0, 257 files, 3991 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 12:11:54 +02:00

360 lines
16 KiB
TypeScript

/**
* The Preset menu, Randomize and Mutate of the Instrument tab (#329): sfxr's generators worked out
* into a Chip, an Envelope, a Filter, a Bitcrusher and a Flanger (#341), and its randomizer and mutator
* on their numbers.
*/
import { describe, expect, it } from 'vitest';
import { ReplaceGraphCommand } from '../../src/core/commands/graphCommands';
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
import type { Graph, GraphNode } from '../../src/core/graph/Graph';
import {
CHIP_PRESETS,
chipPresetGraph,
EFFECT_PRESETS,
hasChipNumbers,
mutatedParams,
randomizedParams,
TONE_PRESETS,
} from '../../src/core/instrument/chipPresets';
import { compileInstrument, instrumentSourcesOf } from '../../src/core/instrument/compile';
import { CHIP_ROWS, ENVELOPE_ROWS, starterInstrumentGraph } from '../../src/core/instrument/nodes';
import type { InstrumentAsset } from '../../src/core/model/Asset';
import type { FlatDef } from '../../src/core/properties/PropertySchema';
import {
CRUSH_ROWS,
FLANGER_ROWS,
type FxChipPlan,
type FxEnvelopePlan,
FILTER_ROWS,
type FxPlan,
fxChainOf,
PHASER_ROWS,
} from '../../src/core/soundfx/nodes';
import { makeProject } from '../helpers';
/** A seeded random, so a draw is the same on every run. */
function seeded(seed = 1): () => number {
let state = seed;
return () => {
state = (state * 1664525 + 1013904223) >>> 0;
return state / 2 ** 32;
};
}
const byType = (graph: Graph, type: string) => graph.nodes.find((node) => node.type === type)!;
const ROWS: Record<string, Record<string, FlatDef<'number'>>> = {
InstrumentChip: CHIP_ROWS,
InstrumentEnvelope: ENVELOPE_ROWS,
SoundFxFilter: FILTER_ROWS,
SoundFxBitcrusher: CRUSH_ROWS,
SoundFxPhaser: PHASER_ROWS,
SoundFxFlanger: FLANGER_ROWS,
};
function withinRows(node: GraphNode, keys: string[]): void {
for (const key of keys) {
const row = ROWS[node.type]![key]!;
const value = node.params[key] as number;
expect(value, `${node.type}.${key}`).toBeGreaterThanOrEqual(row.min ?? -Infinity);
expect(value, `${node.type}.${key}`).toBeLessThanOrEqual(row.max ?? Infinity);
}
}
describe('a preset (#329)', () => {
it('has the instruments first in the menu, then the sounds of sfxr', () => {
expect(CHIP_PRESETS.map((p) => p.value)).toEqual([
...TONE_PRESETS.map((p) => p.value),
...EFFECT_PRESETS.map((p) => p.value),
]);
expect(TONE_PRESETS).toHaveLength(15);
});
it('of sfxr is a chain from every note through Chip, Envelope, Filter and Bitcrusher, ending by itself', () => {
const sources = instrumentSourcesOf(makeProject().project);
for (const { value } of EFFECT_PRESETS) {
const graph = chipPresetGraph(value, seeded());
// Only an explosion can have sfxr's phaser, as a Flanger after the Filter.
const flanged = graph.nodes.some((n) => n.type === 'SoundFxFlanger');
expect(flanged && value !== 'explosion', value).toBe(false);
expect(
graph.nodes.map((n) => n.type),
value,
).toEqual([
'InstrumentNotes',
'InstrumentChip',
'InstrumentEnvelope',
'SoundFxFilter',
...(flanged ? ['SoundFxFlanger'] : []),
'SoundFxBitcrusher',
'InstrumentOutput',
]);
const frequencies = [0, 60, 127].map((note) => {
const build = compileInstrument(graph, note, sources);
expect(build.reason, value).toBe('');
const chain = fxChainOf(build.layers[0]!);
expect('chip' in chain && chain.stages.map((s) => s.kind), value).toEqual([
'envelope',
'pass',
...(flanged ? ['flanger'] : []),
'crush',
]);
const envelope = chain.stages[0]!;
expect(envelope.kind === 'envelope' && envelope.envelope.sustain, value).toBe(0);
return 'chip' in chain ? chain.chip?.frequency : undefined;
});
// Its own frequency on every key, as an sfxr sound has.
expect(new Set(frequencies).size, value).toBe(1);
withinRows(byType(graph, 'InstrumentChip'), Object.keys(CHIP_ROWS));
}
});
it("works sfxr's settings out into Hz and seconds", () => {
// At 0 every draw takes the low end: a coin at sfxr's 0.4, no arpeggio, a decay of 0.1.
const low = chipPresetGraph('pickup', () => 0);
expect(byType(low, 'InstrumentNotes').params).toEqual({ from: '0', to: '127' });
expect(byType(low, 'InstrumentChip').params).toMatchObject({
wave: 'square',
followNote: false,
frequency: 568,
duty: 0.5,
slide: 0,
arpeggio: 0,
});
expect(byType(low, 'InstrumentEnvelope').params).toMatchObject({ attack: 0, decay: 0.068, sustain: 0 });
expect(byType(low, 'SoundFxFilter').params).toEqual({ filter: 'lowpass', cutoff: 20000, resonance: 0 });
expect(byType(low, 'SoundFxBitcrusher').params).toEqual({ bits: 8, rate: 22050, mix: 1 });
// Near 1 the coin jumps up by sfxr's arpeggio: a period 0.676 times as long is about 7 semitones.
const high = byType(
chipPresetGraph('pickup', () => 0.999),
'InstrumentChip',
).params;
expect(high).toMatchObject({ frequency: 2858, arpeggio: 7, arpeggioTime: 0.042 });
// A laser slides down, an explosion is noise.
expect(byType(chipPresetGraph('laser', seeded(3)), 'InstrumentChip').params.slide).toBeLessThan(0);
expect(byType(chipPresetGraph('explosion', seeded(3)), 'InstrumentChip').params.wave).toBe('noise');
});
it("gives one explosion in two sfxr's phaser, as a Flanger with its offset and sweep (#341)", () => {
// Near 1: sfxr's offset 0.5991 and ramp -0.2997, a delay of 1.04 ms falling 11.2 ms a second.
expect(
byType(
chipPresetGraph('explosion', () => 0.999),
'SoundFxFlanger',
).params,
).toEqual({
offset: 1.04,
sweep: -11.2,
rate: 0,
depth: 0,
feedback: 0,
mix: 1,
});
expect(chipPresetGraph('explosion', () => 0).nodes.some((n) => n.type === 'SoundFxFlanger')).toBe(false);
const random = seeded(4);
const flangers = Array.from({ length: 400 }, () => chipPresetGraph('explosion', random)).flatMap(
(graph) => graph.nodes.filter((n) => n.type === 'SoundFxFlanger'),
);
expect(flangers.length).toBeGreaterThan(160);
expect(flangers.length).toBeLessThan(240);
for (const flanger of flangers) withinRows(flanger, Object.keys(FLANGER_ROWS));
});
it('is drawn anew every time', () => {
const random = seeded(7);
const a = byType(chipPresetGraph('laser', random), 'InstrumentChip').params;
const b = byType(chipPresetGraph('laser', random), 'InstrumentChip').params;
expect(a).not.toEqual(b);
});
it('takes the place of the graph in one step', () => {
const f = makeProject();
const command = new CreateAssetCommand('instrument', 'Coin');
f.stack.execute(command);
const instrument = command.asset as InstrumentAsset;
const before = instrument.graph.nodes.map((n) => n.id);
f.stack.execute(
new ReplaceGraphCommand(instrument.id, chipPresetGraph('pickup', seeded()), 'Preset Pickup'),
);
expect(instrument.graph.nodes).toHaveLength(6);
f.stack.undo();
expect(instrument.graph.nodes.map((n) => n.id)).toEqual(before);
});
});
/** The Chips and the Envelopes on the ways of a plan, a Mix's two ways included. */
function walkPlan(plan: FxPlan): { chips: FxChipPlan[]; envelopes: FxEnvelopePlan[] } {
const envelopes = plan.stages.flatMap((stage) => (stage.kind === 'envelope' ? [stage.envelope] : []));
if ('mix' in plan) {
const a = walkPlan(plan.a);
const b = walkPlan(plan.b);
return { chips: [...a.chips, ...b.chips], envelopes: [...a.envelopes, ...b.envelopes, ...envelopes] };
}
return { chips: plan.chip ? [plan.chip] : [], envelopes };
}
describe('an instrument preset', () => {
const sources = () => instrumentSourcesOf(makeProject().project);
const build = (graph: Graph, note: number) => {
const built = compileInstrument(graph, note, sources());
return { ...built, ...walkPlan(built.layers[0] ?? { pitch: [], stages: [] }) };
};
/** Those that die away however long the key is held. */
const STRUCK = new Set(['bell', 'pluck', 'drumKit']);
it('plays every key at its own pitch, without a problem, and holds it unless it is struck', () => {
for (const { value } of TONE_PRESETS.filter((p) => p.value !== 'drumKit')) {
const graph = chipPresetGraph(value);
const [c4, c5] = [60, 72].map((note) => build(graph, note));
expect(c4!.reason, value).toBe('');
expect(c4!.problems, value).toEqual({});
// The first Chip follows the note: an octave up is twice the frequency.
expect(c5!.chips[0]!.frequency.value / c4!.chips[0]!.frequency.value, value).toBeCloseTo(2, 6);
const held = c4!.envelopes.some((envelope) => envelope.sustain.value > 0);
expect(held, value).toBe(!STRUCK.has(value));
for (const node of graph.nodes.filter((n) => n.type === 'InstrumentChip'))
withinRows(node, Object.keys(CHIP_ROWS));
}
});
it('comes out the same every time it is picked', () => {
const shape = (graph: Graph) => graph.nodes.map(({ type, x, y, params }) => ({ type, x, y, params }));
for (const { value } of TONE_PRESETS)
expect(shape(chipPresetGraph(value, seeded(1))), value).toEqual(
shape(chipPresetGraph(value, seeded(2))),
);
});
it('builds what it says: two Chips a little apart, a filter swept by an Envelope, an arpeggio', () => {
const saws = build(chipPresetGraph('detunedSaws'), 60);
expect(saws.chips.map((chip) => chip.wave)).toEqual(['saw', 'saw']);
const ways = saws.layers[0]!;
expect('mix' in ways && 'pitch' in ways.b && ways.b.pitch.map((p) => p.value)).toEqual([0.1]);
const bass = chipPresetGraph('pluckBass');
const filter = byType(bass, 'SoundFxFilter');
const sweep = bass.edges.find((e) => e.to.node === filter.id && e.to.port === 'cutoff');
expect(bass.nodes.find((n) => n.id === sweep?.from.node)?.params).toMatchObject({ from: 180, to: 2400 });
expect(byType(chipPresetGraph('octaveArp'), 'InstrumentChip').params).toMatchObject({
arpeggio: 12,
repeat: 0.08,
});
// Phased Pad: a saw through a Phaser of six stages (#341).
const pad = build(chipPresetGraph('phasedPad'), 60).layers[0]!;
expect(fxChainOf(pad).stages.at(-1)).toMatchObject({ kind: 'phaser', stages: 6, frequency: 600, mix: 1 });
});
it('Drum Kit: kick, snare and hi-hats on their General MIDI keys, each ending by itself', () => {
const graph = chipPresetGraph('drumKit');
const notes = byType(graph, 'InstrumentNotes').params;
expect(notes).toMatchObject({ singleNotes: true, name36: 'Kick', name38: 'Snare', name42: 'Closed hat' });
const sounding = [];
for (let note = 36; note <= 46; note++) {
const drum = build(graph, note);
expect(drum.reason, String(note)).toBe('');
if (drum.layers.length === 0) continue;
sounding.push(note);
expect(
drum.envelopes.every((envelope) => envelope.sustain.value === 0),
String(note),
).toBe(true);
}
expect(sounding).toEqual([36, 38, 42, 46]);
// The kick drops from its own 160 Hz on every key, the snare is a tone and noise together.
const kick = build(graph, 36).chips;
expect(kick).toHaveLength(1);
expect(kick[0]).toMatchObject({ wave: 'sine', frequency: { value: 160 } });
expect(kick[0]!.slide.value).toBeLessThan(0);
expect(build(graph, 38).chips.map((chip) => chip.wave)).toEqual(['triangle', 'noise']);
});
});
describe('Randomize and Mutate (#329)', () => {
/** A preset with a parameter fed into the Chip's duty, which neither may touch. */
function fedPreset(): Graph {
const graph = chipPresetGraph('laser', seeded(5));
const chip = byType(graph, 'InstrumentChip');
const notes = byType(graph, 'InstrumentNotes');
chip.params.wave = 'triangle';
chip.params.duty = 0.3;
graph.edges.push({
id: 'fed',
from: { node: notes.id, port: 'velocity' },
to: { node: chip.id, port: 'duty' },
});
byType(graph, 'SoundFxBitcrusher').params.mix = 0.4;
byType(graph, 'SoundFxFilter').params.filter = 'highpass';
return graph;
}
function apply(graph: Graph, changes: ReturnType<typeof randomizedParams>): void {
for (const change of changes)
graph.nodes.find((n) => n.id === change.node)!.params[change.key] = change.value;
}
it('Randomize draws only the numbers, held to their rows, and leaves a fed input alone', () => {
const graph = fedPreset();
const changes = randomizedParams(graph, seeded(11));
expect(changes.some((c) => c.key === 'duty')).toBe(false);
expect(changes.some((c) => ['wave', 'filter', 'followNote', 'mix', 'from', 'to'].includes(c.key))).toBe(
false,
);
expect(new Set(changes.map((c) => graph.nodes.find((n) => n.id === c.node)!.type))).toEqual(
new Set(['InstrumentChip', 'InstrumentEnvelope', 'SoundFxFilter', 'SoundFxBitcrusher']),
);
apply(graph, changes);
const chip = byType(graph, 'InstrumentChip');
expect(chip.params).toMatchObject({ wave: 'triangle', duty: 0.3 });
withinRows(chip, Object.keys(CHIP_ROWS));
withinRows(byType(graph, 'SoundFxBitcrusher'), ['bits', 'rate']);
expect(byType(graph, 'SoundFxBitcrusher').params.mix).toBe(0.4);
expect(byType(graph, 'SoundFxFilter').params.filter).toBe('highpass');
expect(byType(graph, 'InstrumentEnvelope').params.sustain).toBe(0);
});
it('Mutate moves some numbers a little and leaves a 0 at 0', () => {
const graph = chipPresetGraph('pickup', () => 0);
const chip = byType(graph, 'InstrumentChip');
// 0.9 every time: each number moves, by 0.8 of its most.
const changes = mutatedParams(graph, () => 0.9);
const of = (key: string) => changes.find((c) => c.node === chip.id && c.key === key)?.value;
expect(of('frequency')).toBe(Math.round(568 * 2 ** 0.2));
expect(of('duty')).toBeCloseTo(0.5 + 0.8 * (0.025 + 0.05), 2);
for (const key of ['slide', 'arpeggio', 'repeat', 'minFrequency', 'vibratoDepth'])
expect(of(key), key).toBeUndefined();
expect(changes.some((c) => c.key === 'sustain')).toBe(false);
// Below 0.5 a number stays as it is.
expect(mutatedParams(graph, () => 0.1)).toEqual([]);
});
it('draw and move the numbers of a Phaser and a Flanger too, but not their mix or stages (#341)', () => {
const graph = chipPresetGraph('explosion', () => 0.999);
const pad = chipPresetGraph('phasedPad');
graph.nodes.push(byType(pad, 'SoundFxPhaser'));
byType(graph, 'SoundFxFlanger').params.mix = 0.4;
const changes = randomizedParams(graph, seeded(13));
const keysOf = (type: string) =>
changes.filter((c) => c.node === byType(graph, type).id).map((c) => c.key);
expect(keysOf('SoundFxPhaser')).toEqual(['frequency', 'rate', 'depth', 'feedback']);
expect(keysOf('SoundFxFlanger')).toEqual(['offset', 'sweep', 'rate', 'depth', 'feedback']);
apply(graph, changes);
withinRows(byType(graph, 'SoundFxPhaser'), Object.keys(PHASER_ROWS));
withinRows(byType(graph, 'SoundFxFlanger'), Object.keys(FLANGER_ROWS));
expect(byType(graph, 'SoundFxPhaser').params).toMatchObject({ stages: '6', mix: 1 });
expect(byType(graph, 'SoundFxFlanger').params.mix).toBe(0.4);
// Mutate leaves the Flanger's LFO off and moves its offset and sweep, and the Phaser's numbers.
const fresh = chipPresetGraph('explosion', () => 0.999);
fresh.nodes.push(byType(chipPresetGraph('phasedPad'), 'SoundFxPhaser'));
const moved = (type: string) =>
mutatedParams(fresh, () => 0.9)
.filter((c) => c.node === byType(fresh, type).id)
.map((c) => c.key);
expect(moved('SoundFxFlanger')).toEqual(['offset', 'sweep']);
expect(moved('SoundFxPhaser')).toEqual(['frequency', 'rate', 'depth', 'feedback']);
});
it('find nothing to change in an instrument of Samples', () => {
expect(hasChipNumbers(starterInstrumentGraph())).toBe(false);
expect(randomizedParams(starterInstrumentGraph())).toEqual([]);
expect(hasChipNumbers(chipPresetGraph('blip'))).toBe(true);
});
});