A Filter sounds like one band of the Parametric EQ, but it keeps what the EQ cannot: linked numbers, the all-pass and +-40 dB. So it stays a node of its own and gets the EQ's editor with itself as the one band. The editors also stop running out of their room: what does not fit is left out instead of being cut. - The curve of the EQ is now BandCurve with a host the EQ and the Filter both fill. CurvePanel holds what their panels share. - FilterPanel shows the Filter as one band. A drag of its handle sets frequency and gain as one undo step. A number fed by a link is greyed out and the drag leaves it alone. - A double-click on the Filter's title opens the same in a tab of its own. Delete there does nothing, the node stays. - The Filter is tapped like the EQ, so its node and its tab draw the spectrum of what passes it. A tap is two gain nodes per voice, the analysers are made only while someone listens. - EqFace and FilterFace are one CurveFace now, given the panel. - EQ and Filter nodes have a corner to resize them, drawn like the floating window's. The size is saved on the node as `size`, each drag is one undo step, the least is 240 x 120. - In the node the help line is gone. The row of numbers comes in only once the curve keeps 120 px of height. A group of the row that does not fit is not drawn, the one on the right first. - A frequency or dB label that would run into the one before it is left out. - The all-pass is drawn flat. The curve's maths takes every shape a Filter has. Found on the way: - The instrument presets set nodes 240 apart. A Filter with its editor is about 700 wide and covered the Bitcrusher, so a Filter now takes four columns. Tests: - Unit: 8 new (filter band, resize command and save, fitting rules), stage lists with a Filter now hold its taps - e2e 34 (atx): ALL PASSED, 130 checks (4 new) - Gate (atx): CHECK EXIT 0, 284 files, 4367 tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
858 lines
29 KiB
TypeScript
858 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;
|
|
/** Columns a node takes that is drawn as its editor in the graph (#385): a Filter's curve is wide. */
|
|
const COLUMNS_OF: Readonly<Record<string, number>> = { [SoundFxFilter.type]: 4 };
|
|
|
|
/** 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;
|
|
}
|