Skip to content

AudioKit

Plays registered sounds over bus SoundGroups with priority channel eviction, pooling, fades, ducking, layered music, occlusion, reverb zones and dialogue, all on one stepper that sleeps while idle.

Realm
Shared
Require
ReplicatedStorage.ChloeKernel.AudioKit
Since
v0.3.0
Source
AudioKit.luau

AudioKit is the kernel’s audio engine. It plays bank-registered sounds in 2D and 3D over one SoundGroup per bus, and handles fades, crossfades, ducking, weight-mixed music layers, loop-aware cue points, reverb zones, soundscapes and dialogue with subtitle events. One scheduler stepper drives every fade, cue and occlusion update: there is no TweenService and no per-sound Heartbeat connection. The stepper exists only while it has work, so a quiet game pays nothing. The same design makes the engine deterministic under test, because specs step time by hand.

It runs on either machine. Attach on the client for player-facing audio: per-player volumes, ducking and the device’s channel budget from DeviceBench belong there. Attach on the server for world audio that replicates as ordinary Sound instances. AudioKit.server() and audio:listen() carry server-triggered plays to each client’s kit over one state channel. CutsceneKit and VfxSuite play their sound events through an AudioKit.

attach creates one SoundGroup per bus under SoundService, named CKAudio_<Bus>. The default buses are Music, SFX, Ambient, Voice and UI. Every registered sound routes into its bus’s group, so a bus volume change or a duck moves everything on the bus at once. The group’s volume is the bus volume times the deepest active duck:

  • setBusVolume(bus, volume) sets the base, clamped to 0-2, with a 0.25 s fade by default.
  • duck(bus, scale) adds a token and returns a release function. Stacked ducks do not multiply with each other. The deepest (lowest) scale wins, and releasing it lifts the bus to the next deepest.
  • A sound config’s Duck map holds ducks for the handle’s lifetime. They are taken once the sound is live, so a play that fails to start cannot leave a bus ducked.
  • speak() ducks Music to 0.35 and SFX to 0.6 while a line plays.

Every active handle holds a channel. The budget is MaxChannels: the value you pass, otherwise the DeviceBench profile’s AudioChannels on a client (the bench runs once, so run it during the loading screen) and 32 on the server.

When the budget is full, a new play evicts the lowest-priority active handle at or below its own priority, oldest first among ties, with a 0.1 s fade. If every active handle outranks it, the play is rejected and returns nil. Priorities run 1-4 (default 2), so a priority-3 grenade never loses its bang to thirty priority-1 footsteps.

Two kinds of handle are never evicted. playMusic handles are Protected. A handle that is already fading out still counts against the budget but is skipped: without that rule, a same-frame burst would evict the same fading handle once per play and admit every newcomer past the cap. Stats counts Played, Rejected and Evicted.

With Pooled = true, a finished handle returns its Sound to a per-name pool instead of destroying it, and the next play reuses it. playAt with a Vector3 plays from a pooled invisible emitter part (anchored, no collision, query or touch) in a local workspace.CKAudioEmitters folder, and the emitter returns to its pool when the handle finishes. Spatial audio at a position has no dependency on streamed geometry; see StreamingEnabled.

Sound.Ended never fires for a looped or interrupted sound, so each handle also watches its Sound for Destroying and for leaving the DataModel. A handle whose target part is destroyed or streamed out finishes at once, releases its channel and ducks, and destroys its Sound (a destroyed instance’s Parent is locked, so it cannot go back in a pool).

One job on the kernel Scheduler at 30 Hz and Priority.Low advances fades (smoothstep eased, one fade per object and property, a new fade replacing the old one mid-flight), cue tracking and, every 0.25 s, occlusion and reverb-zone checks. It starts when something needs it and cancels itself on the first tick with nothing left:

  • a fade in flight (volume, bus, duck, crossfade, occlusion EQ),
  • a registered reverb zone, or a zone reverb still applied,
  • an active handle with cue points or occlusion.

Soundscape chirps do not keep it awake. Each soundscape keeps one scheduler deadline for its next chirp. LoopHandle is nil while the kit is idle.

Fade completions run after the whole sweep, each on its own thread, because a completion can start fades of its own (a finished handle releases its ducks). A fade that replaces a stop fade inherits the stop’s completion, so a handle whose volume is changed mid-stop still finishes. setVolume on a stopped handle does nothing, since the stop fade owns the volume until the handle finishes.

Occlusion applies to playAt with a Vector3 target, the plays that own an emitter. It adds an EqualizerSoundEffect to the sound and, every 0.25 s, fades HighGain toward -30 and MidGain toward -8 over 0.2 s, scaled by a muffle amount from 0 to 1.

true or "Ray" mode muffles fully when a ray from the listener to the emitter is blocked. The default check reuses one RaycastParams, excludes the local player’s character (a third-person camera’s ray would otherwise hit the player’s own body) and sets RespectCanCollide = true, so zones, hazards and trigger volumes do not muffle. A part with AudioCanCollide = false passes sound the way it does in the engine’s audio simulation: the ray casts again past it, up to 4 casts, and a stack deeper than that reads as open.

"Path" mode muffles by the detour ratio of a route around the geometry. A route as long as the straight line is clear (0), one twice the straight line or longer is fully muffled (1), and no route at all is fully muffled. It needs Occlusion.Pathfinding and Occlusion.Grid at attach and falls back to ray mode without them. The route source is any object with find({ Method = "AStar", Grid, Start, Goal }) -> (ok, waypoints).

reverbZone(part, reverbType, priority?) applies a reverb while the listener stands inside the part’s box. Overlaps resolve by the highest priority. AudioKit writes SoundService.AmbientReverb only while a zone applies. It captures the current value when the first zone applies and restores it when the listener leaves every zone, so game code owns AmbientReverb everywhere else. destroy() restores it only if a zone applied.

playLayers creates one looped stem per layer on the bus (default Music). Stems start together, at one TimePosition, once every one of them has loaded: a stem started while another still loads would play out of phase for the whole loop. A live kit waits at most LoadTimeout seconds (default 10), then warns with the missing layers and starts. A late stem joins when it loads, and resync() realigns it. Weights mix from 0 to 1 per layer, so several layers can be audible at once.

speak queues lines and plays one at a time through each line’s configured bus (register dialogue on Voice). While a line plays, Music and SFX are ducked, and a line with text publishes Audio.Subtitle and then Audio.SubtitleEnded. A line whose name is not registered logs a warning and the queue moves to the next line. The check happens before the ducks are taken, so a bad name never leaves the buses ducked. A line rejected by channel pressure releases its ducks and moves on the same way.

src/Client/Game/Controllers/AudioController.luau
--!strict
-- AudioController: the client audio kit, its bank and the settings-bound volumes.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local AudioKit = require(ReplicatedStorage.ChloeKernel.AudioKit)
local NetClient = require(ReplicatedStorage.ChloeKernel.Net.Client)
local SettingsClient = require(ReplicatedStorage.ChloeKernel.Net.SettingsClient)
local AudioController = {
Name = "AudioController",
}
function AudioController.init(self: any, kernel: any)
self.Kernel = kernel
self.Audio = AudioKit.attach(kernel)
-- Replace the ids with your own uploads
self.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
})
end
function AudioController.start(self: any)
local Net = NetClient.new()
self.Prefs = SettingsClient.new(Net)
self.Prefs:fetch()
-- Applies stored volumes now and follows confirmed changes
self.UnbindSettings = self.Audio:bindSettings(self.Prefs, { Music = "MusicVolume", SFX = "SfxVolume" })
-- Plays the server sends through AudioKit.server
self.Listener = self.Audio:listen(Net)
end
function AudioController.stop(self: any)
if self.UnbindSettings then
self.UnbindSettings()
end
if self.Listener then
self.Listener:Disconnect()
end
self.Audio:destroy()
end
return AudioController
Audio:play("Click") -- 2D
local Handle = Audio:play("Shot", { FadeIn = 0.05, Volume = 0.9 })
if Handle then -- channel pressure can reject a play
Handle:setVolume(0.5, 0.3)
Handle:stop(0.2)
end

playAt takes a Vector3 (pooled emitter), a BasePart or an Attachment. Occlusion needs the Vector3 form:

-- Muffled while a wall stands between the camera and the muzzle
Audio:playAt("Shot", MuzzlePosition, { Occlusion = true })
-- Plays from the part; follows it, no occlusion
Audio:playAt("Siren", workspace.Tower.Horn)

Path mode muffles by the detour around the geometry, so the next corridor sounds far away instead of clear through the wall. It takes a route source at attach:

local Audio = AudioKit.attach(kernel, {
Occlusion = {
Pathfinding = RouteSource, -- find({ Method = "AStar", Grid, Start, Goal }) -> (ok, waypoints)
Grid = NavGrid,
},
})
Audio:playAt("Shot", MuzzlePosition, { Occlusion = "Path" })

On the server, Alert forwards a spatial play to NPCKit emitSound, so the sounds players make are information NPCs can react to:

local WorldAudio = AudioKit.attach(kernel, { Npcs = Npcs })
WorldAudio:playAt("Shot", MuzzlePosition, { Alert = { Range = 70 } })
-- One music track at a time; a new one crossfades from the current
Audio:playMusic("CaveTheme", { Crossfade = 3 })
-- Stems start together once all have loaded, then mix by weight
local Layers = Audio:playLayers({
Calm = "rbxassetid://10",
Combat = "rbxassetid://11", -- registered names work too
}, { StartLayer = "Calm", Volume = 0.8, LoadTimeout = 8 })
Layers:setWeights({ Calm = 0.3, Combat = 1 }, 2) -- battle starts: 2 s blend
Layers:resync("Calm") -- snap every stem to the Calm stem's position
Layers:stop(1.5)

Cue points publish loop-aware Bus events, so visuals sync to the music without polling TimePosition:

self.Kernel.Bus:subscribe("Audio.Cue", function(_topic, soundName, cueName, handle)
if soundName == "CaveTheme" and cueName == "Drop" then
Lights:pulse()
end
end)
Audio:speak("GuideLine1") -- the config's Subtitle text
Audio:speak("GuideLine2", { Text = "Storm's coming.", Duration = 3 })
self.Kernel.Bus:subscribe("Audio.Subtitle", function(_topic, text, durationSeconds)
SubtitleLabel.Text = text -- durationSeconds is 0 unless the caller passed one
SubtitleLabel.Visible = true
end)
self.Kernel.Bus:subscribe("Audio.SubtitleEnded", function()
SubtitleLabel.Visible = false
end)
-- Cave reverb while the camera is inside the part; the previous AmbientReverb returns on exit
local RemoveZone = Audio:reverbZone(workspace.CaveVolume, Enum.ReverbType.Cave, 2)
-- A looped bed plus random one-shot chirps scattered around the listener
local Forest = Audio:soundscape({
Bed = "WindLoop",
Chirps = { "Bird1", "Bird2" },
Interval = { 4, 12 }, -- seconds between chirps
Radius = 30, -- scatter radius around the listener
})
Forest:stop(2)
RemoveZone()

The server does not play player-facing sounds itself. It sends play commands over one state channel (CKAudio, schema { "String", "String", "Any" }) and each listening client plays them through its own buses, volumes and channel budget:

src/Server/Game/Services/AnnouncerService.luau
--!strict
-- AnnouncerService: round stingers and blasts, played on every client's AudioKit.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local AudioKit = require(ReplicatedStorage.ChloeKernel.AudioKit)
local AnnouncerService = {
Name = "AnnouncerService",
}
function AnnouncerService.init(self: any, kernel: any)
self.Kernel = kernel
self.Audio = AudioKit.server(kernel) -- defines CKAudio before start
end
function AnnouncerService.start(self: any)
self.Connections = {
self.Kernel.Bus:subscribe("Round.Started", function()
self.Audio:broadcast("RoundStart")
end),
self.Kernel.Bus:subscribe("Round.Won", function(_topic, winner: Player)
self.Audio:playFor(winner, "Victory")
end),
self.Kernel.Bus:subscribe("Blast.Detonated", function(_topic, position: Vector3)
self.Audio:broadcastAt("Explosion", position, { Occlusion = true })
end),
}
end
function AnnouncerService.stop(self: any)
for _, Connection in self.Connections or {} do
Connection:disconnect()
end
end
return AnnouncerService

The topics here are the game’s own. Each client’s audio:listen(net) receives the command and calls play or playAt with the options table, so a client-side kit must have the same names registered.

assetIds() feeds Preload so first plays do not wait on a download:

Preload.run(kernel, { Banks = { Audio } }):await()
Member Description
AudioKit.attach(kernel, options?) -> AudioKit Creates the bus groups. The stepper starts on first need. On a client, warns once if SoundService.RespectFilteringEnabled is off.
AudioKit.server(kernel) -> Remote Server helper over the 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 and layer stems.
Npcs nil NPCKit instance for Alert.
Occlusion.GetListener Current camera position () -> Vector3 listener for occlusion and reverb zones.
Occlusion.IsBlocked Cached raycast described above (from, to) -> boolean for ray mode.
Occlusion.Pathfinding, Occlusion.Grid nil Route source and grid for "Path" mode.
SkipLoop false Never start the stepper; specs call _step(dt).
Member Description
Id: string Asset id. Required.
Bus: string? Default "SFX". register errors on an unknown bus.
Volume: number? Base volume. Default 1.
Speed: number? Playback speed. Default 1.
Looped: boolean? Default false.
Priority: number? 1-4, higher survives eviction. Default 2.
Pooled: boolean? Reuse the Sound instance across plays.
RollOffMin: number?, RollOffMax: number? 3D rolloff distances in studs. Default 10 and 100.
RollOffMode: Enum.RollOffMode? Default InverseTapered.
Cues: { [string]: number }? Cue name to seconds. Fires Audio.Cue, re-arming when a loop wraps.
Subtitle: string? Default text for speak().
Duck: { [string]: number }? Bus to scale, held while this sound plays.
Member Description
audio:register(name, config) Adds one config. Errors on an unknown bus.
audio:registerBank(bank) Registers { [name] = SoundConfig }.
audio:assetIds() -> { string } Every registered id, for Preload.
audio:play(name, options?) -> Handle? 2D play into Parent. nil when channel pressure rejects it. Errors on an unregistered name.
audio:playAt(name, target, options?) -> Handle? 3D play at a Vector3 (pooled emitter), a BasePart or an Attachment.
audio:stopAll(fadeSeconds?) Stops every active handle.
audio:playMusic(name, options?) -> Handle? options = { Crossfade?, Volume? }. One music track at a time, looped, crossfade default 2 s. The handle is never evicted.
audio:playLayers(layers, options?) -> LayerHandle layers maps layer name to a registered name or raw id. options = { Bus?, Volume?, StartLayer?, LoadTimeout? }; bus default "Music", timeout default 10 s.
audio:speak(name, options?) options = { Text?, Duration?, Volume? }. Queues a line; an unregistered name warns and is skipped.
audio:activeCount() -> number Live handles.
audio:listen(netClient) -> connection Client side of AudioKit.server.
audio:destroy() Finishes every handle at once (crossfading music included), destroys layer stems, groups, pools and emitters, and restores AmbientReverb if a zone applied.

PlayOptions are Volume?, Speed?, FadeIn?, Looped? and Priority?, plus two spatial options: Occlusion? (true or "Ray" for line of sight, "Path" for route length; Vector3 targets only) and Alert? ({ Range?, Loudness?, Source? }, passed to NPCKit emitSound when Npcs is set).

Member Description
handle:stop(fadeSeconds?) Fades out or cuts, then releases the channel. Pooled sounds and emitters return to their pools.
handle:setVolume(volume, fadeSeconds?) Fades the sound volume. Does nothing once the handle is stopping.
handle:setSpeed(speed) Sets PlaybackSpeed at once.
handle:isActive() -> boolean Whether the handle still holds a channel.
layers:setWeights(weights, fadeSeconds?) { [layer] = 0-1 }, faded over fadeSeconds (default 1.5). Omitted layers fade to 0.
layers:resync(toLayer?) Sets every stem’s TimePosition to the reference layer’s.
layers:stop(fadeSeconds?) Fades out (default 1 s) and destroys the stems.
scape:stop(fadeSeconds?) Stops the bed (default fade 1.5 s) and cancels the chirp deadline.
Member Description
audio:setBusVolume(bus, volume, fadeSeconds?) Base volume clamped to 0-2, faded (default 0.25 s). Errors on an unknown bus.
audio:getBusVolume(bus) -> number The base volume, without ducks.
audio:duck(bus, scale, fadeSeconds?) -> release Token duck. The deepest active duck wins.
audio:bindSettings(settingsClient, map) -> unbind { [bus] = settingsKey }. Applies stored volumes now and follows confirmed changes (Player settings).
audio:reverbZone(part, reverbType, priority?) -> remove Reverb while the listener is inside the part. Highest priority wins; default 1.
audio:soundscape(options) -> Scape { Bed?, Chirps?, Interval?, Radius? }. Interval default { 4, 12 } s, radius default 30 studs.
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 text started. durationSeconds is 0 unless the caller passed one.
Audio.SubtitleEnded none A line that published Audio.Subtitle finished.

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.