> Cinder 0.1.0 documentation: Audio (cinder/audio)
> Canonical page: https://cinder.ash10.dev/docs/guides/audio/ · Source: `docs/guides/audio.md` in the engine repository (engine commit 47c9352, imported 2026-11-09)
> Index of every page for agents: https://cinder.ash10.dev/llms.txt

# 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
`SoundHandle`s 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 `LogRecord`s 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.
