Put a sound in a room, a hall or a cave with a Reverb node (#328)
A sound FX had no space around it. The Reverb node folds the sound with an impulse response: one it makes itself, or a sound asset as the answer of a real space. It works on a voice and on a bus. - Type: Room, Hall, Cave and Plate have fixed numbers. Custom shows Decay (up to 10 s), Damping and Pre-delay. Sound shows the pick of a sound asset and Pre-delay. The rows are derived ports, the sound pick an idle param, so they only appear for their type. - The made impulse response is seeded noise per side, 60 dB down at Decay, through a low-pass closing from 20 kHz toward 500 Hz as far as Damping says. It is made once per rate, decay and damping, the last 16 are kept. - A sound as impulse response: the sound FX compilers now get the project, so a node can turn a sound asset into its file. The build key holds that file, so a cut or swapped sound builds again. The instrument compiler does the same for a Reverb inside an instrument. - The library loads that file with the voice, a sequence or before a bus builds its chain. A missing file leaves the reverb silent. - Mix blends dry and reverb and is the only number that moves while the sound plays, as the issue said. The voice rings for pre-delay plus decay, or as long as its sound. - The open point from #325 about sends on project buses is #351, not part of this. Tests: - Unit reverb (new) 4, soundfx 57 (4 new), sound-library 104 (2 new), green. - Counter-checks, each turns one test red: the impulse file not loaded with the voice, the tail without the length of the sound. - e2e 34 (melektaus-atx): ALL PASSED, 81 checks (1 new). Chrome offline: silent during the pre-delay, 35.9 dB fall over 0.6 s of a 1 s decay (36 expected), the dry half at 0.25 for mix 0.5. A sound with one sample at 0.2 s gives the impulse back 0.25 s later and nowhere else. - Gate (melektaus-atx) over the end of this series #318 to #328: CHECK EXIT 0, 267 files, 4134 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
071f194326
commit
d349251a01
14 files changed
+544
-47
No files matched your search
@@ -0,0 +1,53 @@
|
||||
/** A Reverb (#328): the impulse response it makes for itself. */
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { REVERB_MAX_DECAY, REVERB_PRESETS, reverbImpulse } from '../../src/core/soundfx/reverb';
|
||||
|
||||
const RATE = 48000;
|
||||
|
||||
/** The RMS of a stretch of samples, in dB. */
|
||||
function db(data: Float32Array, from: number, to: number): number {
|
||||
let sum = 0;
|
||||
for (let i = from; i < to; i++) sum += data[i]! ** 2;
|
||||
return 10 * Math.log10(sum / (to - from));
|
||||
}
|
||||
|
||||
/** The share of a stretch's energy that is above a few kHz: what is left after a two-tap average. */
|
||||
function highShare(data: Float32Array, from: number, to: number): number {
|
||||
let all = 0;
|
||||
let high = 0;
|
||||
for (let i = from + 1; i < to; i++) {
|
||||
all += data[i]! ** 2;
|
||||
high += ((data[i]! - data[i - 1]!) / 2) ** 2;
|
||||
}
|
||||
return high / all;
|
||||
}
|
||||
|
||||
describe('the impulse response of a Reverb', () => {
|
||||
it('lasts its decay and falls by 60 dB over it, two channels of their own noise', () => {
|
||||
const [left, right] = reverbImpulse(RATE, 2, 0);
|
||||
expect(left!.length).toBe(2 * RATE);
|
||||
expect(left).not.toEqual(right);
|
||||
// A tenth of the way at the start and at the end: 54 dB apart, 6 dB a tenth.
|
||||
expect(db(left!, 0, RATE / 5) - db(left!, (9 * RATE) / 5, 2 * RATE)).toBeCloseTo(54, 0);
|
||||
});
|
||||
|
||||
it('is the same every time, and never longer than 10 s', () => {
|
||||
expect(reverbImpulse(RATE, 1, 0.5)).toEqual(reverbImpulse(RATE, 1, 0.5));
|
||||
expect(reverbImpulse(RATE, 60, 0)[0]!.length).toBe(REVERB_MAX_DECAY * RATE);
|
||||
});
|
||||
|
||||
it('loses its highs faster with damping', () => {
|
||||
const [open] = reverbImpulse(RATE, 1, 0);
|
||||
const [damped] = reverbImpulse(RATE, 1, 1);
|
||||
const late = [0.8 * RATE, RATE] as const;
|
||||
expect(highShare(damped!, ...late)).toBeLessThan(highShare(open!, ...late) / 4);
|
||||
// At the start both are open.
|
||||
expect(highShare(damped!, 0, RATE / 20)).toBeCloseTo(highShare(open!, 0, RATE / 20), 1);
|
||||
});
|
||||
|
||||
it('has four spaces from a small room to a cave', () => {
|
||||
expect(Object.keys(REVERB_PRESETS)).toEqual(['room', 'hall', 'cave', 'plate']);
|
||||
expect(REVERB_PRESETS.room.decay).toBeLessThan(REVERB_PRESETS.hall.decay);
|
||||
expect(REVERB_PRESETS.hall.decay).toBeLessThan(REVERB_PRESETS.cave.decay);
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
type FxStage,
|
||||
fxTailOf,
|
||||
type FxVoice,
|
||||
impulsesOf,
|
||||
SoundFxReverb,
|
||||
stageLatency,
|
||||
} from '../../src/core/soundfx/nodes';
|
||||
import { saturationAutoGain } from '../../src/core/soundfx/saturation';
|
||||
@@ -43,6 +45,7 @@ import {
|
||||
} from '../helpers';
|
||||
|
||||
const FILE = `${'a1'.repeat(32)}.wav`;
|
||||
const OTHER_FILE = `${'b2'.repeat(32)}.wav`;
|
||||
|
||||
/** The level of a gain or filter stage. */
|
||||
const gainOf = (stage: FxStage | undefined) => (stage && 'gain' in stage ? stage.gain : undefined);
|
||||
@@ -133,6 +136,7 @@ describe('the sound FX asset', () => {
|
||||
'SoundFxSaturation',
|
||||
'SoundFxTremolo',
|
||||
'SoundFxVibrato',
|
||||
'SoundFxReverb',
|
||||
]);
|
||||
f.edit.addNode('SoundFxInput');
|
||||
f.edit.addNode('SoundFxOutput');
|
||||
@@ -152,6 +156,7 @@ describe('the sound FX asset', () => {
|
||||
'SoundFxSaturation',
|
||||
'SoundFxTremolo',
|
||||
'SoundFxVibrato',
|
||||
'SoundFxReverb',
|
||||
]);
|
||||
|
||||
const behavior = makeBehavior(f, 'Logic');
|
||||
@@ -524,6 +529,59 @@ describe('a Tremolo and a Vibrato in a sound FX graph (#320)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('a Reverb in a sound FX graph (#328)', () => {
|
||||
const at = (params?: Record<string, string | number>) =>
|
||||
fxChainOf(compileSoundFx(line([{ type: 'SoundFxReverb', params }]).fx.graph).compiled!).stages;
|
||||
|
||||
it('takes the numbers of its preset, or the ones typed in for Custom, and is left out at a mix of 0', () => {
|
||||
expect(at()).toEqual([{ kind: 'reverb', decay: 0.6, damping: 0.5, predelay: 0.005, mix: 0.3 }]);
|
||||
expect(at({ room: 'cave', decay: 9 })).toEqual([
|
||||
{ kind: 'reverb', decay: 5, damping: 0.15, predelay: 0.04, mix: 0.3 },
|
||||
]);
|
||||
expect(at({ room: 'custom', decay: 40, damping: 0.2, predelay: 0.1, mix: 1 })).toEqual([
|
||||
{ kind: 'reverb', decay: 10, damping: 0.2, predelay: 0.1, mix: 1 },
|
||||
]);
|
||||
expect(at({ mix: 0 })).toEqual([]);
|
||||
});
|
||||
|
||||
it('shows the rows of Custom and of Sound only for them', () => {
|
||||
const f = line([{ type: 'SoundFxReverb' }]);
|
||||
const reverb = f.fx.graph.nodes.find((n) => n.type === 'SoundFxReverb')!;
|
||||
const inputs = (room: string) => {
|
||||
reverb.params.room = room;
|
||||
return resolveDef(reverb, f.fx.graph, graphLookup(f.project))!.inputs.map((p) => p.key);
|
||||
};
|
||||
expect(inputs('hall')).toEqual(['in', 'mix']);
|
||||
expect(inputs('custom')).toEqual(['in', 'mix', 'decay', 'damping', 'predelay']);
|
||||
expect(inputs('sound')).toEqual(['in', 'mix', 'predelay']);
|
||||
expect(SoundFxReverb.idleParams!({ room: 'sound' })).toEqual([]);
|
||||
expect(SoundFxReverb.idleParams!({ room: 'hall' })).toEqual(['impulse']);
|
||||
});
|
||||
|
||||
it('takes the file of a sound as its impulse response, built again when that sound changes', () => {
|
||||
const f = line([{ type: 'SoundFxReverb' }]);
|
||||
const made = new CreateSoundCommand('Church', FILE);
|
||||
f.stack.execute(made);
|
||||
const reverb = f.fx.graph.nodes.find((n) => n.type === 'SoundFxReverb')!;
|
||||
reverb.params = { room: 'sound', impulse: made.asset.id, predelay: 0.02 };
|
||||
const plan = soundFxBuildFor(f.fx, f.project).compiled!;
|
||||
expect(fxChainOf(plan).stages).toMatchObject([{ kind: 'reverb', impulse: FILE, predelay: 0.02 }]);
|
||||
expect(impulsesOf(plan)).toEqual([FILE]);
|
||||
// Without the project there is no sound to find.
|
||||
expect(fxChainOf(compileSoundFx(f.fx.graph).compiled!).stages[0]).not.toHaveProperty('impulse');
|
||||
made.asset.file = OTHER_FILE;
|
||||
expect(impulsesOf(soundFxBuildFor(f.fx, f.project).compiled!)).toEqual([OTHER_FILE]);
|
||||
});
|
||||
|
||||
it('rings its pre-delay and its decay, or as long as its sound', () => {
|
||||
const [made] = at({ room: 'hall' });
|
||||
expect(fxTailOf({ pitch: 0, shifted: false, stages: [made!] })).toBeCloseTo(2.52, 9);
|
||||
const sound: FxStage = { kind: 'reverb', decay: 1, damping: 0, predelay: 0.1, mix: 1, impulse: FILE };
|
||||
expect(fxTailOf({ pitch: 0, shifted: false, stages: [sound] }, () => 3)).toBeCloseTo(3.1, 9);
|
||||
expect(fxTailOf({ pitch: 0, shifted: false, stages: [sound] })).toBeCloseTo(10.1, 9);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the tail of a voice (#324)', () => {
|
||||
const echo = (time: number, feedback: number, mix: number): FxStage => ({
|
||||
kind: 'delay',
|
||||
|
||||
@@ -145,6 +145,8 @@ const clocks: {
|
||||
}[] = [];
|
||||
/** The pan of every track's panner made (#334), in order. */
|
||||
const panners: FakeParam[] = [];
|
||||
/** Every ConvolverNode made (#328), in order. */
|
||||
const convolvers: { buffer: unknown }[] = [];
|
||||
/** Every DynamicsCompressor made (#326), in order. */
|
||||
const compressors: Record<'threshold' | 'knee' | 'ratio' | 'attack' | 'release', FakeParam>[] = [];
|
||||
/** The curve of every WaveShaper made, in order. */
|
||||
@@ -281,6 +283,11 @@ function fakeContext() {
|
||||
clocks.push(clock);
|
||||
return clock;
|
||||
},
|
||||
createConvolver: () => {
|
||||
const node = wired('convolver', { buffer: null, normalize: true });
|
||||
convolvers.push(node);
|
||||
return node;
|
||||
},
|
||||
createDynamicsCompressor: () => {
|
||||
const node = wired('compressor', {
|
||||
threshold: param(-24),
|
||||
@@ -317,6 +324,7 @@ beforeEach(() => {
|
||||
clocks.length = 0;
|
||||
shapers.length = 0;
|
||||
compressors.length = 0;
|
||||
convolvers.length = 0;
|
||||
panners.length = 0;
|
||||
contextState = 'running';
|
||||
decodes = [];
|
||||
@@ -2428,4 +2436,45 @@ describe('the stages of #318 to #328', () => {
|
||||
['depth', 4],
|
||||
]);
|
||||
});
|
||||
|
||||
it('makes the impulse response of a Reverb once, and waits its pre-delay before it (#328)', () => {
|
||||
const context = fakeContext() as unknown as BaseAudioContext;
|
||||
const stage = { kind: 'reverb', decay: 0.75, damping: 0.25, predelay: 0.02, mix: 0.4 } as const;
|
||||
const made = stageNodes(context, stage);
|
||||
stageNodes(context, stage);
|
||||
const buffer = convolvers[0]!.buffer as { length: number };
|
||||
expect(buffer.length).toBe(0.75 * 48000);
|
||||
expect(convolvers[1]!.buffer).toBe(buffer);
|
||||
expect(delays.map((d) => d.delayTime.value)).toEqual([0.02, 0.02]);
|
||||
expect(made.knobs.map((k) => [k.param === null, k.target])).toEqual([
|
||||
[true, 0.75],
|
||||
[true, 0.25],
|
||||
[true, 0.02],
|
||||
[false, 0.4],
|
||||
[false, 0.6],
|
||||
]);
|
||||
});
|
||||
|
||||
it('loads the sound a Reverb takes as its impulse response with the voice, and rings as long (#328)', async () => {
|
||||
const library = new SoundLibrary();
|
||||
const plan: FxPlan = {
|
||||
pitch: [],
|
||||
stages: [
|
||||
{
|
||||
kind: 'reverb',
|
||||
impulse: 'room.wav',
|
||||
decay: { value: 1 },
|
||||
damping: { value: 0 },
|
||||
predelay: { value: 0.1 },
|
||||
mix: { value: 1 },
|
||||
},
|
||||
],
|
||||
};
|
||||
const voice = await play(library, FILE, {}, { plan, params: {} });
|
||||
expect(library.playing(voice)).toBe(true);
|
||||
expect(library.stats().decoded).toBe(2);
|
||||
expect(convolvers[0]!.buffer).toMatchObject({ length: 1200, sampleRate: 800 });
|
||||
// 1200 samples at 800 Hz, after a pre-delay of 0.1 s.
|
||||
expect(library.tail(voice)).toBeCloseTo(1.6, 9);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user