AudioEngine - An easy-to-use adaptive audio system for Roblox

Hi everyone,

I have been working on AudioEngine, an open-source audio runtime for Roblox designed to make music, ambience, SFX, transitions, and adaptive audio easier to build without turning your game into a pile of Sound:Play() calls and special cases.

The main goal is ease of use first, with more advanced systems available when you need them.

You can start with a few lines of code, or gradually move into synchronized stems, adaptive music, ambience rules, bus control, replication, diagnostics, and other systems without changing to a completely different API.

GitHub: GitHub - gmoddev/AudioEngine: Typed declarative Roblox audio engine for adaptive music, ambience, mixing, and diagnostics. Β· GitHub
Playable Roblox demo: AudioEngine | Play on Roblox

The Roblox place currently includes an adaptive music example you can play with directly. More examples will be added there over time.

You can start this small

A major design goal was making sure AudioEngine does not require a giant configuration just to play a sound.

This is a complete valid setup:

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local AudioEngine = require(ReplicatedStorage.Modules.AudioEngine)

AudioEngine.Init({
	Tracks = {
		{
			Id = "Click",
			AssetId = "rbxassetid://1234567890",
		},
	},
})

AudioEngine.Start()
AudioEngine:Play("Click")

That is it.

You can define one track and play it.

The more advanced systems are optional. You only add them when your game actually needs them. The documented Level 0 path is intentionally just define tracks, initialize, start, and play.

Why I built it

Audio systems tend to start simple.

You have a menu song, some ambience, combat music, footsteps, and a few sound effects.

Then eventually you need:

  • combat music that reacts to intensity;
  • synchronized stems;
  • biome ambience;
  • weather-dependent audio;
  • dialogue ducking;
  • boss overrides;
  • smooth transitions;
  • voice limits;
  • server-driven audio state;
  • one-shots and stingers;
  • and some way to figure out why the wrong thing is playing.

At that point, audio logic often ends up spread between combat scripts, zone scripts, UI scripts, NPC systems, cutscenes, and random utility modules.

AudioEngine tries to keep those systems from needing to know how the final mix should work.

Your gameplay code reports what is happening.

For example:

AudioEngine:UpdateInputs({
	States = {
		InCombat = true,
		BossPresent = false,
	},
	Parameters = {
		Intensity = 0.75,
		HealthRatio = 0.4,
	},
})

AudioEngine then evaluates the configured rules and mappings and decides what should actually be playing.

Internally, defaults, mappings, rules, events, manual calls, and overrides all contribute audio intent, and the engine deterministically resolves the final desired playback state.

Easy for simple games, scalable for larger ones

I did not want AudioEngine to be one of those systems where you have to understand the entire framework before you can use one feature.

You can use it at several levels.

Basic playback

AudioEngine:Play("MainTheme")

Change a player-facing mix setting

local MusicBus = AudioEngine:GetBus("Music")

if MusicBus then
	MusicBus:SetVolume(0.65)
end

Tell the engine the player entered combat

AudioEngine:SetState("InCombat", true)

Tell it how intense combat currently is

AudioEngine:SetParameter("Intensity", 0.8)

Or update several things at once

AudioEngine:UpdateInputs({
	States = {
		InCombat = true,
		BossPresent = true,
	},
	Parameters = {
		Intensity = 0.9,
		HealthRatio = 0.35,
	},
})

That means gameplay systems can stay very small.

Combat does not need to know which stem should be at 0.72 volume.

A weather system does not need to know how the ambience bus is mixed.

A boss script does not need to manually stop every other music source.

They report state, and the audio definitions decide what that state means.

Adaptive music and synchronized stems

One of the main reasons I built AudioEngine was adaptive music.

A layer group represents several tracks sharing the same musical transport.

For example:

LayerGroups = {
	{
		Id = "Explore",
		LoopStart = 0,
		LoopEnd = 128,
		BPM = 120,

		Layers = {
			{ Id = "Base", Track = "ExploreBase", DefaultWeight = 1 },
			{ Id = "Percussion", Track = "ExplorePercussion", DefaultWeight = 0 },
			{ Id = "Harmony", Track = "ExploreHarmony", DefaultWeight = 0 },
			{ Id = "Danger", Track = "ExploreDanger", DefaultWeight = 0 },
		},
	},
}

Then you can connect a gameplay value to a layer:

Mappings = {
	{
		Id = "IntensityToPercussion",
		Parameter = "Intensity",

		InputRange = { 0, 1 },
		OutputRange = { 0, 1 },

		Curve = "SmoothStep",

		Target = {
			Type = "LayerWeight",
			Group = "Explore",
			Layer = "Percussion",
		},
	},
}

Your combat system only has to do this:

AudioEngine:SetParameter("Intensity", 0.8)

AudioEngine handles the rest.

All tracks in the group start from the same transport position. Layers that are currently inaudible remain synchronized at zero weight, so bringing percussion, harmony, danger, or another stem back into the mix does not restart it from the beginning.

The engine can also monitor drift and perform configurable synchronization correction while the music runs.

You can hear this type of system directly in the Roblox demo.

Rules can replace a lot of gameplay-side audio code

Discrete audio behavior can be configured with rules.

For example:

{
	Id = "CombatMusic",
	Priority = 200,

	Conditions = {
		State = "InCombat",
	},

	Stability = {
		EnterDelay = 0.2,
		ExitDelay = 0.5,
		MinimumActiveDuration = 2,
	},

	Actions = {
		{
			Type = "SetLayerGroupActive",
			Group = "Combat",
			Active = true,
		},
		{
			Type = "SetLayerWeight",
			Group = "Combat",
			Layer = "Drums",
			Weight = 1,
		},
	},
}

Your gameplay code does not need to know any of that.

It can simply do:

AudioEngine:SetState("InCombat", true)

Rules also support stability controls such as debounce, cooldowns, enter/exit delays, and minimum active/inactive durations, so rapidly changing game state does not necessarily produce rapidly flickering audio behavior.

Continuous parameters

Not everything needs to be a boolean.

Mappings can turn a numeric gameplay value into a continuous audio value.

For example:

Combat intensity
0.0 ─────────────────────── 1.0
 β”‚                           β”‚
 quiet                    intense
 β”‚                           β”‚
 Base      β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
 Drums     β–‘β–‘β–‘β–‘β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
 Harmony   β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
 Danger    β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ

The gameplay system can provide one understandable value such as:

AudioEngine:SetParameter("Intensity", 0.67)

and the audio configuration decides what 0.67 should actually sound like.

Mappings can currently target layer weights and bus volume.

Ambience

AudioEngine is not limited to music.

Looped tracks and state-driven rules can be used for biome ambience:

States = {
	{ Id = "InForest", Default = false },
	{ Id = "InCave", Default = false },
}

Your zone system can update both together:

AudioEngine:UpdateInputs({
	States = {
		InForest = false,
		InCave = true,
	},
})

Then AudioEngine handles the actual transition between ambience beds.

Tracks can share an exclusive group so temporary overlap does not result in multiple environment beds competing with each other.

Random ambience emitters

There is also a declarative random-emitter system for things such as:

  • birds;
  • distant gunshots;
  • branch snaps;
  • machinery;
  • wildlife;
  • city ambience;
  • intermittent weather sounds.

For example:

{
	Id = "ForestBirds",

	Sounds = {
		{ Track = "BirdCallA", Weight = 5 },
		{ Track = "BirdCallB", Weight = 3 },
		{ Track = "BranchSnap", Weight = 1 },
	},

	Interval = { 4, 12 },
	Volume = { 0.6, 1 },
	PlaybackSpeed = { 0.95, 1.05 },

	MaximumSimultaneous = 2,
	Cooldown = 3,

	Conditions = {
		State = "InForest",
	},
}

The engine handles the weighted choice, scheduling, cooldowns, concurrency, volume/speed variation, and optional deterministic seed.

Spatial placement remains project-owned, so AudioEngine does not force a particular zone, CollectionService, attachment, or world-layout system onto your game.

Events and temporary audio

Short-lived audio behavior can also be bundled into events.

For example, an objective-complete event could play a stinger and duck the music at the same time:

{
	Id = "ObjectiveComplete",
	Priority = 700,
	Duration = 3,

	Actions = {
		{
			Type = "PlayTrack",
			Track = "ObjectiveStinger",
		},
		{
			Type = "PushDuck",
			Bus = "Music",
			Gain = 0.35,
			Id = "ObjectiveStingerDuck",
		},
	},
}

Then gameplay code becomes:

AudioEngine:Trigger("ObjectiveComplete")

The temporary contributions can automatically release after the configured duration.

Release handles

The manual API follows an ownership pattern that I think makes larger systems much easier to reason about.

local CombatHandle = AudioEngine:Play("CombatPulse", {
	Id = "CombatMusic",
	Priority = 200,
})

-- Combat ends later.

CombatHandle:Release()

The important part is that Release() does not mean:

stop everything involving CombatPulse.

It means:

this particular system no longer requests control.

AudioEngine then re-evaluates whatever contribution should win next.

Tracks, compositions, layer weights, ducks, overrides, input sources, spatial adapters, and suspension all use this kind of releasable ownership.

Arbitration

Multiple parts of a game can want different things from the same audio target.

Instead of requiring every system to know about every other system, AudioEngine resolves those requests centrally.

Contributions are ordered by:

  1. Priority
  2. Order
  3. Source rank
  4. Contribution ID

Source rank is:

Override
   ↓
Manual
   ↓
Event
   ↓
Rule
   ↓
Mapping
   ↓
Default

Priority and order are evaluated before source rank.

This makes temporary things such as boss fights, cutscenes, or scripted sequences much easier to layer over ordinary audio without writing a pile of cleanup code.

Buses, ducking, and voice budgets

AudioEngine includes built-in bus names such as:

Master
β”œβ”€β”€ Music
β”œβ”€β”€ Ambience
β”œβ”€β”€ SFX
β”œβ”€β”€ Voice
└── UI

You can also create your own hierarchy.

Buses support volume control and fades, and can receive ducking contributions.

Tracks, buses, and the overall runtime can additionally use deterministic polyphony and voice-budget policies.

That includes strategies such as:

  • reject the new voice;
  • steal the oldest;
  • steal the quietest;
  • steal the lowest-priority voice.

This is useful for systems such as weapons, impacts, footsteps, or ambience where unrestricted Sound creation can otherwise get out of hand.

Diagnostics

Another thing I wanted to keep easy was debugging.

Once an audio system becomes sufficiently complicated, hearing the wrong music does not tell you why it is wrong.

AudioEngine provides:

AudioEngine:GetSnapshot()

AudioEngine:Explain("Explore/Percussion")

AudioEngine:GetRecentEvents(20)

AudioEngine:Simulate({
	States = {
		InCombat = true,
	},
	Parameters = {
		Intensity = 0.85,
	},
})

Explain() shows which contribution won and what alternatives were suppressed.

Simulate() evaluates rules, mappings, arbitration, defaults, and exclusive groups without changing live playback.

So instead of debugging audio by repeatedly listening to the game and adding random print statements, you can ask the engine why a specific target currently has the state it does.

Optional replication

AudioEngine also includes replication helpers, but you do not need them for local-only audio.

The model is intentionally narrow:

definitions and asset IDs stay trusted local code.

Only explicitly allowlisted states, parameters, and event IDs are sent across the network.

That means the server can publish something like:

InCombat = true
Intensity = 0.72

and the client-side AudioEngine can decide what that means musically.

You do not need to replicate the entire desired playback graph.

Works particularly well with MusicMCP

AudioEngine can also be used alongside MusicMCP, another open-source project I recently posted.

DevForum: MusicMCP - An experimental, agent-controlled music production tool
GitHub: GitHub - gmoddev/MusicMCP: Local-first structured music engine and MCP server for agent-controlled composition, rendering, analysis, and interchange Β· GitHub

MusicMCP is an experimental local composition and rendering system where an AI coding agent can edit structured musical data instead of directly generating an opaque finished waveform.

One particularly useful result for Roblox is that MusicMCP can render aligned stems.

For example:

Base.wav
Percussion.wav
Harmony.wav
Danger.wav

Those stems can then be uploaded to Roblox and plugged directly into an AudioEngine layer group.

The workflow can look like:

Describe or revise music in MusicMCP
                ↓
Render aligned master + stems
                ↓
Upload approved stems to Roblox
                ↓
Define them in AudioEngine
                ↓
Gameplay updates one or two parameters
                ↓
AudioEngine adapts the music at runtime

The two projects do not depend on one another.

You can use AudioEngine with music made in FL Studio, Ableton, Reaper, another DAW, or anywhere else.

You can also use MusicMCP without AudioEngine.

They just happen to work well together, particularly for adaptive Roblox music.

The AudioEngine demo currently includes an adaptive track that lets you try this style of system directly.

Current v0.1 status

AudioEngine is currently v0.1.

The core runtime already includes:

  • validation and linting;
  • local, replicated, and derived states/parameters;
  • rule conditions and stability;
  • continuous mappings;
  • deterministic arbitration;
  • release handles;
  • hierarchical buses;
  • voice budgets and polyphony;
  • synchronized stems and drift correction;
  • random ambience emitters;
  • asset loading/lifecycle management;
  • diagnostics and simulation;
  • optional replication helpers.

Some of the more advanced behavior is still intentionally partial.

For example, authored duck attack/release values are currently retained as metadata while the winning duck gain changes immediately, and some composition marker/failure behavior still needs broader real-audio acceptance testing.

The repository includes a support matrix documenting those boundaries so an accepted configuration field is not automatically presented as fully production-tested behavior.

What AudioEngine is not

AudioEngine is not a DAW or sound editor.

It does not create your sounds or replace Roblox audio playback.

It is the layer between your gameplay and your audio assets.

Your gameplay system decides:

What is happening?

For example:

Player entered combat
Intensity = 0.74
Health = 0.31
BossPresent = false

AudioEngine decides:

What should that mean for the mix?

That separation is the main goal of the project.

Try it

AudioEngine GitHub:

Playable AudioEngine demo:

MusicMCP DevForum post:

MusicMCP GitHub:

The Roblox place currently includes an adaptive music example, and I plan to add more examples covering other parts of AudioEngine.

If you work with adaptive music, layered soundtracks, ambience, or larger Roblox audio systems, I would especially be interested in feedback on whether the API feels simple for basic use while still covering the more complicated cases.

9 Likes

Thanks a lot! Just in time for a small game I’m working on! Cant wait to implement this system in there. Also very well written documentation!

1 Like