# 2. Pooled AudioSource Service Part of [[Interactable Audio]]. Previous: [[1. Interactable Audio Patterns]]. Next: [[3. Interactable Components with Audio]]. **Goal:** `AudioService.Play(sound, position)` from anywhere, with no `Instantiate`/`Destroy` at runtime, mixer routing preserved, and hard limits so a pile of 40 crates landing at once doesn't eat every voice. > [!note] Verification status > `ObjectPool<T>` constructor parameters (`createFunc`, `actionOnGet`, `actionOnRelease`, `actionOnDestroy`, `collectionCheck`, `defaultCapacity`, `maxSize`) and members (`Get`, `Release`, `CountActive`, `CountInactive`, `CountAll`) from the 6000.5 reference example (2026-11-07). `AudioClip` inherits from `AudioResource` in 6000.5, so `AudioSource.resource` accepts either a clip or an Audio Random Container. `AudioSettings.GetConfiguration().numRealVoices` exists in 6000.5. **Code not compiled or play-tested.** ## 2.1 Design ``` AudioService (one per game, DontDestroyOnLoad) |- ObjectPool<PooledEmitter> (GameObject + AudioSource each, inactive when pooled) |- active list (for global cap and stealing) \- per-sound instance lists (for InteractionSound.maxInstances) Play(sound, pos, follow?) -> AudioHandle { emitter, generation } handle.Stop(fade) is safe even after the emitter was recycled ``` Patterns used (see [[Game Programming Patterns (Review)]]): **Object Pool** (obviously), **Service Locator** (`AudioService.Instance` behind a static accessor that can be swapped for a null service in tests), and a *generation counter* so stale handles can't stop a recycled emitter. ## 2.2 Emitter ```csharp using UnityEngine; /// One pooled voice. Lives under the AudioService; activated on Get, deactivated on Release. [RequireComponent(typeof(AudioSource))] public sealed class PooledEmitter : MonoBehaviour { public AudioSource Source { get; private set; } public InteractionSound Sound { get; private set; } public int Generation { get; private set; } public float StartTime { get; private set; } public int Priority => Sound != null ? Sound.priority : 128; AudioService _owner; Transform _follow; Vector3 _followOffset; float _endTime; // unscaled time when a non-looping clip should be done; <0 = poll isPlaying float _fadeStartVol, _fadeStart, _fadeDuration = -1f; bool _armed; // skip first frame before isPlaying becomes reliable public void Init(AudioService owner) { _owner = owner; Source = GetComponent<AudioSource>(); Source.playOnAwake = false; } public void Begin(InteractionSound sound, Vector3 position, Transform follow, float volumeMul, float pitchMul) { Generation++; Sound = sound; _follow = follow; _followOffset = follow ? position - follow.position : Vector3.zero; transform.position = position; _fadeDuration = -1f; _armed = false; var s = Source; s.outputAudioMixerGroup = sound.output; s.volume = sound.volume * Random.Range(sound.volumeJitter.x, sound.volumeJitter.y) * volumeMul; s.pitch = Random.Range(sound.pitchRange.x, sound.pitchRange.y) * pitchMul; s.priority = sound.priority; s.spatialBlend = sound.spatialBlend; s.minDistance = sound.minDistance; s.maxDistance = sound.maxDistance; s.rolloffMode = sound.rolloff; s.loop = sound.loop; AudioClip clip = sound.resource == null ? sound.PickClip() : null; s.resource = sound.resource != null ? sound.resource : clip; // AudioClip is an AudioResource s.Play(); StartTime = Time.unscaledTime; _endTime = (!sound.loop && clip != null && Mathf.Abs(s.pitch) > 0.01f) ? Time.unscaledTime + clip.length / Mathf.Abs(s.pitch) + 0.05f : -1f; } public void Stop(float fade) { if (fade <= 0f) { _owner.Release(this); return; } _fadeStartVol = Source.volume; _fadeStart = Time.unscaledTime; _fadeDuration = fade; } public void ResetForPool() { Source.Stop(); Source.resource = null; Source.loop = false; Sound = null; _follow = null; _fadeDuration = -1f; } void LateUpdate() { if (_follow != null) transform.position = _follow.position + _followOffset; if (_fadeDuration > 0f) { float t = (Time.unscaledTime - _fadeStart) / _fadeDuration; Source.volume = Mathf.Lerp(_fadeStartVol, 0f, t); if (t >= 1f) { _owner.Release(this); return; } } if (AudioListener.pause) return; // paused audio is not "finished" if (Sound != null && Sound.loop) return; // loops end only via Stop if (_endTime > 0f) { if (Time.unscaledTime >= _endTime) _owner.Release(this); } else if (_armed && !Source.isPlaying) { _owner.Release(this); // Random Container / unknown length: poll } _armed = true; } } ``` > [!warning] Code not compiled in Unity > Not compiled. The end-time path uses `Time.unscaledTime`, so slow-motion (`timeScale`) won't make sounds release early or late - but if you pitch audio with time scale you must recompute. Following a destroyed transform: `_follow != null` uses Unity's overloaded null check, so the emitter simply stops following. ## 2.3 Handle ```csharp /// Safe reference to a playing sound. Default(AudioHandle) is "nothing". public readonly struct AudioHandle { readonly PooledEmitter _emitter; readonly int _generation; public AudioHandle(PooledEmitter emitter) { _emitter = emitter; _generation = emitter != null ? emitter.Generation : -1; } public bool IsPlaying => _emitter != null && _emitter.Generation == _generation && _emitter.isActiveAndEnabled; public void Stop(float fade = 0f) { if (IsPlaying) _emitter.Stop(fade); } public void SetVolumeScale(float scale) { if (IsPlaying && _emitter.Sound != null) _emitter.Source.volume = _emitter.Sound.volume * scale; } } ``` ## 2.4 Service ```csharp using System.Collections.Generic; using UnityEngine; using UnityEngine.Pool; public sealed class AudioService : MonoBehaviour { public static AudioService Instance { get; private set; } [SerializeField] int prewarm = 16; [SerializeField] int maxPoolSize = 64; [Tooltip("Hard cap on simultaneous pooled voices. Keep below the project's real voice count.")] [SerializeField] int maxActiveVoices = 40; [SerializeField] bool showDebug; ObjectPool<PooledEmitter> _pool; readonly List<PooledEmitter> _active = new List<PooledEmitter>(64); readonly Dictionary<InteractionSound, List<PooledEmitter>> _bySound = new Dictionary<InteractionSound, List<PooledEmitter>>(); void Awake() { if (Instance != null && Instance != this) { Destroy(gameObject); return; } Instance = this; DontDestroyOnLoad(gameObject); int realVoices = AudioSettings.GetConfiguration().numRealVoices; if (maxActiveVoices > realVoices - 8) Debug.LogWarning(quot;AudioService cap {maxActiveVoices} is close to numRealVoices {realVoices}; music/ambience may be virtualized.", this); _pool = new ObjectPool<PooledEmitter>( createFunc: CreateEmitter, actionOnGet: e => e.gameObject.SetActive(true), actionOnRelease: e => { e.ResetForPool(); e.gameObject.SetActive(false); }, actionOnDestroy: e => { if (e) Destroy(e.gameObject); }, collectionCheck: true, defaultCapacity: prewarm, maxSize: maxPoolSize); // Prewarm so the first explosion doesn't instantiate 16 GameObjects in one frame. var warm = new List<PooledEmitter>(prewarm); for (int i = 0; i < prewarm; i++) warm.Add(_pool.Get()); foreach (var e in warm) _pool.Release(e); } PooledEmitter CreateEmitter() { var go = new GameObject("PooledEmitter"); go.transform.SetParent(transform, false); go.AddComponent<AudioSource>(); var e = go.AddComponent<PooledEmitter>(); e.Init(this); go.SetActive(false); return e; } // ---------------- public API ---------------- public static AudioHandle Play(InteractionSound sound, Vector3 position, Transform follow = null, float volumeMul = 1f, float pitchMul = 1f) => Instance != null ? Instance.PlayInternal(sound, position, follow, volumeMul, pitchMul) : default; AudioHandle PlayInternal(InteractionSound sound, Vector3 position, Transform follow, float volumeMul, float pitchMul) { if (sound == null || (sound.resource == null && (sound.clips == null || sound.clips.Length == 0))) return default; // Cooldown (anti-spam) float now = Time.unscaledTime; if (now - sound.lastPlayTime < sound.cooldown) return default; sound.lastPlayTime = now; // Per-sound instance limit: steal the oldest instance of this sound if (!_bySound.TryGetValue(sound, out var list)) _bySound[sound] = list = new List<PooledEmitter>(sound.maxInstances); if (sound.maxInstances > 0 && list.Count >= sound.maxInstances) Release(list[0]); // Global cap: steal the lowest-priority (highest number), oldest voice - if it's not more important than us if (_active.Count >= maxActiveVoices) { PooledEmitter victim = null; foreach (var e in _active) if (victim == null || e.Priority > victim.Priority || (e.Priority == victim.Priority && e.StartTime < victim.StartTime)) victim = e; if (victim == null || victim.Priority < sound.priority) return default; // everyone playing matters more Release(victim); } PooledEmitter em = _pool.Get(); em.Begin(sound, position, follow, volumeMul, pitchMul); _active.Add(em); list.Add(em); return new AudioHandle(em); } /// Called by emitters when finished, and by stealing logic. public void Release(PooledEmitter e) { if (e == null || !e.gameObject.activeSelf) return; // already released if (e.Sound != null && _bySound.TryGetValue(e.Sound, out var list)) list.Remove(e); _active.Remove(e); _pool.Release(e); } public void StopAll(float fade = 0f) { for (int i = _active.Count - 1; i >= 0; i--) _active[i].Stop(fade); } void OnGUI() { if (!showDebug) return; GUI.Label(new Rect(10, Screen.height - 60, 600, 50), quot;AudioService active {_active.Count}/{maxActiveVoices} pool inactive {_pool.CountInactive} all {_pool.CountAll}"); } } ``` > [!warning] Code not compiled in Unity > Not compiled or play-tested. `List.Remove` on small lists is fine at these sizes (tens of voices); profile before optimizing. `collectionCheck: true` catches double-release in development - consider disabling it in release builds. `maxSize` limits how many *inactive* emitters the pool keeps; extra released ones are destroyed via `actionOnDestroy`. ## 2.5 Setup steps 1. Bootstrap scene: empty `AudioService` GameObject + component (and `MixerGroups` / `SnapshotDirector` from [[Audio Mixers and Character Audio]] if you use them). Load it first ([[SceneManagement in Unity 6]] bootstrap pattern). 2. Check **Project Settings > Audio** for the real/virtual voice counts; set `maxActiveVoices` comfortably below real voices, leaving room for music, ambience and the player rig. 3. Create `InteractionSound` assets and route their `output` to `SFX/Interactables`. 4. Call `AudioService.Play(sound, transform.position)` from interactables (Part 3). 5. Turn on `showDebug` and stress test: spawn 50 crates and drop them. The active count should cap; nothing should allocate after warm-up (check with the Profiler's GC Alloc column). ## Docs - `ObjectPool<T>`: https://docs.unity3d.com/6000.5/Documentation/ScriptReference/Pool.ObjectPool_1.html - `LinkedPool<T>` (alternative): https://docs.unity3d.com/6000.5/Documentation/ScriptReference/Pool.LinkedPool_1.html - `AudioSource.resource`: https://docs.unity3d.com/6000.5/Documentation/ScriptReference/AudioSource-resource.html - `AudioSource.isVirtual`: https://docs.unity3d.com/6000.5/Documentation/ScriptReference/AudioSource-isVirtual.html - `AudioSettings.GetConfiguration`: https://docs.unity3d.com/6000.5/Documentation/ScriptReference/AudioSettings.GetConfiguration.html - `AudioConfiguration.numRealVoices`: https://docs.unity3d.com/6000.5/Documentation/ScriptReference/AudioConfiguration-numRealVoices.html - `AudioClip`: https://docs.unity3d.com/6000.5/Documentation/ScriptReference/AudioClip.html