using System; using System.Collections; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using FlowScope.Resources; using UnityEngine; namespace FlowScope.Audio { public sealed class AudioService : MonoBehaviour, IAudioService { private readonly List _sfxSlots = new(); private IResourceService _resources; private AudioServiceConfig _config; private AudioSource _bgmSource; private IResourceHandle _bgmResource; private Coroutine _bgmFade; private float _bgmVolume = 1f; private float _sfxVolume = 1f; private bool _mute; private int _playSequence; public AudioService Initialize(IResourceService resources, AudioServiceConfig config = null) { _resources = resources ?? throw new ArgumentNullException(nameof(resources)); _config = config ?? new AudioServiceConfig(); if (_config.SfxPoolSize < 0) { _config.SfxPoolSize = 0; } return this; } public IAudioHandle PlayBgm(string key, bool loop = true, float fadeIn = 0f) { EnsureInitialized(); var clipHandle = LoadClipIfCompleted(key); return PlayBgm(clipHandle, loop, fadeIn); } public async Task PlayBgmAsync( string key, bool loop = true, float fadeIn = 0f, CancellationToken cancellationToken = default) { EnsureInitialized(); var clipHandle = await LoadClipAsync(key, cancellationToken); return PlayBgm(clipHandle, loop, fadeIn); } public IAudioHandle PlaySfx(string key) { EnsureInitialized(); var clipHandle = LoadClipIfCompleted(key); var slot = GetSfxSlot(key); if (slot == null) { clipHandle.Dispose(); return AudioHandle.Stopped; } return PlaySfx(slot, clipHandle); } public async Task PlaySfxAsync( string key, CancellationToken cancellationToken = default) { EnsureInitialized(); var clipHandle = await LoadClipAsync(key, cancellationToken); var slot = GetSfxSlot(key); if (slot == null) { clipHandle.Dispose(); return AudioHandle.Stopped; } return PlaySfx(slot, clipHandle); } public void StopBgm(float fadeOut = 0f) { StopBgmSource(_bgmSource, fadeOut); } public void SetBgmVolume(float volume) { _bgmVolume = Mathf.Clamp01(volume); if (_bgmSource != null) { _bgmSource.volume = EffectiveBgmVolume; } } public void SetSfxVolume(float volume) { _sfxVolume = Mathf.Clamp01(volume); foreach (var slot in _sfxSlots) { if (slot.Source != null) { slot.Source.volume = EffectiveSfxVolume; } } } public void SetMute(bool mute) { _mute = mute; if (_bgmSource != null) { _bgmSource.volume = EffectiveBgmVolume; } foreach (var slot in _sfxSlots) { if (slot.Source != null) { slot.Source.volume = EffectiveSfxVolume; } } } private IAudioHandle PlayBgm(IResourceHandle clipHandle, bool loop, float fadeIn) { StopBgm(); _bgmSource = gameObject.AddComponent(); _bgmResource = clipHandle; _bgmSource.clip = clipHandle.Asset; _bgmSource.loop = loop; _bgmSource.volume = fadeIn > 0f ? 0f : EffectiveBgmVolume; _bgmSource.Play(); if (fadeIn > 0f) { _bgmFade = StartCoroutine(FadeVolume(_bgmSource, 0f, EffectiveBgmVolume, fadeIn, null)); } var source = _bgmSource; return new AudioHandle( () => source != null && source.isPlaying, fadeOut => StopBgmSource(source, fadeOut)); } private IAudioHandle PlaySfx(SfxSlot slot, IResourceHandle clipHandle) { slot.Release(); slot.Resource = clipHandle; slot.Source.clip = clipHandle.Asset; slot.Source.loop = false; slot.Source.volume = EffectiveSfxVolume; slot.Sequence = ++_playSequence; slot.Source.Play(); return new AudioHandle( () => slot.Source != null && slot.Source.isPlaying, fadeOut => StopSfxSlot(slot, fadeOut)); } private float EffectiveBgmVolume => _mute ? 0f : _bgmVolume; private float EffectiveSfxVolume => _mute ? 0f : _sfxVolume; private void Update() { foreach (var slot in _sfxSlots) { if (slot.Source != null && !slot.Source.isPlaying) { slot.Release(); } } } private void OnDestroy() { StopBgm(); foreach (var slot in _sfxSlots) { slot.Release(); } } private IResourceHandle LoadClipIfCompleted(string key) { try { if (!(_resources is ICompletedResourceService completedResources) || !completedResources.TryLoadCompleted(key, out IResourceHandle handle)) { throw new InvalidOperationException( $"Audio clip is not loaded synchronously: {key}. Use PlayBgmAsync or PlaySfxAsync."); } return ValidateLoadedClip(key, handle); } catch (Exception exception) { throw new InvalidOperationException($"Failed to load audio clip '{key}'.", exception); } } private async Task> LoadClipAsync( string key, CancellationToken cancellationToken) { try { var handle = await _resources.LoadAsync(key, cancellationToken); return ValidateLoadedClip(key, handle); } catch (OperationCanceledException) { throw; } catch (Exception exception) { throw new InvalidOperationException($"Failed to load audio clip '{key}'.", exception); } } private static IResourceHandle ValidateLoadedClip( string key, IResourceHandle handle) { if (handle == null || handle.Asset == null) { handle?.Dispose(); throw new InvalidOperationException($"Audio clip is missing: {key}"); } return handle; } private SfxSlot GetSfxSlot(string key) { foreach (var slot in _sfxSlots) { if (slot.Source != null && !slot.Source.isPlaying) { return slot; } } if (_sfxSlots.Count < _config.SfxPoolSize) { var source = gameObject.AddComponent(); var slot = new SfxSlot(source); _sfxSlots.Add(slot); return slot; } if (_config.ReuseOldestWhenExhausted && _sfxSlots.Count > 0) { var oldest = _sfxSlots[0]; foreach (var slot in _sfxSlots) { if (slot.Sequence < oldest.Sequence) { oldest = slot; } } oldest.Source.Stop(); return oldest; } Debug.LogWarning($"SFX pool exhausted, skipped key: {key}"); return null; } private void StopBgmSource(AudioSource source, float fadeOut) { if (source == null || source != _bgmSource) { return; } if (_bgmFade != null) { StopCoroutine(_bgmFade); _bgmFade = null; } if (fadeOut > 0f && source.isPlaying) { _bgmFade = StartCoroutine(FadeVolume( source, source.volume, 0f, fadeOut, () => CompleteBgmStop(source))); return; } CompleteBgmStop(source); } private void CompleteBgmStop(AudioSource source) { if (source == null || source != _bgmSource) { return; } source.Stop(); source.volume = 0f; Destroy(source); _bgmSource = null; _bgmFade = null; _bgmResource?.Dispose(); _bgmResource = null; } private void StopSfxSlot(SfxSlot slot, float fadeOut) { if (slot?.Source == null || !slot.Source.isPlaying) { return; } if (fadeOut > 0f) { StartCoroutine(FadeVolume( slot.Source, slot.Source.volume, 0f, fadeOut, () => { slot.Source.Stop(); slot.Release(); })); return; } slot.Source.Stop(); slot.Release(); } private IEnumerator FadeVolume( AudioSource source, float from, float to, float duration, Action complete) { var elapsed = 0f; while (elapsed < duration && source != null) { elapsed += Time.deltaTime; source.volume = Mathf.Lerp(from, to, Mathf.Clamp01(elapsed / duration)); yield return null; } if (source != null) { source.volume = to; } complete?.Invoke(); } private void EnsureInitialized() { if (_resources == null) { throw new InvalidOperationException("AudioService must be initialized before use."); } } private sealed class SfxSlot { public SfxSlot(AudioSource source) { Source = source; } public AudioSource Source { get; } public IResourceHandle Resource { get; set; } public int Sequence { get; set; } public void Release() { Resource?.Dispose(); Resource = null; } } } }