Log inGet started

soundClip

Updated 6 September 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 original audio (.ogg / .mp3 / .wav / .flac), MOVED in.
                 Present for imported clips; absent for a PCM-baked clip.
  .metadata      compression settings — see below
  data.zaud      engine-native ZAUD payload the runtime decodes + plays, under
                 the codec its own header names
  README.md

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.

.metadata settings

Editing any of these re-runs encoding — against the sibling source.<ext> when the container has one, or against the decoded data.zaud for a PCM-baked clip — and rewrites data.zaud:

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.

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 six 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>.
ext"ogg" (default) / "mp3" / "wav" / "flac"which container bytes is in.
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 those samples are at, in Hz. Required with pcm.
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.
-- from encoded audio bytes (kept as source.<ext>, encoded to data.zaud)
asset.create("soundClip", "music", { bytes = oggBytes, ext = "ogg" })

-- 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 — source.<ext> 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 }), decode its samples with clipRef:pcm(), and read its loop point with clipRef:loopSeam().

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).

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.

  • asset-type
  • reference