Audio (cinder/audio)
cinder/audio is the engine's sound layer over miniaudio. It provides mixing buses, decoded and
streamed sounds, one-shots that clean up after themselves, 3D spatialisation with distance
attenuation, a single listener, and an ECS bridge that moves sounds with entities and the listener
with the camera. It sits beside render, physics, anim and input in the module map
(docs/ARCHITECTURE.md): it depends on core, ecs, platform and the generated ffi
bindings, and is owned and driven by cinder/app.
Source: cinder/audio/engine.mojo (the engine), cinder/audio/sync.mojo (ECS components and
sync_audio), native/src/audio.c (the shim side). API reference: docs/api/audio.md.
Design decisions: docs/adr/0012-physics-animation-audio-input.md, "Audio" section.
Key types and ownership #
| Type | Role |
|---|---|
AudioEngine |
Owns the miniaudio engine, the buses and every sound. One per App (App.audio, an Optional[AudioEngine]). |
SoundDesc |
How to load a sound: path, bus, streamed, looping, spatial, volume, pitch, attenuation model and distances, one_shot. |
SoundHandle |
Generational handle (index, generation) to a loaded sound. SoundHandle.null() is the invalid handle; Bool(handle) is false for it. |
Attenuation |
Distance model: none(), inverse() (default), linear(), exponential(). |
AudioSource |
ECS component: a SoundHandle plus a velocity (Doppler). |
AudioListener |
ECS component marking the entity whose WorldTransform drives the listener. |
Everything miniaudio-shaped lives in the shim. ma_engine, ma_sound and ma_sound_group are
large structs that are opaque to Mojo, so cn_audio_engine_create, cn_audio_sound_create_in_group
and cn_audio_group_create allocate them in C and hand back pointers; Mojo keeps those pointers in
AudioEngine.engine, AudioEngine.buses and a slot table (AudioEngine.slots). Sound handles are
generational: release bumps the slot's generation and puts the index on the free list, so a stale
handle fails is_valid and every method that takes a handle raises "audio: stale sound handle".
Bus 0 is always the master bus, created by the constructor on the engine endpoint; live_buses()
does not count it. live_sounds() counts slots that still hold a sound.
Lifetimes and leaks: AudioEngine.__deinit__ destroys any remaining sounds, then the buses in
reverse order, then the engine and its event queue, so leaking is not fatal. The convention,
enforced by the tests and by App.live_objects(), is that live_sounds() and live_buses() are
zero before the engine goes away: release every sound and destroy_bus every bus you created
(live_buses() counts created buses not yet destroyed) (App.live_objects() sums physics objects, live sounds and live
buses for the shutdown leak report). Because App.audio is a field of App, a Game that holds
SoundHandles must be destroyed before the App; see docs/guides/app.md.
The engine needs the NativePtr of a NativeLibrary that outlives it. Inside App that is
App.lib, declared last so it is destroyed last.
Creating an engine #
Through App, AppConfig.audio (default True) creates the engine; headless apps get a
device-less engine automatically. Standalone, as the tests do:
from cinder.audio import AudioEngine, SoundDesc
from cinder.platform import NativeLibrary
var lib = NativeLibrary()
var audio = AudioEngine(lib.ptr(), True) # headless=True: no device, output is pulled
var sfx = audio.create_bus(String("sfx")) # parent defaults to 0 (master)
var music = audio.create_bus(String("music"))The constructor arguments are (lib, headless=False, sample_rate=48000, channels=2). It raises
"audio: miniaudio engine creation failed (no device?)" when a device is requested on a machine
without one; pass headless=True (or run the app with --headless) in that case. create_bus(name, parent=0) returns the bus index; parent=-1 attaches a bus directly to the engine endpoint, which
is how the master bus itself is created. find_bus(name) returns -1 when no live bus has that
name.
destroy_bus(index) destroys a bus made with create_bus (through the shim's
cn_audio_group_destroy) and lowers live_buses() by one. Its index then stays invalid: indices
are never reused, so every bus method, load and create_bus(parent=index) raise
"audio: bus <i> does not exist" for it, as does a second destroy_bus. It also raises for the
master bus (0 belongs to the engine) and for a bus that still has sounds (release them first) or
live child buses (destroy those first, children before parents).
Loading and playing #
load creates the sound stopped; play starts it. SoundDesc takes the common options
positionally: SoundDesc(path, bus=0, streamed=False, looping=False, spatial=True). The remaining
fields (volume, pitch, attenuation, min_distance, max_distance, rolloff, one_shot) are
set on the value after construction. Supported file types are WAV, FLAC, MP3 and Vorbis.
# Streamed, looping, non-spatial music on its own bus (from sample/game/game.mojo).
var song = audio.load(
SoundDesc(String("assets/game/audio/music_level.wav"), music, True, True, False)
)
audio.play(song)
audio.set_bus_volume(music, 0.6)
# Decoded, looping, spatial effect at a position.
var tone = audio.load(SoundDesc(String("tone.wav"), sfx, False, True, True))
audio.set_position(tone, Vec3(-10.0, 0.0, 0.0))
audio.play(tone)
audio.stop(tone)
audio.release(tone)streamed=Truemaps toMA_SOUND_FLAG_STREAM: the file is decoded while it plays (music). OtherwiseMA_SOUND_FLAG_DECODEdecodes the whole file at load time (effects).spatial=FalsesetsMA_SOUND_FLAG_NO_SPATIALIZATION: the sound is mixed centred, withset_panas the only positioning control.- Per-sound controls after loading:
set_volume,set_pitch,set_pan,set_looping,fade(from, to, milliseconds),seek(frame),is_playing,at_end. - A failed
loadraises"audio: could not load '<path>'"and leaves nothing behind (the slot is returned to the free list; the test assertslive_sounds()is unchanged).
One-shots #
play_one_shot(desc, position) sets one_shot=True and looping=False on the description, loads,
positions the sound if it is spatial, starts it and returns the handle. When the sound ends, the next
update() releases it. The sample fires impact sounds this way:
var desc = SoundDesc(String("assets/game/audio/impact.wav"), self.sfx_bus, False, False, True)
desc.min_distance = 2.0
desc.max_distance = 60.0
_ = app.audio.value().play_one_shot(desc^, position)Do not keep one-shot handles around for long: once released, is_valid is false and play raises.
3D position, attenuation and the listener #
Spatial sounds take set_position(handle, Vec3) and set_velocity(handle, Vec3); set_spatial
toggles spatialisation on an existing sound. The attenuation model and its distances are applied at
load time from the SoundDesc (defaults: Attenuation.inverse(), min_distance=1.0,
max_distance=100.0, rolloff=1.0) and can be changed with
set_attenuation(handle, model, min_distance, max_distance, rolloff).
The listener is miniaudio's listener 0. set_listener(position, forward, up=Vec3.unit_y(), velocity=Vec3.zero()) sets its position, direction, world up and velocity. The engine convention
is right-handed, Y up, -Z forward, so a listener looking down -Z hears a source at negative X on the
left; tests/audio/test_audio_engine.mojo checks exactly that with RMS measurements per channel.
The ECS bridge #
cinder/audio/sync.mojo connects sounds to entities. AudioSource(sound, velocity) on an entity
with a WorldTransform makes sync_audio set that sound's position to the transform's translation
and its velocity to the component's velocity. AudioListener(velocity) marks the listener
entity: its WorldTransform gives the position, -Z transformed by the matrix gives forward,
+Y gives up. When several entities carry AudioListener, the first chunk found wins.
from cinder.audio import AudioListener, AudioSource
from cinder.ecs import LocalTransform, World, propagate_transforms
var camera = world.spawn()
world.add[LocalTransform](camera, LocalTransform(Mat4.identity()))
world.add[AudioListener](camera, AudioListener(Vec3.zero()))
var emitter = world.spawn()
world.add[LocalTransform](emitter, LocalTransform(Mat4.translation(Vec3(-10.0, 0.0, 0.0))))
world.add[AudioSource](emitter, AudioSource(tone, Vec3.zero()))
_ = propagate_transforms(world) # WorldTransform must exist before sync_audio
var positioned = sync_audio(world, audio) # returns the number of sources positionedsync_audio skips sources whose handle is no longer valid instead of raising, so releasing a sound
does not require removing its component first. Component ids are AUDIO_SOURCE_COMPONENT_ID = 10
and AUDIO_LISTENER_COMPONENT_ID = 11. App.run_frame calls propagate_transforms and then
sync_audio every frame, so with App you only add the components.
Per-frame work: update, take_ended, read_frames #
update() drains the shim's event queue. Log callbacks from miniaudio become LogRecords in
AudioEngine.diagnostics (category "audio"; error_count() counts those at error level).
Sound-end events are matched to a live handle, recorded in AudioEngine.ended, and one-shots are
released. update() returns how many sounds ended. take_ended() moves the ended list out so
gameplay can react once:
_ = audio.update()
for h in audio.take_ended():
if h == self.footstep:
self.footstep = SoundHandle.null()In headless mode there is no device pulling the mixer, so nothing progresses unless you pull it.
read_frames(frames) mixes frames frames and returns frames * channels interleaved Float32
samples; it raises on a device-backed engine. App.run_frame does this for you: each frame it reads
dt * sample_rate frames (capped at one second) and discards them, so one-shots end and streams
advance in tests and benchmarks exactly as they would with a device.
Threading #
- The miniaudio device thread never runs Mojo. The shim registers only two callbacks (
on_logandon_sound_endinnative/src/audio.c), and both post fixed-size events to aCnEventQueuethatupdate()drains on the main thread (ADR-0003,docs/THREADING.md). - Setters such as
set_positionandset_bus_volumeare directma_*calls from the calling thread; miniaudio makes them safe against its own mixing thread. Call them from the main thread. - With a headless engine there is no audio thread at all: mixing happens inside
read_frames, so the end callbacks fire on the caller's thread during that call and still only post events.
Pitfalls #
- The sound-end callback fires on the processing step after the one that reached the end, and the
sound is already stopped inside it (
docs/MOJO_NOTES.md, Phase 6). A headless test therefore reads one more buffer withread_framesbefore callingupdate()and expecting the end event. update()releases one-shots, so a handle returned byplay_one_shotbecomes stale without any call of yours. Checkis_validbefore reusing it.- Sound paths are passed to C as a borrowed C string;
loadkeeps theStringalive across the call withkeep. If you add a shim call that takes a path, do the same (MOJO_NOTES.md, untracked pointers do not keep their pointee alive). set_master_volumeis the engine's own volume, separate from bus 0's volume; the sample's settings applymaster_volumeto the engine andmusic_volume/sfx_volumeto buses found by name.- Buses cannot be destroyed individually; they live until the engine does. Create them once in
Game.start. AppConfig.audio = False(--no-audio) leavesApp.audioempty; always testif app.audio:beforeapp.audio.value().
Tests and references #
tests/audio/test_audio_engine.mojo: writes WAV files, then checks buses, left/right panning, distance attenuation, bus muting, streamed looping music, one-shot release throughupdate, failed loads, and the ECS bridge (sources follow entities, listener follows the camera, stale handles are skipped). Run withpixi run test-audio-engine.tests/sample/test_sample_game.mojo: after the level is cleared, 240 headless frames let the one-shots end so only the music loop remains (live_sounds() == 1,live_buses() == 2).docs/adr/0012-physics-animation-audio-input.md: miniaudio used as is, headless engines for tests, callbacks post events, listener follows an entity.docs/adr/0013-tools-and-app-runtime.md: howAppowns the engine and pumps it headless.