using System; using System.Collections.Generic; using System.Linq; 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 /// public class AudioManager : IDisposable, IManager { private const int INITIAL_AMBIENCE_SOURCES = 3; private const int INITIAL_SFX_SOURCES = 6; 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 _ambienceSources = new(); private readonly List _currentAmbienceTracks = new(); private readonly List _sfxChannels = new(); private AudioSource _musicSourceA; private AudioSource _musicSourceB; private AudioSource _voiceSource; private AudioSource _uiSource; private string _activeVoiceSubtitleId; 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(); _ambienceSources.Clear(); _sfxChannels.Clear(); _currentAmbienceTracks.Clear(); _baseDb.Clear(); _musicSourceA = null; _musicSourceB = null; _voiceSource = null; _uiSource = null; _currentMusicTrack = null; _activeVoiceSubtitleId = null; _voiceFinishedPublished = false; Initialized = false; } 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); _uiSource = CreateAudioSource("UI_Source", AudioMixerGroups.UI_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 }); } for (var i = 0; i < INITIAL_AMBIENCE_SOURCES; i++) { _ambienceSources.Add(CreateAudioSource($"Ambience_Source_{i}", AudioMixerGroups.AMBIENCE_GROUP)); } } private void PrimeMixerBaseValues() { PrimeBaseFromMixer(AudioMixerParameters.MASTER_VOLUME); PrimeBaseFromMixer(AudioMixerParameters.MUSIC_VOLUME); PrimeBaseFromMixer(AudioMixerParameters.SFX_VOLUME); PrimeBaseFromMixer(AudioMixerParameters.AMBIENCE_VOLUME); PrimeBaseFromMixer(AudioMixerParameters.VOICE_VOLUME); PrimeBaseFromMixer(AudioMixerParameters.UI_VOLUME); } public void SetVolume(string parameter, float value01) { if (!Initialized) { Debug.LogWarning($"[{nameof(AudioManager)}] SetVolume called before Initialize."); return; } var linear = Mathf.Clamp01(value01); var db = Linear01ToDb(linear); _baseDb[parameter] = db; ApplyEffectiveVolume(parameter); } private static float Linear01ToDb(float linear01) { var lin = Mathf.Max(linear01, 0.0001f); return Mathf.Log10(lin) * 20f; } private void PrimeBaseFromMixer(string parameter) { if (_audioMixer != null && _audioMixer.GetFloat(parameter, out var db)) _baseDb[parameter] = db; else _baseDb[parameter] = 0f; } private void ApplyAllEffectiveVolumes() { ApplyEffectiveVolume(AudioMixerParameters.MASTER_VOLUME); ApplyEffectiveVolume(AudioMixerParameters.MUSIC_VOLUME); ApplyEffectiveVolume(AudioMixerParameters.SFX_VOLUME); ApplyEffectiveVolume(AudioMixerParameters.AMBIENCE_VOLUME); ApplyEffectiveVolume(AudioMixerParameters.VOICE_VOLUME); ApplyEffectiveVolume(AudioMixerParameters.UI_VOLUME); } private void ApplyEffectiveVolume(string parameter) { if (_audioMixer == null || string.IsNullOrWhiteSpace(parameter)) return; if (!_baseDb.TryGetValue(parameter, out var baseDb)) baseDb = 0f; var effective = baseDb; if (parameter == AudioMixerParameters.MUSIC_VOLUME || parameter == AudioMixerParameters.SFX_VOLUME || parameter == AudioMixerParameters.AMBIENCE_VOLUME) { effective += _pauseDuckDbCurrent; } if (parameter == AudioMixerParameters.MUSIC_VOLUME) effective += _musicDuckDbCurrent; _audioMixer.SetFloat(parameter, effective); } 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; } seconds = Mathf.Max(0f, seconds); var from = _pauseDuckDbCurrent; _pauseDuckSequence = Sequence.Create(useUnscaledTime: true) .Group(Tween.Custom( from, targetDb, seconds, v => { _pauseDuckDbCurrent = v; ApplyEffectiveVolume(AudioMixerParameters.MUSIC_VOLUME); ApplyEffectiveVolume(AudioMixerParameters.SFX_VOLUME); ApplyEffectiveVolume(AudioMixerParameters.AMBIENCE_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(); } 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: if (!_currentAmbienceTracks.Contains(audioData)) { _currentAmbienceTracks.Add(audioData); PlayOnAvailableAmbienceSource(audioData); } break; case TrackType.Sfx: PlaySfx(audioData); break; case TrackType.UIFX: PlayOneShotAsync(_uiSource, audioData).Forget(); break; case TrackType.Voice: PlayVoiceLine(audioData).Forget(); break; } if (audioData.DuckMusic) DuckMusicAsync(audioData.Clip.length, audioData.FadeTime).Forget(); } private async UniTaskVoid PlayVoiceLine(AudioFileSo audioData) { if (!Initialized || _voiceSource == null || audioData?.Clip == null) return; StopAndDispose(ref _voiceCts); _voiceCts = new CancellationTokenSource(); var token = _voiceCts.Token; _activeVoiceSubtitleId = SubtitleIdentifiers.Get(audioData.MatchingSubtitleID); _voiceFinishedPublished = false; _eventCoordinator.Publish(new VoiceLineStartedEvent(_activeVoiceSubtitleId)); _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 (!string.IsNullOrEmpty(_activeVoiceSubtitleId)) _eventCoordinator.Publish(new VoiceLineFinishedEvent(_activeVoiceSubtitleId)); _voiceFinishedPublished = true; _activeVoiceSubtitleId = 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); var duckTarget = DEFAULT_VOICE_DUCK_TARGET_DB; try { await TweenMusicDuckTo(duckTarget, 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; } seconds = Mathf.Max(0f, seconds); var from = _musicDuckDbCurrent; _musicDuckSequence = Sequence.Create(useUnscaledTime: true) .Group(Tween.Custom( from, targetDb, seconds, v => { _musicDuckDbCurrent = v; ApplyEffectiveVolume(AudioMixerParameters.MUSIC_VOLUME); }, Ease.OutCubic, useUnscaledTime: true)); await _musicDuckSequence.ToUniTask(cancellationToken: token); _musicDuckSequence = default; } 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)); } private void PlaySfx(AudioFileSo audioData) { if (!Initialized || audioData == null || audioData.Clip == null) return; var channelIndex = GetBestSfxChannelIndex(audioData.Priority); if (channelIndex < 0 || channelIndex >= _sfxChannels.Count) return; var src = _sfxChannels[channelIndex].Source; if (src == null) return; if (src.isPlaying) src.Stop(); src.priority = audioData.Priority; src.pitch = audioData.Pitch; src.PlayOneShot(audioData.Clip, audioData.Volume); _sfxChannels[channelIndex] = new SfxChannel { Source = src, StartedAtUnscaled = Time.unscaledTime }; } private int GetBestSfxChannelIndex(int incomingPriority) { for (var i = 0; i < _sfxChannels.Count; i++) { var src = _sfxChannels[i].Source; if (src == null) continue; if (!src.isPlaying) return i; } var bestIndex = -1; var worstPriority = int.MinValue; var oldestStart = float.MaxValue; for (var i = 0; i < _sfxChannels.Count; i++) { var src = _sfxChannels[i].Source; if (src == null) continue; var p = src.priority; var started = _sfxChannels[i].StartedAtUnscaled; if (p > worstPriority || (p == worstPriority && started < oldestStart)) { worstPriority = p; oldestStart = started; bestIndex = i; } } return bestIndex; } public void StopAmbience(AudioFileSo audioData) { if (!Initialized || !audioData || !audioData.Clip || audioData.Type != TrackType.Ambience) return; if (_currentAmbienceTracks.Remove(audioData)) { foreach (var source in _ambienceSources.Where(s => s != null && s.clip == audioData.Clip)) source.Stop(); } } public void StopAllAmbience() { if (!Initialized) return; foreach (var s in _ambienceSources) { if (s != null) s.Stop(); } _currentAmbienceTracks.Clear(); } private void PlayOnAvailableAmbienceSource(AudioFileSo audioData) { var source = _ambienceSources.FirstOrDefault(s => s != null && !s.isPlaying); if (source == null) { source = CreateAudioSource( $"Ambience_Source_{_ambienceSources.Count}", AudioMixerGroups.AMBIENCE_GROUP); _ambienceSources.Add(source); } PlayOnSource(source, audioData); } 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; } public void StopVoice() { if (!Initialized) return; StopAndDispose(ref _voiceCts); if (_voiceSource != null && _voiceSource.isPlaying) _voiceSource.Stop(); PublishVoiceFinishedIfNeeded(); } public void StopAllSfx() { if (!Initialized) return; for (var i = 0; i < _sfxChannels.Count; i++) { var src = _sfxChannels[i].Source; if (src == null) continue; src.Stop(); _sfxChannels[i] = new SfxChannel { Source = src, StartedAtUnscaled = -999f }; } } public void StopAllAudio() { if (!Initialized) return; StopMusic(); StopVoice(); StopAllSfx(); StopAllAmbience(); if (_uiSource != null) _uiSource.Stop(); } 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 async UniTaskVoid PlayOneShotAsync(AudioSource source, AudioFileSo audioData) { if (!Initialized || source == null || audioData == null || audioData.Clip == null) return; source.priority = audioData.Priority; source.pitch = audioData.Pitch; source.PlayOneShot(audioData.Clip, audioData.Volume); var seconds = audioData.Clip.length / Mathf.Max(audioData.Pitch, 0.0001f); await UniTask.Delay(TimeSpan.FromSeconds(seconds)); } 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; } } }