481 lines
16 KiB
C#
481 lines
16 KiB
C#
using System.Collections;
|
|
using System.Collections.Generic;
|
|
using UnityEngine;
|
|
using UnityEngine.Events;
|
|
using Ashwild.Core;
|
|
|
|
namespace Ashwild.Audio
|
|
{
|
|
public class MusicManager : MonoBehaviour
|
|
{
|
|
public static MusicManager Instance { get; private set; }
|
|
|
|
[Header("Playlist")]
|
|
[SerializeField] private MusicPlaylist activePlaylist;
|
|
[SerializeField] private bool playOnAwake = true;
|
|
|
|
[Header("Ducking")]
|
|
[SerializeField] private bool enableDucking;
|
|
[Range(0f, 1f)] [SerializeField] private float duckVolume = 0.15f;
|
|
[Range(0f, 5f)] [SerializeField] private float duckFadeDuration = 0.5f;
|
|
|
|
[Header("Persistence")]
|
|
[SerializeField] private bool persistAcrossScenes = true;
|
|
|
|
[Header("Events")]
|
|
public UnityEvent<AudioClip> onTrackChanged;
|
|
public UnityEvent<MusicPlaylist> onPlaylistChanged;
|
|
|
|
private AudioSource sourceA;
|
|
private AudioSource sourceB;
|
|
private AudioSource activeSource;
|
|
private AudioSource inactiveSource;
|
|
|
|
private MusicPlaylist currentPlaylist;
|
|
private List<int> shuffleOrder;
|
|
private int shuffleIndex;
|
|
private int sequentialIndex;
|
|
private int lastRandomIndex = -1;
|
|
|
|
private Coroutine playbackRoutine;
|
|
private Coroutine duckRoutine;
|
|
private Coroutine oneShotRoutine;
|
|
|
|
private float masterVolume = 1f;
|
|
private bool isPaused;
|
|
private bool isDucked;
|
|
private float pausedTime;
|
|
private AudioClip pausedTrack;
|
|
private bool isPlayingOneShot;
|
|
|
|
public bool IsPlaying => activeSource != null && activeSource.isPlaying;
|
|
public bool IsPaused => isPaused;
|
|
public float MasterVolume => masterVolume;
|
|
public MusicPlaylist CurrentPlaylist => currentPlaylist;
|
|
|
|
private void Awake()
|
|
{
|
|
if (Instance != null && Instance != this)
|
|
{
|
|
Destroy(gameObject);
|
|
return;
|
|
}
|
|
Instance = this;
|
|
|
|
if (persistAcrossScenes)
|
|
Persistence.Persist(gameObject);
|
|
|
|
sourceA = gameObject.AddComponent<AudioSource>();
|
|
sourceB = gameObject.AddComponent<AudioSource>();
|
|
ConfigureSource(sourceA);
|
|
ConfigureSource(sourceB);
|
|
|
|
activeSource = sourceA;
|
|
inactiveSource = sourceB;
|
|
}
|
|
|
|
private void Start()
|
|
{
|
|
if (playOnAwake && activePlaylist != null)
|
|
SetPlaylist(activePlaylist);
|
|
}
|
|
|
|
private void ConfigureSource(AudioSource source)
|
|
{
|
|
source.playOnAwake = false;
|
|
source.loop = false;
|
|
source.spatialBlend = 0f;
|
|
}
|
|
|
|
// ── Public API ──────────────────────────────────────────────
|
|
|
|
public void SetPlaylist(MusicPlaylist playlist)
|
|
{
|
|
if (playlist == null || playlist.tracks == null || playlist.tracks.Length == 0)
|
|
return;
|
|
|
|
StopAllPlayback();
|
|
|
|
currentPlaylist = playlist;
|
|
sequentialIndex = 0;
|
|
lastRandomIndex = -1;
|
|
BuildShuffleOrder();
|
|
|
|
onPlaylistChanged?.Invoke(currentPlaylist);
|
|
playbackRoutine = StartCoroutine(PlaybackLoop());
|
|
}
|
|
|
|
public void TransitionToPlaylist(MusicPlaylist playlist)
|
|
{
|
|
if (playlist == null || playlist.tracks == null || playlist.tracks.Length == 0)
|
|
return;
|
|
|
|
if (isPlayingOneShot)
|
|
{
|
|
if (oneShotRoutine != null)
|
|
StopCoroutine(oneShotRoutine);
|
|
isPlayingOneShot = false;
|
|
}
|
|
|
|
StartCoroutine(TransitionRoutine(playlist));
|
|
}
|
|
|
|
public void PlayOneShot(AudioClip clip, float volume = 1f, float fadeIn = 1f, float fadeOut = 1f)
|
|
{
|
|
if (clip == null) return;
|
|
if (oneShotRoutine != null)
|
|
StopCoroutine(oneShotRoutine);
|
|
oneShotRoutine = StartCoroutine(OneShotRoutine(clip, volume, fadeIn, fadeOut));
|
|
}
|
|
|
|
public void Pause(bool resumeFromPosition = true)
|
|
{
|
|
if (isPaused || !IsPlaying) return;
|
|
|
|
isPaused = true;
|
|
if (resumeFromPosition)
|
|
pausedTime = activeSource.time;
|
|
|
|
StartCoroutine(FadeSource(activeSource, activeSource.volume, 0f, currentPlaylist != null ? currentPlaylist.fadeOutDuration : 1f, true));
|
|
}
|
|
|
|
public void Resume()
|
|
{
|
|
if (!isPaused) return;
|
|
|
|
isPaused = false;
|
|
|
|
if (pausedTime > 0f)
|
|
activeSource.time = pausedTime;
|
|
|
|
activeSource.UnPause();
|
|
float targetVol = GetTrackVolume(pausedTrack);
|
|
StartCoroutine(FadeSource(activeSource, 0f, targetVol, currentPlaylist != null ? currentPlaylist.fadeInDuration : 1f, false));
|
|
}
|
|
|
|
public void Stop()
|
|
{
|
|
StopAllPlayback();
|
|
}
|
|
|
|
public void SetMasterVolume(float volume)
|
|
{
|
|
float previous = masterVolume;
|
|
masterVolume = Mathf.Clamp01(volume);
|
|
|
|
if (activeSource.isPlaying && previous > 0f)
|
|
activeSource.volume = activeSource.volume / previous * masterVolume;
|
|
}
|
|
|
|
public void Duck()
|
|
{
|
|
if (isDucked || !IsPlaying) return;
|
|
isDucked = true;
|
|
|
|
if (duckRoutine != null)
|
|
StopCoroutine(duckRoutine);
|
|
duckRoutine = StartCoroutine(FadeSource(activeSource, activeSource.volume, duckVolume * masterVolume, duckFadeDuration, false));
|
|
}
|
|
|
|
public void Unduck()
|
|
{
|
|
if (!isDucked) return;
|
|
isDucked = false;
|
|
|
|
float targetVol = currentPlaylist != null ? currentPlaylist.volume * masterVolume : masterVolume;
|
|
if (duckRoutine != null)
|
|
StopCoroutine(duckRoutine);
|
|
duckRoutine = StartCoroutine(FadeSource(activeSource, activeSource.volume, targetVol, duckFadeDuration, false));
|
|
}
|
|
|
|
// ── Playback Loop ───────────────────────────────────────────
|
|
|
|
private IEnumerator PlaybackLoop()
|
|
{
|
|
while (currentPlaylist != null)
|
|
{
|
|
AudioClip track = GetNextTrack();
|
|
if (track == null)
|
|
yield break;
|
|
|
|
float targetVol = GetTrackVolume(track);
|
|
bool useCrossfade = currentPlaylist.crossfadeDuration > 0f && activeSource.isPlaying;
|
|
|
|
if (useCrossfade)
|
|
{
|
|
yield return StartCoroutine(CrossfadeToTrack(track, targetVol));
|
|
}
|
|
else
|
|
{
|
|
yield return StartCoroutine(PlayTrackWithFade(activeSource, track, targetVol));
|
|
}
|
|
|
|
if (isPaused)
|
|
{
|
|
pausedTrack = track;
|
|
yield return new WaitUntil(() => !isPaused);
|
|
}
|
|
|
|
float delay = Random.Range(currentPlaylist.delayMin, currentPlaylist.delayMax);
|
|
yield return new WaitForSeconds(delay);
|
|
}
|
|
}
|
|
|
|
private IEnumerator PlayTrackWithFade(AudioSource source, AudioClip track, float targetVol)
|
|
{
|
|
source.clip = track;
|
|
source.pitch = Random.Range(currentPlaylist.pitchMin, currentPlaylist.pitchMax);
|
|
source.volume = 0f;
|
|
source.Play();
|
|
|
|
pausedTrack = track;
|
|
onTrackChanged?.Invoke(track);
|
|
|
|
yield return StartCoroutine(FadeSource(source, 0f, targetVol, currentPlaylist.fadeInDuration, false));
|
|
|
|
float remainingTime = track.length / source.pitch - currentPlaylist.fadeOutDuration;
|
|
if (remainingTime > 0f)
|
|
yield return new WaitForSeconds(remainingTime);
|
|
|
|
if (!isPaused)
|
|
yield return StartCoroutine(FadeSource(source, source.volume, 0f, currentPlaylist.fadeOutDuration, false));
|
|
|
|
source.Stop();
|
|
}
|
|
|
|
private IEnumerator CrossfadeToTrack(AudioClip track, float targetVol)
|
|
{
|
|
AudioSource newSource = inactiveSource;
|
|
AudioSource oldSource = activeSource;
|
|
|
|
newSource.clip = track;
|
|
newSource.pitch = Random.Range(currentPlaylist.pitchMin, currentPlaylist.pitchMax);
|
|
newSource.volume = 0f;
|
|
newSource.Play();
|
|
|
|
pausedTrack = track;
|
|
onTrackChanged?.Invoke(track);
|
|
|
|
float duration = currentPlaylist.crossfadeDuration;
|
|
float elapsed = 0f;
|
|
float oldStartVol = oldSource.volume;
|
|
|
|
while (elapsed < duration)
|
|
{
|
|
elapsed += Time.deltaTime;
|
|
float t = Mathf.Clamp01(elapsed / duration);
|
|
newSource.volume = Mathf.Lerp(0f, targetVol, t);
|
|
oldSource.volume = Mathf.Lerp(oldStartVol, 0f, t);
|
|
yield return null;
|
|
}
|
|
|
|
oldSource.Stop();
|
|
oldSource.volume = 0f;
|
|
newSource.volume = targetVol;
|
|
|
|
activeSource = newSource;
|
|
inactiveSource = oldSource;
|
|
|
|
float remainingTime = track.length / newSource.pitch - duration - currentPlaylist.fadeOutDuration;
|
|
if (remainingTime > 0f)
|
|
yield return new WaitForSeconds(remainingTime);
|
|
|
|
if (!isPaused)
|
|
yield return StartCoroutine(FadeSource(activeSource, activeSource.volume, 0f, currentPlaylist.fadeOutDuration, false));
|
|
|
|
activeSource.Stop();
|
|
}
|
|
|
|
// ── Transitions ─────────────────────────────────────────────
|
|
|
|
private IEnumerator TransitionRoutine(MusicPlaylist newPlaylist)
|
|
{
|
|
if (playbackRoutine != null)
|
|
{
|
|
StopCoroutine(playbackRoutine);
|
|
playbackRoutine = null;
|
|
}
|
|
|
|
if (activeSource.isPlaying)
|
|
{
|
|
float fadeDuration = currentPlaylist != null ? currentPlaylist.fadeOutDuration : 1f;
|
|
yield return StartCoroutine(FadeSource(activeSource, activeSource.volume, 0f, fadeDuration, false));
|
|
activeSource.Stop();
|
|
}
|
|
|
|
currentPlaylist = newPlaylist;
|
|
sequentialIndex = 0;
|
|
lastRandomIndex = -1;
|
|
BuildShuffleOrder();
|
|
|
|
onPlaylistChanged?.Invoke(currentPlaylist);
|
|
playbackRoutine = StartCoroutine(PlaybackLoop());
|
|
}
|
|
|
|
// ── One-Shot ────────────────────────────────────────────────
|
|
|
|
private IEnumerator OneShotRoutine(AudioClip clip, float volume, float fadeIn, float fadeOut)
|
|
{
|
|
isPlayingOneShot = true;
|
|
bool wasPlaying = activeSource.isPlaying;
|
|
|
|
if (playbackRoutine != null)
|
|
{
|
|
StopCoroutine(playbackRoutine);
|
|
playbackRoutine = null;
|
|
}
|
|
|
|
if (wasPlaying)
|
|
{
|
|
float fadeDuration = currentPlaylist != null ? currentPlaylist.fadeOutDuration : 1f;
|
|
yield return StartCoroutine(FadeSource(activeSource, activeSource.volume, 0f, fadeDuration, false));
|
|
activeSource.Stop();
|
|
}
|
|
|
|
AudioSource shotSource = inactiveSource;
|
|
shotSource.clip = clip;
|
|
shotSource.pitch = 1f;
|
|
shotSource.volume = 0f;
|
|
shotSource.Play();
|
|
|
|
yield return StartCoroutine(FadeSource(shotSource, 0f, volume * masterVolume, fadeIn, false));
|
|
|
|
float remaining = clip.length - fadeIn - fadeOut;
|
|
if (remaining > 0f)
|
|
yield return new WaitForSeconds(remaining);
|
|
|
|
yield return StartCoroutine(FadeSource(shotSource, shotSource.volume, 0f, fadeOut, false));
|
|
shotSource.Stop();
|
|
|
|
isPlayingOneShot = false;
|
|
|
|
if (currentPlaylist != null && wasPlaying)
|
|
playbackRoutine = StartCoroutine(PlaybackLoop());
|
|
}
|
|
|
|
// ── Track Selection ─────────────────────────────────────────
|
|
|
|
private AudioClip GetNextTrack()
|
|
{
|
|
if (currentPlaylist == null || currentPlaylist.tracks.Length == 0)
|
|
return null;
|
|
|
|
int count = currentPlaylist.tracks.Length;
|
|
if (count == 1)
|
|
return currentPlaylist.tracks[0];
|
|
|
|
switch (currentPlaylist.playMode)
|
|
{
|
|
case PlayMode.Sequential:
|
|
int seqIdx = sequentialIndex % count;
|
|
sequentialIndex++;
|
|
return currentPlaylist.tracks[seqIdx];
|
|
|
|
case PlayMode.Random:
|
|
int rndIdx;
|
|
do
|
|
{
|
|
rndIdx = Random.Range(0, count);
|
|
} while (rndIdx == lastRandomIndex);
|
|
lastRandomIndex = rndIdx;
|
|
return currentPlaylist.tracks[rndIdx];
|
|
|
|
case PlayMode.Shuffle:
|
|
if (shuffleIndex >= shuffleOrder.Count)
|
|
{
|
|
int lastPlayed = shuffleOrder.Count > 0 ? shuffleOrder[shuffleOrder.Count - 1] : -1;
|
|
BuildShuffleOrder();
|
|
if (shuffleOrder.Count > 1 && shuffleOrder[0] == lastPlayed)
|
|
{
|
|
int swapIdx = Random.Range(1, shuffleOrder.Count);
|
|
(shuffleOrder[0], shuffleOrder[swapIdx]) = (shuffleOrder[swapIdx], shuffleOrder[0]);
|
|
}
|
|
}
|
|
int idx = shuffleOrder[shuffleIndex];
|
|
shuffleIndex++;
|
|
return currentPlaylist.tracks[idx];
|
|
|
|
default:
|
|
return currentPlaylist.tracks[0];
|
|
}
|
|
}
|
|
|
|
private void BuildShuffleOrder()
|
|
{
|
|
int count = currentPlaylist.tracks.Length;
|
|
shuffleOrder = new List<int>(count);
|
|
for (int i = 0; i < count; i++)
|
|
shuffleOrder.Add(i);
|
|
|
|
for (int i = count - 1; i > 0; i--)
|
|
{
|
|
int j = Random.Range(0, i + 1);
|
|
(shuffleOrder[i], shuffleOrder[j]) = (shuffleOrder[j], shuffleOrder[i]);
|
|
}
|
|
|
|
shuffleIndex = 0;
|
|
}
|
|
|
|
// ── Utility ─────────────────────────────────────────────────
|
|
|
|
private float GetTrackVolume(AudioClip track)
|
|
{
|
|
if (currentPlaylist == null || track == null) return masterVolume;
|
|
if (isDucked) return duckVolume * masterVolume;
|
|
return currentPlaylist.volume * masterVolume;
|
|
}
|
|
|
|
private IEnumerator FadeSource(AudioSource source, float from, float to, float duration, bool pauseAtEnd)
|
|
{
|
|
if (duration <= 0f)
|
|
{
|
|
source.volume = to;
|
|
if (pauseAtEnd) source.Pause();
|
|
yield break;
|
|
}
|
|
|
|
float elapsed = 0f;
|
|
while (elapsed < duration)
|
|
{
|
|
elapsed += Time.deltaTime;
|
|
source.volume = Mathf.Lerp(from, to, Mathf.Clamp01(elapsed / duration));
|
|
yield return null;
|
|
}
|
|
|
|
source.volume = to;
|
|
if (pauseAtEnd) source.Pause();
|
|
}
|
|
|
|
private void StopAllPlayback()
|
|
{
|
|
if (playbackRoutine != null)
|
|
{
|
|
StopCoroutine(playbackRoutine);
|
|
playbackRoutine = null;
|
|
}
|
|
|
|
if (oneShotRoutine != null)
|
|
{
|
|
StopCoroutine(oneShotRoutine);
|
|
oneShotRoutine = null;
|
|
}
|
|
|
|
if (duckRoutine != null)
|
|
{
|
|
StopCoroutine(duckRoutine);
|
|
duckRoutine = null;
|
|
}
|
|
|
|
sourceA.Stop();
|
|
sourceB.Stop();
|
|
sourceA.volume = 0f;
|
|
sourceB.volume = 0f;
|
|
|
|
isPaused = false;
|
|
isDucked = false;
|
|
isPlayingOneShot = false;
|
|
pausedTime = 0f;
|
|
}
|
|
}
|
|
}
|