Ripple Tagged Sound Manager for LÖVE 2D: One Fade, Every Source
Group SFX with ripple tags ('music', 'ambient') so one fadeDuration on the tag fades every source at once, replacing per-source volume bookkeeping in love.audio.


Ripple Tagged Sound Manager for LÖVE 2D: One Fade, Every Source
My last jam had a scene that needed a two-second cross-fade between the forest ambience and a boss theme. I'd wired audio the way I always did: a sounds table, a playingMusic variable, three booleans for ambience, and a love.update block poking :setVolume on each source. That cross-fade took me forty minutes and left the code so tangled I stopped adding audio for the rest of the jam. This lesson is the refactor I wish I'd known before that jam started.
Ripple is a small Lua library that fixes the tangle by borrowing an idea from CSS: tag your sources, then act on tags. Set music.volume = 0 with a fadeDuration and every source tagged music fades. Add a new track tagged music a week later, and it inherits the group behavior for free. No bookkeeping table. No manual iteration.
In this article I'll walk you through building a SoundManager module around ripple that replaces the usual love.audio boilerplate. We'll wire tag hierarchies (master → music → boss_theme), handle scene-scoped fades, and compare the resulting code to a naive per-source fade loop. By the end you'll have a drop-in file you can paste into any LÖVE 2D project.
Why per-source fading breaks down
Why does a sounds table plus a per-frame fade loop feel clean on day one and unmaintainable by week three? The pattern breaks down for three specific reasons, and this section walks through all three. You keep a table of currently-playing sources, and each frame you nudge them toward a target volume:
-- naive per-source fader — the pattern this article replaces
local fades = {}
function fadeSource(source, target, duration)
fades[source] = {
from = source:getVolume(),
to = target,
elapsed = 0,
duration = duration,
}
end
function love.update(dt)
for source, f in pairs(fades) do
f.elapsed = f.elapsed + dt
local t = math.min(f.elapsed / f.duration, 1)
source:setVolume(f.from + (f.to - f.from) * t)
if t >= 1 then fades[source] = nil end
end
end
Three things go wrong as the project grows.
First, groups don't compose. When the player pauses and you want to duck every SFX to 30% while music stays at 80%, you write another loop that knows which sources belong to which category. That knowledge lives in a comment somewhere, and comments drift.
Second, hierarchy is impossible. A boss theme is music, but it's also its own thing you might want to fade independently. Nesting a boss-theme fade inside a music fade using raw :setVolume calls means multiplying two independent curves per frame. The result reads like a physics simulation.
Third, cleanup is manual. Sources that stop playing still sit in your table until you remember to remove them. Miss one, and next scene's fade animates a source that isn't producing sound.
Ripple's tag model treats groups as first-class. A tag has a volume, a fadeDuration, and any number of sources subscribed to it. Change the tag's volume, and ripple recomputes each source's effective volume as the product of every tag it belongs to. The code below is the whole idea in nine lines.
The minimum viable SoundManager
The first time I refactored onto this pattern I deleted 140 lines and spent five minutes convinced I'd broken the audio. Every test still passed. The replacement is a small module that owns ripple, keeps a shallow catalog of loaded sources, and exposes three verbs: play, set, and fade. That's enough surface area to replace 90% of ad-hoc audio code.
-- sound.lua
local ripple = require("libs.ripple")
local SoundManager = {}
SoundManager.__index = SoundManager
function SoundManager.new()
local self = setmetatable({}, SoundManager)
self.sources = {}
self.tags = {
master = ripple.newTag(),
music = ripple.newTag(),
sfx = ripple.newTag(),
ambient = ripple.newTag(),
}
self.tags.music.tags = { self.tags.master }
self.tags.sfx.tags = { self.tags.master }
self.tags.ambient.tags = { self.tags.master }
return self
end
return SoundManager
Each tag's tags field lists its parents. Music, SFX, and ambient all point at master, so a single self.tags.master.volume = 0.5 cuts everything by half. This is where ripple earns its name: the change ripples down.
Register sources under one or more tags:
function SoundManager:load(name, path, tagList)
local source = ripple.newSource(love.audio.newSource(path, "static"))
source.tags = {}
for _, tagName in ipairs(tagList) do
table.insert(source.tags, self.tags[tagName])
end
self.sources[name] = source
return source
end
ripple.newSource wraps a plain love.audio.Source and hooks its volume into the tag graph. :play() and :stop() still work exactly like they do in love.audio. Ripple only replaces the volume math.
Now the play verb becomes trivial:
function SoundManager:play(name)
local source = self.sources[name]
if source then source:play() end
end
And a fade acts on the tag, not the source:
function SoundManager:fade(tagName, target, duration)
local tag = self.tags[tagName]
if tag then
tag.fadeDuration = duration
tag.volume = target
end
end
That's the whole substitution. Where you used to write fadeSource(bossTheme, 0, 2), you write sound:fade("music", 0, 2) and every music source fades together, including tracks loaded ten seconds ago that the fade code has never seen.
Wiring it into love.load and love.update
What's the least glue code you can write to keep ripple alive between frames? One :update(dt) fanout inside a single method, and the game entry point that calls it stays boring on purpose. Wrap that in the manager so the caller never touches ripple directly:
function SoundManager:update(dt)
for _, tag in pairs(self.tags) do
tag:update(dt)
end
end
The game entry point stays boring, which is the point:
-- main.lua
local SoundManager = require("sound")
local sound
function love.load()
sound = SoundManager.new()
sound:load("bg_forest", "assets/audio/forest.ogg", {"ambient"})
sound:load("boss_theme", "assets/audio/boss.ogg", {"music"})
sound:load("hit", "assets/audio/hit.wav", {"sfx"})
sound:load("footstep", "assets/audio/footstep.wav", {"sfx"})
sound:play("bg_forest")
end
function love.update(dt)
sound:update(dt)
end
Compare that to the naive approach's love.update, which iterated a table of live fades and manually clamped per-source volumes. This version doesn't know or care which sources are fading. Ripple handles interpolation on the tag; sources read their effective volume on the next audio buffer refresh.
Scene transitions: the payoff scenario
Here's where the tag model pays for itself. Fade out ambient, fade in boss music, keep SFX untouched:
function enterBossFight()
sound:fade("ambient", 0, 1.5) -- forest ambience dies
sound:play("boss_theme")
sound:fade("music", 1.0, 1.5) -- boss theme rises from 0 to full
end
Three lines. If you add a second ambient track next week (say river_loop tagged ambient), the boss-fight code doesn't change. The new track fades automatically because it's tagged. That's the "add a new track and it inherits the group behavior for free" claim from the intro, and it's the most concrete argument for structuring audio around tags rather than sources.
The same shape covers pause menus. Duck everything except the UI clicks:
function pauseGame()
sound.tags.music.fadeDuration = 0.3
sound.tags.music.volume = 0.2
sound.tags.ambient.fadeDuration = 0.3
sound.tags.ambient.volume = 0.2
sound.tags.sfx.fadeDuration = 0.3
sound.tags.sfx.volume = 0.0
end
function resumeGame()
sound.tags.music.volume = 1.0
sound.tags.ambient.volume = 1.0
sound.tags.sfx.volume = 1.0
end
The fadeDuration set on entry stays applied to the resume, so both directions animate. If you want asymmetric timing (slow duck, snap back), reassign fadeDuration before setting volume.
Ripple tags versus a bespoke bus mixer
At some point every serious audio system in a game engine becomes a bus mixer: named groups, per-group volume, parent-child routing. Unity has AudioMixer. Godot has AudioBus. FMOD has, well, all of FMOD. Rolling one yourself in Lua takes hours and always has bugs around fade cancellation and cross-tag interaction.
Ripple is 200 lines of Lua and gives you 80% of that mixer surface. The trade-off, put concretely:
- Ripple gives you tag hierarchy, per-tag fadeDuration, automatic volume compounding, and ~1KB of source. Missing: DSP effects, per-source pitch groups, HRTF spatialization. Ships as a single file you drop into
libs/. - A hand-rolled bus mixer gets you any feature you want, but you write and maintain it. Expect 400-600 lines and 3× the bug budget.
- A middleware SDK like FMOD or Wwise gives you production-grade routing, effects, and real-time authoring. Overkill for a 48-hour jam; sensible for a 12-month project.
For solo indies shipping a small-to-medium LÖVE game, ripple lands in the sweet spot. You get named groups and cross-fading (the two features that actually matter for scene transitions and pause menus) without carrying a middleware runtime.
The measurable cost: ripple's per-frame update is O(tags), not O(sources). With 4 top-level tags and 40 registered sources, each frame does 4 float lerps instead of 40. That's an 80% cut in fade math even before considering the code you no longer have to read.
Handling one-shot SFX cleanup
Long-lived music sources are easy: load them once, play them forever, fade the tag. One-shot SFX (a hit sound triggered fifty times a second in a bullet-hell) are trickier because you don't want to permanently allocate a source per pool slot.
The idiomatic pattern is to :clone() before playing so overlapping shots don't cut each other off, but ripple wraps newSource, not clone. The workaround is a tiny helper:
function SoundManager:playOneShot(name)
local source = self.sources[name]
if not source then return end
local clone = source:clone() -- ripple exposes clone() as of v1.0
clone:play()
-- clone inherits the parent source's tags automatically
end
The clone inherits the tag list, so it responds to sfx fades just like the original. When the sample finishes, ripple has no persistent reference and Lua's GC reclaims it. No manual cleanup, no orphaned sources sitting at 0 volume in a table you forgot about.
Common pitfalls when adopting the pattern
Three things bit me the first time I refactored a project onto ripple.
Volumes multiply, they don't add. If master.volume = 0.5 and music.volume = 0.5, a music source plays at 0.25, not 1.0. That's correct behavior (tags model gain stages, not additive layers), but it surprises anyone expecting an equalizer-style mixer. Design your default tag volumes around this: keep master at 1.0 unless you specifically want a global attenuation, and treat sub-tags as the primary knob.
fadeDuration is sticky. Setting music.fadeDuration = 2 then later assigning music.volume = 0.7 uses the old 2-second duration. If you want an instantaneous change, set fadeDuration = 0 first. This is why the pause example above reassigns fadeDuration on every state transition.
Loop-tagged sources need explicit :setLooping(true). Ripple wraps the source but doesn't override love.audio semantics. A background music file loaded with newSource(path, "static") still plays once and stops unless you tell it to loop. Do this at load time, before the source becomes part of self.sources.
Fix these three, and the manager behaves like a static bus mixer for the rest of the project's life.
What I keep versus what I throw out
After shipping two jam games and a longer prototype on this pattern, my SoundManager module has stabilized at about 60 lines. What I always keep:
master,music,sfx,ambientas the default four tags. Nothing more until a real project need justifies it.- The
load(name, path, tagList)signature with an explicit list rather than variadic args. Explicitness reads better in call sites. fade(tag, target, duration)as a verb, notset(tag).volume = ...chaining. The verb version is trivially greppable.
What I throw out every time: a stopAll() method that iterates sources. Ripple's master.volume = 0 with a short fade is the correct spelling for "silence everything", and it composes with fade-in on scene enter. A hard stop-all is almost always a symptom of missing a fade somewhere.
The module doesn't need to be more than 60 lines. Every feature past that (dynamic tag creation, priority-based ducking, DSP hooks) is a signal that ripple isn't the right abstraction anymore and you should look at a middleware SDK.
Dropping it into your project
Grab ripple from GitHub (single file), stash it under libs/ripple.lua, and paste the SoundManager module above into sound.lua. Load in love.load, update in love.update, use :play and :fade from anywhere. The whole integration is under thirty lines of caller code.
Once it's in, every future audio decision becomes "which tag does this source belong to?" instead of "how do I coordinate this fade with the eleven other sources I'm tracking?". That reframe is the actual win. The library saves you code; the tag model saves you thinking.
References: