Put a sound in a room, a hall or a cave with a Reverb node (#328)
A sound FX had no space around it. The Reverb node folds the sound with an impulse response: one it makes itself, or a sound asset as the answer of a real space. It works on a voice and on a bus. - Type: Room, Hall, Cave and Plate have fixed numbers. Custom shows Decay (up to 10 s), Damping and Pre-delay. Sound shows the pick of a sound asset and Pre-delay. The rows are derived ports, the sound pick an idle param, so they only appear for their type. - The made impulse response is seeded noise per side, 60 dB down at Decay, through a low-pass closing from 20 kHz toward 500 Hz as far as Damping says. It is made once per rate, decay and damping, the last 16 are kept. - A sound as impulse response: the sound FX compilers now get the project, so a node can turn a sound asset into its file. The build key holds that file, so a cut or swapped sound builds again. The instrument compiler does the same for a Reverb inside an instrument. - The library loads that file with the voice, a sequence or before a bus builds its chain. A missing file leaves the reverb silent. - Mix blends dry and reverb and is the only number that moves while the sound plays, as the issue said. The voice rings for pre-delay plus decay, or as long as its sound. - The open point from #325 about sends on project buses is #351, not part of this. Tests: - Unit reverb (new) 4, soundfx 57 (4 new), sound-library 104 (2 new), green. - Counter-checks, each turns one test red: the impulse file not loaded with the voice, the tail without the length of the sound. - e2e 34 (melektaus-atx): ALL PASSED, 81 checks (1 new). Chrome offline: silent during the pre-delay, 35.9 dB fall over 0.6 s of a 1 s decay (36 expected), the dry half at 0.25 for mix 0.5. A sound with one sample at 0.2 s gives the impulse back 0.25 s later and nowhere else. - Gate (melektaus-atx) over the end of this series #318 to #328: CHECK EXIT 0, 267 files, 4134 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
071f194326
commit
d349251a01
14 files changed
+544
-47
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user