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
@@ -0,0 +1,63 @@
|
||||
/** The curves of a Saturation (#323) and the gain that keeps it as loud as it came in. */
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
driveGain,
|
||||
SATURATION_RANGE,
|
||||
SATURATION_TYPES,
|
||||
saturate,
|
||||
saturationAutoGain,
|
||||
saturationCurve,
|
||||
toneCutoff,
|
||||
} from '../../src/core/soundfx/saturation';
|
||||
|
||||
describe('the curves of a Saturation', () => {
|
||||
it('leave silence silent and never go past full scale, however hard they are driven', () => {
|
||||
for (const { value } of SATURATION_TYPES) {
|
||||
expect(saturate(value, 0)).toBeCloseTo(0, 12);
|
||||
for (const x of [-300, -5, -1, -0.3, 0.3, 1, 5, 300])
|
||||
expect(Math.abs(saturate(value, x))).toBeLessThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('bend each in a shape of their own', () => {
|
||||
expect(saturate('hard', 0.5)).toBe(0.5);
|
||||
expect(saturate('hard', 3)).toBe(1);
|
||||
expect(saturate('cubic', 1)).toBe(1);
|
||||
expect(saturate('cubic', 0.5)).toBeCloseTo(0.6875, 9);
|
||||
// Foldback mirrors at ±1: 1.5 comes back to 0.5, 3 to -1.
|
||||
expect([saturate('foldback', 0.5), saturate('foldback', 1.5), saturate('foldback', 3)]).toEqual([
|
||||
0.5, 0.5, -1,
|
||||
]);
|
||||
expect(saturate('sine', 1)).toBeCloseTo(1, 12);
|
||||
expect(saturate('sine', 2)).toBeCloseTo(0, 12);
|
||||
// Tube and Fuzz bend one half harder than the other; Rectify turns the lower half up.
|
||||
expect(saturate('tube', 2)).not.toBeCloseTo(-saturate('tube', -2), 2);
|
||||
expect(saturate('fuzz', -10)).toBeCloseTo(-0.5, 9);
|
||||
expect(saturate('rectify', -0.5)).toBeCloseTo(saturate('rectify', 0.5), 12);
|
||||
});
|
||||
|
||||
it('are made once, spanning the range a drive of 48 dB reaches', () => {
|
||||
const curve = saturationCurve('hard');
|
||||
expect(saturationCurve('hard')).toBe(curve);
|
||||
expect(curve.length % 2).toBe(1);
|
||||
expect([curve[0], curve[(curve.length - 1) / 2], curve.at(-1)]).toEqual([-1, 0, 1]);
|
||||
expect(10 ** (48 / 20)).toBeLessThan(SATURATION_RANGE);
|
||||
expect(driveGain(0)).toBe(1 / SATURATION_RANGE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the auto-gain of a Saturation', () => {
|
||||
it('leaves a curve that does nothing at -12 dBFS alone, and makes up for what a hard one takes', () => {
|
||||
expect(saturationAutoGain('hard', 0)).toBeCloseTo(0, 9);
|
||||
// Driven 24 dB into a hard clip, a sine is nearly a square at full scale: 3 dB above the
|
||||
// sine's 0 dBFS RMS, which is 12 dB over -12 dBFS. 15 dB come off.
|
||||
expect(saturationAutoGain('hard', 24)).toBeCloseTo(-15, 0);
|
||||
expect(saturationAutoGain('soft', 0)).toBeGreaterThan(0);
|
||||
expect(saturationAutoGain('soft', 0)).toBeLessThan(0.3);
|
||||
});
|
||||
|
||||
it('opens the tone from 1 kHz to 20 kHz', () => {
|
||||
expect([toneCutoff(0), toneCutoff(1), toneCutoff(5)]).toEqual([1000, 20000, 20000]);
|
||||
expect(toneCutoff(0.5)).toBeCloseTo(1000 * Math.sqrt(20), 6);
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
type FxVoice,
|
||||
stageLatency,
|
||||
} from '../../src/core/soundfx/nodes';
|
||||
import { saturationAutoGain } from '../../src/core/soundfx/saturation';
|
||||
import { deserializeProject } from '../../src/core/store/serialize';
|
||||
import type { AudioSink } from '../../src/runtime/systems/Audio';
|
||||
import { paletteEntries } from '../../src/editor/graph/paletteEntries';
|
||||
@@ -129,6 +130,7 @@ describe('the sound FX asset', () => {
|
||||
'SoundFxPan',
|
||||
'SoundFxCompressor',
|
||||
'SoundFxLimiter',
|
||||
'SoundFxSaturation',
|
||||
]);
|
||||
f.edit.addNode('SoundFxInput');
|
||||
f.edit.addNode('SoundFxOutput');
|
||||
@@ -145,6 +147,7 @@ describe('the sound FX asset', () => {
|
||||
'SoundFxPan',
|
||||
'SoundFxCompressor',
|
||||
'SoundFxLimiter',
|
||||
'SoundFxSaturation',
|
||||
]);
|
||||
|
||||
const behavior = makeBehavior(f, 'Logic');
|
||||
@@ -468,6 +471,29 @@ describe('a Compressor and a Limiter in a sound FX graph (#326)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('a Saturation in a sound FX graph (#323)', () => {
|
||||
const at = (params?: Record<string, string | number>) =>
|
||||
fxChainOf(compileSoundFx(line([{ type: 'SoundFxSaturation', params }]).fx.graph).compiled!).stages;
|
||||
|
||||
it('bends at its defaults, its level from auto-gain, and is left out at a typed-in mix of 0', () => {
|
||||
const [stage] = at();
|
||||
expect(stage).toMatchObject({ kind: 'saturate', curve: 'soft', drive: 12, tone: 20000, mix: 1 });
|
||||
expect(20 * Math.log10((stage as { level: number }).level)).toBeCloseTo(
|
||||
saturationAutoGain('soft', 12),
|
||||
9,
|
||||
);
|
||||
expect(at({ mix: 0 })).toEqual([]);
|
||||
expect(at({ curve: 'nope' })).toMatchObject([{ curve: 'soft' }]);
|
||||
});
|
||||
|
||||
it('blends its output and its auto-gain, and is late by the oversampling', () => {
|
||||
const [stage] = at({ curve: 'hard', drive: 24, output: 6, autoGain: 0.5 });
|
||||
const levelDb = 20 * Math.log10((stage as { level: number }).level);
|
||||
expect(levelDb).toBeCloseTo(0.5 * 6 + 0.5 * saturationAutoGain('hard', 24), 9);
|
||||
expect(stageLatency(stage!, 48000)).toBe(0.004);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the tail of a voice (#324)', () => {
|
||||
const echo = (time: number, feedback: number, mix: number): FxStage => ({
|
||||
kind: 'delay',
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { MixBus } from '../../src/core/music/mixer';
|
||||
import type { SequencePlay } from '../../src/core/music/playbacks';
|
||||
import type { ScheduledNote } from '../../src/core/music/Scheduler';
|
||||
import type { PitchOptions } from '../../src/viewport/fxWorklet';
|
||||
import { saturationCurve } from '../../src/core/soundfx/saturation';
|
||||
import { SoundLibrary, stageNodes } from '../../src/viewport/SoundLibrary';
|
||||
|
||||
/** What a test wants to hear: a chain whose pitch, where it is not 0, is a Pitch typed in. */
|
||||
@@ -2358,4 +2359,30 @@ describe('the stages of #318 to #328', () => {
|
||||
]);
|
||||
expect(library.busLatency('mix:c')).toBeCloseTo(0.006, 9);
|
||||
});
|
||||
|
||||
it('saturates through a curve oversampled 4x, the sound itself held back beside it (#323)', () => {
|
||||
const context = fakeContext() as unknown as BaseAudioContext;
|
||||
const made = stageNodes(context, {
|
||||
kind: 'saturate',
|
||||
curve: 'tube',
|
||||
drive: 12,
|
||||
tone: 5000,
|
||||
level: 0.5,
|
||||
mix: 0.25,
|
||||
});
|
||||
expect(shapers[0]!.curve).toBe(saturationCurve('tube'));
|
||||
expect((shapers[0] as unknown as { oversample: string }).oversample).toBe('4x');
|
||||
expect(filters.map((f) => [f.type, f.frequency.value])).toEqual([
|
||||
['highpass', 10],
|
||||
['lowpass', 5000],
|
||||
]);
|
||||
expect(delays.map((d) => d.delayTime.value)).toEqual([0.004]);
|
||||
expect(made.knobs.map((k) => [k.target, k.even])).toEqual([
|
||||
[10 ** (12 / 20) / 256, 'ratio'],
|
||||
[5000, 'ratio'],
|
||||
[0.5, 'ratio'],
|
||||
[0.25, 'value'],
|
||||
[0.75, 'value'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user