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 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:
| 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. |
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.
| 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 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.
| parameter | type | meaning |
|---|---|---|
bytes | string? | 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. |
writeSource | boolean?, default true | keep 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). |
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 the pcm samples are at, in Hz. Required with pcm. To resample a clip, set settings = { sampleRate = ... }. |
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. |
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 whendata.zaudchanges.