Make an 8-bit sound with a Preset, Randomize or Mutate (#329)
game-maker / gate (push) Successful in 3m25s
game-maker / suite (push) Failing after 14m27s

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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-06 23:29:39 +02:00
1 parent f46b2316f2
commit f3ae19431a
7 files changed
+906 -9

No files matched your search

+25
View File
@@ -326,6 +326,31 @@ function setCollapsed(node: GraphNode, collapsed: boolean): void {
else delete node.collapsed; else delete node.collapsed;
} }
/** Other nodes and edges in place of a graph's, as one step: an instrument's Preset (#329). Groups and notes stay. */
export class ReplaceGraphCommand implements Command {
private previous?: Pick<Graph, 'nodes' | 'edges'>;
constructor(
private readonly assetId: string,
private readonly graph: Pick<Graph, 'nodes' | 'edges'>,
readonly label: string,
) {}
execute(project: Project): void {
const graph = requireGraph(project, this.assetId);
this.previous = { nodes: graph.nodes, edges: graph.edges };
graph.nodes = structuredClone(this.graph.nodes);
graph.edges = structuredClone(this.graph.edges);
}
undo(project: Project): void {
if (!this.previous) return;
const graph = requireGraph(project, this.assetId);
graph.nodes = this.previous.nodes;
graph.edges = this.previous.edges;
}
}
export class DisconnectCommand implements Command { export class DisconnectCommand implements Command {
readonly label = 'Disconnect'; readonly label = 'Disconnect';
private removed: Removed<GraphEdge>[] = []; private removed: Removed<GraphEdge>[] = [];
+457
View File
@@ -0,0 +1,457 @@
import { edgesInto, type Graph, type GraphEdge, type GraphNode } from '../graph/Graph';
import { asNumber } from '../graph/values';
import { newId } from '../ids';
import type { PropertyBag } from '../model/Properties';
import type { FlatDef } from '../properties/PropertySchema';
import type { ChipWave } from '../soundfx/chip';
import {
CRUSH_ROWS,
FILTER_ROWS,
type FxPassFilter,
passOf,
SoundFxBitcrusher,
SoundFxFilter,
} from '../soundfx/nodes';
import {
CHIP_ROWS,
ENVELOPE_ROWS,
INSTRUMENT_CHIP,
INSTRUMENT_ENVELOPE,
INSTRUMENT_NOTES,
INSTRUMENT_OUTPUT,
} from './nodes';
import { HIGHEST_NOTE, LOWEST_NOTE } from './notes';
/**
* The Preset menu, Randomize and Mutate of the Instrument tab (#329), the way sfxr makes its sounds:
* its generators and its randomizer draw sfxr's settings, 0 to 1 as its sliders have them, which are
* then worked out into the numbers of a Chip, an Envelope, a Filter and a Bitcrusher. sfxr is
* Copyright (c) 2007 Tomas Pettersson, MIT licence.
*/
export type ChipPreset = 'pickup' | 'laser' | 'explosion' | 'powerup' | 'hit' | 'jump' | 'blip';
export const CHIP_PRESETS: readonly { value: ChipPreset; label: string }[] = [
{ value: 'pickup', label: 'Pickup' },
{ value: 'laser', label: 'Laser' },
{ value: 'explosion', label: 'Explosion' },
{ value: 'powerup', label: 'Powerup' },
{ value: 'hit', label: 'Hit' },
{ value: 'jump', label: 'Jump' },
{ value: 'blip', label: 'Blip' },
];
type Random = () => number;
/** sfxr's settings by its own names, with the wave as ours. */
interface Sfxr {
wave: ChipWave;
baseFreq: number;
freqLimit: number;
freqRamp: number;
freqDramp: number;
duty: number;
dutyRamp: number;
vibStrength: number;
vibSpeed: number;
envAttack: number;
envSustain: number;
envDecay: number;
lpfFreq: number;
lpfResonance: number;
hpfFreq: number;
repeatSpeed: number;
arpSpeed: number;
arpMod: number;
}
const reset = (): Sfxr => ({
wave: 'square',
baseFreq: 0.3,
freqLimit: 0,
freqRamp: 0,
freqDramp: 0,
duty: 0,
dutyRamp: 0,
vibStrength: 0,
vibSpeed: 0,
envAttack: 0,
envSustain: 0.3,
envDecay: 0.4,
lpfFreq: 1,
lpfResonance: 0,
hpfFreq: 0,
repeatSpeed: 0,
arpSpeed: 0,
arpMod: 0,
});
/** sfxr's waves by their number there. */
const SFXR_WAVES: readonly ChipWave[] = ['square', 'saw', 'sine', 'noise'];
/** sfxr's generators, one per button. */
function presetSfxr(preset: ChipPreset, random: Random): Sfxr {
const p = reset();
// sfxr's `frnd`: 0 up to `range`, and `rnd`: a whole number from 0 to `n`, both included.
const f = (range: number) => random() * range;
const r = (n: number) => Math.floor(random() * (n + 1));
switch (preset) {
case 'pickup':
p.baseFreq = 0.4 + f(0.5);
p.envSustain = f(0.1);
p.envDecay = 0.1 + f(0.4);
if (r(1)) {
p.arpSpeed = 0.5 + f(0.2);
p.arpMod = 0.2 + f(0.4);
}
break;
case 'laser': {
let wave = r(2);
if (wave === 2 && r(1)) wave = r(1);
p.wave = SFXR_WAVES[wave]!;
p.baseFreq = 0.5 + f(0.5);
p.freqLimit = Math.max(0.2, p.baseFreq - 0.2 - f(0.6));
p.freqRamp = -0.15 - f(0.2);
if (r(2) === 0) {
p.baseFreq = 0.3 + f(0.6);
p.freqLimit = f(0.1);
p.freqRamp = -0.35 - f(0.3);
}
if (r(1)) {
p.duty = f(0.5);
p.dutyRamp = f(0.2);
} else {
p.duty = 0.4 + f(0.5);
p.dutyRamp = -f(0.7);
}
p.envSustain = 0.1 + f(0.2);
p.envDecay = f(0.4);
if (r(1)) p.hpfFreq = f(0.3);
break;
}
case 'explosion':
p.wave = 'noise';
if (r(1)) {
p.baseFreq = 0.1 + f(0.4);
p.freqRamp = -0.1 + f(0.4);
} else {
p.baseFreq = 0.2 + f(0.7);
p.freqRamp = -0.2 - f(0.2);
}
p.baseFreq *= p.baseFreq;
if (r(4) === 0) p.freqRamp = 0;
if (r(2) === 0) p.repeatSpeed = 0.3 + f(0.5);
p.envSustain = 0.1 + f(0.3);
p.envDecay = f(0.5);
if (r(1)) {
p.vibStrength = f(0.7);
p.vibSpeed = f(0.6);
}
if (r(2) === 0) {
p.arpSpeed = 0.6 + f(0.3);
p.arpMod = 0.8 - f(1.6);
}
break;
case 'powerup':
if (r(1)) p.wave = 'saw';
else p.duty = f(0.6);
p.baseFreq = 0.2 + f(0.3);
if (r(1)) {
p.freqRamp = 0.1 + f(0.4);
p.repeatSpeed = 0.4 + f(0.4);
} else {
p.freqRamp = 0.05 + f(0.2);
if (r(1)) {
p.vibStrength = f(0.7);
p.vibSpeed = f(0.6);
}
}
p.envSustain = f(0.4);
p.envDecay = 0.1 + f(0.4);
break;
case 'hit': {
const wave = r(2);
p.wave = SFXR_WAVES[wave === 2 ? 3 : wave]!;
if (p.wave === 'square') p.duty = f(0.6);
p.baseFreq = 0.2 + f(0.6);
p.freqRamp = -0.3 - f(0.4);
p.envSustain = f(0.1);
p.envDecay = 0.1 + f(0.2);
if (r(1)) p.hpfFreq = f(0.3);
break;
}
case 'jump':
p.duty = f(0.6);
p.baseFreq = 0.3 + f(0.3);
p.freqRamp = 0.1 + f(0.2);
p.envSustain = 0.1 + f(0.3);
p.envDecay = 0.1 + f(0.2);
if (r(1)) p.hpfFreq = f(0.3);
if (r(1)) p.lpfFreq = 1 - f(0.6);
break;
case 'blip':
p.wave = SFXR_WAVES[r(1)]!;
if (p.wave === 'square') p.duty = f(0.6);
p.baseFreq = 0.2 + f(0.4);
p.envSustain = 0.1 + f(0.1);
p.envDecay = f(0.2);
p.hpfFreq = 0.1;
break;
}
return p;
}
/** sfxr's Randomize: every setting drawn, most of them leaning towards 0. */
function randomSfxr(random: Random): Sfxr {
const signed = () => random() * 2 - 1;
const p = reset();
p.baseFreq = signed() ** 2;
if (random() < 0.5) p.baseFreq = signed() ** 3 + 0.5;
p.freqRamp = signed() ** 5;
if (p.baseFreq > 0.7 && p.freqRamp > 0.2) p.freqRamp = -p.freqRamp;
if (p.baseFreq < 0.2 && p.freqRamp < -0.05) p.freqRamp = -p.freqRamp;
p.freqDramp = signed() ** 3;
p.duty = signed();
p.dutyRamp = signed() ** 3;
p.vibStrength = signed() ** 3;
p.vibSpeed = signed();
p.envAttack = signed() ** 3;
p.envSustain = signed() ** 2;
p.envDecay = signed();
if (p.envAttack + p.envSustain + p.envDecay < 0.2) {
p.envSustain += 0.2 + random() * 0.3;
p.envDecay += 0.2 + random() * 0.3;
}
p.lpfResonance = signed();
p.lpfFreq = 1 - random() ** 3;
p.hpfFreq = random() ** 5;
p.repeatSpeed = signed();
p.arpSpeed = signed();
p.arpMod = signed();
return p;
}
const SFXR_RATE = 44100;
/** sfxr counts a period in samples taken 8 times over: its frequency is `f² + 0.001` of 3528 Hz. */
const hzOf = (f: number) => 3528 * (f * f + 0.001);
/** How long sfxr waits before an arpeggio or a repeat, at a speed from 0 to 1. */
const waitOf = (speed: number) => ((1 - speed) ** 2 * 20000 + 32) / SFXR_RATE;
/** A stage of sfxr's envelope: its setting squared, in 100000 samples. */
const stageOf = (value: number) => (value * value * 100000) / SFXR_RATE;
function chipNumbers(p: Sfxr): Record<keyof typeof CHIP_ROWS, number> {
const slide = 1 - p.freqRamp ** 3 * 0.01;
const arpeggio = p.arpMod >= 0 ? 1 - p.arpMod ** 2 * 0.9 : 1 + p.arpMod ** 2 * 10;
return {
frequency: hzOf(p.baseFreq),
duty: 0.5 - p.duty * 0.5,
dutySweep: -p.dutyRamp * 0.00005 * SFXR_RATE,
slide: -12 * Math.log2(slide) * SFXR_RATE,
deltaSlide: (12 / Math.LN2) * SFXR_RATE ** 2 * p.freqDramp ** 3 * 1e-6,
minFrequency: p.freqLimit > 0 ? hzOf(p.freqLimit) : 0,
vibratoDepth: 12 * Math.log2(1 + Math.abs(p.vibStrength) * 0.5),
vibratoSpeed: (p.vibSpeed ** 2 * 0.01 * SFXR_RATE) / (2 * Math.PI),
arpeggio: p.arpSpeed === 1 || p.arpMod === 0 ? 0 : -12 * Math.log2(arpeggio),
arpeggioTime: waitOf(p.arpSpeed),
repeat: p.repeatSpeed === 0 ? 0 : waitOf(p.repeatSpeed),
};
}
/**
* sfxr holds a sound at full for its sustain, then lets it fall in a straight line; an Envelope
* falls away as a struck string does, quick at first. Three times the whole of sfxr's keeps about
* as much of it heard. Sustain 0 ends the note once it has fallen.
*/
const FALL = 3;
const RELEASE = 0.02;
function envelopeNumbers(p: Sfxr): Record<'attack' | 'decay' | 'sustain' | 'release', number> {
return {
attack: stageOf(p.envAttack),
decay: FALL * (stageOf(p.envSustain) + stageOf(p.envDecay)),
sustain: 0,
release: RELEASE,
};
}
/** Where sfxr's filters come out, run 8 times a sample: about 17.8 kHz times `f^1.5` low, 5.6 kHz times `f²` high. */
function filterNumbers(p: Sfxr, filter: FxPassFilter): Record<'cutoff' | 'resonance', number> {
if (filter === 'highpass') return { cutoff: 5615 * p.hpfFreq ** 2, resonance: 0 };
// Wide open, as far as the row goes.
if (p.lpfFreq >= 1) return { cutoff: Infinity, resonance: 0 };
return { cutoff: 17756 * Math.max(0, p.lpfFreq) ** 1.5, resonance: Math.abs(p.lpfResonance) };
}
/** A number held to its row and rounded to as many places as its step has. */
function heldTo(value: number, row: FlatDef<'number'>): number {
const places = Math.max(0, -Math.floor(Math.log10(row.step ?? 0.01)));
const inside = Math.max(row.min ?? -Infinity, Math.min(row.max ?? Infinity, value));
return Number(inside.toFixed(places)) + 0;
}
function held<K extends string>(
values: Record<K, number>,
rows: Record<NoInfer<K>, FlatDef<'number'>>,
): Record<K, number> {
const out = {} as Record<K, number>;
for (const key of Object.keys(values) as K[]) out[key] = heldTo(values[key], rows[key]);
return out;
}
/** Room between the nodes of a preset, left to right. */
const COLUMN = 240;
/**
* A preset as a whole graph (#329): every note plays one Chip, which ends by itself through an
* Envelope, a Filter and a Bitcrusher. The Chip keeps to the preset's own frequency, as sfxr does,
* so every key plays the same sound until "follow note" is turned on. Each call draws it anew.
*/
export function chipPresetGraph(preset: ChipPreset, random: Random = Math.random): Graph {
const p = presetSfxr(preset, random);
const filter: FxPassFilter = p.lpfFreq >= 1 && p.hpfFreq > 0 ? 'highpass' : 'lowpass';
const crushed = random() < 0.5;
const node = (type: string, column: number, params: PropertyBag): GraphNode => ({
id: newId(),
type,
x: column * COLUMN,
y: 0,
params,
});
const notes = node(INSTRUMENT_NOTES, 0, { from: String(LOWEST_NOTE), to: String(HIGHEST_NOTE) });
const chip = node(INSTRUMENT_CHIP, 1, {
wave: p.wave,
followNote: false,
...held(chipNumbers(p), CHIP_ROWS),
});
const envelope = node(INSTRUMENT_ENVELOPE, 2, held(envelopeNumbers(p), ENVELOPE_ROWS));
const pass = node(SoundFxFilter.type, 3, { filter, ...held(filterNumbers(p, filter), FILTER_ROWS) });
const crush = node(
SoundFxBitcrusher.type,
4,
held({ bits: crushed ? 8 : 16, rate: crushed ? 22050 : 44100, mix: 1 }, CRUSH_ROWS),
);
const output = node(INSTRUMENT_OUTPUT, 5, {});
const wire = (from: GraphNode, out: string, to: GraphNode, into: string): GraphEdge => ({
id: newId(),
from: { node: from.id, port: out },
to: { node: to.id, port: into },
});
return {
nodes: [notes, chip, envelope, pass, crush, output],
edges: [
wire(notes, 'any', chip, 'trigger'),
wire(chip, 'out', envelope, 'in'),
wire(envelope, 'out', pass, 'in'),
wire(pass, 'out', crush, 'in'),
wire(crush, 'out', output, 'in'),
],
};
}
/** One number Randomize or Mutate gives a node. */
export interface ParamChange {
node: string;
key: string;
value: number;
}
function rowsOf(type: string): Record<string, FlatDef<'number'>> | null {
switch (type) {
case INSTRUMENT_CHIP:
return CHIP_ROWS;
case INSTRUMENT_ENVELOPE:
return ENVELOPE_ROWS;
case SoundFxFilter.type:
return FILTER_ROWS;
case SoundFxBitcrusher.type:
return CRUSH_ROWS;
default:
return null;
}
}
/** Whether Randomize and Mutate find anything in a graph to change. */
export const hasChipNumbers = (graph: Graph): boolean =>
graph.nodes.some((node) => rowsOf(node.type) !== null);
/** An input an edge feeds keeps what it is fed: a number typed in there would do nothing. */
const typedIn = (graph: Graph, node: GraphNode) => (key: string) =>
edgesInto(graph, node.id, key).length === 0;
function randomNumbers(node: GraphNode, random: Random): Record<string, number> | null {
switch (node.type) {
case INSTRUMENT_CHIP:
return held(chipNumbers(randomSfxr(random)), CHIP_ROWS);
case INSTRUMENT_ENVELOPE:
return held(envelopeNumbers(randomSfxr(random)), ENVELOPE_ROWS);
case SoundFxFilter.type:
return held(filterNumbers(randomSfxr(random), passOf(node.params)), FILTER_ROWS);
case SoundFxBitcrusher.type:
return held({ bits: 4 + random() * 12, rate: 2000 * 24 ** random() }, CRUSH_ROWS);
default:
return null;
}
}
/**
* Randomize (#329): the numbers of every Chip, Envelope, Filter and Bitcrusher drawn anew, as sfxr's
* randomizer draws them. Waves, filter types, the Mix of a Bitcrusher and an Envelope's "from" and
* "to" stay, and so does everything else in the graph.
*/
export function randomizedParams(graph: Graph, random: Random = Math.random): ParamChange[] {
const changes: ParamChange[] = [];
for (const node of graph.nodes) {
const values = randomNumbers(node, random);
if (!values) continue;
const open = typedIn(graph, node);
for (const [key, value] of Object.entries(values))
if (open(key)) changes.push({ node: node.id, key, value });
}
return changes;
}
/** How far Mutate moves a number at most: a share of an octave where it is heard as a ratio, else a step and a tenth of it. */
const MUTATE: Readonly<Record<string, { octaves: number } | { by: number }>> = {
frequency: { octaves: 0.25 },
minFrequency: { octaves: 0.25 },
cutoff: { octaves: 0.25 },
rate: { octaves: 0.25 },
duty: { by: 0.025 },
dutySweep: { by: 0.1 },
slide: { by: 5 },
deltaSlide: { by: 20 },
vibratoDepth: { by: 0.2 },
vibratoSpeed: { by: 0.5 },
arpeggio: { by: 1 },
arpeggioTime: { by: 0.01 },
repeat: { by: 0.02 },
attack: { by: 0.005 },
decay: { by: 0.02 },
release: { by: 0.01 },
resonance: { by: 0.05 },
bits: { by: 0.5 },
};
/**
* Mutate (#329): as sfxr does, each number of those nodes moves a little or not at all, one in two.
* A 0 stays 0, so nothing that was off comes on, and an Envelope keeps its sustain.
*/
export function mutatedParams(graph: Graph, random: Random = Math.random): ParamChange[] {
const changes: ParamChange[] = [];
for (const node of graph.nodes) {
const rows = rowsOf(node.type);
if (!rows) continue;
const open = typedIn(graph, node);
for (const row of Object.values(rows)) {
const how = MUTATE[row.key];
if (!how || !open(row.key) || random() < 0.5) continue;
const now = asNumber(node.params[row.key] ?? row.default);
if (now === 0) continue;
const u = random() * 2 - 1;
const moved =
'octaves' in how ? now * 2 ** (u * how.octaves) : now + u * (how.by + Math.abs(now) * 0.1);
const value = heldTo(moved, row);
if (value !== now) changes.push({ node: node.id, key: row.key, value });
}
}
return changes;
}
+3 -1
View File
@@ -389,6 +389,7 @@ export function passQ(filter: FxPassFilter, resonance: number): number {
const CUTOFF = { ...numberRow('cutoff', 'Cutoff (Hz)', 20, 20000, 1), default: 1000 }; const CUTOFF = { ...numberRow('cutoff', 'Cutoff (Hz)', 20, 20000, 1), default: 1000 };
const RESONANCE = numberRow('resonance', 'Resonance', 0, 1, 0.01); const RESONANCE = numberRow('resonance', 'Resonance', 0, 1, 0.01);
export const FILTER_ROWS = { cutoff: CUTOFF, resonance: RESONANCE };
const PASS_FILTERS: { value: FxPassFilter; label: string }[] = [ const PASS_FILTERS: { value: FxPassFilter; label: string }[] = [
{ value: 'lowpass', label: 'Low-pass' }, { value: 'lowpass', label: 'Low-pass' },
{ value: 'highpass', label: 'High-pass' }, { value: 'highpass', label: 'High-pass' },
@@ -402,7 +403,7 @@ const PASS = {
options: PASS_FILTERS, options: PASS_FILTERS,
}; };
function passOf(settings: PropertyBag): FxPassFilter { export function passOf(settings: PropertyBag): FxPassFilter {
return PASS_FILTERS.find((option) => option.value === settings[PASS.key])?.value ?? 'lowpass'; return PASS_FILTERS.find((option) => option.value === settings[PASS.key])?.value ?? 'lowpass';
} }
@@ -512,6 +513,7 @@ export const SoundFxDelay: SoundFxNodeDef = {
const CRUSH_BITS = { ...numberRow('bits', 'Bits', 1, 16, 0.1), default: 8 }; const CRUSH_BITS = { ...numberRow('bits', 'Bits', 1, 16, 0.1), default: 8 };
const CRUSH_RATE = { ...numberRow('rate', 'Rate (Hz)', 100, 48000, 1), default: 8000 }; const CRUSH_RATE = { ...numberRow('rate', 'Rate (Hz)', 100, 48000, 1), default: 8000 };
const CRUSH_MIX = { ...numberRow('mix', 'Mix', 0, 1, 0.01), default: 1 }; const CRUSH_MIX = { ...numberRow('mix', 'Mix', 0, 1, 0.01), default: 1 };
export const CRUSH_ROWS = { bits: CRUSH_BITS, rate: CRUSH_RATE, mix: CRUSH_MIX };
export const SoundFxBitcrusher: SoundFxNodeDef = { export const SoundFxBitcrusher: SoundFxNodeDef = {
type: 'SoundFxBitcrusher', type: 'SoundFxBitcrusher',
+7
View File
@@ -135,6 +135,13 @@ export function graphHooks({ state, editor }: HookContext) {
}), }),
keyAt: (note: number) => editor.instrumentEditor.keyboard.keyCentre(note), keyAt: (note: number) => editor.instrumentEditor.keyboard.keyCentre(note),
stopAt: () => centreOf(editor.instrumentEditor.stopButton), stopAt: () => centreOf(editor.instrumentEditor.stopButton),
presetAt: () => centreOf(editor.instrumentEditor.presetButton),
randomizeAt: () => centreOf(editor.instrumentEditor.randomizeButton),
mutateAt: () => centreOf(editor.instrumentEditor.mutateButton),
auditionAt: () => ({
...centreOf(editor.instrumentEditor.auditionButton),
toggled: editor.instrumentEditor.auditionButton.toggled,
}),
params: () => editor.instrumentEditor.previewValues, params: () => editor.instrumentEditor.previewValues,
setParam: (key: string, value: number) => editor.instrumentEditor.setPreviewValue(key, value), setParam: (key: string, value: number) => editor.instrumentEditor.setPreviewValue(key, value),
}, },
+132 -8
View File
@@ -1,3 +1,14 @@
import { CompoundCommand } from '../../core/commands/CompoundCommand';
import { ReplaceGraphCommand, SetNodeParamCommand } from '../../core/commands/graphCommands';
import {
CHIP_PRESETS,
type ChipPreset,
chipPresetGraph,
hasChipNumbers,
mutatedParams,
type ParamChange,
randomizedParams,
} from '../../core/instrument/chipPresets';
import { import {
instrumentBuildFor, instrumentBuildFor,
instrumentCheck, instrumentCheck,
@@ -5,10 +16,21 @@ import {
instrumentSourcesOf, instrumentSourcesOf,
playedNotes, playedNotes,
} from '../../core/instrument/compile'; } from '../../core/instrument/compile';
import { noteName } from '../../core/instrument/notes'; import { MIDDLE_C, noteName } from '../../core/instrument/notes';
import type { Asset, InstrumentAsset } from '../../core/model/Asset'; import type { Asset, InstrumentAsset } from '../../core/model/Asset';
import { playNote } from '../../runtime/systems/behaviorContext'; import { playNote } from '../../runtime/systems/behaviorContext';
import { Button, HStack, Label, NumberField, Stack, Theme, VStack, type Widget } from '../../ui'; import {
Button,
Dropdown,
HStack,
Label,
Menu,
NumberField,
Stack,
Theme,
VStack,
type Widget,
} from '../../ui';
import { soundLibrary } from '../../viewport/SoundLibrary'; import { soundLibrary } from '../../viewport/SoundLibrary';
import { AssetTabEditor } from '../AssetTabEditor'; import { AssetTabEditor } from '../AssetTabEditor';
import { GraphCanvas } from '../graph/GraphCanvas'; import { GraphCanvas } from '../graph/GraphCanvas';
@@ -17,22 +39,34 @@ import { TOOLBAR_ICON_SVG } from '../panels/toolbarIcons';
import type { EditorState } from '../state'; import type { EditorState } from '../state';
import { PianoKeyboard } from './PianoKeyboard'; import { PianoKeyboard } from './PianoKeyboard';
/** Room kept for "N notes", or for the reason the graph does not compile. */ /** Room kept for "N notes", or for the reason the graph does not compile: "No sound picked" fits, a longer one is cut. */
const INFO_WIDTH = 220; const INFO_WIDTH = 140;
const PARAM_WIDTH = 48; const PARAM_WIDTH = 48;
/** Whose voices the tab's Stop silences: its own, never a running game's. */ /** Whose voices the tab's Stop silences: its own, never a running game's. */
const PREVIEW_OWNER = 'instrument-preview'; const PREVIEW_OWNER = 'instrument-preview';
/** How long a note is heard after a Preset, Randomize, Mutate or a change with Audition on: a held one is let go then. */
const AUDITION_SECONDS = 2;
/** How long a change waits to be heard with Audition on, so dragging a number plays once it rests. */
const AUDITION_DELAY_MS = 150;
/** /**
* The nodes of one instrument (#331), and below them its keys: a note is heard while a key is held, * The nodes of one instrument (#331), and below them its keys: a note is heard while a key is held,
* by the pointer or by the computer keyboard. The header holds a value for each of its parameters – * by the pointer or by the computer keyboard. The header holds a value for each of its parameters –
* the tab's own, never written into the asset – and a Stop for everything the tab still plays. * the tab's own, never written into the asset – and a Stop for everything the tab still plays. Its
* Preset menu, Randomize and Mutate make 8-bit sounds the way sfxr does (#329) and play them at once.
*/ */
export class InstrumentEditor extends AssetTabEditor<InstrumentAsset, GraphCanvas> { export class InstrumentEditor extends AssetTabEditor<InstrumentAsset, GraphCanvas> {
readonly snap = new SnapToggle(() => this.canvas); readonly snap = new SnapToggle(() => this.canvas);
/** Public for the check scripts. */ /** Public for the check scripts. */
readonly keyboard = new PianoKeyboard(); readonly keyboard = new PianoKeyboard();
readonly stopButton: Button; readonly stopButton: Button;
readonly presetButton: Dropdown;
readonly randomizeButton: Button;
readonly mutateButton: Button;
/** Whether a change to the graph is heard; off by default, as in the Pattern tab. */
readonly auditionButton: Button;
/** The note a Preset, Randomize, Mutate or Audition plays: the one last played. */
lastNote = MIDDLE_C;
private readonly octaveLabel = new Label('', { style: 'small' }); private readonly octaveLabel = new Label('', { style: 'small' });
private readonly paramBar = new HStack({ gap: Theme.gap, align: 'center' }); private readonly paramBar = new HStack({ gap: Theme.gap, align: 'center' });
private readonly paramFields = new Map<string, NumberField>(); private readonly paramFields = new Map<string, NumberField>();
@@ -42,10 +76,31 @@ export class InstrumentEditor extends AssetTabEditor<InstrumentAsset, GraphCanva
private valuesFor = ''; private valuesFor = '';
/** The voices the tab started that may still be playing – what a moved field reaches. */ /** The voices the tab started that may still be playing – what a moved field reaches. */
private voices: number[] = []; private voices: number[] = [];
/** The graph as last heard or shown, by `soundPrint`, and whose it was. */
private heard = '';
private heardFor = '';
private auditionTimer: ReturnType<typeof setTimeout> | undefined;
constructor(state: EditorState) { constructor(state: EditorState) {
super(state, { infoWidth: INFO_WIDTH }); super(state, { infoWidth: INFO_WIDTH });
this.stopButton = new Button('Stop', { icon: TOOLBAR_ICON_SVG.stop, onClick: () => this.stop() }); this.stopButton = new Button('Stop', { icon: TOOLBAR_ICON_SVG.stop, onClick: () => this.stop() });
this.presetButton = new Dropdown('Preset', {
tooltip: 'Make the instrument an 8-bit sound of that kind, drawn anew every time',
onClick: () => this.openPresetMenu(),
});
this.randomizeButton = new Button('Randomize', {
tooltip: 'Draw every number of the Chips, Envelopes, Filters and Bitcrushers anew',
onClick: () => this.randomize(),
});
this.mutateButton = new Button('Mutate', {
tooltip: 'Move some numbers of the Chips, Envelopes, Filters and Bitcrushers a little',
onClick: () => this.mutate(),
});
this.auditionButton = new Button('Audition', {
toggle: true,
tooltip: 'Play the last note again when the instrument changes',
onClick: () => this.auditionButton.setToggled(!this.auditionButton.toggled),
});
this.keyboard.onNoteOn = (note) => this.playNote(note); this.keyboard.onNoteOn = (note) => this.playNote(note);
this.keyboard.onNoteOff = (voice) => soundLibrary.release(voice); this.keyboard.onNoteOff = (voice) => soundLibrary.release(voice);
this.keyboard.isPlaying = (voice) => soundLibrary.playing(voice); this.keyboard.isPlaying = (voice) => soundLibrary.playing(voice);
@@ -54,12 +109,17 @@ export class InstrumentEditor extends AssetTabEditor<InstrumentAsset, GraphCanva
this.mount(new GraphCanvas(state, 'instrument'), [ this.mount(new GraphCanvas(state, 'instrument'), [
...this.snap.widgets, ...this.snap.widgets,
this.stopButton, this.stopButton,
this.presetButton,
this.randomizeButton,
this.mutateButton,
this.auditionButton,
this.octaveLabel, this.octaveLabel,
this.paramBar, this.paramBar,
]); ]);
this.syncOctave(); this.syncOctave();
state.on('load', () => { state.on('load', () => {
this.valuesFor = ''; this.valuesFor = '';
this.heardFor = '';
}); });
} }
@@ -89,11 +149,12 @@ export class InstrumentEditor extends AssetTabEditor<InstrumentAsset, GraphCanva
this.setValue(key, value); this.setValue(key, value);
} }
/** Starts one note as the keys do, held until it is released; gives back its voice. */ /** Starts one note as the keys do, held until it is released or for `duration` seconds; gives back its voice. */
playNote(note: number): number { playNote(note: number, duration = 0): number {
const asset = this.asset(); const asset = this.asset();
if (!asset) return 0; if (!asset) return 0;
const struck = { note, velocity: 1, volume: 1, duration: 0 }; this.lastNote = note;
const struck = { note, velocity: 1, volume: 1, duration };
const voice = playNote( const voice = playNote(
{ project: this.state.project, audio: soundLibrary }, { project: this.state.project, audio: soundLibrary },
asset.id, asset.id,
@@ -105,6 +166,47 @@ export class InstrumentEditor extends AssetTabEditor<InstrumentAsset, GraphCanva
return voice; return voice;
} }
/** Plays the note last played on its own, for a while: what Preset, Randomize, Mutate and Audition do. */
audition(): number {
clearTimeout(this.auditionTimer);
this.stop();
return this.playNote(this.lastNote, AUDITION_SECONDS);
}
applyPreset(preset: ChipPreset): void {
const asset = this.asset();
if (!asset) return;
const label = CHIP_PRESETS.find((option) => option.value === preset)?.label ?? preset;
if (!this.state.execute(new ReplaceGraphCommand(asset.id, chipPresetGraph(preset), `Preset ${label}`)))
return;
this.canvas.fit();
this.audition();
}
randomize(): void {
const asset = this.asset();
if (asset) this.change('Randomize', randomizedParams(asset.graph));
}
mutate(): void {
const asset = this.asset();
if (asset) this.change('Mutate', mutatedParams(asset.graph));
}
private change(label: string, changes: readonly ParamChange[]): void {
const asset = this.asset();
if (!asset || changes.length === 0) return;
const commands = changes.map((c) => new SetNodeParamCommand(asset.id, c.node, c.key, c.value));
if (this.state.execute(new CompoundCommand(label, commands))) this.audition();
}
private openPresetMenu(): void {
if (!this.asset()) return;
new Menu(
CHIP_PRESETS.map((preset) => ({ label: preset.label, onSelect: () => this.applyPreset(preset.value) })),
).openBelow(this.presetButton);
}
stop(): void { stop(): void {
this.keyboard.releaseAll(); this.keyboard.releaseAll();
soundLibrary.stopOwner(PREVIEW_OWNER); soundLibrary.stopOwner(PREVIEW_OWNER);
@@ -137,10 +239,26 @@ export class InstrumentEditor extends AssetTabEditor<InstrumentAsset, GraphCanva
protected override syncExtras(asset: InstrumentAsset | null): void { protected override syncExtras(asset: InstrumentAsset | null): void {
this.snap.setEnabled(Boolean(asset)); this.snap.setEnabled(Boolean(asset));
this.stopButton.setEnabled(Boolean(asset)); this.stopButton.setEnabled(Boolean(asset));
this.presetButton.setEnabled(Boolean(asset));
this.randomizeButton.setEnabled(Boolean(asset && hasChipNumbers(asset.graph)));
this.mutateButton.setEnabled(Boolean(asset && hasChipNumbers(asset.graph)));
this.auditionButton.setEnabled(Boolean(asset));
this.keyboard.setEnabled(Boolean(asset)); this.keyboard.setEnabled(Boolean(asset));
if (!asset) this.keyboard.releaseAll(); if (!asset) this.keyboard.releaseAll();
this.keyboard.setSounding(new Set(this.soundingNotes())); this.keyboard.setSounding(new Set(this.soundingNotes()));
this.syncParams(asset); this.syncParams(asset);
this.followEdits(asset);
}
/** With Audition on, a change to the graph shown is heard once it rests; another asset shown is not a change. */
private followEdits(asset: InstrumentAsset | null): void {
const print = asset ? soundPrint(asset) : '';
const changed = (asset?.id ?? '') === this.heardFor && print !== this.heard;
this.heard = print;
this.heardFor = asset?.id ?? '';
if (!changed || !this.auditionButton.toggled) return;
clearTimeout(this.auditionTimer);
this.auditionTimer = setTimeout(() => this.audition(), AUDITION_DELAY_MS);
} }
private syncOctave(): void { private syncOctave(): void {
@@ -186,3 +304,9 @@ export class InstrumentEditor extends AssetTabEditor<InstrumentAsset, GraphCanva
for (const [name, field] of this.paramFields) field.setNumber(this.values[name] ?? 0); for (const [name, field] of this.paramFields) field.setNumber(this.values[name] ?? 0);
} }
} }
/** How a note of the instrument sounds hangs on its nodes' settings and edges, not on where they lie. */
function soundPrint(asset: InstrumentAsset): string {
const { nodes, edges } = asset.graph;
return JSON.stringify([nodes.map((n) => [n.id, n.type, n.params]), edges.map((e) => [e.from, e.to])]);
}
+195
View File
@@ -0,0 +1,195 @@
/**
* 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);
});
});
+87
View File
@@ -1076,6 +1076,93 @@ export async function sound(t) {
); );
await press('ArrowLeft'); await press('ArrowLeft');
// 34g-6. Presets (#329): Preset → Laser makes the instrument one Chip through Envelope, Filter and
// Bitcrusher and plays the note last played at once – C4, typed just now. Randomize and
// Mutate play anew; a change only plays with Audition on. Three undos bring the blip back.
const insGraph = () =>
json(`(() => {
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(insId)}).graph;
const of = (type) => graph.nodes.find((n) => n.type === type)?.params ?? null;
return { types: graph.nodes.map((n) => n.type), chip: of('InstrumentChip'), envelope: of('InstrumentEnvelope') };
})()`);
const clickHook = async (hook) => {
const at = await json(`__gm.instrument.${hook}()`);
await click(at.x, at.y);
};
await evaluate('__gm.sound.stopAll()');
const beforePreset = await stats();
await clickHook('presetAt');
const presetMenu = await json('__gm.overlayTexts()');
await clickText('Laser');
const preset = await waitFor(stats, (s) => s.started > beforePreset.started);
const laser = await insGraph();
const presetInfo = await json('__gm.instrument.info()');
check(
'Preset → Laser makes Notes → Chip → Envelope → Filter → Bitcrusher → Output and plays at once',
['Pickup', 'Laser', 'Explosion', 'Powerup', 'Hit', 'Jump', 'Blip'].every((label) =>
presetMenu.includes(label),
) &&
laser.types.join() ===
'InstrumentNotes,InstrumentChip,InstrumentEnvelope,SoundFxFilter,SoundFxBitcrusher,InstrumentOutput' &&
laser.chip?.followNote === false &&
laser.chip.slide < 0 &&
laser.envelope?.sustain === 0 &&
preset.last?.file === `instrument:${insId}` &&
presetInfo === '128 notes',
JSON.stringify({ presetMenu, laser, last: preset.last, presetInfo }),
);
await shot('34-instrument-preset');
const beforeRandom = await stats();
await clickHook('randomizeAt');
const randomized = await waitFor(stats, (s) => s.started > beforeRandom.started);
const drawn = await insGraph();
await clickHook('mutateAt');
const mutated = await waitFor(stats, (s) => s.started > randomized.started);
const moved = await insGraph();
check(
'Randomize draws the numbers anew and keeps the wave, Mutate moves some; both play at once',
randomized.started > beforeRandom.started &&
drawn.chip?.wave === laser.chip.wave &&
drawn.chip.frequency !== laser.chip.frequency &&
mutated.started > randomized.started &&
JSON.stringify([moved.chip, moved.envelope]) !== JSON.stringify([drawn.chip, drawn.envelope]),
JSON.stringify({ laser: laser.chip, drawn: drawn.chip, moved: moved.chip }),
);
const setFrequency = (hz) =>
mutate(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(insId)})
.graph.nodes.find((n) => n.type === 'InstrumentChip').params.frequency = ${hz}`);
await sleep(300);
const silent = await stats();
await setFrequency(300);
await sleep(500);
const unheard = await stats();
await clickHook('auditionAt');
const auditionOn = (await json('__gm.instrument.auditionAt()')).toggled;
await setFrequency(400);
const auditioned = await waitFor(stats, (s) => s.started > unheard.started);
await clickHook('auditionAt');
check(
'a change to the instrument is heard only with Audition on',
unheard.started === silent.started && auditionOn && auditioned.started > unheard.started,
JSON.stringify({
silent: silent.started,
unheard: unheard.started,
auditionOn,
auditioned: auditioned.started,
}),
);
for (let i = 0; i < 3; i++) await t.clickToolbar('undo');
const restored = await insGraph();
check(
'three undos take back Mutate, Randomize and the preset: Notes → Sample → Output again',
restored.types.join() === 'InstrumentNotes,InstrumentSample,InstrumentOutput',
JSON.stringify(restored.types),
);
await evaluate('__gm.sound.stopAll()');
await mutate(` await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph; const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph;
graph.nodes.push(${JSON.stringify( graph.nodes.push(${JSON.stringify(