---
title: "Audio"
description: "Audio in Zero is built from two components. An Audio component makes an entity emit sound from a .soundClip asset. An AudioListener component makes an entity the \"ears\" of the scene. Both live on…"
section: "Topics"
slug: "topics-audio"
canonical: "https://origozero.ai/docs/topics-audio"
updated: "2026-09-07T03:15:14.414071915+00:00"
tags: ["documentation", "guide"]
---

# Audio

## The two halves: source and listener

You need one of each for spatial sound to be heard. The source emits; the listener hears.

```lua
-- the ears: attach a listener to whatever should hear (a camera, the player, …)
entity.find("camera").component.add("AudioListener")

-- a one-shot effect
entity.find("sfx").component.add("Audio", { clip = explosionClip })

-- looping music
entity.find("music").component.add("Audio", {
  clip = themeClip, sourceType = "Music", looping = true, volume = 0.7,
})

-- spatial sound, positioned by its entity, heard relative to the listener
entity.find("campfire").component.add("Audio", {
  clip = fireClip, spatial = true, looping = true,
})
```

The listener is **explicit** — you choose which entity hears. A scene with audio sources but no active `AudioListener` is reported at load, and its spatial sources play centered until you add one. Attach the listener to whatever the player experiences the world through.

`active` is live: writing it takes effect on the next frame, so clearing it on the listener that holds the ears hands them to another active listener. When several are active one of them drives the ears — the same one for as long as those entities live, whatever else changes about them — and the engine warns once, naming it. Recreating the entities, as a scene reload or entering play does, chooses again. `audio.observe().listener` reports the winner as `entity`, alongside `present`, `count` (how many are active) and the pose. Which one it picks is not something to author against: deactivate or remove the extras when it matters.

To put the ears on whatever the player sees through, the play-mode camera's entity is `layers.active.camera.entity`:

```lua
layers.active.camera.entity.component.add("AudioListener")
```

The listener target must already exist when you attach the listener. An authored camera is there from the start, but a player avatar spawns asynchronously, so `layers.active.players.localPlayer.avatar` can still be nil in a component's `start()`, and `layers.active.camera.ready` reports whether the play-mode camera has spawned. Attach the listener from a per-frame retry that waits for the target to resolve, rather than once at `start()`.

## Starting playback: `autoplay` vs `play()`

A source with `autoplay = true` (the default) starts as soon as its clip is ready. A source created with `autoplay = false` stays **silent until you start it explicitly**, which is the normal case for game music you cue by state. Set the clip and volume, then call `:play()`. `:stop()` (or `:pause()`) silences it again.

```lua
-- created quiet; the game cues it later
local music = entity.find("music").component.add("Audio", {
  sourceType = "Music", looping = true, volume = 0.7, autoplay = false,
})
-- when the battle starts:
music.clip = battleClip
music:play()          -- WITHOUT this the source stays silent, with no error
```

Setting a clip and a non-zero volume alone produces no sound when `autoplay = false`; the missing call is `:play()`. `asset.inspect("@builtin::components.Audio")` lists `play`, `pause`, `stop`, and `setVolume` under methods.

## The clip is a `.soundClip` asset

`Audio.clip` points at a `.soundClip` asset — a typed handle, not a path. A loose audio file (ogg/mp3/wav/flac) is promoted into a `.soundClip` by the importer, which encodes its playable payload. A clip comes from one of four places: find one that already exists with `asset.list("soundClip")`, bring more in from the shared library, generate one with the audio service (the generating-assets-and-content guide), or compute the samples yourself and bake them into a clip — the section below. The `types/soundClip` guide covers the container the four of them produce.

Whether a clip streams its compressed payload or decodes fully on load follows the clip's own `loadType` setting — `"decompressOnLoad"` and `"streaming"` apply directly, and `"auto"` (the default) picks by duration, so short one-shots decode up front and long tracks stream. Streaming supports spatial sound.

## Does a looping bed loop without a click

`Audio.looping` repeats the whole clip, so a bed's last frame runs straight
into its first and that join is heard once a lap. A bed built so its head meets
its tail cycles unnoticed; one carrying a percussive strike at the head and
silence at the tail steps by the strike's amplitude every lap, which is heard
as a tick. Nothing about it shows up in the clip's duration, channels or
settings — the difference is in the samples.

`clipRef:loopSeam()` reads it:

```lua
local seam = bedClip:loopSeam()
print(seam.ratio, seam.seamless)   -- 41.0  false
```

`ratio` is the step the wrap makes divided by the step the signal ordinarily
makes between two neighbouring samples, so the figure is in the units the clip
itself moves in and a quiet ambience is judged the same way as a loud drone. A
bed whose partials complete a whole number of cycles across the buffer reads
near 1; a clicking one reads in the tens. `seamless` is `ratio <= seam.threshold`,
and `asset.create("soundClip", ...)` logs a warning naming the ratio when the
payload it just baked is past that threshold.

The reading is taken on the decoded payload, so it also answers for a clip that
arrived already encoded — an import, or something installed from ZeroMind —
whose source samples nobody holds. `audio.loopSeam(zaudBytes)` is the same
reading against bytes rather than an asset.

## Making a clip out of samples you computed

The fourth origin is synthesis: compute interleaved f32 samples in Luau and hand
them to `asset.create`, which encodes them into the clip's playable payload.
There is no source file and no service call — it is arithmetic and the engine's
own codec, so it runs headless, costs nothing but CPU, and is the route that
stays open when a generation service is unavailable.

```lua
-- a half-second 220 Hz tone, mono at 48 kHz
local rate, frames = 48000, 24000
local samples = buffer.create(frames * 4)
for i = 0, frames - 1 do
    buffer.writef32(samples, i * 4, math.sin(i / rate * 220 * 2 * math.pi) * 0.4)
end

local tone = asset.create("soundClip", "tone", {
    pcm = samples, sampleRate = rate, channels = 1,
    settings = { codec = "pcm" },
})

entity.find("sfx").component.add("Audio", { clip = tone })
```

`pcm` takes the samples in any container the codec reads — a `buffer`, the shape
`microphone.samples` hands back; a binary string of little-endian f32, the shape
`audio.decode` returns; or a flat number array. A `buffer` is what a long clip wants: a 1.5 s
mono 48 kHz clip is 72 000 samples, which is a 288 KB buffer against a
72 000-entry Luau table. `sampleRate` and `channels` describe what those samples
are, and both are required alongside `pcm`.

`settings` is baked in the same pass that takes the samples, and the codec is
the choice that matters here. The default `opus` compresses, which moves every
sample a little; `pcm` stores them as handed in. A looping bed wants `pcm`,
because the join between the clip's last frame and its first is heard once a lap
and a lossy round trip moves the samples on both sides of it — `clipRef:loopSeam()`,
above, reads what the samples do at that join. Patching the
codec afterwards does not recover them — a `setSettings` re-encodes from the
payload already stored, so a clip baked as `opus` and then set to `pcm` holds
the Opus signal losslessly rather than the samples that went in. Pass the
settings at create time.

Read the clip back to check what was baked:

```lua
local back, sampleRate, channels = tone:pcm()   -- interleaved f32, and what it is
print(#back // 4, sampleRate, channels)         -- 24000  48000  1
print(tone:settings().codec)                    -- "pcm"
```

The same four calls work on a payload that is not an asset yet.
`audio.encodePcm(pcm, sampleRate, channels, opts)` encodes samples into the ZAUD
bytes a clip stores, `audio.encode(sourceBytes, opts)` does the same for
ogg/mp3/wav/flac bytes, `audio.decode(zaud)` returns `(pcm, sampleRate,
channels)`, and `audio.info(zaud)` reads the header — codec, channels, sample
rate, frames and duration — without decoding the samples:

```lua
local zaud = audio.encodePcm(samples, rate, 1, { codec = "pcm" })
local info = audio.info(zaud)
print(info.codec, info.frames, info.durationMs)   -- "pcm"  24000  500
```

`info` carries eight fields, and the length one is `durationMs` — milliseconds,
so a reader reaching for `duration` gets `nil` and a clip that measures as
nothing:

| field | meaning |
|-------|---------|
| `codec` | `"opus"` or `"pcm"` — what the payload is stored as. |
| `sampleRate` | the rate the samples play at, in Hz. |
| `channels` | how many channels they interleave. |
| `frames` | frame count — `channels` samples that sound together. |
| `durationMs` | how long the clip lasts, in milliseconds. |
| `packets` | how many packets the payload holds. |
| `loopStart` | the loop-point start frame, `0` when the clip names none. |
| `loopEnd` | the loop-point end frame, `0` when the clip names none. |

`audio.info` reads a ZAUD payload, which is what `audio.encodePcm` /
`audio.encode` return and what a clip stores. `clipRef:pcm()` hands back decoded
samples rather than a payload, so `audio.info(clipRef:pcm())` is `(nil, err)` —
and the rate and channel count a caller wants from a clip come back beside those
samples as `pcm()`'s second and third returns, which is the reading above.
Encoding the decoded samples again to get a header back reports the codec and
rate passed to *that* encode rather than the clip's own.

The codec calls report a failure in a second return: `audio.encodePcm` and
`audio.encode` hand back `(nil, err)`, and so does `audio.decode`, so read the
second value before the first. Every sample handed to the codec must be finite,
and `err` on a buffer carrying a NaN or an infinity names how many samples fail
and where the first one sits — so a filter that diverged partway through a bake
is caught at the call rather than heard later. The same refusal reaches an
`asset.create` that was handed those samples as an error on the create.

## Common fields

`Audio`: `clip` (an `AssetRef<soundClip>`), `sourceType` (`"SoundEffect"` / `"Music"` / `"Ambient"`), `volume`, `pitch`, `looping`, `spatial`, `maxDistance`, `rolloff`, `autoplay`, `channel`.

`AudioListener`: `active` (whether this listener drives the ears).

## How a spatial source fades with distance

A spatial source is at full volume out to one unit from the listener and reaches silence at `maxDistance` (default 50). Across that window the amplitude follows `(1 - t)^rolloff`, where `t` is how far through the window the source is. With the default `rolloff` of `1` the fade is linear: halfway to `maxDistance` is half the amplitude, three quarters of the way is a quarter of it. A larger `rolloff` drops away sooner and `0` holds full volume all the way out.

So `maxDistance` is the radius the sound carries — set it to the distance you want the source audible from, and the source stays clearly audible over most of it.

Because both are components, you change playback or move the ears by setting fields like any other component (the components guide), and a moving entity carries its sound — or its hearing — with it. Fields stay live after creation: writing `clip` rebinds the source to the new clip, and `volume` and `pitch` reach the sound that is already playing. That is what a music deck that swaps tracks or an ambience bed that crossfades between rooms is made of — one long-lived `Audio` component whose `clip` is reassigned.

## Turning a group down

A source names the mixer channel it plays on — the `channel` field, `"sfx"`
unless the source says otherwise — and is heard at its own `volume` scaled by
the level of that channel and by the master level of the mix. Both levels reach
voices that are already playing, so a settings-screen slider is heard as it
moves.

```lua
audio.setChannelVolume("music", 0.3)   -- every source on "music", nothing else
audio.setMasterVolume(0.8)             -- the whole mix
audio.setMuted(true)                   -- silence it whatever the levels read

local levels = audio.mixer()
print(levels.master, levels.muted, levels.channels.music)
```

`audio.mixer().channels` holds the channels a level has been set on. A channel
absent from it plays at unity, so a scene naming channels of its own is heard as
authored until one of them is turned down. Per voice, `audio.voice(id).gain`
reports both factors as `channel` and `master` beside the source's own `volume`
and its distance attenuation.

## One-shots

For a fire-and-forget sound — an impact, a pickup, a footstep — call `playOneShot` on an `Audio` component:

```lua
entity.find("gun").component.get("Audio"):playOneShot(shotClip)
entity.find("gun").component.get("Audio"):playOneShot(shotClip, { volume = 0.6, pitch = 1.2 })
```

It plays the clip once through a short-lived host at the source's position (spatial if the source is spatial), then cleans itself up. Many one-shots can overlap while the source's own clip keeps playing, so one `Audio` component drives a whole family of SFX. It leaves the source's `clip` and playback state untouched.

## Checking what is actually audible

Reading an `Audio` component's fields back tells you what was asked for. It does
not tell you what the mixer is doing: a source whose clip never became resident,
whose volume resolved to zero, or that sits past its `maxDistance` reads exactly
like one that is filling the room. `audio.observe()` answers the other question.

```lua
local report = audio.observe()
print(report.audibleCount .. " of " .. #report.voices .. " sources are sounding")
print("master level", report.levels.rms, "over", report.levels.windowMs .. "ms")

for _, voice in report.voices do
    if not voice.audible then
        print(voice.entity, "silent:", voice.silence)
    end
end
```

Each voice carries the mixer's own `mixerState` next to the `componentState`
field `:play()` wrote, the gain it reaches the mix at stage by stage, its
position in the clip and the clip's duration, whether the clip's bytes are
resident, and for a spatial source the emitter position, the listener distance
and the window it fades across.

`residentBytes` is what the clip costs the mixer, not the size of the audio you
handed it: a clip that decodes on load is held as stereo `f32` frames at 8 bytes
per frame whatever its own channel count, so a mono clip reads twice its source
PCM. A streaming clip is held as the compressed payload instead.

The mixer runs at the output device's rate, which `audio.observe().sampleRate`
reports and which is independent of the rate a clip was authored at — a clip at
another rate is resampled onto it rather than played at its own.

A source that makes no sound reports one reason:

| Reason | What to change |
|---|---|
| `noBackend` | The mixer never came up on this host. |
| `noDevice` | The mixer is running and no output device is open — `audio.device()` says what happened to it. |
| `notResident` | The clip's bytes never reached the mixer — check the clip reference. |
| `neverStarted` | Nothing cued it: set `autoplay`, or call `:play()`. |
| `refused` | The mixer turned it away; the second return of `audio.whySilent` carries its words. |
| `paused` | It is paused — call `:play()`. |
| `ended` | It played through, or was stopped. |
| `gainZero` | Its `volume` left the chain at or below the amplitude the mix renders as silence — raise it. |
| `channelSilent` | The channel it names is at zero — raise it with `audio.setChannelVolume`. |
| `masterSilent` | The master level is zero or the mix is muted — `audio.mixer()` tells the two apart. |
| `outOfRange` | It is spatial and past `maxDistance`, or the listener is too far. |

## The output device comes and goes

An output device is not a fixed part of the machine: a headset is unplugged, a
Bluetooth sink disconnects, the host switches its default output, an audio
server restarts. The mixer keeps running through all of it, and the engine
opens a stream again on whatever device is there — following a switch to a new
default, and picking a device back up when one returns.

`audio.device()` is what that reads as:

```lua
local device = audio.device()
print(device.state, device.device, device.sampleRate)
-- what the engine has been through since it started
print(device.faults, device.changes, device.reopens, device.failedOpens)
-- dropouts a listener heard, which a machine under load produces
print(device.glitches)
if device.lastError then print(device.lastError) end
```

While `state` is `"silent"` nothing the mixer produces is heard, and every
voice reports `noDevice` rather than reading as audible.

Ask about one source directly:

```lua
local speaker = entity.find("speaker")
local why, detail = audio.whySilent(speaker.id)
-- or straight off the component
local sound = speaker.component.get("Audio")
print(sound:isPlaying(), sound:position(), sound:duration())
```

`isPlaying()` answers **audible**, not **running**: the mixer can be advancing a
voice perfectly while multiplying it to silence, and that reads false here with
`whySilent()` naming which stage did it. The level it is judged against is the
one the mix renders — a voice's gain is applied in decibels and the mixer
carries nothing at or below -60 dB, so a source faded a fraction above zero is
read as silent for the stage that took it there, the same as one written to an
exact zero. The transport underneath is
`mixerState`, which stays `"playing"` through a muted or out-of-range voice —
so the two together separate "the sound is not going" from "the sound is going
and you cannot hear it":

```lua
local voice = sound:observe()
print(sound:isPlaying(), voice and voice.mixerState, sound:whySilent())
-- false  "playing"  "gainZero"   → running, inaudible
-- false  "paused"   "paused"     → not running
```

`refused` means the mixer had no slot left. `audio.voiceAccounting()` reports
that ceiling and the occupancy behind it from the mixer's own tracks, so the
number a source is turned away at is readable before it is hit. It keeps two
pools and reports each apart: a non-spatial voice is a sound on the main track,
which `capacity`, `inUse` and `free` describe, while a spatial voice plays
through its own sub-track and is counted by `spatialCapacity` and
`spatialInUse`. Adding a spatial source therefore raises `spatialInUse` and
leaves `inUse` where it was. `sourcesHolding` counts both pools from the sources
that own them, so it equals `inUse + spatialInUse` while every voice the mixer
holds answers to a source:

```lua
local v = audio.voiceAccounting()
print(v.inUse .. " on the main track, " .. v.spatialInUse .. " spatial, "
      .. v.free .. " free before the next non-spatial source is refused")
```

A missing listener silences positional sound in a way nothing else explains, so
`audio.listener()` reports whether one exists and where it is. `audio.levels()`
reads the master mix's peak and RMS over a window of known length with no
recording involved — enough to answer "is anything sounding at all" in one call,
and its `windows` count keeps advancing while the mixer runs, so a mix that is
silent reads differently from a mixer that has not started. These are the whole
mix, every voice summed, so they answer whether the engine is making sound
rather than how much of it any one source contributed.

A window closes on the mixer's own clock, which runs faster than a script's
frames: reading `levels()` in a loop shows the windows the loop's frames land
on, and a short sound can peak inside one it never had in front of it.
`audio.peakSince(window)` answers the span instead — the loudest peak across
every window that closed after the count stood where it did — so a sound is
measured whatever the reader's rate:

```lua
local mark = audio.levels().windows
sound:play()
-- let the mixer close the windows the sound plays through
while audio.levels().windows < mark + 4 do task.wait() end
print("the sound peaked at", audio.peakSince(mark))
```

It answers `nil` when the meter holds no peak for the span: nothing has closed
since `mark`, or the span reaches further back than the meter's history of
recent windows, which tells a reader that came back too late rather than
handing it the maximum of the part that survived.

A voice's `gain` and the mix's `levels` are different kinds of number and do not
compare: `gain.effective` is the multiplier a voice reaches the mix at, set by
its own volume and distance, while `levels.rms` and `levels.peak` are amplitudes
measured off the audio the mixer actually rendered. A voice at `gain.effective`
of 1.0 playing a quiet clip lands far below a voice at 0.25 playing a loud one. `audio.profile()`
reports what the subsystem costs: decode, encode, voice starts and the streaming
pump. Every total is a **sum across a window**, not a per-frame figure, and the window runs
from the last `audio.resetProfile()` — or from engine start if nothing has reset it, which
on a long-lived engine pools in everything since boot. To measure what a frame costs now,
reset, let frames pass, then divide by the `frames` the window reports:

```lua
audio.resetProfile()
task.wait(1)
local cost = audio.profile()
print("audio per frame:", (cost.pump.totalMs + cost.observe.totalMs) / cost.frames, "ms")
```

The same report is a file: `/runtime/active_audio` serves it whole, and
`/runtime/active_audio/voices/<entity>` serves one source. All of it answers in
edit mode as well as play mode.

## Sound coming IN: the microphone

The components above play sound and hear it *inside* the world. `microphone` is the other direction — the machine's own input device, so world content can react to a real voice, a room, an instrument:

```lua
microphone.start({ fftSize = 512 })
if microphone.awaitRunning(5) == "running" then
    print(microphone.level())        -- RMS loudness 0..1 of the latest window
    print(microphone.peak().hz)      -- the strongest frequency right now
    local bins = microphone.spectrum()  -- fftSize/2+1 amplitudes, microphone.frequencies() labels them
end
```

A capture is a **state, not a call** — `idle` / `starting` / `permissionPending` / `denied` / `running` / `failed` — because a browser has to ask the person at the machine for access. `microphone.awaitRunning(timeout)` waits that out and reports where it landed; `microphone.status()` says where it stands after that. `microphone.samples()` hands back the raw mono f32 PCM nobody has taken yet, and reading a level or a spectrum takes nothing away from it. `microphone.devices()` enumerates inputs.

Smaller `fftSize` trades frequency resolution for a shorter window: 512 at 48 kHz is a ~10 ms window, which is what a mouth or a meter reacting to speech wants; 2048 resolves ~23 Hz per bin and is what telling two tones apart wants.

The **`topics/microphone`** guide covers the whole capture surface — the states, the devices, the spectrum, and reading the raw PCM.

## Finding the exact fields

`asset.inspect("@builtin::components.Audio")` and `asset.inspect("@builtin::components.AudioListener")` print each component's full field list and usage; `lsp.methods("audio")` lists the whole `audio` namespace — the codec calls above alongside the mixer and observation ones — and `lsp.methods("microphone")` lists the capture API; the components guide explains how component fields work in general. The model to hold: sound is a capability you attach to an entity, and so is hearing — and the microphone is the world hearing the room it is running in.
