Skip to content

Instantly share code, notes, and snippets.

@javier-games
Last active October 10, 2024 09:14
Show Gist options
  • Save javier-games/fe5700c29b429e4e750ec7315b3148ff to your computer and use it in GitHub Desktop.
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
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;
}
}
}
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