Log inGet started

soundClip

Updated October 13, 2026

soundClip is a first-class assetType (not a bare plural-dir category) so that:

  • a soundClip has a stable asset identity and an explicit asset_type link, and
  • a component that plays a soundClip records a real .refs edge to it.

Internal layout

A .soundClip/ is permissive (allow_unlisted: true) — the conversion step decides its shape. A managed container holds:

<name>.soundClip/
  source.<ext>   the retained source audio (.ogg / .mp3 / .wav / .flac):
                 `asset.create` keeps a copy of the bytes it was given under
                 this name, and the importer moves the uploaded file in under
                 its own name instead. Absent for a PCM-baked clip.
  settings.yaml  compression settings — see below
  data.zaud      engine-native ZAUD payload the runtime decodes + plays, under
                 the codec its own header names
  README.md      what the sound is and what it is for

data.zaud is the preferred primary: when present the runtime resolves and decodes it directly. When absent the primary falls through to the source audio file, so imported-only containers keep working.

Settings

A soundClip's settings are declared in settings.luau and live in settings.yaml — or, for a clip published before that file existed, a legacy settings block inside .metadata. Editing any of the encode-relevant ones re-runs encoding — against the retained source audio when the container has one, or against the decoded data.zaud for a PCM-baked clip — and rewrites data.zaud. Rewriting the retained source audio re-encodes it the same way:

keyvaluesmeaning
codecopus (default) / pcmoutput codec. opus compresses; pcm stores uncompressed samples.
bitrateKbpsinteger, default 96Opus target bitrate (ignored for pcm).
vbrtrue (default) / falseOpus variable bitrate.
sampleRateinteger, 0 = keep sourceresample target in Hz.
forceMonofalse (default) / truedownmix to a single channel.
loadTypeauto (default) / decompressOnLoad / streaminghow the runtime loads the clip.
loopStartinteger frames, 0 = noneloop-point start.
loopEndinteger frames, 0 = noneloop-point end.

loadType alone never re-runs encoding — it decides how the runtime holds the clip, not what data.zaud holds. It acts on an already-playing Audio component instead: the next source that component builds reads the new value fresh and rebuilds under it.

The data.zaud payload is produced by the engine's audio codec, exposed to Luau as audio.encode / audio.encodePcm / audio.decode / audio.info / audio.loopSeam and consumed by this assetType.

Every sample the codec is handed must be a finite number, so asset.create raises when the pcm or bytes it is given carries a NaN or an infinity, and the error names how many samples fail and where the first one sits.

Does it loop without a click

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. clipRef:loopSeam() reads what the samples do there: the step the wrap makes (|x[1] - x[frames]|) against the step the signal ordinarily makes between neighbouring samples, as ratio = step / meanStep. The figure is 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 read the same way.

fieldmeaning
ratiostep / meanStep on the worst channel — one channel clicking is the clip clicking.
step|x[1] - x[frames]|, the step the wrap makes.
meanStepthe mean |x[i + 1] - x[i]|, the distance the signal ordinarily travels in one sample.
maxStepthe largest |x[i + 1] - x[i]| the channel already carries.
channelwhich channel the four figures above came from, counted from 1.
seamlessratio <= threshold.
thresholdthe ratio the engine warns past — 8.
channelsevery channel's own { ratio, step, meanStep, maxStep }, in channel order.

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.

local seam = clipRef:loopSeam()
if not seam.seamless then
    print(seam.ratio, seam.step, seam.meanStep)  -- e.g. 41.0  0.5993  0.0146
end

asset.create("soundClip", ...) takes the same reading of the payload it just encoded and logs a warning naming the ratio when it is past threshold, so a bed that ticks is reported at the call that bakes it. The re-encodes a source edit and a settings change trigger report it the same way.

The reading is taken on the DECODED payload, 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, from an import or from ZeroMind, whose source buffer nobody holds.

Creating a soundClip

asset.create("soundClip", name, opts) reads seven keys of its own out of opts, and the settings table above is one of them. asset.create's framework keys — folder, into, dest and overwrite — apply to a soundClip the way they apply to every type.

parametertypemeaning
bytesstring?encoded source audio (OGG / MP3 / WAV / FLAC), stored verbatim as source.<ext> by default.
ext"ogg" (default) / "mp3" / "wav" / "flac"which container bytes is in.
writeSourceboolean?, default truekeep the source audio in the container as source.<ext>. false skips the copy — the caller supplies the source itself (the importer moves the uploaded file in under its own name instead).
pcmbuffer | string | { number }?interleaved f32 samples — 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.
sampleRatenumber?the rate the pcm samples are at, in Hz. Required with pcm. To resample a clip, set settings = { sampleRate = ... }.
channelsnumber?how many channels they interleave. Required with pcm.
settingstable?the full settings table from the section above, baked in the same pass; wins over the defaults.

ext and writeSource belong with bytes, and channels with pcm: a create that passes one of them without the opt it belongs with is refused, naming both.

-- from encoded audio bytes (kept as source.<ext>, encoded to data.zaud)
asset.create("soundClip", "music", { bytes = oggBytes, ext = "ogg" })

-- the caller supplies the source itself (moved in under its own name after
-- create, preserving its identity) — the importer's own promote path
asset.create("soundClip", "music", { bytes = oggBytes, ext = "ogg", writeSource = false })

-- from raw PCM samples (no source file; encoded straight to data.zaud)
local samples = buffer.create(960 * 4)
for i = 0, 959 do
    buffer.writef32(samples, i * 4, math.sin(i * 0.05) * 0.5)
end
asset.create("soundClip", "beep", { pcm = samples, sampleRate = 48000, channels = 1 })

-- the same samples kept exactly as handed in, under a codec and a load
-- strategy chosen in the one pass that bakes them
asset.create("soundClip", "bed", {
    pcm = samples, sampleRate = 48000, channels = 1,
    settings = { codec = "pcm", loadType = "decompressOnLoad" },
})

A buffer is the cheap container for a long clip: 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.

Choose the codec at create time. Encoding runs once per bake, and a later settings change re-encodes from what is already stored — the retained source audio when the container has one, and otherwise the decoded data.zaud. So a clip baked with the default opus and then given setSettings({ codec = "pcm" }) stores the Opus signal losslessly rather than the samples that were handed in: the frame count, the duration and the settings table all read as asked for, and only the samples differ. Passing settings to asset.create bakes the samples once, under the codec they are meant to keep.

Read a clip's settings with clipRef:settings(), patch them with clipRef:setSettings({ bitrateKbps = 64 }), read its stored payload's header with clipRef:info(), decode its samples with clipRef:pcm(), and read its loop point with clipRef:loopSeam().

clipRef:info() is the length, rate, channel count, codec and loop points of the payload the container stores, read off its header:

local info = clipRef:info()
print(info.durationMs, info.sampleRate, info.channels)

It is the reading to time something against a clip, such as a caption held for as long as its line is spoken, because it costs a header read where pcm() decodes every sample to arrive at the same figures. The sampleRate it reports is the rate the payload holds; the sampleRate in the settings table above is the resample target the encode was asked for, and 0 there means the source's own rate was kept.

clipRef:pcm() hands back (pcm, sampleRate, channels) — the same triple audio.decode returns. The samples come back packed rather than as a Luau array: a binary string of interleaved little-endian f32, four bytes per sample, so #pcm // 4 is the sample count and one sample reads as string.unpack("<f", pcm, i * 4 + 1) or through buffer.fromstring(pcm).

local pcm, sampleRate, channels = clipRef:pcm()
local frames = #pcm // 4 // channels
print(frames / sampleRate, "seconds")           -- e.g. 1.5  seconds

local peak = 0
for i = 0, #pcm // 4 - 1 do
    peak = math.max(peak, math.abs(string.unpack("<f", pcm, i * 4 + 1)))
end

The second and third returns are the clip's own rate and channel count, so a duration is arithmetic on what pcm() alone hands back. audio.info reads the same figures off the stored payload without decoding it, and the topics/audio guide lists every field it returns.

The topics/audio guide covers synthesis end to end: computing a buffer, baking it into a clip, playing it from an Audio component, and reading it back.

How a source holds the clip

clipRef:streams(bytes?) answers whether a source playing this clip holds it streaming: loadType = "streaming" does, "decompressOnLoad" does not, and "auto" streams a clip longer than 5 seconds. Pass the data.zaud bytes when you already hold them, to skip reading the payload again. Every source the Audio component builds asks it, so the policy lives with the clip.

Auditioning

clipRef:audition() plays the clip once through the mixer, unpositioned, on the sfx channel. It plays on a temporary host entity that the scene never saves and the entity listings leave out, and it replaces any clip already auditioning. It returns the host's entity id, or nil when the payload is unreadable. The host goes when the mixer reports the voice ended. clipRef:stopAudition() ends the audition at once and its host goes on the next frame; it returns whether this clip was the one auditioning. A later audition ends the earlier one the same way. clipRef:lastAuditionEnd() says how this clip's most recent audition ended once its host is gone, as { host, reason }: "ended" (the mixer reported it played through), "stopped", "replaced", "unvoiced" (no output, or the mixer turned it away), "timeout" (on the mixer past the clip's length with no end reported), "neverArrived" or "hostGone". The Playback section shows it under "Not playing.". clipRef:auditionVoice() is the audition in progress for this clip: { host, voice }, where voice is audio.voice(host) (the mixer's view of the voice, nil until the mixer has it), or nil when this clip is not auditioning.

In the Inspector

Selecting a clip shows, after its identity:

  • Clip: the payload header (duration, sample rate, channels, frames, codec, packets and the loop points when set) and whether a source holds the clip streaming or decoded on load.
  • Playback: Play auditions the clip and Stop ends the audition. While it plays, the section shows the mixer's state for the voice and, when the voice is silent, why.
  • Settings: the settings above.
  • Waveform (collapsed at first): 96 bars, each the loudest sample of an equal span of the clip across its channels, from audio.peaks. The payload is decoded a block at a time, so a long clip costs no more memory than a short one, and it is read again only when data.zaud changes.
  • asset-type
  • reference