13.1 Audio Systems & Middleware (Wwise/FMOD)

Phase 13 · Audio · Study time: 25–40 h

How game audio works — the audio thread's hard-real-time rules, mixing, and driving sound through Wwise/FMOD events and buses.

Every sound in a game -- a footstep, an explosion, a music track -- is, underneath, just a long list of numbers being pushed out to a speaker many thousands of times per second. This section covers how that actually works in Unity with C#, and why almost every shipping studio game does not hand-code that whole pipeline itself: it authors sound in outside tools called audio middleware (Wwise, FMOD) and lets a programmer just say "play this" by name. We will build up from a single AudioSource playing a clip, through Unity's mixer and bus system, into the event/container/RTPC model middleware uses, and the streaming, memory, and voice-limit decisions a real project has to make once it has thousands of sounds instead of ten.

1. How Game Audio Actually Works: Samples and the Audio Thread

A microphone turns sound into a wiggling electrical voltage. To store that in a computer, we measure (sample) that voltage many times per second and save each measurement as a number. A typical game sound is sampled 44,100 or 48,000 times per second (called the sample rate, measured in Hertz, Hz). Play those numbers back through a speaker fast enough, in the same order, and the speaker cone moves in and out and reproduces the same wiggle -- you hear the sound again.

Recording: air pressure wave --> measured 48,000 times/sec --> numbers: -3, 120, 340, 290, -50, ... Playback: numbers --> sent to speaker 48,000 times/sec --> air pressure wave (speaker cone moves in/out)

Getting those numbers to the speaker on time is the job of a dedicated audio thread (a separate line of execution that runs independently of your game's main thread, the one running Update()). Roughly like this, forever, in a loop:

audio thread loop (simplified): wait for the sound hardware to ask for more data | v fill a small buffer with the next batch of samples | v hand the buffer to the operating system / sound driver | v (back to the top) Each "fill the buffer" step has a hard deadline, usually a few milliseconds. Miss it, and there is nothing to send -- the speaker gets silence or garbage for that instant.

That deadline is why the audio thread is one of the most unforgiving pieces of a game engine. If it is late, the player does not see a slow frame -- they hear it, as a click, a pop, or a moment of dead silence (called a buffer underrun or dropout: the buffer ran out of samples before new ones arrived). This is true even on a machine where the rest of the game is running at a perfectly smooth 60 frames per second, because the audio thread runs on its own schedule, independent of the render loop.

The practical rule that follows: never do slow or unpredictable work directly on the audio thread -- no heap allocation (which can trigger the garbage collector), no file loading, no locks that might block, no calling into arbitrary game code. Unity exposes the audio thread directly through OnAudioFilterRead, a callback you can write to process raw samples yourself:

using UnityEngine;
using System.Collections.Generic;

public class BadAudioFilter : MonoBehaviour
{
    // Unity calls this ON THE AUDIO THREAD, not the main thread,
    // every time it needs the next batch of samples for this source.
    void OnAudioFilterRead(float[] data, int channels)
    {
        // BAD: allocates a new List every single call. That allocation
        // can trigger the garbage collector, which can pause execution
        // -- including this thread -- for long enough to miss the deadline.
        List<float> temp = new List<float>(data.Length);
        for (int i = 0; i < data.Length; i++)
        {
            temp.Add(data[i] * 0.5f); // halve the volume
        }
        for (int i = 0; i < data.Length; i++)
        {
            data[i] = temp[i];
        }
    }
}

Worked trace: this callback might run hundreds of times per second, each call expected to finish in well under a millisecond. The first few calls probably run fine. Then, at some unpredictable moment, the garbage collector decides it needs to reclaim memory from all those discarded List objects, and pauses managed code for a few milliseconds. If that pause lands while the audio thread is mid-callback, the buffer misses its deadline -- the player hears a click, with no error message and nothing in the Console pointing at the cause. The fix is to never allocate inside the callback at all:

using UnityEngine;

public class OkAudioFilter : MonoBehaviour
{
    public float volumeScale = 0.5f;

    void OnAudioFilterRead(float[] data, int channels)
    {
        // GOOD: no allocation, just simple math directly on the buffer
        // Unity already gave us.
        for (int i = 0; i < data.Length; i++)
        {
            data[i] = data[i] * volumeScale;
        }
    }
}

This is why studios treat audio as its own engineering problem, not just "add a sound file." Every design in this section -- buses, events, streaming, voice limits -- exists partly to keep expensive decisions (which sounds to play, how loud, from where) on the safe main thread or a dedicated audio-engine thread built for it, far away from that unforgiving per-buffer deadline.

Tip You will rarely write OnAudioFilterRead yourself in a normal gameplay project -- it is mainly for custom DSP (digital signal processing) effects. It is shown here because it is the clearest place to see the audio thread's deadline directly in Unity's C# API.

2. AudioSource and AudioListener: Unity's Audio Model

Unity's audio model has two halves, matching how you would describe sound in real life: something makes noise, and something hears it.

[AudioSource: footsteps]---+ [AudioSource: gunfire]-----+---> mixed together ---> [AudioListener]---> speakers [AudioSource: music]-------+ (usually on Main Camera)

A minimal setup: attach an AudioSource to a GameObject, give it a clip, and play it.

using UnityEngine;

[RequireComponent(typeof(AudioSource))]
public class SimplePlayer : MonoBehaviour
{
    public AudioClip clip;
    private AudioSource source;

    void Awake()
    {
        source = GetComponent<AudioSource>();
        source.clip = clip;
        source.playOnAwake = false; // we will call Play() ourselves
    }

    void Start()
    {
        source.Play();
        Debug.Log("Playing " + clip.name + ", length " + clip.length + "s");
    }
}

Expected output (assuming a clip named explosion_01 that is 1.8 seconds long):

Playing explosion_01, length 1.8s

If the AudioSource has Spatial Blend set above 0 (partway or fully toward "3D"), Unity also attenuates (reduces) the volume based on distance from the AudioListener, and pans it left or right based on direction -- this is what makes an explosion off to your right sound like it is coming from your right. A Spatial Blend of 0 means fully 2D: same volume and no panning no matter where the AudioListener is, which is normally what you want for music and UI sounds.

Common mistake Leaving Play On Awake checked on an AudioSource that you also call Play() on from a script. The clip starts twice -- once automatically, once from your code -- and you hear two overlapping copies of the same sound slightly out of phase.

3. Playing One-Shot Sounds

Calling source.Play() a second time while the source is already playing something replaces whatever was playing -- it cuts the old sound off and starts the new one. That is wrong for something like gunfire, where you want each new shot to layer on top of the last one instead of cutting it short. Unity's fix is PlayOneShot: it plays a clip once, without disturbing whatever the source is already doing.

using UnityEngine;

public class Gun : MonoBehaviour
{
    public AudioSource source;
    public AudioClip shotClip;

    public void Fire()
    {
        // PlayOneShot layers a new, independent playback of shotClip
        // on top of anything this source is already playing.
        source.PlayOneShot(shotClip);
    }
}

Worked trace: suppose Fire() is called three times, at t=0.00s, t=0.05s, and t=0.10s, from a rapid-fire weapon. Each call starts its own independent playback of shotClip, layered on top of the others:

t=0.00s Fire() -> shot A starts [A=====================] t=0.05s Fire() -> shot B starts [B=====================] t=0.10s Fire() -> shot C starts [C=====================] Result heard: three overlapping gunshots, stacked in time. If Play() had been used instead of PlayOneShot(), shot B would have cut shot A off completely, and shot C would have cut off B.

A limitation: because PlayOneShot does not return a reference to the sound it started, you cannot stop, pause, or adjust the pitch of one specific one-shot after it has started -- only source.Stop() can stop everything that source is currently playing, all at once. When you need to control an individual sound after it starts (a looping engine sound whose pitch changes with speed, for example), use a dedicated AudioSource and Play() instead, which is exactly what the next section does.

4. Looping, Volume, and Pitch

Three AudioSource fields cover most everyday control: loop (a bool -- restart automatically when the clip ends), volume (0 to 1, linear), and pitch (a speed multiplier -- 1 is normal, 2 is double speed and an octave higher, 0.5 is half speed and an octave lower). A common use is tying an engine or wind sound's pitch to some gameplay value:

using UnityEngine;

public class EngineSound : MonoBehaviour
{
    public AudioSource source;
    public float minPitch = 0.8f;
    public float maxPitch = 2.0f;

    void Start()
    {
        source.loop = true;
        source.volume = 0.7f;
        source.Play();
    }

    void Update()
    {
        float speedFraction = GetCarSpeedFraction(); // 0 (stopped) to 1 (top speed)
        source.pitch = Mathf.Lerp(minPitch, maxPitch, speedFraction);
    }

    float GetCarSpeedFraction()
    {
        return 0.5f; // placeholder -- a real script reads this from the car's Rigidbody
    }
}

Worked trace: with speedFraction fixed at 0.5 for this example, Mathf.Lerp(0.8, 2.0, 0.5) evaluates to 1.4 -- the engine loop plays 40% faster and noticeably higher-pitched than its recorded pitch, and because loop is true, it restarts seamlessly every time it reaches the end of the clip, for as long as the object exists.

Common mistake Forgetting that loop = true means forever, not "until the object looks done." A looping AudioSource on a pooled or deactivated object keeps playing (or keeps its state ready to resume) unless you explicitly call Stop() before disabling or returning it to a pool. A very common bug: an enemy dies, its GameObject is deactivated for reuse, but its looping "alert" sound was never stopped -- it silently keeps consuming a voice (see Section 11) or resumes barking the moment the object is reactivated somewhere else.

5. The Mixer: Buses and Groups

A game easily has hundreds of AudioSources active in a busy scene. Controlling each one's volume individually does not scale -- if a player wants to turn music down without touching sound effects, you do not want to hunt down every music-related AudioSource in the scene. Unity's AudioMixer solves this with groups (also called buses in other tools): named nodes arranged in a tree, each AudioSource routed to exactly one group, and each group's volume affecting everything routed to it or to any of its children.

Master |-- Music |-- SFX | |-- Weapons | |-- Footsteps | |-- Ambience |-- Voice |-- Dialogue |-- UI Voice Every AudioSource routes its output into exactly one of these groups (set in the AudioSource's "Output" field). Turning down the "SFX" group's volume turns down Weapons, Footsteps, and Ambience together, without touching Music or Voice at all.

This tree structure is also where you attach effects that should apply to a whole category at once -- a compressor and limiter on Master so the overall mix never clips, a low-pass filter on Ambience so distant sounds feel muffled, and so on -- instead of adding the same effect component to every individual AudioSource by hand.

Tip A settings menu with separate Music / SFX / Voice sliders is really just three numbers being written into three exposed mixer parameters (Section 6). The bus tree is what makes that a five-minute feature instead of a system you have to build yourself.

6. Controlling the Mixer From Code

To change a group's volume from a script -- a settings slider, for example -- you call AudioMixer.SetFloat on a parameter that has been exposed (right-click the group's Volume field in the Mixer window and choose "Expose to script"; an unexposed parameter cannot be reached from code). The important trap: mixer volume parameters are in decibels (dB), a logarithmic unit, not the simple 0-to-1 linear range a UI slider naturally gives you.

using UnityEngine;
using UnityEngine.Audio;

public class MixerVolumeSlider : MonoBehaviour
{
    public AudioMixer mixer;

    // Called from a UI slider with a value from 0 (silent) to 1 (full volume).
    public void SetMusicVolume(float linear01)
    {
        // AudioMixer parameters are in decibels, not 0..1.
        // 1.0 linear -> 0 dB (unchanged). 0.0 linear -> -80 dB (effectively silent).
        float dB = (linear01 <= 0.0001f) ? -80f : Mathf.Log10(linear01) * 20f;
        mixer.SetFloat("MusicVolume", dB);
        Debug.Log("Slider " + linear01 + " -> " + dB + " dB");
    }
}

Worked trace: calling SetMusicVolume(0.5f) computes Mathf.Log10(0.5) * 20. Log10(0.5) is about -0.301, times 20 is about -6.02:

Slider 0.5 -> -6.02 dB

The logarithmic scale is not an arbitrary choice -- human hearing itself perceives loudness roughly logarithmically, so a straight linear 0-to-1 fade sounds like it drops almost all its volume in the last 10% of the slider, while a dB-based fade sounds smooth and even across the whole range. Reading a value back works the same way in reverse, with mixer.GetFloat("MusicVolume", out float currentDB) -- used in the ducking code in the next section.

Common mistake Calling SetFloat with a parameter name that was never exposed in the Mixer window. It does not throw an exception -- it silently returns false and does nothing, which is a confusing, quiet failure the first time you hit it.

7. Ducking: Lowering Music When Dialogue Plays

Ducking means automatically lowering one bus's volume while another bus is active -- the classic case is pulling the Music bus down while a Voice line plays, so the player can actually understand the dialogue, then bringing Music back up once the line ends.

Music volume 0 dB |--MUSIC--+ +--MUSIC-- | \ / | +----DUCKED (-12dB)----+ -80dB +----------------------------------------------> time ^ ^ dialogue starts dialogue ends

A hand-rolled version fades the "MusicVolume" mixer parameter from Section 6 toward a quieter target over a short time, using a coroutine so the change is smooth instead of an abrupt jump:

using System.Collections;
using UnityEngine;
using UnityEngine.Audio;

public class DialogueDucking : MonoBehaviour
{
    public AudioMixer mixer;
    public float duckedDB = -12f;  // how quiet music gets while someone talks
    public float normalDB = 0f;    // music at full volume
    public float fadeTime = 0.3f;  // seconds to fade in or out

    public void OnDialogueStart()
    {
        StopAllCoroutines();
        StartCoroutine(FadeMusicTo(duckedDB));
    }

    public void OnDialogueEnd()
    {
        StopAllCoroutines();
        StartCoroutine(FadeMusicTo(normalDB));
    }

    private IEnumerator FadeMusicTo(float targetDB)
    {
        mixer.GetFloat("MusicVolume", out float startDB);
        float t = 0f;
        while (t < fadeTime)
        {
            t += Time.deltaTime;
            float current = Mathf.Lerp(startDB, targetDB, t / fadeTime);
            mixer.SetFloat("MusicVolume", current);
            yield return null;
        }
        mixer.SetFloat("MusicVolume", targetDB);
    }
}

Worked trace: a dialogue system calls OnDialogueStart() at t=10.0s. Over the next 0.3 seconds, FadeMusicTo runs once per frame, moving MusicVolume smoothly from 0 dB toward -12 dB using Lerp, the same interpolation you have used for movement in earlier chapters -- just applied to a decibel value instead of a position. When the line ends and OnDialogueEnd() fires, the same coroutine pattern fades it back up to 0 dB.

Unity's AudioMixer also has a built-in alternative for exactly this: Snapshots (a saved set of every parameter's value at once) that you can transition between with mixer.TransitionToSnapshots(...), letting you author a "Dialogue" snapshot (Music quieter, maybe a low-pass filter on Ambience too) visually in the Mixer window instead of scripting every parameter by hand. Wwise and FMOD both offer the same idea out of the box, usually called a ducking rule or sidechain: "whenever any sound on bus X is playing, automatically attenuate bus Y by N dB" -- configured once by a sound designer, with zero per-line code required from a programmer.

8. Why Studios Use Middleware Instead of Hand-Coding Audio

Everything so far -- one-shots, loops, mixer groups, a scripted duck -- works. It also does not scale past a small game. A shipping game commonly has many hundreds or thousands of distinct sounds: a dozen footstep variations per surface type, RTPC-driven engine notes, occlusion (muffling behind walls), randomized weapon barks, layered ambiences that crossfade with weather and time of day. Coding all of that by hand in C# means:

Audio middleware (Wwise by Audiokinetic and FMOD by Firelight Technologies are the two dominant ones in the industry) splits the job in two. The sound designer works in a separate authoring application, outside Unity or Unreal entirely, where they build named events, wire up randomization and variation, hook game values to audio parameters, and set voice limits and priorities -- then exports that work as a soundbank (a packaged bundle of events, clips, and settings) that ships with the game. The programmer's entire job shrinks to two calls: "play this event by name" and "set this parameter to this value." Nobody on the programming side needs to know how many footstep variations exist, or what curve maps engine RPM to pitch -- that knowledge lives entirely on the audio side, in the tool.

Without middleware: With middleware: Sound designer --> describes Sound designer --> builds events, what they want containers, RTPCs to a programmer directly in Wwise/FMOD | | v v Programmer writes Programmer calls custom C# for it, PostEvent("Play_X") ships a build -- ships anytime, no rebuild needed for audio-only tweaks

This is the same split you have already seen elsewhere in this curriculum between data and logic -- the "what" (which sound, how it varies, how loud) is authored as data by a specialist, and the "when" (trigger this now) stays as a single, simple call in game code.

9. Events, Containers, and RTPC

Three ideas make up almost everything you need to know about how middleware like Wwise or FMOD is actually used from code.

Events

An event is a named trigger authored entirely inside the audio tool -- for example, "Play_Footstep_Grass". Posting that event by name from code causes whatever the sound designer configured for it to happen: play a sound, stop a sound, start a music segment, adjust a mixing rule. The programmer never needs to know what is actually inside the event, only its name.

Containers

A container groups several sound variations under one event and decides how to pick among them:

RTPC (Real-Time Parameter Control)

An RTPC is a continuous numeric value passed from the game to the audio engine every frame (or whenever it changes), which the sound designer maps to some audio property using a curve they draw in the tool -- "Speed" from 0 to 10 mapped to a pitch curve, or "Health" from 100 to 0 mapped to a low-pass filter that muffles all sound as the player nears death. The programmer just keeps pushing the number; the mapping curve, and what it controls, is entirely the sound designer's decision.

Event: "Play_Footstep_Grass" | v Random Container | | | | v1 v2 v3 v4 (one picked at random each time) | v RTPC "Speed" (0..10) --> mapped to Pitch (0.9 .. 1.3)

In code, this looks almost identical to the hand-rolled event system built in Section 13, just backed by a much bigger authoring tool instead of a C# dictionary. The Wwise Unity integration, for example, uses a static class called AkSoundEngine:

using UnityEngine;

public class FootstepEmitter : MonoBehaviour
{
    // Called from an animation event on the footstep frame.
    // This code has no idea there are 4 variations, or that they
    // are picked randomly -- that lives entirely inside Wwise.
    public void OnFootstepAnimEvent()
    {
        AkSoundEngine.PostEvent("Play_Footstep_Grass", gameObject);
    }

    void Update()
    {
        float speed = GetCurrentSpeed(); // 0..10, from movement code
        AkSoundEngine.SetRTPCValue("Speed", speed, gameObject);
    }

    float GetCurrentSpeed()
    {
        return 3.5f; // placeholder -- a real script reads this from the CharacterController
    }
}

Worked trace: each call to PostEvent("Play_Footstep_Grass", gameObject) triggers whatever the sound designer built for that event -- say, the random container picks variation 3 of 4. That variation then plays with its pitch shifted according to the current "Speed" RTPC value (3.5), following a curve the sound designer drew in the tool, without a single line of pitch-randomization or speed-mapping code anywhere in this script.

Tip Posting events by a raw string name has a small runtime cost (Wwise has to hash the string to find the event). Real projects usually include a generated header of integer IDs (commonly named something like Wwise_IDs) built automatically from the project, and post the integer ID instead of the string -- same idea, faster lookup, and a compile error instead of a silent typo if an event name ever changes.

10. Streaming vs. Loaded-in-Memory Audio

An AudioClip's data has to live somewhere. Unity, Wwise, and FMOD all offer the same two basic choices:

The deciding factor is almost always length and frequency, and the reason comes straight from the numbers. A short C# program makes the memory cost concrete:

using System;

class AudioBudget
{
    static void Main()
    {
        int sampleRate = 44100;   // samples per second
        int bytesPerSample = 2;   // 16-bit audio = 2 bytes per sample
        int channels = 2;         // stereo
        int seconds = 180;        // a 3-minute music track

        long bytes = (long)sampleRate * bytesPerSample * channels * seconds;
        double megabytes = bytes / (1024.0 * 1024.0);

        Console.WriteLine("Raw PCM size: " + bytes + " bytes");
        Console.WriteLine("Raw PCM size: " + megabytes.ToString("F1") + " MB");
    }
}

Expected output:

Raw PCM size: 31752000 bytes
Raw PCM size: 30.3 MB

One uncompressed, three-minute, stereo music track alone costs about 30 MB of RAM. A game with even a modest amount of music, ambience, and voiced dialogue can easily have hours of total audio -- impossible to hold entirely in memory at once on a console or phone with a few gigabytes of RAM shared with textures, meshes, and everything else. That is why long, rarely-repeated content (music, ambience beds, dialogue) is almost always streamed, while short, frequently-triggered content (footsteps, UI clicks, gunfire) is loaded into memory, where its tiny size (a half-second footstep clip is roughly 86 KB by the same formula) is a non-issue and instant playback matters far more than saving a few kilobytes.

Common mistake Setting a long music or ambience track to load fully into memory "to be safe." It works in a quick test with one track, then quietly blows the memory budget once a real level has a dozen music cues and several ambience beds loaded at once. Streaming is the default assumption for anything measured in minutes, not seconds.

11. Voice Limits and Prioritization

A voice is one currently-active, actually-being-mixed sound. Mixing costs CPU time per voice, and past a certain count, more simultaneous voices also just sound like mud -- the player cannot pick out any individual sound in a wall of noise. Every engine sets a practical ceiling on how many voices can play at once, and needs a rule for what happens when a new sound wants to play but the ceiling is already reached.

Unity's built-in audio (not middleware) exposes a simple version of this through AudioSource.priority, a number from 0 (most important) to 256 (least important, default is 128):

using UnityEngine;

public class ExplosionSFX : MonoBehaviour
{
    public AudioSource source;

    void Start()
    {
        // 0 = highest priority. Give big, rare, important sounds a low
        // number so they win over small, common sounds when the engine
        // has to decide what to cut.
        source.priority = 0;
    }
}

Worked trace: a project has its real voice count set to 32 (Edit > Project Settings > Audio). A firefight is in full swing: 20 footsteps, 15 ambient birds, 8 gunfire one-shots, and 1 explosion all want to play in the same instant -- 44 requested voices against a limit of 32. Unity keeps the 32 highest-priority sounds as real, audible voices and virtualizes the rest: it keeps tracking their playback position internally without actually mixing them (so they cost almost no CPU and make no sound), and can promote a virtual sound back to a real voice later if, for example, the camera moves closer to it. Because the explosion was given priority 0, it is guaranteed to be one of the 32 kept real, even if a dozen background birds get virtualized instead.

Middleware gives finer control over the same idea: a per-event maximum instance count (never more than 4 copies of this exact gunshot at once, regardless of the global voice limit), a priority that can itself be an RTPC-driven curve (closer sounds automatically outrank farther ones), and explicit rules for what happens at the limit -- kill the oldest instance, kill the quietest, or simply refuse to play the new one.

12. Compression Formats

How an AudioClip is stored on disk (and sometimes in memory) trades off three things against each other: file size, CPU cost to decode it during playback, and audio quality.

Format Size vs raw PCM CPU to decode Typical use --------------------------------------------------------------- PCM / WAV 100% (none) ~none very short, very (uncompressed) frequent SFX where zero decode cost matters most ADPCM ~25% low mid-length SFX, memory-constrained platforms Vorbis / MP3 ~10% - 15% moderate/high music, ambience, (lossy) long dialogue, anything streamed

PCM (Pulse-Code Modulation, the raw, uncompressed sample data described in Section 1) costs nothing to decode because there is nothing to decode -- the numbers are already the numbers to send to the speaker. That makes it a good choice for very short, very frequent effects where CPU spent decompressing would outweigh the memory saved. ADPCM (Adaptive Differential PCM) stores the difference between each sample and the last one instead of the raw value, which compresses reasonably well at a low, predictable decode cost -- a common middle ground for mid-length sound effects. Vorbis and MP3 are lossy compressed formats (they discard audio detail a listener is unlikely to notice) that shrink a file drastically at the cost of real CPU time to decompress every frame of playback -- the right trade for long streamed content, where the file size savings matter far more than the modest CPU cost.

Unity exposes this as two mostly-independent per-clip import settings: Load Type (Decompress On Load, Compressed In Memory, or Streaming -- this is the Section 10 decision) and Compression Format (PCM, ADPCM, or Vorbis -- this section's decision). A short, frequent footstep clip typically wants Decompress On Load plus PCM or ADPCM; a long music track typically wants Streaming plus Vorbis.

13. Putting It Together: A Small Event-Based Audio Manager

You do not need Wwise or FMOD to use the event pattern from Section 9 -- you can build a tiny version of it yourself with plain Unity AudioSources, which is a useful way to feel exactly why the bigger tools exist once your list of sounds grows past a handful.

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Audio;

public class AudioManager : MonoBehaviour
{
    public static AudioManager Instance { get; private set; }

    [System.Serializable]
    public struct Sound
    {
        public string id;
        public AudioClip clip;
        public AudioMixerGroup group;
        [Range(0f, 1f)] public float volume;
    }

    public Sound[] sounds;
    private Dictionary<string, Sound> lookup;
    private AudioSource oneShotSource;

    void Awake()
    {
        Instance = this;
        lookup = new Dictionary<string, Sound>();
        foreach (Sound s in sounds)
        {
            lookup[s.id] = s;
        }
        oneShotSource = gameObject.AddComponent<AudioSource>();
    }

    // The rest of the game calls this ONE method by name -- the same
    // shape as posting an event to Wwise or FMOD. Nothing else in the
    // codebase touches AudioSource, AudioClip, or AudioMixer directly.
    public void Play(string id)
    {
        if (!lookup.TryGetValue(id, out Sound s))
        {
            Debug.LogWarning("No sound registered with id: " + id);
            return;
        }
        oneShotSource.outputAudioMixerGroup = s.group;
        oneShotSource.PlayOneShot(s.clip, s.volume);
    }
}

Called from anywhere else in the game, exactly like an event post:

AudioManager.Instance.Play("footstep_grass");

Worked trace: Play("footstep_grass") looks up the matching entry, routes the shared one-shot AudioSource's output to whatever AudioMixerGroup was assigned in the Inspector (say, SFX > Footsteps), and plays the clip at the configured volume. A player controller, an animation event, an enemy AI script -- none of them need to know an AudioClip or AudioMixer exists; they only ever call Play(string id).

This is exactly the gap Wwise and FMOD fill at scale: this hand-rolled manager has no random containers, no RTPCs, no streaming control, no voice limits, and no ducking rules -- every one of those would be more C# to write and maintain by hand. Once a project needs hundreds of sounds with that level of behavior, licensing and learning a dedicated tool that already solved all of it is usually cheaper than continuing to grow this file.

14. Glossary

15. Exercises

Exercise 1 -- Memory Budget for a Dialogue Line A dialogue line is recorded mono (1 channel), at 48,000 Hz, 16-bit (2 bytes per sample), and lasts 12 seconds. (a) Using the formula from Section 10, calculate its raw PCM size in bytes and in KB. (b) A story-heavy game has roughly 8,000 such dialogue lines. Based on the streaming-vs-memory rule from Section 10, should individual dialogue lines be streamed or loaded fully into memory? Justify your answer using both the size of one line and the total count.
Show answer

(a) Using bytes = sampleRate x bytesPerSample x channels x seconds:

bytes = 48000 x 2 x 1 x 12 = 1,152,000 bytes
KB    = 1,152,000 / 1024 =~ 1,125 KB (about 1.1 MB)

(b) One line at roughly 1.1 MB is not huge by itself, but the deciding factor from Section 10 is length and frequency together, not just the size of a single file. 8,000 lines at roughly 1.1 MB each would be about 8.8 GB if all loaded into memory at once -- far beyond what any platform can spare for dialogue alone. Dialogue lines are also each played rarely (once, maybe twice, at a specific story moment) rather than constantly retriggered like a footstep, so the zero-delay benefit of preloading matters far less than the memory it would cost. Dialogue should be streamed, loading only the handful of lines about to play, not the whole 8,000-line library.

Exercise 2 -- Fake a Random Container The AudioManager.Play(string id) method from Section 13 always plays the exact same clip at the exact same pitch and volume every time it is called, which will sound repetitive for something like footsteps triggered dozens of times a minute. Modify Play so that every call applies a small random pitch variation between 0.95 and 1.05, and a small random volume variation between 90% and 100% of the configured volume, before playing. Which concept from Section 9 does this approximate, even though it is not a true random container (it does not pick between different recordings)?
Show answer
public void Play(string id)
{
    if (!lookup.TryGetValue(id, out Sound s))
    {
        Debug.LogWarning("No sound registered with id: " + id);
        return;
    }

    oneShotSource.outputAudioMixerGroup = s.group;
    oneShotSource.pitch = Random.Range(0.95f, 1.05f);

    float volumeJitter = Random.Range(0.9f, 1.0f);
    oneShotSource.PlayOneShot(s.clip, s.volume * volumeJitter);
}

This approximates a random container (Section 9), but a shallow version of one: a true random container picks between several different recordings, which breaks up repetition far more convincingly than pitch/volume jitter on the same single recording can. Jitter alone helps, but ten footsteps in a row still sound like variations of one take, not like ten different footfalls -- which is exactly the kind of thing a sound designer would fix by recording and wiring up four or five real variations in Wwise or FMOD, instead of asking a programmer to fake it with randomized math.

Exercise 3 -- Ducking That Survives Overlapping Lines The DialogueDucking script from Section 7 has a bug: if a second dialogue line starts while the first is still playing, its OnDialogueEnd() call (from the first line finishing) will fade the music back up even though the second line is still talking. Fix DialogueDucking so that music only un-ducks once every currently-playing dialogue line has ended, not just the most recent one.
Show answer
using System.Collections;
using UnityEngine;
using UnityEngine.Audio;

public class DialogueDucking : MonoBehaviour
{
    public AudioMixer mixer;
    public float duckedDB = -12f;
    public float normalDB = 0f;
    public float fadeTime = 0.3f;

    private int activeDialogueCount = 0;
    private Coroutine fadeRoutine;

    public void OnDialogueStart()
    {
        activeDialogueCount++;
        if (activeDialogueCount == 1)
        {
            // Only the FIRST overlapping line should trigger a duck --
            // if we are already ducked, there is nothing new to do.
            if (fadeRoutine != null) StopCoroutine(fadeRoutine);
            fadeRoutine = StartCoroutine(FadeMusicTo(duckedDB));
        }
    }

    public void OnDialogueEnd()
    {
        activeDialogueCount--;
        if (activeDialogueCount <= 0)
        {
            activeDialogueCount = 0;
            // Only un-duck once EVERY overlapping line has finished.
            if (fadeRoutine != null) StopCoroutine(fadeRoutine);
            fadeRoutine = StartCoroutine(FadeMusicTo(normalDB));
        }
    }

    private IEnumerator FadeMusicTo(float targetDB)
    {
        mixer.GetFloat("MusicVolume", out float startDB);
        float t = 0f;
        while (t < fadeTime)
        {
            t += Time.deltaTime;
            float current = Mathf.Lerp(startDB, targetDB, t / fadeTime);
            mixer.SetFloat("MusicVolume", current);
            yield return null;
        }
        mixer.SetFloat("MusicVolume", targetDB);
    }
}

The fix replaces a boolean "is dialogue playing" with a counter, activeDialogueCount. Ducking starts only on the transition from 0 to 1 (the first line of an overlap), and un-ducking only fires on the transition from 1 to 0 (the last line ending) -- so a second line starting while the first is still active increments the counter to 2 without touching the mixer, and the first line's OnDialogueEnd() only drops it back to 1, which is not zero, so the music correctly stays ducked until the second line also ends.

← Back to all chapters