Let echoes ring on with a Delay node in sound FX #324 #327
game-maker / gate (push) Successful in 3m20s
game-maker / suite (push) Failing after 15m4s

A voice used to end the moment its sources did, and Stop sound cut it
dead. An echo has to outlive its sound, and a stop must not click.

- Delay node: a delay line with a damped feedback loop, added at mix
  next to the untouched sound. Time 0.01 to 2 s, feedback up to 0.95,
  damping 20 kHz to 500 Hz evenly in octaves, all fed by parameters
  and moved by Set sound FX parameters like any other number.
- Ping-pong is a setting of the node. It takes the sound into the left
  of the line and swaps the sides every round.
- A voice with an echo rings on for its tail: until the last echo is
  60 dB below the sound, from time, feedback and mix (fxTailOf). It
  stays reachable meanwhile, and a change counts the tail again.
- The end of a tail is timed on the audio clock with a silent
  ConstantSourceNode, so a paused run holds it. Then the voice is
  disconnected, since an echo is a loop of nodes.
- Stop sound fades a voice out in 10 ms instead of cutting it. Let
  ring fades only its sources and keeps the voice for its echoes.
  Where the context is suspended it cuts at once.
- Stop sound takes a voice. Connected, it stops only that play, and a
  0 from a Play sound that played nothing stops nothing.
- Play sound hands out tail: length plus the echoes at its start
  values.
- Destroying a behavior and ending a run still cut, echoes included.
- No limit on voices. The emit of a sound FX node now also gets the
  node's settings.

Finding: in Chrome every round of a feedback loop comes one render
quantum late (128 samples, 2.7 ms at 48 kHz), measured as 0, 128, 256,
384 for the first four echoes. Left so, since a browser without it
would then hear the echoes early. Written in the wiki.

Tests:
- sound-library, soundfx, node-catalog, sounds, keep-on-scene-load:
  489 tests green. New: fade on stop, cut while suspended, echo and
  ping-pong wiring, ringing until the tail, tail counted again, let
  ring, stop by voice, echo knobs, delay compile, tail sums.
- Counter-checks: no tail, a stop without fade, a connected voice 0
  read as unconnected. Each fails its tests, files back by cmp.
- e2e 34 (melektaus-atx): ALL PASSED, 37 checks. A blip through a
  ping-pong Delay (0.3 s, 0.5, 0.5) rang 2.97 s, 2.95 s worked out.
  Let ring kept the voice, a stop after it cut it. Rendered offline
  with the player's own stageNodes the echoes are 0.25 left, 0.125
  right, 0.063 left. 64 stereo voices of it took about 0.3 s for 2 s,
  so about 400 play at once.
- Gate (melektaus-atx): CHECK EXIT 0, 242 test files, 3713 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-06 09:05:05 +02:00
1 parent 6e70421b92
commit cf6799a784
16 files changed
+987 -72

No files matched your search

+9 -2
View File
@@ -255,8 +255,15 @@ export interface NodeContext {
* exist or has not been loaded yet. * exist or has not been loaded yet.
*/ */
soundLength(assetId: string): number; soundLength(assetId: string): number;
/** Silences every voice of that sound; `''` silences everything this run started. */ /** How many seconds that voice rings on after its sound ends: its echoes (#324); 0 for none. */
stopSound(assetId: string): void; soundTail(voice: number): number;
/**
* Silences every voice of that sound; `''` silences everything this run started. `letRing` stops
* the sound and lets its echoes fade (#324).
*/
stopSound(assetId: string, letRing: boolean): void;
/** Silences one voice, by the number `playSound` gave it. */
stopVoice(voice: number, letRing: boolean): void;
/** /**
* Hands new values to the sound FX parameters of one voice, by the number `playSound` gave it * Hands new values to the sound FX parameters of one voice, by the number `playSound` gave it
* (#315), so a running sound follows the game. A voice that has ended is not reached. `time` is * (#315), so a running sound follows the game. A voice that has ended is not reached. `time` is
+5 -3
View File
@@ -63,7 +63,8 @@ export const PlaySound: NodeDef = {
'number of this one play, for Set sound FX parameters to change it while it plays; 0 where ' + 'number of this one play, for Set sound FX parameters to change it while it plays; 0 where ' +
'nothing was played. "length" is how many seconds one run of the sound lasts, to hand to the ' + 'nothing was played. "length" is how many seconds one run of the sound lasts, to hand to the ' +
'"time" of Set sound FX parameters so a change spans the sound; 0 where nothing was played or ' + '"time" of Set sound FX parameters so a change spans the sound; 0 where nothing was played or ' +
'the sound has not been loaded yet. A pitch never changes it.', 'the sound has not been loaded yet. A pitch never changes it. "tail" is that length and the ' +
'echoes of its FX after it, as they are when it starts – how long until the voice is quiet.',
inputs: [ inputs: [
exec(), exec(),
soundPort(), soundPort(),
@@ -73,7 +74,7 @@ export const PlaySound: NodeDef = {
soundFxPort(), soundFxPort(),
{ key: 'fxParams', label: 'fx params', type: 'fxparams' }, { key: 'fxParams', label: 'fx params', type: 'fxparams' },
], ],
outputs: [exec(), num('voice'), num('length')], outputs: [exec(), num('voice'), num('length'), num('tail')],
params: [], params: [],
run: (ctx, inputs) => { run: (ctx, inputs) => {
const sound = asString(inputs.sound); const sound = asString(inputs.sound);
@@ -88,7 +89,8 @@ export const PlaySound: NodeDef = {
) )
: 0; : 0;
const length = voice ? ctx.soundLength(sound) : 0; const length = voice ? ctx.soundLength(sound) : 0;
return { next: 'exec', outputs: { voice, length } }; const tail = length > 0 ? length + ctx.soundTail(voice) : 0;
return { next: 'exec', outputs: { voice, length, tail } };
}, },
}; };
+25 -5
View File
@@ -1,6 +1,6 @@
import type { NodeDef } from '../../NodeRegistry'; import type { NodeDef } from '../../NodeRegistry';
import { asString } from '../../values'; import { asBoolean, asNumber, asString } from '../../values';
import { exec, soundPort } from '../shared'; import { exec, num, soundPort } from '../shared';
export const StopSound: NodeDef = { export const StopSound: NodeDef = {
type: 'StopSound', type: 'StopSound',
@@ -8,12 +8,32 @@ export const StopSound: NodeDef = {
category: 'action', category: 'action',
description: description:
'Silences every voice of a sound, however many plays started it – including a looping one. ' + 'Silences every voice of a sound, however many plays started it – including a looping one. ' +
'With no sound picked it silences everything the run is playing.', 'With no sound picked it silences everything the run is playing. With "voice" connected it ' +
inputs: [exec(), soundPort()], 'silences only that one play, the number Play sound handed out, and nothing where it is 0. ' +
'A voice fades out in a few milliseconds, echoes and all; with "let ring" only the sound stops ' +
'and its echoes fade as they would.',
inputs: [
exec(),
soundPort(),
// Not typed in: unconnected it is null, and only then does the sound decide.
{ ...num('voice'), inline: false },
{
key: 'letRing',
label: 'let ring',
type: 'boolean',
default: false,
field: { key: 'letRing', label: 'Let ring', kind: 'boolean', default: false },
},
],
outputs: [exec()], outputs: [exec()],
params: [], params: [],
run: (ctx, inputs) => { run: (ctx, inputs) => {
ctx.stopSound(asString(inputs.sound)); const letRing = asBoolean(inputs.letRing);
if (inputs.voice === null || inputs.voice === undefined) ctx.stopSound(asString(inputs.sound), letRing);
else {
const voice = asNumber(inputs.voice);
if (voice > 0) ctx.stopVoice(voice, letRing);
}
return { next: 'exec' }; return { next: 'exec' };
}, },
}; };
+1 -1
View File
@@ -57,7 +57,7 @@ export function compileSoundFx(graph: Graph): SoundFxBuild {
for (const [key, value] of Object.entries(inputs)) for (const [key, value] of Object.entries(inputs))
if ('amount' in value) amounts[key] = value.amount; if ('amount' in value) amounts[key] = value.amount;
else sounds[key] = value.plan; else sounds[key] = value.plan;
return { out: { plan: def.emit(sounds, amounts) } }; return { out: { plan: def.emit(sounds, amounts, node.params) } };
}, },
unfed: (node, port) => { unfed: (node, port) => {
if (port.type === 'number') return { amount: { value: asNumber(inlineValue(node, port)) } }; if (port.type === 'number') return { amount: { value: asNumber(inlineValue(node, port)) } };
+112 -6
View File
@@ -1,6 +1,7 @@
import type { DeclaredPort } from '../graph/Graph'; import type { DeclaredPort } from '../graph/Graph';
import { SOUND_FX_INPUT, SOUND_FX_OUTPUT } from '../graph/nested'; import { SOUND_FX_INPUT, SOUND_FX_OUTPUT } from '../graph/nested';
import type { NodeDef, PortDef } from '../graph/NodeRegistry'; import type { NodeDef, PortDef } from '../graph/NodeRegistry';
import type { PropertyBag } from '../model/Properties';
import type { FlatDef } from '../properties/PropertySchema'; import type { FlatDef } from '../properties/PropertySchema';
export { SOUND_FX_INPUT, SOUND_FX_OUTPUT }; export { SOUND_FX_INPUT, SOUND_FX_OUTPUT };
@@ -8,10 +9,23 @@ export { SOUND_FX_INPUT, SOUND_FX_OUTPUT };
/** The filter shapes the EQ uses – Web Audio's names, so the player hands them on as they are. */ /** The filter shapes the EQ uses – Web Audio's names, so the player hands them on as they are. */
export type FxFilter = 'lowshelf' | 'peaking' | 'highshelf'; export type FxFilter = 'lowshelf' | 'peaking' | 'highshelf';
/** One step a voice passes on its way out: a volume, or one band of an equalizer. */ /**
* One step a voice passes on its way out: a volume, one band of an equalizer, or an echo (#327). An
* echo's `damping` is already the cutoff of the low-pass in its feedback, in Hz.
*/
export type FxStage = export type FxStage =
| { kind: 'gain'; gain: number } | { kind: 'gain'; gain: number }
| { kind: 'filter'; filter: FxFilter; frequency: number; gain: number; q: number }; | { kind: 'filter'; filter: FxFilter; frequency: number; gain: number; q: number }
| FxDelay;
export interface FxDelay {
kind: 'delay';
time: number;
feedback: number;
damping: number;
mix: number;
pingPong: boolean;
}
/** /**
* A sound on its way through the graph: how many semitones it is played up or down, and what it * A sound on its way through the graph: how many semitones it is played up or down, and what it
@@ -40,7 +54,15 @@ export interface FxAmount {
export type FxStagePlan = export type FxStagePlan =
| { kind: 'gain'; db: FxAmount } | { kind: 'gain'; db: FxAmount }
| { kind: 'filter'; filter: FxFilter; frequency: number; gain: FxAmount; q: number }; | { kind: 'filter'; filter: FxFilter; frequency: number; gain: FxAmount; q: number }
| {
kind: 'delay';
time: FxAmount;
feedback: FxAmount;
damping: FxAmount;
mix: FxAmount;
pingPong: boolean;
};
/** A chain with its parameters still open: compiled once, filled in for every voice by `fxChainOf`. */ /** A chain with its parameters still open: compiled once, filled in for every voice by `fxChainOf`. */
export type FxPlan = export type FxPlan =
@@ -62,12 +84,16 @@ export const DRY_VOICE: FxVoice = { plan: EMPTY_PLAN, params: {} };
/** /**
* A node of a sound FX graph (#311). It never runs: `emit` says what it does to the sounds arriving * A node of a sound FX graph (#311). It never runs: `emit` says what it does to the sounds arriving
* at its audio inputs, by input key, given the numbers at its other inputs. Input and Output have no * at its audio inputs, by input key, given the numbers at its other inputs and its own settings.
* effect. * Input and Output have no effect.
*/ */
export interface SoundFxNodeDef extends NodeDef { export interface SoundFxNodeDef extends NodeDef {
category: 'sound'; category: 'sound';
emit(sounds: Readonly<Record<string, FxPlan>>, amounts: Readonly<Record<string, FxAmount>>): FxPlan; emit(
sounds: Readonly<Record<string, FxPlan>>,
amounts: Readonly<Record<string, FxAmount>>,
settings: PropertyBag,
): FxPlan;
} }
export function isSoundFxNodeDef(def: NodeDef): def is SoundFxNodeDef { export function isSoundFxNodeDef(def: NodeDef): def is SoundFxNodeDef {
@@ -233,6 +259,48 @@ export const SoundFxMix: SoundFxNodeDef = {
}, },
}; };
/** As long as a delay line is ever made: every voice holds one this long, whatever its time. */
export const DELAY_MAX = 2;
const DELAY_TIME = { ...numberRow('time', 'Time (s)', 0.01, DELAY_MAX, 0.01), default: 0.25 };
/** Never 1: the echoes would never fade, and above it they would grow. */
const DELAY_FEEDBACK = { ...numberRow('feedback', 'Feedback', 0, 0.95, 0.01), default: 0.4 };
const DELAY_DAMPING = { ...numberRow('damping', 'Damping', 0, 1, 0.01), default: 0.3 };
const DELAY_MIX = { ...numberRow('mix', 'Mix', 0, 1, 0.01), default: 0.5 };
const PING_PONG = { key: 'pingPong', label: 'Ping-pong', kind: 'boolean' as const, default: false };
export const SoundFxDelay: SoundFxNodeDef = {
type: 'SoundFxDelay',
label: 'Delay',
category: 'sound',
description:
'Echoes the sound: it comes back after "time" seconds, again and again, each echo "feedback" times as loud as the one before. "damping" makes every echo duller than the last, like tape. "mix" is how loud the echoes are next to the sound, which itself stays as it is. With "ping-pong" the echoes take turns left and right. The echoes ring on after the sound ends; a voice lasts until they have faded.',
inputs: [audioIn, ...[DELAY_TIME, DELAY_FEEDBACK, DELAY_DAMPING, DELAY_MIX].map(numberIn)],
outputs: [audioOut],
params: [PING_PONG],
emit: (sounds, amounts, settings) => {
const mix = held(amounts.mix, DELAY_MIX);
if (isNone(mix)) return arriving(sounds);
return withStages(arriving(sounds), [
{
kind: 'delay',
time: held(amounts.time, DELAY_TIME),
feedback: held(amounts.feedback, DELAY_FEEDBACK),
damping: held(amounts.damping, DELAY_DAMPING),
mix,
pingPong: settings[PING_PONG.key] === true,
},
]);
},
};
/** Where damping 0 leaves the echoes and damping 1 takes them: evenly in octaves between. */
const UNDAMPED_HZ = 20000;
const DAMPED_HZ = 500;
export function dampingCutoff(damping: number): number {
return UNDAMPED_HZ * (DAMPED_HZ / UNDAMPED_HZ) ** Math.max(0, Math.min(1, damping));
}
export const SOUND_FX_NODES: readonly SoundFxNodeDef[] = [ export const SOUND_FX_NODES: readonly SoundFxNodeDef[] = [
SoundFxInput, SoundFxInput,
SoundFxOutput, SoundFxOutput,
@@ -240,6 +308,7 @@ export const SOUND_FX_NODES: readonly SoundFxNodeDef[] = [
SoundFxPitch, SoundFxPitch,
SoundFxEq, SoundFxEq,
SoundFxMix, SoundFxMix,
SoundFxDelay,
]; ];
/** An amount with the value handed in for its parameter, held to its row; a 0 there does nothing. */ /** An amount with the value handed in for its parameter, held to its row; a 0 there does nothing. */
@@ -258,6 +327,17 @@ function stagesOf(plan: FxPlan, params: FxParams): FxStage[] {
if (db !== 0 || stage.db.param !== undefined) stages.push({ kind: 'gain', gain: 10 ** (db / 20) }); if (db !== 0 || stage.db.param !== undefined) stages.push({ kind: 'gain', gain: 10 ** (db / 20) });
continue; continue;
} }
if (stage.kind === 'delay') {
stages.push({
kind: 'delay',
time: amountOf(stage.time, params),
feedback: amountOf(stage.feedback, params),
damping: dampingCutoff(amountOf(stage.damping, params)),
mix: amountOf(stage.mix, params),
pingPong: stage.pingPong,
});
continue;
}
const gain = amountOf(stage.gain, params); const gain = amountOf(stage.gain, params);
if (gain !== 0 || stage.gain.param !== undefined) stages.push({ ...stage, gain }); if (gain !== 0 || stage.gain.param !== undefined) stages.push({ ...stage, gain });
} }
@@ -281,3 +361,29 @@ export function fxChainOf(plan: FxPlan, params: FxParams = {}): FxChain {
stages, stages,
}; };
} }
/** Whether a plan can ring on after its sources end (#324): it has an echo somewhere. */
export function fxRings(plan: FxPlan): boolean {
if (plan.stages.some((stage) => stage.kind === 'delay')) return true;
return 'mix' in plan && (fxRings(plan.a) || fxRings(plan.b));
}
/** How far below the sound a tail has to fall to count as gone: -60 dB. */
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.
*/
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);
}
/** The echo `k` is `mix * feedback^(k-1)` as loud as the sound; the last one still heard sets the tail. */
function delayTail({ time, feedback, mix }: FxDelay): number {
if (!(mix >= SILENT) || !(time > 0)) return 0;
const echoes = feedback > 0 ? 1 + Math.floor(Math.log(SILENT / mix) / Math.log(feedback)) : 1;
return echoes * time;
}
+6 -1
View File
@@ -1,6 +1,7 @@
import type { Application } from 'pixi.js'; import type { Application } from 'pixi.js';
import { physicsStatus } from '../runtime/physics/engine'; import { physicsStatus } from '../runtime/physics/engine';
import { soundLibrary } from '../viewport/SoundLibrary'; import type { FxStage } from '../core/soundfx/nodes';
import { soundLibrary, stageNodes } from '../viewport/SoundLibrary';
import { curveHooks } from './debug/curveHooks'; import { curveHooks } from './debug/curveHooks';
import { documentHooks } from './debug/documentHooks'; import { documentHooks } from './debug/documentHooks';
import { graphHooks } from './debug/graphHooks'; import { graphHooks } from './debug/graphHooks';
@@ -47,6 +48,10 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
/** Decodes ahead of a check, so a play can be asserted without racing the network. */ /** Decodes ahead of a check, so a play can be asserted without racing the network. */
load: (file: string) => soundLibrary.load(file).then((buffer) => buffer?.duration ?? null), load: (file: string) => soundLibrary.load(file).then((buffer) => buffer?.duration ?? null),
stopAll: () => soundLibrary.stopAll(), stopAll: () => soundLibrary.stopAll(),
/** What Stop sound asks for: every voice of that file, its echoes cut or left to ring (#324). */
stop: (file: string, letRing: boolean) => soundLibrary.stop(file, letRing),
/** The nodes a stage of a voice becomes, in any context – an offline one renders what it does. */
stageNodes: (context: BaseAudioContext, stage: FxStage) => stageNodes(context, stage),
}, },
world: () => editor.worldSnapshot(), world: () => editor.worldSnapshot(),
playInfo: () => editor.playInfo(), playInfo: () => editor.playInfo(),
+14 -2
View File
@@ -33,8 +33,18 @@ export interface AudioSink {
* its bytes have not arrived. * its bytes have not arrived.
*/ */
length(file: string): number; length(file: string): number;
/** Silences every voice of that file; `''` silences all of them. */ /**
stop(file: string): void; * How many seconds one voice rings on after its sound has ended (#324), at the values it has now:
* the echoes of its FX; 0 where it has none or is not there.
*/
tail(voice: number): number;
/**
* Silences every voice of that file; `''` silences all of them. A voice is faded out in a few
* milliseconds, its echoes with it – unless `letRing`, which stops the sound and lets them fade.
*/
stop(file: string, letRing?: boolean): void;
/** Silences one voice, by the number `play` gave it, the same way. */
stopVoice(voice: number, letRing?: boolean): void;
/** Silences every voice started by that owner – what destroying a behavior does. */ /** Silences every voice started by that owner – what destroying a behavior does. */
stopOwner(owner: string): void; stopOwner(owner: string): void;
} }
@@ -43,6 +53,8 @@ export const SILENT_AUDIO: AudioSink = {
play: () => 0, play: () => 0,
tune: () => {}, tune: () => {},
length: () => 0, length: () => 0,
tail: () => 0,
stop: () => {}, stop: () => {},
stopVoice: () => {},
stopOwner: () => {}, stopOwner: () => {},
}; };
+4 -2
View File
@@ -172,10 +172,12 @@ export function behaviorContext(
}, },
// An id nothing resolves stops nothing, rather than silencing the whole run by accident; // An id nothing resolves stops nothing, rather than silencing the whole run by accident;
// only the empty id means "everything", which is what the node promises. // only the empty id means "everything", which is what the node promises.
stopSound: (assetId) => { soundTail: (voice) => run.audio.tail(voice),
stopSound: (assetId, letRing) => {
const file = assetId === '' ? '' : soundFileOf(project, assetId); const file = assetId === '' ? '' : soundFileOf(project, assetId);
if (assetId === '' || file) run.audio.stop(file); if (assetId === '' || file) run.audio.stop(file, letRing);
}, },
stopVoice: (voice, letRing) => run.audio.stopVoice(voice, letRing),
tuneSound: (voice, fxParams, time, ease) => run.audio.tune(voice, fxParams, time, ease), tuneSound: (voice, fxParams, time, ease) => run.audio.tune(voice, fxParams, time, ease),
spawn: (assetId, position, parent) => world.spawn(assetId, position, resolve(parent) ?? null), spawn: (assetId, position, parent) => world.spawn(assetId, position, resolve(parent) ?? null),
addJoint: (assetId, owner, connected, at) => { addJoint: (assetId, owner, connected, at) => {
+273 -29
View File
@@ -1,7 +1,11 @@
import { import {
DELAY_MAX,
DRY_VOICE, DRY_VOICE,
fxChainOf, fxChainOf,
type FxChain, type FxChain,
type FxDelay,
fxRings,
fxTailOf,
type FxParams, type FxParams,
type FxPlan, type FxPlan,
type FxStage, type FxStage,
@@ -18,11 +22,15 @@ const GLIDE = 0.03;
/** The rate a sound loaded ahead of its first play is decoded at; a source resamples it on the way out. */ /** The rate a sound loaded ahead of its first play is decoded at; a source resamples it on the way out. */
const DECODE_RATE = 48000; const DECODE_RATE = 48000;
/** Seconds a stopped voice takes to fall silent, so it does not click (#324). */
const FADE = 0.01;
/** /**
* One number of a voice that can still change while it plays (#315): a level, a share of a Mix, the * One number of a voice that can still change while it plays (#315): a level, a share of a Mix, the
* gain of a band, the pitch of a way in semitones. `even` says what a timed change runs evenly in: a * gain of a band, the pitch of a way in semitones, a number of an echo. `even` says what a timed
* gain stage in decibels (its node holds the factor), everything else in what it holds. A way whose * change runs evenly in: a gain stage in decibels (its node holds the factor) and the damping of an
* pitch has no worklet to play it has no `param`: it keeps its own pitch. * echo in octaves (its node holds the cutoff), everything else in what it holds. A way whose pitch
* has no worklet to play it has no `param`: it keeps its own pitch.
*/ */
interface Knob { interface Knob {
param: AudioParam | null; param: AudioParam | null;
@@ -30,11 +38,16 @@ interface Knob {
even: 'value' | 'ratio'; even: 'value' | 'ratio';
} }
/** One way's sound as a voice holds it: a buffer source, or the pitch worklet where the way has a Pitch (#317). */ /**
* One way's sound as a voice holds it: a buffer source, or the pitch worklet where the way has a
* Pitch (#317). A voice that can ring on gives each of its sources a `fade` of its own, so they can
* fall silent without a click while the echoes carry on (#324).
*/
interface Source { interface Source {
start(): void; start(): void;
stop(): void; stop(): void;
ended(then: () => void): void; ended(then: () => void): void;
fade: GainNode | null;
} }
/** /**
@@ -52,6 +65,11 @@ interface Voice {
gain: GainNode; gain: GainNode;
fx: FxVoice; fx: FxVoice;
knobs: Knob[]; knobs: Knob[];
/** Whether its FX can ring on after the sources end (#324), and whether that is all it does now. */
rings: boolean;
ringing: boolean;
/** Calls off the clock that ends its ringing. */
quiet: (() => void) | null;
} }
/** What one voice is wired from: the file, and what has been made for it so far. */ /** What one voice is wired from: the file, and what has been made for it so far. */
@@ -60,6 +78,7 @@ interface Wiring {
file: string; file: string;
buffer: AudioBuffer; buffer: AudioBuffer;
loop: boolean; loop: boolean;
rings: boolean;
sources: Source[]; sources: Source[];
pitches: number[]; pitches: number[];
knobs: Knob[]; knobs: Knob[];
@@ -74,6 +93,8 @@ interface Pending {
id: number; id: number;
fx: FxVoice; fx: FxVoice;
timed: { params: FxParams; time: number; ease: EaseShare }[]; timed: { params: FxParams; time: number; ease: EaseShare }[];
/** Stopped by its number before it started. */
stopped: boolean;
} }
/** /**
@@ -146,13 +167,18 @@ export class SoundLibrary implements AudioSink {
return id; return id;
} }
const askedAt = this.cancelsFor(file, owner); const askedAt = this.cancelsFor(file, owner);
const waiting: Pending = { id, fx: { plan: fx.plan, params: { ...fx.params } }, timed: [] }; const waiting: Pending = {
id,
fx: { plan: fx.plan, params: { ...fx.params } },
timed: [],
stopped: false,
};
this.pending.push(waiting); this.pending.push(waiting);
void Promise.all([this.load(file), pitching]).then(([loaded]) => { void Promise.all([this.load(file), pitching]).then(([loaded]) => {
this.pending = this.pending.filter((other) => other !== waiting); this.pending = this.pending.filter((other) => other !== waiting);
// A `Stop sound` – or the destruction of the behavior that asked – that happened while the // A `Stop sound` – or the destruction of the behavior that asked – that happened while the
// bytes were in flight has to win, or a sound the run already ended would start afterwards. // bytes were in flight has to win, or a sound the run already ended would start afterwards.
if (!loaded || this.cancelsFor(file, owner) !== askedAt) return; if (!loaded || waiting.stopped || this.cancelsFor(file, owner) !== askedAt) return;
this.start(id, file, loaded, volume, loop, owner, waiting.fx); this.start(id, file, loaded, volume, loop, owner, waiting.fx);
for (const { params, time, ease } of waiting.timed) this.tune(id, params, time, ease); for (const { params, time, ease } of waiting.timed) this.tune(id, params, time, ease);
}); });
@@ -173,16 +199,39 @@ export class SoundLibrary implements AudioSink {
if (!voice) return false; if (!voice) return false;
voice.fx = { plan: voice.fx.plan, params: { ...voice.fx.params, ...params } }; voice.fx = { plan: voice.fx.plan, params: { ...voice.fx.params, ...params } };
this.glide(voice, time, ease); this.glide(voice, time, ease);
// A longer feedback makes for a longer tail: it is counted again from where the change ends.
if (voice.ringing) this.ringFor(voice, Math.max(0, time) + this.tailOf(voice.fx));
this.tuned += 1; this.tuned += 1;
return true; return true;
} }
stop(file: string): void { /**
this.silence(file === '' ? this.voices : this.voices.filter((voice) => voice.file === file)); * Seconds a voice rings on after its sound has ended, at the values it has now (#324); 0 for a
* voice without echoes and for one that is not there.
*/
tail(id: number): number {
const voice =
this.voices.find((other) => other.id === id) ?? this.pending.find((other) => other.id === id);
return voice ? this.tailOf(voice.fx) : 0;
}
/** `letRing` stops the sound itself and lets its echoes fade as they would (#324). */
stop(file: string, letRing = false): void {
this.silence(file === '' ? this.voices : this.voices.filter((voice) => voice.file === file), letRing);
if (file === '') this.allStops += 1; if (file === '') this.allStops += 1;
else this.stops.set(file, (this.stops.get(file) ?? 0) + 1); else this.stops.set(file, (this.stops.get(file) ?? 0) + 1);
} }
/** One voice by its number, also one still waiting on its bytes. */
stopVoice(id: number, letRing = false): void {
const waiting = this.pending.find((other) => other.id === id);
if (waiting) waiting.stopped = true;
this.silence(
this.voices.filter((voice) => voice.id === id),
letRing,
);
}
/** /**
* Every voice of one behavior. An empty owner is deliberately no one: those are the voices that * Every voice of one behavior. An empty owner is deliberately no one: those are the voices that
* were started to outlive their behavior, so "stop the owner" must not reach them. * were started to outlive their behavior, so "stop the owner" must not reach them.
@@ -289,11 +338,102 @@ export class SoundLibrary implements AudioSink {
return work; return work;
} }
/** Ends these voices now: `onended` only fires next frame and the counts have to be right here. */ /**
private silence(going: Voice[]): void { * Ends these voices now: `onended` only fires next frame and the counts have to be right here. A
for (const voice of going) for (const source of voice.sources) source.stop(); * voice is faded out rather than cut, so it does not click. With `letRing` one with echoes only
this.voices = this.voices.filter((voice) => !going.includes(voice)); * loses its sound and stays until they have faded (#324); one already ringing is left to it.
this.stopped += going.length; */
private silence(going: Voice[], letRing = false): void {
this.stopped += going.filter((voice) => !(letRing && voice.ringing)).length;
for (const voice of going) {
if (letRing && voice.rings) {
if (!voice.ringing) this.release(voice);
continue;
}
this.cut(voice);
}
}
/** Fades the whole voice out and lets it go. */
private cut(voice: Voice): void {
this.forget(voice);
const context = this.audio();
const done = () => {
for (const source of voice.sources) source.stop();
voice.gain.disconnect();
};
// A suspended clock would hold the fade – and the sound – until the next run resumes it.
if (context.state !== 'running') return done();
fadeOut(voice.gain.gain, context.currentTime);
this.after(context.currentTime + FADE, done);
}
/** Fades the sources of a voice with echoes out and lets the echoes ring on (#324). */
private release(voice: Voice): void {
voice.ringing = true;
const context = this.audio();
for (const source of voice.sources) if (source.fade) fadeOut(source.fade.gain, context.currentTime);
this.after(context.currentTime + FADE, () => {
for (const source of voice.sources) source.stop();
});
this.ringFor(voice, FADE + this.tailOf(voice.fx));
}
/** The sources of a voice have ended by themselves: it goes, or rings on first where it has echoes. */
private ended(voice: Voice): void {
if (voice.ringing || !this.voices.includes(voice)) return;
const tail = voice.rings ? this.tailOf(voice.fx) : 0;
if (tail <= 0) return this.done(voice);
voice.ringing = true;
this.ringFor(voice, tail);
}
/** (Re)sets the clock that ends a ringing voice, `seconds` from now. */
private ringFor(voice: Voice, seconds: number): void {
voice.quiet?.();
voice.quiet = this.after(this.audio().currentTime + seconds, () => {
voice.quiet = null;
this.done(voice);
});
}
/** A voice whose sound and echoes have run out: out of the count, and its nodes let go. */
private done(voice: Voice): void {
this.forget(voice);
// An echo is a loop of nodes; left connected it would keep the audio thread busy for nothing.
if (voice.rings) voice.gain.disconnect();
}
private forget(voice: Voice): void {
voice.quiet?.();
voice.quiet = null;
this.voices = this.voices.filter((other) => other !== voice);
}
private tailOf(fx: FxVoice): number {
return fxRings(fx.plan) ? fxTailOf(fxChainOf(fx.plan, fx.params)) : 0;
}
/**
* Calls `then` once the audio clock reaches `at` – the clock, not the page's, so a paused game
* holds a tail where it is. A silent source that ends then is the only timer Web Audio has. Gives
* back what calls it off.
*/
private after(at: number, then: () => void): () => void {
const context = this.audio();
const clock = context.createConstantSource();
clock.offset.value = 0;
clock.connect(context.destination);
clock.onended = () => {
clock.disconnect();
then();
};
clock.start();
clock.stop(at);
return () => {
clock.onended = null;
clock.disconnect();
};
} }
private start( private start(
@@ -311,7 +451,18 @@ export class SoundLibrary implements AudioSink {
if (context.state === 'suspended') void context.resume(); if (context.state === 'suspended') void context.resume();
const gain = context.createGain(); const gain = context.createGain();
gain.gain.value = Math.max(0, Math.min(1, volume)); gain.gain.value = Math.max(0, Math.min(1, volume));
const wiring: Wiring = { context, file, buffer, loop, sources: [], pitches: [], knobs: [], stages: 0 }; const rings = fxRings(fx.plan);
const wiring: Wiring = {
context,
file,
buffer,
loop,
rings,
sources: [],
pitches: [],
knobs: [],
stages: 0,
};
this.wire(wiring, fxChainOf(fx.plan, fx.params)).connect(gain).connect(context.destination); this.wire(wiring, fxChainOf(fx.plan, fx.params)).connect(gain).connect(context.destination);
const { sources } = wiring; const { sources } = wiring;
this.last = { this.last = {
@@ -322,11 +473,22 @@ export class SoundLibrary implements AudioSink {
stages: wiring.stages, stages: wiring.stages,
duration: buffer.duration, duration: buffer.duration,
}; };
const voice: Voice = { id, file, owner, sources, gain, fx, knobs: wiring.knobs }; const voice: Voice = {
id,
file,
owner,
sources,
gain,
fx,
knobs: wiring.knobs,
rings,
ringing: false,
quiet: null,
};
let playing = sources.length; let playing = sources.length;
for (const source of sources) for (const source of sources)
source.ended(() => { source.ended(() => {
if (--playing === 0) this.voices = this.voices.filter((other) => other !== voice); if (--playing === 0) this.ended(voice);
}); });
this.voices.push(voice); this.voices.push(voice);
this.started += 1; this.started += 1;
@@ -353,13 +515,10 @@ export class SoundLibrary implements AudioSink {
knobs.push({ param: shareB.gain, target: chain.mix, even: 'value' }); knobs.push({ param: shareB.gain, target: chain.mix, even: 'value' });
} else tail = this.source(wiring, chain.pitch, chain.shifted); } else tail = this.source(wiring, chain.pitch, chain.shifted);
for (const stage of chain.stages) { for (const stage of chain.stages) {
const [node, param] = fxNode(context, stage); const made = stageNodes(context, stage);
tail = tail.connect(node); tail.connect(made.input);
knobs.push({ tail = made.output;
param, knobs.push(...made.knobs);
target: stage.gain,
even: stage.kind === 'gain' ? 'ratio' : 'value',
});
} }
wiring.stages += chain.stages.length; wiring.stages += chain.stages.length;
return tail; return tail;
@@ -371,6 +530,14 @@ export class SoundLibrary implements AudioSink {
* its own pitch rather than not at all. * its own pitch rather than not at all.
*/ */
private source(wiring: Wiring, pitch: number, shifted: boolean): AudioNode { private source(wiring: Wiring, pitch: number, shifted: boolean): AudioNode {
const sound = this.sound(wiring, pitch, shifted);
if (!wiring.rings) return sound;
const fade = gainNode(wiring.context, 1);
wiring.sources.at(-1)!.fade = fade;
return sound.connect(fade);
}
private sound(wiring: Wiring, pitch: number, shifted: boolean): AudioNode {
const { context, file, buffer, loop } = wiring; const { context, file, buffer, loop } = wiring;
wiring.pitches.push(pitch); wiring.pitches.push(pitch);
if (shifted && this.canPitch) { if (shifted && this.canPitch) {
@@ -398,6 +565,7 @@ export class SoundLibrary implements AudioSink {
if (event.data === 'ended') then(); if (event.data === 'ended') then();
}; };
}, },
fade: null,
}); });
wiring.knobs.push({ param: node.parameters.get('pitch')!, target: pitch, even: 'value' }); wiring.knobs.push({ param: node.parameters.get('pitch')!, target: pitch, even: 'value' });
return node; return node;
@@ -411,6 +579,7 @@ export class SoundLibrary implements AudioSink {
ended: (then) => { ended: (then) => {
source.onended = then; source.onended = then;
}, },
fade: null,
}); });
if (shifted) wiring.knobs.push({ param: null, target: pitch, even: 'value' }); if (shifted) wiring.knobs.push({ param: null, target: pitch, even: 'value' });
return source; return source;
@@ -477,24 +646,97 @@ function pitches(plan: FxPlan): boolean {
return 'mix' in plan ? pitches(plan.a) || pitches(plan.b) : plan.pitch.length > 0; return 'mix' in plan ? pitches(plan.a) || pitches(plan.b) : plan.pitch.length > 0;
} }
function gainNode(context: AudioContext, level: number): GainNode { function gainNode(context: BaseAudioContext, level: number): GainNode {
const gain = context.createGain(); const gain = context.createGain();
gain.gain.value = level; gain.gain.value = level;
return gain; return gain;
} }
/** The node a stage becomes, with the number of it a parameter can move. */ /** What a stage becomes: where its sound goes in, where it comes out, and the numbers a parameter can move. */
function fxNode(context: AudioContext, stage: FxStage): [AudioNode, AudioParam] { interface StageNodes {
input: AudioNode;
output: AudioNode;
knobs: Knob[];
}
/** Exported for a check that renders a stage offline in a real browser (test hook). */
export function stageNodes(context: BaseAudioContext, stage: FxStage): StageNodes {
if (stage.kind === 'gain') { if (stage.kind === 'gain') {
const gain = gainNode(context, stage.gain); const gain = gainNode(context, stage.gain);
return [gain, gain.gain]; return { input: gain, output: gain, knobs: [{ param: gain.gain, target: stage.gain, even: 'ratio' }] };
} }
if (stage.kind === 'delay') return delayNodes(context, stage);
const filter = context.createBiquadFilter(); const filter = context.createBiquadFilter();
filter.type = stage.filter; filter.type = stage.filter;
filter.frequency.value = stage.frequency; filter.frequency.value = stage.frequency;
filter.gain.value = stage.gain; filter.gain.value = stage.gain;
filter.Q.value = stage.q; filter.Q.value = stage.q;
return [filter, filter.gain]; return {
input: filter,
output: filter,
knobs: [{ param: filter.gain, target: stage.gain, even: 'value' }],
};
}
/**
* The Q of a low-pass with no bump at its cutoff, in the decibels Web Audio takes it in. An echo
* coming out of the damping louder than it went in would grow with every round.
*/
const FLAT_Q = 20 * Math.log10(Math.SQRT1_2);
/**
* An echo (#327): the sound goes on as it is, and through a delay line whose output is damped, fed
* back into it and added at `mix`. Ping-pong sends the sound into the left of the line only and
* swaps the sides on every round.
*/
function delayNodes(context: BaseAudioContext, stage: FxDelay): StageNodes {
const input = context.createGain();
const output = context.createGain();
input.connect(output);
const line = context.createDelay(DELAY_MAX);
line.delayTime.value = stage.time;
const damp = context.createBiquadFilter();
damp.type = 'lowpass';
damp.frequency.value = stage.damping;
damp.Q.value = FLAT_Q;
const feedback = gainNode(context, stage.feedback);
const wet = gainNode(context, stage.mix);
line.connect(damp);
damp.connect(wet).connect(output);
if (stage.pingPong) {
const mono = context.createGain();
mono.channelCount = 1;
mono.channelCountMode = 'explicit';
const left = context.createChannelMerger(2);
input.connect(mono).connect(left, 0, 0);
left.connect(line);
const sides = context.createChannelSplitter(2);
const crossed = context.createChannelMerger(2);
damp.connect(sides);
sides.connect(crossed, 0, 1);
sides.connect(crossed, 1, 0);
crossed.connect(feedback).connect(line);
} else {
input.connect(line);
damp.connect(feedback).connect(line);
}
return {
input,
output,
knobs: [
{ param: line.delayTime, target: stage.time, even: 'value' },
{ param: feedback.gain, target: stage.feedback, even: 'value' },
{ param: damp.frequency, target: stage.damping, even: 'ratio' },
{ param: wet.gain, target: stage.mix, even: 'value' },
],
};
}
/** Sends a level to silence in `FADE` seconds from wherever it is now. */
function fadeOut(param: AudioParam, now: number): void {
param.cancelScheduledValues(now);
param.setValueAtTime(param.value, now);
param.linearRampToValueAtTime(0, now + FADE);
} }
/** /**
@@ -555,7 +797,9 @@ function levelsOf(chain: FxChain, into: number[] = []): number[] {
levelsOf(chain.b, into); levelsOf(chain.b, into);
into.push(chain.mix); into.push(chain.mix);
} else if (chain.shifted) into.push(chain.pitch); } else if (chain.shifted) into.push(chain.pitch);
for (const stage of chain.stages) into.push(stage.gain); for (const stage of chain.stages)
if (stage.kind === 'delay') into.push(stage.time, stage.feedback, stage.damping, stage.mix);
else into.push(stage.gain);
return into; return into;
} }
+2
View File
@@ -58,6 +58,8 @@ function start(f: Fixture, audio?: AudioSink) {
tune: () => {}, tune: () => {},
length: () => 0, length: () => 0,
stop: () => {}, stop: () => {},
tail: () => 0,
stopVoice: () => {},
stopOwner: (owner: string) => stoppedOwners.push(owner), stopOwner: (owner: string) => stoppedOwners.push(owner),
}; };
const run = GameRun.start(f.project, f.sceneId, new Input(), { const run = GameRun.start(f.project, f.sceneId, new Input(), {
+22 -3
View File
@@ -1980,14 +1980,15 @@ const CASES: Record<string, Case[]> = {
name: 'no sound is a no-op that still continues the flow', name: 'no sound is a no-op that still continues the flow',
inputs: { sound: '', volume: 1, loop: '' }, inputs: { sound: '', volume: 1, loop: '' },
next: 'exec', next: 'exec',
outputs: { voice: 0, length: 0 }, outputs: { voice: 0, length: 0, tail: 0 },
after: (ctx) => expect(ctx.played).toEqual([]), after: (ctx) => expect(ctx.played).toEqual([]),
}, },
{ {
name: 'hands the id on with the volume and loop it was given, and gives out the voice (#315) and its length', name: 'hands the id on with the volume and loop it was given, and gives out the voice (#315), its length and its tail (#324)',
inputs: { sound: 'snd-1', volume: 0.5, loop: 'true' }, inputs: { sound: 'snd-1', volume: 0.5, loop: 'true' },
next: 'exec', next: 'exec',
outputs: { voice: 1, length: 0.25 }, // The fake context rings a voice on for its number in tenths of a second.
outputs: { voice: 1, length: 0.25, tail: 0.35 },
// What the two mean – a factor on the asset's volume, an answer that overrules its loop // What the two mean – a factor on the asset's volume, an answer that overrules its loop
// flag – is the BehaviorSystem's to resolve, since only it holds the project (see // flag – is the BehaviorSystem's to resolve, since only it holds the project (see
// `sounds.test.ts`). // `sounds.test.ts`).
@@ -2066,6 +2067,24 @@ const CASES: Record<string, Case[]> = {
next: 'exec', next: 'exec',
after: (ctx) => expect(ctx.silenced).toEqual(['']), after: (ctx) => expect(ctx.silenced).toEqual(['']),
}, },
{
name: 'lets the echoes of the sound ring when asked to (#324)',
inputs: { sound: 'snd-1', letRing: true },
next: 'exec',
after: (ctx) => expect(ctx.silenced).toEqual(['snd-1 (let ring)']),
},
{
name: 'with a voice connected silences only that one, whatever sound is named',
inputs: { sound: 'snd-1', voice: 3, letRing: true },
next: 'exec',
after: (ctx) => expect(ctx.silenced).toEqual(['voice 3 (let ring)']),
},
{
name: 'a connected voice of 0 – a Play sound that played nothing – silences nothing, not everything',
inputs: { sound: '', voice: 0 },
next: 'exec',
after: (ctx) => expect(ctx.silenced).toEqual([]),
},
], ],
SetSoundFxParameters: [ SetSoundFxParameters: [
{ {
+102 -4
View File
@@ -13,11 +13,15 @@ import { isPlaceableType } from '../../src/core/model/AssetTypes';
import { assetRefChoices } from '../../src/core/properties/assetRef'; import { assetRefChoices } from '../../src/core/properties/assetRef';
import { compileSoundFx, soundFxBuildFor } from '../../src/core/soundfx/compile'; import { compileSoundFx, soundFxBuildFor } from '../../src/core/soundfx/compile';
import { import {
dampingCutoff,
DRY, DRY,
EMPTY_PLAN, EMPTY_PLAN,
type FxChain, type FxChain,
fxChainOf, fxChainOf,
type FxPlan, type FxPlan,
fxRings,
type FxStage,
fxTailOf,
type FxVoice, type FxVoice,
} from '../../src/core/soundfx/nodes'; } from '../../src/core/soundfx/nodes';
import { deserializeProject } from '../../src/core/store/serialize'; import { deserializeProject } from '../../src/core/store/serialize';
@@ -36,6 +40,9 @@ import {
const FILE = `${'a1'.repeat(32)}.wav`; const FILE = `${'a1'.repeat(32)}.wav`;
/** The level of a gain or filter stage. */
const gainOf = (stage: FxStage | undefined) => (stage && 'gain' in stage ? stage.gain : undefined);
/** The pitch of a sound that is not mixed. */ /** The pitch of a sound that is not mixed. */
const pitchOf = <T extends FxChain | FxPlan>(sound: T) => ('pitch' in sound ? sound.pitch : undefined); const pitchOf = <T extends FxChain | FxPlan>(sound: T) => ('pitch' in sound ? sound.pitch : undefined);
@@ -47,7 +54,7 @@ function makeFx(f: ProjectFixture = makeProject()) {
} }
/** Input, the given effects in a row, Output. */ /** Input, the given effects in a row, Output. */
function line(effects: { type: string; params?: Record<string, number> }[]) { function line(effects: { type: string; params?: Record<string, number | boolean> }[]) {
const f = makeFx(); const f = makeFx();
const input = f.edit.addNode('SoundFxInput'); const input = f.edit.addNode('SoundFxInput');
let tail = input; let tail = input;
@@ -111,10 +118,11 @@ describe('the sound FX asset', () => {
'SoundFxPitch', 'SoundFxPitch',
'SoundFxEq', 'SoundFxEq',
'SoundFxMix', 'SoundFxMix',
'SoundFxDelay',
]); ]);
f.edit.addNode('SoundFxInput'); f.edit.addNode('SoundFxInput');
f.edit.addNode('SoundFxOutput'); f.edit.addNode('SoundFxOutput');
expect(ids()).toEqual(['SoundFxGain', 'SoundFxPitch', 'SoundFxEq', 'SoundFxMix']); expect(ids()).toEqual(['SoundFxGain', 'SoundFxPitch', 'SoundFxEq', 'SoundFxMix', 'SoundFxDelay']);
const behavior = makeBehavior(f, 'Logic'); const behavior = makeBehavior(f, 'Logic');
expect(paletteEntries(f.project, behavior).some((e) => e.def.category === 'sound')).toBe(false); expect(paletteEntries(f.project, behavior).some((e) => e.def.category === 'sound')).toBe(false);
@@ -153,7 +161,7 @@ describe('compiling a sound FX graph', () => {
expect(pitchOf(chain)).toBe(7); expect(pitchOf(chain)).toBe(7);
expect(chain.stages).toHaveLength(3); expect(chain.stages).toHaveLength(3);
expect(chain.stages[0]!.kind).toBe('gain'); expect(chain.stages[0]!.kind).toBe('gain');
expect(chain.stages[0]!.gain).toBeCloseTo(0.501, 3); expect(gainOf(chain.stages[0])).toBeCloseTo(0.501, 3);
expect(chain.stages.slice(1)).toEqual([ expect(chain.stages.slice(1)).toEqual([
{ kind: 'filter', filter: 'lowshelf', frequency: 80, gain: 6, q: 1 }, { kind: 'filter', filter: 'lowshelf', frequency: 80, gain: 6, q: 1 },
{ kind: 'filter', filter: 'highshelf', frequency: 12000, gain: -3, q: 1 }, { kind: 'filter', filter: 'highshelf', frequency: 12000, gain: -3, q: 1 },
@@ -168,7 +176,7 @@ describe('compiling a sound FX graph', () => {
const loud = fxChainOf( const loud = fxChainOf(
compileSoundFx(line([{ type: 'SoundFxGain', params: { gain: 200 } }]).fx.graph).compiled!, compileSoundFx(line([{ type: 'SoundFxGain', params: { gain: 200 } }]).fx.graph).compiled!,
); );
expect(loud.stages[0]!.gain).toBeCloseTo(10 ** (24 / 20)); expect(gainOf(loud.stages[0])).toBeCloseTo(10 ** (24 / 20));
}); });
it('ignores a node hanging off the line', () => { it('ignores a node hanging off the line', () => {
@@ -256,6 +264,94 @@ describe('a Mix in a sound FX graph (#316)', () => {
}); });
}); });
describe('a Delay in a sound FX graph (#327)', () => {
it('echoes at its defaults, the damping a cutoff evenly in octaves between 20 kHz and 500 Hz', () => {
const chain = fxChainOf(compileSoundFx(line([{ type: 'SoundFxDelay' }]).fx.graph).compiled!);
expect(chain.stages).toEqual([
{ kind: 'delay', time: 0.25, feedback: 0.4, damping: dampingCutoff(0.3), mix: 0.5, pingPong: false },
]);
expect([dampingCutoff(0), dampingCutoff(0.5), dampingCutoff(1)]).toEqual([
20000,
expect.closeTo(Math.sqrt(20000 * 500)),
expect.closeTo(500),
]);
});
it('holds its numbers to their rows, never a feedback of 1, and carries ping-pong', () => {
const chain = fxChainOf(
compileSoundFx(
line([
{ type: 'SoundFxDelay', params: { time: 9, feedback: 2, damping: -1, mix: 3, pingPong: true } },
]).fx.graph,
).compiled!,
);
expect(chain.stages).toEqual([
{ kind: 'delay', time: 2, feedback: 0.95, damping: 20000, mix: 1, pingPong: true },
]);
});
it('is left out at a typed-in mix of 0, and kept for a mix from a parameter', () => {
const off = compileSoundFx(line([{ type: 'SoundFxDelay', params: { mix: 0 } }]).fx.graph);
expect(off.compiled).toEqual(EMPTY_PLAN);
const fed = withParam('SoundFxDelay', 'mix', [{ key: 'wet', kind: 'number' }]);
const plan = soundFxBuildFor(fed.fx).compiled!;
expect(fxChainOf(plan).stages).toMatchObject([{ kind: 'delay', mix: 0 }]);
expect(fxChainOf(plan, { wet: 0.7 }).stages).toMatchObject([{ kind: 'delay', mix: 0.7 }]);
});
it('takes its time from a parameter, held to two seconds', () => {
const f = withParam('SoundFxDelay', 'time', [{ key: 'echo', kind: 'number', default: 0.5 }]);
const plan = soundFxBuildFor(f.fx).compiled!;
expect(fxChainOf(plan).stages).toMatchObject([{ time: 0.5 }]);
expect(fxChainOf(plan, { echo: 1 }).stages).toMatchObject([{ time: 1 }]);
expect(fxChainOf(plan, { echo: 5 }).stages).toMatchObject([{ time: 2 }]);
});
});
describe('the tail of a voice (#324)', () => {
const echo = (time: number, feedback: number, mix: number): FxStage => ({
kind: 'delay',
time,
feedback,
damping: 20000,
mix,
pingPong: false,
});
const chain = (...stages: FxStage[]): FxChain => ({ pitch: 0, shifted: false, stages });
it('is nothing without an echo', () => {
expect(fxTailOf(DRY)).toBe(0);
expect(fxTailOf(chain({ kind: 'gain', gain: 2 }))).toBe(0);
expect(fxRings(EMPTY_PLAN)).toBe(false);
});
it('lasts until the last echo is 60 dB below the sound', () => {
expect(fxTailOf(chain(echo(0.25, 0, 0.5)))).toBe(0.25);
// 0.5^9 is 2 in 1000, 0.5^10 one in 1000 and a bit less: ten echoes are heard.
expect(fxTailOf(chain(echo(0.25, 0.5, 1)))).toBe(2.5);
expect(fxTailOf(chain(echo(0.1, 0.95, 1)))).toBeCloseTo(13.5);
expect(fxTailOf(chain(echo(0.25, 0.9, 0.0001)))).toBe(0);
});
it('adds up echoes behind each other and takes the longer way of a Mix', () => {
expect(fxTailOf(chain(echo(0.25, 0, 1), echo(0.5, 0, 1)))).toBe(0.75);
const mixed: FxChain = {
mix: 0.5,
a: chain(echo(1, 0, 1)),
b: chain(echo(0.25, 0, 1)),
stages: [echo(0.5, 0, 1)],
};
expect(fxTailOf(mixed)).toBe(1.5);
});
it('is known from the plan before any value is: an echo rings, wherever it is', () => {
const f = withParam('SoundFxDelay', 'mix', [{ key: 'wet', kind: 'number' }]);
expect(fxRings(soundFxBuildFor(f.fx).compiled!)).toBe(true);
const forked = { mix: { value: 0.5 }, a: EMPTY_PLAN, b: soundFxBuildFor(f.fx).compiled!, stages: [] };
expect(fxRings(forked)).toBe(true);
});
});
/** /**
* Records what it was asked to play, with the line of effects its values make, and every tune with * Records what it was asked to play, with the line of effects its values make, and every tune with
* its time on the side. A voice is numbered by how many have been played, so the first is 1. Every * its time on the side. A voice is numbered by how many have been played, so the first is 1. Every
@@ -278,6 +374,8 @@ function fakeAudio(): AudioSink & {
}, },
length: () => 0.25, length: () => 0.25,
stop: () => {}, stop: () => {},
tail: () => 0,
stopVoice: () => {},
stopOwner: () => {}, stopOwner: () => {},
}; };
return sink; return sink;
+2
View File
@@ -44,7 +44,9 @@ function fakeAudio(): AudioSink & {
sink.played.push({ file, volume, loop, owner }), sink.played.push({ file, volume, loop, owner }),
tune: () => {}, tune: () => {},
length: () => 0, length: () => 0,
tail: () => 0,
stop: (file: string) => sink.stopped.push(file), stop: (file: string) => sink.stopped.push(file),
stopVoice: () => {},
stopOwner: (owner: string) => sink.stoppedOwners.push(owner), stopOwner: (owner: string) => sink.stoppedOwners.push(owner),
}; };
return sink; return sink;
+5 -1
View File
@@ -272,7 +272,11 @@ export function fakeContext(overrides: Partial<NodeContext> = {}): FakeContext {
ctx.played.push({ assetId, volume, loop, detached, fx, fxParams }), ctx.played.push({ assetId, volume, loop, detached, fx, fxParams }),
// Every sound lasts a quarter of a second, as if it had been loaded. // Every sound lasts a quarter of a second, as if it had been loaded.
soundLength: () => 0.25, soundLength: () => 0.25,
stopSound: (assetId) => ctx.silenced.push(assetId), // The tail of a voice is its number in tenths of a second, so a test can tell one from another.
soundTail: (voice) => voice / 10,
// A voice by its number, and letting ring, are written into the line, so one list says it all.
stopSound: (assetId, letRing) => ctx.silenced.push(letRing ? `${assetId} (let ring)` : assetId),
stopVoice: (voice, letRing) => ctx.silenced.push(`voice ${voice}${letRing ? ' (let ring)' : ''}`),
tuned: [], tuned: [],
tuneSound: (voice, fxParams, time, ease) => ctx.tuned.push({ voice, fxParams, time, ease }), tuneSound: (voice, fxParams, time, ease) => ctx.tuned.push({ voice, fxParams, time, ease }),
loadScene: (sceneId) => ctx.loaded.push(sceneId), loadScene: (sceneId) => ctx.loaded.push(sceneId),
+263 -11
View File
@@ -6,7 +6,7 @@
* The device is stubbed – nothing makes a noise in a unit test, and Web Audio does not exist here. * The device is stubbed – nothing makes a noise in a unit test, and Web Audio does not exist here.
*/ */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { FxPlan, FxStage, FxVoice } from '../../src/core/soundfx/nodes'; import type { FxPlan, FxStage, FxStagePlan, FxVoice } from '../../src/core/soundfx/nodes';
import type { PitchOptions } from '../../src/viewport/pitchWorklet'; import type { PitchOptions } from '../../src/viewport/pitchWorklet';
import { SoundLibrary } from '../../src/viewport/SoundLibrary'; import { SoundLibrary } from '../../src/viewport/SoundLibrary';
@@ -90,17 +90,38 @@ const wires: string[] = [];
const gains: FakeParam[] = []; const gains: FakeParam[] = [];
const filters: { const filters: {
type: string; type: string;
frequency: { value: number }; frequency: FakeParam;
gain: FakeParam; gain: FakeParam;
Q: { value: number }; Q: { value: number };
}[] = []; }[] = [];
/** Every delay line, with the longest time it was made for. */
const delays: { max: number; delayTime: FakeParam }[] = [];
/** The kinds of node that were let go, in order. */
const disconnects: string[] = [];
/**
* The silent sources the library times things on the audio clock with (#324): when each was told to
* stop, and whether it has. `clockTo` lets the clock run.
*/
const clocks: { stopAt: number; fired: boolean; onended: (() => void) | null }[] = [];
let contextState = 'running';
/** Runs the audio clock up to `until`, ending each timer on the way in order, also ones set meanwhile. */
function clockTo(until = Infinity): void {
for (;;) {
const next = clocks.filter((c) => !c.fired && c.stopAt <= until).sort((a, b) => a.stopAt - b.stopAt)[0];
if (!next) return;
next.fired = true;
next.onended?.();
}
}
/** A node that logs where it is connected to and hands the target back, as Web Audio's does. */ /** A node that logs where it is connected to and hands the target back, as Web Audio's does. */
function wired<T extends object>( function wired<T extends object>(
kind: string, kind: string,
node: T, node: T,
): T & { kind: string; connect: (to: unknown) => unknown } { ): T & { kind: string; connect: (to: unknown) => unknown; disconnect: () => void } {
return { return {
disconnect: () => void disconnects.push(kind),
...node, ...node,
kind, kind,
connect: (to: unknown) => { connect: (to: unknown) => {
@@ -127,7 +148,9 @@ function fakeBuffer(_channels: number, length: number, sampleRate: number) {
function fakeContext() { function fakeContext() {
return { return {
state: 'running', get state() {
return contextState;
},
currentTime: 0, currentTime: 0,
sampleRate: 48000, sampleRate: 48000,
audioWorklet: { audioWorklet: {
@@ -167,13 +190,34 @@ function fakeContext() {
createBiquadFilter: () => { createBiquadFilter: () => {
const filter = wired('filter', { const filter = wired('filter', {
type: '', type: '',
frequency: { value: 0 }, frequency: param(0),
gain: param(0), gain: param(0),
Q: { value: 0 }, Q: { value: 0 },
}); });
filters.push(filter); filters.push(filter);
return filter; return filter;
}, },
createDelay: (max: number) => {
const delay = wired('delay', { max, delayTime: param(0) });
delays.push(delay);
return delay;
},
createChannelMerger: () => wired('merger', {}),
createChannelSplitter: () => wired('splitter', {}),
createConstantSource: () => {
const clock = wired('clock', {
offset: { value: 1 },
stopAt: Infinity,
fired: false,
onended: null as (() => void) | null,
start: () => {},
stop: (at: number) => {
clock.stopAt = at;
},
});
clocks.push(clock);
return clock;
},
}; };
} }
@@ -188,6 +232,10 @@ beforeEach(() => {
wires.length = 0; wires.length = 0;
filters.length = 0; filters.length = 0;
gains.length = 0; gains.length = 0;
delays.length = 0;
disconnects.length = 0;
clocks.length = 0;
contextState = 'running';
decodes = []; decodes = [];
vi.stubGlobal( vi.stubGlobal(
'AudioContext', 'AudioContext',
@@ -232,11 +280,18 @@ afterEach(() => {
/** A chain as the plan of an FX with nothing left open, so a test can say what it wants to hear. */ /** A chain as the plan of an FX with nothing left open, so a test can say what it wants to hear. */
function planOf(chain: Chain): FxPlan { function planOf(chain: Chain): FxPlan {
const stages = chain.stages.map((stage) => const stages = chain.stages.map((stage): FxStagePlan => {
stage.kind === 'gain' if (stage.kind === 'gain') return { kind: 'gain', db: { value: 20 * Math.log10(stage.gain) } };
? { kind: 'gain' as const, db: { value: 20 * Math.log10(stage.gain) } } if (stage.kind === 'filter') return { ...stage, gain: { value: stage.gain } };
: { ...stage, gain: { value: stage.gain } }, return {
); ...stage,
time: { value: stage.time },
feedback: { value: stage.feedback },
// The cutoff back to the damping that makes it: 0 is 20 kHz, every 1/5.32 an octave less.
damping: { value: Math.log(stage.damping / 20000) / Math.log(500 / 20000) },
mix: { value: stage.mix },
};
});
if ('mix' in chain) return { mix: { value: chain.mix }, a: planOf(chain.a), b: planOf(chain.b), stages }; if ('mix' in chain) return { mix: { value: chain.mix }, a: planOf(chain.a), b: planOf(chain.b), stages };
return { pitch: chain.pitch === 0 ? [] : [{ value: chain.pitch }], stages }; return { pitch: chain.pitch === 0 ? [] : [{ value: chain.pitch }], stages };
} }
@@ -436,9 +491,13 @@ describe('the sound library', () => {
worklets[0]!.port.onmessage!({ data: 'ended' }); worklets[0]!.port.onmessage!({ data: 'ended' });
expect(library.stats().playing).toBe(1); expect(library.stats().playing).toBe(1);
library.stop(FILE); library.stop(FILE);
expect(library.stats()).toMatchObject({ playing: 0, stopped: 1 });
// Both ways go on for the fade, then both stop.
expect(sources.map((s) => s.stopped)).toEqual([false]);
expect(worklets[0]).toMatchObject({ posted: [], disconnected: false });
clockTo();
expect(sources.map((s) => s.stopped)).toEqual([true]); expect(sources.map((s) => s.stopped)).toEqual([true]);
expect(worklets[0]).toMatchObject({ posted: ['stop'], disconnected: true }); expect(worklets[0]).toMatchObject({ posted: ['stop'], disconnected: true });
expect(library.stats()).toMatchObject({ playing: 0, stopped: 1 });
}); });
it('a pitched voice that ends by itself leaves the count when the worklet says so', async () => { it('a pitched voice that ends by itself leaves the count when the worklet says so', async () => {
@@ -615,6 +674,7 @@ describe('the sound library', () => {
library.stopAll(); library.stopAll();
expect(library.stats()).toMatchObject({ playing: 0, stopped: 2 }); expect(library.stats()).toMatchObject({ playing: 0, stopped: 2 });
clockTo();
expect(sources.every((s) => s.stopped)).toBe(true); expect(sources.every((s) => s.stopped)).toBe(true);
}); });
@@ -637,4 +697,196 @@ describe('the sound library', () => {
await settle(); await settle();
expect(library.stats().started).toBe(0); expect(library.stats().started).toBe(0);
}); });
describe('the end of a voice (#324)', () => {
/** One echo after a quarter second, at half the volume: a tail of 0.25 s. */
const echo = (feedback = 0, pingPong = false): FxStage => ({
kind: 'delay',
time: 0.25,
feedback,
damping: 20000,
mix: 0.5,
pingPong,
});
it('fades a stopped voice out instead of cutting it, then lets its nodes go', async () => {
const library = new SoundLibrary();
await play(library);
library.stop(FILE);
const level = gains[0]!;
expect(level.ramps).toEqual([['linear', 0, 0.01]]);
expect(level.holds).toBe(1);
expect(sources[0]!.stopped).toBe(false);
expect(disconnects).toEqual([]);
clockTo(0.005);
expect(sources[0]!.stopped).toBe(false);
clockTo(0.01);
expect(sources[0]!.stopped).toBe(true);
expect(disconnects.filter((kind) => kind !== 'clock')).toEqual(['gain']);
});
it('cuts at once where the clock stands still, so a paused run cannot sound in the next', async () => {
const library = new SoundLibrary();
await play(library);
contextState = 'suspended';
library.stopAll();
expect(sources[0]!.stopped).toBe(true);
expect(clocks).toHaveLength(0);
});
it('wires an echo: the sound goes on, the line feeds back through the damping, added at mix', async () => {
await play(new SoundLibrary(), FILE, {}, { pitch: 0, stages: [echo(0.4)] });
expect(delays).toMatchObject([{ max: 2, delayTime: { value: 0.25 } }]);
expect(filters.map((f) => [f.type, f.frequency.value])).toEqual([['lowpass', 20000]]);
expect(filters[0]!.Q.value).toBeCloseTo(-3.0103, 3);
expect(wires).toEqual([
'source > gain', // the source through its fade
'gain > gain', // the sound on, as it is
'delay > filter',
'filter > gain', // into the mix
'gain > gain', // and out
'gain > delay', // the sound into the line
'filter > gain', // round again,
'gain > delay', // at the feedback
'gain > gain', // the fade into the echo
'gain > gain',
'gain > destination',
]);
// The voice, the fade made before it, the echo's in, out, feedback and mix.
expect(gains.map((g) => g.value)).toEqual([1, 1, 1, 1, 0.4, 0.5]);
});
it('wires a ping-pong echo into the left only, crossing sides on every round', async () => {
await play(new SoundLibrary(), FILE, {}, { pitch: 0, stages: [echo(0.4, true)] });
expect(wires).toEqual(
expect.arrayContaining(['gain > merger', 'merger > delay', 'filter > splitter', 'splitter > merger']),
);
expect(wires.filter((w) => w === 'splitter > merger')).toHaveLength(2);
expect(wires.filter((w) => w.endsWith('> delay'))).toEqual(['merger > delay', 'gain > delay']);
});
it('rings on after its sound has ended, still reached, and goes when the echoes have faded', async () => {
const library = new SoundLibrary();
const voice = await play(library, FILE, {}, { pitch: 0, stages: [echo()] });
expect(library.tail(voice)).toBe(0.25);
sources[0]!.onended!();
expect(library.stats()).toMatchObject({ playing: 1, stopped: 0 });
expect(library.tune(voice, {})).toBe(true);
clockTo(0.24);
expect(library.stats().playing).toBe(1);
clockTo(0.25);
expect(library.stats()).toMatchObject({ playing: 0, stopped: 0 });
expect(library.tune(voice, {})).toBe(false);
expect(library.tail(voice)).toBe(0);
// An echo is a loop: it is let go, where a dry voice is simply left to end.
expect(disconnects.filter((kind) => kind !== 'clock')).toEqual(['gain']);
});
it('a dry voice ends with its sound, as before', async () => {
const library = new SoundLibrary();
const voice = await play(library);
expect(library.tail(voice)).toBe(0);
sources[0]!.onended!();
expect(library.stats().playing).toBe(0);
expect(clocks).toHaveLength(0);
});
it('counts a tail again when a change makes it longer', async () => {
const library = new SoundLibrary();
const plan: FxPlan = {
pitch: [],
stages: [
{
kind: 'delay',
time: { value: 0.25 },
feedback: { value: 0, param: 'back', min: 0, max: 0.95 },
damping: { value: 0 },
mix: { value: 1 },
pingPong: false,
},
],
};
const voice = await play(library, FILE, {}, { plan, params: {} });
sources[0]!.onended!();
// Half as loud each round: ten echoes are heard, ending 2.5 s on – from where the change ends.
library.tune(voice, { back: 0.5 }, 1);
expect(library.tail(voice)).toBe(2.5);
clockTo(3.4);
expect(library.stats().playing).toBe(1);
clockTo(3.5);
expect(library.stats().playing).toBe(0);
});
it('lets the echoes ring when asked to: only the sound fades, the voice stays until they are gone', async () => {
const library = new SoundLibrary();
await play(library, FILE, {}, { pitch: 0, stages: [echo()] });
library.stop(FILE, true);
const [level, fade] = gains;
expect(fade!.ramps).toEqual([['linear', 0, 0.01]]);
expect(level!.ramps).toEqual([]);
expect(library.stats()).toMatchObject({ playing: 1, stopped: 1 });
clockTo(0.01);
expect(sources[0]!.stopped).toBe(true);
// Its own end afterwards changes nothing: it is ringing already.
sources[0]!.onended?.();
expect(library.stats().playing).toBe(1);
clockTo(0.26);
expect(library.stats()).toMatchObject({ playing: 0, stopped: 1 });
});
it('a stop without letting ring cuts a ringing voice short, and asking again changes nothing', async () => {
const library = new SoundLibrary();
await play(library, FILE, {}, { pitch: 0, stages: [echo()] });
library.stop(FILE, true);
library.stop(FILE, true);
expect(library.stats()).toMatchObject({ playing: 1, stopped: 1 });
library.stop(FILE);
expect(library.stats()).toMatchObject({ playing: 0, stopped: 2 });
expect(gains[0]!.ramps).toEqual([['linear', 0, 0.01]]);
});
it('a voice without echoes is simply faded out when asked to let ring', async () => {
const library = new SoundLibrary();
await play(library);
library.stop(FILE, true);
expect(library.stats()).toMatchObject({ playing: 0, stopped: 1 });
expect(gains[0]!.ramps).toEqual([['linear', 0, 0.01]]);
});
it('stops one voice by its number, also one still waiting on its bytes', async () => {
const library = new SoundLibrary();
const first = await play(library);
const second = await play(library);
library.stopVoice(first);
expect(library.stats()).toMatchObject({ playing: 1, stopped: 1 });
expect(library.tune(second, {})).toBe(true);
const waiting = await play(library, OTHER, { hold: true });
library.stopVoice(waiting);
await arrive();
expect(library.stats()).toMatchObject({ started: 2, playing: 1 });
});
it('moves the numbers of an echo while it plays, the damping evenly in octaves', async () => {
const library = new SoundLibrary();
const plan: FxPlan = {
pitch: [],
stages: [
{
kind: 'delay',
time: { value: 0.25, param: 'time', min: 0.01, max: 2 },
feedback: { value: 0.4 },
damping: { value: 0, param: 'dull', min: 0, max: 1 },
mix: { value: 0.5, param: 'wet', min: 0, max: 1 },
pingPong: false,
},
],
};
const voice = await play(library, FILE, {}, { plan, params: {} });
library.tune(voice, { time: 0.5, dull: 1, wet: 0.2 }, 2);
expect(delays[0]!.delayTime.ramps).toEqual([['linear', 0.5, 2]]);
expect(filters[0]!.frequency.ramps).toEqual([['exponential', expect.closeTo(500), 2]]);
expect(gains.at(-1)!.ramps).toEqual([['linear', 0.2, 2]]);
expect(library.levels(FILE)).toEqual([[0.25, 0.4, 20000, 0.5]]);
});
});
}); });
+142 -2
View File
@@ -301,8 +301,9 @@ export async function sound(t) {
const fxPalette = await json('__gm.overlayTexts()'); const fxPalette = await json('__gm.overlayTexts()');
check( check(
'its palette offers the sound effects and no behavior node', 'its palette offers the sound effects and no behavior node',
['Input', 'Output', 'Gain', 'Pitch', '5-Band-EQ', 'Mix'].every((label) => fxPalette.includes(label)) && ['Input', 'Output', 'Gain', 'Pitch', '5-Band-EQ', 'Mix', 'Delay'].every((label) =>
!fxPalette.includes('Branch'), fxPalette.includes(label),
) && !fxPalette.includes('Branch'),
JSON.stringify(fxPalette.slice(0, 12)), JSON.stringify(fxPalette.slice(0, 12)),
); );
await press('Escape'); await press('Escape');
@@ -384,6 +385,68 @@ export async function sound(t) {
graph.nodes = graph.nodes.filter((n) => n.id !== 'fx-mix'); graph.nodes = graph.nodes.filter((n) => n.id !== 'fx-mix');
graph.edges = graph.edges.filter((e) => !['fx-e5', 'fx-e6', 'fx-e7'].includes(e.id)); graph.edges = graph.edges.filter((e) => !['fx-e5', 'fx-e6', 'fx-e7'].includes(e.id));
graph.edges.push(${JSON.stringify(edge('fx-e3', 'fx-gain', 'out', 'fx-out', 'in'))});`); graph.edges.push(${JSON.stringify(edge('fx-e3', 'fx-gain', 'out', 'fx-out', 'in'))});`);
// #324, #327: a ping-pong Delay behind the gain. The blip is a quarter of a second, its echoes come
// 0.3 s apart, each half as loud as the one before: nine are heard, 2.7 s – the voice
// stays that long after its sound and goes then. Asked to let ring, only the sound
// stops and the voice stays; a stop after that cuts it.
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
graph.nodes.push(${JSON.stringify(
node('fx-delay', 'SoundFxDelay', 550, 0, { time: 0.3, feedback: 0.5, mix: 0.5, pingPong: true }),
)});
graph.edges = graph.edges.filter((e) => e.id !== 'fx-e3');
graph.edges.push(
${JSON.stringify(edge('fx-e8', 'fx-gain', 'out', 'fx-delay', 'in'))},
${JSON.stringify(edge('fx-e9', 'fx-delay', 'out', 'fx-out', 'in'))},
);`);
const delayInfo = await json('__gm.soundfx.info()');
await evaluate('__gm.sound.stopAll()');
const beforeEcho = await stats();
await click(fxPlayAt.x, fxPlayAt.y);
const echoStarted = await waitFor(stats, (s) => s.started > beforeEcho.started);
const startedAt = Date.now();
const echoLevels = await json(`__gm.sound.levels(${JSON.stringify(blip.file)})`);
await sleep(1000);
const ringing = await stats();
const quiet = await waitFor(stats, (s) => s.playing === 0, 40);
const rangFor = (Date.now() - startedAt) / 1000;
const echoKnobs = echoLevels?.[0]?.slice(2) ?? [];
check(
'a Delay keeps the voice after its sound has ended, until its echoes have faded',
delayInfo === '3 effects' &&
echoStarted.last?.stages === 2 &&
echoKnobs.length === 4 &&
Math.abs(echoKnobs[0] - 0.3) < 1e-6 &&
Math.abs(echoKnobs[1] - 0.5) < 1e-6 &&
Math.abs(echoKnobs[2] / (20000 * 0.025 ** 0.3) - 1) < 1e-3 &&
Math.abs(echoKnobs[3] - 0.5) < 1e-6 &&
ringing.playing === 1 &&
quiet.playing === 0 &&
rangFor > 2.4 &&
rangFor < 4.5,
JSON.stringify({ delayInfo, last: echoStarted.last, echoLevels, ringing: ringing.playing, rangFor }),
);
await click(fxPlayAt.x, fxPlayAt.y);
await waitFor(stats, (s) => s.playing === 1);
const beforeLetRing = await stats();
await evaluate(`__gm.sound.stop(${JSON.stringify(blip.file)}, true)`);
await sleep(500);
const letRing = await stats();
await evaluate(`__gm.sound.stop(${JSON.stringify(blip.file)})`);
const cut = await stats();
check(
'Let ring stops the sound and keeps the voice for its echoes; a stop after that cuts it',
letRing.playing === 1 &&
letRing.stopped === beforeLetRing.stopped + 1 &&
cut.playing === 0 &&
cut.stopped === beforeLetRing.stopped + 2,
JSON.stringify({ before: beforeLetRing.stopped, letRing, cut }),
);
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
graph.nodes = graph.nodes.filter((n) => n.id !== 'fx-delay');
graph.edges = graph.edges.filter((e) => !['fx-e8', 'fx-e9'].includes(e.id));
graph.edges.push(${JSON.stringify(edge('fx-e3', 'fx-gain', 'out', 'fx-out', 'in'))});`);
await showTab('scene'); await showTab('scene');
await mutate(` await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph; const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph;
@@ -584,6 +647,83 @@ export async function sound(t) {
offline.sweepLength > 48000 - 100, offline.sweepLength > 48000 - 100,
JSON.stringify(offline), JSON.stringify(offline),
); );
// #327: the player's own echo, rendered offline. A 10 ms burst into a ping-pong Delay (0.3 s,
// feedback 0.5, mix 0.5) comes back left at 0.3 s at half its level, right at 0.6 s at
// half of that, left again at 0.9 s – in Chrome each round 128 samples later than that. Then
// 64 stereo voices of it for 2 s, timed, for how many this machine plays at once (#324).
const echo = await evaluate(`
(async () => {
const RATE = 48000;
const echoOf = (pingPong) => ({ kind: 'delay', time: 0.3, feedback: 0.5, damping: 20000, mix: 0.5, pingPong });
const render = async (voices, seconds, stage = echoOf(true)) => {
const context = new OfflineAudioContext(2, RATE * seconds, RATE);
const burst = context.createBuffer(1, RATE * 0.01, RATE);
burst.getChannelData(0).fill(0.5);
for (let v = 0; v < voices; v++) {
const source = context.createBufferSource();
source.buffer = burst;
const nodes = __gm.sound.stageNodes(context, stage);
source.connect(nodes.input);
nodes.output.connect(context.destination);
source.start();
}
const started = performance.now();
const out = await context.startRendering();
return { left: out.getChannelData(0), right: out.getChannelData(1), ms: performance.now() - started };
};
// The middle of the burst: its edges ring a little through the damping.
const peak = (data, at) => {
let sum = 0;
const from = Math.round((at + 0.003) * RATE);
const to = Math.round((at + 0.007) * RATE);
for (let i = from; i < to; i++) sum += Math.abs(data[i]);
return Math.round((sum / (to - from)) * 1000) / 1000;
};
// Samples from where each echo is due to where it is heard, in both kinds of echo.
const late = ({ left, right }) => {
const found = [];
for (let k = 1; k <= 4; k++) {
const from = Math.round((k * 0.3 - 0.05) * RATE);
let at = from;
while (at < from + RATE * 0.1 && Math.abs(left[at]) <= 0.002 && Math.abs(right[at]) <= 0.002) at++;
found.push(at - Math.round(k * 0.3 * RATE));
}
return found;
};
const one = await render(1, 1.3);
const plain = await render(1, 1.3, echoOf(false));
const load = await render(64, 2);
const lateBy = late(one);
const at = (k) => k * 0.3 + lateBy[k - 1] / RATE;
return {
late: lateBy,
latePlain: late(plain),
dry: [peak(one.left, 0), peak(one.right, 0)],
first: [peak(one.left, at(1)), peak(one.right, at(1))],
second: [peak(one.left, at(2)), peak(one.right, at(2))],
third: [peak(one.left, at(3)), peak(one.right, at(3))],
between: [peak(one.left, 0.45), peak(one.right, 0.45)],
voices: 64,
renderMs: Math.round(load.ms),
realTimeVoices: Math.round((64 * 2000) / load.ms),
};
})()`);
const near = (value, to) => Math.abs(value - to) < 0.02;
check(
'the player’s ping-pong echo comes back left, then right, then left, each round half as loud',
near(echo.dry[0], 0.5) &&
near(echo.dry[1], 0.5) &&
near(echo.first[0], 0.25) &&
echo.first[1] < 0.005 &&
echo.second[0] < 0.005 &&
near(echo.second[1], 0.125) &&
near(echo.third[0], 0.0625) &&
echo.third[1] < 0.005 &&
echo.between.every((v) => v < 0.005) &&
// Chrome takes a render quantum more for every round of a feedback loop.
[echo.late, echo.latePlain].every((late) => late.every((n, k) => n === 0 || n === k * 128)),
JSON.stringify(echo),
);
await mutate(` await mutate(`
const logic = __gm.state.project.assets.find((a) => a.id === 'sn-logic'); const logic = __gm.state.project.assets.find((a) => a.id === 'sn-logic');
logic.variables = []; logic.variables = [];