Files
feuerwerk/tests/core/chip-presets.test.ts
T
adminandClaude Opus 5.5 f92d69d902
game-maker / gate (push) Successful in 4m23s
game-maker / suite (push) Failing after 18m20s
Rebuild the Acid node after the acid voice of nodeseq (#421)
The first Acid hardly squelched. Its tanh ate the resonance, and its
sweep fell evenly in octaves. The user pointed at the acid tone of
nodeseq.com/studio, so the node now follows how that voice is built
from Web Audio parts.

- The filter is a two-pole 12 dB low-pass with Q in dB up to 17.5,
  like a BiquadFilterNode. The ladder and its tanh are gone.
- One ADSR drives both the volume and the filter. The sweep falls
  back in Hz, fast from the top and long at the bottom.
- Every note glides from the one before. A new note cuts the last
  one off, and the node plays its release itself.
- Knobs: cutoff, reso, env, attack, decay, sustain, release and
  glide. The defaults are the acid preset of nodeseq.
- Accent, slide, drive and tuning are dropped. Old projects were not
  kept, the user had only placed the node for a try.
- A hook __gm.acid.compare renders a line offline through our
  worklet and through the nodeseq graph, for e2e 34.

Found on the way: in Chrome an oscillator that starts inside a
render block runs its first samples at 440 Hz. The comparison evens
this out.

Tests:
- tsc, eslint, Prettier clean
- Unit: acid.test.ts rewritten with 12 tests. chip-presets and
  sound-library follow the new node.
- e2e 34 on melektaus-atx: ALL PASSED, 148 checks. Against nodeseq
  the loudness per 10 ms window is at most 0.14 dB apart, and 95 to
  97 % of the windows are equally bright.
- With a linear Q instead of Q in dB the comparison is red.
- Gate on melektaus-atx: green, 306 files, 4752 tests. That is
  4754 less the 14 old and plus the 12 new Acid tests.
- Not checked: the sound by ear, the master chain of nodeseq, the
  keyboard and a bake with the new node.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 12:39:01 +02:00

374 lines
17 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(16);
});
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',
// The Filter is tapped for its spectrum (#385).
'tap',
'pass',
'tap',
...(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, q: 0.707 });
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', () => {
// The Acid Bass plays no Chip (#421): it has a check of its own below.
for (const { value } of TONE_PRESETS.filter((p) => p.value !== 'drumKit' && p.value !== 'acidBass')) {
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,
});
// Acid Bass (#421): an Acid at the pitch of the note, into a Delay.
const acid = [60, 72].map((note) => {
const built = compileInstrument(chipPresetGraph('acidBass'), note, sources());
expect(built.problems).toEqual({});
const chain = fxChainOf(built.layers[0]!);
return 'mix' in chain ? null : chain;
});
expect(acid[1]!.acid!.frequency / acid[0]!.acid!.frequency).toBeCloseTo(2, 6);
expect(acid[0]!.acid).toMatchObject({ wave: 'saw', cutoff: 250, resonance: 0.78 });
expect(acid[0]!.stages.map((stage) => stage.kind)).toEqual(['delay']);
// 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);
});
});