using System.Collections; using AutomaticDoorSystem.Utilities; using SteamAudio; using Unity.Collections; using Unity.Mathematics; using UnityEngine; using Vector3 = UnityEngine.Vector3; namespace AutomaticDoorSystem { /// /// Keeps a small pool of AudioSources parked on the doors nearest the player. /// Doors, their positions and their all come from /// (i.e. straight off the baked entities), so no per-door /// companion object is needed in the main scene. /// [DefaultExecutionOrder(-50)] public class AudioSourcePoolManager : MonoBehaviour { public static AudioSourcePoolManager Instance { get; private set; } #region Configuration [Header("Prefab Configuration")] [Tooltip("Prefab to spawn for each pooled AudioSource (must have an AudioSource, optionally a SteamAudioSource).\n" + "If null, a basic AudioSource GameObject will be created.")] public GameObject audioSourcePrefab; [Header("Pool Configuration")] [Tooltip("Maximum number of AudioSources in the pool (default: 25)")] [Range(2, 100)] public int maxPoolSize = 25; [Tooltip("Distance from player (Camera.main) at which AudioSources are activated (default: 25m)")] [Range(5f, 100f)] public float cullingDistance = 25f; [Tooltip("Doors farther than this stop simulating Steam Audio reflections (default: 15m). " + "Steam Audio has a hard game-wide cap on simultaneous reflection sources, so keep this tight - " + "the AudioSource itself stays parked and audible up to Culling Distance.")] [Range(2f, 50f)] public float reflectionsCullingDistance = 15f; [Tooltip("Frequency of distance checks in seconds (default: 0.5s)")] [Range(0.1f, 2f)] public float distanceCheckInterval = 0.5f; [Tooltip("Minimum distance between AudioSources to prevent overlap (meters)")] [Range(0f, 5f)] public float minimumSourceSpacing = 0.5f; [Tooltip("How much closer a new door must be to steal an AudioSource from an assigned door (multiplier). Higher = more stable assignments.")] [Range(0.5f, 2f)] public float reassignmentThreshold = 1.3f; [Tooltip("Keep AudioSources assigned to out-of-range doors until needed. Reduces reassignments when player moves back and forth.")] public bool keepOutOfRangeAssignments = true; [Header("Audio Fade Settings")] [Tooltip("Duration of fade in/out when activating/deactivating AudioSources (default: 0.1s)")] [Range(0.01f, 1f)] public float fadeDuration = 0.1f; #endregion #region Private Fields private Transform _cameraTransform; private AudioSource[] _audioSourcePool; private Transform _poolContainer; private PooledAudioSourceState[] _poolStates; private DoorSelectionStrategy _selectionStrategy; private Coroutine _distanceCheckCoroutine; private WaitForSeconds _distanceCheckWait; private bool _duplicateIdWarningLogged; #endregion #region Structs private struct PooledAudioSourceState { public AudioSource audioSource; public SteamAudioSource steamAudioSource; public int assignedDoorNumber; public float targetVolume; public Coroutine fadeCoroutine; public bool isPlayingAudio; public bool configWantsReflections; } #endregion #region Unity Lifecycle private void Awake() { if (Instance != null && Instance != this) { Destroy(this); return; } Instance = this; if (DoorDataBridge.Instance == null && GetComponent() == null) { gameObject.AddComponent(); } InitializePool(); _selectionStrategy = new DoorSelectionStrategy(500, maxPoolSize, Allocator.Persistent); _distanceCheckWait = new WaitForSeconds(distanceCheckInterval); CacheCameraReference(); } private void OnDestroy() { if (Instance == this) { Instance = null; } if (_selectionStrategy.IsCreated) { _selectionStrategy.Dispose(); } } private void OnEnable() { StartDistanceChecks(); } private void OnDisable() { StopDistanceChecks(); if (_poolStates == null) return; for (int i = 0; i < _poolStates.Length; i++) { if (_poolStates[i].fadeCoroutine != null) { StopCoroutine(_poolStates[i].fadeCoroutine); } } } #endregion #region Initialization private void InitializePool() { _poolContainer = new GameObject("AudioSource Pool").transform; _poolContainer.SetParent(transform); _poolContainer.localPosition = Vector3.zero; _audioSourcePool = new AudioSource[maxPoolSize]; _poolStates = new PooledAudioSourceState[maxPoolSize]; bool usingPrefab = audioSourcePrefab != null; for (int i = 0; i < maxPoolSize; i++) { GameObject audioSourceObj; AudioSource audioSource; if (usingPrefab) { audioSourceObj = Instantiate(audioSourcePrefab, _poolContainer); audioSourceObj.name = $"PooledAudioSource_{i:D2}"; audioSourceObj.transform.localPosition = Vector3.zero; audioSource = audioSourceObj.GetComponent(); if (audioSource == null) { audioSource = audioSourceObj.AddComponent(); ConfigureAudioSource(audioSource); } } else { audioSourceObj = new GameObject($"PooledAudioSource_{i:D2}"); audioSourceObj.transform.SetParent(_poolContainer); audioSourceObj.transform.localPosition = Vector3.zero; audioSource = audioSourceObj.AddComponent(); ConfigureAudioSource(audioSource); } audioSource.playOnAwake = false; _audioSourcePool[i] = audioSource; _poolStates[i] = new PooledAudioSourceState { audioSource = audioSource, steamAudioSource = audioSourceObj.GetComponent(), assignedDoorNumber = -1, targetVolume = 1f, fadeCoroutine = null, isPlayingAudio = false, configWantsReflections = false }; audioSource.volume = 0f; audioSource.mute = true; // Parked INACTIVE: an enabled SteamAudioSource registers with the Steam Audio // simulator whether or not anything is playing, and its reflections pass has a // hard game-wide source cap. A slot only goes active while assigned to a door. audioSourceObj.SetActive(false); } } private void ConfigureAudioSource(AudioSource audioSource) { audioSource.spatialBlend = 1f; // 3D sound audioSource.minDistance = 1f; audioSource.maxDistance = 25f; audioSource.rolloffMode = AudioRolloffMode.Linear; } private void CacheCameraReference() { if (Camera.main != null) { _cameraTransform = Camera.main.transform; } } #endregion #region Distance-Based Activation private void StartDistanceChecks() { if (_distanceCheckCoroutine != null) { StopCoroutine(_distanceCheckCoroutine); } UpdateAudioSourceActivation(); _distanceCheckCoroutine = StartCoroutine(DistanceCheckRoutine()); } private void StopDistanceChecks() { if (_distanceCheckCoroutine != null) { StopCoroutine(_distanceCheckCoroutine); _distanceCheckCoroutine = null; } } private IEnumerator DistanceCheckRoutine() { while (true) { yield return _distanceCheckWait; UpdateAudioSourceActivation(); } } private void UpdateAudioSourceActivation() { if (_cameraTransform == null) { CacheCameraReference(); if (_cameraTransform == null) return; } var bridge = DoorDataBridge.Instance; if (bridge == null || !_selectionStrategy.IsCreated) return; var allDoors = bridge.GetAllDoorInfo(); if (allDoors == null || allDoors.Count == 0) return; float3 playerPosition = _cameraTransform.position; _selectionStrategy.BeginSelection(); for (int i = 0; i < allDoors.Count; i++) { var doorInfo = allDoors[i]; // A door with no audio config has nothing to play - don't let it hold a slot. if (doorInfo.audioConfig == null) continue; _selectionStrategy.AddCandidate(doorInfo.doorId, doorInfo.audioPosition, playerPosition); } _selectionStrategy.FilterByDistance(cullingDistance); _selectionStrategy.SortByDistance(); int duplicateIds = _selectionStrategy.RemoveDuplicateIds(); if (duplicateIds > 0 && !_duplicateIdWarningLogged) { _duplicateIdWarningLogged = true; Debug.LogError( $"[AudioSourcePoolManager] {duplicateIds} door(s) in range share a Door Id with another door. " + "AudioSources are assigned per Door Id, so the duplicates stay silent. " + "Run Tools > AutomaticDoorSystem > Setup Validator to find and renumber them.", this); } _selectionStrategy.RemoveSpatialDuplicates(minimumSourceSpacing); // A source that is mid-clip must not be stolen out from under the sound it is playing. for (int i = 0; i < maxPoolSize; i++) { var state = _poolStates[i]; _selectionStrategy.SetSlotLocked(i, state.isPlayingAudio && state.audioSource != null && state.audioSource.isPlaying); } _selectionStrategy.AssignPoolSlots(maxPoolSize, keepOutOfRangeAssignments, reassignmentThreshold); for (int i = 0; i < maxPoolSize; i++) { int doorId = _selectionStrategy.GetDoorIdForSlot(i); if (doorId == -1) { DeactivateAudioSource(i); continue; } if (bridge.TryGetDoorInfo(doorId, out var doorInfo) && doorInfo.audioConfig != null) { ActivateAudioSourceForDoor(i, doorInfo, playerPosition); } else { // The door went away (subscene unloaded) - release the slot. _selectionStrategy.UnassignSlot(i); DeactivateAudioSource(i); } } } private void ActivateAudioSourceForDoor(int poolIndex, DoorDataBridge.DoorInfo doorInfo, Vector3 playerPosition) { PooledAudioSourceState state = _poolStates[poolIndex]; if (state.audioSource == null) return; if (!state.audioSource.gameObject.activeSelf) { state.audioSource.gameObject.SetActive(true); } if (state.isPlayingAudio && state.audioSource.isPlaying) { state.audioSource.transform.position = doorInfo.audioPosition; ApplyReflectionsCulling(state, doorInfo.audioPosition, playerPosition); return; } if (state.assignedDoorNumber == doorInfo.doorId) { state.audioSource.transform.position = doorInfo.audioPosition; ApplyReflectionsCulling(state, doorInfo.audioPosition, playerPosition); if (DoorAudioBridge.Instance != null) { DoorAudioBridge.Instance.RegisterAudioSource(doorInfo.doorId, state.audioSource, doorInfo.audioConfig); } return; } if (state.assignedDoorNumber != -1 && DoorAudioBridge.Instance != null) { DoorAudioBridge.Instance.UnregisterAudioSource(state.assignedDoorNumber, state.audioSource); } var config = doorInfo.audioConfig; state.targetVolume = config.volume; config.ApplyToAudioSource(state.audioSource); if (state.steamAudioSource != null) { config.ApplyToSteamAudioSource(state.steamAudioSource); } // Remember what the config asked for, so distance culling can restore reflections // when the door comes close again without forcing them on config-disabled doors. state.configWantsReflections = config.useSteamAudio && config.reflections; state.audioSource.transform.position = doorInfo.audioPosition; ApplyReflectionsCulling(state, doorInfo.audioPosition, playerPosition); if (state.fadeCoroutine != null) { StopCoroutine(state.fadeCoroutine); } if (state.audioSource.mute || state.audioSource.volume < 0.01f) { state.audioSource.mute = false; state.audioSource.volume = 0f; state.fadeCoroutine = StartCoroutine(FadeVolume(poolIndex, state.targetVolume, fadeDuration)); } else { state.audioSource.mute = false; state.audioSource.volume = state.targetVolume; } if (DoorAudioBridge.Instance != null) { DoorAudioBridge.Instance.RegisterAudioSource(doorInfo.doorId, state.audioSource, config); } state.assignedDoorNumber = doorInfo.doorId; // The door id in the name makes the hierarchy and the audio doctor legible: which // pooled source belongs to which door is otherwise invisible. state.audioSource.gameObject.name = $"PooledAudioSource_{poolIndex:D2} [door {doorInfo.doorId}]"; _poolStates[poolIndex] = state; } private void DeactivateAudioSource(int poolIndex) { PooledAudioSourceState state = _poolStates[poolIndex]; if (state.assignedDoorNumber == -1 || state.audioSource == null) { return; } if (state.isPlayingAudio && state.audioSource.isPlaying) { return; } if (state.fadeCoroutine != null) { StopCoroutine(state.fadeCoroutine); } state.fadeCoroutine = StartCoroutine(FadeOutAndDeactivate(poolIndex)); _poolStates[poolIndex] = state; } private IEnumerator FadeVolume(int poolIndex, float targetVolume, float duration) { PooledAudioSourceState state = _poolStates[poolIndex]; if (state.audioSource == null) yield break; float startVolume = state.audioSource.volume; float elapsed = 0f; while (elapsed < duration) { elapsed += Time.deltaTime; float t = elapsed / duration; state.audioSource.volume = Mathf.Lerp(startVolume, targetVolume, t); yield return null; } state.audioSource.volume = targetVolume; state = _poolStates[poolIndex]; state.fadeCoroutine = null; _poolStates[poolIndex] = state; } private IEnumerator FadeOutAndDeactivate(int poolIndex) { PooledAudioSourceState state = _poolStates[poolIndex]; if (state.audioSource == null) yield break; int previousDoorNumber = state.assignedDoorNumber; float startVolume = state.audioSource.volume; float elapsed = 0f; while (elapsed < fadeDuration) { elapsed += Time.deltaTime; float t = elapsed / fadeDuration; state.audioSource.volume = Mathf.Lerp(startVolume, 0f, t); yield return null; } state.audioSource.volume = 0f; state.audioSource.mute = true; if (previousDoorNumber != -1 && DoorAudioBridge.Instance != null) { DoorAudioBridge.Instance.UnregisterAudioSource(previousDoorNumber, state.audioSource); } state = _poolStates[poolIndex]; state.fadeCoroutine = null; state.assignedDoorNumber = -1; if (state.audioSource != null) { state.audioSource.gameObject.name = $"PooledAudioSource_{poolIndex:D2}"; // Unassigned = out of the Steam Audio simulator entirely (see InitializePool). state.audioSource.gameObject.SetActive(false); } _poolStates[poolIndex] = state; } /// /// Steam Audio's reflections pass has a hard game-wide cap on simultaneous sources /// ("Simulating reflections for N sources, which is more than the max"). Reflections are /// inaudible from far doors anyway, so only sources within /// keep the flag their config asked for. /// private void ApplyReflectionsCulling(in PooledAudioSourceState state, Vector3 doorPosition, Vector3 playerPosition) { if (state.steamAudioSource == null) return; bool wantReflections = state.configWantsReflections && Vector3.Distance(doorPosition, playerPosition) <= reflectionsCullingDistance; if (state.steamAudioSource.reflections != wantReflections) { state.steamAudioSource.reflections = wantReflections; } } #endregion #region Public API public void NotifyAudioPlayback(int doorNumber, AudioSource audioSource, float clipLength) { if (_poolStates == null) return; for (int i = 0; i < _poolStates.Length; i++) { if (_poolStates[i].audioSource != audioSource || _poolStates[i].assignedDoorNumber != doorNumber) continue; var state = _poolStates[i]; state.isPlayingAudio = true; _poolStates[i] = state; StartCoroutine(ClearPlaybackFlag(i, clipLength)); break; } } private IEnumerator ClearPlaybackFlag(int poolIndex, float duration) { yield return new WaitForSeconds(duration); if (poolIndex >= 0 && poolIndex < _poolStates.Length) { var state = _poolStates[poolIndex]; state.isPlayingAudio = false; _poolStates[poolIndex] = state; } } /// /// Diagnostic lookup of the pool slot currently holding a door, if any. Used by the /// Door Doctor window; not part of the playback path. /// public bool TryGetPoolSlot(int doorNumber, out int poolIndex, out AudioSource audioSource, out bool isPlayingFlag) { poolIndex = -1; audioSource = null; isPlayingFlag = false; if (_poolStates == null) return false; for (int i = 0; i < _poolStates.Length; i++) { if (_poolStates[i].assignedDoorNumber != doorNumber) continue; poolIndex = i; audioSource = _poolStates[i].audioSource; isPlayingFlag = _poolStates[i].isPlayingAudio; return true; } return false; } [ContextMenu("Force Update Audio Sources")] public void ForceUpdateAudioSources() { UpdateAudioSourceActivation(); } public int GetActiveAudioSourceCount() { int count = 0; for (int i = 0; i < maxPoolSize; i++) { if (_poolStates[i].assignedDoorNumber != -1) { count++; } } return count; } /// /// Pooled sources currently registered with the Steam Audio simulator (active GameObjects) /// and, of those, how many are in the reflections pass. Diagnostic (Door Doctor) - each /// reflection source counts against Steam Audio's game-wide cap. /// public void GetSteamAudioLoad(out int simulatedSources, out int reflectionSources) { simulatedSources = 0; reflectionSources = 0; if (_poolStates == null) return; for (int i = 0; i < _poolStates.Length; i++) { var state = _poolStates[i]; if (state.audioSource == null || !state.audioSource.gameObject.activeInHierarchy) continue; if (state.steamAudioSource != null) { simulatedSources++; if (state.steamAudioSource.reflections) reflectionSources++; } } } #endregion #region Debug private void OnDrawGizmosSelected() { if (_cameraTransform == null) return; Gizmos.color = Color.yellow; Gizmos.DrawWireSphere(_cameraTransform.position, cullingDistance); Gizmos.color = Color.cyan; Gizmos.DrawWireSphere(_cameraTransform.position, reflectionsCullingDistance); if (_poolStates == null) return; Gizmos.color = Color.green; for (int i = 0; i < maxPoolSize; i++) { if (_poolStates[i].assignedDoorNumber != -1 && _poolStates[i].audioSource != null) { Gizmos.DrawWireSphere(_poolStates[i].audioSource.transform.position, 1f); } } } #endregion } }