Skip to content

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.

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 (clamped 0..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’s Duck map holds a duck for the handle’s lifetime; speak() ducks Music to 0.35 and SFX to 0.6 while a line plays.

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.

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.

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.

-- Client Bootstrap
local 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") -- 2D
local 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)
end

playAt targets a Vector3 (pooled emitter part), a BasePart, or an Attachment:

-- Muffle behind geometry: true/"Ray" is a line-of-sight raycast
Audio: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 intel
Audio: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,
},
})
-- One music track at a time; starting a new one crossfades from the current
Audio: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 once
local 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 blend
Layers:resync("Calm") -- snap drifted stems back to one timeline
Layers: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()
end
end)

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 text
Audio: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 = true
end)
Kernel.Bus:subscribe("Audio.SubtitleEnded", function()
SubtitleLabel.Visible = false
end)
-- Reverb while the listener stands inside the part; overlaps resolve by
-- priority; leaving every zone restores the original ambience
local Remove = Audio:reverbZone(workspace.CaveVolume, Enum.ReverbType.Cave, 2)
-- Ambient bed + randomized one-shot chirps scattered around the listener
local 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" })

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:

-- Server
local 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)

assetIds() feeds Preload so first plays never hitch on download:

Preload.run(Kernel, { Banks = { Audio } }):await()
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)
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
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).

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