From b73c7ee3cb20862ebef8643b0e38c23cd4fe2cfb Mon Sep 17 00:00:00 2001 From: ivan tkachenko Date: Thu, 17 Jul 2025 22:34:38 +0300 Subject: [PATCH] Sync playback to the actual beat count rather than relying on BPM --- MuzikaGromche/Plugin.cs | 143 ++++++++++++++++++++++++++-------------- 1 file changed, 92 insertions(+), 51 deletions(-) diff --git a/MuzikaGromche/Plugin.cs b/MuzikaGromche/Plugin.cs index 0a68a3b..9a9d629 100644 --- a/MuzikaGromche/Plugin.cs +++ b/MuzikaGromche/Plugin.cs @@ -29,42 +29,42 @@ namespace MuzikaGromche Name = "MuzikaGromche", Language = Language.RUSSIAN, WindUpTimer = 46.3f, - Bpm = 130f, + Bars = 8, }, new Track { Name = "VseVZale", Language = Language.RUSSIAN, WindUpTimer = 39f, - Bpm = 138f, + Bars = 8, }, new Track { Name = "DeployDestroy", Language = Language.RUSSIAN, WindUpTimer = 40.7f, - Bpm = 130f, + Bars = 8, }, new Track { Name = "MoyaZhittya", Language = Language.ENGLISH, WindUpTimer = 34.5f, - Bpm = 120f, + Bars = 8, }, new Track { Name = "Gorgorod", Language = Language.RUSSIAN, WindUpTimer = 43.2f, - Bpm = 180f, + Bars = 6, }, new Track { Name = "Durochka", Language = Language.RUSSIAN, WindUpTimer = 37f, - Bpm = 130f, + Bars = 10, } ]; @@ -79,24 +79,8 @@ namespace MuzikaGromche return Tracks[trackId]; } - public static Coroutine JesterLightSwitching; public static Track CurrentTrack; - public static void StartLightSwitching(MonoBehaviour __instance) - { - StopLightSwitching(__instance); - JesterLightSwitching = __instance.StartCoroutine(RotateColors()); - } - - public static void StopLightSwitching(MonoBehaviour __instance) - { - if (JesterLightSwitching != null) - { - __instance.StopCoroutine(JesterLightSwitching); - JesterLightSwitching = null; - } - } - public static void SetLightColor(Color color) { foreach (var light in RoundManager.Instance.allPoweredLights) @@ -110,30 +94,6 @@ namespace MuzikaGromche SetLightColor(Color.white); } - // TODO: Move to Track class to make them customizable per-song - static List colors = [Color.magenta, Color.cyan, Color.green, Color.yellow]; - - public static IEnumerator RotateColors() - { - Debug.Log("Starting color rotation"); - var i = 0; - while (true) - { - var color = colors[i]; - Debug.Log("Chose color " + color); - SetLightColor(color); - i = (i + 1) % colors.Count; - if (CurrentTrack != null) - { - yield return new WaitForSeconds(60f / CurrentTrack.Bpm); - } - else - { - yield break; - } - } - } - private void Awake() { string text = Info.Location.TrimEnd((PluginInfo.PLUGIN_NAME + ".dll").ToCharArray()); @@ -183,9 +143,18 @@ namespace MuzikaGromche // from the looped part. This also means that the light show starts before // the looped track does, so we need to sync them up as soon as we enter the Loop. public float WindUpTimer; - // BPM for light switching in sync with the music. There is no offset, - // so the Loop track should start precisely on a beat. - public float Bpm; + + // Estimated number of beats per minute. Not used for light show, but might come in handy. + public float Bpm => 60f / (LoadedLoop.length / Beats); + + // How many beats the loop segment has. The default strategy is to switch color of lights on each beat. + public int Beats; + + // Shorthand for four beats + public int Bars + { + set => Beats = value * 4; + } // MPEG is basically mp3, and it can produce gaps at the start. // WAV is OK, but takes a lot of space. Try OGGVORBIS instead. @@ -194,6 +163,9 @@ namespace MuzikaGromche public AudioClip LoadedStart; public AudioClip LoadedLoop; + // This does not account for the timestamp when Jester has actually popped + public float FixedLoopDelay => LoadedStart.length - WindUpTimer; + // How often this track should be chosen, relative to the sum of weights of all tracks. public SyncedEntry Weight; @@ -206,6 +178,70 @@ namespace MuzikaGromche AudioType.OGGVORBIS => "ogg", _ => "", }; + + public float CalculateBeat(AudioSource start, AudioSource loop) + { + // If popped, calculate which beat the music is currently at. + // In order to do that we should choose one of two strategies: + // + // 1. If start source is still playing, use its position since WindUpTimer. + // 2. Otherwise use loop source, adding the delay after WindUpTimer, + // which is the remaining of the start, i.e. (LoadedStart.length - WindUpTimer). + // + // NOTE 1: PlayDelayed also counts as isPlaying, so loop.isPlaying is always true and as such it's useful. + // NOTE 2: There is a weird state when Jester has popped and chases a player: + // Start/farAudio isPlaying is true but stays exactly at zero time, so we need to ignore that. + + var elapsed = start.isPlaying && start.time != 0f + // [1] Start source is still playing + ? start.time - WindUpTimer + // [2] Start source has finished + : loop.time + FixedLoopDelay; + + var normalized = Mod.Positive(elapsed / LoadedLoop.length, 1f); + + var beat = normalized * (float)Beats; +#if DEBUG + var color = ColorAtBeat(beat); + Debug.LogFormat("MuzikaGromche t={0,10:N4} d={1,7:N4} Start[{2}{3,8:N4} ==0f? {4}] Loop[{5}{6,8:N4}] norm={7,6:N4} beat={8,7:N4} color={9}", + Time.realtimeSinceStartup, Time.deltaTime, + (start.isPlaying ? '+' : ' '), start.time, (start.time == 0f ? 'Y' : 'n'), + (loop.isPlaying ? '+' : ' '), loop.time, + normalized, beat, color); +#endif + return beat; + } + + static readonly List Colors = [Color.magenta, Color.cyan, Color.green, Color.yellow]; + + public Color ColorAtBeat(float beat) + { + int beatIndex = Mod.Positive(Mathf.FloorToInt(beat), Beats); + + return Mod.Index(Colors, beatIndex); + } + } + + // Default C#/.NET remainder operator % returns negative result for negative input + // which is unsuitable as an index for an array. + public static class Mod + { + public static int Positive(int x, int m) + { + int r = x % m; + return r < 0 ? r + m : r; + } + + public static float Positive(float x, float m) + { + float r = x % m; + return r < 0f ? r + m : r; + } + + public static T Index(IList array, int index) + { + return array[Mod.Positive(index, array.Count)]; + } } public readonly struct RandomWeightedIndex @@ -506,7 +542,6 @@ namespace MuzikaGromche if (__instance.previousState != 2 && __state.previousState == 2) { - Plugin.StopLightSwitching(__instance); Plugin.ResetLightColor(); } @@ -526,8 +561,14 @@ namespace MuzikaGromche Debug.Log($"Start length: {Plugin.CurrentTrack.LoadedStart.length}; played time: {time}"); Debug.Log($"Playing loop music: maxDistance: {__instance.creatureVoice.maxDistance}, minDistance: {__instance.creatureVoice.minDistance}, volume: {__instance.creatureVoice.volume}, spread: {__instance.creatureVoice.spread}, in seconds: {delay}"); + } - Plugin.StartLightSwitching(__instance); + // Manage the timeline: switch color of the lights according to the current playback/beat position. + if (__instance.previousState == 2) + { + var beat = Plugin.CurrentTrack.CalculateBeat(start: __instance.farAudio, loop: __instance.creatureVoice); + var color = Plugin.CurrentTrack.ColorAtBeat(beat); + Plugin.SetLightColor(color); } } }