Abdallah El BelkasyA. El Belkasy
Dubai --:-- 120 BPM Open to Rust · systems · backend

Notes · Godot 4.7 · Web Audio

The Godot web build that played no sound

Every sample played. None of it reached the speakers. The bug was in the engine, and the worse one was in my check.

19 to 22 September

For three days my audio check said the browser build of PolyBall was fine. The build made no sound at all. The Android build was fine, the desktop builds were fine, and the check passed every time I ran it.

PolyBall is a rhythm game. Every catch plays a note.

What the check was counting

The check loads the export in Chromium on a phone profile, wraps AudioBufferSourceNode.prototype.start and counts. Did the ambient bed start? Does it loop on its own instead of being restarted? Do notes fire once a run gets going? All yes. Godot was playing every sample. It was playing them into a graph that never reached the speakers.

I did measure the output once. On 20 September I put an AnalyserNode on the last hop, read silence, and decided the measurement was the problem, since everything else said the build was fine. I was half right. Godot calls disconnect() with no arguments on a bus's output, which drops every edge on it, my tap included, so that tap would have read silence either way. But the build really was silent too, and I didn't find that out for two more days.

What was actually wrong

The game builds its buses at runtime: Music, Drums, Echo, SFX and Note. Every one was added the same way:

var i := AudioServer.bus_count
AudioServer.add_bus(i)
AudioServer.set_bus_name(i, "Music")
AudioServer.set_bus_send(i, "Master")

On desktop that is fine. On the web, since 4.3, Godot keeps a copy of the bus list in JavaScript (library_godot_audio.js), and that copy gets out of order.

AudioServer::add_bus() turns any position at or past the end into -1, which means "append". The web driver hands that to Bus.addAt(-1), which creates the bus at the end and then calls Bus.move(id, -1), because -1 is not the new bus's index. move() inserts with splice(toIndex - 1, 0, bus). That is splice(-2, …), and with only Master in the list the new bus lands at index 0, in front of Master.

After the first add_bus() on the web
IndexEngine's listBrowser's list
0MasterMusic
1MusicMaster

Every later call by index now lands on the wrong bus in the browser. set_bus_send(1, "Master") sent the browser's Master into Music. Master was disconnected from ctx.destination, wired into a loop with a sub-bus, and picked up the sub-bus volumes as its own gain. The one node still connected to the speakers was the stream worklet, and nothing played through it: with the web's default playback type, Sample, an AudioStreamWAV plays through the sample buses.

The fix

One line per bus:

# was: AudioServer.add_bus(i)
AudioServer.bus_count = i + 1

Setting bus_count appends in place. On the web it goes through set_sample_bus_count, which adds the new bus to the end of the JavaScript list, in order. A default_bus_layout.tres takes that path too, which is probably why most projects never hit this. Only buses added at runtime with add_bus() are affected. PolyBall commit c0cbe65, 22 September.

The same code is still in Godot 4.7 (add_bus(), addAt() and move()) and in master as I write this. It is reported upstream as godotengine/godot#119026, with a one-line fix open in #119027.

The check that would have caught it

Counting buffers tells you what you sent. It says nothing about what arrived. So the check now watches the last hop itself, from before Godot loads:

window.__final = new Set();
const connect = AudioNode.prototype.connect;
const disconnect = AudioNode.prototype.disconnect;
AudioNode.prototype.connect = function (dst, ...rest) {
  if (dst instanceof AudioDestinationNode)
    window.__final.add(this);
  return connect.call(this, dst, ...rest);
};
AudioNode.prototype.disconnect = function (...a) {
  // no arguments drops every edge,
  // the one to the speakers included
  if (!a.length || a[0] instanceof AudioDestinationNode)
    window.__final.delete(this);
  return disconnect.apply(this, a);
};

That set is always "who feeds the speakers right now". Godot 4.7's web audio code only ever calls disconnect() with no arguments, so the other forms don't come up. While the ambient bed plays, the check puts a fresh AnalyserNode on each node in the set and reads the RMS. It passes only if a GainNode is in there with RMS above 0.001. A Godot bus ends in a GainNode, and the stream worklet is connected to the speakers too, so the worklet alone can't pass it.

On the broken build no GainNode was in the set, so it failed. On the fixed build Master reads about 0.01. I checked both, because a check that can't fail on the bug it was written for isn't doing anything.

What I'd tell myself on the 19th

I measured the output once, got silence, and blamed the measurement, because my other numbers disagreed. Half of that was fair. The other half cost me two more days.

If the thing you care about is sound coming out of a speaker, measure the last hop.

Play PolyBall in the browser, sound on