DocsGuides

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:

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

mojo
# 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=True maps to MA_SOUND_FLAG_STREAM: the file is decoded while it plays (music). Otherwise MA_SOUND_FLAG_DECODE decodes the whole file at load time (effects).
  • spatial=False sets MA_SOUND_FLAG_NO_SPATIALIZATION: the sound is mixed centred, with set_pan as 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 load raises "audio: could not load '<path>'" and leaves nothing behind (the slot is returned to the free list; the test asserts live_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:

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

mojo
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 positioned

sync_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:

mojo
_ = 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_log and on_sound_end in native/src/audio.c), and both post fixed-size events to a CnEventQueue that update() drains on the main thread (ADR-0003, docs/THREADING.md).
  • Setters such as set_position and set_bus_volume are direct ma_* 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 with read_frames before calling update() and expecting the end event.
  • update() releases one-shots, so a handle returned by play_one_shot becomes stale without any call of yours. Check is_valid before reusing it.
  • Sound paths are passed to C as a borrowed C string; load keeps the String alive across the call with keep. 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_volume is the engine's own volume, separate from bus 0's volume; the sample's settings apply master_volume to the engine and music_volume/sfx_volume to 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) leaves App.audio empty; always test if app.audio: before app.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 through update, failed loads, and the ECS bridge (sources follow entities, listener follows the camera, stale handles are skipped). Run with pixi 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: how App owns the engine and pumps it headless.
View as MarkdownSource: docs/guides/audio.md