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));
        }
    }
}