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
+535 -38

No files matched your search

+16 -6
View File
@@ -18,7 +18,7 @@ import { findAssetOf, type Project } from '../model/Project';
import { playedSoundOf } from '../properties/playedSound'; import { playedSoundOf } from '../properties/playedSound';
import { resolveAssetProps } from '../properties/resolve'; import { resolveAssetProps } from '../properties/resolve';
import { volumeOf } from '../properties/sounds'; import { volumeOf } from '../properties/sounds';
import { soundFxBuildFor } from '../soundfx/compile'; import { soundFxBuildFor, soundFxKey } from '../soundfx/compile';
import { import {
type FxAmount, type FxAmount,
type FxChipPlan, type FxChipPlan,
@@ -32,6 +32,8 @@ import {
SOUND_FX_OUTPUT, SOUND_FX_OUTPUT,
SoundFxMix, SoundFxMix,
type SoundFxNodeDef, type SoundFxNodeDef,
type SoundFxProject,
SoundFxReverb,
withPitch, withPitch,
withStages, withStages,
} from '../soundfx/nodes'; } from '../soundfx/nodes';
@@ -83,13 +85,13 @@ export function instrumentSourcesOf(project: Project): InstrumentSources {
}, },
soundFx: (assetId) => { soundFx: (assetId) => {
const asset = findAssetOf(project, assetId, 'soundfx'); const asset = findAssetOf(project, assetId, 'soundfx');
return asset ? soundFxBuildFor(asset).compiled : null; return asset ? soundFxBuildFor(asset, project).compiled : null;
}, },
key: (assetId) => { key: (assetId) => {
const sound = findAssetOf(project, assetId, 'sound'); const sound = findAssetOf(project, assetId, 'sound');
if (sound) return `${playedSoundOf(project, sound)}@${volumeOf(resolveAssetProps(project, sound))}`; if (sound) return `${playedSoundOf(project, sound)}@${volumeOf(resolveAssetProps(project, sound))}`;
const fx = findAssetOf(project, assetId, 'soundfx'); const fx = findAssetOf(project, assetId, 'soundfx');
return fx ? graphFingerprint(fx.graph) : ''; return fx ? soundFxKey(fx, project) : '';
}, },
}; };
} }
@@ -130,6 +132,7 @@ export function instrumentParams(graph: Graph): readonly DeclaredPort[] {
* are numbers here, `velocity` and the parameters stay open for every voice to fill in. * are numbers here, `velocity` and the parameters stay open for every voice to fill in.
*/ */
export function compileInstrument(graph: Graph, note: number, sources: InstrumentSources): InstrumentBuild { export function compileInstrument(graph: Graph, note: number, sources: InstrumentSources): InstrumentBuild {
const project: SoundFxProject = { soundFile: (assetId) => sources.sound(assetId)?.file ?? '' };
const walk = graphEmitter<Def, Value>(graph, { const walk = graphEmitter<Def, Value>(graph, {
isDef: isInstrumentDef, isDef: isInstrumentDef,
noun: 'instrument', noun: 'instrument',
@@ -224,10 +227,12 @@ export function compileInstrument(graph: Graph, note: number, sources: Instrumen
const [a] = plansAt('a'); const [a] = plansAt('a');
const [b] = plansAt('b'); const [b] = plansAt('b');
if (!a || !b) return { out: { plans: a ? [a] : b ? [b] : [] } }; if (!a || !b) return { out: { plans: a ? [a] : b ? [b] : [] } };
return { out: { plans: [def.emit({ a, b }, amounts, node.params)] } }; return { out: { plans: [def.emit({ a, b }, amounts, node.params, project)] } };
} }
const [arriving] = plansAt('in'); const [arriving] = plansAt('in');
return { out: arriving ? { plans: [def.emit({ in: arriving }, amounts, node.params)] } : SILENT }; return {
out: arriving ? { plans: [def.emit({ in: arriving }, amounts, node.params, project)] } : SILENT,
};
} }
/** A Sample's sound, or null after saying why it has none – also where its trigger is off. */ /** A Sample's sound, or null after saying why it has none – also where its trigger is off. */
@@ -383,7 +388,12 @@ export function instrumentBuildFor(
sources: InstrumentSources, sources: InstrumentSources,
): InstrumentBuild { ): InstrumentBuild {
const named = asset.graph.nodes const named = asset.graph.nodes
.map((node) => textOf(node.params[node.type === INSTRUMENT_SOUND_FX ? 'fx' : 'sound'] ?? '')) .flatMap((node) =>
node.type === INSTRUMENT_SOUND_FX
? [node.params.fx]
: [node.params.sound, ...(node.type === SoundFxReverb.type ? [node.params.impulse] : [])],
)
.map((id) => textOf(id ?? ''))
.filter((id) => id !== ''); .filter((id) => id !== '');
const fingerprint = `${graphFingerprint(asset.graph)}|${named.map((id) => sources.key(id)).join('|')}`; const fingerprint = `${graphFingerprint(asset.graph)}|${named.map((id) => sources.key(id)).join('|')}`;
let cached = builds.get(asset.id); let cached = builds.get(asset.id);
+1 -1
View File
@@ -17,7 +17,7 @@ export function fxVoiceOf(
params: FxParams = {}, params: FxParams = {},
): FxVoice | null { ): FxVoice | null {
const fx = fxId ? findAssetOf(project, fxId, 'soundfx') : undefined; const fx = fxId ? findAssetOf(project, fxId, 'soundfx') : undefined;
const plan = fx && soundFxBuildFor(fx).compiled; const plan = fx && soundFxBuildFor(fx, project).compiled;
return plan ? { plan, params, tempo } : null; return plan ? { plan, params, tempo } : null;
} }
+31 -6
View File
@@ -5,6 +5,9 @@ import { NO_LOOKUP } from '../graph/lookup';
import { inlineValue, nodeDef } from '../graph/NodeRegistry'; import { inlineValue, nodeDef } from '../graph/NodeRegistry';
import { asNumber } from '../graph/values'; import { asNumber } from '../graph/values';
import type { SoundFxAsset } from '../model/Asset'; import type { SoundFxAsset } from '../model/Asset';
import { textOf } from '../model/Properties';
import type { Project } from '../model/Project';
import { playedSoundById } from '../properties/playedSound';
import { import {
EMPTY_PLAN, EMPTY_PLAN,
type FxAmount, type FxAmount,
@@ -13,6 +16,7 @@ import {
SOUND_FX_INPUT, SOUND_FX_INPUT,
SOUND_FX_OUTPUT, SOUND_FX_OUTPUT,
type SoundFxNodeDef, type SoundFxNodeDef,
type SoundFxProject,
} from './nodes'; } from './nodes';
export interface SoundFxBuild { export interface SoundFxBuild {
@@ -47,7 +51,7 @@ function inputOutputs(node: GraphNode): Record<string, FxValue> {
* (#316); a node fed from nowhere is a problem rather than silence. A number fed from a parameter * (#316); a node fed from nowhere is a problem rather than silence. A number fed from a parameter
* stays open in the plan until a voice is started (#311). * stays open in the plan until a voice is started (#311).
*/ */
export function compileSoundFx(graph: Graph): SoundFxBuild { export function compileSoundFx(graph: Graph, project?: SoundFxProject): SoundFxBuild {
const walk = graphEmitter<SoundFxNodeDef, FxValue>(graph, { const walk = graphEmitter<SoundFxNodeDef, FxValue>(graph, {
isDef: isSoundFxNodeDef, isDef: isSoundFxNodeDef,
noun: 'sound FX', noun: 'sound FX',
@@ -60,7 +64,7 @@ export function compileSoundFx(graph: Graph): SoundFxBuild {
for (const [key, value] of Object.entries(inputs)) for (const [key, value] of Object.entries(inputs))
if ('amount' in value) amounts[key] = value.amount; if ('amount' in value) amounts[key] = value.amount;
else sounds[key] = value.plan; else sounds[key] = value.plan;
return { out: { plan: def.emit(sounds, amounts, node.params) } }; return { out: { plan: def.emit(sounds, amounts, node.params, project) } };
}, },
unfed: (node, port) => { unfed: (node, port) => {
if (port.type === 'number') return { amount: { value: asNumber(inlineValue(node, port)) } }; if (port.type === 'number') return { amount: { value: asNumber(inlineValue(node, port)) } };
@@ -91,15 +95,36 @@ export function compileSoundFx(graph: Graph): SoundFxBuild {
const builds = new Map<string, { fingerprint: string; build: SoundFxBuild }>(); const builds = new Map<string, { fingerprint: string; build: SoundFxBuild }>();
/** The sounds a graph names as the impulse response of a Reverb (#328), by asset id. */
function impulseIds(graph: Graph): string[] {
return graph.nodes.flatMap((node) => {
const id = node.type === 'SoundFxReverb' ? textOf(node.params.impulse ?? '') : '';
return id ? [id] : [];
});
}
/**
* What a build of the asset is made of: its graph, and the files of the sounds it names, which
* change when one of them is cut or swapped.
*/
export function soundFxKey(asset: SoundFxAsset, project?: Project): string {
const files = project ? impulseIds(asset.graph).map((id) => playedSoundById(project, id)) : [];
return `${graphFingerprint(asset.graph)}|${files.join('|')}`;
}
/** /**
* The asset's build, compiled again only when what it does has changed – a voice is started far * The asset's build, compiled again only when what it does has changed – a voice is started far
* more often than its graph is edited, and a moved node changes nothing. * more often than its graph is edited, and a moved node changes nothing. Without `project` a
* Reverb that takes a sound has none.
*/ */
export function soundFxBuildFor(asset: SoundFxAsset): SoundFxBuild { export function soundFxBuildFor(asset: SoundFxAsset, project?: Project): SoundFxBuild {
const fingerprint = graphFingerprint(asset.graph); const fingerprint = soundFxKey(asset, project);
const cached = builds.get(asset.id); const cached = builds.get(asset.id);
if (cached && cached.fingerprint === fingerprint) return cached.build; if (cached && cached.fingerprint === fingerprint) return cached.build;
const build = compileSoundFx(asset.graph); const build = compileSoundFx(
asset.graph,
project && { soundFile: (assetId) => playedSoundById(project, assetId) },
);
builds.set(asset.id, { fingerprint, build }); builds.set(asset.id, { fingerprint, build });
return build; return build;
} }
+116 -7
View File
@@ -1,12 +1,13 @@
import type { DeclaredPort } from '../graph/Graph'; import type { DeclaredPort } from '../graph/Graph';
import { SOUND_FX_INPUT, SOUND_FX_OUTPUT } from '../graph/nested'; import { SOUND_FX_INPUT, SOUND_FX_OUTPUT } from '../graph/nested';
import type { NodeDef, PortDef } from '../graph/NodeRegistry'; import type { NodeDef, PortDef } from '../graph/NodeRegistry';
import type { PropertyBag } from '../model/Properties'; import { type PropertyBag, textOf } from '../model/Properties';
import { DEFAULT_TEMPO, heldTempo, secondsOfQuarters } from '../music/time'; import { DEFAULT_TEMPO, heldTempo, secondsOfQuarters } from '../music/time';
import type { FlatDef } from '../properties/PropertySchema'; import type { FlatDef } from '../properties/PropertySchema';
import type { ChipShape, ChipWave } from './chip'; import type { ChipShape, ChipWave } from './chip';
import { COMPRESSOR_LATENCY, compressorMakeup, limiterLatency } from './dynamics'; import { COMPRESSOR_LATENCY, compressorMakeup, limiterLatency } from './dynamics';
import { LFO_SHAPES, type LfoShape, vibratoDelay } from './lfo'; import { LFO_SHAPES, type LfoShape, vibratoDelay } from './lfo';
import { REVERB_MAX_DECAY, REVERB_PRESETS, REVERB_ROOMS, type ReverbRoom } from './reverb';
import { import {
SATURATION_TYPES, SATURATION_TYPES,
saturationAutoGain, saturationAutoGain,
@@ -44,7 +45,21 @@ export type FxStage =
| FxLimit | FxLimit
| FxSaturate | FxSaturate
| FxLfo<'tremolo'> | FxLfo<'tremolo'>
| FxLfo<'vibrato'>; | FxLfo<'vibrato'>
| FxReverb;
/**
* A Reverb (#328): the decay and damping its impulse response is made with, or the file of a sound
* that is its impulse response; the pre-delay before it, and how much of it is heard.
*/
export interface FxReverb {
kind: 'reverb';
decay: number;
damping: number;
predelay: number;
mix: number;
impulse?: string;
}
/** A Tremolo or a Vibrato (#320): its shape, its rate in Hz, its depth (0 to 1, or semitones). */ /** A Tremolo or a Vibrato (#320): its shape, its rate in Hz, its depth (0 to 1, or semitones). */
export interface FxLfo<K extends 'tremolo' | 'vibrato' = 'tremolo' | 'vibrato'> { export interface FxLfo<K extends 'tremolo' | 'vibrato' = 'tremolo' | 'vibrato'> {
@@ -289,7 +304,8 @@ export type FxStagePlan =
FxAmount FxAmount
>) >)
| { kind: 'tremolo'; shape: LfoShape; rate: FxAmount; depth: FxAmount } | { kind: 'tremolo'; shape: LfoShape; rate: FxAmount; depth: FxAmount }
| { kind: 'vibrato'; shape: LfoShape; rate: FxAmount; depth: FxAmount }; | { kind: 'vibrato'; shape: LfoShape; rate: FxAmount; depth: FxAmount }
| ({ kind: 'reverb'; impulse?: string } & Record<'decay' | 'damping' | 'predelay' | 'mix', FxAmount>);
/** A chain with its parameters still open: compiled once, filled in for every voice by `fxChainOf`. */ /** A chain with its parameters still open: compiled once, filled in for every voice by `fxChainOf`. */
export type FxPlan = export type FxPlan =
@@ -324,9 +340,15 @@ export interface SoundFxNodeDef extends NodeDef {
sounds: Readonly<Record<string, FxPlan>>, sounds: Readonly<Record<string, FxPlan>>,
amounts: Readonly<Record<string, FxAmount>>, amounts: Readonly<Record<string, FxAmount>>,
settings: PropertyBag, settings: PropertyBag,
project?: SoundFxProject,
): FxPlan; ): FxPlan;
} }
/** What a node reaches of the project around it: the file a sound asset plays (#328), '' for none. */
export interface SoundFxProject {
soundFile(assetId: string): string;
}
export function isSoundFxNodeDef(def: NodeDef): def is SoundFxNodeDef { export function isSoundFxNodeDef(def: NodeDef): def is SoundFxNodeDef {
return def.category === 'sound' && 'emit' in def; return def.category === 'sound' && 'emit' in def;
} }
@@ -973,6 +995,63 @@ export const SoundFxVibrato: SoundFxNodeDef = {
}, },
}; };
const REVERB_ROOM = {
key: 'room',
label: 'Type',
kind: 'enum' as const,
default: 'room',
options: REVERB_ROOMS,
};
const IMPULSE = { key: 'impulse', label: 'Sound', kind: 'sound' as const, default: '' };
const REVERB_DECAY = { ...numberRow('decay', 'Decay (s)', 0.1, REVERB_MAX_DECAY, 0.01), default: 1.5 };
const REVERB_DAMPING = { ...numberRow('damping', 'Damping', 0, 1, 0.01), default: 0.4 };
const PREDELAY = { ...numberRow('predelay', 'Pre-delay (s)', 0, 0.5, 0.001), default: 0.01 };
const REVERB_MIX = { ...numberRow('mix', 'Mix', 0, 1, 0.01), default: 0.3 };
export const REVERB_ROWS = {
decay: REVERB_DECAY,
damping: REVERB_DAMPING,
predelay: PREDELAY,
mix: REVERB_MIX,
};
export function reverbRoomOf(settings: PropertyBag): ReverbRoom {
return REVERB_ROOMS.find((option) => option.value === settings[REVERB_ROOM.key])?.value ?? 'room';
}
export const SoundFxReverb: SoundFxNodeDef = {
type: 'SoundFxReverb',
label: 'Reverb',
category: 'sound',
description:
'The sound in a space, from a small room to a cave. "Type" is a space with its own decay, damping and pre-delay (Room, Hall, Cave, Plate), Custom to set them yourself, or Sound to take a sound asset as the impulse response of a real space. "decay" (s, up to 10) is how long the reverb takes to fall by 60 dB, "damping" from 0 to 1 how much faster its highs fall, "pre-delay" (s) how long it waits before it starts. "mix" is how much of it is heard instead of the sound itself: 1 is the reverb alone, for a bus that only carries reverb. Only the mix can change while the sound plays; the rest is set when the sound starts, on a bus when it gets its FX. The sound lasts until the reverb has faded.',
inputs: [audioIn, numberIn(REVERB_MIX)],
outputs: [audioOut],
params: [REVERB_ROOM, IMPULSE],
idleParams: (params) => (reverbRoomOf(params) === 'sound' ? [] : [IMPULSE.key]),
derivePorts: ({ node }) => {
const room = reverbRoomOf(node.params);
if (room === 'custom') return { inputs: [REVERB_DECAY, REVERB_DAMPING, PREDELAY].map(numberIn) };
return room === 'sound' ? { inputs: [numberIn(PREDELAY)] } : {};
},
emit: (sounds, amounts, settings, project) => {
const mix = held(amounts.mix, REVERB_MIX);
if (isNone(mix)) return arriving(sounds);
const room = reverbRoomOf(settings);
const preset = room === 'custom' || room === 'sound' ? null : REVERB_PRESETS[room];
const impulse = room === 'sound' ? (project?.soundFile(textOf(settings[IMPULSE.key] ?? '')) ?? '') : '';
return withStages(arriving(sounds), [
{
kind: 'reverb',
decay: preset ? { value: preset.decay } : held(amounts.decay, REVERB_DECAY),
damping: preset ? { value: preset.damping } : held(amounts.damping, REVERB_DAMPING),
predelay: preset ? { value: preset.predelay } : held(amounts.predelay, PREDELAY),
mix,
...(impulse && { impulse }),
},
]);
},
};
/** Where damping 0 leaves the echoes and damping 1 takes them: evenly in octaves between. */ /** Where damping 0 leaves the echoes and damping 1 takes them: evenly in octaves between. */
const UNDAMPED_HZ = 20000; const UNDAMPED_HZ = 20000;
const DAMPED_HZ = 500; const DAMPED_HZ = 500;
@@ -999,6 +1078,7 @@ export const SOUND_FX_NODES: readonly SoundFxNodeDef[] = [
SoundFxSaturation, SoundFxSaturation,
SoundFxTremolo, SoundFxTremolo,
SoundFxVibrato, SoundFxVibrato,
SoundFxReverb,
]; ];
/** An amount with the value handed in for its parameter, held to its row; a 0 there does nothing. */ /** An amount with the value handed in for its parameter, held to its row; a 0 there does nothing. */
@@ -1153,6 +1233,17 @@ function stagesOf(plan: FxPlan, params: FxParams, tempo: number): FxStage[] {
}); });
continue; continue;
} }
if (stage.kind === 'reverb') {
stages.push({
kind: 'reverb',
decay: amountOf(stage.decay, params),
damping: amountOf(stage.damping, params),
predelay: amountOf(stage.predelay, params),
mix: amountOf(stage.mix, params),
...(stage.impulse && { impulse: stage.impulse }),
});
continue;
}
if (stage.kind === 'tremolo') { if (stage.kind === 'tremolo') {
const lfo = { shape: stage.shape, rate: amountOf(stage.rate, params) }; const lfo = { shape: stage.shape, rate: amountOf(stage.rate, params) };
stages.push({ kind: 'tremolo', ...lfo, depth: amountOf(stage.depth, params) / 100 }); stages.push({ kind: 'tremolo', ...lfo, depth: amountOf(stage.depth, params) / 100 });
@@ -1247,7 +1338,7 @@ function chipOf(plan: FxChipPlan, params: FxParams): FxChip {
} }
/** The stages that sound on after their input ends: an echo, and what holds the sound back on the worklet. */ /** The stages that sound on after their input ends: an echo, and what holds the sound back on the worklet. */
const RINGING: ReadonlySet<FxStagePlan['kind']> = new Set(['delay', 'limit', 'vibrato']); const RINGING: ReadonlySet<FxStagePlan['kind']> = new Set(['delay', 'limit', 'vibrato', 'reverb']);
/** Whether a plan can ring on after its sources end (#324): it has an echo somewhere, or a Limiter. */ /** Whether a plan can ring on after its sources end (#324): it has an echo somewhere, or a Limiter. */
export function fxRings(plan: FxPlan): boolean { export function fxRings(plan: FxPlan): boolean {
@@ -1262,10 +1353,28 @@ const SILENT = 1e-3;
* How many seconds a chain rings on after its sources end (#324), at the values it has now: until * How many seconds a chain rings on after its sources end (#324), at the values it has now: until
* its last echo is 60 dB below the sound. Damping only makes echoes quieter, so it is left out and * its last echo is 60 dB below the sound. Damping only makes echoes quieter, so it is left out and
* the tail is never too short. Echoes behind each other add up, two ways of a Mix take the longer. * the tail is never too short. Echoes behind each other add up, two ways of a Mix take the longer.
* A Reverb (#328) rings its pre-delay and its decay, or as long as the sound that is its impulse
* response – `soundLength` says how long that is, the longest a Reverb may ring where it is unknown.
*/ */
export function fxTailOf(chain: FxChain): number { export function fxTailOf(
const ways = 'mix' in chain ? Math.max(fxTailOf(chain.a), fxTailOf(chain.b)) : 0; chain: FxChain,
return chain.stages.reduce((sum, stage) => sum + (stage.kind === 'delay' ? delayTail(stage) : 0), ways); soundLength: (file: string) => number = () => REVERB_MAX_DECAY,
): number {
const ways = 'mix' in chain ? Math.max(fxTailOf(chain.a, soundLength), fxTailOf(chain.b, soundLength)) : 0;
return chain.stages.reduce((sum, stage) => {
if (stage.kind === 'delay') return sum + delayTail(stage);
if (stage.kind === 'reverb')
return sum + stage.predelay + (stage.impulse ? soundLength(stage.impulse) : stage.decay);
return sum;
}, ways);
}
/** The files the Reverbs of a plan take as their impulse response (#328). */
export function impulsesOf(plan: FxPlan): string[] {
const own = plan.stages.flatMap((stage) =>
stage.kind === 'reverb' && stage.impulse ? [stage.impulse] : [],
);
return 'mix' in plan ? [...impulsesOf(plan.a), ...impulsesOf(plan.b), ...own] : own;
} }
/** /**
+71
View File
@@ -0,0 +1,71 @@
/**
* A Reverb (#328): a ConvolverNode with an impulse response made here – noise that has fallen by
* 60 dB after `decay` seconds, its highs falling faster by `damping` – or a sound of the project.
*/
export type ReverbRoom = 'room' | 'hall' | 'cave' | 'plate' | 'custom' | 'sound';
export const REVERB_ROOMS: { value: ReverbRoom; label: string }[] = [
{ value: 'room', label: 'Room' },
{ value: 'hall', label: 'Hall' },
{ value: 'cave', label: 'Cave' },
{ value: 'plate', label: 'Plate' },
{ value: 'custom', label: 'Custom' },
{ value: 'sound', label: 'Sound' },
];
/** Decay in seconds, damping from 0 to 1, pre-delay in seconds. */
export interface ReverbShape {
decay: number;
damping: number;
predelay: number;
}
export const REVERB_PRESETS: Record<'room' | 'hall' | 'cave' | 'plate', ReverbShape> = {
room: { decay: 0.6, damping: 0.5, predelay: 0.005 },
hall: { decay: 2.5, damping: 0.35, predelay: 0.02 },
cave: { decay: 5, damping: 0.15, predelay: 0.04 },
plate: { decay: 1.6, damping: 0.1, predelay: 0 },
};
/** The longest a Reverb may ring, as the issue set it. */
export const REVERB_MAX_DECAY = 10;
/** Where the highs of a fully damped tail have fallen to by its end, and an undamped one stays. */
const OPEN_HZ = 20000;
const DAMPED_HZ = 500;
/** A small, fast generator, so every voice gets the same noise and a check the same numbers. */
function noise(seed: number): () => number {
let state = seed >>> 0;
return () => {
state = (state + 0x6d2b79f5) >>> 0;
let t = state;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return (((t ^ (t >>> 14)) >>> 0) / 4294967296) * 2 - 1;
};
}
/**
* The impulse response of a Reverb, one channel per side, each its own noise: `decay` seconds long,
* 60 dB down by its end, and through a low-pass that closes from 20 kHz towards 500 Hz over that
* time as far as `damping` says.
*/
export function reverbImpulse(sampleRate: number, decay: number, damping: number): Float32Array[] {
const length = Math.max(1, Math.round(Math.min(REVERB_MAX_DECAY, Math.max(0.01, decay)) * sampleRate));
const shut = Math.max(0, Math.min(1, damping));
return [1, 2].map((seed) => {
const next = noise(seed);
const out = new Float32Array(length);
let low = 0;
for (let i = 0; i < length; i++) {
const t = i / length;
const cutoff = OPEN_HZ * (DAMPED_HZ / OPEN_HZ) ** (shut * t);
const keep = Math.exp((-2 * Math.PI * cutoff) / sampleRate);
low = (1 - keep) * next() + keep * low;
out[i] = low * 10 ** (-3 * t);
}
return out;
});
}
+4 -3
View File
@@ -82,10 +82,11 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
sampleRate: () => soundLibrary.liveContext().sampleRate, sampleRate: () => soundLibrary.liveContext().sampleRate,
/** /**
* The nodes a stage of a voice becomes, in any context – an offline one renders what it does. * The nodes a stage of a voice becomes, in any context – an offline one renders what it does.
* `at` is when its voice starts, which a Flanger's sweep counts from (#341). * `at` is when its voice starts, which a Flanger's sweep counts from (#341), `impulse` the
* sound a Reverb that takes one has (#328).
*/ */
stageNodes: (context: BaseAudioContext, stage: FxStage, at?: number) => stageNodes: (context: BaseAudioContext, stage: FxStage, at?: number, impulse?: AudioBuffer) =>
stageNodes(context, stage, undefined, at), stageNodes(context, stage, undefined, at, impulse),
/** The gain in dB a Saturation's auto-gain gives at a drive (#323). */ /** The gain in dB a Saturation's auto-gain gives at a drive (#323). */
saturationAutoGain, saturationAutoGain,
/** What drives the two shares of a Mix "Both at center", in any context (unstarted). */ /** What drives the two shares of a Mix "Both at center", in any context (unstarted). */
+1 -1
View File
@@ -311,7 +311,7 @@ export class GraphCanvas extends Widget {
: asset.type === 'particles' : asset.type === 'particles'
? particleLibrary.buildFor(asset).problems ? particleLibrary.buildFor(asset).problems
: asset.type === 'soundfx' : asset.type === 'soundfx'
? soundFxBuildFor(asset).problems ? soundFxBuildFor(asset, this.state.project).problems
: asset.type === 'instrument' : asset.type === 'instrument'
? instrumentCheck(asset, instrumentSourcesOf(this.state.project)).problems ? instrumentCheck(asset, instrumentSourcesOf(this.state.project)).problems
: asset.type === 'song' : asset.type === 'song'
+5 -3
View File
@@ -100,7 +100,7 @@ export class SoundFxEditor extends AssetTabEditor<SoundFxAsset, GraphCanvas> {
play(): void { play(): void {
const asset = this.asset(); const asset = this.asset();
const sound = findAssetOf(this.state.project, this.previewSoundId, 'sound'); const sound = findAssetOf(this.state.project, this.previewSoundId, 'sound');
const plan = asset && soundFxBuildFor(asset).compiled; const plan = asset && soundFxBuildFor(asset, this.state.project).compiled;
if (!sound?.file || !plan) return; if (!sound?.file || !plan) return;
const props = resolveAssetProps(this.state.project, sound); const props = resolveAssetProps(this.state.project, sound);
const fx = { plan, params: { ...this.values } }; const fx = { plan, params: { ...this.values } };
@@ -123,7 +123,7 @@ export class SoundFxEditor extends AssetTabEditor<SoundFxAsset, GraphCanvas> {
} }
protected override infoText(asset: SoundFxAsset): string { protected override infoText(asset: SoundFxAsset): string {
const build = soundFxBuildFor(asset); const build = soundFxBuildFor(asset, this.state.project);
if (!build.compiled) return build.reason; if (!build.compiled) return build.reason;
return `${build.effects} ${build.effects === 1 ? 'effect' : 'effects'}`; return `${build.effects} ${build.effects === 1 ? 'effect' : 'effects'}`;
} }
@@ -133,7 +133,9 @@ export class SoundFxEditor extends AssetTabEditor<SoundFxAsset, GraphCanvas> {
const sound = findAssetOf(this.state.project, this.previewSoundId, 'sound'); const sound = findAssetOf(this.state.project, this.previewSoundId, 'sound');
this.soundPicker.setText(sound?.name ?? 'No sound'); this.soundPicker.setText(sound?.name ?? 'No sound');
this.soundPicker.setEnabled(Boolean(asset && sound)); this.soundPicker.setEnabled(Boolean(asset && sound));
this.playButton.setEnabled(Boolean(asset && sound?.file && soundFxBuildFor(asset).compiled)); this.playButton.setEnabled(
Boolean(asset && sound?.file && soundFxBuildFor(asset, this.state.project).compiled),
);
this.stopButton.setEnabled(Boolean(asset)); this.stopButton.setEnabled(Boolean(asset));
this.syncParams(asset); this.syncParams(asset);
} }
+1 -1
View File
@@ -288,7 +288,7 @@ function playSound(
const props = resolveAssetProps(project, asset); const props = resolveAssetProps(project, asset);
const level = Math.max(0, Math.min(1, volumeOf(props) * volume)); const level = Math.max(0, Math.min(1, volumeOf(props) * volume));
const fx = fxId ? findAssetOf(project, fxId, 'soundfx') : undefined; const fx = fxId ? findAssetOf(project, fxId, 'soundfx') : undefined;
const plan = fx && soundFxBuildFor(fx).compiled; const plan = fx && soundFxBuildFor(fx, project).compiled;
return audio.play( return audio.play(
playedSoundOf(project, asset), playedSoundOf(project, asset),
level, level,
+78 -10
View File
@@ -16,9 +16,11 @@ import {
type FxMod, type FxMod,
type FxPass, type FxPass,
type FxPhaser, type FxPhaser,
type FxReverb,
type FxSaturate, type FxSaturate,
fxRings, fxRings,
fxTailOf, fxTailOf,
impulsesOf,
type FxParams, type FxParams,
type FxPlan, type FxPlan,
type FxStage, type FxStage,
@@ -38,6 +40,7 @@ import { DEFAULT_TEMPO } from '../core/music/time';
import { type CutRange, cutChannels, cutRange, parseCutName } from '../core/model/sound'; import { type CutRange, cutChannels, cutRange, parseCutName } from '../core/model/sound';
import { shifterLatency } from '../core/soundfx/pitchPlayer'; import { shifterLatency } from '../core/soundfx/pitchPlayer';
import { LFO_SHAPES } from '../core/soundfx/lfo'; import { LFO_SHAPES } from '../core/soundfx/lfo';
import { REVERB_MAX_DECAY, reverbImpulse } from '../core/soundfx/reverb';
import { driveGain, saturationCurve, SHAPER_LATENCY_FRAMES } from '../core/soundfx/saturation'; import { driveGain, saturationCurve, SHAPER_LATENCY_FRAMES } from '../core/soundfx/saturation';
import type { AudioSink, ClipTiming, NoteTiming } from '../runtime/systems/Audio'; import type { AudioSink, ClipTiming, NoteTiming } from '../runtime/systems/Audio';
import type { ChipOptions, FlangerOptions, LfoOptions, PhaserOptions, PitchOptions } from './fxWorklet'; import type { ChipOptions, FlangerOptions, LfoOptions, PhaserOptions, PitchOptions } from './fxWorklet';
@@ -678,7 +681,10 @@ export class SoundLibrary implements AudioSink {
bus.fx = fx; bus.fx = fx;
this.unchain(bus); this.unchain(bus);
if (!fx) return; if (!fx) return;
if (needsWorklet(fx.plan)) await this.fxWorklet(); await Promise.all([
needsWorklet(fx.plan) ? this.fxWorklet() : null,
...impulsesOf(fx.plan).map((file) => this.load(file)),
]);
if (bus.fx === fx && !bus.chain) this.chain(bus); if (bus.fx === fx && !bus.chain) this.chain(bus);
} }
@@ -766,7 +772,8 @@ export class SoundLibrary implements AudioSink {
for (const stage of chain.stages) { for (const stage of chain.stages) {
// A sound FX has no Envelope; one would only silence the bus. // A sound FX has no Envelope; one would only silence the bus.
if (stage.kind === 'envelope') continue; if (stage.kind === 'envelope') continue;
const stageMade = stageNodes(context, stage, undefined, context.currentTime); const impulse = stage.kind === 'reverb' && stage.impulse ? this.buffer(stage.impulse) : null;
const stageMade = stageNodes(context, stage, undefined, context.currentTime, impulse);
latency += stageLatency(stage, context.sampleRate); latency += stageLatency(stage, context.sampleRate);
tail.connect(stageMade.input); tail.connect(stageMade.input);
tail = stageMade.output; tail = stageMade.output;
@@ -812,7 +819,8 @@ export class SoundLibrary implements AudioSink {
*/ */
private ringOf(name: string): number { private ringOf(name: string): number {
const fx = this.buses.get(name)?.fx; const fx = this.buses.get(name)?.fx;
const tail = fx && fxRings(fx.plan) ? fxTailOf(fxChainOf(fx.plan, fx.params, fx.tempo)) : 0; const tail =
fx && fxRings(fx.plan) ? fxTailOf(fxChainOf(fx.plan, fx.params, fx.tempo), this.soundLength) : 0;
return tail + this.busLatency(name); return tail + this.busLatency(name);
} }
@@ -1032,7 +1040,9 @@ export class SoundLibrary implements AudioSink {
private begin(start: Start): number { private begin(start: Start): number {
const id = this.nextId++; const id = this.nextId++;
const asked: Start = { ...start, id }; const asked: Start = { ...start, id };
const loaded = start.files.every((file) => this.buffer(file) !== null); // A Reverb's sound (#328) is waited for as well, but a voice is not silent for lack of it.
const impulseFiles = start.fx.layers.flatMap(impulsesOf);
const loaded = [...start.files, ...impulseFiles].every((file) => this.buffer(file) !== null);
const worklet = start.fx.layers.some(needsWorklet) ? this.fxWorklet() : null; const worklet = start.fx.layers.some(needsWorklet) ? this.fxWorklet() : null;
if (loaded && (!worklet || this.hasWorklet !== null)) { if (loaded && (!worklet || this.hasWorklet !== null)) {
this.start(asked); this.start(asked);
@@ -1048,7 +1058,9 @@ export class SoundLibrary implements AudioSink {
released: false, released: false,
}; };
this.pending.push(waiting); this.pending.push(waiting);
void Promise.all([Promise.all(start.files.map((file) => this.load(file))), worklet]).then(([buffers]) => { const impulsesLoaded = Promise.all(impulseFiles.map((file) => this.load(file)));
void Promise.all([Promise.all(start.files.map((file) => this.load(file))), worklet, impulsesLoaded]).then(
([buffers]) => {
this.pending = this.pending.filter((other) => other !== waiting); this.pending = this.pending.filter((other) => other !== waiting);
// A `Stop sound` – or the destruction of the behavior that asked – that happened while the // A `Stop sound` – or the destruction of the behavior that asked – that happened while the
// bytes were in flight has to win, or a sound the run already ended would start afterwards. // bytes were in flight has to win, or a sound the run already ended would start afterwards.
@@ -1058,7 +1070,8 @@ export class SoundLibrary implements AudioSink {
this.start({ ...asked, fx: waiting.fx }); this.start({ ...asked, fx: waiting.fx });
for (const { params, time, ease } of waiting.timed) this.tune(id, params, time, ease); for (const { params, time, ease } of waiting.timed) this.tune(id, params, time, ease);
if (waiting.released) this.release(id); if (waiting.released) this.release(id);
}); },
);
return id; return id;
} }
@@ -1251,7 +1264,7 @@ export class SoundLibrary implements AudioSink {
*/ */
async prepare(layers: readonly FxPlan[], files: readonly string[] = []): Promise<void> { async prepare(layers: readonly FxPlan[], files: readonly string[] = []): Promise<void> {
await Promise.all([ await Promise.all([
...[...new Set([...layers.flatMap(filesOf), ...files])] ...[...new Set([...layers.flatMap(filesOf), ...layers.flatMap(impulsesOf), ...files])]
.filter((file) => file !== '') .filter((file) => file !== '')
.map((file) => this.load(file)), .map((file) => this.load(file)),
layers.some(needsWorklet) ? this.fxWorklet() : null, layers.some(needsWorklet) ? this.fxWorklet() : null,
@@ -1441,7 +1454,7 @@ export class SoundLibrary implements AudioSink {
return fx.layers.reduce((longest, plan) => { return fx.layers.reduce((longest, plan) => {
if (!fxRings(plan)) return longest; if (!fxRings(plan)) return longest;
const chain = fxChainOf(plan, fx.params, fx.tempo); const chain = fxChainOf(plan, fx.params, fx.tempo);
return Math.max(longest, fxTailOf(chain) + fxLatencyOf(chain, rate)); return Math.max(longest, fxTailOf(chain, this.soundLength) + fxLatencyOf(chain, rate));
}, 0); }, 0);
} }
@@ -1503,7 +1516,7 @@ export class SoundLibrary implements AudioSink {
at: when, at: when,
// Held here, not looked up later: a cut can be let go while its voice plays. // Held here, not looked up later: a cut can be let go while its voice plays.
buffers: new Map( buffers: new Map(
files.flatMap((name) => { [...files, ...fx.layers.flatMap(impulsesOf)].flatMap((name) => {
const buffer = this.buffer(name); const buffer = this.buffer(name);
return buffer ? [[name, buffer] as const] : []; return buffer ? [[name, buffer] as const] : [];
}), }),
@@ -1611,7 +1624,9 @@ export class SoundLibrary implements AudioSink {
const under = wiring.sources.slice(first); const under = wiring.sources.slice(first);
for (const stage of chain.stages) { for (const stage of chain.stages) {
if (stage.kind === 'envelope') this.envelope(wiring, stage.envelope).sources.push(...under); if (stage.kind === 'envelope') this.envelope(wiring, stage.envelope).sources.push(...under);
const made = stageNodes(context, stage, follow, wiring.at); const impulse =
stage.kind === 'reverb' && stage.impulse ? (wiring.buffers.get(stage.impulse) ?? null) : null;
const made = stageNodes(context, stage, follow, wiring.at, impulse);
tail.connect(made.input); tail.connect(made.input);
tail = made.output; tail = made.output;
knobs.push(...made.knobs); knobs.push(...made.knobs);
@@ -1861,6 +1876,9 @@ export class SoundLibrary implements AudioSink {
return { whole, ...cutRange(whole.length, whole.sampleRate, cut) }; return { whole, ...cutRange(whole.length, whole.sampleRate, cut) };
} }
/** How long a sound a Reverb takes as its impulse response rings (#328): all it may, while unknown. */
private readonly soundLength = (file: string): number => this.duration(file) ?? REVERB_MAX_DECAY;
/** Whether the file of a name could not be had: a cut of it cannot either. */ /** Whether the file of a name could not be had: a cut of it cannot either. */
private hasFailed(name: string): boolean { private hasFailed(name: string): boolean {
return this.failed.has(parseCutName(name).file); return this.failed.has(parseCutName(name).file);
@@ -2045,6 +2063,7 @@ export function stageNodes(
stage: FxStage, stage: FxStage,
follow?: Follow, follow?: Follow,
at = context.currentTime, at = context.currentTime,
impulse: AudioBuffer | null = null,
): StageNodes { ): StageNodes {
if (stage.kind === 'gain') { if (stage.kind === 'gain') {
const gain = gainNode(context, stage.mod ? 0 : stage.gain); const gain = gainNode(context, stage.mod ? 0 : stage.gain);
@@ -2064,6 +2083,7 @@ export function stageNodes(
if (stage.kind === 'flanger') return flangerNodes(context, stage, at, follow); if (stage.kind === 'flanger') return flangerNodes(context, stage, at, follow);
if (stage.kind === 'compress') return compressNodes(context, stage); if (stage.kind === 'compress') return compressNodes(context, stage);
if (stage.kind === 'saturate') return saturateNodes(context, stage); if (stage.kind === 'saturate') return saturateNodes(context, stage);
if (stage.kind === 'reverb') return reverbNodes(context, stage, impulse);
if (stage.kind === 'tremolo' || stage.kind === 'vibrato') return lfoNodes(context, stage, at); if (stage.kind === 'tremolo' || stage.kind === 'vibrato') return lfoNodes(context, stage, at);
if (stage.kind === 'limit') if (stage.kind === 'limit')
return workletNodes(context, 'limiter', [ return workletNodes(context, 'limiter', [
@@ -2238,6 +2258,52 @@ function lfoNodes(context: BaseAudioContext, stage: FxLfo, at: number): StageNod
); );
} }
/** Impulse responses made for a Reverb, by rate, decay and damping: the last few are kept. */
const impulses = new Map<string, AudioBuffer>();
const KEPT_IMPULSES = 16;
/** The impulse response a Reverb makes for itself (#328), made once for these numbers. */
function madeImpulse(context: BaseAudioContext, decay: number, damping: number): AudioBuffer {
const key = `${context.sampleRate}|${decay}|${damping}`;
let buffer = impulses.get(key);
if (!buffer) {
const channels = reverbImpulse(context.sampleRate, decay, damping);
buffer = context.createBuffer(channels.length, channels[0]!.length, context.sampleRate);
channels.forEach((data, c) => buffer!.copyToChannel(data as Float32Array<ArrayBuffer>, c));
impulses.set(key, buffer);
if (impulses.size > KEPT_IMPULSES) impulses.delete(impulses.keys().next().value!);
}
return buffer;
}
/**
* A Reverb (#328): the sound itself, and beside it through the pre-delay into a ConvolverNode with
* the impulse response made for it – or the one of a sound, `impulse`, where it takes a sound; a
* sound not loaded leaves the reverb silent. Only the mix moves while it plays.
*/
function reverbNodes(context: BaseAudioContext, stage: FxReverb, impulse: AudioBuffer | null): StageNodes {
const input = context.createGain();
const output = context.createGain();
const convolver = context.createConvolver();
const buffer = stage.impulse ? impulse : madeImpulse(context, stage.decay, stage.damping);
if (buffer) convolver.buffer = buffer;
const wet = gainNode(context, stage.mix);
const dry = gainNode(context, 1 - stage.mix);
heldBack(context, input, stage.predelay).connect(convolver).connect(wet).connect(output);
input.connect(dry).connect(output);
return {
input,
output,
knobs: [
{ param: null, target: stage.decay, even: 'value' },
{ param: null, target: stage.damping, even: 'value' },
{ param: null, target: stage.predelay, even: 'value' },
{ param: wet.gain, target: stage.mix, even: 'value' },
{ param: dry.gain, target: 1 - stage.mix, even: 'value' },
],
};
}
/** Below what a Saturation takes out the DC its bent sound gained (#323). */ /** Below what a Saturation takes out the DC its bent sound gained (#323). */
const DC_BLOCK_HZ = 10; const DC_BLOCK_HZ = 10;
@@ -2569,6 +2635,8 @@ function levelsOf(chain: FxChain, into: number[] = []): number[] {
into.push(stage.threshold, stage.knee, stage.ratio, stage.attack, stage.release, stage.gain); 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 === 'limit') into.push(stage.threshold, stage.ceiling, stage.release, stage.autoGain);
else if (stage.kind === 'tremolo' || stage.kind === 'vibrato') into.push(stage.rate, stage.depth); else if (stage.kind === 'tremolo' || stage.kind === 'vibrato') into.push(stage.rate, stage.depth);
else if (stage.kind === 'reverb')
into.push(stage.decay, stage.damping, stage.predelay, stage.mix, 1 - stage.mix);
else if (stage.kind === 'saturate') else if (stage.kind === 'saturate')
into.push(driveGain(stage.drive), stage.tone, stage.level, stage.mix, 1 - stage.mix); into.push(driveGain(stage.drive), stage.tone, stage.level, stage.mix, 1 - stage.mix);
else if (stage.kind !== 'envelope') into.push(stage.gain); else if (stage.kind !== 'envelope') into.push(stage.gain);
+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, type FxStage,
fxTailOf, fxTailOf,
type FxVoice, type FxVoice,
impulsesOf,
SoundFxReverb,
stageLatency, stageLatency,
} from '../../src/core/soundfx/nodes'; } from '../../src/core/soundfx/nodes';
import { saturationAutoGain } from '../../src/core/soundfx/saturation'; import { saturationAutoGain } from '../../src/core/soundfx/saturation';
@@ -43,6 +45,7 @@ import {
} from '../helpers'; } from '../helpers';
const FILE = `${'a1'.repeat(32)}.wav`; const FILE = `${'a1'.repeat(32)}.wav`;
const OTHER_FILE = `${'b2'.repeat(32)}.wav`;
/** The level of a gain or filter stage. */ /** The level of a gain or filter stage. */
const gainOf = (stage: FxStage | undefined) => (stage && 'gain' in stage ? stage.gain : undefined); const gainOf = (stage: FxStage | undefined) => (stage && 'gain' in stage ? stage.gain : undefined);
@@ -133,6 +136,7 @@ describe('the sound FX asset', () => {
'SoundFxSaturation', 'SoundFxSaturation',
'SoundFxTremolo', 'SoundFxTremolo',
'SoundFxVibrato', 'SoundFxVibrato',
'SoundFxReverb',
]); ]);
f.edit.addNode('SoundFxInput'); f.edit.addNode('SoundFxInput');
f.edit.addNode('SoundFxOutput'); f.edit.addNode('SoundFxOutput');
@@ -152,6 +156,7 @@ describe('the sound FX asset', () => {
'SoundFxSaturation', 'SoundFxSaturation',
'SoundFxTremolo', 'SoundFxTremolo',
'SoundFxVibrato', 'SoundFxVibrato',
'SoundFxReverb',
]); ]);
const behavior = makeBehavior(f, 'Logic'); 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)', () => { describe('the tail of a voice (#324)', () => {
const echo = (time: number, feedback: number, mix: number): FxStage => ({ const echo = (time: number, feedback: number, mix: number): FxStage => ({
kind: 'delay', kind: 'delay',
+49
View File
@@ -145,6 +145,8 @@ const clocks: {
}[] = []; }[] = [];
/** The pan of every track's panner made (#334), in order. */ /** The pan of every track's panner made (#334), in order. */
const panners: FakeParam[] = []; const panners: FakeParam[] = [];
/** Every ConvolverNode made (#328), in order. */
const convolvers: { buffer: unknown }[] = [];
/** Every DynamicsCompressor made (#326), in order. */ /** Every DynamicsCompressor made (#326), in order. */
const compressors: Record<'threshold' | 'knee' | 'ratio' | 'attack' | 'release', FakeParam>[] = []; const compressors: Record<'threshold' | 'knee' | 'ratio' | 'attack' | 'release', FakeParam>[] = [];
/** The curve of every WaveShaper made, in order. */ /** The curve of every WaveShaper made, in order. */
@@ -281,6 +283,11 @@ function fakeContext() {
clocks.push(clock); clocks.push(clock);
return clock; return clock;
}, },
createConvolver: () => {
const node = wired('convolver', { buffer: null, normalize: true });
convolvers.push(node);
return node;
},
createDynamicsCompressor: () => { createDynamicsCompressor: () => {
const node = wired('compressor', { const node = wired('compressor', {
threshold: param(-24), threshold: param(-24),
@@ -317,6 +324,7 @@ beforeEach(() => {
clocks.length = 0; clocks.length = 0;
shapers.length = 0; shapers.length = 0;
compressors.length = 0; compressors.length = 0;
convolvers.length = 0;
panners.length = 0; panners.length = 0;
contextState = 'running'; contextState = 'running';
decodes = []; decodes = [];
@@ -2428,4 +2436,45 @@ describe('the stages of #318 to #328', () => {
['depth', 4], ['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);
});
}); });
+51
View File
@@ -1145,6 +1145,57 @@ export async function sound(t) {
Math.abs(swung.down - 940.5) < 1, Math.abs(swung.down - 940.5) < 1,
JSON.stringify(swung), JSON.stringify(swung),
); );
// #328: Reverb rendered offline. An impulse at 0.05 s through a Custom reverb of 1 s with a
// pre-delay of 50 ms: nothing of it before 0.1 s, then it falls by 60 dB a second, 36 dB
// from 0.2-0.3 s to 0.8-0.9 s after it. At mix 0.5 the impulse itself is there at half.
// With a sound as its impulse response – one sample at 0.2 s – the impulse comes back
// 0.2 s and the pre-delay later, and nowhere else.
const reverbed = await evaluate(`
(async () => {
const RATE = 48000;
const render = async (stage, impulse) => {
const context = new OfflineAudioContext(1, RATE * 1.5, RATE);
const sound = context.createBuffer(1, RATE * 1.5, RATE);
sound.getChannelData(0)[RATE * 0.05] = 0.5;
let ir;
if (impulse) {
ir = context.createBuffer(2, RATE / 2, RATE);
for (let c = 0; c < 2; c++) ir.getChannelData(c)[RATE * impulse] = 1;
}
const source = context.createBufferSource();
source.buffer = sound;
const nodes = __gm.sound.stageNodes(context, { kind: 'reverb', decay: 1, damping: 0, predelay: 0.05, mix: 1, ...stage }, 0, ir);
source.connect(nodes.input);
nodes.output.connect(context.destination);
source.start();
return (await context.startRendering()).getChannelData(0);
};
const db = (out, from, to) => {
let sum = 0;
for (let i = from * RATE; i < to * RATE; i++) sum += out[i] ** 2;
return 10 * Math.log10(sum / ((to - from) * RATE));
};
const peak = (out, from, to) => Math.max(...out.subarray(from * RATE, to * RATE).map(Math.abs));
const wet = await render({});
const half = await render({ mix: 0.5 });
const sounded = await render({ impulse: 'room.wav' }, 0.2);
const heard = [];
sounded.forEach((v, i) => { if (Math.abs(v) > 1e-6) heard.push(i); });
return {
before: peak(wet, 0, 0.0999),
fall: +(db(wet, 0.25, 0.35) - db(wet, 0.85, 0.95)).toFixed(1),
dry: +half[RATE * 0.05].toFixed(4),
sound: [heard[0], heard.at(-1)],
};
})()`);
check(
'a Reverb waits its pre-delay, falls 60 dB in its decay, and takes a sound as its impulse response',
reverbed.before === 0 &&
Math.abs(reverbed.fall - 36) < 3 &&
reverbed.dry === 0.25 &&
JSON.stringify(reverbed.sound) === JSON.stringify([0.3 * 48000, 0.3 * 48000]),
JSON.stringify(reverbed),
);
// #329: the Chip on the worklet, rendered offline. A square at 220 Hz with a pitch of 12 and a duty // #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 // 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 // quarter of each. Sliding two octaves a second down from 440 Hz with a lowest frequency of