Last active
October 10, 2024 09:14
-
-
Save javier-games/fe5700c29b429e4e750ec7315b3148ff to your computer and use it in GitHub Desktop.
A Unity experiment for seamless dynamic music changes in games, using Scriptable Objects to trigger transitions without noticeable shifts in the soundtrack. Try it at https://javier-games.itch.io/smplsmplr
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using UnityEngine; | |
namespace SmplSmplr.Scripts.AudioSamples | |
{ | |
/// <summary> | |
/// Defines a section of an audio clip and whether is a loop or not. | |
/// </summary> | |
[CreateAssetMenu(fileName = "Audio Loop", menuName = "Audio/Audio Loop")] | |
public class AudioSampler : ScriptableObject | |
{ | |
[SerializeField] private AudioClip _audioClip; | |
[SerializeField] private bool _isLoop; | |
[SerializeField] private double _startTime; | |
[SerializeField] private double _endTime; | |
public AudioClip AudioClip | |
{ | |
get => _audioClip; | |
private set => _audioClip = value; | |
} | |
public bool IsLoop | |
{ | |
get => _isLoop; | |
private set => _isLoop = value; | |
} | |
public double StartTime | |
{ | |
get => _startTime; | |
private set => _startTime = value; | |
} | |
public double EndTime | |
{ | |
get => _endTime; | |
private set => _endTime = value; | |
} | |
public double Lenght => _endTime - _startTime; | |
public float GetBeatsAmount(int bpm) | |
{ | |
return (float)(bpm * Lenght / 60f); | |
} | |
public double GetClipTime(double beat, ref bool success) | |
{ | |
var time = _startTime + beat; | |
if (time < 0) | |
{ | |
success = false; | |
return 0; | |
} | |
if (time > Lenght && time > AudioClip.length) | |
{ | |
success = false; | |
return AudioClip.length; | |
} | |
success = true; | |
return time; | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using System.Collections; | |
using System.Collections.Generic; | |
using UnityEngine; | |
using UnityEngine.Audio; | |
namespace SmplSmplr.Scripts.AudioSamples | |
{ | |
/// <summary> | |
/// Plays the audio clips related to the audio samplers with | |
/// a seamless transition between them. | |
/// </summary> | |
public class SamplerPlayer: MonoBehaviour { | |
[SerializeField, Space] | |
private uint bpm; | |
[SerializeField, Range (0.0f, 5f)] | |
private float loadDuration; | |
[SerializeField, Range (0.0f, 5f)] | |
private float fadeDuration; | |
[SerializeField] | |
private AnimationCurve fadeInCurve = AnimationCurve.Linear (0, 0, 1, 1); | |
[SerializeField] | |
private AnimationCurve fadeOutCurve = AnimationCurve.Linear (0, 1, 1, 0); | |
#region Audio Source Settings | |
[Space] | |
[Header ("Audio Source Settings")] | |
[SerializeField] | |
protected AudioMixerGroup output; | |
[SerializeField] | |
protected bool mute; | |
[SerializeField] | |
protected bool bypassEffects; | |
[SerializeField] | |
protected bool bypassListenerEffects; | |
[SerializeField] | |
protected bool bypassReverbZones; | |
[SerializeField] | |
protected bool playOnAwake; | |
[SerializeField] | |
protected bool loop; | |
[SerializeField, Range (0, 256)] | |
protected int priority = 128; | |
[SerializeField, Range (0, 1)] | |
protected float volume = 1; | |
[SerializeField, Range (-3, 3)] | |
protected float pitch = 1; | |
[SerializeField, Range (-1, 1)] | |
protected float stereoPan; | |
[SerializeField, Range (0, 1)] | |
protected float spatialBlend; | |
[SerializeField, Range (0, 1.1f)] | |
protected float reverbZoneMix = 1; | |
[Header ("3D Sound Settings")] | |
[SerializeField, Range (0, 5)] | |
protected float dopplerLevel = 1; | |
[SerializeField, Range (0, 360)] | |
protected int spread; | |
[SerializeField] | |
protected AudioRolloffMode volumeRolloff = AudioRolloffMode.Logarithmic; | |
[SerializeField] | |
protected float minDistance = 1; | |
[SerializeField] | |
protected float maxDistance = 500; | |
#endregion | |
private AudioSampler _currentSampler; | |
private List<AudioSampler> _samplerQueue; | |
private List<AudioSource> _sourcesList; | |
private Stack<AudioSource> _sourcesStack; | |
private AudioSampler _incomingSampler; | |
private AudioSource _source; | |
private AudioSource _nextSource; | |
private double _referenceTime; | |
private AudioSampler CurrentSampler | |
{ | |
get => _currentSampler; | |
set | |
{ | |
_currentSampler = value; | |
CurrentSamplerChanged?.Invoke(_currentSampler); | |
} | |
} | |
public System.Action<AudioSampler> CurrentSamplerChanged { get; set; } | |
public System.Action<AudioSampler> NextSamplerChanged { get; set; } | |
public double ChangeTime { get; private set; } | |
public bool IsPlaying { get; private set; } | |
private bool SkipLoop { get; set; } | |
public double DSPTime => AudioSettings.dspTime; | |
public uint Bpm => bpm; | |
double BeatDuration => 60f / Bpm; | |
double FadeDuration => fadeDuration; | |
double IncomingBeatTime { | |
get { | |
var difference = DSPTime - _referenceTime; | |
var beat = Mathf.CeilToInt ((float)(difference / BeatDuration)); | |
return beat * BeatDuration + _referenceTime; | |
} | |
} | |
private void Awake () { | |
if (_referenceTime <= 0) | |
_referenceTime = DSPTime; | |
_samplerQueue = new List<AudioSampler>(); | |
_sourcesList = new List<AudioSource>(); | |
_sourcesStack = new Stack<AudioSource>(); | |
AddAudioSource (); | |
AddAudioSource (); | |
ChangeTime = 0; | |
} | |
private void Start () { | |
if (playOnAwake) | |
Play (); | |
} | |
private void AddAudioSource () { | |
var audioSource = gameObject.AddComponent<AudioSource> (); | |
audioSource.hideFlags = HideFlags.HideInInspector; | |
audioSource.outputAudioMixerGroup = output; | |
audioSource.mute = mute; | |
audioSource.bypassEffects = bypassEffects; | |
audioSource.bypassListenerEffects = bypassListenerEffects; | |
audioSource.bypassReverbZones = bypassReverbZones; | |
audioSource.playOnAwake = false; | |
audioSource.loop = false; | |
audioSource.priority = priority; | |
audioSource.volume = 0; | |
audioSource.pitch = pitch; | |
audioSource.panStereo = stereoPan; | |
audioSource.spatialBlend = spatialBlend; | |
audioSource.reverbZoneMix = reverbZoneMix; | |
audioSource.dopplerLevel = dopplerLevel; | |
audioSource.spread = spread; | |
audioSource.rolloffMode = volumeRolloff; | |
audioSource.minDistance = minDistance; | |
audioSource.maxDistance = maxDistance; | |
_sourcesList.Add (audioSource); | |
_sourcesStack.Push (audioSource); | |
} | |
public void Next() | |
{ | |
if (IsPlaying) | |
{ | |
SkipLoop = true; | |
} | |
else | |
{ | |
Play(); | |
} | |
} | |
public void Play () { | |
PlayScheduled (IncomingBeatTime); | |
} | |
private void PlayScheduled (double schedule) { | |
if (IsPlaying) | |
return; | |
Prepare (schedule); | |
StartCoroutine (FadeCoroutine (schedule)); | |
IsPlaying = true; | |
} | |
private IEnumerator StopCoroutine (float delay) { | |
yield return new WaitForSeconds (delay); | |
Stop (); | |
} | |
public void Stop () { | |
ChangeTime = 0; | |
StopAllCoroutines (); | |
_sourcesList.ForEach (source => source.Stop ()); | |
IsPlaying = false; | |
} | |
private void Prepare (double schedule) { | |
_incomingSampler = GetSampler (); | |
if (!_incomingSampler) { | |
StartCoroutine (StopCoroutine((float)(ChangeTime-DSPTime))); | |
return; | |
} | |
ChangeTime = schedule + _incomingSampler.Lenght; | |
var fadeIn = false; | |
var startTime = _incomingSampler.GetClipTime (-FadeDuration, ref fadeIn); | |
var playTime = fadeIn ? schedule - FadeDuration : schedule; | |
var fadeOut = false; | |
var endTime = _incomingSampler.GetClipTime (FadeDuration, ref fadeOut); | |
var stopTime = fadeOut ? ChangeTime + FadeDuration : ChangeTime; | |
_nextSource = GetAudioSource (); | |
_nextSource.clip = _incomingSampler.AudioClip; | |
_nextSource.time = (float)startTime; | |
_nextSource.PlayScheduled (playTime); | |
_nextSource.SetScheduledEndTime (stopTime); | |
StartCoroutine (PrepareCoroutine (ChangeTime)); | |
} | |
private IEnumerator PrepareCoroutine (double schedule) { | |
var delay = (float)(schedule - DSPTime) - loadDuration; | |
yield return new WaitForSecondsRealtime (delay > 0 ? delay : 0); | |
Prepare (schedule); | |
StartCoroutine (FadeCoroutine (schedule)); | |
} | |
private IEnumerator FadeCoroutine (double schedule) { | |
// Fade In Next Source. | |
if (_nextSource) | |
_nextSource.volume = 0; | |
if (_source) | |
_source.volume = volume; | |
while (DSPTime < schedule) { | |
var t = 0.5f - 0.5f * Mathf.Clamp01 ((float)((schedule - DSPTime) / FadeDuration)); | |
if (_source) | |
_source.volume = fadeOutCurve.Evaluate(t)*volume; | |
if (_nextSource) | |
_nextSource.volume = fadeInCurve.Evaluate(t)*volume; | |
yield return null; | |
} | |
yield return null; | |
// Fade Out Current Source. | |
while (DSPTime < schedule + FadeDuration) { | |
var t = 1 - 0.5f * Mathf.Clamp01 ((float)((schedule + FadeDuration - DSPTime) / FadeDuration)); | |
if (_source) | |
_source.volume = fadeOutCurve.Evaluate (t) * volume; | |
if (_nextSource) | |
_nextSource.volume = fadeInCurve.Evaluate (t) * volume; | |
yield return null; | |
} | |
if (_nextSource) | |
_nextSource.volume = volume; | |
if (_source) | |
_source.volume = 0; | |
SetNextToCurrent (); | |
IsPlaying = CurrentSampler; | |
} | |
private void SetNextToCurrent () { | |
// Clearing current source. | |
if (_source) { | |
_source.Pause(); | |
// _source.Stop (); | |
// _source.clip = null; | |
StackAudioSource (_source); | |
} | |
_source = null; | |
CurrentSampler = null; | |
_source = _nextSource; | |
CurrentSampler = _incomingSampler; | |
_nextSource = null; | |
_incomingSampler = null; | |
} | |
private AudioSource GetAudioSource () { | |
var source = _sourcesStack.Pop (); | |
source.Stop (); | |
return source; | |
} | |
private void StackAudioSource (AudioSource source) { | |
// source.Stop (); | |
_sourcesStack.Push (source); | |
} | |
/// <summary> Adds an audio sampler to queue. </summary> | |
/// <returns><c>true</c>, if to queue was added.</returns> | |
/// <param name="sampler">Audio Sampler.</param> | |
public bool AddToQueue (AudioSampler sampler) { | |
if (!sampler || !sampler.AudioClip) return false; | |
_samplerQueue.Add (sampler); | |
return true; | |
} | |
public void AddToQueue(AudioSampler[] samplers) | |
{ | |
for (var i = 0; i < samplers.Length; i++) | |
{ | |
AddToQueue(samplers[i]); | |
} | |
} | |
private AudioSampler GetSampler () { | |
// Return the same sample if it's a loop. | |
if (IsPlaying && CurrentSampler.IsLoop && !SkipLoop) | |
{ | |
return CurrentSampler; | |
} | |
SkipLoop = false; | |
AudioSampler next = null; | |
while (!next && _samplerQueue.Count > 0) { | |
if (!_samplerQueue[0].AudioClip) { | |
_samplerQueue.RemoveAt (0); | |
} | |
else { | |
next = _samplerQueue[0]; | |
_samplerQueue.RemoveAt (0); | |
} | |
} | |
NextSamplerChanged?.Invoke(_samplerQueue.Count > 0 ? _samplerQueue[0] : null); | |
return next; | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment