From d349251a016af8f33cd172390b9e1c5d6efb0b3f Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Wed, 7 Oct 2026 21:27:22 +0200 Subject: [PATCH] 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 --- src/core/instrument/compile.ts | 22 +++-- src/core/music/needs.ts | 2 +- src/core/soundfx/compile.ts | 37 ++++++-- src/core/soundfx/nodes.ts | 123 +++++++++++++++++++++++-- src/core/soundfx/reverb.ts | 71 ++++++++++++++ src/editor/debugHook.ts | 7 +- src/editor/graph/GraphCanvas.ts | 2 +- src/editor/soundfx/SoundFxEditor.ts | 8 +- src/runtime/systems/behaviorContext.ts | 2 +- src/viewport/SoundLibrary.ts | 106 +++++++++++++++++---- tests/core/reverb.test.ts | 53 +++++++++++ tests/core/soundfx.test.ts | 58 ++++++++++++ tests/viewport/sound-library.test.ts | 49 ++++++++++ tools/e2e/34-sound.mjs | 51 ++++++++++ 14 files changed, 544 insertions(+), 47 deletions(-) create mode 100644 src/core/soundfx/reverb.ts create mode 100644 tests/core/reverb.test.ts diff --git a/src/core/instrument/compile.ts b/src/core/instrument/compile.ts index 3d28f1e7..18a42197 100644 --- a/src/core/instrument/compile.ts +++ b/src/core/instrument/compile.ts @@ -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(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); diff --git a/src/core/music/needs.ts b/src/core/music/needs.ts index 51059e3f..cf531dd8 100644 --- a/src/core/music/needs.ts +++ b/src/core/music/needs.ts @@ -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; } diff --git a/src/core/soundfx/compile.ts b/src/core/soundfx/compile.ts index 58a4865a..ce004eb4 100644 --- a/src/core/soundfx/compile.ts +++ b/src/core/soundfx/compile.ts @@ -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 { * (#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(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(); +/** 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; } diff --git a/src/core/soundfx/nodes.ts b/src/core/soundfx/nodes.ts index 04c494da..d6663755 100644 --- a/src/core/soundfx/nodes.ts +++ b/src/core/soundfx/nodes.ts @@ -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 { @@ -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>, amounts: Readonly>, 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 = new Set(['delay', 'limit', 'vibrato']); +const RINGING: ReadonlySet = 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; } /** diff --git a/src/core/soundfx/reverb.ts b/src/core/soundfx/reverb.ts new file mode 100644 index 00000000..2e48209f --- /dev/null +++ b/src/core/soundfx/reverb.ts @@ -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; + }); +} diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 20ebfbd3..d3ea5dfb 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -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). */ diff --git a/src/editor/graph/GraphCanvas.ts b/src/editor/graph/GraphCanvas.ts index b7af6bf3..8e790f4c 100644 --- a/src/editor/graph/GraphCanvas.ts +++ b/src/editor/graph/GraphCanvas.ts @@ -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' diff --git a/src/editor/soundfx/SoundFxEditor.ts b/src/editor/soundfx/SoundFxEditor.ts index f1ae0b78..053d3ab1 100644 --- a/src/editor/soundfx/SoundFxEditor.ts +++ b/src/editor/soundfx/SoundFxEditor.ts @@ -100,7 +100,7 @@ export class SoundFxEditor extends AssetTabEditor { 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 { } 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 { 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); } diff --git a/src/runtime/systems/behaviorContext.ts b/src/runtime/systems/behaviorContext.ts index c38dc230..06f2b9cf 100644 --- a/src/runtime/systems/behaviorContext.ts +++ b/src/runtime/systems/behaviorContext.ts @@ -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, diff --git a/src/viewport/SoundLibrary.ts b/src/viewport/SoundLibrary.ts index da6070df..f5b83bee 100644 --- a/src/viewport/SoundLibrary.ts +++ b/src/viewport/SoundLibrary.ts @@ -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,17 +1058,20 @@ 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]) => { - 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. - const silent = buffers.every((buffer) => !buffer) && !start.fx.layers.some(hasChip); - if (silent || waiting.stopped) return; - if (this.cancelsFor(start.file, start.owner) !== askedAt) return; - 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); - }); + 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. + const silent = buffers.every((buffer) => !buffer) && !start.fx.layers.some(hasChip); + if (silent || waiting.stopped) return; + if (this.cancelsFor(start.file, start.owner) !== askedAt) return; + 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 { 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(); +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, 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); diff --git a/tests/core/reverb.test.ts b/tests/core/reverb.test.ts new file mode 100644 index 00000000..f1648b19 --- /dev/null +++ b/tests/core/reverb.test.ts @@ -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); + }); +}); diff --git a/tests/core/soundfx.test.ts b/tests/core/soundfx.test.ts index 66dc7e65..d40bc8f8 100644 --- a/tests/core/soundfx.test.ts +++ b/tests/core/soundfx.test.ts @@ -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) => + 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', diff --git a/tests/viewport/sound-library.test.ts b/tests/viewport/sound-library.test.ts index 1825ee87..6b3c5d45 100644 --- a/tests/viewport/sound-library.test.ts +++ b/tests/viewport/sound-library.test.ts @@ -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); + }); }); diff --git a/tools/e2e/34-sound.mjs b/tools/e2e/34-sound.mjs index 9a8bd3d3..f53bf592 100644 --- a/tools/e2e/34-sound.mjs +++ b/tools/e2e/34-sound.mjs @@ -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