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>
Load scene sets a flag on the World like Stop and Restart do; the swap
happens after the tick, and the last wish of a tick wins. Exactly one
thing crosses over: the SharedState, so global variables and global
machines keep what they know while everything local is built anew.
Loading the scene that is already running rebuilds it, which is what
"reset the level" means when the score must survive.
A run starts either from the start scene (a new project field, set from
the asset panel and marked with an arrow in the list) or from whatever
scene is open. The toggle next to Play says which question is asked and
remembers the answer in localStorage: it is a way of working, not part
of the game.
Sounds no longer end because a scene ends. A voice belongs to the
behavior that started it and stops right after On destroy, so the scene
swap is no longer a special case at all. For the enemy that plays its
explosion and destroys itself in the same breath there is now a
detached input on Play sound.
The scene selector stays usable while a run plays, because a double
click in the asset panel already did the same thing unguarded. It moves
what the editor looks at, not what the run plays.
Sounds follow the road fonts already paved: the file goes to the server
content-addressed under its sha256, the project keeps a named asset that
points at it, and nothing is ever re-encoded. The format is read from the
file's own signature, not from what the browser claims - the same .m4a
arrives as audio/mp4, audio/x-m4a or as nothing at all.
Playing is two nodes. "Play sound" takes the asset and may raise or lower
its volume; every call is its own voice, so two jumps in a row are two
jumps heard. Its loop input is a choice of three - from the asset, on,
off - because a checkbox has to spend one of its two positions on "not
set" and would lose a case: an asset that loops could then never be
played once.
Below the graph core stays browser-free: NodeContext hands out an asset
id, BehaviorSystem resolves it against the project and folds the asset's
own volume and loop into the call, and only viewport/SoundLibrary knows
about Web Audio. A run without a sink plays nothing instead of throwing.
The CI browser is muted, so the ear is no witness: the debug hook counts
started and stopped voices, and step 34 reads that counter.