Summary

A game can switch its music between emotional states (explore, tension, combat, victory) without FMOD or Wwise. Unity’s built-in audio offers four tools, each matching one of the adaptive-music techniques described in interactive-music-techniques:

TechniqueUnity toolWhat it gives you
Simple crossfade between two cuesTwo AudioSources and a fade coroutineSmooth state changes in about 30 lines of C#
Mood changes across many sources at onceAudioMixer snapshots with AudioMixerSnapshot.TransitionToTimed mixes (volume, effects) set up in the Editor, not in code
Musically timed switches (horizontal re-sequencing)AudioSource.PlayScheduled with AudioSettings.dspTimeA new cue that starts exactly on the next bar line
Intensity layers (vertical layering)One looping AudioSource per stem, all started on the same dspTimeInstant intensity changes with no tempo drift

Short answer to “C# or middleware?” For a student or small project with two to four music states, plain C# is enough: crossfade two AudioSources, or move between mixer snapshots. Add bar-aligned scheduling if switches must land on the beat. Move to middleware when the composer needs to author the transition rules, quantisation and layer curves themselves, or when the music logic grows beyond a few states (see the checklist under Trade-offs).


Implementation

1. Two-AudioSource crossfade (the minimum)

Put two AudioSource components on a music object, with Loop on and Play On Awake off. One plays; the other waits. To change cue, start the idle one silently and fade the volumes in opposite directions. The AudioSource.volume property runs from 0 to 1 (see unity-audiosource).

using System.Collections;
using UnityEngine;
 
public class SimpleMusicCrossfade : MonoBehaviour
{
    [SerializeField] private AudioSource current;  // the track playing, or being faded towards
    [SerializeField] private AudioSource other;    // idle, or fading out
    [SerializeField, Range(0f, 1f)] private float musicVolume = 0.8f;
 
    public void CrossfadeTo(AudioClip clip, float seconds)
    {
        if (current.clip == clip && current.isPlaying) return;  // already there or on the way
        StopAllCoroutines();                 // safe: the new fade starts from the current volumes
        if (other.clip != clip || !other.isPlaying)             // a new track, not the one fading out
        {
            if (other.isPlaying && other.volume > current.volume)
                (current, other) = (other, current);            // keep the louder track audible
            other.clip = clip;                                  // reuse the quieter source
            other.volume = 0f;
            other.Play();
        }
        (current, other) = (other, current);                    // the requested track becomes current
        StartCoroutine(Fade(other, current, seconds));
    }
 
    private IEnumerator Fade(AudioSource from, AudioSource to, float seconds)
    {
        float fromStart = from.volume, toStart = to.volume;
        for (float t = 0f; t < 1f; t += Time.unscaledDeltaTime / seconds)
        {
            float angle = t * Mathf.PI * 0.5f;                  // equal-power curve
            from.volume = fromStart * Mathf.Cos(angle);
            to.volume = Mathf.Lerp(toStart, musicVolume, Mathf.Sin(angle));
            yield return null;
        }
        from.Stop();
        to.volume = musicVolume;
    }
}

Game code then calls music.CrossfadeTo(combatTheme, 1.5f) when the state changes. Three details matter:

  • Interrupting a fade. If a new request arrives mid-fade, the next fade starts from the volumes the two sources have at that moment, so nothing jumps. Asking for the track that is fading out fades it back in without restarting it. With only two sources, a third track replaces whichever of the two is quieter at that moment.

  • Time.unscaledDeltaTime keeps the fade running when the game is paused with Time.timeScale = 0. With Time.deltaTime the fade would freeze halfway.

  • Call it from a state change, not every frame. Trigger it from the event that changes the game state (see observer-pattern and ai-state-machine-pattern). Add a minimum time between switches so a player hovering on a trigger boundary does not flip the music back and forth. Sweet calls this a timeout (Sweet, Writing Interactive Music for Video Games, see source-writing-interactive-music).

2. Equal-power vs linear fades

CurveOutgoing gainIncoming gainGain of each at the midpointUse for
Linear1 - tt0.5 (−6 dB)Very short fades, or two versions of the same recording kept in sync
Equal-powercos(t·π/2)sin(t·π/2)0.707 (−3 dB)Crossfading between different pieces of music: the default choice

A linear crossfade keeps the two gains summing to 1, but two different signals at half amplitude are heard as roughly 3 dB quieter than one signal at full level, so the music audibly dips in the middle of the fade. Quarter-sine and quarter-cosine curves hold each signal at about 0.707 (−3 dB) at the midpoint, which removes the dip (Dobrian, Max Cookbook: Stereo balance and panning, UC Irvine). Arguably a linear or near-linear fade suits two beat-synchronised mixes of the same material, because those signals add together rather than as independent sounds. Treat that as a rule of thumb and check it by ear.

3. AudioMixer snapshots and TransitionTo

When several sources must change together (music, ambience, a low-pass filter on everything during a “danger” state), do the mixing in an Audio Mixer asset rather than in C#:

  1. Create the mixer. Assets → Create → Audio Mixer. Add groups such as Music_Explore, Music_Combat and Ambience.
  2. Route each source. Set each AudioSource’s Output (outputAudioMixerGroup in code) to its group, so the group’s volume and effects apply to that signal.
  3. Capture a snapshot per mood. Snapshots capture the values of every parameter in the mixer: volume, pitch, send level, wet mix and effect settings. Create one per mood with + in the Snapshots panel, select it, and set the mix. For example, Explore has Music_Combat at −80 dB, and Combat reverses it. Right-click a snapshot and choose Set as Start Snapshot to pick the mix the mixer loads with.
  4. Keep both cues playing (looping, in sync if possible), then move between snapshots from code:
using UnityEngine;
using UnityEngine.Audio;
 
public class MusicMood : MonoBehaviour
{
    [SerializeField] private AudioMixer musicMixer;
    [SerializeField] private AudioMixerSnapshot exploreSnapshot;
    [SerializeField] private AudioMixerSnapshot combatSnapshot;
 
    public void EnterCombat() => combatSnapshot.TransitionTo(1.0f);
    public void LeaveCombat() => exploreSnapshot.TransitionTo(3.0f);
 
    // Blend by a 0-1 danger value instead of switching outright.
    public void SetDanger(float danger)
    {
        var snapshots = new[] { exploreSnapshot, combatSnapshot };
        var weights   = new[] { 1f - danger, danger };
        musicMixer.TransitionToSnapshots(snapshots, weights, 0.5f);
    }
}

TransitionTo(timeToReach) performs an interpolated transition towards the snapshot over that many seconds. TransitionToSnapshots(snapshots, weights, timeToReach) moves to a weighted mixture of snapshots, which the Scripting API suggests for games that treat state as a continuum (Unity Technologies, Scripting API: AudioMixerSnapshot.TransitionTo; AudioMixer.TransitionToSnapshots). AudioMixer.FindSnapshot("Combat") fetches a snapshot by its exact name if you prefer not to assign it in the Inspector.

Mixer gotchas (Unity Technologies, Scripting API: AudioMixer.SetFloat; AudioMixer.updateMode; Unity Manual: Audio Mixer):

  • SetFloat takes a parameter away from snapshots. Once you call AudioMixer.SetFloat on an exposed parameter (for example a “MusicVolume” options slider), snapshots no longer control it. Use ClearFloat to hand it back. Keep player volume settings on a different group (for example a parent Music group) from the groups your snapshots move.
  • Don’t call SetFloat in Awake or OnEnable. The Scripting API advises Start or later.
  • Pausing. Snapshot transitions follow scaled game time by default. Set the mixer’s updateMode to AudioMixerUpdateMode.UnscaledTime if music must keep changing while Time.timeScale is 0.
  • Mixer group volumes are in decibels, not 0–1 like AudioSource.volume. The bottom of the fader, −80 dB, is used as “off” in practice. Fade a group by moving between snapshots rather than by writing a linear 0–1 value into a dB parameter.

4. Bar-aligned switching with PlayScheduled and dspTime (horizontal re-sequencing)

A crossfade that starts mid-phrase can sound like a radio being retuned. To change cue on the next bar line, as horizontal re-sequencing does, use the audio system’s own clock:

  • AudioSettings.dspTime (a double, in seconds) is the current time of the audio system. It is based on the number of samples actually processed, so it is far more precise than Time.time.
  • AudioSource.PlayScheduled(double time) plays the clip at an absolute time on that dspTime timeline. It does not depend on frame rate and is accurate to the sample. Unity calls it the preferred way to stitch clips together in a music player. Schedule about 100–200 ms ahead so streamed audio has time to load. If the time has already passed, playback starts as soon as possible and the missed audio is skipped.
  • AudioSource.SetScheduledEndTime(double time) changes when an already scheduled sound stops, on the same timeline.

(Unity Technologies, Scripting API: AudioSource.PlayScheduled; AudioSettings.dspTime; AudioSource.SetScheduledEndTime)

public class BarClock : MonoBehaviour
{
    [SerializeField] private float bpm = 120f;
    [SerializeField] private int beatsPerBar = 4;
    private double songStartDsp;   // set when bar 1 of the current cue was scheduled
 
    public double NextBarDspTime()
    {
        double secondsPerBar = 60.0 / bpm * beatsPerBar;
        double now = AudioSettings.dspTime;
        double bars = System.Math.Floor((now - songStartDsp) / secondsPerBar) + 1.0;
        double nextBar = songStartDsp + bars * secondsPerBar;
        return (nextBar - now < 0.1) ? nextBar + secondsPerBar : nextBar; // leave load time
    }
 
    public void SwitchOnBar(AudioSource from, AudioSource to, AudioClip cue)
    {
        double barTime = NextBarDspTime();
        to.clip = cue;
        to.PlayScheduled(barTime);         // new cue starts exactly on the bar line
        from.SetScheduledEndTime(barTime); // old cue stops on the same sample
        songStartDsp = barTime;
    }
}

This is a hard cut on the bar. Composers often write cues so that a cut on a bar line sounds intentional. For a softer change, keep the old cue playing and run the equal-power fade from section 1, starting when dspTime reaches barTime. The full example AdaptiveMusicPlayer.cs does both: CrossfadeNow and SwitchOnNextBar.

Requirements for the bar maths to stay correct:

  • Shared grid. Every cue shares one tempo and time signature, or you store BPM per cue.
  • Bar-length loops. Each looping clip is exactly a whole number of bars long, with no silence at the start or end of the file. Otherwise the loop drifts off the grid.
  • Use doubles. Do the timing arithmetic in double, not float: dspTime keeps growing for the whole session.
  • Schedule ahead. The scheduling call must happen at least a frame or two before the bar line, hence the 0.1 s safety margin.
  • Scheduled start (tentative). SetScheduledEndTime is documented for a sound that has already been scheduled to play, so start your cues with PlayScheduled rather than Play if you intend to end them this way. This has not been tested against a Play()-started source.

5. Vertical layering (stems) in plain Unity

For continuous intensity, such as more drums as enemies close in, use vertical layering. Ask the composer for stems of one piece (pad, bass, percussion, lead), all exported at the same length and tempo. Give each stem its own looping AudioSource and start them all on the same dspTime with PlayScheduled, so they stay locked together. Never stop and restart an individual stem; only change its volume:

double start = AudioSettings.dspTime + 0.2;
foreach (AudioSource stem in stems) { stem.loop = true; stem.volume = 0f; stem.PlayScheduled(start); }
 
// Each frame: move each stem towards its target (1 if i <= intensity, else 0).
stems[i].volume = Mathf.MoveTowards(stems[i].volume, target[i], fadeSpeed * Time.unscaledDeltaTime);

The complete component is MusicStemLayers.cs. Alternatively, route each stem to its own mixer group and let snapshots (section 3) do the fading.

Choosing between vertical and horizontal

Vertical layering (stems)Horizontal re-sequencing (cue switching)
Unity toolsSynchronised PlayScheduled start + volume fades or snapshotsTwo decks + PlayScheduled on the next bar, or a crossfade
ResponseImmediateWaits for the next bar or phrase
Musical change possibleSame harmony and tempo, more or less densityNew key, tempo, instrumentation
Best forRising and falling tension (stealth → alert → combat)Distinct states (town → dungeon → boss)

These trade-offs follow Sweet’s account of the two techniques (Sweet, Writing Interactive Music for Video Games, see source-writing-interactive-music). Many games combine them: vertical layers inside each state, and horizontal switches between states.

6. Changing tempo when combat starts

AudioSource.pitch is not a tempo control. The Manual describes the Pitch property as the “Amount of change in pitch due to slowdown/speed up of the Audio Clip. Value 1 is normal playback speed” (Unity Technologies, Unity Manual: Audio Source component reference). It works like a tape or record played faster: pitch = 1.1f makes the music 10% faster and about a semitone and a half higher, so every instrument shifts key and timbre. That can be a deliberate effect (a “hurry up” timer, a slowed-down death moment), but it is not what a composer means by a tempo change.

For a real tempo change on entering combat:

  1. Ask for a combat cue written at the new tempo (or a version of the exploration cue re-rendered at that tempo). This is horizontal re-sequencing: the table above lists a new tempo as something only a cue switch can deliver.
  2. Switch on a bar line with PlayScheduled (section 4), as a hard cut or with a short equal-power fade. Because the two cues have different tempi, store the BPM and beats per bar per cue and update BarClock’s values when the new cue starts, otherwise the next bar calculation uses the old grid.
  3. Optionally add a transition cue: a one-bar fill or riser the composer wrote to bridge the two tempi, scheduled on the bar before the combat cue.

C# or FMOD/Wwise? For one or two tempo changes (explore → combat → explore), the C# above is enough. Middleware earns its place when the composer wants to author these transitions themselves: in FMOD Studio, tempo markers on the timeline define the beat grid that quantised transitions use, and transition timelines provide the bridging passage, all without code changes (Firelight Technologies, FMOD Studio User Manual: Authoring Events). Either way, the game code’s job is the same: report “combat started”, and leave the musical timing to the music system.

(Tentative, not tested for this page: Unity’s mixer has an Audio Pitch Shifter effect, so in principle you could raise pitch and shift the mixer group back down to keep the key. Expect audible artefacts on music; a cue written at the right tempo sounds better.)


Trade-offs

Plain Unity is enough when:

  • there are a handful of music states, and changes can wait for a fade or the next bar
  • one programmer owns both the music logic and the C#
  • vertical layering uses a few stems of a single piece
  • the project is a jam, prototype, coursework or small release, where adding another toolchain costs more than it saves

FMOD or Wwise is worth it when (the more boxes you tick, the stronger the case):

  • The composer or sound designer needs to author and audition transitions, layers and mixes themselves, without waiting for code changes. Composer-facing state tools are one of the main things middleware provides (Sweet, Writing Interactive Music for Video Games, see source-writing-interactive-music).
  • Music should respond to continuous game parameters (health, threat, speed). In FMOD, game code sets one value with EventInstance.setParameterByName("Intensity", value) and the event’s own automation curves decide what happens (Firelight Technologies, FMOD Studio API: EventInstance::setParameterByName; FMOD Studio User Manual: Authoring Events).
  • You need quantised transitions (jump only on a chosen beat or bar), transition timelines (a bridge passage played during the jump) or several conditional destinations. FMOD Studio provides these as transition regions, tempo markers and transition timelines. Without tempo markers it assumes 120 BPM in 4/4 (Firelight Technologies, FMOD Studio User Manual: Authoring Events). In Unity you would have to hand-build each of these from dspTime maths.
  • There are many states, stingers and variations, and the hand-written C# state logic is becoming the hard part.
  • You need runtime DSP, randomised variation and per-platform audio optimisation at scale (Sweet, Writing Interactive Music for Video Games, see source-writing-interactive-music).
  • The team can absorb another toolchain: the integration package, building banks, licensing, and keeping both tools in sync. See audio-middleware-overview.

With middleware, the division of labour changes. C# only reports what state the game is in, for example by setting a parameter or a state. The timing of the musical change (wait for the bar, play a bridge, crossfade over two beats) is authored in the middleware project. In plain Unity, your C# owns both.


Examples

  • Explore → combat in a top-down shooter: two decks, with SwitchOnNextBar(combatCue) when the first enemy notices the player. When the last enemy dies, a stinger plays (PlayOneShot) followed by CrossfadeNow(exploreCue, 3f).
  • Stealth tension: a MusicStemLayers with four stems. Intensity rises from 0 (pad only) to 3 (full percussion) as guards move from unaware to searching to alerted, matching the AI states in ai-state-machine-pattern.
  • Underwater or pause muffling: a mixer snapshot that adds a low-pass filter to the Music and Ambience groups, reached with TransitionTo(0.3f), with the mixer’s updateMode set to UnscaledTime.
  • Red Dead Redemption (2010) is Sweet’s example of vertical layering responding to place and threat. Uncharted 2 (2009) triggers explore → suspense transitions from spatial volumes (Sweet, see interactive-music-techniques).