Files
feuerwerk/src/core/instrument/chipPresets.ts
T
adminandClaude Opus 5.5 d4a45c127b Show a Delay node as a device with its echoes over time (#393)
After the Chip, the Envelope, the Reverb and the one-knob devices, the
Delay is drawn as a device of its own size too. Its screen shows what
time, feedback, damping, wet and ping-pong make of a sound, and a light
runs over the echoes at the beat they come at.

- DelayPanel: a 680x280 plate in the Compressor's brushed aluminium,
  now exported as ALUMINIUM. A screen, levers for Sync and Ping-pong,
  knobs for Time, Feedback, Damping and Wet, and an Echo lamp.
- The screen draws the sound as a white bar and each echo as a bar one
  step later. A bar is as tall as the echo is loud, evenly in dB to
  -60. It turns from warm white to rust as the damping takes the highs.
  With ping-pong the bars alternate above and below, the first left.
- At most 24 bars are drawn, and the screen says how many more are
  heard. Under it stand the step and the end of the tail, above it
  where the highs of the first echo end.
- The running light: DelayFace.tick returns true while the node is in
  the picture, so the editor keeps drawing, as for the Particles tab.
  The light goes round the span in real time, a bar glows as it is
  passed, and the lamp blinks with every echo but not with the sound.
- On beats the screen counts at 120 BPM, as the tempo of the voice is
  not known in the graph. The time knob then names the note, dotted
  and triplet ones included. In seconds, 2 s and up reads "2 s max".
- delayView holds the rules apart from the drawing: layout, tapers,
  echoTrain, echoCutoff for k rounds of the Butterworth low-pass,
  sweepAt and the texts. At wet 0 the rest is drawn faint.
- delayEchoes in the core counts the echoes heard. The tail of a voice
  and the screen use it both. DELAY_ROWS, DELAY_SYNC and PING_PONG are
  exported.
- Knob.reformat writes the number anew when its format changes, here
  when Sync switches between seconds and beats.
- The test hook __gm.delay.panel returns knobs, levers, idle settings,
  the echoes drawn, the texts and where the light is.
- Instrument presets give the Delay 4 columns. Echo Lead and Bell use
  one. In the picture of Echo Lead nothing overlaps.

Found on the way: a node with a face draws no setting rows. The e2e
check of a bypassed Delay (#354) read "Bypass: true" from them. It now
checks the faded node and the stages heard, as before.

Tests:
- Unit delay-view: 17 tests, new. Counter-check: the first ping-pong
  echo on the right turns the side test red.
- e2e 34 (melektaus-atx): 136 checks, 1 new.
- Gate (melektaus-atx): 288 files, 4416 tests.
- Pictures looked at: default, ping-pong, beats with damping, 83
  echoes at 50 ms, wet 0, and the preset Echo Lead.

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

869 lines
30 KiB
TypeScript

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,
FLAT_Q,
type FxPassFilter,
passOf,
SoundFxBitcrusher,
FLANGER_ROWS,
PHASER_ROWS,
SoundFxDelay,
SoundFxFilter,
SoundFxFlanger,
SoundFxMix,
SoundFxPhaser,
SoundFxPitch,
} from '../soundfx/nodes';
import {
CHIP_ROWS,
ENVELOPE_ROWS,
INSTRUMENT_CHIP,
INSTRUMENT_ENVELOPE,
INSTRUMENT_NOTES,
INSTRUMENT_OUTPUT,
noteNameKey,
noteOutputKey,
} 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, a Bitcrusher and a Flanger (#341),
* which sfxr calls its phaser. sfxr is
* Copyright (c) 2007 Tomas Pettersson, MIT licence.
*
* Next to those sounds the menu has instruments to play tunes on: Chips that follow the note and hold
* it while it is held, the same every time they are picked.
*/
export type EffectPreset = 'pickup' | 'laser' | 'explosion' | 'powerup' | 'hit' | 'jump' | 'blip';
export type TonePreset =
| 'squareLead'
| 'pulseLead'
| 'echoLead'
| 'octaveArp'
| 'brass'
| 'detunedSaws'
| 'stringPad'
| 'phasedPad'
| 'organ'
| 'flute'
| 'bell'
| 'pluck'
| 'triangleBass'
| 'pluckBass'
| 'drumKit';
export type ChipPreset = EffectPreset | TonePreset;
export const TONE_PRESETS: readonly { value: TonePreset; label: string }[] = [
{ value: 'squareLead', label: 'Square Lead' },
{ value: 'pulseLead', label: 'Pulse Lead' },
{ value: 'echoLead', label: 'Echo Lead' },
{ value: 'octaveArp', label: 'Octave Arp' },
{ value: 'brass', label: 'Synth Brass' },
{ value: 'detunedSaws', label: 'Detuned Saws' },
{ value: 'stringPad', label: 'String Pad' },
{ value: 'phasedPad', label: 'Phased Pad' },
{ value: 'organ', label: 'Organ' },
{ value: 'flute', label: 'Flute' },
{ value: 'bell', label: 'Bell' },
{ value: 'pluck', label: 'Pluck' },
{ value: 'triangleBass', label: 'Triangle Bass' },
{ value: 'pluckBass', label: 'Pluck Bass' },
{ value: 'drumKit', label: 'Drum Kit' },
];
export const EFFECT_PRESETS: readonly { value: EffectPreset; 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' },
];
/** Every preset, the instruments first, as the menu has them. */
export const CHIP_PRESETS: readonly { value: ChipPreset; label: string }[] = [
...TONE_PRESETS,
...EFFECT_PRESETS,
];
const isTone = (preset: ChipPreset): preset is TonePreset =>
TONE_PRESETS.some((option) => option.value === preset);
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;
phaOffset: number;
phaRamp: 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,
phaOffset: 0,
phaRamp: 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: EffectPreset, 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.phaOffset = -0.3 + f(0.9);
p.phaRamp = -f(0.3);
}
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();
// Drawn last, not where sfxr has them, so the numbers before stay as they were.
p.phaOffset = signed() ** 3;
p.phaRamp = signed() ** 3;
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,
};
}
/**
* The Q a Filter had at a resonance from 0 to 1 before it took a Q (#318): from no bump up to a
* 20 dB ring for a low- or high-pass, from about two octaves wide to a narrow ring for a band-pass.
*/
function resonanceQ(filter: FxPassFilter, resonance: number): number {
const r = Math.max(0, Math.min(1, resonance));
if (filter === 'bandpass') return 0.7 * (20 / 0.7) ** r;
return FLAT_Q * (10 / FLAT_Q) ** r;
}
/** 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' | 'q', number> {
if (filter === 'highpass') return { cutoff: 5615 * p.hpfFreq ** 2, q: FLAT_Q };
// Wide open, as far as the row goes.
if (p.lpfFreq >= 1) return { cutoff: Infinity, q: FLAT_Q };
return { cutoff: 17756 * Math.max(0, p.lpfFreq) ** 1.5, q: resonanceQ(filter, Math.abs(p.lpfResonance)) };
}
/**
* sfxr's phaser as a Flanger: its delay is `offset²` of 1020 samples taken 8 times over, and moves
* `ramp²` of one of them every sample. Mirrored at 0 as it is, a sweep towards 0 is one with the
* other sign than the offset.
*/
function flangerNumbers(p: Sfxr): Record<'offset' | 'sweep', number> {
const taken = 8 * SFXR_RATE;
const away = Math.sign(p.phaOffset || 1) * Math.sign(p.phaRamp);
return {
offset: ((p.phaOffset ** 2 * 1020) / taken) * 1000,
sweep: (away * p.phaRamp ** 2 * SFXR_RATE * 1000) / taken,
};
}
/** 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 and top to bottom: a Chip is a tall node. */
const COLUMN = 240;
const ROW = 340;
/**
* Columns a node takes that is drawn as its editor in the graph (#385): a Filter's curve is wide,
* a Chip's device (#386) wider, an Envelope's (#388) a little less, a Pitch's and a Mix's (#390) two,
* a Delay's (#393) four.
*/
const COLUMNS_OF: Readonly<Record<string, number>> = {
[SoundFxFilter.type]: 4,
[SoundFxDelay.type]: 4,
[SoundFxPitch.type]: 2,
[SoundFxMix.type]: 2,
[INSTRUMENT_CHIP]: 5,
[INSTRUMENT_ENVELOPE]: 3,
};
/** The column right of a node, past all it takes. */
const columnAfter = (node: GraphNode): number => node.x / COLUMN + (COLUMNS_OF[node.type] ?? 1);
/** A node to make: its type and its params. */
type Step = readonly [type: string, params: PropertyBag];
/** A graph laid out on a grid while it is made. */
class Sketch {
readonly nodes: GraphNode[] = [];
readonly edges: GraphEdge[] = [];
add(type: string, column: number, row: number, params: PropertyBag): GraphNode {
const node: GraphNode = { id: newId(), type, x: column * COLUMN, y: row * ROW, params };
this.nodes.push(node);
return node;
}
wire(from: GraphNode, out: string, to: GraphNode, into: string): void {
this.edges.push({ id: newId(), from: { node: from.id, port: out }, to: { node: to.id, port: into } });
}
/** Nodes one after another to the right of `from`, each taking the sound of the one before. */
line(from: GraphNode, ...steps: readonly Step[]): GraphNode {
let last = from;
for (const [type, params] of steps) {
const next = this.add(type, columnAfter(last), last.y / ROW, params);
this.wire(last, 'out', next, 'in');
last = next;
}
return last;
}
/** The Output, right of what it plays. */
end(last: GraphNode): Graph {
this.line(last, [INSTRUMENT_OUTPUT, {}]);
return { nodes: this.nodes, edges: this.edges };
}
}
const everyNote: PropertyBag = { from: String(LOWEST_NOTE), to: String(HIGHEST_NOTE) };
/**
* An effect preset as a whole graph (#329): every note plays one Chip, which ends by itself through
* an Envelope, a Filter, sfxr's phaser where it has one, 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.
*/
function effectGraph(preset: EffectPreset, random: Random): Graph {
const p = presetSfxr(preset, random);
const filter: FxPassFilter = p.lpfFreq >= 1 && p.hpfFreq > 0 ? 'highpass' : 'lowpass';
const crushed = random() < 0.5;
const s = new Sketch();
const notes = s.add(INSTRUMENT_NOTES, 0, 0, everyNote);
const chip = s.add(INSTRUMENT_CHIP, 1, 0, {
wave: p.wave,
followNote: false,
...held(chipNumbers(p), CHIP_ROWS),
});
s.wire(notes, 'any', chip, 'trigger');
const flanged: Step[] =
p.phaOffset !== 0 || p.phaRamp !== 0
? [
[
SoundFxFlanger.type,
held({ ...flangerNumbers(p), rate: 0, depth: 0, feedback: 0, mix: 1 }, FLANGER_ROWS),
],
]
: [];
return s.end(
s.line(
chip,
[INSTRUMENT_ENVELOPE, held(envelopeNumbers(p), ENVELOPE_ROWS)],
[SoundFxFilter.type, { filter, ...held(filterNumbers(p, filter), FILTER_ROWS) }],
...flanged,
[
SoundFxBitcrusher.type,
held({ bits: crushed ? 8 : 16, rate: crushed ? 22050 : 44100, mix: 1 }, CRUSH_ROWS),
],
),
);
}
type ChipNumbers = Partial<Record<keyof typeof CHIP_ROWS, number>>;
/** A Chip, triggered by `out` of the Notes: at the pitch of the note, unless it has a frequency of its own. */
function chipOn(
s: Sketch,
notes: GraphNode,
row: number,
wave: ChipWave,
numbers: ChipNumbers = {},
{ out = 'any', column = 1 } = {},
): GraphNode {
const defaults = Object.fromEntries(Object.values(CHIP_ROWS).map((r) => [r.key, r.default])) as Record<
keyof typeof CHIP_ROWS,
number
>;
const chip = s.add(INSTRUMENT_CHIP, column, row, {
wave,
followNote: numbers.frequency === undefined,
...held({ ...defaults, ...numbers }, CHIP_ROWS),
});
s.wire(notes, out, chip, 'trigger');
return chip;
}
const envelope = (attack: number, decay: number, sustain: number, release: number) =>
[INSTRUMENT_ENVELOPE, held({ attack, decay, sustain, release }, ENVELOPE_ROWS)] as const;
const pass = (filter: FxPassFilter, cutoff: number, resonance = 0) =>
[SoundFxFilter.type, { filter, cutoff, q: heldTo(resonanceQ(filter, resonance), FILTER_ROWS.q) }] as const;
const pitch = (semitones: number) => [SoundFxPitch.type, { semitones }] as const;
const phaser = (stages: number, frequency: number, rate: number, depth: number, feedback: number) =>
[SoundFxPhaser.type, { stages: String(stages), frequency, rate, depth, feedback, mix: 1 }] as const;
/** Below 0.5 a Delay's wet keeps the sound in full and has the echoes at twice it (#354). */
const echo = (time: number, feedback: number, wet: number, pingPong = false) =>
[SoundFxDelay.type, { time, sync: 'beats', feedback, damping: 0.4, mix: wet, pingPong }] as const;
/** Two ways into one Mix, a column right of the further one; `b` weighs `share`. */
function mixed(s: Sketch, a: GraphNode, b: GraphNode, share: number, curve = 'crossfade'): GraphNode {
const mix = s.add(SoundFxMix.type, Math.max(columnAfter(a), columnAfter(b)), Math.min(a.y, b.y) / ROW, {
curve,
mix: share,
});
s.wire(a, 'out', mix, 'a');
s.wire(b, 'out', mix, 'b');
return mix;
}
/**
* A low-pass whose cutoff an Envelope of its own sweeps, from `from` Hz at rest up to `to` and down
* to the sustain between them, as a synth's filter envelope does. The Envelope sits under the Filter.
*/
function swept(
s: Sketch,
from: GraphNode,
resonance: number,
sweep: { from: number; to: number; attack: number; decay: number; sustain: number; release: number },
): GraphNode {
const filter = s.line(from, pass('lowpass', sweep.from, resonance));
const { from: low, to: high, ...curve } = sweep;
const moves = s.add(INSTRUMENT_ENVELOPE, filter.x / COLUMN, filter.y / ROW + 1, {
...held(curve, ENVELOPE_ROWS),
from: low,
to: high,
});
s.wire(moves, 'value', filter, 'cutoff');
return filter;
}
/** Two saws a tenth of a semitone apart, mixed evenly: a fuller sound than one alone. */
function detuned(s: Sketch, notes: GraphNode, numbers: ChipNumbers = {}): GraphNode {
const a = chipOn(s, notes, 0, 'saw', numbers);
const b = s.line(chipOn(s, notes, 1, 'saw', numbers), pitch(0.1));
return mixed(s, a, b, 0.5);
}
/** The drums of the kit, on the keys General MIDI has them on. */
const KICK = 36;
const SNARE = 38;
const CLOSED_HAT = 42;
const OPEN_HAT = 46;
/**
* A kit on single notes: a kick that drops in pitch, a snare of a falling triangle and noise, and two
* hi-hats of bright noise, short and long. Each ends by itself; only one of them plays on a key, so
* the Mixes that join them pass it on whole.
*/
function drumKit(s: Sketch): GraphNode {
const notes = s.add(INSTRUMENT_NOTES, 0, 0, {
from: String(KICK),
to: String(OPEN_HAT),
singleNotes: true,
[noteNameKey(KICK)]: 'Kick',
[noteNameKey(SNARE)]: 'Snare',
[noteNameKey(CLOSED_HAT)]: 'Closed hat',
[noteNameKey(OPEN_HAT)]: 'Open hat',
});
const on = (note: number) => ({ out: noteOutputKey(note) });
const kick = s.line(
chipOn(s, notes, 0, 'sine', { frequency: 160, slide: -120, minFrequency: 30 }, on(KICK)),
envelope(0.001, 0.3, 0, 0.02),
);
const body = s.line(
chipOn(s, notes, 1, 'triangle', { frequency: 190, slide: -60 }, on(SNARE)),
envelope(0.001, 0.1, 0, 0.02),
);
const rattle = s.line(
chipOn(s, notes, 2, 'noise', { frequency: 2500 }, on(SNARE)),
pass('highpass', 1200),
envelope(0.001, 0.18, 0, 0.02),
);
const closed = s.line(
chipOn(s, notes, 3, 'noise', { frequency: 4000 }, on(CLOSED_HAT)),
pass('highpass', 7000, 0.1),
envelope(0.001, 0.05, 0, 0.01),
);
const open = s.line(
chipOn(s, notes, 4, 'noise', { frequency: 4000 }, on(OPEN_HAT)),
pass('highpass', 6000, 0.1),
envelope(0.001, 0.35, 0, 0.05),
);
const snare = mixed(s, body, rattle, 0.6);
const hats = mixed(s, closed, open, 0.5);
return mixed(s, mixed(s, kick, snare, 0.5), hats, 0.5);
}
/** What each instrument is made of, up to what the Output plays. */
const TONES: Record<Exclude<TonePreset, 'drumKit'>, (s: Sketch, notes: GraphNode) => GraphNode> = {
// The plain square of the NES with a slight vibrato, its harshest top taken off.
squareLead: (s, notes) =>
s.line(
chipOn(s, notes, 0, 'square', { vibratoDepth: 0.2, vibratoSpeed: 5.5 }),
envelope(0.005, 0.2, 0.6, 0.15),
pass('lowpass', 6000),
),
// The thin, nasal eighth of a square, as near as the duty's steps of 0.01 come.
pulseLead: (s, notes) =>
s.line(
chipOn(s, notes, 0, 'square', { duty: 0.13, vibratoDepth: 0.1, vibratoSpeed: 6 }),
envelope(0.002, 0.1, 0.8, 0.08),
),
// A quarter square with dotted-eighth echoes, left and right.
echoLead: (s, notes) =>
s.line(
chipOn(s, notes, 0, 'square', { duty: 0.25 }),
envelope(0.005, 0.15, 0.5, 0.1),
echo(0.75, 0.35, 0.175, true),
),
// The note and its octave by turns, 25 times a second: how a console played a chord.
octaveArp: (s, notes) =>
s.line(
chipOn(s, notes, 0, 'square', { duty: 0.25, arpeggio: 12, arpeggioTime: 0.04, repeat: 0.08 }),
envelope(0.002, 0.1, 0.7, 0.08),
),
// A saw that swells and opens up as it is blown.
brass: (s, notes) =>
swept(
s,
s.line(
chipOn(s, notes, 0, 'saw', { vibratoDepth: 0.1, vibratoSpeed: 5 }),
envelope(0.04, 0.2, 0.8, 0.2),
),
0.15,
{ from: 400, to: 3500, attack: 0.08, decay: 0.4, sustain: 0.5, release: 0.2 },
),
detunedSaws: (s, notes) => s.line(detuned(s, notes), envelope(0.01, 0.3, 0.8, 0.3), pass('lowpass', 7000)),
// Slow to come and to go, darker, with a little vibrato.
stringPad: (s, notes) =>
s.line(
detuned(s, notes, { vibratoDepth: 0.1, vibratoSpeed: 5 }),
envelope(0.5, 0.5, 0.8, 1),
pass('lowpass', 2200, 0.1),
),
// A saw pad, slow to come, swirled by a phaser of six stages.
phasedPad: (s, notes) =>
s.line(
chipOn(s, notes, 0, 'saw', { vibratoDepth: 0.1, vibratoSpeed: 5 }),
envelope(0.3, 0.5, 0.8, 0.8),
pass('lowpass', 3000, 0.1),
phaser(6, 600, 0.3, 2.5, 0.5),
),
// A square and the square an octave up, held at full while the key is.
organ: (s, notes) => {
const low = chipOn(s, notes, 0, 'square');
const high = s.line(chipOn(s, notes, 1, 'square'), pitch(12));
return s.line(mixed(s, low, high, 0.35), envelope(0.005, 0.05, 1, 0.06), pass('lowpass', 4500));
},
// A triangle with vibrato and a breath of noise around 2.5 kHz.
flute: (s, notes) => {
const tone = chipOn(s, notes, 0, 'triangle', { vibratoDepth: 0.2, vibratoSpeed: 5 });
const breath = s.line(chipOn(s, notes, 1, 'noise', { frequency: 2000 }), pass('bandpass', 2500, 0.3));
return s.line(mixed(s, tone, breath, 0.08), envelope(0.06, 0.1, 0.85, 0.15));
},
// Two sines 2.76 times apart, as a bell's partials are, the upper fading first; echoes after.
bell: (s, notes) => {
const low = s.line(chipOn(s, notes, 0, 'sine'), envelope(0.001, 2, 0, 0.4));
const high = s.line(chipOn(s, notes, 1, 'sine'), pitch(17.6), envelope(0.001, 0.6, 0, 0.2));
return s.line(mixed(s, low, high, 0.35), echo(0.5, 0.3, 0.125));
},
// Struck, bright at once and dull soon after, gone in a moment however long it is held.
pluck: (s, notes) =>
swept(s, s.line(chipOn(s, notes, 0, 'square', { duty: 0.3 }), envelope(0.001, 0.6, 0, 0.1)), 0.2, {
from: 600,
to: 6000,
attack: 0.001,
decay: 0.15,
sustain: 0,
release: 0.1,
}),
// The triangle of the NES as its bass: no fade while it is held.
triangleBass: (s, notes) => s.line(chipOn(s, notes, 0, 'triangle'), envelope(0.002, 0.05, 1, 0.04)),
// A saw whose filter snaps open and shut on every note.
pluckBass: (s, notes) =>
swept(s, s.line(chipOn(s, notes, 0, 'saw'), envelope(0.002, 0.4, 0.5, 0.08)), 0.35, {
from: 180,
to: 2400,
attack: 0.002,
decay: 0.25,
sustain: 0.15,
release: 0.08,
}),
};
/** An instrument preset: every note at its own pitch, the same graph each time it is picked. */
function toneGraph(preset: TonePreset): Graph {
const s = new Sketch();
if (preset === 'drumKit') return s.end(drumKit(s));
return s.end(TONES[preset](s, s.add(INSTRUMENT_NOTES, 0, 0, everyNote)));
}
/**
* A preset as a whole graph (#329): an sfxr sound, drawn anew on every call, or an instrument to
* play tunes on, which comes out the same every time.
*/
export function chipPresetGraph(preset: ChipPreset, random: Random = Math.random): Graph {
return isTone(preset) ? toneGraph(preset) : effectGraph(preset, random);
}
/** 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;
case SoundFxPhaser.type:
return PHASER_ROWS;
case SoundFxFlanger.type:
return FLANGER_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);
case SoundFxPhaser.type:
return held(
{
frequency: 200 * 20 ** random(),
rate: 0.05 * 100 ** random(),
depth: 0.5 + random() * 3.5,
feedback: random() * 0.8,
},
PHASER_ROWS,
);
case SoundFxFlanger.type:
return held(
{
...flangerNumbers(randomSfxr(random)),
rate: 0.05 * 100 ** random(),
depth: random() * 4,
feedback: random() * 0.8,
},
FLANGER_ROWS,
);
default:
return null;
}
}
/**
* Randomize (#329): the numbers of every Chip, Envelope, Filter, Bitcrusher, Phaser and Flanger
* (#341) drawn anew, as sfxr's randomizer draws them; a Phaser's, and a Flanger's LFO, which sfxr
* has not, within bounds of their own. Waves, filter types, the stages of a Phaser, every Mix 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 },
q: { octaves: 0.25 },
gain: { by: 1 },
bits: { by: 0.5 },
offset: { by: 0.1 },
sweep: { by: 2 },
depth: { by: 0.2 },
feedback: { by: 0.05 },
};
/**
* 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;
}