audio
Public Luau surface over the `__audio` Internal FFI namespace — the engine-native CPU audio codec for the canonical ZAUD payload (backed by `zero_audio::blob`), and the engine's audio observation surface. Auto-injected as `_G.audio` via the prelude.
audio Module
Public Luau surface over the __audio Internal FFI namespace — the
engine-native CPU audio codec for the canonical ZAUD payload (backed by
zero_audio::blob), and the engine's audio observation surface. Auto-injected
as _G.audio via the prelude.
Observing what is audible
audio.observe() answers "what is this engine sounding right now, and why is a
source not" in one read. It reports the MIXER's own bookkeeping, which is what
makes it different from reading the Audio component's fields back: those carry
what content asked for, and AudioSource.state carries the value :play() wrote
there. Every voice report carries both — mixerState beside componentState —
so the two are compared rather than confused.
audio.observe() -> AudioObservation— the whole surface in one call:{ available, sampleRate, channels, sampledAtMs, voices, audibleCount, listener, voiceAccounting, levels, profile }.audio.voices() -> { AudioVoice }— every live source with the mixer's opinion of it: the clip, the channel, whether its bytes are resident, the mixer's playback state, the position in the clip and the clip's duration, the gain it reaches the mix at stage by stage, and for a spatial source its emitter, the listener distance,maxDistanceandrolloff.audio.voice(entityId) -> AudioVoice?— the voice on one entity.audio.whySilent(entityId) -> reason?, detail?— why a source makes no sound, from a closed set:noBackend,noDevice,notResident,neverStarted,refused,paused,ended,gainZero,channelSilent,masterSilent,outOfRange.nilwhile it IS sounding; the second return carries the mixer's own words when it refused the source.audio.device() -> AudioDeviceStatus— the output device the mix reaches:state("open"/"silent"), the device an open stream runs on, the rate and channel count it runs at, and the counters for what the engine has been through keeping a stream open —faults,changes,reopens,failedOpens,glitchesandlastError. An output device that goes away leaves the mixer running and every voice reportingnoDeviceuntil the engine opens a stream again.audio.levels() -> AudioLevels— the master mix's peak and RMS over the meter's most recent closed window, with no recording involved.windowscounts the windows the mixer has closed since it started, so a silent mix reads differently from a mixer that is not running.audio.peakSince(window) -> number?— the loudest peak the master mix reached across the windows that closed afteraudio.levels().windowsstood atwindow.levels()carries the window that closed last, so a reader sees the windows its own frames land on; this spans all of them, which is what measuring a sound shorter than the gap between two reads takes.nilwhen the meter holds no peak for the span — nothing has closed sincewindow, or it reaches further back than the meter's history of recent windows.audio.listener() -> AudioListenerState— where the scene is heard from, and how many active listeners exist.audio.voiceAccounting() -> AudioVoiceAccounting— voice capacity, in use and free read off the mixer's own tracks, sofreeis what the next play call is granted or refused against. The mixer keeps two pools and each is reported apart:capacity/inUse/freeare the main track, carrying the NON-spatial voices, while a spatial voice plays through its own sub-track and is counted byspatialCapacity/spatialInUse.sourcesHoldingcounts both pools from the sources that own them, so it equalsinUse + spatialInUsewhile every voice the mixer carries answers to a source — and a mixer carrying voices no source reaches is the difference between those numbers.audio.profile() -> AudioProfile/audio.resetProfile()— what the audio subsystem costs: the streaming pump, clip decode and encode, voice starts, and building the observation. Every total is a sum across a window rather than a per-frame figure: the window runs from the lastresetProfile— or from engine start when nothing has reset it — and its length comes back aswindowMsandframes. Reset, let frames pass, then divide a total byframesfor what a frame costs.
The same document is served at /runtime/active_audio, with one file per source
under /runtime/active_audio/voices/<entity>, as the audio systems published it
after the last frame; audio.observe() leaves out the voices of entities a
script has despawned since. It answers in edit mode as well as play mode.
Setting what the mix is heard at
Every voice names a channel — the Audio component's 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, and both come back per voice as gain.channel and gain.master.
audio.mixer() -> AudioMixerLevels—{ master, muted, channels }. A channel absent fromchannelshas had no level set and plays at unity, so a scene that invented its own channel names is heard exactly as authored until something turns one of them down.audio.setChannelVolume(channel, volume)— set one channel's level. Scales every voice on that channel and nothing else.audio.setMasterVolume(volume)— set the level of the whole mix.audio.setMuted(muted)— silence the mix whatever the levels read; unmuting hands them back untouched.
audio.setChannelVolume("music", 0.3) -- music at 30%, sfx untouched
audio.setMasterVolume(0.8)
audio.setMuted(true) -- every voice reports masterSilent
Codec functions
audio.encode(sourceBytes, opts?) -> bytes?, err?— decode container audio (ogg / mp3 / wav / flac) and re-encode it into a ZAUD payload.audio.encodePcm(pcm, sampleRate, channels, opts?) -> bytes?, err?— encode raw interleaved f32 PCM (abufferor a binary string of little-endian f32, OR a flat number array) into a ZAUD payload.audio.decode(zaud) -> pcm?, sampleRate?, channels?— decode a ZAUD payload to interleaved f32 PCM (pcmis a binary little-endian-f32 string).audio.info(zaud) -> table?, err?— read a ZAUD header:{ sampleRate, channels, frames, durationMs, codec, loopStart, loopEnd, packets }.audio.loopSeam(zaud) -> table?, err?— measure what a clip's samples do where a whole-clip loop wraps:{ ratio, step, meanStep, maxStep, channel, seamless, threshold, channels }.audio.peaks(zaud, buckets) -> { number }?, err?— the loudest sample in each ofbuckets(an integer from 1 to 4096) equal spans of a clip, across its channels, held to 1: the shape a waveform is drawn from. A span isframes // bucketsframes; the payload is decoded a block at a time, so the work never holds the clip's whole PCM. A payload that is not a clip, or stops short, answers(nil, err).
Every sample an encode is handed must be a finite number. A NaN or an infinity
carries no signal, and both codecs turn one into a payload whose header reads as
a healthy clip and whose samples sound as nothing — so audio.encode and
audio.encodePcm return (nil, err) naming how many samples fail and where the
first one sits, and audio.info reads a PCM payload's samples the same way and
answers for what the payload carries rather than for what its header claims.
That makes a divergent filter, a 0/0, or a math.log(0) in a bake visible at
the call that would otherwise have written it.
local zaud, err = audio.encodePcm(samples, 22050, 1, { codec = "pcm" })
if not zaud then
print(err) -- "…: 3 of 176400 samples are not finite, first at index 88214"
end
Whole-clip repeat is the loop the runtime plays, so the join between a clip's
last frame and its first is heard once a lap, and a bed whose head does not
meet its tail ticks for as long as it plays. audio.loopSeam is the engine's
reading of that join: the step the wrap makes (|x[1] - x[frames]|) against
the step the signal ordinarily makes between neighbouring samples, as
ratio = step / meanStep. Reading it as a ratio puts the figure in the units
the signal itself moves in, so a generated ambience travelling a thousandth of
a unit per sample and a synth drone travelling a hundredth are judged the same
way. A bed whose partials complete a whole number of cycles across the buffer
reads near 1; one carrying a percussive strike at its head and silence at its
tail reads in the tens. seamless is ratio <= threshold, and threshold is
the ratio asset.create("soundClip", ...) warns past.
local seam = audio.loopSeam(clipRef:getBytes())
print(seam.ratio, seam.seamless, seam.threshold) -- 41.0 false 8
-- `ratio` is the worst channel's; `channel` names it and `channels` carries
-- each one's own { ratio, step, meanStep, maxStep }.
print(seam.channel, seam.channels[seam.channel].step)
The reading is taken on the DECODED samples, so it answers for what the codec
left behind rather than for the buffer that was handed to the encoder, and for
a clip that arrived already encoded and whose source buffer nobody holds. That
costs a decode of the whole payload — audio.info reads a header without one.
A clip is counted in frames — channels samples that sound together — and a
header's frames * channels is the sample count audio.decode hands back. So
the buffer audio.encodePcm takes is a whole number of frames, and one whose
length leaves a tail over comes back as (nil, err) naming the whole frames it
holds and the samples past them.
local zaud, err = audio.encodePcm(fiveSamples, 22050, 2, { codec = "pcm" })
if not zaud then
print(err) -- "…: 5 samples is not a whole number of 2-channel frames — 2 whole frames and 1 left over"
end
A byte payload carries a sample per 4-byte lane, so audio.encodePcm takes the
buffer microphone.samples() hands back and the string audio.decode returns
as they are, and recording a capture into a clip is those two calls:
local status = microphone.status()
local zaud = audio.encodePcm(microphone.samples(), status.sampleRate, 1)
A byte length that stops partway through a lane describes a sample the caller
holds only part of, and comes back as (nil, err) naming the whole samples it
holds and the bytes past them:
local zaud, err = audio.encodePcm(twentySevenBytes, 22050, 1, { codec = "pcm" })
if not zaud then
print(err) -- "…: 27 bytes is not a whole number of f32 samples — 6 whole samples and 3 bytes left over"
end
opts (all optional): codec ("opus" default / "pcm"), bitrateKbps
(default 96), vbr (default true), sampleRate (resample target, default
0 = keep source), forceMono (default false), loopStart / loopEnd
(frame indices, default 0). Pure CPU — runs on native, WASM, and headless.
Interface
What this asset declares: the schema it conforms to, what it exposes, and the rendered structured payload.
conforms to
zero/source-extract/v2module audio Engine-native audio: encode audio (or raw PCM) into the ZAUD compressed payload, decode/inspect it, set what the mix is heard at — a level per named channel, a master level and a mute — and observe what the mixer is making audible right now: live voices, why a source is silent, master levels, mixer voice accounting, listener state and subsystem cost. global audio global-types require modules/api/engine/audio
encode(sourceBytes: buffer | string, opts: { [string]: any }?) →
Encode container audio bytes (ogg / mp3 / wav / flac) into a ZAUD payload. Every decoded sample must be finite; a source whose samples carry a NaN or an infinity comes back as `(nil, err)` naming how many fail and where the first one sits.
| arg | type | description |
|---|---|---|
| sourceBytes | buffer | string | Encoded source audio bytes — a buffer or a binary string. |
| opts | { [string]: any }? | `{ codec: "opus"|"pcm"?, bitrateKbps: number?, vbr: boolean?, sampleRate: number?, forceMono: boolean?, loopStart: number?, loopEnd: number? }` |
examples
local zaud = audio.encode(oggBytes, { bitrateKbps = 96 })encodePcm(pcm: any, sampleRate: number, channels: number, opts: { [string]: any }?) →
Encode raw interleaved f32 PCM into a ZAUD payload. Every sample must be finite; a buffer carrying a NaN or an infinity comes back as `(nil, err)` naming how many fail and where the first one sits, so a filter that diverged over part of a bake is caught before it is written. The sample count is a whole number of `channels`: a buffer with a tail over comes back as `(nil, err)` naming the whole frames it holds and the samples past them. little-endian f32, the shape `microphone.samples` and `audio.decode` hand back, or a flat number array. A byte payload's samples are its 4-byte lanes, and a length that stops partway through one comes back as `(nil, err)` naming the whole samples it holds and the bytes past them.
| arg | type | description |
|---|---|---|
| pcm | any | Interleaved f32 samples — a buffer or a binary string of |
| sampleRate | number | Source sample rate in Hz. |
| channels | number | 1 or 2, and a divisor of the sample count. |
| opts | { [string]: any }? | Same shape as `audio.encode`. |
examples
local s = microphone.status(); local zaud = audio.encodePcm(microphone.samples(), s.sampleRate, 1)
decode(zaud: buffer | string) →
Decode a ZAUD payload into interleaved f32 PCM. A PCM payload comes back as the frames its header accounts for, held to the whole frames the bytes behind it fill, so the sample count is always a whole number of `channels` and a consumer walking it `channels` at a time ends on a frame.
| arg | type | description |
|---|---|---|
| zaud | buffer | string | A ZAUD payload — a buffer or a binary string. |
info(zaud: buffer | string) →
Read a ZAUD payload's header. A PCM payload's samples are its bytes, and the header is read against them: a sample count differing from `frames * channels` comes back as `(nil, err)` naming both counts, and a sample carrying a NaN or an infinity comes back as `(nil, err)` naming how many fail and where the first one sits, so the header handed back describes a clip that is as long as it says and can sound. The header describes the clip's shape — rate, channels, frames, duration, codec, loop points. What the samples do where a whole-clip loop wraps is a reading of its own, `audio.loopSeam`, which is the call that answers whether a bed cycles without a click.
| arg | type | description |
|---|---|---|
| zaud | buffer | string | A ZAUD payload — a buffer or a binary string. |
examples
local info = audio.info(zaud); print(info.durationMs)
peaks(zaud: buffer | string, buckets: number) →
The loudest sample in each of `buckets` equal spans of a clip, across its channels, as an absolute value held to 1: the shape a waveform is drawn from. A span is `frames // buckets` frames (at least one), and the frames past `buckets * span` fall in no bucket; a clip shorter than `buckets` frames fills one bucket per frame and leaves the rest at 0. The payload is decoded a block at a time, so the work never holds the clip's whole PCM, however long it runs.
| arg | type | description |
|---|---|---|
| zaud | buffer | string | A ZAUD payload — a buffer or a binary string. |
| buckets | number | How many spans to divide the clip into: an integer from 1 to 4096. |
examples
local bars = audio.peaks(clipRef:getBytes(), 96)
loopSeam(zaud: buffer | string) →
Measure what a clip's samples do where a whole-clip loop wraps, so a bed can be judged before anyone hears it tick. The wrap's own step (`|x[1] - x[frames]|`) is reported against the step the signal ordinarily makes between neighbouring samples, as `ratio = step / meanStep` — a figure in the units the signal itself moves in, so a quiet ambience and a loud drone are read the same way. A bed whose partials wrap reads near 1; one carrying a strike at its head and silence at its tail reads in the tens. `ratio` and the `step` / `meanStep` / `maxStep` beside it belong to the worst channel, `channel` names it, and `channels` carries every channel's own reading. `seamless` is `ratio <= threshold`, the same threshold `asset.create("soundClip", ...)` warns past. The reading is taken on the DECODED samples, so it answers for what the codec left behind and for a clip that arrived already encoded and whose source buffer nobody holds. Costs a decode of the whole payload; `audio.info` reads a header without one.
| arg | type | description |
|---|---|---|
| zaud | buffer | string | A ZAUD payload — a buffer or a binary string. |
examples
local seam = audio.loopSeam(clipRef:getBytes()); print(seam.ratio, seam.seamless)
observe( ) → AudioObservation
Report what the mixer is making audible right now, and why a source is not. One read covering every live voice with the mixer's own playback state and effective gain, the master mix's level, the mixer's voice accounting, the listener, the output device the mix is reaching, and what the subsystem costs. Answers in edit mode as well as play mode.
examples
local a = audio.observe(); print(a.audibleCount, a.levels.rms)
for _, v in audio.observe().voices do print(v.entity, v.mixerState, v.silence) end
device( ) → AudioDeviceStatus
What the engine's audio output is doing: `state` is `"open"` while a stream is running on an output device and `"silent"` while none is, and `device` names the device an open stream runs on. The counters record what the engine has been through keeping one open — `faults` a live stream reported, `changes` of the host's default output, `reopens` the engine made, `failedOpens` the platform refused, and the `glitches` a listener heard as dropouts, with `lastError` carrying what the platform said. A device that goes away leaves the mixer running and the engine opening a stream again as soon as one is there.
examples
local d = audio.device(); print(d.state, d.device, d.reopens)
voices( ) →
Every live audio source with the mixer's opinion of it.
examples
for _, v in audio.voices() do print(v.clip, v.gain.effective) end
voice(entityId: string) → AudioVoice
The voice on one entity, or nil when that entity carries no audio source. An entity despawned since the frame the observation describes answers with its report from that frame, so an `onDestroyed` handler reads how the entity's voice stood as it went.
| arg | type | description |
|---|---|---|
| entityId | string | The entity's stable id. |
examples
local v = audio.voice(e.id); print(v and v.mixerState)
whySilent(entityId: string) →
Why the source on an entity is making no sound. Returns nil when it IS sounding, and one of `noBackend`, `noDevice`, `notResident`, `neverStarted`, `refused`, `paused`, `ended`, `gainZero`, `channelSilent`, `masterSilent`, `outOfRange` when it is not — the nearest cause, so the answer names the thing to change. A second `"no audio source on this entity"` when nothing there plays at all.
| arg | type | description |
|---|---|---|
| entityId | string | The entity's stable id. |
examples
local why = audio.whySilent(e.id); if why then print(why) end
levels( ) → AudioLevels
The master mix's peak and RMS over the meter's most recent closed window, measured without recording anything.
examples
local l = audio.levels(); print(l.peak, l.rms, l.windowMs)
peakSince(window: number) → number
The loudest peak the master mix reached across the meter's windows that closed after its `windows` count stood at `window`. `audio.levels()` carries the window that closed last, so a reader sees the windows its own frames happen to land on; this spans all of them, which is what measuring a sound shorter than the gap between two reads takes. Take the mark from `audio.levels().windows` before the sound starts, wait until `windows` has advanced past the sound's length, then read the span. peak for it — nothing has closed since `window`, or the span reaches further back than the meter's history of recent windows, so a reader that came back too late learns that instead of reading the maximum of the part that survived.
| arg | type | description |
|---|---|---|
| window | number | A `windows` count taken from `audio.levels()` earlier. |
examples
local mark = audio.levels().windows
local peak = audio.peakSince(mark)
listener( ) → AudioListenerState
Where the scene is heard from, how many active listeners exist, and which entity's listener drives the ears.
examples
local l = audio.listener(); print(l.present, l.count, l.entity)
voiceAccounting( ) → AudioVoiceAccounting
How many voices the mixer can hold, how many are in use, how many are free — read off the mixer's own tracks, so the free count is the one a play call is granted or refused against. The two pools are reported apart: `capacity` / `inUse` / `free` are the main track, which carries the NON-spatial voices, while a spatial voice plays through its own sub-track and is counted by `spatialInUse` instead. `sourcesHolding` counts both pools from the sources that own them, so it equals `inUse + spatialInUse` while every voice answers to a source.
examples
local v = audio.voiceAccounting(); print(v.inUse .. "/" .. v.capacity)
local v = audio.voiceAccounting(); print(v.sourcesHolding - (v.inUse + v.spatialInUse))
profile( ) → AudioProfile
What the audio subsystem has cost since the profiling window opened — the streaming pump, clip decode, clip encode, voice starts, and building the observation itself. Every total is a SUM across that window rather than a per-frame figure, and the window runs from the last `audio.resetProfile()` or from engine start. For what a frame costs now, reset, let frames pass, then divide by the `frames` the window reports.
examples
audio.resetProfile(); task.wait(1); local p = audio.profile()
print("per frame:", (p.pump.totalMs + p.observe.totalMs) / p.frames)mixer( ) → AudioMixerLevels
The levels the mixer is applying to the mix right now: the master level, whether the mix is muted, and the level of every channel one has been set on. A channel absent from `channels` plays at unity, so a source naming it is heard at the volume it asks for.
examples
local m = audio.mixer(); print(m.master, m.muted, m.channels.music)
setMasterVolume(volume: number) → void
Set the master level of the mix, on the engine's 0..1 amplitude scale. It scales every voice whatever channel it plays on, reaches voices that are already playing, and comes back per voice as `gain.master`.
| arg | type | description |
|---|---|---|
| volume | number | Master level, 0..1. |
examples
audio.setMasterVolume(0.5)
setChannelVolume(channel: string, volume: number) → void
Set the level of one mixer channel — the `channel` an `Audio` component names, such as "sfx", "music" or "ambient", or any name the scene invents. It scales every voice on that channel and nothing else, reaches voices that are already playing, and comes back per voice as `gain.channel`. A channel no level has been set on plays at unity.
| arg | type | description |
|---|---|---|
| channel | string | The channel name, matching `Audio.channel`. |
| volume | number | Channel level, 0..1. |
examples
audio.setChannelVolume("music", 0.3)for _, v in audio.voices() do print(v.channel, v.gain.channel) end
setMuted(muted: boolean) → void
Silence or unsilence the whole mix. A muted mix sounds nothing whatever its master and channel levels read, every voice reports `masterSilent`, and unmuting hands the levels back untouched.
| arg | type | description |
|---|---|---|
| muted | boolean | Whether the mix is silenced. |
examples
audio.setMuted(true)
resetProfile( ) → void
Open a new audio profiling window, discarding what the previous one measured. Call this before timing a stretch of frames: without it `audio.profile()` reports totals reaching back to engine start.
examples
audio.resetProfile()
AudioGain = {AudioSpatialization = {AudioVoice = {AudioListenerState = {AudioVoiceAccounting = {AudioLevels = {AudioMixerLevels = {AudioStageCost = {AudioProfile = {AudioDeviceStatus = {AudioObservation = {AudioInfo = {AudioChannelSeam = {AudioLoopSeam = {Sub-parts
Everything contained inside this part. Assets are composite children (clickable cards). Files are leaf payloads. Expand any row to view its source.
Showing up to 3 levels deep. Open a part to see what it holds further down.
Problems
Everything affecting this asset right now: its own problems, anything wrong inside it, and problems on its direct dependencies.
agent_score is exposed.+ quality × 0.35
+ performance × 0.25
± compat factor
Usability ratings
Did the part work as advertised when consumers tried to drop it in. Separate from upvotes: those are taste; this is "did it function".
Scoped to this part · feeds back into the world's score.