Phaser 4's volume setter isn't broken
Short version: in Phaser 4.1.0 the WebAudioSound volume setter works. It just
doesn’t read back right away, so it looks dead. Write only the gain node
instead and Phaser’s own currentConfig.volume stays at 1 — then re-applies
itself on every resume(). Our music came back 3.13x too loud. Set both.
Here’s how we got there, because the wrong turn is the interesting part.
The symptom: Phaser music that shouted after every ad
We’re building Cook Line, a kitchen-rush game where you plate orders against a timer. Music sits at 0.32 in the mix. Quiet. It’s a bed, not a feature — you should notice it when it stops, not while it plays.
Then you hit pause. Or an ad rolls. And when you come back, the music is right there in your face. Not slightly loud. Comically loud, the kind where you lunge for the volume key on your laptop.
Pause again, resume again, same thing. Every single time.
You know that feeling when a bug is perfectly reproducible and you’re still annoyed, because reproducible means it’s your fault and you’re about to find out how? Yeah.
The wrong diagnosis: “the volume setter is a no-op”
Weeks earlier — back when we first added music to the game — we’d written this about Phaser’s audio in the project rules:
WebAudioSound.volumesetter andsetVolume()are no-ops. The getter reads the underlyingGainNode, so volume looks pinned at1while every write silently does nothing.
That note came from an honest test. Set the volume, read it back, get the old number. Do it again. Same. It really does look like the setter goes nowhere.
So we worked around it. Grabbed the underlying gain node and wrote to it directly:
if (track.volumeNode) track.volumeNode.gain.value = level;
That worked! Music played at the level we asked for. Fades ran. Ship it.
Except the diagnosis was wrong, and the workaround was the thing that broke resume.
Why Phaser 4’s volume setter looks dead
Phaser 4.1.0’s WebAudioSound.volume setter is not a no-op. It’s eleven lines
of WebAudioSound.js, and it does two things: stores your value in
currentConfig.volume, then calls setValueAtTime(value, 0) on the gain node.
set: function (value) {
this.currentConfig.volume = value;
this.volumeNode.gain.setValueAtTime(value, 0);
this.emit(Events.VOLUME, this, value);
}
And there’s the trap. setValueAtTime is scheduling, not assignment.
You’re telling the Web Audio graph “make this the value,” and the audio thread
gets around to it. Read .value back on the same tick and you get the old
number, because nothing has landed yet.
The Phaser getter reads the gain node directly — return this.volumeNode.gain.value. So:
track.volume = 0.32;
console.log(track.volume); // 1
Which reads exactly like a dead setter. Same evidence, two completely different causes, and we picked the wrong one.
I don’t think that test was dumb, honestly. Assign, read back, compare — that’s the test you’d write. It’s just that Web Audio doesn’t play by those rules, and nothing warns you.
The actual bug: currentConfig.volume stays at 1
Writing only the gain node, and never the setter, leaves Phaser holding a stale copy of your volume — and it hands that copy back to you on every resume.
currentConfig.volume is Phaser’s own record of how loud the sound should be.
Bypass the setter and it never updates. It stays at its default of 1, while
the gain node quietly holds your real 0.32.
Two sources of truth. Fine, until they meet.
And they meet, because Phaser doesn’t keep one buffer source alive forever.
Both play() and resume() call createAndStartBufferSource(),
which builds a fresh source and then calls applyConfig(). That lands in
BaseSound.applyConfig, which is six assignments in a row:
this.mute = this.currentConfig.mute;
this.volume = this.currentConfig.volume; // ← here
this.rate = this.currentConfig.rate;
// ...
Look at that second line. It writes back through the very setter you decided was broken — using the config value you never updated. The loop closes on itself.
So: pause the game, come back, and Phaser restores the volume you never set.
1. Against a bed mixed at 0.32, that’s 3.13x louder.
Which is precisely the pause screen and precisely the ad break. The two places it happened. The two places we’d been squinting at pause logic for, because the bug shows up where the resume happens, not where the mistake lives.
The fix: set track.volume and the gain node
To set volume reliably on a WebAudioSound in Phaser 4.1.0, write both — the
setter to keep currentConfig.volume honest, the gain node because that’s the
half guaranteed to take effect on this frame:
private applyGain(track: MusicTrack): void {
// Only exists once play() has been called.
if (!track.volumeNode) return;
const target = (this.levels.get(track) ?? 0) * audioSettings.musicVolume;
track.volume = target; // keeps currentConfig.volume in step
track.volumeNode.gain.value = target; // takes effect immediately
}
Two lines that look redundant and absolutely are not. If someone deletes one in six months, the mix breaks in a way nobody notices until a player mentions it. So there’s a comment above them in the real file explaining exactly this.
Then belt and braces — re-apply on the sound’s own RESUME event:
track.on(Phaser.Sound.Events.RESUME, () => this.applyGain(track));
The event fires after applyConfig(), so this always wins. Now the level
self-heals against any other internal path that rebuilds the source, including
ones we haven’t found yet. Costs nothing. Sleep better.
Three more Phaser 4.1.0 audio gotchas
volumeNodedoesn’t exist untilplay()has been called. Set the volume after you start the track, or it bursts in at full scale for a frame. An audible pop, not a theoretical one.- The
volumeoption passed tosound.add()really is ignored. That part of the original note was right. Configure it, then set it properly anyway. - Tween a proxy object into the gain, not the sound. Tweening
track.volumefights the same scheduling behaviour every frame.
All of this is Phaser 4.1.0 specifically — every line number and snippet above is from the v4.1.0 tag, so you can check me. If you’re on something newer, re-test before you copy any of it. The shape of the fix might have changed, and a workaround for a bug that no longer exists is its own kind of mess. Which is sort of the whole point of this post.
What I took from this: write down what you saw
The lesson isn’t “read the source,” though sure, read the source.
It’s that a workaround built on a wrong diagnosis doesn’t fail loudly. It works. It ships. It sits there being subtly wrong until some unrelated code path — a pause screen, an ad — walks past and knocks it over, and by then you’ve forgotten the workaround exists and you’re debugging the wrong file entirely.
When you write “X is broken, here’s the workaround” in your project notes, put the evidence next to it. What did you actually observe? Not what you concluded — what you saw. Ours said “the setter is a no-op.” If it had said “assigning volume and reading it back returns the old value,” someone would have spotted the Web Audio scheduling thing in about four seconds.
We fixed the note the same day we fixed the code. That felt like the more important commit.
The same engine caught us the same way a second time, in a different subsystem:
Scale.RESIZE can’t use device pixels, and for a while we thought the
problem was the font. Same shape — evidence that supports two explanations, and
the obvious one is wrong. A third, the scene instance Phaser reuses across
restart(), went the other way: the evidence pointed at content, and
the cause was a lifetime we had never thought about. And once, the explanation
we’d written down was simply wrong — no bug at all, just a confident
comment nobody had tested.