# 3. Snapshot Blends Stem Remixing and Scheduling
Part of [[Music Mood Mixer]]. Previous: [[2. Mood Definitions and Stats Signals]]. Next: [[4. Music Mood Mixer Pitfalls]].
> [!note] Verification status
> Checked 2026-11-08 against `AudioMixerSnapshot.TransitionTo(float timeToReach)`, `AudioMixer.TransitionToSnapshots(AudioMixerSnapshot[] snapshots, float[] weights, float timeToReach)`, `AudioMixer.FindSnapshot` (exact name match), `AudioMixer.updateMode` / `AudioMixerUpdateMode.UnscaledTime`, `AudioSource.PlayScheduled(double time)`, `AudioSource.PlayDelayed`, `AudioSettings.dspTime` (seconds based on samples processed; more precise than `Time.time`). The PlayScheduled page recommends scheduling ~100-200 ms ahead so the audio system can prepare playback, and notes that a time in the past starts as soon as possible rather than reconstructing missed audio. **None of the code has been compiled or play-tested in Unity.**
## 3.1 Mixer and snapshot setup (do this once)
1. Under `Music`, create child groups `Bed`, `Tension`, `Percussion`, `Stingers` (and `Radio` if you are jumping ahead to [[In-Game Radio Stations]]).
2. Add snapshots `Explore`, `Tension`, `Combat`, `Victory`, `LowHealth`. Select each snapshot *before* you edit faders.
3. Author them roughly like this (tweak by ear; numbers are starting points, not laws):
| Group | Explore | Tension | Combat | Victory | LowHealth |
|---|---|---|---|---|---|
| Bed | 0 dB | -2 dB | -4 dB | -6 dB | -8 dB + low-pass ~2 kHz |
| Tension | -80 dB | -4 dB | -2 dB | -12 dB | -6 dB |
| Percussion | -80 dB | -18 dB | 0 dB | -8 dB | -6 dB |
| Stingers | -80 dB | -80 dB | -80 dB | 0 dB | -80 dB |
4. Expose `MusicVol` on the parent `Music` attenuation only. Snapshots must not touch it. See [[2. Audio Mixer Snapshots and Exposed Parameters]].
5. Route four looping music sources to the first three groups plus a one-shot source to `Stingers`.
6. Set the mixer's `updateMode` to `UnscaledTime` if pause menus should still fade (same reason as the `Paused` snapshot in Audio 1).
Start every looping stem on the first frame of a level, even the ones that sit at -80 dB. They share a downbeat that way, and raising Percussion later does not start the drums on a random sixteenth.
## 3.2 Stem player
```csharp
using UnityEngine;
using UnityEngine.Audio;
/// Holds one looping AudioSource per stem and a one-shot source for stingers.
/// Mood changes do not stop these sources; snapshots ride their mixer groups.
public class MusicStemPlayer : MonoBehaviour
{
[System.Serializable]
public class Stem
{
public string name;
public AudioClip clip;
public AudioMixerGroup group;
[Range(0f, 1f)] public float volume = 1f;
}
[SerializeField] Stem[] loopingStems;
[SerializeField] AudioMixerGroup stingerGroup;
[SerializeField] bool playOnStart = true;
AudioSource[] _loopSources;
AudioSource _stinger;
void Awake()
{
_loopSources = new AudioSource[loopingStems.Length];
for (int i = 0; i < loopingStems.Length; i++)
{
var go = new GameObject("Stem_" + loopingStems[i].name);
go.transform.SetParent(transform, false);
var src = go.AddComponent<AudioSource>();
src.clip = loopingStems[i].clip;
src.outputAudioMixerGroup = loopingStems[i].group;
src.loop = true;
src.playOnAwake = false;
src.spatialBlend = 0f;
src.priority = 0; // 0 = highest; keep music from being virtualized
src.volume = loopingStems[i].volume;
_loopSources[i] = src;
}
var stingGo = new GameObject("Stem_Stinger");
stingGo.transform.SetParent(transform, false);
_stinger = stingGo.AddComponent<AudioSource>();
_stinger.outputAudioMixerGroup = stingerGroup;
_stinger.loop = false;
_stinger.playOnAwake = false;
_stinger.spatialBlend = 0f;
_stinger.priority = 0;
}
void Start()
{
if (playOnStart)
PlayAllLoops(AudioSettings.dspTime + 0.15);
}
public void PlayAllLoops(double dspTime)
{
for (int i = 0; i < _loopSources.Length; i++)
{
if (_loopSources[i].clip == null) continue;
_loopSources[i].PlayScheduled(dspTime);
}
}
public void PlayStinger(AudioClip clip, float volume = 1f)
{
if (clip == null || _stinger == null) return;
_stinger.PlayOneShot(clip, volume);
}
public float StingerLength(AudioClip clip) => clip != null ? clip.length : 0f;
}
```
> [!warning] Code not compiled in Unity
> Not compiled. `priority` ranges from 0 (most important) to 256 (least) with a default of 128; the Audio Source reference explicitly says to use 0 for music so it is not swapped out. `spatialBlend = 0` keeps the score out of 3D calculations.
## 3.3 Optional horizontal section switcher
Use this when entering Combat or Victory should also swap the *arrangement*, not just the mix. It is Unity's official flip-flop pattern from the `PlayScheduled` example, adapted for section clips stored on the mood assets.
```csharp
using UnityEngine;
/// Flip-flop two sources and stitch section clips on AudioSettings.dspTime.
public class MusicSectionScheduler : MonoBehaviour
{
[SerializeField] AudioMixerGroup outputGroup;
[Tooltip("How far ahead to schedule so the audio thread can prepare the clip.")]
[SerializeField] double scheduleAhead = 0.2;
AudioSource[] _players;
int _flip;
void Awake()
{
_players = new AudioSource[2];
for (int i = 0; i < 2; i++)
{
var go = new GameObject("SectionPlayer_" + i);
go.transform.SetParent(transform, false);
var src = go.AddComponent<AudioSource>();
src.playOnAwake = false;
src.loop = false;
src.spatialBlend = 0f;
src.priority = 0;
src.outputAudioMixerGroup = outputGroup;
_players[i] = src;
}
}
/// Schedule clip to start shortly in the future. Returns the dsp time used.
public double Schedule(AudioClip clip, bool loop)
{
if (clip == null) return AudioSettings.dspTime;
var src = _players[_flip];
_flip = 1 - _flip;
src.Stop();
src.clip = clip;
src.loop = loop;
double when = AudioSettings.dspTime + scheduleAhead;
src.PlayScheduled(when);
return when;
}
public void FadeOutPlaying(double endDspTime)
{
foreach (var src in _players)
{
if (src.isPlaying)
src.SetScheduledEndTime(endDspTime);
}
}
}
```
> [!warning] Code not compiled in Unity
> Not compiled. `PlayDelayed` exists if you only need "start this in N seconds" relative to now, but `PlayScheduled` plus `dspTime` is what you want for stitching. `SetScheduledEndTime` takes an *absolute* dsp timestamp, not a duration - if a clip starts at `T` and you want 3 seconds of it, the end time is `T + 3`.
If your explore and combat sections share a tempo, compute the next bar line instead of `dspTime + 0.2`. The official example advances `nextEventTime` by `(60.0 / bpm) * numBeatsPerSegment`. Aligning to a bar is what makes a combat drop feel arranged rather than interrupted.
## 3.4 The director
```csharp
using UnityEngine;
using UnityEngine.Audio;
/// State machine that turns PlayerMusicSignals into snapshot transitions
/// (and optional horizontal section changes). Hysteresis lives here, not in publishers.
public class MusicMoodDirector : MonoBehaviour
{
[SerializeField] AudioMixer mixer;
[SerializeField] PlayerMusicSignals signals;
[SerializeField] MusicStemPlayer stems;
[SerializeField] MusicSectionScheduler sections; // optional
[SerializeField] MusicMoodDefinition explore;
[SerializeField] MusicMoodDefinition tension;
[SerializeField] MusicMoodDefinition combat;
[SerializeField] MusicMoodDefinition victory;
[SerializeField] MusicMoodDefinition lowHealth;
[SerializeField] bool useWeightedHealthBlend = true;
[SerializeField] bool resequenceOnCombatAndVictory = false;
MusicMoodDefinition _current;
MusicMoodDefinition _pending;
float _pendingTimer;
float _leaveTimer;
float _victoryTimer;
bool _lowHealthLatched;
readonly AudioMixerSnapshot[] _blend = new AudioMixerSnapshot[2];
readonly float[] _weights = new float[2];
void Awake()
{
if (mixer != null)
mixer.updateMode = AudioMixerUpdateMode.UnscaledTime;
}
void OnEnable()
{
if (signals != null)
signals.SignalsChanged += OnSignalsChanged;
}
void OnDisable()
{
if (signals != null)
signals.SignalsChanged -= OnSignalsChanged;
}
void Start()
{
ApplyImmediate(explore, forceSection: true);
}
void Update()
{
if (signals == null) return;
if (_current != null && _current.id == MusicMoodId.Victory)
{
_victoryTimer -= Time.unscaledDeltaTime;
if (_victoryTimer <= 0f)
{
signals.ClearVictory();
ApplyImmediate(ResolveDesired(), forceSection: resequenceOnCombatAndVictory);
}
return;
}
var desired = ResolveDesired();
if (desired == _current)
{
_pending = null;
_pendingTimer = 0f;
_leaveTimer = 0f;
if (useWeightedHealthBlend)
BlendLowHealthIfNeeded();
return;
}
// Still requested: run enter hold.
if (desired == _pending)
{
_pendingTimer += Time.unscaledDeltaTime;
if (_pendingTimer >= desired.enterHoldSeconds)
ApplyImmediate(desired, forceSection: ShouldResequence(desired));
return;
}
// Desired changed away from current: run exit hold on the current mood first
// (except Explore, which is the fallback and has no exit hold).
if (_current != explore)
{
_leaveTimer += Time.unscaledDeltaTime;
if (_leaveTimer < _current.exitHoldSeconds)
{
_pending = desired;
_pendingTimer = 0f;
return;
}
}
_pending = desired;
_pendingTimer = 0f;
_leaveTimer = 0f;
if (desired.enterHoldSeconds <= 0f)
ApplyImmediate(desired, forceSection: ShouldResequence(desired));
}
void OnSignalsChanged()
{
// Update() evaluates; this handler exists so designers can also drive
// the director from events without polling if they later add an Event Queue.
}
MusicMoodDefinition ResolveDesired()
{
if (signals.victoryRequested)
return victory;
float h = signals.healthNormalized;
if (_lowHealthLatched)
_lowHealthLatched = h < lowHealth.lowHealthExit;
else
_lowHealthLatched = h <= lowHealth.lowHealthEnter;
// When we weighted-blend LowHealth we stay in Combat/Tension/Explore
// and let TransitionToSnapshots colour the mix. Otherwise LowHealth wins.
if (!useWeightedHealthBlend && _lowHealthLatched)
return lowHealth;
if (signals.inCombat)
return combat;
if (signals.inTension || signals.wantedLevel >= 1)
return tension;
return explore;
}
bool ShouldResequence(MusicMoodDefinition next)
{
return resequenceOnCombatAndVictory &&
(next.id == MusicMoodId.Combat || next.id == MusicMoodId.Victory);
}
void ApplyImmediate(MusicMoodDefinition next, bool forceSection)
{
if (next == null) return;
var snap = next.ResolveSnapshot(mixer);
if (snap == null)
Debug.LogWarning(
quot;Music mood '{next.name}' snapshot '{next.snapshotName}' was not found.", this);
else
snap.TransitionTo(next.fadeInSeconds);
if (next.id == MusicMoodId.Victory)
{
var clip = next.sectionClip;
stems?.PlayStinger(clip);
_victoryTimer = clip != null ? clip.length : next.fadeInSeconds + 1.5f;
}
if (forceSection && sections != null && next.sectionClip != null && next.id != MusicMoodId.Victory)
{
double when = sections.Schedule(next.sectionClip, loop: next.id != MusicMoodId.Victory);
sections.FadeOutPlaying(when);
}
_current = next;
_pending = null;
_pendingTimer = 0f;
_leaveTimer = 0f;
}
void BlendLowHealthIfNeeded()
{
if (_current == null || _current.id == MusicMoodId.Victory) return;
var gameplay = _current.ResolveSnapshot(mixer);
var sick = lowHealth.ResolveSnapshot(mixer);
if (gameplay == null || sick == null) return;
// Same shape as SnapshotDirector.BlendHealth in the mixer tutorial:
// start blending below the enter threshold, full LowHealth at 10%.
float danger = Mathf.InverseLerp(lowHealth.lowHealthEnter, 0.1f, signals.healthNormalized);
_blend[0] = gameplay;
_weights[0] = 1f - danger;
_blend[1] = sick;
_weights[1] = danger;
mixer.TransitionToSnapshots(_blend, _weights, 0.25f);
}
}
```
> [!warning] Code not compiled in Unity
> Not compiled. Assign all five mood assets, the mixer, signals and stem player. Leave `sections` empty until you have authored separate explore/combat clips. `useWeightedHealthBlend` defaults on so low health *colours* the current mood instead of replacing combat drums with a separate cue - that matches the Gameplay/LowHealth blend you already wrote in [[2. Audio Mixer Snapshots and Exposed Parameters]].
## 3.5 Why the director is a state machine
Nystrom's State chapter (see [[Game Programming Patterns (Review)]]) exists because a nest of booleans (`isCombat && !isDead && health < 0.3 && !victory`) becomes unreadable the moment you add "but not during a cutscene" or "but not while the radio takeover snapshot is running." Here the *current mood* is the state. Enter/exit holds are the transition guards. Victory is a timed state that returns to whatever `ResolveDesired()` now says.
If you later add a `Cutscene` or `RadioTakeover` mood, add a definition asset and a new branch in `ResolveDesired` rather than scattering `TransitionTo` calls through timeline signals and UI scripts. [[Timeline Basics]] can raise `signals.RequestVictory()` or a future `SetCutscene(true)` without knowing snapshot names.
## 3.6 Wiring checklist
1. Empty GameObject `MusicSystem` with `PlayerMusicSignals`, `MusicStemPlayer`, `MusicMoodDirector` (and optional `MusicSectionScheduler`).
2. Create the five `MusicMoodDefinition` assets; point `snapshotName` at the mixer snapshots; optionally assign `sectionClip`.
3. On `MusicStemPlayer`, assign Bed / Tension / Percussion clips and their mixer groups.
4. From your health component, call `signals.SetHealth`.
5. From enemy attack-state enter/exit, call `signals.SetCombat`. From alert/search, `SetTension`.
6. From the encounter-complete code path, `signals.RequestVictory()`.
7. Play: walk (Explore), enter a dark trigger (Tension), aggro an enemy (Combat), drop below 30% health (blend), win (Victory sting, then Explore).
Classroom demo that lands Rogers' Level 16 point: run the same fight twice, once with percussion stuck at -80 dB and once with the director enabled. Students hear immediately that the action did not change and the music did - and which take feels like a game.