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>
This commit is contained in:
1 parent
f46b2316f2
commit
f3ae19431a
7 files changed
+906
-9
No files matched your search
@@ -326,6 +326,31 @@ function setCollapsed(node: GraphNode, collapsed: boolean): void {
|
||||
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 {
|
||||
readonly label = 'Disconnect';
|
||||
private removed: Removed<GraphEdge>[] = [];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -389,6 +389,7 @@ export function passQ(filter: FxPassFilter, resonance: number): number {
|
||||
|
||||
const CUTOFF = { ...numberRow('cutoff', 'Cutoff (Hz)', 20, 20000, 1), default: 1000 };
|
||||
const RESONANCE = numberRow('resonance', 'Resonance', 0, 1, 0.01);
|
||||
export const FILTER_ROWS = { cutoff: CUTOFF, resonance: RESONANCE };
|
||||
const PASS_FILTERS: { value: FxPassFilter; label: string }[] = [
|
||||
{ value: 'lowpass', label: 'Low-pass' },
|
||||
{ value: 'highpass', label: 'High-pass' },
|
||||
@@ -402,7 +403,7 @@ const PASS = {
|
||||
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';
|
||||
}
|
||||
|
||||
@@ -512,6 +513,7 @@ export const SoundFxDelay: SoundFxNodeDef = {
|
||||
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_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 = {
|
||||
type: 'SoundFxBitcrusher',
|
||||
|
||||
@@ -135,6 +135,13 @@ export function graphHooks({ state, editor }: HookContext) {
|
||||
}),
|
||||
keyAt: (note: number) => editor.instrumentEditor.keyboard.keyCentre(note),
|
||||
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,
|
||||
setParam: (key: string, value: number) => editor.instrumentEditor.setPreviewValue(key, value),
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
instrumentBuildFor,
|
||||
instrumentCheck,
|
||||
@@ -5,10 +16,21 @@ import {
|
||||
instrumentSourcesOf,
|
||||
playedNotes,
|
||||
} 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 { 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 { AssetTabEditor } from '../AssetTabEditor';
|
||||
import { GraphCanvas } from '../graph/GraphCanvas';
|
||||
@@ -17,22 +39,34 @@ import { TOOLBAR_ICON_SVG } from '../panels/toolbarIcons';
|
||||
import type { EditorState } from '../state';
|
||||
import { PianoKeyboard } from './PianoKeyboard';
|
||||
|
||||
/** Room kept for "N notes", or for the reason the graph does not compile. */
|
||||
const INFO_WIDTH = 220;
|
||||
/** 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 = 140;
|
||||
const PARAM_WIDTH = 48;
|
||||
/** Whose voices the tab's Stop silences: its own, never a running game's. */
|
||||
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,
|
||||
* 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> {
|
||||
readonly snap = new SnapToggle(() => this.canvas);
|
||||
/** Public for the check scripts. */
|
||||
readonly keyboard = new PianoKeyboard();
|
||||
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 paramBar = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
private readonly paramFields = new Map<string, NumberField>();
|
||||
@@ -42,10 +76,31 @@ export class InstrumentEditor extends AssetTabEditor<InstrumentAsset, GraphCanva
|
||||
private valuesFor = '';
|
||||
/** The voices the tab started that may still be playing – what a moved field reaches. */
|
||||
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) {
|
||||
super(state, { infoWidth: INFO_WIDTH });
|
||||
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.onNoteOff = (voice) => soundLibrary.release(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.snap.widgets,
|
||||
this.stopButton,
|
||||
this.presetButton,
|
||||
this.randomizeButton,
|
||||
this.mutateButton,
|
||||
this.auditionButton,
|
||||
this.octaveLabel,
|
||||
this.paramBar,
|
||||
]);
|
||||
this.syncOctave();
|
||||
state.on('load', () => {
|
||||
this.valuesFor = '';
|
||||
this.heardFor = '';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -89,11 +149,12 @@ export class InstrumentEditor extends AssetTabEditor<InstrumentAsset, GraphCanva
|
||||
this.setValue(key, value);
|
||||
}
|
||||
|
||||
/** Starts one note as the keys do, held until it is released; gives back its voice. */
|
||||
playNote(note: number): number {
|
||||
/** Starts one note as the keys do, held until it is released or for `duration` seconds; gives back its voice. */
|
||||
playNote(note: number, duration = 0): number {
|
||||
const asset = this.asset();
|
||||
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(
|
||||
{ project: this.state.project, audio: soundLibrary },
|
||||
asset.id,
|
||||
@@ -105,6 +166,47 @@ export class InstrumentEditor extends AssetTabEditor<InstrumentAsset, GraphCanva
|
||||
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 {
|
||||
this.keyboard.releaseAll();
|
||||
soundLibrary.stopOwner(PREVIEW_OWNER);
|
||||
@@ -137,10 +239,26 @@ export class InstrumentEditor extends AssetTabEditor<InstrumentAsset, GraphCanva
|
||||
protected override syncExtras(asset: InstrumentAsset | null): void {
|
||||
this.snap.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));
|
||||
if (!asset) this.keyboard.releaseAll();
|
||||
this.keyboard.setSounding(new Set(this.soundingNotes()));
|
||||
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 {
|
||||
@@ -186,3 +304,9 @@ export class InstrumentEditor extends AssetTabEditor<InstrumentAsset, GraphCanva
|
||||
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])]);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -1076,6 +1076,93 @@ export async function sound(t) {
|
||||
);
|
||||
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(`
|
||||
const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph;
|
||||
graph.nodes.push(${JSON.stringify(
|
||||
|
||||
Reference in new issue
Block a user