Skip to content

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.

  1. 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 QOA play() decodes a whole 5120-sample QOA frame synchronously on the calling (main) thread, which costs ~30 µs per play() 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.
  2. The GDScript routing in AudioManager.consume() is negligible (~0.3 µs/tick). [Certain]
  3. beam_hum is 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]
  4. 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)
  5. 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)
  6. The telemetry sim_ms (= Performance.TIME_PHYSICS_PROCESS) already includes audio, because audio.consume() runs inside main.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.
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).

  • Manager: audio/audio_manager.gd (296 lines), a render-side Node created in main.gd:401. It never touches /sim or the sim RNG streams. Death-variant choice and AudioThrottle pitch use the global randi()/randf_range(), which is fine for determinism [Certain].
  • Pool: POOL_SIZE := 16 plain AudioStreamPlayers (: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 GDScript String, converted to StringName on each call), player.stream = … (internally this stop()s the voice, rebuilds the parameter list and emits property_list_changed, even for the same stream), volume_db, pitch_scale, then play().
  • Per tick (main.gd:1492-1493, inside _physics_process): set_weapon_load() and consume(sim.fx_events, _visible_world_rect()). consume() walks every fx event: Dictionary.get("kind"), two has() lookups and a few string compares. PER_FRAME_CAP := 4 covers deaths and the mapped kinds. Named/boss reactions are uncapped. aim_fire is throttled separately (8/s). Also per physics frame: set_orbit_active() (main.gd:1378).
  • Dedicated players: orbit loop (:150-154), music (:156-158, lazy load() of res://audio/<key>.mp3, runtime loop = true).
  • Discrete helpers: level_up/game_over/victory/dash/hurt on SFX, ui_nav/ui_buy on the UI bus (called from ~10 UI panels).
  • Buses: audio/bus_layout.tres: Master (deprecated AudioEffectLimiter, 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.wav 10 s with loop_mode=1. All compress/mode=2 (QOA), the importer default (enum PCM, IMA ADPCM, QOA, default 2 in editor/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 by AudioStreamMP3.
  • Project settings: both project.godot:21-23 and platform/tvos/project.godot:19-21 set only buses/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/audio is a symlink to the root audio/, so import changes apply to both projects.
  • Debug toggle already there: ui/debug_settings_panel.gd:326-330 mutes the Music/SFX buses. Muting a bus does not skip play(), 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).

  • 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) calls playback->start() on the calling thread. AudioStreamPlaybackWAV::start() then calls begin_resample(), which pre-fills 128 frames via _mix_internal() (servers/audio/audio_stream.cpp:155-163). For QOA that runs qoa_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(), QOA 30.1-30.6
    AudioStreamPlayer.play(), PCM 0.56-0.60
    AudioStreamPlayer.play(), IMA-ADPCM 0.84-0.88
    instantiate_playback() alone (any format) 0.16-0.18
    AudioStreamPolyphonic.play_stream(), PCM / QOA 0.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()).

  • On iOS/tvOS, AudioDriverCoreAudio::init sets mix_rate = [AVAudioSession sharedInstance].sampleRate, and on macOS it uses the device nominal rate (drivers/coreaudio/audio_driver_coreaudio.mm:126-143). audio/driver/mix_rate is ignored on Apple platforms [Certain]. ATV over HDMI is almost certainly 48 kHz [Likely]. Read it on device with AudioServer.get_mix_rate().
  • AudioStreamPlaybackWAV extends AudioStreamPlaybackResampled, whose mix() 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_frames from output_latency but only applies it on macOS (audio_driver_coreaudio.mm:158-165). Godot never sets preferredIOBufferDuration, so the IO buffer is iOS’s default. audio/driver/output_latency is effectively a no-op on device (compare the similar godot#108204 on Windows) [Certain] (source). Not a perf lever.
  • Background: on_focus_out() calls audio_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-play bus/stream churn, not the pool pattern itself [Certain] (measured).
  • set_stream() always stop()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 calls AudioServer::set_playback_bus_exclusive for each. Assigning a String bus to a playing player measured 2.8-3.7 µs, against 0.13 µs for a StringName, and 0.02 µs on an idle player [Certain].
  • The deprecated AudioEffectLimiter is a per-sample soft clip, and AudioEffectHardLimiter (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”.)
  • 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).
  • audio/general/default_playback_type.web defaults 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 a Vector<AudioFrame>, copies it into a planar float array, and JS copies that again into an AudioBuffer (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). Because menu_theme and survival_loop are 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.

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).

# 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.

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: summed Time.get_ticks_usec() around audio.consume() plus the helpers in the window. This is the direct cost and separates audio from sim_ms.
  • aud_plays and aud_capped: play() calls, and events dropped by PER_FRAME_CAP or the cooldown.
  • aud_voices_max: max count of _pool[i].playing seen 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()) and aud_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.

  • Godot docs: Importing audio samples, Audio streams, AudioEffectHardLimiter, ProjectSettings audio/general/default_playback_type.web (in-tree doc/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)