# 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