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 { resolveAssetProps } from '../properties/resolve';
import { volumeOf } from '../properties/sounds';
import { soundFxBuildFor } from '../soundfx/compile';
import { soundFxBuildFor, soundFxKey } from '../soundfx/compile';
import {
type FxAmount,
type FxChipPlan,
@@ -32,6 +32,8 @@ import {
SOUND_FX_OUTPUT,
SoundFxMix,
type SoundFxNodeDef,
type SoundFxProject,
SoundFxReverb,
withPitch,
withStages,
} from '../soundfx/nodes';
@@ -83,13 +85,13 @@ export function instrumentSourcesOf(project: Project): InstrumentSources {
},
soundFx: (assetId) => {
const asset = findAssetOf(project, assetId, 'soundfx');
return asset ? soundFxBuildFor(asset).compiled : null;
return asset ? soundFxBuildFor(asset, project).compiled : null;
},
key: (assetId) => {
const sound = findAssetOf(project, assetId, 'sound');
if (sound) return `${playedSoundOf(project, sound)}@${volumeOf(resolveAssetProps(project, sound))}`;
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.
*/
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, {
isDef: isInstrumentDef,
noun: 'instrument',
@@ -224,10 +227,12 @@ export function compileInstrument(graph: Graph, note: number, sources: Instrumen
const [a] = plansAt('a');
const [b] = plansAt('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');
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. */
@@ -383,7 +388,12 @@ export function instrumentBuildFor(
sources: InstrumentSources,
): InstrumentBuild {
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 !== '');
const fingerprint = `${graphFingerprint(asset.graph)}|${named.map((id) => sources.key(id)).join('|')}`;
let cached = builds.get(asset.id);
+1 -1
View File
@@ -17,7 +17,7 @@ export function fxVoiceOf(
params: FxParams = {},
): FxVoice | null {
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;
}
+31 -6
View File
@@ -5,6 +5,9 @@ import { NO_LOOKUP } from '../graph/lookup';
import { inlineValue, nodeDef } from '../graph/NodeRegistry';
import { asNumber } from '../graph/values';
import type { SoundFxAsset } from '../model/Asset';
import { textOf } from '../model/Properties';
import type { Project } from '../model/Project';
import { playedSoundById } from '../properties/playedSound';
import {
EMPTY_PLAN,
type FxAmount,
@@ -13,6 +16,7 @@ import {
SOUND_FX_INPUT,
SOUND_FX_OUTPUT,
type SoundFxNodeDef,
type SoundFxProject,
} from './nodes';
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
* 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, {
isDef: isSoundFxNodeDef,
noun: 'sound FX',
@@ -60,7 +64,7 @@ export function compileSoundFx(graph: Graph): SoundFxBuild {
for (const [key, value] of Object.entries(inputs))
if ('amount' in value) amounts[key] = value.amount;
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) => {
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 }>();
/** 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
* 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 {
const fingerprint = graphFingerprint(asset.graph);
export function soundFxBuildFor(asset: SoundFxAsset, project?: Project): SoundFxBuild {
const fingerprint = soundFxKey(asset, project);
const cached = builds.get(asset.id);
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 });
return build;
}
+116 -7
View File
@@ -1,12 +1,13 @@
import type { DeclaredPort } from '../graph/Graph';
import { SOUND_FX_INPUT, SOUND_FX_OUTPUT } from '../graph/nested';
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 type { FlatDef } from '../properties/PropertySchema';
import type { ChipShape, ChipWave } from './chip';
import { COMPRESSOR_LATENCY, compressorMakeup, limiterLatency } from './dynamics';
import { LFO_SHAPES, type LfoShape, vibratoDelay } from './lfo';
import { REVERB_MAX_DECAY, REVERB_PRESETS, REVERB_ROOMS, type ReverbRoom } from './reverb';
import {
SATURATION_TYPES,
saturationAutoGain,
@@ -44,7 +45,21 @@ export type FxStage =
| FxLimit
| FxSaturate
| 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). */
export interface FxLfo<K extends 'tremolo' | 'vibrato' = 'tremolo' | 'vibrato'> {
@@ -289,7 +304,8 @@ export type FxStagePlan =
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`. */
export type FxPlan =
@@ -324,9 +340,15 @@ export interface SoundFxNodeDef extends NodeDef {
sounds: Readonly<Record<string, FxPlan>>,
amounts: Readonly<Record<string, FxAmount>>,
settings: PropertyBag,
project?: SoundFxProject,
): 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 {
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. */
const UNDAMPED_HZ = 20000;
const DAMPED_HZ = 500;
@@ -999,6 +1078,7 @@ export const SOUND_FX_NODES: readonly SoundFxNodeDef[] = [
SoundFxSaturation,
SoundFxTremolo,
SoundFxVibrato,
SoundFxReverb,
];
/** 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;
}
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') {
const lfo = { shape: stage.shape, rate: amountOf(stage.rate, params) };
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. */
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. */
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
* 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.
* 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 {
const ways = 'mix' in chain ? Math.max(fxTailOf(chain.a), fxTailOf(chain.b)) : 0;
return chain.stages.reduce((sum, stage) => sum + (stage.kind === 'delay' ? delayTail(stage) : 0), ways);
export function fxTailOf(
chain: FxChain,
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,
/**
* 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, stage, undefined, at),
stageNodes: (context: BaseAudioContext, stage: FxStage, at?: number, impulse?: AudioBuffer) =>
stageNodes(context, stage, undefined, at, impulse),
/** The gain in dB a Saturation's auto-gain gives at a drive (#323). */
saturationAutoGain,
/** 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'
? particleLibrary.buildFor(asset).problems
: asset.type === 'soundfx'
? soundFxBuildFor(asset).problems
? soundFxBuildFor(asset, this.state.project).problems
: asset.type === 'instrument'
? instrumentCheck(asset, instrumentSourcesOf(this.state.project)).problems
: asset.type === 'song'
+5 -3
View File
@@ -100,7 +100,7 @@ export class SoundFxEditor extends AssetTabEditor<SoundFxAsset, GraphCanvas> {
play(): void {
const asset = this.asset();
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;
const props = resolveAssetProps(this.state.project, sound);
const fx = { plan, params: { ...this.values } };
@@ -123,7 +123,7 @@ export class SoundFxEditor extends AssetTabEditor<SoundFxAsset, GraphCanvas> {
}
protected override infoText(asset: SoundFxAsset): string {
const build = soundFxBuildFor(asset);
const build = soundFxBuildFor(asset, this.state.project);
if (!build.compiled) return build.reason;
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');
this.soundPicker.setText(sound?.name ?? 'No 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.syncParams(asset);
}
+1 -1
View File
@@ -288,7 +288,7 @@ function playSound(
const props = resolveAssetProps(project, asset);
const level = Math.max(0, Math.min(1, volumeOf(props) * volume));
const fx = fxId ? findAssetOf(project, fxId, 'soundfx') : undefined;
const plan = fx && soundFxBuildFor(fx).compiled;
const plan = fx && soundFxBuildFor(fx, project).compiled;
return audio.play(
playedSoundOf(project, asset),
level,
+78 -10
View File
@@ -16,9 +16,11 @@ import {
type FxMod,
type FxPass,
type FxPhaser,
type FxReverb,
type FxSaturate,
fxRings,
fxTailOf,
impulsesOf,
type FxParams,
type FxPlan,
type FxStage,
@@ -38,6 +40,7 @@ import { DEFAULT_TEMPO } from '../core/music/time';
import { type CutRange, cutChannels, cutRange, parseCutName } from '../core/model/sound';
import { shifterLatency } from '../core/soundfx/pitchPlayer';
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 type { AudioSink, ClipTiming, NoteTiming } from '../runtime/systems/Audio';
import type { ChipOptions, FlangerOptions, LfoOptions, PhaserOptions, PitchOptions } from './fxWorklet';
@@ -678,7 +681,10 @@ export class SoundLibrary implements AudioSink {
bus.fx = fx;
this.unchain(bus);
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);
}
@@ -766,7 +772,8 @@ export class SoundLibrary implements AudioSink {
for (const stage of chain.stages) {
// A sound FX has no Envelope; one would only silence the bus.
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);
tail.connect(stageMade.input);
tail = stageMade.output;
@@ -812,7 +819,8 @@ export class SoundLibrary implements AudioSink {
*/
private ringOf(name: string): number {
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);
}
@@ -1032,7 +1040,9 @@ export class SoundLibrary implements AudioSink {
private begin(start: Start): number {
const id = this.nextId++;
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;
if (loaded && (!worklet || this.hasWorklet !== null)) {
this.start(asked);
@@ -1048,7 +1058,9 @@ export class SoundLibrary implements AudioSink {
released: false,
};
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);
// 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.
@@ -1058,7 +1070,8 @@ export class SoundLibrary implements AudioSink {
this.start({ ...asked, fx: waiting.fx });
for (const { params, time, ease } of waiting.timed) this.tune(id, params, time, ease);
if (waiting.released) this.release(id);
});
},
);
return id;
}
@@ -1251,7 +1264,7 @@ export class SoundLibrary implements AudioSink {
*/
async prepare(layers: readonly FxPlan[], files: readonly string[] = []): Promise<void> {
await Promise.all([
...[...new Set([...layers.flatMap(filesOf), ...files])]
...[...new Set([...layers.flatMap(filesOf), ...layers.flatMap(impulsesOf), ...files])]
.filter((file) => file !== '')
.map((file) => this.load(file)),
layers.some(needsWorklet) ? this.fxWorklet() : null,
@@ -1441,7 +1454,7 @@ export class SoundLibrary implements AudioSink {
return fx.layers.reduce((longest, plan) => {
if (!fxRings(plan)) return longest;
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);
}
@@ -1503,7 +1516,7 @@ export class SoundLibrary implements AudioSink {
at: when,
// Held here, not looked up later: a cut can be let go while its voice plays.
buffers: new Map(
files.flatMap((name) => {
[...files, ...fx.layers.flatMap(impulsesOf)].flatMap((name) => {
const buffer = this.buffer(name);
return buffer ? [[name, buffer] as const] : [];
}),
@@ -1611,7 +1624,9 @@ export class SoundLibrary implements AudioSink {
const under = wiring.sources.slice(first);
for (const stage of chain.stages) {
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 = made.output;
knobs.push(...made.knobs);
@@ -1861,6 +1876,9 @@ export class SoundLibrary implements AudioSink {
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. */
private hasFailed(name: string): boolean {
return this.failed.has(parseCutName(name).file);
@@ -2045,6 +2063,7 @@ export function stageNodes(
stage: FxStage,
follow?: Follow,
at = context.currentTime,
impulse: AudioBuffer | null = null,
): StageNodes {
if (stage.kind === '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 === 'compress') return compressNodes(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 === 'limit')
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). */
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);
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 === 'reverb')
into.push(stage.decay, stage.damping, stage.predelay, stage.mix, 1 - stage.mix);
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);
+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);
});
});
+51
View File
@@ -1145,6 +1145,57 @@ export async function sound(t) {
Math.abs(swung.down - 940.5) < 1,
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
// 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