Skip to content

Instantly share code, notes, and snippets.

@StamatisP
Created July 10, 2023 22:54
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save StamatisP/8d24e4199ee9e4093eff4436162fc0bd to your computer and use it in GitHub Desktop.
Save StamatisP/8d24e4199ee9e4093eff4436162fc0bd to your computer and use it in GitHub Desktop.
Damn Daniel's Conductor
using System.Collections;
using System.Collections.Generic;
using DG.Tweening;
using Melanchall.DryWetMidi.Interaction;
using Sirenix.OdinInspector;
using UnityEngine;
using UnityEngine.Audio;
using UnityEngine.Events;
// thank you graham! https://www.gamasutra.com/blogs/GrahamTattersall/20190515/342454/Coding_to_the_Beat__Under_the_Hood_of_a_Rhythm_Game_in_Unity.php
public class Conductor : MonoBehaviour
{
[Header("Audio")] public AudioMixer audioMixer;
public AudioSource IntroSource;
public AudioSource LoopSource;
public AudioSource OutroSource;
//The offset to the first beat of the song in seconds
[Header("Enterable Info")] public float firstBeatOffset;
//Song beats per minute
//This is determined by the song you're trying to sync up to
public float songBpm;
//The number of seconds for each song beat
public float secPerBeat => 60f / songBpm;
//the number of beats in each loop
public float beatsPerLoop;
public float ConductorVolume = 0.25f;
//time signature
[Tooltip("Time signature")] public int NumberOfBeatsPerMeasure = 4;
[Header("Song info")]
//Current song position, in seconds
public float songPosition;
//Current song position, in beats
public float songPositionInBeats;
// Current song position in measures
public int songPositionInMeasures;
// A measure has 4 beats. this shows the position between 0 and 3 (4 beats)
public float songPositionInBeatsWithinMeasure;
//The current relative position of the song within the loop measured between 0 and 1.
public float loopPositionInAnalog;
// duh
public float loopPositionInSeconds;
//How many seconds have passed since the song started
public double dspSongTime;
//the total number of loops completed since the looping clip first started
public int completedLoops = 0;
//The current position of the song within the loop in beats.
public float loopPositionInBeats;
// Current position in measures.
public int loopPositionInMeasures;
// Dictionary that represent beats, and the events related to them, in the song.
public Dictionary<int, SongEvent> SongEvents;
//an AudioSource attached to this GameObject that will play the music.
// 0 is Intro, 1 is Loop, 2 is Outro
[HideInInspector] public AudioSource[] musicSources = new AudioSource[3];
[HideInInspector] public BeatEvent OnNewBeat;
[HideInInspector] public BeatEvent experimentNewBeat;
[HideInInspector] public BeatEvent OnNewLoopBeat;
[HideInInspector] public UnityEvent OnSwitchFromIntro;
private Coroutine
_introSwitchCoroutine; // this is so we can stop it if another song is played before the intro of the original song ends
[HideInInspector] public UnityEvent OnSwitchToOutro;
[HideInInspector] public UnityEvent OnNewSong;
[HideInInspector] public UnityEvent OnSongLoop;
public delegate void OnSongEvent(SongEvent songEvent);
public static OnSongEvent onSongEvent;
public delegate void OnSongEnd();
public static OnSongEnd onSongEnd;
// a high negative value cause no way a song has 90 beats of intro... right
public int lastIntBeat = -90;
private float lastbeat; //as a test
[SerializeField] private bool UseSongInfo = true;
[SerializeField] private SetSongInfo SongInfoUI;
private Song _song;
private bool IsPaused = false;
private Dictionary<Song, float> SongSavedTimes;
// for music app access
public List<Song> AllSongs;
public enum ConductorState
{
IDLE,
INTRO,
LOOP,
OUTRO
}
[ShowInInspector] public static ConductorState state;
private List<ConductorBpmHistory> _bpmHistories;
private float songPosOffset = 0;
private void Awake()
{
GameManager.conductor = this;
GameManager.audioMixer = audioMixer;
state = ConductorState.IDLE;
}
// Start is called before the first frame update
void Start()
{
//Load the AudioSource attached to the Conductor GameObject
musicSources[0] = IntroSource;
musicSources[1] = LoopSource;
musicSources[2] = OutroSource;
DontDestroyOnLoad(gameObject);
if (OnNewBeat == null)
OnNewBeat = new BeatEvent();
dspSongTime = AudioSettings.dspTime;
SongSavedTimes = new Dictionary<Song, float>();
state = ConductorState.IDLE;
_bpmHistories = new List<ConductorBpmHistory>();
}
public void PlaySong(Song song, float fadeIn = 3f, bool playFromSavedTime = false, bool playTrack = false)
{
// make this better todo
Stop();
SetMusicInfo(song);
// this is to prevent interrupting the intro cause it loads in with a hitch
double latency = 0.1d;
if (song.Intro)
musicSources[0].clip = song.Intro;
musicSources[1].clip = song.Loop;
musicSources[0].volume = ConductorVolume;
musicSources[1].volume = ConductorVolume;
musicSources[2].volume = ConductorVolume;
musicSources[1].time = 0; // done to prevent fuckery
completedLoops = 0;
if (song.Outro)
musicSources[2].clip = song.Outro;
// playing whole track
if (playTrack)
{
if (UseSongInfo)
SongInfoUI.SetInfo(song);
state = ConductorState.INTRO;
firstBeatOffset = 0;
musicSources[0].clip = song.Track;
musicSources[0].Play();
dspSongTime = AudioSettings.dspTime;
OnNewSong.Invoke();
return;
}
if (playFromSavedTime)
{
musicSources[1].time = GetSavedSongTime(song);
musicSources[1].volume = 0;
musicSources[1].DOFade(ConductorVolume, fadeIn);
musicSources[1].Play();
dspSongTime = (AudioSettings.dspTime - musicSources[1].time) + firstBeatOffset;
if (UseSongInfo)
SongInfoUI.SetInfo(song);
// this is done to prevent the intro switch event being called if the song is switched midway through an intro
if (_introSwitchCoroutine != null)
StopCoroutine(_introSwitchCoroutine);
state = ConductorState.LOOP;
OnNewSong.Invoke();
return;
}
if (song.Intro)
{
musicSources[0].PlayScheduled(AudioSettings.dspTime + latency);
musicSources[0].DOFade(ConductorVolume, fadeIn);
// this is done to prevent the intro switch event being called if the song is switched midway through an intro
if (_introSwitchCoroutine != null)
StopCoroutine(_introSwitchCoroutine);
double introDuration = (double) musicSources[0].clip.samples / musicSources[0].clip.frequency;
_introSwitchCoroutine =
StartCoroutine(SwitchFromIntroEvent(AudioSettings.dspTime + introDuration + (float) latency));
musicSources[1].PlayScheduled(AudioSettings.dspTime + introDuration + latency);
state = ConductorState.INTRO;
dspSongTime = AudioSettings.dspTime + latency;
if (UseSongInfo)
SongInfoUI.SetInfo(song);
IsPaused = false;
OnNewSong.Invoke();
return;
}
else
{
musicSources[1].Play();
state = ConductorState.LOOP;
}
dspSongTime = AudioSettings.dspTime;
if (UseSongInfo)
SongInfoUI.SetInfo(song);
IsPaused = false;
OnNewSong.Invoke();
}
public void Stop()
{
foreach (var musicSource in musicSources)
{
musicSource.Stop();
musicSource.DOKill();
}
state = ConductorState.IDLE;
onSongEnd?.Invoke();
}
public void Pause()
{
IsPaused = true;
foreach (var musicSource in musicSources)
{
musicSource.Pause();
}
}
public void UnPause()
{
foreach (var musicSource in musicSources)
{
musicSource.UnPause();
}
IsPaused = false;
}
public void TogglePause()
{
if (IsPaused)
{
foreach (var musicSource in musicSources)
{
musicSource.UnPause();
}
IsPaused = false;
}
else
{
IsPaused = true;
foreach (var musicSource in musicSources)
{
musicSource.Pause();
}
}
}
public void EndSongOnNextMeasure()
{
print("End song on next measure queued");
OnNewBeat.AddListener(CheckIfMeasureThenEnd);
}
void CheckIfMeasureThenEnd(int beat)
{
if (beat != 0) return;
OnSwitchToOutro.Invoke();
musicSources[1].DOFade(0f, 0.2f);
musicSources[2].Play();
OnNewBeat.RemoveListener(CheckIfMeasureThenEnd);
}
public void SetVolume(float vol)
{
foreach (var musicSource in musicSources)
{
musicSource.volume = vol;
}
}
public void FadeOut(float duration = 1f, bool saveEndingTime = false)
{
Song currentSong = GetCurrentSong();
foreach (var musicSource in musicSources)
{
if (!saveEndingTime)
musicSource.DOFade(0f, duration).OnComplete(Stop);
else
musicSource.DOFade(0f, duration).OnComplete(() =>
{
SaveSongTime(currentSong);
Stop();
}
);
}
}
public void SaveSongTime(Song song)
{
if (SongSavedTimes.ContainsKey(song))
{
// if dictionary contains this song as a key
SongSavedTimes[song] = musicSources[1].time;
}
else
{
SongSavedTimes.Add(song, musicSources[1].time);
}
}
public float GetSavedSongTime(Song song)
{
// try using TryGetValue?
if (SongSavedTimes.ContainsKey(song))
{
// if dictionary contains this song as a key
return SongSavedTimes[song];
}
else
{
Debug.LogWarning("Song \"" + song.TrackName + "\" does not have a saved time!");
return 0;
}
}
public Song GetCurrentSong()
{
return _song;
}
public float GetSongLength()
{
AudioSource audioSource = null;
switch (state)
{
case ConductorState.IDLE:
return 0f;
case ConductorState.INTRO:
audioSource = musicSources[0];
break;
case ConductorState.LOOP:
audioSource = musicSources[1];
break;
case ConductorState.OUTRO:
audioSource = musicSources[2];
break;
}
return audioSource.clip.length;
}
public AudioSource GetCurrentPlayingSource()
{
switch (state)
{
case ConductorState.IDLE:
return musicSources[0];
case ConductorState.INTRO:
return musicSources[0];
case ConductorState.LOOP:
return musicSources[1];
case ConductorState.OUTRO:
return musicSources[2];
default:
break;
}
return null;
}
public IEnumerator SwitchFromIntroEvent(double introLength)
{
while (AudioSettings.dspTime < introLength)
{
yield return null;
}
//yield return new WaitForSecondsRealtime(introLength / musicSources[1].pitch);
print("switching from intro");
OnSwitchFromIntro.Invoke();
_introSwitchCoroutine = null;
state = ConductorState.LOOP;
}
// Update is called once per frame
void Update()
{
if (_song == null) return;
//determine how many seconds since the song started
if (IsPaused)
{
dspSongTime += Time.deltaTime;
return;
}
//if (_song != null)
//songBpm = _song.songBpm * musicSources[1].pitch;
songPosition = (float) (AudioSettings.dspTime - dspSongTime) - (firstBeatOffset);
//determine how many beats since the song started
songPositionInBeats = GetTotalSongBeats();
if (songPositionInBeats >= (completedLoops + 1) * beatsPerLoop)
{
completedLoops++;
OnSongLoop.Invoke();
}
loopPositionInBeats = songPositionInBeats - completedLoops * beatsPerLoop;
loopPositionInAnalog = loopPositionInBeats / beatsPerLoop;
//loopPositionInSeconds = songPosition - completedLoops * (beatsPerLoop * secPerBeat);
loopPositionInSeconds = songPosition % _song.Loop.length;
loopPositionInMeasures = (int) loopPositionInBeats / NumberOfBeatsPerMeasure;
songPositionInMeasures = (int) songPositionInBeats / NumberOfBeatsPerMeasure;
songPositionInBeatsWithinMeasure = songPositionInBeats - songPositionInMeasures * NumberOfBeatsPerMeasure;
if (Mathf.FloorToInt(songPositionInBeats) > lastIntBeat && state != ConductorState.IDLE)
{
//print(Mathf.FloorToInt(songPositionInBeats) + " is greater than " + lastIntBeat);
int songpos = Mathf.FloorToInt(songPositionInBeats);
int looppos = Mathf.FloorToInt(loopPositionInBeats);
if (SongEvents != null && SongEvents.ContainsKey(looppos)) onSongEvent?.Invoke(SongEvents[looppos]);
OnNewBeat.Invoke(songpos - songPositionInMeasures * NumberOfBeatsPerMeasure);
OnNewLoopBeat.Invoke(looppos);
lastIntBeat = songpos;
}
// test
if (songPositionInBeats > lastbeat + secPerBeat)
{
lastbeat += secPerBeat;
experimentNewBeat.Invoke(Mathf.FloorToInt(lastbeat));
}
if (!musicSources[0].isPlaying && !musicSources[1].isPlaying && !musicSources[2].isPlaying &&
state != ConductorState.IDLE)
{
Stop();
}
Shader.SetGlobalFloat("_ShaderTimeInBeats", songPositionInBeats);
}
public void SetMusicInfo(Song song)
{
_song = song;
firstBeatOffset = song.Intro != null ? song.Intro.length : 0f;
songBpm = song.songBpm;
beatsPerLoop = song.beatsPerLoop;
NumberOfBeatsPerMeasure = song.NumberOfBeatsPerMeasure;
//beatsPerLoop = song.Loop.length * (songBpm / 60f);
SongEvents = song.SongEvents;
lastIntBeat = -90;
lastbeat = 0;
}
public void ChangeBPM(float bpm)
{
// save bpm switch info
ConductorBpmHistory hist = new ConductorBpmHistory(songPosOffset, songPosition, secPerBeat);
_bpmHistories.Add(hist);
songBpm = bpm;
songPosOffset = songPosition;
Debug.Log($"BPM changed to {bpm}, song offset {songPosOffset}");
}
public void ChangeTimeSignature(TimeSignature timeSig)
{
NumberOfBeatsPerMeasure = timeSig.Numerator;
}
public float GetTotalSongBeats()
{
float songPosInBeats = 0;
foreach (var history in _bpmHistories)
{
songPosInBeats += history.GetSongPosInBeats();
}
float currentPos = (songPosition - songPosOffset) / secPerBeat;
return songPosInBeats + currentPos;
}
public float SecondsToBeats(float seconds)
{
return seconds * (songBpm / 60f);
}
public float BeatsToSeconds(float beats)
{
return beats / (songBpm / 60f);
}
public class WaitForNextMeasure : CustomYieldInstruction
{
readonly Conductor _cnd;
readonly int _lastMeasure;
public override bool keepWaiting => !(_cnd.songPositionInMeasures > _lastMeasure);
/// <summary>
/// Wait for next measure.
/// </summary>
/// <param name="_cnd"></param>
/// <param name="measuresToWait">How many measures you want to wait - 1</param>
public WaitForNextMeasure(Conductor _cnd, int measuresToWait = 0)
{
this._cnd = _cnd;
_lastMeasure = this._cnd.songPositionInMeasures + measuresToWait;
}
}
public class WaitForBeats : CustomYieldInstruction
{
readonly Conductor _cnd;
readonly float _lastBeat;
public override bool keepWaiting => !(_cnd.songPositionInBeats > _lastBeat);
/// <summary>
/// Wait for next measure.
/// </summary>
/// <param name="cnd"></param>
/// <param name="beatsToWait">How many measures you want to wait - 1</param>
public WaitForBeats(Conductor cnd, float beatsToWait = 0)
{
this._cnd = cnd;
_lastBeat = this._cnd.songPositionInBeats + beatsToWait;
}
}
public class WaitForIntro : CustomYieldInstruction
{
readonly Conductor _cnd;
public override bool keepWaiting => Conductor.state == ConductorState.INTRO;
/// <summary>
/// Wait for intro.
/// </summary>
/// <param name="cnd"></param>
public WaitForIntro(Conductor cnd)
{
this._cnd = cnd;
}
}
public class ConductorBpmHistory
{
private float secPerBeat = 0;
private float songPosOffset;
private float songPosEnding;
public ConductorBpmHistory(float songOffset, float songEnding, float secPb)
{
songPosOffset = songOffset;
songPosEnding = songEnding;
secPerBeat = secPb;
}
public float GetSongPosInBeats()
{
return (songPosEnding - songPosOffset) / secPerBeat;
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment