Change the sound FX parameters of a sound while it plays #315
game-maker / gate (push) Successful in 3m14s
game-maker / suite (push) Successful in 15m39s

Until now a value handed to a sound FX counted from the start of the
voice only. An engine whose pitch follows the speed, or a loop that
gets quieter, could not be built. Every play of Play sound now gives
out the number of its voice, and a new node lets the knobs of that one
voice glide to new values while it plays.

- Play sound has a new output voice: the number of this one play, or 0
  where nothing was played. Kept in a Number variable it reaches the
  voice from another event. Two plays of one sound are two voices.
- New action node Set sound FX parameters (exec, voice, fx params).
  It reaches exactly the voice it is given, a detached one too. A
  voice that has ended, or voice 0, is not reached.
- AudioSink.play returns the voice number and takes the plan and the
  values (FxVoice) instead of a finished chain. AudioSink.tune takes a
  voice number and new values. A name the voice's FX does not have is
  left out, one not given keeps its value.
- SoundLibrary hands out the number at once, before the bytes are
  there, and never twice. A voice still waiting for its bytes starts
  with the values handed in meanwhile.
- Every knob of a voice moves with setTargetAtTime (30 ms time
  constant). A knob whose value did not change is not sent again, so a
  tune every frame costs next to nothing.
- A pitch on a playing voice glides by playbackRate. A new WSOLA copy
  in the middle of a sound is not worked out, so the voice gets shorter
  or longer while it is moved. On a loop that is not heard.
- fxChainOf now gives a chain whose shape depends on the plan alone.
  A stage fed by a parameter stays at 0 dB, and a Mix fed by a
  parameter keeps both ways. A typed-in 0 or Mix at 0/1 is still left
  out by the compiler.
- Every way of a voice has a source of its own, also at the same
  pitch, so each can glide on its own. The pitched copy is shared.
- The tab keeps the numbers of what it plays, so moving a parameter
  field is heard at once.
- Test hook __gm.sound.levels(file) reads the knobs of every voice of
  a file, oldest first, and stats() counts the voices tuned.

Tests:
- soundfx.test.ts: 29 tests (+2 for the node in a running world: the
  voice of the first of two plays of one sound, and a play that played
  nothing)
- sound-library.test.ts: 15 tests (+3: glide, reach by number,
  pending voice)
- node-catalog.test.ts: 370 tests (+2, Play sound checks its voice)
- sounds + keep-on-scene-load: 31 tests, unchanged
- Counter-test: a tune that reaches every voice of the same file makes
  the reach test fail.
- e2e 34 (melektaus-atx): ALL PASSED - 30 checks (+1). In Chrome two
  looping voices of one sound start at depth 7. The one whose number
  sits in a variable glides to 0.7938 (2^(-4/12) = 0.7937), the other
  stays at 1. Both gains stay at 0.5012.
- The gate ran before voices got a number (CHECK EXIT 0, 241 files,
  3658 tests), not after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-05 20:53:26 +02:00
1 parent 072537b2e8
commit c7e9769f90
18 files changed
+563 -106

No files matched your search

+7 -1
View File
@@ -240,6 +240,7 @@ export interface NodeContext {
* the one case where the rule is in the way: the death sound of an object that destroys itself.
* `fx` names a sound FX asset the voice is played through (#311); `''` plays it as it is.
* `fxParams` are values for its parameters; one not given is the default the FX declares.
* Returns the number of the voice for `tuneSound`, or 0 where nothing is played.
*/
playSound(
assetId: string,
@@ -248,9 +249,14 @@ export interface NodeContext {
detached: boolean,
fx: string,
fxParams: FxParams,
): void;
): number;
/** Silences every voice of that sound; `''` silences everything this run started. */
stopSound(assetId: string): void;
/**
* 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.
*/
tuneSound(voice: number, fxParams: FxParams): void;
/**
* Asks for another scene, by asset id. The swap happens once the frame is over – a graph must be
* able to finish the flow it is in, and half a frame in the old world and half in the new one is
+16 -13
View File
@@ -59,7 +59,9 @@ export const PlaySound: NodeDef = {
'the node only colors it. Each play is a voice of its own: firing this twice in a row is heard ' +
'twice, overlapping. The sound belongs to this object and stops when the object is destroyed, ' +
'unless "detached" is on. "fx" sends it through a sound FX asset; left empty, it plays as it is. ' +
'"fx params" takes values for its parameters from a Sound FX parameters node.',
'"fx params" takes values for its parameters from a Sound FX parameters node. "voice" is the ' +
'number of this one play, for Set sound FX parameters to change it while it plays; 0 where ' +
'nothing was played.',
inputs: [
exec(),
soundPort(),
@@ -69,25 +71,26 @@ export const PlaySound: NodeDef = {
soundFxPort(),
{ key: 'fxParams', label: 'fx params', type: 'fxparams' },
],
outputs: [exec()],
outputs: [exec(), num('voice')],
params: [],
run: (ctx, inputs) => {
const sound = asString(inputs.sound);
if (sound)
ctx.playSound(
sound,
asNumber(inputs.volume),
loopChoice(inputs.loop),
asBoolean(inputs.detached),
asString(inputs.fx),
fxParamsOf(inputs.fxParams),
);
return { next: 'exec' };
const voice = sound
? ctx.playSound(
sound,
asNumber(inputs.volume),
loopChoice(inputs.loop),
asBoolean(inputs.detached),
asString(inputs.fx),
fxParamsOf(inputs.fxParams),
)
: 0;
return { next: 'exec', outputs: { voice } };
},
};
/** The numbers of a parameter bundle; anything else hands in none. */
function fxParamsOf(value: Value | undefined): FxParams {
export function fxParamsOf(value: Value | undefined): FxParams {
if (!isStruct(value)) return {};
const params: Record<string, number> = {};
for (const [key, item] of Object.entries(value)) if (typeof item === 'number') params[key] = item;
@@ -0,0 +1,32 @@
import type { NodeDef } from '../../NodeRegistry';
import { asNumber } from '../../values';
import { exec, num } from '../shared';
import { fxParamsOf } from './PlaySound';
/**
* Changes the sound FX parameters of a sound that is already playing (#315). It reaches one voice,
* by the number its `Play sound` handed out: every play is a sound of its own, and two objects with
* the same engine sound – or one object playing it twice – each keep theirs.
*/
export const SetSoundFxParameters: NodeDef = {
type: 'SetSoundFxParameters',
label: 'Set sound FX parameters',
category: 'action',
description:
'Changes the sound FX parameters of a sound while it plays: the voice a Play sound handed out ' +
'glides to the values from a Sound FX parameters node. Keep the voice in a variable to reach it ' +
'from another event. A parameter not given keeps its value, a voice that has ended is not ' +
'reached. A pitch changes by speed here, so the sound gets shorter or longer while it is moved.',
inputs: [
exec(),
{ ...num('voice'), inline: false },
{ key: 'fxParams', label: 'fx params', type: 'fxparams' },
],
outputs: [exec()],
params: [],
run: (ctx, inputs) => {
const voice = asNumber(inputs.voice);
if (voice > 0) ctx.tuneSound(voice, fxParamsOf(inputs.fxParams));
return { next: 'exec' };
},
};
@@ -6,14 +6,14 @@ import { asNumber } from '../../values';
/**
* Values for the parameters of one sound FX (#311), picked from those its Input declares the way a
* Variables node picks variables. Each picked one is a number input starting at the FX's default;
* the bundle goes into the `fx params` input of a `Play sound`.
* the bundle goes into the `fx params` input of a `Play sound` or a `Set sound FX parameters`.
*/
export const SoundFxParameters: NodeDef = {
type: SOUND_FX_PARAMETERS,
label: 'Sound FX parameters',
category: 'data',
description:
'Values for the parameters of a sound FX, for the "fx params" input of a Play sound. Pick the parameters to set; one not picked keeps the default its Input gives it.',
'Values for the parameters of a sound FX, for the "fx params" input of a Play sound or a Set sound FX parameters. Pick the parameters to set; one not picked keeps the default its Input gives it, or on a playing sound the value it has.',
inputs: [],
outputs: [{ key: 'params', label: 'params', type: 'fxparams' }],
params: [SOUND_FX_SETTING],
+2
View File
@@ -23,6 +23,7 @@ import { Raycast } from './action/Raycast';
import { RaycastAll } from './action/RaycastAll';
import { RestartGame } from './action/RestartGame';
import { SetPosition } from './action/SetPosition';
import { SetSoundFxParameters } from './action/SetSoundFxParameters';
import { SetZ } from './action/SetZ';
import { Spawn } from './action/Spawn';
import { StopGame } from './action/StopGame';
@@ -298,6 +299,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
StopAnimation,
PlaySound,
StopSound,
SetSoundFxParameters,
SoundFxParameters,
LoadScene,
Log,
+19 -10
View File
@@ -50,6 +50,14 @@ export const EMPTY_PLAN: FxPlan = { pitch: [], stages: [] };
/** Values for the parameters, by name; a name the plan does not have is left out. */
export type FxParams = Readonly<Record<string, number>>;
/** How a voice goes through its sound FX: the plan and the values it starts with, kept so they can change while it plays (#315). */
export interface FxVoice {
plan: FxPlan;
params: FxParams;
}
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
* at its audio inputs, by input key, given the numbers at its other inputs. Input and Output have no
@@ -239,23 +247,24 @@ function amountOf(amount: FxAmount, params: FxParams): number {
return Math.max(amount.min ?? -Infinity, Math.min(amount.max ?? Infinity, given));
}
/** A stage fed by a parameter stays at 0 as well: a value handed in later has to find it there. */
function stagesOf(plan: FxPlan, params: FxParams): FxStage[] {
const stages: FxStage[] = [];
for (const stage of plan.stages) {
if (stage.kind === 'gain') {
const db = amountOf(stage.db, params);
if (db !== 0) stages.push({ kind: 'gain', gain: 10 ** (db / 20) });
if (db !== 0 || stage.db.param !== undefined) stages.push({ kind: 'gain', gain: 10 ** (db / 20) });
continue;
}
const gain = amountOf(stage.gain, params);
if (gain !== 0) stages.push({ ...stage, gain });
if (gain !== 0 || stage.gain.param !== undefined) stages.push({ ...stage, gain });
}
return stages;
}
/**
* The chain one voice is sent along: the plan with every parameter filled in. A Mix that ends at 0
* or 1 is left out with the sound it silences, so that one is not even started.
* The chain one voice is sent along: the plan with every parameter filled in. Its shape depends on
* the plan alone, never on the values, so the values of a playing voice can change in place (#315).
*/
export function fxChainOf(plan: FxPlan, params: FxParams = {}): FxChain {
const stages = stagesOf(plan, params);
@@ -263,10 +272,10 @@ export function fxChainOf(plan: FxPlan, params: FxParams = {}): FxChain {
const pitch = plan.pitch.reduce((sum, amount) => sum + amountOf(amount, params), 0);
return { pitch, stages };
}
const mix = amountOf(plan.mix, params);
if (mix <= 0 || mix >= 1) {
const heard = fxChainOf(mix <= 0 ? plan.a : plan.b, params);
return { ...heard, stages: [...heard.stages, ...stages] };
}
return { mix, a: fxChainOf(plan.a, params), b: fxChainOf(plan.b, params), stages };
return {
mix: amountOf(plan.mix, params),
a: fxChainOf(plan.a, params),
b: fxChainOf(plan.b, params),
stages,
};
}