Warm up or distort a sound with a Saturation node (#323)

A sound FX had no way to bend a sound. The Saturation node pushes it
into a curve, from warmth to a hard distortion, on a voice or a bus.

- Eight types, each a fixed curve made once: Soft (tanh), Cubic, Hard
  clip, Tube, Fuzz, Foldback, Sine fold and Rectify. The issue asked
  for as many as useful.
- Drive (0 to 48 dB) is a gain in front of the curve. The curve spans
  256 times full scale, so the drive glides like any gain.
- Output and Auto-gain blend as on the Compressor. Auto-gain brings a
  sine at -12 dBFS back to the RMS it came in with.
- Tone is a low-pass after the curve, 1 kHz at 0 to 20 kHz at 1, as
  recommended in the plan. A 10 Hz high-pass takes out the DC the
  lopsided types make.
- Mix blends with the dry sound. The 4x oversampling is 192 samples
  late in Chrome, so the dry way is held back as far. The node counts
  as late by that much for voices, Mixes and buses.
- A test hook gives e2e the auto-gain at a drive.

Tests:
- Unit saturation (new) 5, soundfx 51 (2 new), sound-library 101
  (1 new), green.
- Counter-checks, each turns tests red: no hold-back of the dry way
  (1), the level without auto-gain (2).
- e2e 34 (melektaus-atx): ALL PASSED, 79 checks (1 new). Chrome
  offline: 192 samples late at 48 and at 44.1 kHz. Half dry at 1125 Hz
  0.00 dB. A sine at -12 dBFS driven 24 dB into Soft comes out at
  0.00 dB with full auto-gain.
- The gate runs once on the last commit of this series.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-07 21:04:16 +02:00
1 parent a645026568
commit d3a4cb7d0e
8 files changed
+407 -3

No files matched your search

+94 -3
View File
@@ -6,6 +6,13 @@ import { DEFAULT_TEMPO, heldTempo, secondsOfQuarters } from '../music/time';
import type { FlatDef } from '../properties/PropertySchema';
import type { ChipShape, ChipWave } from './chip';
import { COMPRESSOR_LATENCY, compressorMakeup, limiterLatency } from './dynamics';
import {
SATURATION_TYPES,
saturationAutoGain,
type SaturationType,
SHAPER_LATENCY_FRAMES,
toneCutoff,
} from './saturation';
import { PHASER_STAGES } from './phaser';
export { SOUND_FX_INPUT, SOUND_FX_OUTPUT };
@@ -33,7 +40,21 @@ export type FxStage =
| FxFlanger
| { kind: 'pan'; pan: number; mod?: FxMod }
| FxCompress
| FxLimit;
| FxLimit
| FxSaturate;
/**
* A Saturation (#323): the curve of its type, the drive in dB in front of it, the cutoff its tone
* opens the low-pass after it to, the level the bent sound is set to, and how much of it is heard.
*/
export interface FxSaturate {
kind: 'saturate';
curve: SaturationType;
drive: number;
tone: number;
level: number;
mix: number;
}
/**
* A Compressor (#326) as Web Audio's node takes it, in dB and seconds, and `gain`, the factor after
@@ -251,7 +272,11 @@ export type FxStagePlan =
'threshold' | 'knee' | 'ratio' | 'attack' | 'release' | 'makeup' | 'autoGain',
FxAmount
>)
| ({ kind: 'limit' } & Record<'threshold' | 'ceiling' | 'release' | 'autoGain', FxAmount>);
| ({ kind: 'limit' } & Record<'threshold' | 'ceiling' | 'release' | 'autoGain', FxAmount>)
| ({ kind: 'saturate'; curve: SaturationType } & Record<
'drive' | 'tone' | 'output' | 'autoGain' | 'mix',
FxAmount
>);
/** A chain with its parameters still open: compiled once, filled in for every voice by `fxChainOf`. */
export type FxPlan =
@@ -830,6 +855,56 @@ export const SoundFxLimiter: SoundFxNodeDef = {
]),
};
const DRIVE = { ...numberRow('drive', 'Drive (dB)', 0, 48, 0.5), default: 12 };
const TONE = { ...numberRow('tone', 'Tone', 0, 1, 0.01), default: 1 };
const OUTPUT = numberRow('output', 'Output (dB)', -24, 24, 0.5);
const SATURATION_AUTO_GAIN = { ...AUTO_GAIN, default: 1 };
const SATURATION_MIX = { ...numberRow('mix', 'Mix', 0, 1, 0.01), default: 1 };
export const SATURATION_ROWS = {
drive: DRIVE,
tone: TONE,
output: OUTPUT,
autoGain: SATURATION_AUTO_GAIN,
mix: SATURATION_MIX,
};
const SATURATION_TYPE = {
key: 'curve',
label: 'Type',
kind: 'enum' as const,
default: 'soft',
options: SATURATION_TYPES,
};
export function saturationTypeOf(settings: PropertyBag): SaturationType {
return SATURATION_TYPES.find((option) => option.value === settings[SATURATION_TYPE.key])?.value ?? 'soft';
}
export const SoundFxSaturation: SoundFxNodeDef = {
type: 'SoundFxSaturation',
label: 'Saturation',
category: 'sound',
description:
'Bends the sound, from warmth to a hard distortion. "Type" is the shape it is bent by: Soft (tanh) and Cubic round the peaks off, Hard clip cuts them, Tube and Fuzz bend one half harder than the other, Foldback and Sine fold mirror what goes past full scale back in, Rectify turns the lower half up, an octave higher. "drive" (dB) is how hard the sound is pushed into it. "tone" from 0 to 1 is a low-pass after it, from 1 kHz up to wide open. "output" (dB) sets the level of the bent sound, "auto-gain" from 0 to 1 blends that with the gain that keeps a sound at -12 dBFS as loud as it came in. "mix" is how much of the bent sound is heard instead of the sound itself. It is 4 ms late (192 samples), which a voice, a Mix and a bus make up for. All numbers can change while the sound plays.',
inputs: [audioIn, ...Object.values(SATURATION_ROWS).map(numberIn)],
outputs: [audioOut],
params: [SATURATION_TYPE],
emit: (sounds, amounts, settings) => {
const mix = held(amounts.mix, SATURATION_MIX);
if (isNone(mix)) return arriving(sounds);
return withStages(arriving(sounds), [
{
kind: 'saturate',
curve: saturationTypeOf(settings),
drive: held(amounts.drive, DRIVE),
tone: held(amounts.tone, TONE),
output: held(amounts.output, OUTPUT),
autoGain: held(amounts.autoGain, SATURATION_AUTO_GAIN),
mix,
},
]);
},
};
/** Where damping 0 leaves the echoes and damping 1 takes them: evenly in octaves between. */
const UNDAMPED_HZ = 20000;
const DAMPED_HZ = 500;
@@ -853,6 +928,7 @@ export const SOUND_FX_NODES: readonly SoundFxNodeDef[] = [
SoundFxPan,
SoundFxCompressor,
SoundFxLimiter,
SoundFxSaturation,
];
/** An amount with the value handed in for its parameter, held to its row; a 0 there does nothing. */
@@ -993,6 +1069,20 @@ function stagesOf(plan: FxPlan, params: FxParams, tempo: number): FxStage[] {
});
continue;
}
if (stage.kind === 'saturate') {
const drive = amountOf(stage.drive, params);
const w = amountOf(stage.autoGain, params);
const levelDb = (1 - w) * amountOf(stage.output, params) + w * saturationAutoGain(stage.curve, drive);
stages.push({
kind: 'saturate',
curve: stage.curve,
drive,
tone: toneCutoff(amountOf(stage.tone, params)),
level: dbToGain(levelDb),
mix: amountOf(stage.mix, params),
});
continue;
}
if (stage.kind === 'pan') {
const pan = amountOf(stage.pan, params);
const mod = modOf(stage.pan, params);
@@ -1098,10 +1188,11 @@ export function fxTailOf(chain: FxChain): number {
return chain.stages.reduce((sum, stage) => sum + (stage.kind === 'delay' ? delayTail(stage) : 0), ways);
}
/** Seconds a stage holds the sound back (#326): a Compressor's look-ahead, a Limiter's. */
/** Seconds a stage holds the sound back (#326): a Compressor's look-ahead, a Limiter's, a Saturation's oversampling. */
export function stageLatency(stage: FxStage, sampleRate: number): number {
if (stage.kind === 'compress') return COMPRESSOR_LATENCY;
if (stage.kind === 'limit') return limiterLatency(sampleRate);
if (stage.kind === 'saturate') return SHAPER_LATENCY_FRAMES / sampleRate;
return 0;
}
+102
View File
@@ -0,0 +1,102 @@
/**
* 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. */
const 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, 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(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;