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:
1 parent
a645026568
commit
d3a4cb7d0e
8 files changed
+407
-3
No files matched your search
@@ -15,6 +15,7 @@ import {
|
||||
type FxMod,
|
||||
type FxPass,
|
||||
type FxPhaser,
|
||||
type FxSaturate,
|
||||
fxRings,
|
||||
fxTailOf,
|
||||
type FxParams,
|
||||
@@ -35,6 +36,7 @@ import { dbToGain, SOUND_ITEM_MAX_DB } from '../core/music/song';
|
||||
import { DEFAULT_TEMPO } from '../core/music/time';
|
||||
import { type CutRange, cutChannels, cutRange, parseCutName } from '../core/model/sound';
|
||||
import { shifterLatency } from '../core/soundfx/pitchPlayer';
|
||||
import { driveGain, saturationCurve, SHAPER_LATENCY_FRAMES } from '../core/soundfx/saturation';
|
||||
import type { AudioSink, ClipTiming, NoteTiming } from '../runtime/systems/Audio';
|
||||
import type { ChipOptions, FlangerOptions, PhaserOptions, PitchOptions } from './fxWorklet';
|
||||
import fxWorkletUrl from './fxWorklet.ts?worker&url';
|
||||
@@ -2052,6 +2054,7 @@ export function stageNodes(
|
||||
if (stage.kind === 'phaser') return phaserNodes(context, stage, follow);
|
||||
if (stage.kind === 'flanger') return flangerNodes(context, stage, at, follow);
|
||||
if (stage.kind === 'compress') return compressNodes(context, stage);
|
||||
if (stage.kind === 'saturate') return saturateNodes(context, stage);
|
||||
if (stage.kind === 'limit')
|
||||
return workletNodes(context, 'limiter', [
|
||||
{ name: 'threshold', value: stage.threshold, even: 'value' },
|
||||
@@ -2202,6 +2205,49 @@ function compressNodes(context: BaseAudioContext, stage: FxCompress): StageNodes
|
||||
};
|
||||
}
|
||||
|
||||
/** Below what a Saturation takes out the DC its bent sound gained (#323). */
|
||||
const DC_BLOCK_HZ = 10;
|
||||
|
||||
/**
|
||||
* A Saturation (#323): the sound driven into the curve of its type, oversampled 4x, its DC taken
|
||||
* out, through the low-pass of its tone and set to its level – and the sound itself beside it,
|
||||
* held back as far as the oversampling makes the bent one late, so the two meet without a comb.
|
||||
*/
|
||||
function saturateNodes(context: BaseAudioContext, stage: FxSaturate): StageNodes {
|
||||
const input = context.createGain();
|
||||
const output = context.createGain();
|
||||
const drive = gainNode(context, driveGain(stage.drive));
|
||||
const shaper = context.createWaveShaper();
|
||||
shaper.curve = saturationCurve(stage.curve) as Float32Array<ArrayBuffer>;
|
||||
shaper.oversample = '4x';
|
||||
const dc = context.createBiquadFilter();
|
||||
dc.type = 'highpass';
|
||||
dc.frequency.value = DC_BLOCK_HZ;
|
||||
dc.Q.value = passQ('highpass', FLAT_Q);
|
||||
const tone = context.createBiquadFilter();
|
||||
tone.type = 'lowpass';
|
||||
tone.frequency.value = stage.tone;
|
||||
tone.Q.value = passQ('lowpass', FLAT_Q);
|
||||
const level = gainNode(context, stage.level);
|
||||
const wet = gainNode(context, stage.mix);
|
||||
const dry = gainNode(context, 1 - stage.mix);
|
||||
input.connect(drive).connect(shaper).connect(dc).connect(tone).connect(level).connect(wet).connect(output);
|
||||
heldBack(context, input, SHAPER_LATENCY_FRAMES / context.sampleRate)
|
||||
.connect(dry)
|
||||
.connect(output);
|
||||
return {
|
||||
input,
|
||||
output,
|
||||
knobs: [
|
||||
{ param: drive.gain, target: driveGain(stage.drive), even: 'ratio' },
|
||||
{ param: tone.frequency, target: stage.tone, even: 'ratio' },
|
||||
{ param: level.gain, target: stage.level, even: 'ratio' },
|
||||
{ param: wet.gain, target: stage.mix, even: 'value' },
|
||||
{ param: dry.gain, target: 1 - stage.mix, even: 'value' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* An echo (#327): the sound goes on as it is, and through a delay line whose output is damped, fed
|
||||
* back into it and added at `mix`. Ping-pong sends the sound into the left of the line only and
|
||||
@@ -2489,6 +2535,8 @@ function levelsOf(chain: FxChain, into: number[] = []): number[] {
|
||||
else if (stage.kind === 'compress')
|
||||
into.push(stage.threshold, stage.knee, stage.ratio, stage.attack, stage.release, stage.gain);
|
||||
else if (stage.kind === 'limit') into.push(stage.threshold, stage.ceiling, stage.release, stage.autoGain);
|
||||
else if (stage.kind === 'saturate')
|
||||
into.push(driveGain(stage.drive), stage.tone, stage.level, stage.mix, 1 - stage.mix);
|
||||
else if (stage.kind !== 'envelope') into.push(stage.gain);
|
||||
return into;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user