using System; using System.Collections.Generic; using System.Threading; using BriarQueen.Data.Identifiers; using BriarQueen.Framework.Coordinators.Events; using BriarQueen.Framework.Events.Audio; using BriarQueen.Framework.Events.UI; using BriarQueen.Framework.Managers.Audio.Data; using BriarQueen.Framework.Registries; using Cysharp.Threading.Tasks; using PrimeTween; using UnityEngine; using UnityEngine.Audio; using VContainer; using Object = UnityEngine.Object; namespace BriarQueen.Framework.Managers.Audio { /// /// AudioManager (modifier-based mixer control) /// Key idea: /// - Settings set "base" volumes (in dB) per mixer parameter. /// - Runtime states (Pause duck, Voice duck) apply "modifiers" (extra dB offsets). /// - Effective mixer value is always: effectiveDb = baseDb + modifiersDb /// - UI and Ambience route through SFX channel/group. /// - SFX pool is transient — new channels are spawned on demand and /// finished channels are reaped before each play. /// public class AudioManager : IDisposable, IManager { private const int INITIAL_SFX_SOURCES = 8; private const float PAUSE_DUCK_TARGET_DB = -18f; private const float PAUSE_DUCK_FADE_SECONDS = 0.25f; private const float DEFAULT_VOICE_DUCK_TARGET_DB = -20f; private readonly AudioMixer _audioMixer; private readonly AudioRegistry _audioRegistry; private readonly EventCoordinator _eventCoordinator; private readonly Dictionary _baseDb = new(); private readonly List _createdAudioObjects = new(); private readonly List _sfxChannels = new(); private AudioSource _musicSourceA; private AudioSource _musicSourceB; private AudioSource _voiceSource; private VoiceKey _activeVoiceKey = VoiceKey.None; private SubtitleKey _activeSubtitleKey = SubtitleKey.None; private AudioFileSo _currentMusicTrack; private CancellationTokenSource _musicDuckCts; private CancellationTokenSource _musicFadeCts; private CancellationTokenSource _voiceCts; private float _musicDuckDbCurrent; private float _pauseDuckDbCurrent; private Sequence _musicDuckSequence; private Sequence _pauseDuckSequence; private bool _voiceFinishedPublished; private bool _disposed; public bool Initialized { get; private set; } [Inject] public AudioManager(AudioMixer mainMixer, AudioRegistry audioRegistry, EventCoordinator eventCoordinator) { _audioMixer = mainMixer; _audioRegistry = audioRegistry; _eventCoordinator = eventCoordinator; } public void Initialize() { if (_disposed) { Debug.LogWarning($"[{nameof(AudioManager)}] Initialize called after Dispose."); return; } if (Initialized) { Debug.LogWarning($"[{nameof(AudioManager)}] Initialize called more than once."); return; } Debug.Log($"[{nameof(AudioManager)}] Initializing..."); CreateSources(); PrimeMixerBaseValues(); _pauseDuckDbCurrent = 0f; _musicDuckDbCurrent = 0f; ApplyAllEffectiveVolumes(); _eventCoordinator.Subscribe(OnUIStackChanged); Initialized = true; Debug.Log($"[{nameof(AudioManager)}] Initialized."); } public void Dispose() { if (_disposed) return; _disposed = true; if (Initialized) _eventCoordinator.Unsubscribe(OnUIStackChanged); StopAndDispose(ref _musicDuckCts); StopAndDispose(ref _musicFadeCts); StopAndDispose(ref _voiceCts); if (_pauseDuckSequence.isAlive) { _pauseDuckSequence.Stop(); _pauseDuckSequence = default; } if (_musicDuckSequence.isAlive) { _musicDuckSequence.Stop(); _musicDuckSequence = default; } foreach (var go in _createdAudioObjects) { if (go != null) Object.Destroy(go); } _createdAudioObjects.Clear(); _sfxChannels.Clear(); _baseDb.Clear(); _musicSourceA = null; _musicSourceB = null; _voiceSource = null; _currentMusicTrack = null; _activeVoiceKey = VoiceKey.None; _activeSubtitleKey = SubtitleKey.None; _voiceFinishedPublished = false; Initialized = false; } // ── Source creation ─────────────────────────────────────────── private void CreateSources() { _musicSourceA = CreateAudioSource("Music_Source_A", AudioMixerGroups.MUSIC_GROUP); _musicSourceB = CreateAudioSource("Music_Source_B", AudioMixerGroups.MUSIC_GROUP); _voiceSource = CreateAudioSource("Voice_Source", AudioMixerGroups.VOICE_GROUP); for (var i = 0; i < INITIAL_SFX_SOURCES; i++) { var src = CreateAudioSource($"SFX_Source_{i}", AudioMixerGroups.SFX_GROUP); _sfxChannels.Add(new SfxChannel { Source = src, StartedAtUnscaled = -999f }); } } // ── Volume ──────────────────────────────────────────────────── private void PrimeMixerBaseValues() { PrimeBaseFromMixer(AudioMixerParameters.MASTER_VOLUME); PrimeBaseFromMixer(AudioMixerParameters.MUSIC_VOLUME); PrimeBaseFromMixer(AudioMixerParameters.SFX_VOLUME); PrimeBaseFromMixer(AudioMixerParameters.VOICE_VOLUME); } public void SetVolume(string parameter, float value01) { if (!Initialized) { Debug.LogWarning($"[{nameof(AudioManager)}] SetVolume called before Initialize."); return; } _baseDb[parameter] = Linear01ToDb(Mathf.Clamp01(value01)); ApplyEffectiveVolume(parameter); } private static float Linear01ToDb(float linear01) { return Mathf.Log10(Mathf.Max(linear01, 0.0001f)) * 20f; } private void PrimeBaseFromMixer(string parameter) { _baseDb[parameter] = _audioMixer != null && _audioMixer.GetFloat(parameter, out var db) ? db : 0f; } private void ApplyAllEffectiveVolumes() { ApplyEffectiveVolume(AudioMixerParameters.MASTER_VOLUME); ApplyEffectiveVolume(AudioMixerParameters.MUSIC_VOLUME); ApplyEffectiveVolume(AudioMixerParameters.SFX_VOLUME); ApplyEffectiveVolume(AudioMixerParameters.VOICE_VOLUME); } private void ApplyEffectiveVolume(string parameter) { if (_audioMixer == null || string.IsNullOrWhiteSpace(parameter)) return; var baseDb = _baseDb.GetValueOrDefault(parameter, 0f); var effective = baseDb; if (parameter == AudioMixerParameters.MUSIC_VOLUME || parameter == AudioMixerParameters.SFX_VOLUME) { effective += _pauseDuckDbCurrent; } if (parameter == AudioMixerParameters.MUSIC_VOLUME) effective += _musicDuckDbCurrent; _audioMixer.SetFloat(parameter, effective); } // ── UI stack / pause duck ───────────────────────────────────── private void OnUIStackChanged(UIStackChangedEvent e) { if (!Initialized) return; if (e.AnyUIOpen) OnGamePausedInternal().Forget(); else OnGameUnpausedInternal().Forget(); } private async UniTask OnGamePausedInternal() { PauseVoiceSource(true); await TweenPauseDuckTo(PAUSE_DUCK_TARGET_DB, PAUSE_DUCK_FADE_SECONDS); } private async UniTask OnGameUnpausedInternal() { await TweenPauseDuckTo(0f, PAUSE_DUCK_FADE_SECONDS); PauseVoiceSource(false); } private async UniTask TweenPauseDuckTo(float targetDb, float seconds) { if (_pauseDuckSequence.isAlive) { _pauseDuckSequence.Stop(); _pauseDuckSequence = default; } var from = _pauseDuckDbCurrent; _pauseDuckSequence = Sequence.Create(useUnscaledTime: true) .Group(Tween.Custom( from, targetDb, Mathf.Max(0f, seconds), v => { _pauseDuckDbCurrent = v; ApplyEffectiveVolume(AudioMixerParameters.MUSIC_VOLUME); ApplyEffectiveVolume(AudioMixerParameters.SFX_VOLUME); }, Ease.OutCubic, useUnscaledTime: true)); await _pauseDuckSequence.ToUniTask(); _pauseDuckSequence = default; } public void PauseVoiceSource(bool paused) { if (!Initialized || _voiceSource == null) return; if (paused) _voiceSource.Pause(); else _voiceSource.UnPause(); } // ── Play ────────────────────────────────────────────────────── public void Play(string audioName) { if (!Initialized) { Debug.LogWarning($"[{nameof(AudioManager)}] Play called before Initialize."); return; } if (!_audioRegistry.TryGetAudio(audioName, out var audioFile)) { Debug.LogWarning($"[AudioManager] Audio '{audioName}' not found in registry!"); return; } if (audioFile is AudioFileSo audio) Play(audio); else Debug.LogWarning($"[AudioManager] Audio '{audioName}' is not a valid AudioSO!"); } public void Play(AudioFileSo audioData) { if (!Initialized || !audioData || !audioData.Clip) return; switch (audioData.Type) { case TrackType.Music: CrossfadeMusic(audioData, audioData.FadeTime > 0f ? audioData.FadeTime : 1.0f).Forget(); break; case TrackType.Ambience: case TrackType.UIFX: case TrackType.Sfx: PlaySfx(audioData); break; case TrackType.Voice: PlayVoiceLine(audioData).Forget(); break; } if (audioData.DuckMusic) DuckMusicAsync(audioData.Clip.length, audioData.FadeTime).Forget(); } // ── Voice ───────────────────────────────────────────────────── private async UniTaskVoid PlayVoiceLine(AudioFileSo audioData) { if (!Initialized || _voiceSource == null || audioData?.Clip == null) return; StopAndDispose(ref _voiceCts); _voiceCts = new CancellationTokenSource(); var token = _voiceCts.Token; _activeVoiceKey = audioData.VoiceKey; _activeSubtitleKey = audioData.MatchingSubtitleID; _voiceFinishedPublished = false; _eventCoordinator.Publish(new VoicePlaybackStartedEvent( _activeVoiceKey, _activeSubtitleKey, audioData.Clip.length)); _voiceSource.clip = audioData.Clip; _voiceSource.pitch = audioData.Pitch; _voiceSource.volume = audioData.Volume; _voiceSource.loop = false; _voiceSource.priority = audioData.Priority; _voiceSource.Play(); try { await UniTask.WaitUntil(() => !_voiceSource.isPlaying, cancellationToken: token); } catch (OperationCanceledException) { } finally { PublishVoiceFinishedIfNeeded(); } } private void PublishVoiceFinishedIfNeeded() { if (_voiceFinishedPublished) return; if (_activeVoiceKey != VoiceKey.None || _activeSubtitleKey != SubtitleKey.None) { _eventCoordinator.Publish(new VoicePlaybackFinishedEvent( _activeVoiceKey, _activeSubtitleKey)); } _voiceFinishedPublished = true; _activeVoiceKey = VoiceKey.None; _activeSubtitleKey = SubtitleKey.None; } public void StopVoice() { if (!Initialized) return; StopAndDispose(ref _voiceCts); if (_voiceSource != null && _voiceSource.isPlaying) _voiceSource.Stop(); PublishVoiceFinishedIfNeeded(); } // ── SFX (transient pool) ────────────────────────────────────── private void PlaySfx(AudioFileSo audioData) { if (!Initialized || audioData == null || audioData.Clip == null) return; // Reap finished channels first so we don't accumulate stale entries ReapFinishedSfxChannels(); // Try to find a free channel from the existing pool AudioSource src = null; var channelIndex = -1; for (var i = 0; i < _sfxChannels.Count; i++) { var s = _sfxChannels[i].Source; if (s != null && !s.isPlaying) { src = s; channelIndex = i; break; } } // No free channel — spawn a transient one if (src == null) { src = CreateAudioSource($"SFX_Source_Transient_{_sfxChannels.Count}", AudioMixerGroups.SFX_GROUP); _sfxChannels.Add(new SfxChannel { Source = src, StartedAtUnscaled = -999f }); channelIndex = _sfxChannels.Count - 1; Debug.Log($"[AudioManager] SFX pool expanded to {_sfxChannels.Count} channels."); } src.priority = audioData.Priority; src.pitch = audioData.Pitch; src.loop = audioData.Loopable; src.PlayOneShot(audioData.Clip, audioData.Volume); _sfxChannels[channelIndex] = new SfxChannel { Source = src, StartedAtUnscaled = Time.unscaledTime }; } /// /// Removes finished transient channels from the pool to prevent unbounded growth. /// Preserves the initial pool channels even when idle. /// private void ReapFinishedSfxChannels() { for (var i = _sfxChannels.Count - 1; i >= INITIAL_SFX_SOURCES; i--) { var src = _sfxChannels[i].Source; if (src == null || src.isPlaying) continue; // Destroy the transient GameObject and remove from pool if (src.gameObject != null) { _createdAudioObjects.Remove(src.gameObject); Object.Destroy(src.gameObject); } _sfxChannels.RemoveAt(i); } } public void StopAllSfx() { if (!Initialized) return; for (var i = _sfxChannels.Count - 1; i >= 0; i--) { var src = _sfxChannels[i].Source; if (src == null) continue; src.Stop(); // Destroy transient channels, reset initial ones if (i >= INITIAL_SFX_SOURCES) { if (src.gameObject != null) { _createdAudioObjects.Remove(src.gameObject); Object.Destroy(src.gameObject); } _sfxChannels.RemoveAt(i); } else { _sfxChannels[i] = new SfxChannel { Source = src, StartedAtUnscaled = -999f }; } } } // ── Music ───────────────────────────────────────────────────── public async UniTask CrossfadeMusic(AudioFileSo newTrack, float duration) { if (!Initialized || !newTrack || !newTrack.Clip) return; if (_currentMusicTrack == newTrack) return; StopAndDispose(ref _musicFadeCts); _musicFadeCts = new CancellationTokenSource(); var token = _musicFadeCts.Token; var activeSource = _musicSourceA.isPlaying ? _musicSourceA : _musicSourceB.isPlaying ? _musicSourceB : null; var inactiveSource = activeSource == _musicSourceA ? _musicSourceB : _musicSourceA; PlayOnSource(inactiveSource, newTrack); if (activeSource == null) { inactiveSource.volume = newTrack.Volume; _currentMusicTrack = newTrack; _eventCoordinator.Publish(new MusicTrackChangedEvent(newTrack)); return; } duration = Mathf.Max(0.0001f, duration); var elapsed = 0f; var startVolume = activeSource.volume; try { while (elapsed < duration) { token.ThrowIfCancellationRequested(); var t = elapsed / duration; activeSource.volume = Mathf.Lerp(startVolume, 0f, t); inactiveSource.volume = Mathf.Lerp(0f, newTrack.Volume, t); elapsed += Time.unscaledDeltaTime; await UniTask.Yield(PlayerLoopTiming.Update, token); } activeSource.volume = 0f; inactiveSource.volume = newTrack.Volume; } catch (OperationCanceledException) { return; } activeSource.Stop(); activeSource.volume = startVolume; _currentMusicTrack = newTrack; _eventCoordinator.Publish(new MusicTrackChangedEvent(newTrack)); } public void StopMusic() { if (!Initialized) return; StopAndDispose(ref _musicFadeCts); if (_musicSourceA != null) { _musicSourceA.Stop(); _musicSourceA.clip = null; _musicSourceA.volume = 0f; } if (_musicSourceB != null) { _musicSourceB.Stop(); _musicSourceB.clip = null; _musicSourceB.volume = 0f; } _currentMusicTrack = null; } private async UniTask DuckMusicAsync(float clipLengthSeconds, float fadeTimeSeconds) { if (!Initialized) return; StopAndDispose(ref _musicDuckCts); _musicDuckCts = new CancellationTokenSource(); var token = _musicDuckCts.Token; fadeTimeSeconds = Mathf.Max(0.0001f, fadeTimeSeconds); try { await TweenMusicDuckTo(DEFAULT_VOICE_DUCK_TARGET_DB, fadeTimeSeconds, token); var hold = clipLengthSeconds - fadeTimeSeconds * 2f; if (hold > 0.01f) await UniTask.Delay(TimeSpan.FromSeconds(hold), cancellationToken: token); await TweenMusicDuckTo(0f, fadeTimeSeconds, token); } catch (OperationCanceledException) { await TweenMusicDuckTo(0f, 0.35f, CancellationToken.None); } } private async UniTask TweenMusicDuckTo(float targetDb, float seconds, CancellationToken token) { if (_musicDuckSequence.isAlive) { _musicDuckSequence.Stop(); _musicDuckSequence = default; } var from = _musicDuckDbCurrent; _musicDuckSequence = Sequence.Create(useUnscaledTime: true) .Group(Tween.Custom( from, targetDb, Mathf.Max(0f, seconds), v => { _musicDuckDbCurrent = v; ApplyEffectiveVolume(AudioMixerParameters.MUSIC_VOLUME); }, Ease.OutCubic, useUnscaledTime: true)); await _musicDuckSequence.ToUniTask(cancellationToken: token); _musicDuckSequence = default; } // ── Stop all ────────────────────────────────────────────────── public void StopAllAudio() { if (!Initialized) return; StopMusic(); StopVoice(); StopAllSfx(); } // ── Helpers ─────────────────────────────────────────────────── private static void StopAndDispose(ref CancellationTokenSource cts) { if (cts == null) return; try { cts.Cancel(); } catch { } cts.Dispose(); cts = null; } private AudioSource CreateAudioSource(string name, string groupName) { var obj = new GameObject(name); Object.DontDestroyOnLoad(obj); _createdAudioObjects.Add(obj); var src = obj.AddComponent(); var group = _audioMixer.FindMatchingGroups(groupName); if (group != null && group.Length > 0) src.outputAudioMixerGroup = group[0]; else Debug.LogWarning($"[AudioManager] Mixer Group '{groupName}' not found for {name}"); return src; } private void PlayOnSource(AudioSource source, AudioFileSo audioData) { if (!Initialized || source == null || audioData == null) return; source.clip = audioData.Clip; source.loop = audioData.Loopable; source.volume = audioData.Volume; source.pitch = audioData.Pitch; source.priority = audioData.Priority; source.Play(); } private struct SfxChannel { public AudioSource Source; public float StartedAtUnscaled; } } }