A Saturation in the graph was a stack of rows. The user wanted a device of its own style and size, like the Delay and the Reverb, that shows warmth and heat the harder the sound is driven. - The node is a copper plate of 720x280 with a button per type, the auto-gain lever, a big drive knob and small knobs for tone, output and wet. Each change is one undo step, wired numbers lock their knob. - The screen draws a sine at -12 dBFS, the level auto-gain is reckoned at, as a grey ghost. Over it is the wave that comes out: bent by the type, its DC taken out, auto-gain and output after it, blended by wet. Under it stand the auto-gain and the peak. - The heat is the drive over 48 dB, as far as wet lets it be heard. The wave goes from cold steel through ember red to white, a glow rises from below and flickers, and embers fly up from about 8 dB on. A lamp beside the name glows with it. - The node keeps the editor drawing only while there is heat. Without drive it draws nothing, unlike the Delay, which always animates. - The Reverb's private type button is now PickButton with its own colours, and the Saturation's type buttons use it as well. - No instrument preset uses a Saturation, so no preset needs more columns. Tests: - Unit saturation-view: 14 new tests. - Counter-check: without taking out the DC, exactly the DC test fails. - e2e 34 (atx): ALL PASSED, 137 checks, one new for type, lever and drive with undo. - Gate (atx): CHECK EXIT 0, 289 files, 4431 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
103 lines
4.1 KiB
TypeScript
103 lines
4.1 KiB
TypeScript
/**
|
|
* The curves of a Saturation (#323). A WaveShaperNode reads its curve over -1 to 1, so the curve
|
|
* spans `SATURATION_RANGE` times that and the sound is turned down as far before it: then a drive
|
|
* of up to 48 dB is a gain in front of the curve, which can glide, and not a curve of its own.
|
|
*/
|
|
|
|
export type SaturationType = 'soft' | 'cubic' | 'hard' | 'tube' | 'fuzz' | 'foldback' | 'sine' | 'rectify';
|
|
|
|
export const SATURATION_TYPES: { value: SaturationType; label: string }[] = [
|
|
{ value: 'soft', label: 'Soft (tanh)' },
|
|
{ value: 'cubic', label: 'Cubic' },
|
|
{ value: 'hard', label: 'Hard clip' },
|
|
{ value: 'tube', label: 'Tube' },
|
|
{ value: 'fuzz', label: 'Fuzz' },
|
|
{ value: 'foldback', label: 'Foldback' },
|
|
{ value: 'sine', label: 'Sine fold' },
|
|
{ value: 'rectify', label: 'Rectify' },
|
|
];
|
|
|
|
/** How far past full scale the curve goes: a little more than the 48 dB a drive can give. */
|
|
export const SATURATION_RANGE = 256;
|
|
|
|
/** Where a tube's curve is shifted to, so its two halves bend differently. */
|
|
const TUBE_BIAS = 0.4;
|
|
|
|
const wrap = (value: number, by: number) => ((value % by) + by) % by;
|
|
|
|
/** What a type makes of a level `x`, on full scale ±1 and beyond. */
|
|
export function saturate(type: SaturationType, x: number): number {
|
|
switch (type) {
|
|
case 'soft':
|
|
return Math.tanh(x);
|
|
case 'cubic':
|
|
return Math.abs(x) >= 1 ? Math.sign(x) : 1.5 * (x - x ** 3 / 3);
|
|
case 'hard':
|
|
return Math.max(-1, Math.min(1, x));
|
|
case 'tube':
|
|
// Bent harder on one side than on the other: even harmonics, as a valve gives.
|
|
return (Math.tanh(x + TUBE_BIAS) - Math.tanh(TUBE_BIAS)) / (1 + Math.tanh(TUBE_BIAS));
|
|
case 'fuzz':
|
|
return x >= 0 ? 1 - Math.exp(-4 * x) : -0.5 * (1 - Math.exp(4 * x));
|
|
case 'foldback':
|
|
// Up to full scale as it is, then mirrored back at ±1 again and again.
|
|
return Math.abs(wrap(x - 1, 4) - 2) - 1;
|
|
case 'sine':
|
|
return Math.sin((Math.PI / 2) * x);
|
|
case 'rectify':
|
|
return Math.tanh(Math.abs(x));
|
|
}
|
|
}
|
|
|
|
/** The gain in front of the curve for a drive in dB: the drive, and down as far as the curve spans. */
|
|
export function driveGain(driveDb: number): number {
|
|
return 10 ** (driveDb / 20) / SATURATION_RANGE;
|
|
}
|
|
|
|
/** Points of a curve: odd, so 0 in is a point of its own. */
|
|
const POINTS = 65537;
|
|
const curves = new Map<SaturationType, Float32Array>();
|
|
|
|
/** The curve a WaveShaper takes for a type, made once. */
|
|
export function saturationCurve(type: SaturationType): Float32Array {
|
|
let curve = curves.get(type);
|
|
if (!curve) {
|
|
curve = new Float32Array(POINTS);
|
|
for (let i = 0; i < POINTS; i++)
|
|
curve[i] = saturate(type, ((2 * i) / (POINTS - 1) - 1) * SATURATION_RANGE);
|
|
curves.set(type, curve);
|
|
}
|
|
return curve;
|
|
}
|
|
|
|
/** The level a reference sine is at: -12 dBFS, about where a sound is loud but not at its peak. */
|
|
export const SATURATION_REFERENCE = 0.25;
|
|
const STEPS = 256;
|
|
|
|
/**
|
|
* The gain in dB that brings a sine at -12 dBFS, driven by `driveDb` and bent by the curve, back to
|
|
* as loud as it came in, counted by its RMS without what it gained in DC. Held to ±48 dB, so a
|
|
* curve that leaves next to nothing cannot ask for a blast.
|
|
*/
|
|
export function saturationAutoGain(type: SaturationType, driveDb: number): number {
|
|
const drive = 10 ** (driveDb / 20);
|
|
const out = Array.from({ length: STEPS }, (_, i) =>
|
|
saturate(type, SATURATION_REFERENCE * Math.sin((2 * Math.PI * i) / STEPS) * drive),
|
|
);
|
|
const mean = out.reduce((sum, v) => sum + v, 0) / STEPS;
|
|
const rms = Math.sqrt(out.reduce((sum, v) => sum + (v - mean) ** 2, 0) / STEPS);
|
|
const gain = rms > 0 ? 20 * Math.log10(SATURATION_REFERENCE / Math.SQRT2 / rms) : 48;
|
|
return Math.max(-48, Math.min(48, gain));
|
|
}
|
|
|
|
/** What a tone from 0 to 1 opens a Saturation's low-pass to: 1 kHz up to 20 kHz, evenly in octaves. */
|
|
export function toneCutoff(tone: number): number {
|
|
return 1000 * 20 ** Math.max(0, Math.min(1, tone));
|
|
}
|
|
|
|
/**
|
|
* Samples a WaveShaperNode with 4x oversampling holds the sound back, as Chrome was measured to
|
|
* (2026-10-07, offline at 48 kHz): its up- and down-sampling filters, the same number at every rate.
|
|
*/
|
|
export const SHAPER_LATENCY_FRAMES = 192;
|