soundClip
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_typelink, and - a component that plays a soundClip records a real
.refsedge 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:
| key | values | meaning |
|---|---|---|
codec | opus (default) / pcm | output codec. opus compresses; pcm stores uncompressed samples. |
bitrateKbps | integer, default 96 | Opus target bitrate (ignored for pcm). |
vbr | true (default) / false | Opus variable bitrate. |
sampleRate | integer, 0 = keep source | resample target in Hz. |
forceMono | false (default) / true | downmix to a single channel. |
loadType | auto (default) / decompressOnLoad / streaming | how the runtime loads the clip. |
loopStart | integer frames, 0 = none | loop-point start. |
loopEnd | integer frames, 0 = none | loop-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.
| field | meaning |
|---|---|
ratio | step / meanStep on the worst channel — one channel clicking is the clip clicking. |
step | |x[1] - x[frames]|, the step the wrap makes. |
meanStep | the mean |x[i + 1] - x[i]|, the distance the signal ordinarily travels in one sample. |
maxStep | the largest |x[i + 1] - x[i]| the channel already carries. |
channel | which channel the four figures above came from, counted from 1. |
seamless | ratio <= threshold. |
threshold | the ratio the engine warns past — 8. |
channels | every 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.
| parameter | type | meaning |
|---|---|---|
bytes | string? | encoded source audio (OGG / MP3 / WAV / FLAC), stored verbatim as source.<ext>. |
ext | "ogg" (default) / "mp3" / "wav" / "flac" | which container bytes is in. |
pcm | buffer | 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. |
sampleRate | number? | the rate those samples are at, in Hz. Required with pcm. |
channels | number? | how many channels they interleave. Required with pcm. |
settings | table? | 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.