Files
feuerwerk/src/core/instrument/chipPresets.ts
T
adminandClaude Opus 5.5 e1ca6a5f5a Shape a sound with every filter type, a Q and a slope (#318)
The Filter node from #335 only knew low-pass, high-pass and band-pass
and had a Resonance from 0 to 1. It now has every type Web Audio
offers, a Q as a plain factor and a slope up to 48 dB per octave.

- Types: low-pass, high-pass, band-pass, notch, low shelf, high shelf,
  peaking and all-pass. The cutoff row is now labelled Frequency, its
  key stays `cutoff`.
- Q from 0.1 to 30, 0.707 by default, replaces Resonance. Web Audio
  takes the Q of a low- or high-pass in dB, so it is converted when the
  voice is wired. No project used the node.
- Gain in dB is an input only for the shelves and the peak. It is a
  derived port, so the sound FX compiler now resolves derived ports.
- Slope 12, 24 or 48 dB/oct runs 1, 2 or 4 filters in a row. They
  share one cutoff carried by a ConstantSource, only the last has the
  Q. Shelves, peak and all-pass hide the row.
- The Chip Presets get the Q their resonance gave before. Mutate moves
  Q in octaves and gain by up to a dB.
- bindStage in the instrument compiler binds every amount of a stage
  generically, so new stages need no case of their own there.

Finding: FLAT_Q was in dB and the delay damping used it. It is a
factor now, the damping takes it through passQ and is unchanged.

Tests:
- Unit envelope 13, sound-library 96 (2 new), chip-presets 14,
  soundfx 45, all green.
- Counter-checks: the Q on every filter in a row, the gain row for
  every type. Each turns one test red.
- e2e 34 (melektaus-atx): ALL PASSED, 76 checks (1 new). Chrome
  offline, 2 kHz through a low-pass at 1 kHz: -12.37 dB at 12 dB/oct,
  -49.5 dB at 48. A +6 dB peak gives 6.00 dB, a notch -124 dB.
- The gate runs once on the last commit of this series.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:43:49 +02:00

848 lines
29 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;
/** 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, last.x / COLUMN + 1, 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;
const echo = (time: number, feedback: number, mix: number, pingPong = false) =>
[SoundFxDelay.type, { time, sync: 'beats', feedback, damping: 0.4, mix, 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(a.x, b.x) / COLUMN + 1, 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.35, 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.25));
},
// 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;
}