Audio System
Everything the player hears goes through one path: a cue is requested by gameplay or QML, the cue registry picks a resource from the manifest, AudioSystem decides whether it may play, and the miniaudio backend mixes it. Music and ambience beds are chosen by tags in the manifest rather than by ID in code, so the asset set can change without touching C++ or QML.
Where each file came from and under what licence is in AUDIO_LICENSES.md.
Where things live
| Path | Role |
|---|---|
assets/audio/audio_manifest.json |
Every shipped file: ID, path, category, load policy, tags, playback limits |
assets/audio/audio_cues.json |
Every gameplay cue: which resources answer it, volume, priority, cooldown, spatial |
game/audio/audio_cues.* |
CueRegistry, play_cue, play_cue_at |
game/audio/audio_system.* |
AudioSystem: event queue, admission (cooldowns, priority, channel caps), volumes |
game/audio/audio_event_handler.* |
Turns engine events (selection, spawn, death, ambient state, hits) into cues |
game/audio/miniaudio_backend.* |
Device lifecycle, command ring, channel and effect mixer (on_audio) |
game/audio/decode_worker.*, track_decoder.*, track_store.* |
Decode thread and queues, file to mastered PCM, slot registry and residency |
game/audio/audio_mastering.* |
Decode-time loudness, tilt, resonance and limiter chain (soi_audio_mastering) |
game/audio/audio_spectrum_analysis.* |
FFT, octave smoothing and resonance-notch selection feeding analyse |
game/audio/gameplay_mix.h |
Mix buses and their ducking |
game/audio/bus_limiter.h |
Output limiter and listening presets |
game/audio/spatial.h |
Distance attenuation and pan |
game/audio/cue_trace.* |
Per-request outcome counting, trace log, mission summary export |
game/audio/audio_settings.h |
Persisted volume sliders and listening preset |
app/audio/audio_resource_loader.* |
Reads both JSON files, loads by policy, answers tag queries |
app/audio/audio_coordinator.* |
Frontend music, mission ambience, voice and ambient-state mappings |
app/audio/weather_audio.* |
Rain and snow layer over the mission bed |
app/audio/audio_system_proxy.* |
QML entry point (audioSystem.play_cue, volume sliders) |
third_party/miniaudio.h, stb_vorbis |
Device output and Vorbis decoding |
tools/audio_synth, tools/audio_field, tools/audio_import |
The three asset pipelines, see Asset pipelines |
The path of one cue
gameplay code / system QML (ui/qml/design/UiSound.qml)
play_cue("combat.hit.sword") audioSystem.play_cue("ui.click")
or publish AudioCueEvent β source tagged "qml"
β β
βΌ βΌ
CueRegistry::play caller thread, mutex
unbound? ββββββββββββββΊ drop: unbound
spatial, out of earshot? βΊ drop: muted (spends no cooldown)
cue cooldown? βββββββββΊ drop: cue_cooldown
choose a variant ββββββΊ drop: no_loaded_resource (none loaded)
β
βΌ
AudioSystem::play_sound βββΊ AudioEvent queue
β
βΌ
AudioSystem audio thread process_event
resource loaded? ββββββΊ drop: resource_not_loaded
max_instances βββββββββΊ drop: instance_limit
resource cooldown βββββΊ drop: resource_cooldown
spatialize + sliders ββΊ drop: muted (volume β 0, incl. out of earshot)
32-channel priority βββΊ drop: global_priority
category cap ββββββββββΊ drop: category_priority
pick mix bus
β
βΌ
MiniaudioBackend::play_sound βββΊ lock-free command ring (256)
β
βΌ
device callback on_audio no locks
music channels + sound-effect slots
Γ per-bus gain (GameplayMix) β BusLimiter β device
AudioEventHandler also drops a request as audience_filtered before it reaches the registry when the event belongs to another player (you do not hear an AI opening its own gate). Every request ends in exactly one outcome, so requests always equal accepted plus drops.
The source location is captured with std::source_location, so no call site passes it. An AudioCueEvent records where it was published, not where the handler dispatched it.
The two catalogues
audio_manifest.json β the files
{
"id": "voice.roman.archer",
"aliases": ["archer_voice", "roman.archer"],
"path": "voices/roman/archer.ogg",
"category": "voice",
"load_policy": "mission",
"tags": { "faction": "roman", "unit": "archer" }
}
| Field | Meaning |
|---|---|
id, aliases |
Resource ID; aliases resolve to the same resource |
path |
Relative to assets/audio/ |
category |
sfx, voice, ambience (or ambient), music β picks the volume slider, channel cap and mastering profile |
load_policy |
See Loading |
tags |
Free-form string pairs used by tag queries (screen_context, ambient_state, faction, unit, biome, sourceβ¦) |
priority, cooldown_ms, max_instances, volume, loop |
Per-resource playback limits, applied on the audio thread |
provenance |
Origin and licence; required on every track and rendered into AUDIO_LICENSES.md |
audio_cues.json β the cues
{
"id": "ui.click",
"description": "Menu button, list row or combo box entry is activated.",
"category": "sfx",
"importance": "optional",
"volume": 0.85,
"priority": 5,
"cooldown_ms": 40,
"resources": ["ui.click.primary"],
"wanted": "Firm wood-on-leather confirm tap with a small low-mid body."
}
| Field | Meaning |
|---|---|
resources, weights |
The pool. Unknown resource IDs are skipped with a warning; mismatched weights fall back to equal weights |
spatial |
true places the cue at the world point passed to play_cue_at; everything else plays flat |
importance |
required, optional or ambient. make audio-check fails when a required cue is silent or never fired |
wanted |
The sound's spec, in words β what a recipe or a replacement has to match |
Cue IDs used from C++ are constants in game/audio/cue_ids.h. A cue bound to nothing warns once (audio cue requested but bound to nothing); a cue whose resources exist but are not loaded warns check its load_policy.
Variant choice
With more than one resource, CueRegistry avoids the last pool / 2 picks, prefers a variant whose own resource cooldown has elapsed, then draws by weight. A two-take cue therefore never repeats back to back, and a four-take cue never repeats within two plays.
Loading
| Policy | Loaded | Unloaded |
|---|---|---|
startup |
At engine composition, before the menu | Never |
screen |
When the audio event handler starts, and whenever frontend music is applied | Never |
mission |
When a mission or skirmish starts, and when a save is loaded | Mission teardown |
lazy |
Only on demand, through AudioResourceLoader::ensure_audio_resource_loaded (most music, some voices and beds) |
Mission teardown |
Loading only queues a decode; the decode worker does the work (see Decode-time mastering). A one-shot asked for before its decode lands is skipped, and a looping bed is held and starts the moment it lands. AudioSystem::is_resource_ready does not mean decoded: it only says the resource is loaded, off cooldown and under its instance limit.
Resident decoded PCM is tracked against a budget (SOI_AUDIO_PCM_BUDGET_MB overrides it; the default is 320 MB); overruns are counted and logged, not refused.
Tracks are held as 32-bit float stereo at the device rate, so a minute of music costs about 22 MB at 48 kHz and twice that at 96 kHz. The two four-minute peaceful beds (music.base.echoes_ancient_outpost, music.base.ancient_peak_fires) are 88 MB each, and the startup and mission sets add roughly 20 MB more, which is why a mission that rotates onto its second long bed sits near 200 MB. The budget is a diagnostic: raise it (or shorten the long beds) rather than expecting the backend to evict anything.
Waiting on a decode
There is one decode worker, and a scripted launch reaches the mission preload while the
startup set (about a hundred tracks) is still queued on it. MiniaudioBackend::unload
runs with AudioSystem::resource_mutex held, and every load_sound in the mission
preload needs that mutex, so any wait inside unload stalls the loading thread. Two rules
keep that wait short. finish_job wakes m_decode_idle after every job, so
wait_for_track returns when its own track lands instead of when the whole queue drains.
And unload first calls cancel_queued_decode, which drops a job that has not started
yet, so only a decode already in flight is ever waited for. Before this the mission
preload measured anywhere from under 1 ms to 5.6 s on the same map.
Tag-driven selection
AudioCoordinator and AudioEventHandler pick music, beds and voices by querying manifest tags. No track ID for music or ambience appears in code except the final fallback bed.
Frontend music. Tracks tagged screen_context=<context> rotate round-robin per context. The context battle (or empty) stops music.
Mission music. For each ambient state (peaceful, tense, combat, victory, defeat) the handler holds the tracks tagged ambient_state=<state>; for combat, victory and defeat the player's own faction is queried first. A state change rotates to the next track and plays one of the SFX tagged state=<state> as a stinger (priority 7, at most once a minute). Coming back into a state within 90 seconds of leaving it resumes that state's last track instead of rotating, so a lull in a battle does not turn the score over twice. Victory and defeat play only state.victory / state.defeat, never a stinger as well.
The final assault. When a mission announces its last wave (MissionWaveIncomingEvent with final_wave, at the wave's warning), the handler switches straight to the track tagged ambient_state=combat and intensity=final_assault (combat_siege_at_dawn, the densest and most percussive combat track by onset rate and spectral flux) and holds it through every ambient change until the wave is cleared or the match is decided. Without this the Sepulcher Bridge's biggest rising stormed the gate to a quiet peaceful bed: the third rising's fight had released combat 20 s after it ended, the fourth rising was still marching from the barrows, and base_ancient_peak_fires opens with 40 s at about -40 dB. Keep final_assault off faction-tagged tracks: the faction-free fallback query matches them too, so tagging combat_carthaginian_dust as well turned a Roman climax into a coin toss between the two.
Slow tracks are not combat tracks. combat_last_defensive_wall is slow and melancholic (the documentary and marketing cuts ban it as the music that reads as defeat) and is tagged ambient_state=defeat; combat_shield_wall_at_dusk (about one onset a second) is tagged tense. Both were in the Roman combat pool, so a Roman player could get a dirge for a battle.
Ambient state. AmbientStateManager samples every two seconds. Combat means one of the player's units has an attack target, an enemy troop (per OwnerRegistry::are_enemies: not an ally, not neutral wildlife, not a building) stands within 15 m of one, or a living enemy mission-wave unit (AssaultWaveComponent) is on the field, so a wave marching on the player keeps the battle score instead of letting it release while the wave is still on its way. Combat is entered at once and released only after 20 seconds of quiet; other changes settle for 4 seconds.
Mission ambience. One looping bed, chosen by the first query below that matches anything:
- Words in the map path, map name, mission ID, title, summary and terrain:
harbor/portβbiome=harbor;city/marketβcity;village/hamletβvillage;siegeβbiome=camp, context=siege;road/crossroads(Roman players only) βbiome=plains, context=road, faction=roman;desert/zamaβbiome=plains, faction=carthage. - The biome: the mission's
terrain_typeif set, otherwise the map β rivers or lakes βriver,AlpineMixβmountain,GrassDryβplains,SoilRockyβbattlefield,SoilFertileβcamp, anything else βforest.campandplainstry the player's faction first. biome=battlefield.
Among the matches the bed is picked by a hash of map path, faction and mission ID, so the same mission always gets the same bed. If nothing matches, ambient.battlefield_dry_wind_distant_march_01 plays.
Weather. Weather is a layer, not a bed per sky. WeatherAudio plays ambient.weather_rain or ambient.weather_snow on top of the mission bed at intensity Γ 0.85, and stops below 0.02. Those two beds carry only the weather, never ground sound.
Unit voices. Selecting or spawning a unit plays the voice resolved in this order: the faction.unit mapping built from tags faction + unit, the unit-type mapping, the manifest ID voice.<faction>.<unit>, then the legacy alias <unit>_voice. Commanders are matched by unit_def. Selection voices play at least 120 ms apart, or 300 ms when the same line would repeat; spawn voices share a 1.5 s group cooldown.
Cues chosen by who is acting. Some actions pick their cue from the units doing them, because a resource pool is drawn at random and a mixed pool plays the wrong animal. A run order counts the selection's foot, cavalry and elephants (CommandController::selection_mounts) and plays combat.charge_elephant if any elephant is selected, combat.charge_cavalry if any cavalry is, and combat.charge otherwise (CommandController::charge_cue). An accepted move order plays order.move_mounted β horses moving off at a walk β only when every selected unit is cavalry (CommandController::move_order_cue). A Roman medicus binds wounds with combat.heal_bind; every other healer plays combat.heal.
One moment, one sound. Each thing that happens is voiced by exactly one owner:
| Moment | Voiced by | Not by |
|---|---|---|
| A unit dies | on_unit_died, positioned at the body; troops only (no human cry for a sheep or a wolf) |
the killing blow |
| An order is accepted | accepted_order_cue β hold, guard, formation placed and gather have their own cues; recruit is answered by build.unit_queued |
the mode-changed signals, or command.accept layered on top |
| A projectile is loosed | a positioned launch cue, heard by anyone near it; a volley at the archers' centre | β |
| A projectile lands | a hit is voiced by its CombatHitEvent; a siege shot plays combat.siege_impact only when it misses; arrows mark where a burst comes down with combat.arrow_flyby |
β |
| A rockfall is released | hazard.rockfall at the release point; hard landings play combat.siege_impact; a boulder striking a troop is voiced as combat.hit.siege by its CombatHitEvent |
a release cue per boulder |
| A builder task ends | build.construction_complete only when a structure is raised; construction_started only at a construction site |
felling a tree, lifting a harvest |
| A healer pulses | one combat.heal / combat.heal_bind per pulse, at the healer |
one per soldier healed |
| A dismantle finishes | build.construction_complete |
build.building_destroyed (the collapse) |
| A dialog is answered | its footer button (confirm or back) |
the dialog's close |
| A screen is left with Cancel or Escape | the screen's onCancelled (ui.back) |
the Cancel button, or the screen hiding |
In QML, IronButton.uiSound (click, back, confirm, toggle, none) says what a button plays; use none when the action it triggers already speaks. The in-game HUD's cards, chips and command buttons play no hover sound, and ambient-priority toasts and refusals raise no ui.notification.
Wildlife. Wolves howl at the pack (wildlife.wolf_hunt), sheep bleat where a flock bolts (wildlife.sheep_alarm), and birds are heard where a flock is flushed (wildlife.birds_flush, once per flock alarm). All three are spatial.
Commander chatter. The banks in assets/data/commanders/voices/*.json are on-screen lines. Each line may name a voice_cue, but no bank sets one today, so commanders speak in text only; the three files in voices/commanders/ are their selection barks.
Spatial audio
A cue with "spatial": true fired through play_cue_at is heard relative to the listener. The engine publishes the listener every frame from the render camera (app/core/game_engine.cpp), placed at the ground point the camera looks at, not at the lens: the RTS camera hangs tens of metres up, and measuring from there put everything on screen 30β70% of the way into the fade.
| Distance to listener | Volume |
|---|---|
| β€ 14 m | full |
| 14β90 m | (1 β reach)Β², quadratic fade |
| β₯ 90 m | silent, reported as muted |
Pan is the source's offset along the camera's right vector divided by 26 m, clamped, scaled to Β±0.85. Before a camera exists the listener is invalid and every cue plays flat at full volume.
A spatial request that distance would silence is dropped as muted before it spends the cue cooldown, so a fight at the far edge of the map cannot starve the same cue under the camera.
Distant fighting is not simply lost: AudioEventHandler counts impacts that distance silenced, and six inside three seconds become one combat.distant_battle cue. tests/core/audio_battle_load_test.cpp measures this against a paced melee.
The mix
Categories and channels
| Category | Slider (first run) | Concurrent cap |
|---|---|---|
| master | 0.70 | 32 channels |
| sfx | 1.00 | 16 |
| voice | 1.00 | 2 |
| ambience | 0.30 | 4 |
| music | 0.45 | 4 music channels, crossfaded |
A new sound above the 32-channel or category cap evicts the lowest-priority, oldest sound only if it outranks it; otherwise it is dropped. Sliders and the listening preset persist through QSettings (INI, organisation djeada, application StandardOfIron, keys audio/*). Invalid saved values are clamped with a warning.
Mix buses
Every playing sound sits on a bus chosen from its cue ID, then its resource ID (a leading sound_ or sfx. is ignored). Voice-category sounds always use the Voice bus.
| Bus | ID prefixes | Base gain |
|---|---|---|
| Music | music channels | 0.50 |
| Ambience | ambience category fallback | 0.50 |
| Combat | combat., move., movement.; sfx fallback |
0.40 |
| Voice | voice.; voice category |
0.89 |
| Interface | ui., order., command. |
0.63 |
| Economy | build., economy. |
0.32 |
| Weather | weather., ambience.weather., ambient.weather_ |
0.32 |
| Environment | wildlife., environment. |
0.25 |
| Alert | alert., state. |
0.79 |
GameplayMix retargets the gains every mix block (256 frames) and glides toward them (20 ms attack, 350 ms release):
- Density. Combat, Economy and Environment scale by
1 / β(max(1, active / 3))across those three buses, so a large melee does not sum to a wall. - Voice ducking. While any voice plays, Music, Ambience, Weather, Combat, Economy and Environment drop to 0.85Γ.
- Critical ducking. A Voice or Alert sound at priority β₯ 7 drops the same buses to 0.63Γ.
- Stacking. Voice and Alert divide by
βcount, so simultaneous callouts stay intelligible.
Listening presets and the limiter
The bus sum goes through BusLimiter: 3 ms look-ahead, 120 ms release, 6 dB soft knee, hard ceiling 0.79. The preset in the settings menu sets how much it compresses below the ceiling:
| Preset | Threshold | Ratio | Also |
|---|---|---|---|
| Headphones | ceiling | 1:1 | limiter only |
| Speakers (default) | 0.35 | 2:1 | |
| Night | 0.18 | 4:1 | Combat/Economy/Environment Γ 0.7 |
Decode-time mastering
Each track is processed once, on the decode worker, when it is loaded:
- Decode to 32-bit float stereo.
- Resample to the device rate (48 kHz by default) with a polyphase filter (90 dB stopband).
- Analyse: integrated loudness, presence and air bands, up to four resonances.
- Apply the material's profile: loudness gain, spectral tilt, resonance notches, limiter to the ceiling.
- Music and ambience only: seal the loop with a 0.12 s crossfade (at most 10% of the length), so the wrap does not click.
- Store a mono file once if both channels are identical, then convert to 16-bit.
The device callback only reads finished tracks through an atomic table; decoding never blocks the caller or the mixer.
Loudness is matched within a category, not across them
The material comes from the category; SFX resources whose ID starts with ui. (or contains .ui. or /ui/) count as Interface.
| Material | Loudness | Ceiling |
|---|---|---|
| Music | toward β15 LUFS, at most Β±6 dB | β1 dBTP |
| Ambience | toward β16.5 LUFS, at most Β±6 dB | β1.5 dBTP |
| Voice | toward β15.5 LUFS | β1 dBTP |
| Interface | not normalised; fixed make-up gain | β1 dBTP |
| Effect | attenuate only: files louder than β18 LUFS come down, quieter ones are left alone | β1 dBTP |
What this means when authoring a file:
- The level baked into an effect is the design decision. The chain never lifts a quiet effect, so a replacement has to land at the RMS of the file it replaces or the mix changes.
import_cues.pyandtools/audio_field/battle.pyboth match RMS for this reason. - Music has limited authority. The lift is capped at 6 dB and by the headroom under the ceiling, so a track baked much below about β19 LUFS stays quiet for good.
import_music.pybakes to β14.1 LUFS at β1 dBTP. - Beds need headroom. A bed delivered near full scale makes the limiter pull several dB, audible as the bed ducking under itself. The recorded beds are levelled to β19.3 LUFS with peaks held well under full scale.
- Nothing may decode above 0 dBFS. Vorbis overshoots peaks. The build tools measure the decoded file and re-encode at lower gain until it lands under the ceiling.
audio_master_preview (make audio-preview) links the same soi_audio_mastering library, so what it renders to artifacts/audio_preview is what the game plays. scripts/promo-edit.py uses it to score reels with mastered audio.
Packaging
assets.qrcembeds everysfx/andvoices/file plus both JSON catalogues.- Music and ambience are not embedded.
install(DIRECTORY assets/)installs them beside the binary, and a player's package always carries them. - The loader looks for
assets/audio/at<app>/assets/audio,<app>/../Resources/assets/audio(macOS bundle) and<app>/../../assets/audio, then falls back to:/assets/audio/. A file on disk wins over the embedded copy.
Asset pipelines
| Material | Recipe or record | Rebuild | Committed |
|---|---|---|---|
Synthesised cues (source: synth) |
tools/audio_synth/cues.py |
make audio-assets or synthesize_cues.py <cue> |
yes |
| Recorded beds | tools/audio_field/sources.py |
make audio-field-ambience (network) |
yes |
| Recorded one-shots (hits, footsteps) | tools/audio_field/oneshots.py |
python3 tools/audio_field/build_oneshots.py (network) |
yes |
Composed battle cues (source: field) |
tools/audio_field/battle.py |
make audio-battle (network) |
yes |
ElevenLabs cues (source: generated) |
PLAN in tools/audio_import/import_cues.py |
import_cues.py --source DIR |
yes |
| ElevenLabs music | BATCHES in tools/audio_import/import_music.py |
import_music.py --source DIR --batch NAME |
yes |
| Voices | none | none | yes |
| New drop-ins | files in new sfx/ |
make audio-import, then AUDIO_IMPORT_ARGS=--apply |
β |
tools/audio_synth/README.md and tools/audio_field/README.md cover recipes, takes and window selection in detail.
Rebuild traps
- A recipe can outlive its file. When a cue is replaced by an import, its recipe still names the path.
synthesize_cues.py,register_cues.pyandbuild_battle.pytherefore read the manifest'ssourcetag and skip any file another pipeline owns (--forceoverrides the two renderers). Recipes for replaced files were deleted in September 2026; delete a recipe when its file is replaced rather than relying on the guard. - ffmpeg picks a random Ogg stream serial, so re-encoding an unchanged recipe still changes a few header bytes. Regenerate the cue you are working on, not the set.
After any rebuild, git status assets/audio shows what was actually touched.
Debugging
| Variable | Effect |
|---|---|
SOI_AUDIO_TRACE=1 |
One log line per cue request |
SOI_AUDIO_TRACE_SUMMARY=<path> |
JSON summary per mission, written at mission teardown and at shutdown; later exports in the same process get an index suffix |
SOI_AUDIO_OFFLINE=1 |
Opens no audio device; the mixer is driven by offline rendering (reels, preview) |
SOI_AUDIO_PCM_BUDGET_MB=<n> |
Resident decoded-PCM budget (default 320) |
A trace line reads:
audio cue [30.838s rts -318.9,69.6,-196.4] alert.commander_message -> sfx.alert.commander_message: accepted (app/core/game_engine.cpp:1996)
Time since the trace last reset, listener mode (rts, commander, or none), listener position, cue, the resource the pool chose (- if the drop happened before selection), the outcome, and the call site. The summary's never_accepted list is the cues a mission asked for that the player never heard. A cue never requested does not appear at all, which is what the static report covers.
Throttles are wall-clock and not scoped to a mission: cue cooldowns in CueRegistry and resource cooldowns in AudioSystem carry across restarts, scenarios and tests. CueRegistry::reset_cooldowns() and AudioSystem::reset_playback_throttles() clear them without touching bindings or loaded resources. Sounds outlive a test in the same way: the tails of earlier tests can fill a category, and the next low-priority cue is dropped as category_priority. AudioSystem::stop_all_sounds() silences them; the scenario tests call it in SetUp. Two cues sharing one resource share its cooldown too, and the loser is reported as resource_cooldown.
Checks and tests
| Command | What it proves |
|---|---|
make audio-check |
Cues, manifest, files, QRC and direct references agree; required cues are bound and fired; every track has provenance, every shipped .ogg is in the manifest, and docs/AUDIO_LICENSES.md matches the manifest |
make audio-scan |
Every shipped clip decoded and scanned for leading silence and boundary clicks (report only) |
make audio-report |
The wiring matrix β declared, fired, loaded, verified β in artifacts/audio/AUDIO_WISHLIST.md |
python3 scripts/audio_provenance.py --doc |
Re-renders docs/AUDIO_LICENSES.md from the manifest after a provenance change |
make audio-preview |
Mastered renders of the library in artifacts/audio_preview |
Tests: audio_system_test, audio_cues_test, audio_backend_test, audio_command_ring_test, audio_mastering_test, audio_gameplay_scenarios_test (production gameplay paths; clears both throttles in SetUp), command_controller_test (a run order never trumpets without elephants), audio_battle_load_test, ambience_assets_test (every bed at the mixer rate, 2β6 kHz at least 3 dB under 100β800 Hz), weather_audio_test, and the commander_voice_* tests.