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.SoundFlowscripts 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 adjustSoundFlowPlayeraccordingly. - Place a SoundFlowPlayer GameObject in your scene. With Pi-Dev Utilities,
SoundFlowPlayer.instancewill 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
BasicFlowTracktracks 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.targetVolumeas 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 bySafeInvokeon 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.currentVolumeand other per-track logic here.OnCleanup(SoundFlowPlayer engine)— disposeAudioSource(s) and transient objects.OnErrored(Exception e)— optional error hook.
- Single
AudioSourceplayback for oneAudioClip. - Supports
startOffset,loop,pitch, and a per-trackvolumeScale(multiplied bystate.currentVolume). - Starts immediately or at
state.scheduledDsp; appliessource.timeifstartOffsetis set and clip is known.
- Multi-stem adaptive music: each Stem has an
AudioClipand anintensityCurve. - Global
intensitydrives 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.
OnPlaycreates/schedules one loopingAudioSourceper stem at a shared DSP start for tight sync andOnCleanupdestroys 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:
stereoModeuses 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
SequenceClipitems in order, each with an optionaldelayAfter. - Uses shared
_source(non-looping). Schedules the next clip whenAudioSettings.dspTimeapproaches_nextStartDsp. loopSequenceoptionally 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 itsplayerreference.
To start playback, you can:
- Start a one-off clip (creates a
BasicFlowTrackunder 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.stopTimeor falls back tostartTime), then ramps to zero and optionally unloads/destroys when silent. Callstrack.OnStopimmediately;OnCleanuplater 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
BasicFlowTrackby passing the appropriate parameter toPlay
Executed in Update() on SoundFlowPlayer:
- Start due schedules — any non-errored, registered track with
!isPlayingandscheduledDsp <= nowgetsactiveFadeTime = fading.startTimeandOnPlay()invoked. - Candidate set — considers tracks that are: not errored, not
exclusiveMode, andisPlaying. - Priority scan — finds the max
settings.priorityamong unmuted candidates. Muted tracks are ignored for this decision. - Weight sum — sums
settings.weightacross the audible group (top-priority, unmuted). - Target assignment & ducking
- Muted ⇒ ramp target
0usingfading.pauseTime. - Lower priority than max ⇒ duck to
0usingfading.pauseTime. - Top priority & unmuted:
- If
manualVolumeControl=false, per-tracktarget = weight / weightSum(or0if 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.currentVolumemoves towardstate.rampTargetat a rate derived fromstate.activeFadeTime(falls back tofading.startTimeif needed). - Per-track update — calls
track.UpdateFlowTrack(this)on every audible, non-exclusive track so the track can applystate.currentVolumeto its internalAudioSource(s). - Cleanup — if
state.removeIfSilentandmanualVolumeControl=falseandcurrentVolume≈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 differentweights, and let the engine normalize. - Animate
weightA from 1→0 and B from 0→1; the engine keeps the sum normalized within the audible group.
- Set ambience
prioritylower 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=trueand adjustsettings.targetVolumeyourself. The engine won't normalize it by weights and won't auto-remove while manual control is on.
- Use
AdaptiveFlowTrack; setstate.scheduledDspbeforePlay()so all stems start sample-aligned. Driveintensityto 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.