Audio

Music, sound effects, and voice through a Unity AudioMixer with pooled sources and pause-aware fades.

Music, effects, and voice through three AudioMixer channels · Pooled sources, automatic fades, pause-aware behavior · No-op replacement: NullAudio

CGS routes all in-game sound through IAudioService — three dedicated channels on a Unity AudioMixer: Music (looping background tracks), Sfx (one-shot sound effects, 2D or positioned in 3D), and Voice (dialogue and announcements, one clip at a time). Pooled AudioSource components mean no garbage-collection spikes when effects fire rapidly; fades and crossfades are built in; and pause behaves the way players expect — music keeps playing while gameplay effects stop. No network calls, no third-party plugins, IL2CPP-safe.

What it does

  • Music with automatic fades. PlayMusic(clip) fades the track in; call it again with a different clip and the service crossfades instead of cutting. StopMusic() fades out.
  • Pooled sound effects. PlaySfx(clip) grabs a source from a pre-allocated pool, applies a slight random pitch variation (about ±10% by default) so repeated effects sound natural, and returns the source to the pool when the clip ends. When every source is busy, the oldest playing effect is stopped to make room.
  • One-at-a-time voice. PlayVoice(clip) stops whatever voice line was playing and starts the new one — dialogue never overlaps itself.
  • Mixer snapshots. A snapshot is a saved set of mixer volume levels. Transition between presets — normal gameplay, music ducked under dialogue, muffled behind a menu — with one call and a smooth fade.
  • Live volume settings. Volume sliders wired through the Configuration service reach the mixer in real time.

Getting the service

Resolve in Awake (on a class marked [DefaultExecutionOrder(100)]) and cache:

using CommonGameSystem.Core;
using UnityEngine;

[DefaultExecutionOrder(100)]
public class SoundManager : MonoBehaviour
{
    private IAudioService _audio;

    private void Awake()
    {
        _audio = ServiceLocator.Resolve<IAudioService>();
    }
}

Do not call Resolve inside Update() — the lookup is a dictionary search. Cache it once.

API reference

Background music

void PlayMusic(AudioClip clip, float fadeInSeconds = -1f, float crossfadeSeconds = -1f)

Starts a looping track with a fade-in. If a track is already playing, the service crossfades to the new one over crossfadeSeconds instead. Pass -1f to use the defaults (1.5-second fade-in, 1.0-second crossfade); pass 0f for a hard cut.

void StopMusic(float fadeOutSeconds = -1f) // Fade out and stop (default 2.0 seconds)
bool IsMusicPlaying { get; }               // true while a track is playing

Sound effects (2D and 3D)

bool PlaySfx(AudioClip clip, float pitchVariation = -1f)
bool PlaySfx(AudioClip clip, Vector3 worldPosition, float pitchVariation = -1f)

The first overload plays a flat 2D effect; the second plays a 3D effect at a world position with distance-based rolloff. Both return true on success, or false when the clip is null, not yet loaded, or the pool rejected the request. pitchVariation is the random pitch half-range: -1f uses the default (about ±10%), 0f disables variation, and values above 0.5f are clamped with a warning.

int ActiveSfxCount { get; } // Number of effect sources currently playing

Voice and dialogue

void PlayVoice(AudioClip clip) // Starts the clip; any prior voice clip stops immediately
void StopVoice()               // Stops the current voice clip
bool IsVoicePlaying { get; }   // true while voice is playing

Mixer snapshots and global stop

void SetSnapshot(AudioSnapshot snapshot, float transitionSeconds = -1f)
void SetSnapshot(string snapshotName, float transitionSeconds = -1f)
void StopAll()

The enum overload transitions to one of the built-in presets — Normal, DialogueDucked (music and effects lowered about 6 dB under dialogue), or MenuOpen. The string overload targets any snapshot you added to your own mixer. StopAll() silences every channel immediately, with no fade.

Full example

using CommonGameSystem.Core;
using UnityEngine;

[DefaultExecutionOrder(100)]
public class GameAudio : MonoBehaviour
{
    [SerializeField] private AudioClip _explosionClip;

    private IAudioService _audio;

    private void Awake()
    {
        _audio = ServiceLocator.Resolve<IAudioService>();
    }

    public void PlayBattleMusic(AudioClip clip)
    {
        // Fade in over 1.5 seconds; if music is already playing,
        // the service crossfades to the new track automatically.
        _audio.PlayMusic(clip, fadeInSeconds: 1.5f);
    }

    public void PlayExplosion(Vector3 position)
    {
        // 3D effect with the default pitch variation (about ±10%)
        _audio.PlaySfx(_explosionClip, position);
    }

    public void DialogueStart()
    {
        // Duck music and effects while dialogue plays
        _audio.SetSnapshot(AudioSnapshot.DialogueDucked);
    }

    public void PlayDialogue(AudioClip voiceClip)
    {
        _audio.PlayVoice(voiceClip);
    }

    public void DialogueEnd()
    {
        _audio.SetSnapshot(AudioSnapshot.Normal);
    }
}

Turning it off

ServiceLocator.Replace<IAudioService>(new NullAudio());

This one line silences the service: every method becomes a no-op and no game code changes. Useful for tests, voice-over recording sessions, or when you bring your own audio middleware. The AudioListener is unaffected — your camera still needs one, but nothing plays through it.

Common pitfalls

  • Main thread only. All PlayMusic, PlaySfx, PlayVoice, and SetSnapshot calls must happen on the main thread. If a worker thread needs to trigger sound, marshal the call to the main thread first — the Scheduler can do this for you.

  • Pause behavior is built in. Music fades run on the Time service's Background clock, so music keeps playing when gameplay pauses. Effect timers run on the Gameplay clock, so effects stop. "Music continues, effects stop" needs no extra code from you.

  • Prewarm the pool before busy scenes. By default, 8 effect sources are pre-allocated at startup. If your first combat scene fires 20 explosions at once, the pool grows on demand and can cause a frame hitch. Raise AudioOptions.sfxPoolPrewarmCount at startup to pre-allocate more — a little startup memory for zero runtime stutter.

  • Volume sliders need a flush to apply. The Configuration service batches setting writes, so calling Set(new AudioSettings { ... }) alone only queues the change — the mixer hears nothing yet. Call FlushPending<AudioSettings>() on your resolved IConfiguration right after Set for real-time slider feedback. Once flushed, the mixer updates immediately.

  • Custom AudioMixer parameter names. If you supply your own AudioMixer through AudioOptions, it must expose four parameters: MasterVolume, MusicVolume, SfxVolume, and VoiceVolume (all in dB). If your names differ, point the service at them via AudioOptions.masterParam, musicParam, sfxParam, and voiceParam.

  • Clip length drives effect cleanup. After PlaySfx(clip), the source returns to the pool automatically after clip.length seconds, measured in unscaled time — slow motion does not delay cleanup. No manual bookkeeping needed.

  • Custom types need link.xml for IL2CPP. If you add your own configuration types that hold audio settings, add a link.xml entry so IL2CPP does not strip them:

    <assembly fullname="Assembly-CSharp">
      <type fullname="YourGame.CustomAudioConfig" preserve="all" />
    </assembly>
    
  • Time — the clocks behind pause-aware fades
  • Configuration — settings groups and FlushPending
  • Scheduler — marshaling calls to the main thread