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

+44
View File
@@ -1055,6 +1055,50 @@ export async function sound(t) {
dynamics.ceiling > -1.2,
JSON.stringify(dynamics),
);
// #323: Saturation rendered offline. An impulse through it comes out 192 samples late at 48 and
// at 44.1 kHz, the oversampling's delay. Half bent and half dry at 1125 Hz – where 192
// samples off would cancel – the two meet in step and the sine stays as loud. A sine at
// -12 dBFS driven 24 dB into tanh comes back as loud with full auto-gain.
const saturated = await evaluate(`
(async () => {
const render = async (rate, stage, fill) => {
const context = new OfflineAudioContext(1, rate / 2, rate);
const sound = context.createBuffer(1, rate / 2, rate);
fill(sound.getChannelData(0), rate);
const source = context.createBufferSource();
source.buffer = sound;
const nodes = __gm.sound.stageNodes(context, { kind: 'saturate', curve: 'hard', drive: 0, tone: 20000, level: 1, mix: 1, ...stage });
source.connect(nodes.input);
nodes.output.connect(context.destination);
source.start();
return (await context.startRendering()).getChannelData(0);
};
const impulse = (data) => { data[480] = 0.5; };
const sine = (hz, amplitude) => (data, rate) => {
for (let i = 0; i < data.length; i++) data[i] = Math.sin((2 * Math.PI * hz * i) / rate) * amplitude;
};
const loudest = (out) => out.reduce((at, v, i) => (Math.abs(v) > Math.abs(out[at]) ? i : at), 0);
const db = (out, amplitude) => {
let sum = 0;
const from = out.length / 2;
for (let i = from; i < out.length; i++) sum += out[i] ** 2;
return +(20 * Math.log10(Math.sqrt(sum / (out.length - from)) / (amplitude * Math.SQRT1_2))).toFixed(2);
};
return {
late48: loudest(await render(48000, {}, impulse)) - 480,
late44: loudest(await render(44100, {}, impulse)) - 480,
halfDry: db(await render(48000, { mix: 0.5 }, sine(1125, 0.5)), 0.5),
autoGain: db(await render(48000, { curve: 'soft', drive: 24, level: 10 ** (__gm.sound.saturationAutoGain('soft', 24) / 20) }, sine(1000, 0.25)), 0.25),
};
})()`);
check(
'a Saturation is 192 samples late at either rate, its dry half meets the bent one in step, auto-gain keeps it as loud',
saturated.late48 === 192 &&
saturated.late44 === 192 &&
Math.abs(saturated.halfDry) < 0.1 &&
Math.abs(saturated.autoGain) < 0.5,
JSON.stringify(saturated),
);
// #329: the Chip on the worklet, rendered offline. A square at 220 Hz with a pitch of 12 and a duty
// of 0.25, starting at 0.25 s: silence until then, 440 periods a second after it, up a
// quarter of each. Sliding two octaves a second down from 440 Hz with a lowest frequency of