Log inGet started

Audio

Updated 6 September 2026

The two halves: source and listener

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

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

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.

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

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.

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

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:

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

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.

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:

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.

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:

ReasonWhat to change
noBackendThe mixer never came up on this host.
noDeviceThe mixer is running and no output device is open — audio.device() says what happened to it.
notResidentThe clip's bytes never reached the mixer — check the clip reference.
neverStartedNothing cued it: set autoplay, or call :play().
refusedThe mixer turned it away; the second return of audio.whySilent carries its words.
pausedIt is paused — call :play().
endedIt played through, or was stopped.
gainZeroIts volume left the chain at or below the amplitude the mix renders as silence — raise it.
channelSilentThe channel it names is at zero — raise it with audio.setChannelVolume.
masterSilentThe master level is zero or the mix is muted — audio.mixer() tells the two apart.
outOfRangeIt 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:

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:

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

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:

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:

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:

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:

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 callidle / 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.

  • documentation
  • guide