diff --git a/CHANGELOG.md b/CHANGELOG.md index e6c274e..5a073ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - Added a new track OnePartiyaUdar in Japanese language. - Remastered recently added tracks at conventional 44100 Hz for better stitching. +- Improved playback experience: use precise DSP time and up-front scheduing for seamless audio stitching, add custom Audio Sources to improve reliability. ## MuzikaGromche 1337.420.9001 - Multiverse Edition diff --git a/MuzikaGromche/Plugin.cs b/MuzikaGromche/Plugin.cs index 28841f3..1bc5ba9 100644 --- a/MuzikaGromche/Plugin.cs +++ b/MuzikaGromche/Plugin.cs @@ -2249,20 +2249,57 @@ namespace MuzikaGromche } else { - Debug.Log($"{nameof(MuzikaGromche)} Patching {nameof(JesterAI)} with {nameof(MuzikaGromcheJesterNetworkBehaviour)} component"); networkPrefab.Prefab.AddComponent(); + Debug.Log($"{nameof(MuzikaGromche)} Patched JesterEnemy"); } } } class MuzikaGromcheJesterNetworkBehaviour : NetworkBehaviour { + const string IntroAudioGameObjectName = "MuzikaGromcheAudio (Intro)"; + const string LoopAudioGameObjectName = "MuzikaGromcheAudio (Loop)"; + // Number of times a selected track has been played. // Increases by 1 with each ChooseTrackServerRpc call. // Resets on SettingChanged. private int SelectedTrackIndex = 0; internal BeatTimeState? BeatTimeState = null; + internal AudioSource IntroAudioSource = null!; + internal AudioSource LoopAudioSource = null!; + + void Awake() + { + var farAudioTransform = gameObject.transform.Find("FarAudio"); + if (farAudioTransform == null) + { + Debug.LogError($"{nameof(MuzikaGromche)} JesterEnemy->FarAudio prefab not found!"); + } + else + { + // Instead of hijacking farAudio and creatureVoice sources, + // create our own copies to ensure uniform playback experience. + // For reasons unknown adding them directly to the prefab didn't work. + var introAudioGameObject = Instantiate(farAudioTransform.gameObject, gameObject.transform); + introAudioGameObject.name = IntroAudioGameObjectName; + + var loopAudioGameObject = Instantiate(farAudioTransform.gameObject, gameObject.transform); + loopAudioGameObject.name = LoopAudioGameObjectName; + + IntroAudioSource = introAudioGameObject.GetComponent(); + IntroAudioSource.maxDistance = Plugin.AudioMaxDistance; + IntroAudioSource.dopplerLevel = 0; + IntroAudioSource.loop = false; + + LoopAudioSource = loopAudioGameObject.GetComponent(); + LoopAudioSource.maxDistance = Plugin.AudioMaxDistance; + LoopAudioSource.dopplerLevel = 0; + LoopAudioSource.loop = true; + + Debug.Log($"{nameof(MuzikaGromche)} {nameof(MuzikaGromcheJesterNetworkBehaviour)} Patched JesterEnemy"); + } + } public override void OnNetworkSpawn() { @@ -2327,26 +2364,37 @@ namespace MuzikaGromche SetTrackClientRpc(audioTrack.Name); SelectedTrackIndex += 1; } + + internal void PlayScheduledLoop() + { + double loopStartDspTime = AudioSettings.dspTime + IntroAudioSource.clip.length - IntroAudioSource.time; + LoopAudioSource.PlayScheduled(loopStartDspTime); + Debug.Log($"{nameof(MuzikaGromche)} Play Intro: dspTime={AudioSettings.dspTime:N4}, intro.time={IntroAudioSource.time:N4}/{IntroAudioSource.clip.length:N4}, scheduled Loop={loopStartDspTime}"); + } } - // farAudio is during windup, Intro overrides popGoesTheWeaselTheme - // creatureVoice is when popped, Loop overrides screamingSFX [HarmonyPatch(typeof(JesterAI))] static class JesterPatch { -#if DEBUG [HarmonyPatch(nameof(JesterAI.SetJesterInitialValues))] [HarmonyPostfix] - static void AlmostInstantFollowTimerPostfix(JesterAI __instance) + static void SetJesterInitialValuesPostfix(JesterAI __instance) { + var behaviour = __instance.GetComponent(); + behaviour.IntroAudioSource.Stop(); + behaviour.LoopAudioSource.Stop(); + +#if DEBUG + // Almost instant follow timer __instance.beginCrankingTimer = 1f; - } #endif + } class State { - public required AudioSource farAudio; + public required int currentBehaviourStateIndex; public required int previousState; + public required float stunNormalizedTimer; } [HarmonyPatch(nameof(JesterAI.Update))] @@ -2355,20 +2403,10 @@ namespace MuzikaGromche { __state = new State { - farAudio = __instance.farAudio, + currentBehaviourStateIndex = __instance.currentBehaviourStateIndex, previousState = __instance.previousState, + stunNormalizedTimer = __instance.stunNormalizedTimer, }; - if (__instance.currentBehaviourStateIndex == 2 && __instance.previousState != 2) - { - // If just popped out, then override farAudio so that vanilla logic does not stop the modded Intro music. - // The game will stop farAudio it during its Update, so we temporarily set it to any other AudioSource - // which we don't care about stopping for now. - // - // Why creatureVoice though? We gonna need creatureVoice later in Postfix to schedule the Loop, - // but right now we still don't care if it's stopped, so it shouldn't matter. - // And it's cheaper and simpler than figuring out how to instantiate an AudioSource behaviour. - __instance.farAudio = __instance.creatureVoice; - } } [HarmonyPatch(nameof(JesterAI.Update))] @@ -2378,76 +2416,84 @@ namespace MuzikaGromche if (Plugin.CurrentTrack == null) { #if DEBUG - Debug.Log($"{nameof(MuzikaGromche)} CurrentTrack is not set!"); + Debug.LogError($"{nameof(MuzikaGromche)} CurrentTrack is not set!"); #endif return; } var behaviour = __instance.GetComponent(); + var introAudioSource = behaviour.IntroAudioSource; + var loopAudioSource = behaviour.LoopAudioSource; - if (__instance.previousState == 1 && __state.previousState != 1) + // This switch statement resembles the one from JesterAI.Update + switch (__state.currentBehaviourStateIndex) { - // if just started winding up - // then stop the default music... - __instance.farAudio.Stop(); - __instance.creatureVoice.Stop(); + case 1: + if (__state.previousState != 1) + { + // if just started winding up + // then stop the default music... (already done above) + // ...and set up both modded audio clips in advance + introAudioSource.clip = Plugin.CurrentTrack.LoadedIntro; + loopAudioSource.clip = Plugin.CurrentTrack.LoadedLoop; + behaviour.BeatTimeState = new BeatTimeState(Plugin.CurrentTrack); - // ...and start modded music - behaviour.BeatTimeState = new BeatTimeState(Plugin.CurrentTrack); - // Set up custom popup timer, which is shorter than Start audio - __instance.popUpTimer = Plugin.CurrentTrack.WindUpTimer; + // Set up custom popup timer, which is shorter than Intro audio + __instance.popUpTimer = Plugin.CurrentTrack.WindUpTimer; - // Override popGoesTheWeaselTheme with Start audio - __instance.farAudio.maxDistance = Plugin.AudioMaxDistance; - __instance.farAudio.clip = Plugin.CurrentTrack.LoadedIntro; - __instance.farAudio.loop = false; - if (Config.ShouldSkipWindingPhase) - { - var rewind = 5f; - __instance.popUpTimer = rewind; - __instance.farAudio.time = Plugin.CurrentTrack.WindUpTimer - rewind; - } - else - { - // reset if previously skipped winding by assigning different starting time. - __instance.farAudio.time = 0; - } - __instance.farAudio.Play(); + if (Config.ShouldSkipWindingPhase) + { + var rewind = 5f; + __instance.popUpTimer = rewind; + introAudioSource.time = Plugin.CurrentTrack.WindUpTimer - rewind; + } + else + { + // reset if previously skipped winding by assigning different starting time. + introAudioSource.time = 0f; + } - Debug.Log($"{nameof(MuzikaGromche)} Playing Intro music: maxDistance: {__instance.farAudio.maxDistance}, minDistance: {__instance.farAudio.minDistance}, volume: {__instance.farAudio.volume}, spread: {__instance.farAudio.spread}"); + __instance.farAudio.Stop(); + introAudioSource.Play(); + behaviour.PlayScheduledLoop(); + } + if (__instance.stunNormalizedTimer > 0f) + { + introAudioSource.Pause(); + loopAudioSource.Stop(); + } + else + { + if (!introAudioSource.isPlaying) + { + __instance.farAudio.Stop(); + introAudioSource.UnPause(); + behaviour.PlayScheduledLoop(); + } + } + break; + case 2: + if (__state.previousState != 2) + { + __instance.creatureVoice.Stop(); + } + break; } - if (__instance.previousState != 2 && __state.previousState == 2) + // transition away from state 2 ("poppedOut"), normally to state 0 + if (__state.previousState == 2 && __instance.previousState != 2) { - behaviour.BeatTimeState = null; Plugin.ResetLightColor(); DiscoBallManager.Disable(); // Rotate track groups - __instance.GetComponent()?.ChooseTrackServerRpc(); - } - - if (__instance.previousState == 2 && __state.previousState != 2) - { - // Restore stashed AudioSource. See the comment in Prefix - __instance.farAudio = __state.farAudio; - - var time = __instance.farAudio.time; - var delay = Plugin.CurrentTrack.LoadedIntro.length - time; - - // Override screamingSFX with Loop, delayed by the remaining time of the Intro audio - __instance.creatureVoice.Stop(); - __instance.creatureVoice.maxDistance = Plugin.AudioMaxDistance; - __instance.creatureVoice.clip = Plugin.CurrentTrack.LoadedLoop; - __instance.creatureVoice.PlayDelayed(delay); - - Debug.Log($"{nameof(MuzikaGromche)} Intro length: {Plugin.CurrentTrack.LoadedIntro.length}; played time: {time}"); - Debug.Log($"{nameof(MuzikaGromche)} Playing loop music: maxDistance: {__instance.creatureVoice.maxDistance}, minDistance: {__instance.creatureVoice.minDistance}, volume: {__instance.creatureVoice.volume}, spread: {__instance.creatureVoice.spread}, in seconds: {delay}"); + behaviour.ChooseTrackServerRpc(); + behaviour.BeatTimeState = null; } // Manage the timeline: switch color of the lights according to the current playback/beat position. - if ((__instance.previousState == 1 || __instance.previousState == 2) && behaviour.BeatTimeState is { } beatTimeState) + else if ((__instance.previousState == 1 || __instance.previousState == 2) && behaviour.BeatTimeState is { } beatTimeState) { - var events = beatTimeState.Update(intro: __instance.farAudio, loop: __instance.creatureVoice); + var events = beatTimeState.Update(introAudioSource, loopAudioSource); foreach (var ev in events) { switch (ev)