Files
feuerwerk/tests/core/chip-presets.test.ts
T
adminandClaude Opus 5.5 f3ae19431a
game-maker / gate (push) Successful in 3m25s
game-maker / suite (push) Failing after 14m27s
Make an 8-bit sound with a Preset, Randomize or Mutate (#329)
A Chip could play a sound, but finding a good one meant typing a dozen
numbers. sfxr's buttons do that in one click: a kind of sound drawn
anew every time, a wild draw, or a small nudge. The Instrument tab now
has the same, and plays the result at once.

- Preset menu with Pickup, Laser, Explosion, Powerup, Hit, Jump and
  Blip. It puts a new graph in place of the instrument's, as one undo
  step: Notes for all 128 notes, Chip, Envelope, Filter, Bitcrusher,
  Output. The Chip keeps its own frequency with follow note off, so
  every key plays the same effect, and sustain 0 ends it by itself.
- The presets are sfxr's generators, worked out into Hz, semitones and
  seconds with sfxr's own formulas, in chipPresets.ts. sfxr's envelope
  holds and falls in a line, ours falls quick at first. So the decay
  is three times sfxr's hold and fall.
- Randomize draws every number of the Chips, Envelopes, Filters and
  Bitcrushers anew, as sfxr's randomizer does. Waves, filter types,
  the Bitcrusher's mix and an Envelope's from and to stay.
- Mutate moves each of those numbers a little, one in two. A 0 stays
  0, so nothing that was off comes on.
- An input fed by an edge is left alone by both. Both are grey while
  the graph has none of those nodes.
- Audition, off by default as in the Pattern tab, plays the note last
  played again 150 ms after a change to the graph rests.
- Preset, Randomize and Mutate always play that note, for up to 2 s.
- ReplaceGraphCommand swaps nodes and edges and keeps groups and notes.
- The info on the right of the header kept 220 px and was cut off
  with the four new buttons. 140 px still fit "No sound picked".

Not done: punch, filter sweeps and the phaser of sfxr (#341).

Tests:
- Unit chip-presets (new) 7. Counter-check: a wrong Hz formula and
  fed inputs not left alone turn 3 of them red. Unit tests of the
  instrument tab, graph commands and sound FX nodes: 49 files, 851
  green.
- e2e 34 (melektaus-atx): ALL PASSED, 64 checks (4 new): Preset,
  Randomize and Mutate, Audition, three undos back to the Sample.
- Screenshot with a Laser preset: the header fits at 1280 px, the
  chain is fitted, the Chip is a saw with its duty grey.
- Gate (melektaus-atx): CHECK EXIT 0, 256 files, 3969 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 23:29:39 +02:00

196 lines
8.4 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 and a Bitcrusher, 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,
hasChipNumbers,
mutatedParams,
randomizedParams,
} 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, FILTER_ROWS, fxChainOf } 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,
};
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('is a chain from every note through Chip, Envelope, Filter and Bitcrusher, ending by itself', () => {
const sources = instrumentSourcesOf(makeProject().project);
for (const { value } of CHIP_PRESETS) {
const graph = chipPresetGraph(value, seeded());
expect(
graph.nodes.map((n) => n.type),
value,
).toEqual([
'InstrumentNotes',
'InstrumentChip',
'InstrumentEnvelope',
'SoundFxFilter',
'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',
'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('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);
});
});
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('find nothing to change in an instrument of Samples', () => {
expect(hasChipNumbers(starterInstrumentGraph())).toBe(false);
expect(randomizedParams(starterInstrumentGraph())).toEqual([]);
expect(hasChipNumbers(chipPresetGraph('blip'))).toBe(true);
});
});