sfxr's "phaser" is a comb filter, the sound plus a copy delayed by up to 2.9 ms. That is a flanger, not the phaser a guitarist knows. Both effects come in as nodes of their own, so each name fits its sound and sfxr is covered in full. Both run on the FX worklet like the Bitcrusher, because a feedback loop in Web Audio costs a render quantum per round. - Phaser: first-order allpasses (Stages 2, 4, 6, 8, 12) around Frequency, moved by a sine LFO Depth octaves wide at Rate Hz, with Feedback and Mix. An Envelope can move Frequency and Mix. - Flanger: a delay line of Offset ms. Sweep moves it on in ms/s from the start of the voice, an LFO adds up to Depth ms. As in sfxr the delay is mirrored at 0, and it never goes past 20 ms. Feedback comes back exactly one delay later. An Envelope can move Offset and Mix. - Mix 0 leaves the sound as it is, 1 is half sound and half copy. The notches are deepest there and the peaks stay at the sound's level. sfxr adds both at full, so its sound comes out 6 dB louder. - The Flanger's processor gets the start of its voice and begins the sweep on that sample. stageNodes takes it as a new argument `at`. - Explosion gets a Flanger in one draw out of two, with sfxr's offset and sweep and no LFO. Offset is offset^2 * 2.89 ms, Sweep is ramp^2 * 125 ms/s, signed so the direction holds. - Randomize draws a Flanger's offset and sweep like sfxr. Its LFO and feedback and all of a Phaser are drawn within bounds of their own. Mix and Stages stay, as a Bitcrusher's Mix does. Mutate moves them too, and a 0 stays 0. - New instrument preset Phased Pad: saw, Envelope, low-pass, Phaser of six stages. The other 14 are unchanged. - The Bitcrusher's worklet setup is now shared with both nodes (workletNodes), its behaviour is unchanged. Tests: - Unit phaser (new): 8 green. Counter-check with clamping at 0 instead of mirroring and a flipped allpass coefficient: 3 red. - Unit soundfx, sound-library, chip-presets: 45 / 76 / 14 green. Counter-checks: start time not passed on, phaser frequency eased linearly, sweep sign dropped, Phaser left out of Mutate, each red. - Unit core, viewport, graph: 179 files, 2979 tests green. - e2e 34 (melektaus-atx): ALL PASSED, 70 checks (1 new), first run. Chrome offline: 1 kHz notch RMS 0.0000 with two stages, 0.5657 with four. Flanger echo after 48 samples, swept echo at 533/534. About 47 Phasers (12 stages) or 63 Flangers fill the audio thread. - Gate (melektaus-atx): CHECK EXIT 0, 257 files, 3991 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
836 lines
28 KiB
TypeScript
836 lines
28 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,
|
|
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,
|
|
};
|
|
}
|
|
|
|
/** Where sfxr's filters come out, run 8 times a sample: about 17.8 kHz times `f^1.5` low, 5.6 kHz times `f²` high. */
|
|
function filterNumbers(p: Sfxr, filter: FxPassFilter): Record<'cutoff' | 'resonance', number> {
|
|
if (filter === 'highpass') return { cutoff: 5615 * p.hpfFreq ** 2, resonance: 0 };
|
|
// Wide open, as far as the row goes.
|
|
if (p.lpfFreq >= 1) return { cutoff: Infinity, resonance: 0 };
|
|
return { cutoff: 17756 * Math.max(0, p.lpfFreq) ** 1.5, resonance: Math.abs(p.lpfResonance) };
|
|
}
|
|
|
|
/**
|
|
* 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, resonance }] 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 },
|
|
resonance: { by: 0.05 },
|
|
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;
|
|
}
|