Put a sound in a room, a hall or a cave with a Reverb node (#328)
game-maker / gate (push) Successful in 4m0s
game-maker / suite (push) Failing after 13m51s

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:
adminandClaude Opus 5.5 committed 2026-10-07 21:27:22 +02:00
1 parent 071f194326
commit d349251a01
14 files changed
+544 -47

No files matched your search

+53
View File
@@ -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);
});
});
+58
View File
@@ -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',
+49
View File
@@ -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);
});
});