Audio Subsystem

A reusable Unity audio architecture for one-shot sound events, weapon audio, music playback, ambience loops, UI sounds, footsteps, mixer routing, and player-facing volume settings.

System Overview

The audio subsystem turns raw sound clips into reusable, routed, tunable gameplay feedback.

What It Does

The Echo Systems Lab audio subsystem separates audio playback from gameplay logic. Instead of placing raw AudioClip arrays directly inside every weapon, button, menu, and player script, the project uses reusable audio data assets that describe how sounds should be selected, routed, pitched, spatialized, throttled, and played.

The central GameAudioManager handles one-shot playback for gameplay, UI, weapons, footsteps, and other short sounds. MusicManager handles looping tracks and crossfades. SceneAmbienceController handles layered environmental loops. AudioSettingsManager connects player-facing sliders to exposed AudioMixer parameters.

This keeps audio flexible as the project grows. A weapon can fire, reload, dry fire, and equip using different audio events. A menu can bind hover and click sounds automatically. A floor surface can define its own footstep, jump, and landing sounds. The result is less audio clutter in gameplay scripts and a cleaner tuning workflow in the Inspector.

Primary Role

Provide reusable audio playback, mixer routing, volume settings, ambience, music, UI feedback, weapon sounds, and footsteps.

Core Loop

Gameplay event → Audio data lookup → Manager playback → Mixer routing → Player hears readable feedback.

Portfolio Value

Demonstrates subsystem design, ScriptableObject-driven tuning, audio routing, UI settings, and feedback architecture.

Audio System Snapshots

Screenshot references for audio settings, audio event data, weapon audio data, mixer routing, music, ambience, footsteps, and UI audio binding.

Unity gameplay screenshot of the audio settings menu showing master, music, SFX, weapons, UI, and ambience volume sliders

Audio Settings Menu

Player-facing sliders connect to exposed mixer parameters for master, music, SFX, weapons, UI, and ambience volume.

  • Settings UI
  • Audio Mixer
  • Volume Control
Unity Inspector screenshot of AudioEventData showing clip array, selection mode, mixer routing, volume, pitch range, spatial settings, cooldown, and playback rules

AudioEventData

Reusable audio event assets define clip selection, pitch variation, volume variation, mixer routing, cooldown, and spatial settings.

  • ScriptableObject
  • One-Shot Audio
  • Randomization
Unity Inspector screenshot of WeaponAudioData showing fire, dry fire, equip, reload start, reload insert, reload end, chamber, shell eject, and safety click audio events

Weapon Audio Data

Weapon sounds are grouped into data assets so fire, dry fire, equip, reload, chamber, shell eject, and safety clicks stay modular.

  • Weapon Audio
  • Reload Events
  • Data-Driven
Unity Audio Mixer screenshot showing master, music, SFX, weapons, UI, and ambience mixer groups with exposed volume parameters

Mixer Routing

Mixer groups separate sound categories so player settings can control broad audio layers without touching individual clips.

  • Master
  • Weapons
  • UI
Unity Inspector screenshot of MusicManager and MusicStarter showing track data, play on start, force restart, fade timing, and persistent music playback

Music Playback

MusicManager uses two AudioSources for crossfading tracks and MusicStarter objects let scenes request music cleanly.

  • MusicManager
  • Crossfade
  • Scene Start
Unity Inspector screenshot of FootstepSurfaceData showing walk, run, jump takeoff, landing audio, and optional step interval override values

Footstep Surfaces

Footstep surface data lets floor objects define walk, run, jump, and landing audio through scene tags and raycast detection.

  • Footsteps
  • Surfaces
  • Landing Audio

Audio Flow

The system keeps playback logic centralized while letting each feature request the sound it needs.

1. Gameplay Event Fires

A weapon fires, reload starts, a UI button is clicked, the player lands, or a scene starts music.

2. Audio Data Is Read

The calling system reads a relevant ScriptableObject such as AudioEventData, WeaponAudioData, UIAudioData, MusicTrackData, or FootstepSurfaceData.

3. Manager Handles Playback

GameAudioManager handles one-shots, MusicManager handles tracks, and SceneAmbienceController handles ambience layers.

4. Mixer Group Routes Sound

Sounds flow into mixer groups like Weapons, UI, Music, SFX, or Ambience so settings can adjust categories independently.

5. Variation Is Applied

Audio events can randomize clip, volume, and pitch so repeated sounds avoid becoming a tiny robotic woodpecker.

6. Cleanup Is Automatic

One-shot audio objects destroy themselves after playback, keeping the scene free of invisible sound debris.

Key Components

Each audio script handles one lane of responsibility so the subsystem stays expandable.

AudioEventData

Defines reusable one-shot behavior: clip selection, random or sequential playback, mixer output, volume range, pitch range, spatial settings, cooldown, and pause behavior.

  • Audio Data
  • Clip Selection
  • Pitch Variation

GameAudioManager

Central one-shot playback service. It creates temporary AudioSource objects, applies audio event settings, plays the selected clip, and destroys the object after playback.

  • One-Shots
  • AudioSource Pool
  • Cleanup

AudioSettingsManager

Loads and saves volume settings, converts normalized slider values to decibels, and applies them to exposed AudioMixer parameters.

  • Volume Settings
  • PlayerPrefs
  • Mixer Params

WeaponAudioData

Groups weapon-related audio events so each weapon can define fire, dry fire, equip, reload start, reload insert, reload end, chamber, shell eject, and safety click sounds.

  • Weapon Sounds
  • Reload Cues
  • Feedback Data

MusicManager

Plays MusicTrackData assets, crossfades between tracks, supports loop settings, and persists across scenes through the bootstrap.

  • Music
  • Crossfade
  • Persistent

SceneAmbienceController

Plays one or more ambience loop layers for a scene, fading them in and out while keeping ambience setup separate from music.

  • Ambience
  • Layered Loops
  • Fades

PlayerFootstepAudioController

Uses movement, grounded state, surface raycasts, and jump events to play walk, run, jump takeoff, and landing audio.

  • Footsteps
  • Surface Tags
  • Jump / Land

UIAudioBinder

Automatically binds UI audio to buttons, sliders, toggles, and dropdowns so menus can gain feedback without manual wiring on every control.

  • UI Sounds
  • Menu Feedback
  • Auto Binding

Subsystem Map

The audio system is split into data, playback, settings, and feature-specific requesters.

How the Pieces Connect

AudioEventData is the core one-shot data unit. It describes what should play and how it should sound. GameAudioManager turns that data into a temporary AudioSource and handles playback details.

Higher-level systems use their own data wrappers. WeaponAudioData groups weapon sounds. UIAudioData groups menu sounds. FootstepSurfaceData groups surface-specific step and landing sounds. MusicTrackData and AmbienceLoopData handle longer looping audio.

AudioSettingsManager sits above all of this and controls mixer volume parameters, giving the player control over broad audio categories without changing individual asset values.

Data Layer

  • AudioEventData
  • WeaponAudioData
  • UIAudioData
  • FootstepSurfaceData
  • MusicTrackData
  • AmbienceLoopData

Playback Layer

  • GameAudioManager
  • MusicManager
  • SceneAmbienceController
  • AudioSource one-shots
  • Crossfade sources
  • Loop sources

Feature Layer

  • PlayerWeaponAudioController
  • PlayerFootstepAudioController
  • UIAudioBinder
  • MusicStarter
  • Settings menu UI
  • Scene ambience objects

Mixer Routing & Settings

The mixer gives the player broad control while preserving per-sound tuning in data assets.

Master Volume

Controls the full audio output and acts as the final player-facing volume gate.

Music Volume

Controls looping music tracks managed by MusicManager and requested by MusicStarter objects.

SFX Volume

Handles general sound effects that do not fit into more specific subgroups.

Weapons Volume

Separates weapon fire, reload, dry fire, equip, chamber, and related mechanical sounds.

UI Volume

Controls button clicks, hovers, toggles, sliders, dropdowns, menu open, menu close, and back sounds.

Ambience Volume

Controls scene ambience layers separately from music, allowing environmental tone to be tuned independently.

Feature Breakdown

The subsystem covers four major audio families: weapons, footsteps, interface sounds, and long-form loops.

Weapons

Weapon audio is routed through PlayerWeaponAudioController and WeaponAudioData. The weapon controller raises events when the weapon fires, dry fires, reloads, inserts a round, finishes reloading, or equips. The audio controller listens to those events and plays the appropriate data-driven sound.

Footsteps

Footsteps use surface data instead of hardcoded clips. The player raycasts down to find a FootstepSurfaceTag, then selects the current surface audio. This supports different walk, run, jump, and landing sounds for tile, grass, metal, wood, or future materials.

Menus

UIAudioBinder can attach sound behavior to buttons, sliders, toggles, and dropdowns. This keeps menu feedback consistent without manually wiring every button click by hand.

Music & Ambience

Music and ambience are separated. MusicManager handles major tracks and crossfades. SceneAmbienceController handles layered loops that belong to the current environment.

Weapon Events

  • Fire
  • Dry fire
  • Equip
  • Reload start
  • Reload insert
  • Reload end

Surface Events

  • Walk footstep
  • Run footstep
  • Jump takeoff
  • Landing
  • Surface override intervals

UI Events

  • Button hover
  • Button click
  • Menu open
  • Menu close
  • Slider change
  • Toggle change

Source Code Snapshot

These dropdowns show the core reusable audio event data and one-shot playback manager. Most other audio pieces are best shown through Inspector screenshots because their value is in clean data wiring.

AudioEventData.cs Reusable one-shot audio data for clips, mixer routing, volume, pitch, spatial settings, cooldowns, and selection mode
//-----AudioEventData.cs START-----
using UnityEngine;
using UnityEngine.Audio;

public enum AudioEventSelectionMode
{
    Random,
    Sequential
}

[CreateAssetMenu(
    fileName = "AudioEventData_NewEvent",
    menuName = "Echo Systems Lab/Audio/Audio Event Data")]
public class AudioEventData : ScriptableObject
{
    [Header("Clips")]
    public AudioClip[] clips;
    public AudioEventSelectionMode selectionMode = AudioEventSelectionMode.Random;

    [Header("Mixer Routing")]
    public AudioMixerGroup outputMixerGroup;

    [Header("Volume")]
    [Range(0f, 1f)]
    public float volume = 1f;

    [Tooltip("Optional extra random multiplier applied after Volume.")]
    public Vector2 volumeMultiplierRange = new Vector2(1f, 1f);

    [Header("Pitch")]
    public Vector2 pitchRange = new Vector2(1f, 1f);

    [Header("Spatial Settings")]
    [Range(0f, 1f)]
    public float spatialBlend = 0f;

    public float minDistance = 1f;
    public float maxDistance = 30f;
    public AudioRolloffMode rolloffMode = AudioRolloffMode.Logarithmic;

    [Header("Playback Rules")]
    [Tooltip("Prevents this AudioEventData from playing again until this cooldown expires.")]
    public float cooldownSeconds = 0f;

    [Tooltip("Useful for pause-menu UI sounds if you later use AudioListener.pause.")]
    public bool ignoreListenerPause = false;

    [Header("Debug")]
    public bool debugLogs = false;

    [System.NonSerialized] private int nextSequentialIndex;
    [System.NonSerialized] private float nextAllowedPlayTime;

    public bool TryGetClip(out AudioClip clip)
    {
        clip = null;

        if (!CanPlay())
        {
            if (debugLogs)
                Debug.Log($"{name} audio event blocked by cooldown.");

            return false;
        }

        clip = SelectClip();

        if (clip == null)
        {
            if (debugLogs)
                Debug.LogWarning($"{name} audio event has no valid clip.");

            return false;
        }

        MarkPlayed();
        return true;
    }

    public void ApplyToAudioSource(AudioSource source, AudioMixerGroup fallbackMixerGroup)
    {
        if (source == null)
            return;

        source.playOnAwake = false;
        source.loop = false;

        source.outputAudioMixerGroup = outputMixerGroup != null
            ? outputMixerGroup
            : fallbackMixerGroup;

        source.volume = GetRandomVolume();
        source.pitch = GetRandomPitch();

        source.spatialBlend = Mathf.Clamp01(spatialBlend);
        source.minDistance = Mathf.Max(0f, minDistance);
        source.maxDistance = Mathf.Max(source.minDistance, maxDistance);
        source.rolloffMode = rolloffMode;
        source.ignoreListenerPause = ignoreListenerPause;
    }

    public float GetRandomPitch()
    {
        float minPitch = Mathf.Min(pitchRange.x, pitchRange.y);
        float maxPitch = Mathf.Max(pitchRange.x, pitchRange.y);

        return Random.Range(minPitch, maxPitch);
    }

    public float GetRandomVolume()
    {
        float minMultiplier = Mathf.Min(volumeMultiplierRange.x, volumeMultiplierRange.y);
        float maxMultiplier = Mathf.Max(volumeMultiplierRange.x, volumeMultiplierRange.y);

        float multiplier = Random.Range(minMultiplier, maxMultiplier);
        return Mathf.Clamp01(volume * multiplier);
    }

    public float GetPlaybackDuration(AudioClip clip, float pitch)
    {
        if (clip == null)
            return 0f;

        float safePitch = Mathf.Max(0.01f, Mathf.Abs(pitch));
        return clip.length / safePitch;
    }

    private bool CanPlay()
    {
        if (cooldownSeconds <= 0f)
            return true;

        return Time.unscaledTime >= nextAllowedPlayTime;
    }

    private void MarkPlayed()
    {
        if (cooldownSeconds <= 0f)
            return;

        nextAllowedPlayTime = Time.unscaledTime + cooldownSeconds;
    }

    private AudioClip SelectClip()
    {
        if (clips == null || clips.Length == 0)
            return null;

        switch (selectionMode)
        {
            case AudioEventSelectionMode.Sequential:
                return SelectSequentialClip();

            case AudioEventSelectionMode.Random:
            default:
                return SelectRandomClip();
        }
    }

    private AudioClip SelectRandomClip()
    {
        if (clips == null || clips.Length == 0)
            return null;

        for (int i = 0; i < clips.Length; i++)
        {
            AudioClip clip = clips[Random.Range(0, clips.Length)];

            if (clip != null)
                return clip;
        }

        return null;
    }

    private AudioClip SelectSequentialClip()
    {
        if (clips == null || clips.Length == 0)
            return null;

        for (int i = 0; i < clips.Length; i++)
        {
            int index = nextSequentialIndex;
            nextSequentialIndex++;

            if (nextSequentialIndex >= clips.Length)
                nextSequentialIndex = 0;

            AudioClip clip = clips[index];

            if (clip != null)
                return clip;
        }

        return null;
    }
}
//-----AudioEventData.cs END-----
GameAudioManager.cs Central one-shot playback, temporary AudioSource creation, mixer fallback routing, and cleanup
//-----GameAudioManager.cs START-----
using System.Collections;
using UnityEngine;
using UnityEngine.Audio;

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

    [Header("Lifetime")]
    [SerializeField] private bool dontDestroyOnLoad = true;

    [Header("Fallback Routing")]
    [Tooltip("Used if an AudioEventData does not have an output mixer group assigned.")]
    [SerializeField] private AudioMixerGroup fallbackMixerGroup;

    [Header("One Shot Cleanup")]
    [SerializeField] private float cleanupPaddingSeconds = 0.15f;

    [Header("Debug")]
    [SerializeField] private bool debugLogs = false;

    private Transform oneShotRoot;

    private void Awake()
    {
        if (Instance != null && Instance != this)
        {
            Destroy(gameObject);
            return;
        }

        Instance = this;

        if (dontDestroyOnLoad)
            DontDestroyOnLoad(gameObject);

        CreateOneShotRoot();
    }

    public AudioSource PlayOneShot(AudioEventData audioEvent)
    {
        return PlayOneShotInternal(
            audioEvent,
            transform.position,
            null,
            false);
    }

    public AudioSource PlayOneShotAtPosition(AudioEventData audioEvent, Vector3 worldPosition)
    {
        return PlayOneShotInternal(
            audioEvent,
            worldPosition,
            null,
            true);
    }

    public AudioSource PlayOneShotAttached(AudioEventData audioEvent, Transform parent)
    {
        Vector3 position = parent != null
            ? parent.position
            : transform.position;

        return PlayOneShotInternal(
            audioEvent,
            position,
            parent,
            parent != null);
    }

    private AudioSource PlayOneShotInternal(
        AudioEventData audioEvent,
        Vector3 worldPosition,
        Transform parent,
        bool useWorldPosition)
    {
        if (audioEvent == null)
            return null;

        if (!audioEvent.TryGetClip(out AudioClip clip))
            return null;

        GameObject audioObject = new GameObject($"AudioEvent_{audioEvent.name}");

        if (parent != null)
        {
            audioObject.transform.SetParent(parent);
            audioObject.transform.localPosition = Vector3.zero;
            audioObject.transform.localRotation = Quaternion.identity;
        }
        else
        {
            audioObject.transform.SetParent(oneShotRoot);
            audioObject.transform.position = useWorldPosition ? worldPosition : transform.position;
            audioObject.transform.rotation = Quaternion.identity;
        }

        AudioSource source = audioObject.AddComponent<AudioSource>();

        audioEvent.ApplyToAudioSource(source, fallbackMixerGroup);

        source.clip = clip;
        source.Play();

        float duration = audioEvent.GetPlaybackDuration(clip, source.pitch);
        StartCoroutine(DestroyAfterPlayback(audioObject, duration + cleanupPaddingSeconds));

        if (debugLogs)
            Debug.Log($"Played audio event: {audioEvent.name} / Clip: {clip.name}");

        return source;
    }

    private IEnumerator DestroyAfterPlayback(GameObject audioObject, float delaySeconds)
    {
        if (delaySeconds > 0f)
            yield return new WaitForSecondsRealtime(delaySeconds);

        if (audioObject != null)
            Destroy(audioObject);
    }

    private void CreateOneShotRoot()
    {
        if (oneShotRoot != null)
            return;

        GameObject rootObject = new GameObject("OneShotAudioRoot");
        rootObject.transform.SetParent(transform);
        rootObject.transform.localPosition = Vector3.zero;
        rootObject.transform.localRotation = Quaternion.identity;

        oneShotRoot = rootObject.transform;
    }
}
//-----GameAudioManager.cs END-----

Problems Solved

This system came from cleaning audio out of gameplay scripts and giving sound its own proper machinery.

Key Iteration Wins

Early weapon audio could work when sounds were directly attached to weapon handling or gameplay scripts, but that approach started to create duplicated fields, unclear routing, and scattered playback logic.

Moving audio into reusable data assets made the system easier to inspect and tune. A reload sound failing was no longer a code mystery. It could be checked directly in the AudioEventData asset, where missing clips, routing, cooldowns, pitch, and volume settings are visible.

The persistent bootstrap also made audio more reliable across scene changes. Music, settings, save systems, and one-shot playback can live on the root bootstrap object, avoiding duplicate managers and scene-specific setup traps.

Scattered Clips

Replaced raw clip references spread across gameplay scripts with reusable audio event data.

Reload Audio Debugging

Audio event assets make missing clips, cooldowns, routing, and pitch settings easy to inspect.

Scene Persistence

Core audio managers live under the bootstrap so they can persist cleanly between hub, menu, and trial scenes.

Portfolio Value

The audio subsystem shows that feedback systems are architecture too, not just polish added at the end.

Reusable Data

AudioEventData, WeaponAudioData, UIAudioData, MusicTrackData, AmbienceLoopData, and FootstepSurfaceData make audio behavior configurable.

Clean Separation

Weapons, UI, movement, music, and ambience request audio without owning low-level playback and cleanup logic.

Player Control

Mixer-backed settings give players category-level control over music, SFX, weapons, UI, and ambience.

Debuggable Workflow

Missing clips and routing issues are visible in Inspector assets instead of buried inside controller branches.

Scalable Feedback

New weapons, surfaces, menus, tracks, and scenes can plug into the same audio framework.

Professional Architecture

The subsystem demonstrates event-based feedback, persistence, data-driven tuning, and player-facing settings.