Created
July 10, 2023 22:54
-
-
Save StamatisP/8d24e4199ee9e4093eff4436162fc0bd to your computer and use it in GitHub Desktop.
Damn Daniel's Conductor
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 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