Change the sound FX parameters of a sound while it plays #315
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:
1 parent
072537b2e8
commit
c7e9769f90
18 files changed
+563
-106
No files matched your search
+67
-1
@@ -1,4 +1,4 @@
|
||||
import { edge, node } from '../lib/builders.mjs';
|
||||
import { edge, node, port, variable, written } from '../lib/builders.mjs';
|
||||
import { LANES, MAKE_OBJECT, pickAssetType } from './lib/scaffold.mjs';
|
||||
|
||||
/**
|
||||
@@ -414,6 +414,72 @@ export async function sound(t) {
|
||||
JSON.stringify(gameParam),
|
||||
);
|
||||
await stop();
|
||||
// #315: two looping voices of the same sound start at depth 7, the first keeps its number in a
|
||||
// variable, and `Set sound FX parameters` hands that one voice depth 3 every frame. In a
|
||||
// real AudioContext the first one's speed glides there, the second one's stays put.
|
||||
const tuneIds = ['sn-keep', 'sn-play2', 'sn-tick', 'sn-read', 'sn-tune', 'sn-fxp2'];
|
||||
const tuneEdges = [
|
||||
'sn-e-keep',
|
||||
'sn-e-voice',
|
||||
'sn-e-play2',
|
||||
'sn-e-fxp3',
|
||||
'sn-e-tick',
|
||||
'sn-e-read',
|
||||
'sn-e-fxp2',
|
||||
];
|
||||
await mutate(`
|
||||
const logic = __gm.state.project.assets.find((a) => a.id === 'sn-logic');
|
||||
logic.variables = [${JSON.stringify(variable('voice', 'number', 0))}];
|
||||
const graph = logic.graph;
|
||||
const play = graph.nodes.find((n) => n.id === 'sn-play');
|
||||
play.params.loop = 'true';
|
||||
graph.nodes.push(
|
||||
${JSON.stringify(node('sn-keep', 'Variables', 520, 0, {}, [written('voice', 'number')]))},
|
||||
{ ...structuredClone(play), id: 'sn-play2', x: 780 },
|
||||
${JSON.stringify(node('sn-tick', 'OnUpdate', 0, 300))},
|
||||
${JSON.stringify(node('sn-read', 'Variables', 0, 420, {}, [port('voice', 'number')]))},
|
||||
${JSON.stringify(node('sn-tune', 'SetSoundFxParameters', 260, 300))},
|
||||
${JSON.stringify(
|
||||
node('sn-fxp2', 'SoundFxParameters', 0, 540, { soundFxAsset: fxId, depth: 3 }, [
|
||||
{ key: 'depth', kind: 'number' },
|
||||
]),
|
||||
)},
|
||||
);
|
||||
graph.edges.push(
|
||||
${JSON.stringify(edge('sn-e-keep', 'sn-play', 'exec', 'sn-keep', 'exec'))},
|
||||
${JSON.stringify(edge('sn-e-voice', 'sn-play', 'voice', 'sn-keep', 'voice'))},
|
||||
${JSON.stringify(edge('sn-e-play2', 'sn-keep', 'exec', 'sn-play2', 'exec'))},
|
||||
${JSON.stringify(edge('sn-e-fxp3', 'sn-fxp', 'params', 'sn-play2', 'fxParams'))},
|
||||
${JSON.stringify(edge('sn-e-tick', 'sn-tick', 'exec', 'sn-tune', 'exec'))},
|
||||
${JSON.stringify(edge('sn-e-read', 'sn-read', 'voice', 'sn-tune', 'voice'))},
|
||||
${JSON.stringify(edge('sn-e-fxp2', 'sn-fxp2', 'params', 'sn-tune', 'fxParams'))},
|
||||
);`);
|
||||
const beforeTune = await stats();
|
||||
await play();
|
||||
const tunedStats = await waitFor(
|
||||
stats,
|
||||
(s) => s.tuned > beforeTune.tuned && s.started >= beforeTune.started + 2,
|
||||
);
|
||||
const rate = 2 ** ((3 - 7) / 12);
|
||||
const glided = (levels) =>
|
||||
Array.isArray(levels) && levels.length === 2 && Math.abs(levels[0][0] - rate) < 0.01;
|
||||
const levels = await waitFor(() => json(`__gm.sound.levels(${JSON.stringify(blip.file)})`), glided);
|
||||
check(
|
||||
'Set sound FX parameters moves the one voice whose number it holds from depth 7 to 3, not the other',
|
||||
tunedStats.last?.pitches.join() === '7' &&
|
||||
tunedStats.last.loop === true &&
|
||||
glided(levels) &&
|
||||
Math.abs(levels[0][1] - 10 ** (-6 / 20)) < 0.001 &&
|
||||
levels[1][0] === 1,
|
||||
JSON.stringify({ last: tunedStats.last, tuned: tunedStats.tuned, levels, rate }),
|
||||
);
|
||||
await stop();
|
||||
await mutate(`
|
||||
const logic = __gm.state.project.assets.find((a) => a.id === 'sn-logic');
|
||||
logic.variables = [];
|
||||
const graph = logic.graph;
|
||||
graph.nodes = graph.nodes.filter((n) => !${JSON.stringify(tuneIds)}.includes(n.id));
|
||||
graph.edges = graph.edges.filter((e) => !${JSON.stringify(tuneEdges)}.includes(e.id));`);
|
||||
await mutate(`
|
||||
const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph;
|
||||
graph.nodes = graph.nodes.filter((n) => n.id !== 'sn-fxp');
|
||||
|
||||
Reference in new issue
Block a user