Source file unity-csharp/audio/AdaptiveMusicPlayer.cs from the GDnD code examples. Download raw file
// GDnD wiki example (1 of 2: AdaptiveMusicPlayer.cs, MusicStemLayers.cs)
// Demonstrates: adaptive music in plain Unity, with no FMOD or Wwise.
// Horizontal re-sequencing between whole music cues (Explore, Combat, ...) using two
// AudioSources that take turns ("A/B decks"):
// 1. CrossfadeNow(cue, seconds) - immediate crossfade, equal-power or linear curve.
// 2. SwitchOnNextBar(cue) - waits for the next bar line on the audio clock
// (AudioSettings.dspTime), starts the new cue sample-accurately with
// AudioSource.PlayScheduled, then crossfades over a short overlap.
// All cues are assumed to share one tempo and time signature (set bpm / beatsPerBar).
// Fades use unscaled time, so they still finish while the game is paused (timeScale = 0).
// Interrupting a fade is safe: the new fade starts from both decks' current volumes, and
// switching back to the cue that is fading out fades it back in without restarting it.
// Related pages: [[unity-adaptive-music-crossfade]], [[interactive-music-techniques]],
// [[unity-audiosource]], [[audio-middleware-overview]]
// Unity 6 (6000.x). Built-in audio only (UnityEngine.AudioModule).
using System.Collections;
using UnityEngine;
namespace GDnD.Audio
{
public enum FadeCurve { EqualPower, Linear }
public class AdaptiveMusicPlayer : MonoBehaviour
{
[Header("Two sources, both Loop = true, Play On Awake = false")]
[SerializeField] private AudioSource deckA;
[SerializeField] private AudioSource deckB;
[Header("Musical grid shared by every cue")]
[SerializeField] private float bpm = 120f;
[SerializeField] private int beatsPerBar = 4;
[Header("Mix")]
[SerializeField, Range(0f, 1f)] private float musicVolume = 0.8f;
[SerializeField] private FadeCurve curve = FadeCurve.EqualPower;
[SerializeField] private AudioClip startCue;
private AudioSource active; // the deck the player hears now
private double songStartDsp; // dspTime of bar 1, beat 1 of the active cue
private double idleStartDsp; // the same for the idle deck's cue (used when switching back)
private Coroutine fadeRoutine;
private void Start()
{
active = deckA;
if (startCue == null) return;
// Schedule slightly ahead so the clip can load (the Scripting API suggests 100-200 ms).
songStartDsp = AudioSettings.dspTime + 0.2;
deckA.clip = startCue;
deckA.volume = musicVolume;
deckA.PlayScheduled(songStartDsp);
}
private AudioSource Idle => active == deckA ? deckB : deckA;
private double SecondsPerBar => 60.0 / bpm * beatsPerBar;
/// <summary>Crossfade to a new cue straight away (good for stingers-to-cue or menus).</summary>
public void CrossfadeNow(AudioClip cue, float seconds)
{
if (cue == null || cue == active.clip) return;
if (!PrepareIdle(cue))
{
Idle.Play();
idleStartDsp = AudioSettings.dspTime;
}
StartFade(seconds, 0.0);
}
/// <summary>Start the new cue exactly on the next bar line, then overlap-fade.</summary>
public void SwitchOnNextBar(AudioClip cue, float fadeSeconds = 0.5f)
{
if (cue == null || cue == active.clip) return;
double now = AudioSettings.dspTime;
double bar = SecondsPerBar;
double barsElapsed = System.Math.Floor((now - songStartDsp) / bar) + 1.0;
double nextBar = songStartDsp + barsElapsed * bar;
if (nextBar - now < 0.1) nextBar += bar; // too close to load safely: use the bar after
if (!PrepareIdle(cue))
{
Idle.PlayScheduled(nextBar); // sample-accurate start on the audio clock
idleStartDsp = nextBar; // the new cue's bar 1 is the bar line we chose
}
// If the cue was already playing (fading out), it keeps its position and
// simply fades back in from the next bar line.
StartFade(fadeSeconds, nextBar);
}
/// <summary>
/// Gets the idle deck ready for a cue. Returns true if that deck is already playing the
/// cue (the player went back before the fade-out finished), so it must not be restarted.
/// </summary>
private bool PrepareIdle(AudioClip cue)
{
AudioSource idle = Idle;
if (idle.isPlaying && idle.clip == cue) return true;
// Two decks, three cues: keep the louder deck audible and reuse the quieter one.
if (idle.isPlaying && idle.volume > active.volume) SwapRoles();
idle = Idle;
idle.Stop();
idle.clip = cue;
idle.volume = 0f;
return false;
}
private void SwapRoles()
{
active = Idle;
(songStartDsp, idleStartDsp) = (idleStartDsp, songStartDsp);
}
private void StartFade(float seconds, double startDsp)
{
// Interrupting a running fade is safe: Crossfade reads both decks' current volumes.
if (fadeRoutine != null) StopCoroutine(fadeRoutine);
AudioSource from = active;
SwapRoles(); // the idle deck (the requested cue) becomes active
fadeRoutine = StartCoroutine(Crossfade(from, active, seconds, startDsp));
}
private IEnumerator Crossfade(AudioSource from, AudioSource to, float seconds, double startDsp)
{
// Wait (on the audio clock) until the scheduled start, if there is one.
while (AudioSettings.dspTime < startDsp) yield return null;
float startFromVolume = from.volume;
float startToVolume = to.volume; // 0 for a new cue, higher if it was fading out
float t = 0f;
while (t < 1f)
{
t += Time.unscaledDeltaTime / Mathf.Max(seconds, 0.01f);
float k = Mathf.Clamp01(t);
(float outGain, float inGain) = Gains(k);
from.volume = startFromVolume * outGain;
to.volume = Mathf.Lerp(startToVolume, musicVolume, inGain);
yield return null;
}
from.Stop();
from.volume = 0f;
to.volume = musicVolume;
fadeRoutine = null;
}
private (float outGain, float inGain) Gains(float k)
{
if (curve == FadeCurve.Linear) return (1f - k, k);
// Equal-power: quarter-cosine out, quarter-sine in. At the midpoint both are
// about 0.707 (-3 dB), so the combined loudness does not dip.
float angle = k * Mathf.PI * 0.5f;
return (Mathf.Cos(angle), Mathf.Sin(angle));
}
}
}