# 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.