Audio performance audit (2026-09-29)
Audio performance audit (2026-09-29)
Section titled “Audio performance audit (2026-09-29)”Scope: every past audio problem, the current setup, what Godot 4.7 actually does underneath, a headless measurement, and a ranked list of changes toward the highest-performance setup for Apple TV / iPhone / iPad / web. Read-only audit, so no game code was changed. The measurements used scratch scripts outside the repo.
Confidence tags: [Certain] = read in source or measured here. [Likely] = strong inference from source or docs, not measured on device. [Guessing] = estimate.
- The biggest cost is the WAV import mode, not the routing code. Every SFX is imported as
QOA (
compress/mode=2, Godot’s default). A QOAplay()decodes a whole 5120-sample QOA frame synchronously on the calling (main) thread, which costs ~30 µs perplay()on an M5, against ~0.6 µs for PCM (50x). [Certain]: measured, and the code path was traced in the fork source. The fix is a one-line import change per file, and all 54 SFX add only ~2.6 MB of RAM. - The GDScript routing in
AudioManager.consume()is negligible (~0.3 µs/tick). [Certain] beam_humis re-triggered almost every tick while a beam fires (~43 plays/s in the measured window). That burns main-thread time and voices, and truncates other sounds. [Certain]- On Apple platforms Godot mixes at the hardware rate (
AVAudioSession.sampleRate) whatever the project says, and the resampler has no fast path. Converting the 44.1 kHz assets to 48 kHz would buy nothing on the CPU side. [Certain] (source) - On web (Sample playback mode), each MP3 music track is fully decoded to stereo float32 on the main thread the first time it plays. That is a ~60 MB retained buffer per track plus a larger transient peak, and a hitch at first boss-theme play. The Master limiter also does not run on web. [Likely] (source read, not measured in a browser)
- The telemetry
sim_ms(=Performance.TIME_PHYSICS_PROCESS) already includes audio, becauseaudio.consume()runs insidemain.gd::_physics_process. Part of the ATV’s ~850 µs “sim” figure is QOA decode. [Certain] that the time is included; [Guessing] how large that share is on ATV.
1. Past audio issues
Section titled “1. Past audio issues”| Date / commit | Issue | Root cause | Fix still holds? |
|---|---|---|---|
2026-07-01 8cfc99cd |
Menu theme didn’t loop | Commit claimed the runtime AudioStreamMP3.loop = true was ignored in favour of the .import loop=false, so menu_theme.mp3.import was flipped to loop=true |
Yes for menu. But the diagnosis looks wrong: survival_loop.mp3.import and boss_theme.mp3.import still say loop=false, and a headless 4.7.1 test seeking to 1 s before the end showed boss_theme looping fine via the runtime flag (pos 1.79 s after 3 s). No bug today [Certain, headless Stream mode]. The .import files should still be aligned to loop=true so the answer doesn’t depend on runtime mutation. |
2026-07-01 792dfe68 |
Orbit hum played the whole time Orbit was owned | Not gated on actual hits | Yes: main.gd:1371-1378 gates on arsenal.orbit.is_hitting, for every pilot since dfb67714 |
2026-07-01 97a517f9, fc451da0 |
Drone pulse played the big reaction burst | Shared generic reaction fx kind |
Yes: drone_pulse kind (audio_manager.gd:14) |
2026-07-01 c402364b |
Harsh 8-bit enemy-death / hit sounds | Chess-defense placeholders | Yes: replaced (death later split into 4 light + 4 heavy variants, 15415779) |
2026-07-01 f981f6ec |
Late-game weapon fire deafening | Volume didn’t scale with build | Yes: set_weapon_load() (audio_manager.gd:238), co-op-aware since fb63be2a |
2026-07-01 369994d8 |
Audio would break on a second menu→run cycle | audio node missing from _return_to_menu() persistent list |
Yes: main.gd:704, main.gd:857 |
2026-07-05 50d77655 |
Warp sound too short, legacy name | Placeholder deploy.wav |
Yes |
2026-07-09 cd688dae |
Enemy telegraph sounds “annoying”, off-screen, full volume | No camera gating | Yes: ENEMY_FX_KINDS + visible-rect gate, -9 dB (audio_manager.gd:100-104,183). Camera-anchored since bfb26e2a |
2026-08-12 2d3958dd |
Concurrent SFX clipped at 0 dBFS | Pool sums voices with no gain staging | Partly. The Master limiter is still there (audio/bus_layout.tres:3-9), but it is the deprecated AudioEffectLimiter (Godot says to use AudioEffectHardLimiter), and bus effects don’t run on web in Sample mode, so web still clips [Certain] (ProjectSettings docs) |
2026-09-26/27 2bc96888, ffe5ae1f, dfb67714, fb63be2a |
P2 hurt / warp / orbit / weapon-load audio silent in co-op | P1-only checks | Yes |
2026-09-27 f39bcb34 → cde2ab6f |
Rift Bolt machine-gun buzz; then every throttled shot at 0.88 pitch, and the pitch leaked into later sounds | No rate limit; fmod(int*k, 1.0) is always 0; play() only set pitch when != 1 |
Yes: AudioThrottle (8/s) and an unconditional pitch_scale set (audio_manager.gd:171) |
Open (docs/RELEASE-1.0.1.md:80) |
menu_theme.mp3 and survival_loop.mp3 are byte-identical (same MD5), 3.7 MB each, both shipped |
Asset duplication | Open, Chris’s call. Perf impact is only on web (two separate full decodes, see §3.6) |
No past issue was a CPU or stutter problem caused by audio. The history is all about correctness
and mix quality. So the QOA main-thread cost below is new, not a regression of an old fix.
[Certain] (git log --all -i --grep='hitch|stall|stutter' shows nothing audio-related).
2. Current implementation (file:line)
Section titled “2. Current implementation (file:line)”- Manager:
audio/audio_manager.gd(296 lines), a render-sideNodecreated inmain.gd:401. It never touches/simor the sim RNG streams. Death-variant choice andAudioThrottlepitch use the globalrandi()/randf_range(), which is fine for determinism [Certain]. - Pool:
POOL_SIZE := 16plainAudioStreamPlayers (:9, built at:142-146). Voices are picked by blind round-robin (:166-167). The comment says “oldest-wins if all busy”, but the code steals the next slot even when other slots are idle, and it will cut off a 1.2 s boss sting after 16 later plays. - Per
play()(:162-172):player.bus = bus(a GDScriptString, converted toStringNameon each call),player.stream = …(internally thisstop()s the voice, rebuilds the parameter list and emitsproperty_list_changed, even for the same stream),volume_db,pitch_scale, thenplay(). - Per tick (
main.gd:1492-1493, inside_physics_process):set_weapon_load()andconsume(sim.fx_events, _visible_world_rect()).consume()walks every fx event:Dictionary.get("kind"), twohas()lookups and a few string compares.PER_FRAME_CAP := 4covers deaths and the mapped kinds. Named/boss reactions are uncapped.aim_fireis throttled separately (8/s). Also per physics frame:set_orbit_active()(main.gd:1378). - Dedicated players: orbit loop (
:150-154), music (:156-158, lazyload()ofres://audio/<key>.mp3, runtimeloop = true). - Discrete helpers:
level_up/game_over/victory/dash/hurton SFX,ui_nav/ui_buyon the UI bus (called from ~10 UI panels). - Buses:
audio/bus_layout.tres: Master (deprecatedAudioEffectLimiter, threshold -3 dB, ceiling -1 dB) and SFX/Music/UI with no effects. One cheap per-sample effect [Likely]. - Assets: 54 SFX WAVs, all mono / 16-bit / 44.1 kHz, 0.04-1.2 s, plus
orbit_ambience.wav10 s withloop_mode=1. Allcompress/mode=2(QOA), the importer default (enumPCM, IMA ADPCM, QOA, default 2 ineditor/import/resource_importer_wav.cpp:89). Total PCM size ~3.2 MB. 3 music MP3s, stereo 192 kbps 44.1 kHz, ~153-155 s each, streamed byAudioStreamMP3. - Project settings: both
project.godot:21-23andplatform/tvos/project.godot:19-21set onlybuses/default_bus_layout. Mix rate, output latency, playback type and iOS session category are all defaults. So the session is Ambient (respects the silent switch and mixes with other audio), the mix rate is the hardware rate on Apple, the web plays in Sample mode, and the web export is single-threaded (export_presets.cfg:25).platform/tvos/audiois a symlink to the rootaudio/, so import changes apply to both projects. - Debug toggle already there:
ui/debug_settings_panel.gd:326-330mutes the Music/SFX buses. Muting a bus does not skipplay(), so it can’t be used as a CPU A/B [Certain].
3. What Godot 4.7 does underneath (source + docs)
Section titled “3. What Godot 4.7 does underneath (source + docs)”All source references are to ~/Claude/godot-tvos/godot (the fork that builds the tvOS archive;
the audio code is unmodified upstream 4.7).
3.1 QOA vs PCM vs IMA-ADPCM
Section titled “3.1 QOA vs PCM vs IMA-ADPCM”-
Docs: WAV is “lightweight to play back on the CPU (hundreds of simultaneous voices … are fine)”. QOA “reduces file size a bit more than IMA ADPCM and the quality decrease is much less noticeable, at the cost of slightly higher CPU usage”. IMA-ADPCM loses quality audibly. “Use WAV for short and repetitive sound effects” (Importing audio samples).
-
What “slightly higher CPU” hides [Certain]:
AudioServer::start_playback_stream()(servers/audio/audio_server.cpp:1248) callsplayback->start()on the calling thread.AudioStreamPlaybackWAV::start()then callsbegin_resample(), which pre-fills 128 frames via_mix_internal()(servers/audio/audio_stream.cpp:155-163). For QOA that runsqoa_decode_frame()over a whole 5120-sample frame (scene/resources/audio_stream_wav.cpp:177-184). Measured on M5 (headless, Dummy driver):Path µs per call AudioStreamPlayer.play(), QOA30.1-30.6 AudioStreamPlayer.play(), PCM0.56-0.60 AudioStreamPlayer.play(), IMA-ADPCM0.84-0.88 instantiate_playback()alone (any format)0.16-0.18 AudioStreamPolyphonic.play_stream(), PCM / QOA0.28 / 0.82 (start is deferred to the audio thread) -
On the audio thread, QOA adds one more frame decode per 5120 samples (~116 ms) per voice. That is small next to the main-thread spike [Likely].
-
Memory: QOA is ~1/5 of PCM (8.6 KB against 42 KB for a 0.48 s death pop). PCM for every SFX is ~3.2 MB resident against ~0.65 MB now [Certain] (file sizes / measured
data.size()).
3.2 Mix rate and resampling
Section titled “3.2 Mix rate and resampling”- On iOS/tvOS,
AudioDriverCoreAudio::initsetsmix_rate = [AVAudioSession sharedInstance].sampleRate, and on macOS it uses the device nominal rate (drivers/coreaudio/audio_driver_coreaudio.mm:126-143).audio/driver/mix_rateis ignored on Apple platforms [Certain]. ATV over HDMI is almost certainly 48 kHz [Likely]. Read it on device withAudioServer.get_mix_rate(). AudioStreamPlaybackWAVextendsAudioStreamPlaybackResampled, whosemix()always runs 4-tap cubic interpolation, even at a 1:1 ratio (audio_stream.cpp:184-230). Matching asset rate to mix rate saves no CPU (it would only avoid a tiny quality loss) [Certain].- Output latency: on iOS/tvOS the driver computes
buffer_framesfromoutput_latencybut only applies it on macOS (audio_driver_coreaudio.mm:158-165). Godot never setspreferredIOBufferDuration, so the IO buffer is iOS’s default.audio/driver/output_latencyis effectively a no-op on device (compare the similar godot#108204 on Windows) [Certain] (source). Not a perf lever. - Background:
on_focus_out()callsaudio_driver.stop()(drivers/apple_embedded/os_apple_embedded.mm:770-785), so no audio CPU runs in the background [Certain].
3.3 Pool of players vs AudioStreamPolyphonic
Section titled “3.3 Pool of players vs AudioStreamPolyphonic”AudioStreamPlayer.play()allocates a playback object plus two list nodes and starts the stream synchronously.AudioStreamPolyphonic.play_stream()only queues. Both are fine with PCM (0.6 against 0.3 µs). The pool’s real problems are voice stealing and the per-playbus/streamchurn, not the pool pattern itself [Certain] (measured).set_stream()alwaysstop()s, then re-emits the parameter/property-list notifications (scene/audio/audio_stream_player_internal.cpp,set_stream).set_bus()loops over live playbacks and callsAudioServer::set_playback_bus_exclusivefor each. Assigning aStringbus to a playing player measured 2.8-3.7 µs, against 0.13 µs for aStringName, and 0.02 µs on an idle player [Certain].
3.4 Bus effects
Section titled “3.4 Bus effects”- The deprecated
AudioEffectLimiteris a per-sample soft clip, andAudioEffectHardLimiter(its replacement) is a look-ahead limiter. Both are O(n) per mix buffer on one bus, which is negligible [Likely]. (AudioEffectHardLimiter,doc/classes/AudioEffectLimiter.xml:2“deprecated … Use AudioEffectHardLimiter”.)
3.5 Music format
Section titled “3.5 Music format”- Docs: Ogg Vorbis needs “significantly more processing power”; MP3 has “less CPU usage to play back compared to Ogg Vorbis” and is “useful for mobile and web projects where CPU resources are limited”. Keep MP3. Don’t convert music to Ogg for performance [Certain] (docs).
3.6 Web specifics
Section titled “3.6 Web specifics”audio/general/default_playback_type.webdefaults to Sample: sounds play through Web Audio buffers, and “AudioEffects are not supported” (ProjectSettings docs). Forcing Stream on a non-threaded export risks crackle (godot#103442). Stay on Sample [Certain].- On first play,
AudioDriverWeb::register_sample()(platform/web/audio_driver_web.cpp:238-255) decodes the entire stream at the context rate into aVector<AudioFrame>, copies it into a planar float array, and JS copies that again into anAudioBuffer(platform/web/js/libs/library_godot_audio.js:1634-1662). For a 155 s track at 48 kHz, each copy is ~60 MB, so there is a ~120-240 MB transient peak and ~60 MB retained per track, all on the main thread [Likely] (arithmetic from source, not measured). Becausemenu_themeandsurvival_loopare separate resources, the same music gets decoded twice.boss_theme’s first decode lands at the first boss spawn, mid-fight. - The QOA choice doesn’t matter on web: every sample is decoded once at registration.
4. Measurement
Section titled “4. Measurement”Available signals. Telemetry has no audio columns. Godot exposes
AudioServer.get_mix_rate(), get_output_latency(), get_time_to_next_mix() and per-bus peak
dB (get_bus_peak_volume_left_db). There is no “active voices” monitor. Voice count has to be
sampled script-side (_pool[i].playing). The existing samples.sim_ms already includes audio
routing and play() time, since both run in _physics_process [Certain].
Headless run (M5, Godot 4.7.1 stock, Dummy driver; the script side and play() run for real).
Scratch script: seed 1234 survival, invulnerable, weapon-first picks, 16 200 warm-up ticks, then
1800 ticks of fx_events recorded and replayed through AudioManager.consume() with a per-tick
camera rect around the player.
| Measure | Value |
|---|---|
| Late window | 5 weapons (blaster, blade, turret, beam, scar_tissue), ~0.5 fx events/tick (max 9), sim 213 µs/tick |
Routing only (play() stubbed) |
0.31 µs/tick |
Full consume() incl. real play() |
10.6 µs/tick (≈ 0.34 plays/tick = 20.7 plays/s × ~30 µs) |
| Plays by key in 30 s | beam_hum 1285 (43/s), turret_impact 600 (20/s), enemy_ranged_fire 400, blaster_fire 325, melee_swish 290, deaths ~160 |
| Worst case allowed by the caps | 4 capped plays/tick + uncapped boss cues + aim_fire ≤ 8/s ≈ 240+ plays/s ≈ 7 ms/s ≈ 120 µs/tick on M5 |
Caveats. The invulnerable autopilot kept the swarm small (≤113 enemies in a first 150 s run, ≤24
in this one), so this is not a dense late-run swarm. The worst-case row is the realistic
ceiling for a heavy swarm, because PER_FRAME_CAP bounds it. ATV CPUs are slower than an M5
single core. A 2-4x factor gives ~60-120 µs per QOA play() on ATV, and up to ~0.25-0.5 ms
per tick in a heavy fight [Guessing] (the scale factor is not measured). With PCM, the same
ceiling is ~2-5 µs/tick [Likely]. Side finding: tools/sim_bench/sim_bench.gd is broken. It
calls s.apply_upgrade(ch[0]), which moved to upgrade_system.apply_upgrade(sim, pilot, id).
5. Ranked changes
Section titled “5. Ranked changes”| # | Change | Expected gain | Effort | Risk | Verify on device |
|---|---|---|---|---|---|
| 1 | Import every SFX as PCM: compress/mode=0 in all 54 audio/*.wav.import, then godot --headless --import. Add [importer_defaults] wav={"compress/mode": 0} to both project.godots so new sounds (e.g. from tools/sound_map assign) inherit it, and add a GUT test asserting every audio/*.wav.import has compress/mode=0, so it can’t drift back (a structural rule, not a memory rule) |
Main-thread play() ~30 → ~0.6 µs (50x) [Certain, M5]. Removes QOA decode from the audio thread. Up to ~120 µs/tick at the M5 worst case, likely 0.25-0.5 ms/tick on ATV [Guessing] |
30 min | Quality: better (lossless). RAM +~2.6 MB, pck +~2.5 MB. Determinism: none | ATV sim_ms p50/p95 before and after in a comparable late run (auto_showcase). Instruments Time Profiler: qoa_decode_frame should disappear from the main-thread stack |
| 2 | Per-key retrigger cooldown in consume(): generalise AudioThrottle into a per-key min-interval (e.g. 4-6 ticks for beam_hum/turret_impact/melee_swish/deaths), counted on _tick_counter |
Measured window: ~60-70% fewer plays (beam 43/s → ≤15/s) [Likely]. Also frees voices so boss cues stop getting stolen | 1-2 h + tests | Audio feel changes, needs Chris’s ear. Determinism: none (tick counter, no sim RNG) | Dev A/B toggle (§6) plus aud_plays telemetry; listen for “machine-gun” beam and turret sounds |
| 3 | Voice allocation: prefer an idle voice (scan 16 playing flags, or rotate to the next idle), steal oldest only when all are busy; reserve 2-3 voices for named/boss cues and put UI on its own 2-voice mini-pool |
Fewer stop/restart pairs. No truncated boss stings | 1-2 h | Quality: better. Determinism: none | aud_voices_max, aud_steals telemetry; listen to Graviton/Eye stings in a busy fight |
| 4 | Stop per-play churn: each pooled player gets its bus once in _ready (StringName constants &"SFX"); drop the bus param from the hot path; skip stream = when unchanged |
~1-3 µs/play (the String bus on a playing voice measured ~3 µs) [Certain] |
30 min | None | Covered by #1’s measurement |
| 5 | Web music: point survival_loop at menu_theme’s resource (or delete the duplicate, Chris’s call) and pre-register boss_theme on the menu (play it muted for one frame at boot, or AudioServer.register_stream_as_sample) so the decode hitch isn’t mid-fight. Consider a shorter or mono web-only music loop |
~60 MB retained less (dedupe); the boss-spawn hitch moves to boot [Likely] | 1 h | Web only. None for native | Chrome DevTools performance trace of the web demo at the first boss spawn; memory panel |
| 6 | Swap AudioEffectLimiter → AudioEffectHardLimiter (ceiling -1 dB, keep the -3 dB intent via pre_gain_db/release tuning) |
Correctness (a supported class that isn’t deprecated). CPU about the same [Likely] | 15 min | Slight loudness/character change, needs an ear check. Web still has no limiter (Sample mode), so for web keep new assets peak-normalised | A/B the -1 dB ceiling with a loud overlap (ffmpeg amix normalize=0 recipe in docs/architecture/godot-gotchas.md) |
| 7 | Align survival_loop.mp3.import / boss_theme.mp3.import to loop=true |
Removes reliance on runtime mutation (works today) | 5 min | None | n/a |
| 8 | Optional: migrate the one-shot pool to one AudioStreamPolyphonic player per bus |
play_stream 0.28 µs, start deferred to the audio thread |
2-3 h | Moderate refactor; tests poke _pool/_pool_cursor |
Only worth it if #3 is being rewritten anyway |
| — | Don’t: resample assets to 48 kHz, convert music to Ogg, force Stream playback on web, or touch output_latency |
None (§3.2, §3.5, §3.6) | — | — | — |
#1 alone gets most of the available gain. #2 and #3 are mix-quality fixes that also cut CPU.
6. Proposed dev A/B toggle and telemetry
Section titled “6. Proposed dev A/B toggle and telemetry”Toggle (dev builds only): add DevFlags.audio_sfx_off in meta/dev_flags.gd (same
load/save/force-false-in-non-dev pattern as perf_bench). When it is set,
AudioManager.consume() and the discrete helpers return immediately (music keeps playing). That
gives the full upper bound of the SFX main-thread cost on the ATV in one flag. PCM against QOA
can’t be switched at runtime (the device only has the imported sample), so compare #1 across two
dev builds using the counters below.
Telemetry: extend the existing per-window samples row (net/telemetry.gd:186), and write
nothing from /sim. Suggested fields:
aud_us: summedTime.get_ticks_usec()aroundaudio.consume()plus the helpers in the window. This is the direct cost and separates audio fromsim_ms.aud_playsandaud_capped:play()calls, and events dropped byPER_FRAME_CAPor the cooldown.aud_voices_max: max count of_pool[i].playingseen in the window (sample once per tick, 16 bool reads).aud_steals: plays that stopped a voice that was still playing.- Once per session (lifecycle row):
aud_mix_rate(AudioServer.get_mix_rate()) andaud_latency_ms(get_output_latency()*1000).
This is performance data of the same kind as fps/sim_ms, but CLAUDE.md requires checking the
privacy page and ASC label wording for any new kind of data. It is probably covered, but confirm.
Any new D1 columns go on the existing samples table (already in RETENTION_TABLES).
Device protocol: ATV dev build, auto_showcase on, same seed, 10 min, run four ways: QOA
against PCM builds, each with audio_sfx_off off and on. Compare aud_us, sim_ms p95 and
hitches counts. Expected result: PCM aud_us is within noise of audio_sfx_off.
Sources
Section titled “Sources”- Godot docs: Importing audio samples, Audio streams, AudioEffectHardLimiter, ProjectSettings
audio/general/default_playback_type.web(in-treedoc/classes/ProjectSettings.xml:480-488) - Godot issues: #108204 output_latency has no effect, #103442 web no-threads crackle, #76797 audio tracker
- Engine source (fork
~/Claude/godot-tvos/godot):drivers/coreaudio/audio_driver_coreaudio.mm,drivers/apple_embedded/godot_app_delegate_service_apple_embedded.mm,drivers/apple_embedded/os_apple_embedded.mm,scene/resources/audio_stream_wav.cpp,servers/audio/audio_stream.cpp,servers/audio/audio_server.cpp,scene/audio/audio_stream_player_internal.cpp,platform/web/audio_driver_web.cpp,platform/web/js/libs/library_godot_audio.js - Scratch benches (session scratchpad, not in repo):
audio_bench.gd(replay/play()costs),poly_bench.gd(Polyphonic against pool),loop_test.gd(MP3 runtime loop)