A modern, explicit Unity audio runtime with global weight, duck, and mute logic.
SoundFlow is a modular audio management system for Unity that lets you layer, sequence, and adapt audio playback in real time.
It's designed for precision control with adaptive music in mind. Supports live mixing, song blending and smooth transitions. Runs on top of Unity's AudioMixer.
SoundFlow is not a general-purpose runtime sound system like fmod or wwise, it does not support one-shot SFX and no events. It's is dedicated to music playback, optimized for music transitions and mixing stems.
SoundFlow cooperates very well with PiDevUtils SoundBankSet for SFX, also available here on GitHub. In fact, SoundFlow builds on top and replaces PiDevUtils' MediaPlayer, but can ve used standalone.
-
Global Audio Logic
- Weight-based volume balancing
- Automatic ducking by priority
- Per track mute control
-
Multiple Track Types
- BasicFlowTrack – loops a single AudioClip
- AdaptiveFlowTrack – multi-stem adaptive mixing based on single intensity value
- LayeredFlowTrack – mixes multiple stems with per stem weight and pan.
-
Runtime Track Management
- Start, stop, and swap between music tracks on the fly
- Smoothly fade between tracks during gameplay by weight and priority
- Schedule precise playback times using DSP timing
- Add the
PiDev.SoundFlow
scripts to your Unity project. - (Optional but recommended) Install Pi-Dev Utilities — this provides the
Singleton<T>
base used by SoundFlowPlayer. Without it, you must implement your own singleton pattern or adjustSoundFlowPlayer
accordingly. - Place a SoundFlowPlayer GameObject in your scene. With Pi-Dev Utilities,
SoundFlowPlayer.instance
will be globally available for direct access from scripts. - Attach and configure
FlowTrackBase
-derived components (Basic, Adaptive, Layered, Sequence) on GameObjects to set up your music playback, layering, or adaptive mixing. - you can also start
BasicFlowTrack
tracks by using thePlay()
method that acceptsAudioClip
.
var player = GetComponent<SoundFlowPlayer>();
// Play a basic sound
var track = player.Play(myClip, volumeScale: 1f, name: "SFX");
// Stop by name with fade
player.StopWithName("SFX", fadeTime: 0.5f);
// Adaptive track example
// You are encouraged to design AdaptiveFlowTrack inside Unity Editor
var adaptive = gameObject.AddComponent<AdaptiveFlowTrack>();
adaptive.stems.Add(new AdaptiveFlowTrack.Stem { clip = myMusicStem });
adaptive.intensity = 0.8f;
SoundFlowPlayer.instance.Play(adaptive);
Simple playback of a single AudioClip
with pitch, looping, and volume scale.
Multi-stem adaptive mixing driven by single intensity
parameter with per-stem curves and fade settings.
Layered multi-track playback with Manual or Weighted mixing modes and per-layer panning.
Plays a list of AudioClips in order, with optional looping and delay between clips.
Add a SoundFlowDebugger
UI Text element to see all active tracks, their volumes, weights, and priorities in real time.
Below is developer-facing documentation for SoundFlowPlayer and the core track system.
- SoundFlowPlayer — central mixer/controller. Holds all active tracks and runs the global weight/duck/mute algorithm every frame.
- FlowTrackBase — abstract base for all tracks. Implements common state, fading and settings.
public SoundFlowPlayer player;
public string trackName;
public Fading fading; // start/stop/pause/resume times (seconds)
public State state; // runtime envelope & flags
public Settings settings; // mixing directives
public bool registerOnStart = true;
On Start()
, if registerOnStart && player != null
, the track auto-registers. Override if you need manual control.
mute
— force the track to silence (ramped usingfading.pauseTime
).priority
— higher wins; lower priorities duck to 0 while a higher one is active.weight
— relative share within the top-priority audible group whenmanualVolumeControl=false
.manualVolumeControl
— if true, the engine will not normalize by weight; it uses yoursettings.targetVolume
as the per-track target. Cleanup-on-silence is also skipped while manual control is true.targetVolume
— explicit target whenmanualVolumeControl=true
. Ignored otherwise; the engine computes a normalized target from weights.exclusiveMode
— excluded from the global mix/normalization pass (the engine skips them in candidate set and inUpdateFlowTrack
). Useful for tracks you control entirely yourself.
startTime
,stopTime
— default fade-in/out durations for engine starts/stops.pauseTime
,resumeTime
— used for ducking/muting behaviors.
currentVolume
— the engine-computed envelope (0..1).activeFadeTime
— the time constant currently used to ramp towardrampTarget
.removeIfSilent
— when true andmanualVolumeControl=false
, the engine will auto-remove when the track reaches silence.hasError
— set bySafeInvoke
on exceptions.isPlaying
— engine considers the track in the active set.scheduledDsp
— absolute DSP time for next start; NaN means “now”.rampTarget
— internal per-frame target after priority/weight logic.
OnPlay(SoundFlowPlayer engine)
— create/configureAudioSource
(s), schedule start if needed.OnStop(SoundFlowPlayer engine)
— mark local intent to stop; typically used to set “ramp down” flags.UpdateFlowTrack(SoundFlowPlayer engine)
— called every frame while the track participates in the mix; applystate.currentVolume
and other per-track logic here.OnCleanup(SoundFlowPlayer engine)
— disposeAudioSource
(s) and transient objects.OnErrored(Exception e)
— optional error hook.
- Single
AudioSource
playback for oneAudioClip
. - Supports
startOffset
,loop
,pitch
, and a per-trackvolumeScale
(multiplied bystate.currentVolume
). - Starts immediately or at
state.scheduledDsp
; appliessource.time
ifstartOffset
is set and clip is known.
- Multi-stem adaptive music: each Stem has an
AudioClip
and anintensityCurve
. - Global
intensity
drives per-stemtargetVolume = intensityCurve(intensity) * state.currentVolume
, with independent in/out fade times (mixFade
). - Optional self-governed mixing:
setOwnTargetVolume
⇒ controls track's own target volume and assigns manual volume control.setOwnWeight
⇒ controls track's own weight value based on the intensity value.setOwnPriority
⇒ controls track's priority value based on the intensity value.
OnPlay
creates/schedules one loopingAudioSource
per stem at a shared DSP start for tight sync andOnCleanup
destroys created sources.
- Multiple stems with either ManualVolume or Weighted mixing:
- ManualVolume — per-layer magnitude from
weight
(mono) orleftWeight/rightWeight
(stereo). No normalization. - Weighted — per-channel normalization: compute each stem's left/right contribution, normalize within L/R sums, and use the dominant share as the stem's normalized magnitude.
- ManualVolume — per-layer magnitude from
- Per-stem panning:
stereoMode
uses explicit L/R weights; mono uses pan derived from those shares. - Independent per-stem fades and pan ramps via
mixFade
. - Convenience setters
SetLayerWeight(...)
for name or index.
This track type is currently not fully implemented.
- Plays a list of
SequenceClip
items in order, each with an optionaldelayAfter
. - Uses shared
_source
(non-looping). Schedules the next clip whenAudioSettings.dspTime
approaches_nextStartDsp
. loopSequence
optionally wraps to the first clip. Volume followsstate.currentVolume
.
Register(FlowTrackBase track)
— attaches the track to the player (ignores tracks withstate.hasError
). Setstrack.player
, clearsisPlaying
, and adds to the internal list.Unregister(FlowTrackBase track)
— removes the track and clears itsplayer
reference.
To start playback, you can:
- Start a one-off clip (creates a
BasicFlowTrack
under the player):If an equivalent non-erroredvar t = SoundFlowPlayer.instance.Play(myClip, volumeScale: 1f, name: "Music", dspTime: null, startOffset: 0f, loop: true, pitch: 1f);
BasicFlowTrack
(same clip + name) already exists, the player reuses it. - Start an existing track instance:
var layered = gameObject.AddComponent<LayeredFlowTrack>(); SoundFlowPlayer.instance.Play(layered); // schedules and sets isPlaying=true
Play(track)
setsstate.scheduledDsp
(if provided), flipsisPlaying=true
, and invokestrack.OnPlay
.
Stop(track, fadeTime = NaN, unload = true)
— computes a final fade (defaults totrack.fading.stopTime
or falls back tostartTime
), then ramps to zero and optionally unloads/destroys when silent. Callstrack.OnStop
immediately;OnCleanup
later when removed.StopWithName(name, fadeTime = 0, unload = true)
— stop all tracks with a giventrackName
.StopAll(fadeTime = 0, unload = true)
— stop every track (per-track fallbacks applied).
-
A track can be scheduled to start at an absolute DSP time via
state.scheduledDsp
. The player checks each frame and starts tracks whosescheduledDsp <= AudioSettings.dspTime
; ifNaN
, they're considered ready immediately. -
You can schedule a
BasicFlowTrack
by passing the appropriate parameter toPlay
Executed in Update()
on SoundFlowPlayer:
- Start due schedules — any non-errored, registered track with
!isPlaying
andscheduledDsp <= now
getsactiveFadeTime = fading.startTime
andOnPlay()
invoked. - Candidate set — considers tracks that are: not errored, not
exclusiveMode
, andisPlaying
. - Priority scan — finds the max
settings.priority
among unmuted candidates. Muted tracks are ignored for this decision. - Weight sum — sums
settings.weight
across the audible group (top-priority, unmuted). - Target assignment & ducking
- Muted ⇒ ramp target
0
usingfading.pauseTime
. - Lower priority than max ⇒ duck to
0
usingfading.pauseTime
. - Top priority & unmuted:
- If
manualVolumeControl=false
, per-tracktarget = weight / weightSum
(or0
if sum is 0). - If
manualVolumeControl=true
, per-track target stays atsettings.targetVolume
.
This per-track “ramp target” is stored instate.rampTarget
.
- If
- Muted ⇒ ramp target
- Ramp envelope —
state.currentVolume
moves towardstate.rampTarget
at a rate derived fromstate.activeFadeTime
(falls back tofading.startTime
if needed). - Per-track update — calls
track.UpdateFlowTrack(this)
on every audible, non-exclusive track so the track can applystate.currentVolume
to its internalAudioSource
(s). - Cleanup — if
state.removeIfSilent
andmanualVolumeControl=false
andcurrentVolume≈0
, the track is stopped, removed,OnCleanup()
is called, and the GameObject is destroyed (only if it's a child of the player).
SafeInvoke
wraps all callbacks; any exception marks the track hasError=true
, calls OnErrored(e)
, and logs in the editor.
- SoundFlowDebugger (attach to a
UnityEngine.UI.Text
) prints one line per track with: name, clip info, current → target volume, weight, priority, and flags (M mute, U manualVolumeControl, X exclusiveMode, R removeIfSilent, ERROR). Useful to verify the priority/weight logic at runtime.
- Give both tracks the same
priority
, set differentweight
s, and let the engine normalize. - Animate
weight
A from 1→0 and B from 0→1; the engine keeps the sum normalized within the audible group.
- Set ambience
priority
lower than music. When music is active, ambience ramps to 0 usingfading.pauseTime
; when music stops, ambience resumes.
- For a track you want to drive directly, set
settings.manualVolumeControl=true
and adjustsettings.targetVolume
yourself. The engine won't normalize it by weights and won't auto-remove while manual control is on.
- Use
AdaptiveFlowTrack
; setstate.scheduledDsp
beforePlay()
so all stems start sample-aligned. Driveintensity
to morph the mix.
Any exception in OnPlay/OnStop/UpdateFlowTrack/OnCleanup
marks the track hasError=true
, attempts OnErrored(e)
, and logs (Editor). Errored tracks are skipped by the engine.
// Create a layered music controller at runtime
var go = new GameObject("Music");
var layered = go.AddComponent<LayeredFlowTrack>();
layered.trackName = "MainMusic";
layered.stems.Add(new LayeredFlowTrack.Stem { name = "Drums", clip = drums });
layered.stems.Add(new LayeredFlowTrack.Stem { name = "Bass", clip = bass });
layered.stems.Add(new LayeredFlowTrack.Stem { name = "Pads", clip = pads });
// Start at equal share
layered.mixingMode = LayeredFlowTrack.MixingMode.Weighted;
layered.settings.priority = 10;
SoundFlowPlayer.instance.Play(layered);
// During gameplay: bring Pads forward
layered.SetLayerWeight("Pads", 1.0f);
layered.SetLayerWeight("Drums", 0.6f);
layered.SetLayerWeight("Bass", 0.8f);
// Stop gracefully
SoundFlowPlayer.instance.Stop(layered, fadeTime: 1.5f);
(Behavior: the player normalizes by per-channel shares at top priority and ramps each stem's volume/pan with mixFade
.)
If you need this split into separate README sections (Overview, Usage, API Reference) or formatted for doc tooling, say and I'll output it with the structure you prefer.
MIT License – free to use, modify, and distribute.