Files
feuerwerk/src/core/soundfx/saturation.ts
T
adminandClaude Opus 5.5 80b25488ce
game-maker / gate (push) Successful in 4m5s
game-maker / suite (push) Successful in 16m2s
Show a Saturation node as a device that glows with its drive (#396)
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>
2026-10-10 20:05:11 +02:00

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;