From a69e46c6a3036c22989b1b1320456db3175300be Mon Sep 17 00:00:00 2001 From: ivan tkachenko Date: Sat, 12 Jul 2025 23:28:23 +0300 Subject: [PATCH] Sync playback to the actual beat count rather than relying on BPM --- MuzikaGromche/Plugin.cs | 123 +++++++++++++++++++++++----------------- 1 file changed, 71 insertions(+), 52 deletions(-) diff --git a/MuzikaGromche/Plugin.cs b/MuzikaGromche/Plugin.cs index c2ddb19..442a528 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, } ]; @@ -88,24 +88,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) @@ -119,30 +103,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()); @@ -192,9 +152,20 @@ 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. + // This should be an integer, but it is stored as float for convenience of calculations. + public float Beats; + + // Shorthand for four beats + public float Bars + { + get => Beats / 4f; + set => Beats = value * 4f; + } // MPEG is basically mp3, and it can produce gaps at the start. // WAV is OK, but takes a lot of space. Try OGGVORBIS instead. @@ -203,6 +174,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; @@ -215,6 +189,41 @@ 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: PlayDelayed also counts as isPlaying, so loop.isPlaying is always true. + + var elapsed = start.isPlaying + // [1] Start source is still playing + ? start.time - WindUpTimer + // [2] Start source has finished + : loop.time + FixedLoopDelay; + + var normilized = elapsed / LoadedLoop.length % 1f; + + var beat = normilized * Beats; +#if DEBUG + Debug.LogFormat("MuzikaGromche beat {0,10:N4} {1,10:N4} {2,10:N4}", Time.realtimeSinceStartup, normilized, beat); +#endif + return beat; + } + + static readonly List Colors = [Color.magenta, Color.cyan, Color.green, Color.yellow]; + + public Color ColorForBeat(float beat) + { + int beatIndex = (int)(Math.Floor(beat) % Beats); + + return Colors[beatIndex % Colors.Count]; + } } public readonly struct RandomWeightedIndex @@ -370,6 +379,10 @@ namespace MuzikaGromche public static CanModifyResult CanModifyWeightsNow() { +#if DEBUG + // In debug mode let us modify weights any time without restarting the level + return CanModifyResult.True(); +#else var startOfRound = StartOfRound.Instance; if (!startOfRound) { @@ -384,6 +397,7 @@ namespace MuzikaGromche return CanModifyResult.False("Only while orbiting"); } return CanModifyResult.True(); +#endif } } @@ -435,7 +449,7 @@ namespace MuzikaGromche // ...and start modded music Plugin.CurrentTrack = Plugin.ChooseTrack(); - // Set up custom + // Set up custom popup timer, which is shorter than Start audio __instance.popUpTimer = Plugin.CurrentTrack.WindUpTimer; // Override popGoesTheWeaselTheme with Start audio @@ -449,7 +463,6 @@ namespace MuzikaGromche if (__instance.previousState != 2 && __state.previousState == 2) { - Plugin.StopLightSwitching(__instance); Plugin.ResetLightColor(); } @@ -469,8 +482,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.ColorForBeat(beat); + Plugin.SetLightColor(color); } } }