AudioKit
AudioKit is the kernel’s audio engine: bank-registered 2D/3D playback over auto-created bus SoundGroups, fades, crossfades, ducking, weight-mixed music layers, loop-aware cue points, reverb zones, soundscapes, and dialogue with subtitle events. The defining decision: one scheduler stepper drives every fade, crossfade, duck, cue, and occlusion update — no TweenService churn, no per-sound Heartbeat connections — which is also why the whole engine is deterministic under spec: tests inject a listener and step time by hand.
It runs on either machine. Attach on the client for player-facing audio — that is where per-player volumes, ducking, and the device channel budget belong. Attach on the server for world audio that replicates as ordinary Sound instances. AudioKit.server() plus audio:listen() carry server-triggered plays to client-side kits over one state channel.
Mental model
Section titled “Mental model”attach creates one SoundGroup per bus — CKAudio_Music, CKAudio_SFX, … — parented to SoundService. Defaults: Music, SFX, Ambient, Voice, UI. Every registered sound routes into its bus’s group, so a bus volume change or duck moves everything on the bus at once. The applied group volume is BusVolume × deepest duck:
setBusVolume(bus, volume)sets the base (clamped0..2).duck(bus, scale)adds a token and returns a release function. Stacked ducks don’t multiply — the deepest (lowest scale) wins. A sound config’sDuckmap holds a duck for the handle’s lifetime;speak()ducksMusicto 0.35 andSFXto 0.6 while a line plays.
Channels
Section titled “Channels”Every active handle occupies a channel. The budget comes from MaxChannels: explicit if you pass it, otherwise the DeviceBench profile’s AudioChannels on clients (the bench runs once — run it during the loading screen) and 32 on the server. Under pressure _requestChannel evicts the lowest-priority active handle at-or-below the newcomer’s priority — oldest first among ties — with a 0.1s fade-out; if every active sound outranks the newcomer, the play is rejected and returns nil. playMusic handles are Protected and never evicted. Stats counts Played, Rejected, Evicted.
Priorities are 1..4 (default 2): a grenade at priority 3 never loses its bang to thirty priority-1 footsteps.
Pooling
Section titled “Pooling”Sounds with Pooled = true return their Sound instance to a per-name pool on stop instead of being destroyed — the next play reuses it (rapid gunfire never allocates). 3D plays at a Vector3 ride pooled invisible emitter parts in a local workspace.CKAudioEmitters folder; emitters return to the pool when the handle finishes. Because emitters are local pooled parts driven by wire data, spatial audio has no dependency on streamed world geometry — see StreamingEnabled.
The stepper
Section titled “The stepper”One 30Hz loop on the kernel Scheduler at Priority.Low advances everything: fades (smoothstep-eased, object+property keyed, a new fade replaces the old one mid-flight), cue tracking, soundscape chirp timers, and — on a 0.25s cadence — occlusion EQ and reverb-zone checks.
Register a bank, play sounds
Section titled “Register a bank, play sounds”-- Client Bootstraplocal ReplicatedStorage = game:GetService("ReplicatedStorage")local Kernel = require(ReplicatedStorage.ChloeKernel).boot()local AudioKit = require(ReplicatedStorage.ChloeKernel.AudioKit)
local Audio = AudioKit.attach(Kernel)Audio:registerBank({ Click = { Id = "rbxassetid://1", Bus = "UI", Volume = 0.8, Pooled = true }, Shot = { Id = "rbxassetid://2", Bus = "SFX", Priority = 3, Pooled = true }, CaveTheme = { Id = "rbxassetid://3", Bus = "Music", Looped = true, Cues = { Drop = 32.5 } }, GuideLine1 = { Id = "rbxassetid://4", Bus = "Voice", Subtitle = "Head for the summit." }, Siren = { Id = "rbxassetid://5", Duck = { Music = 0.3 } }, -- Music dips while it plays})
Audio:play("Click") -- 2Dlocal Handle = Audio:play("Shot", { FadeIn = 0.05, Volume = 0.9 })if Handle then -- channel pressure can reject: always nil-check Handle:setVolume(0.5, 0.3) Handle:stop(0.2)end3D playback, occlusion, NPC ears
Section titled “3D playback, occlusion, NPC ears”playAt targets a Vector3 (pooled emitter part), a BasePart, or an Attachment:
-- Muffle behind geometry: true/"Ray" is a line-of-sight raycastAudio:playAt("Shot", MuzzlePosition, { Occlusion = true })
-- "Path" mode muffles by the pathfinding detour around the geometry —-- the next corridor sounds far, not crystal-clear through the wall.-- Needs Occlusion.Pathfinding and Occlusion.Grid in the attach options.Audio:playAt("Shot", MuzzlePosition, { Occlusion = "Path" })
-- Alert rings NPC ears through NPCKit:emitSound — players' sounds are NPC intelAudio:playAt("Shot", MuzzlePosition, { Alert = { Range = 70 } })Ray occlusion drives an EqualizerSoundEffect to HighGain -30, MidGain -8 when blocked, faded over 0.2s and re-checked every 0.25s. Path occlusion scales the muffle by the detour ratio — a route as long as the straight line is clear (0), twice the straight line is fully muffled (1), no route at all is fully muffled. Wire the seams at attach:
local Audio = AudioKit.attach(Kernel, { Npcs = NpcKit, -- NPCKit instance for Alert (see /kits/npc-kit/) Occlusion = { Pathfinding = Pathfinding, -- "Path" mode route source (see /systems/pathfinding/) Grid = NavGrid, },})Music: crossfades and adaptive layers
Section titled “Music: crossfades and adaptive layers”-- One music track at a time; starting a new one crossfades from the currentAudio:playMusic("CaveTheme", { Crossfade = 3 })
-- Adaptive layers: every stem starts together on one locked timeline,-- weights mix (0..1 each) and several layers can be audible at oncelocal Layers = Audio:playLayers({ Calm = "rbxassetid://10", Combat = "rbxassetid://11", -- registered names work too}, { StartLayer = "Calm", Volume = 0.8 })
Layers:setWeights({ Calm = 0.3, Combat = 1 }, 2) -- battle starts: 2s blendLayers:resync("Calm") -- snap drifted stems back to one timelineLayers:stop(1.5)Cue points fire loop-aware bus events — sync visuals to the beat without polling TimePosition:
Kernel.Bus:subscribe("Audio.Cue", function(_, soundName, cueName, handle) if soundName == "CaveTheme" and cueName == "Drop" then Vfx:pulse() endend)Dialogue and subtitles
Section titled “Dialogue and subtitles”speak queues lines and plays them one at a time through each line’s configured bus (register dialogue on Voice), ducks Music/SFX while a line runs, and publishes subtitle events for your UI:
Audio:speak("GuideLine1") -- uses the config's Subtitle textAudio:speak("GuideLine2", { Text = "Storm's coming.", Duration = 3 })
Kernel.Bus:subscribe("Audio.Subtitle", function(_, text, durationSeconds) SubtitleLabel.Text = text -- durationSeconds is 0 unless the caller passed one SubtitleLabel.Visible = trueend)Kernel.Bus:subscribe("Audio.SubtitleEnded", function() SubtitleLabel.Visible = falseend)Zones, soundscapes, persisted volumes
Section titled “Zones, soundscapes, persisted volumes”-- Reverb while the listener stands inside the part; overlaps resolve by-- priority; leaving every zone restores the original ambiencelocal Remove = Audio:reverbZone(workspace.CaveVolume, Enum.ReverbType.Cave, 2)
-- Ambient bed + randomized one-shot chirps scattered around the listenerlocal Forest = Audio:soundscape({ Bed = "WindLoop", Chirps = { "Bird1", "Bird2" }, Interval = { 4, 12 }, -- seconds between chirps. Default { 4, 12 } Radius = 30, -- chirp scatter around the listener. Default 30})Forest:stop(2)
-- Bus volumes ride the validated Settings service — persisted, clamped,-- and remaps survive rejoins (see /systems/player-settings/)local Unbind = Audio:bindSettings(Prefs, { Music = "MusicVolume", SFX = "SfxVolume" })Server-triggered playback
Section titled “Server-triggered playback”The server never plays sounds at clients directly — it broadcasts play commands over one state channel (CKAudio, schema { "String", "String", "Any" }), and each listening client plays them locally through its own buses, volumes, and channel budget:
-- Serverlocal Remote = AudioKit.server(Kernel)Remote:broadcast("RoundStart")Remote:playFor(SomePlayer, "SecretFound")Remote:broadcastAt("Explosion", BlastPosition, { Occlusion = true })-- Client (Net is your NetClient — see /networking/net-client/)Audio:listen(Net)Warm the bank
Section titled “Warm the bank”assetIds() feeds Preload so first plays never hitch on download:
Preload.run(Kernel, { Banks = { Audio } }):await()API reference
Section titled “API reference”Constructor and options
Section titled “Constructor and options”| Member | Description |
|---|---|
AudioKit.attach(kernel, options?) → AudioKit |
Creates the bus groups and starts the 30Hz stepper |
AudioKit.server(kernel) → Remote |
Server helper over one CKAudio state channel: remote:broadcast(name, options?), remote:playFor(player, name, options?), remote:broadcastAt(name, position, options?) |
| Option | Default | Description |
|---|---|---|
Buses |
{ "Music", "SFX", "Ambient", "Voice", "UI" } |
Bus names; one SoundGroup each |
MaxChannels |
client: DeviceBench profile().AudioChannels; server: 32 |
Concurrent-handle budget |
Parent |
SoundService |
Parent for 2D sounds |
Npcs |
nil |
NPCKit instance for Alert passthrough |
Occlusion.GetListener |
current camera position | () → Vector3 listener source |
Occlusion.IsBlocked |
workspace raycast | (from, to) → boolean for "Ray" mode |
Occlusion.Pathfinding, Occlusion.Grid |
nil |
Route source for "Path" mode (Pathfinding) |
SkipLoop |
false |
Skip the stepper; specs drive _step(dt) |
SoundConfig
Section titled “SoundConfig”| Field | Default | Description |
|---|---|---|
Id |
— | Asset id. Required |
Bus |
"SFX" |
Must name a configured bus; register errors otherwise |
Volume |
1 |
Base volume |
Speed |
1 |
Playback speed |
Looped |
false |
|
Priority |
2 |
1..4; higher survives channel eviction |
Pooled |
false |
Reuse the Sound instance across plays |
RollOffMin / RollOffMax |
10 / 100 |
3D rolloff distances (studs) |
RollOffMode |
InverseTapered |
|
Cues |
nil |
{ [cueName] = seconds }; fires Audio.Cue, loop-aware |
Subtitle |
nil |
Default text for speak() |
Duck |
nil |
{ [bus] = scale } held while this sound plays |
Playback
Section titled “Playback”| Member | Description |
|---|---|
audio:register(name, config) / audio:registerBank(bank) |
Add configs; unknown buses error |
audio:assetIds() → { string } |
Every registered id, for Preload |
audio:play(name, options?) → Handle? |
2D play into Parent. nil when channel pressure rejects |
audio:playAt(name, target, options?) → Handle? |
3D at a Vector3 (pooled emitter), BasePart, or Attachment |
audio:stopAll(fadeSeconds?) |
Stops every active handle |
audio:playMusic(name, { Crossfade?, Volume? }?) → Handle? |
One music track at a time; crossfade default 2; the handle is eviction-Protected |
audio:playLayers(layers, { Bus?, Volume?, StartLayer? }?) → LayerHandle |
Locked-timeline stems; layers maps layer name → registered name or raw id; bus default "Music" |
audio:speak(name, { Text?, Duration?, Volume? }?) |
Queue a dialogue line; ducks Music/SFX; publishes subtitles |
audio:activeCount() → number |
Live handles |
audio:listen(netClient) → connection |
Client side of AudioKit.server |
audio:destroy() |
Stops everything, destroys groups/pools/emitters, restores the original AmbientReverb |
PlayOptions: Volume?, Speed?, FadeIn?, Looped?, Priority?, plus spatial extras Occlusion? (true/"Ray" line of sight, "Path" route-length muffle) and Alert? ({ Range?, Loudness?, Source? } — NPCKit emitSound passthrough).
Handles
Section titled “Handles”| Member | Description |
|---|---|
handle:stop(fadeSeconds?) |
Fade out (or cut) and release the channel; pooled sounds and emitters return to their pools |
handle:setVolume(volume, fadeSeconds?) |
Fade the sound volume |
handle:setSpeed(speed) |
Immediate PlaybackSpeed |
handle:isActive() → boolean |
Still occupying a channel |
layerHandle:setWeights(weights, fadeSeconds?) |
{ [layer] = 0..1 }, faded (default 1.5); omitted layers fade to 0 |
layerHandle:resync(toLayer?) |
Snap every stem’s TimePosition to the reference layer |
layerHandle:stop(fadeSeconds?) |
Fade out (default 1) and destroy the stems |
scape:stop(fadeSeconds?) |
Stop a soundscape’s bed (default fade 1.5) and its chirps |
Buses and mixing
Section titled “Buses and mixing”| Member | Description |
|---|---|
audio:setBusVolume(bus, volume, fadeSeconds?) |
Base volume, clamped 0..2, faded (default 0.25). Unknown buses error |
audio:getBusVolume(bus) → number |
The base volume (ducks not included) |
audio:duck(bus, scale, fadeSeconds?) → release |
Token duck; deepest active duck wins; call release() to lift |
audio:bindSettings(settingsClient, map) → unbind |
{ [bus] = settingsKey }: applies stored volumes now, follows confirmed changes (Player settings) |
audio:reverbZone(part, reverbType, priority?) → remove |
Listener-inside-box reverb; highest priority wins; default priority 1 |
audio:soundscape(options) → Scape |
{ Bed?, Chirps?, Interval? = {4, 12}, Radius? = 30 } |
audio.Stats |
{ Played, Rejected, Evicted } counters |
Bus topics
Section titled “Bus topics”| Topic | Payload | Fired |
|---|---|---|
Audio.Cue |
soundName, cueName, handle |
A playing sound crossed a configured cue time; re-arms when a looped sound wraps |
Audio.Subtitle |
text, durationSeconds |
A speak line with subtitle text started (durationSeconds is 0 unless passed) |
Audio.SubtitleEnded |
— | The subtitled line finished |