using System; using ADOFAIRenderer.Core; using ADOFAIRenderer.Util.Audio.Clip; using ADOFAIRenderer.Util.Audio.Mixer; using HarmonyLib; using UnityEngine; using UnityEngine.Audio; namespace ADOFAIRenderer.Patch.Engine; /// /// Records the file path of each external clip the game loads (always active, also outside rendering, /// because levels load their song and PlaySound files before a render starts). /// FindOrLoadAudioClipExternal returns the clip already in audioLib when its key exists, so the path is recorded /// only when the key is missing and the file is about to be loaded. /// [HarmonyPatch(typeof(AudioManager), nameof(AudioManager.FindOrLoadAudioClipExternal))] public static class AudioManagerFindOrLoadAudioClipExternalPatch { [HarmonyPrefix] // ReSharper disable once InconsistentNaming public static void Prefix(AudioManager __instance, string path) { if (string.IsNullOrEmpty(path)) return; if (__instance.audioLib.ContainsKey(AudioClipPaths.KeyOf(path))) return; AudioClipPaths.Record(path); } } [HarmonyPatch(typeof(AudioManager), "_Play")] public static class AudioManagerPlayPatch { [HarmonyPrefix] public static bool Prefix(string snd, double time, AudioMixerGroup group, float volume, int priority) { if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio) { // Cache the clip on the main thread RenderManager.Instance.AudioMixer?.CacheClip(snd); } return true; // Let the original method execute to prevent NullReferenceException } [HarmonyPostfix] // ReSharper disable once InconsistentNaming public static void Postfix(AudioSource __result, string snd, double time, AudioMixerGroup group, float volume, int priority) { if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio && __result != null) { // Convert absolute DSP time to relative time in seconds from render start double relativeStartTime = time - RenderManager.Instance.dspTimeBase; // Register event to the offline mixer (default pitch = 1.0f) and map it to the AudioSource RenderManager.Instance.AudioMixer?.RegisterSoundEvent(__result, snd, relativeStartTime, -1.0, volume, 1.0f, RenderManager.Instance.AudioTime); } } } /// /// scrConductor.PlayWithEndTime: registers a looping effect voice until the end time. The AudioSource it creates with /// AudioManager.MakeSource is mapped to that voice (). /// [HarmonyPatch(typeof(scrConductor), "PlayWithEndTime")] public static class PlayWithEndTimePatch { /// Voice registered by the running PlayWithEndTime call, until its MakeSource returns. Main thread only. internal static Voice? PendingVoice; [HarmonyFinalizer] public static void Finalizer() => PendingVoice = null; [HarmonyPrefix] public static bool Prefix(string snd, double time, double endTime, float volume, int priority) { if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio) { var mixer = RenderManager.Instance.AudioMixer; if (mixer != null) { // Cache the clip on the main thread mixer.CacheClip(snd); // Convert absolute DSP times to relative times in seconds from render start double relativeStartTime = time - RenderManager.Instance.dspTimeBase; double relativeEndTime = endTime - RenderManager.Instance.dspTimeBase; // Register looping sound event to the offline mixer (default pitch = 1.0f) PendingVoice = mixer.RegisterSoundEvent(null, snd, relativeStartTime, relativeEndTime, volume, 1.0f, RenderManager.Instance.AudioTime); } } return true; // Let the original method execute } } /// Maps the AudioSource created inside scrConductor.PlayWithEndTime to its voice (). [HarmonyPatch(typeof(AudioManager), nameof(AudioManager.MakeSource))] public static class AudioManagerMakeSourcePatch { [HarmonyPostfix] // ReSharper disable once InconsistentNaming public static void Postfix(AudioSource __result) { var voice = PlayWithEndTimePatch.PendingVoice; if (voice == null || !__result) return; PlayWithEndTimePatch.PendingVoice = null; RenderManager.Instance.AudioMixer?.MapSource(__result, voice); } } /// /// AudioSource.isPlaying getter: a source mapped to a voice reports true until the voice has finished, including before its /// scheduled start, as a source started with PlayScheduled does. During rendering PlayScheduled is blocked, so without this /// the game sees every effect source as stopped and AudioManager.Update returns it to the reusable pool on the next frame. /// Voice completion is set by the render thread, which mixes behind the main thread, so the value turns false at or after /// the render time the voice ends. /// [HarmonyPatch(typeof(AudioSource), "isPlaying", MethodType.Getter)] public static class AudioSourceIsPlayingPatch { [HarmonyPostfix] // ReSharper disable once InconsistentNaming public static void Postfix(AudioSource __instance, ref bool __result) { if (__result || !RenderManager.IsCapturingActive) return; var mixer = RenderManager.Instance.AudioMixer; if (mixer != null && mixer.TryGetVoice(__instance, out var voice)) { __result = !voice.IsFinished; } } } [HarmonyPatch(typeof(AudioSource), "pitch", MethodType.Setter)] public static class AudioSourcePitchPatch { [HarmonyPostfix] // ReSharper disable once InconsistentNaming public static void Postfix(AudioSource __instance, float value) { if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio) { var mixer = RenderManager.Instance.AudioMixer; if (mixer != null && mixer.TryGetVoice(__instance, out var voice)) { voice.SetPitch(value, RenderManager.Instance.AudioTime); } } } } [HarmonyPatch(typeof(AudioSource), "SetScheduledEndTime", typeof(double))] public static class AudioSourceSetScheduledEndTimePatch { [HarmonyPostfix] // ReSharper disable once InconsistentNaming public static void Postfix(AudioSource __instance, double time) { if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio) { var mixer = RenderManager.Instance.AudioMixer; if (mixer != null && mixer.TryGetVoice(__instance, out var voice)) { voice.SetEndTime(time - RenderManager.Instance.dspTimeBase, RenderManager.Instance.AudioTime); } } } } /// /// AudioSource.time setter: moves the playback position of the mapped voice (clip seconds) from the current render time. /// Set before the scheduled start (ffxPlaySound sets it right after AudioManager.Play), it becomes the start position. /// [HarmonyPatch(typeof(AudioSource), "time", MethodType.Setter)] public static class AudioSourceTimePatch { [HarmonyPostfix] // ReSharper disable once InconsistentNaming public static void Postfix(AudioSource __instance, float value) { if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio) { var mixer = RenderManager.Instance.AudioMixer; if (mixer != null && mixer.TryGetVoice(__instance, out var voice)) { voice.SetPosition(value, RenderManager.Instance.AudioTime); } } } } /// /// AudioSource.Stop(): stops the mapped voice at the current render time and removes the mapping. /// AudioManager.Update calls Stop() on sources it recycles, which are finished (). /// [HarmonyPatch(typeof(AudioSource), "Stop", new Type[0])] public static class AudioSourceStopPatch { [HarmonyPostfix] // ReSharper disable once InconsistentNaming public static void Postfix(AudioSource __instance) { if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio) { var mixer = RenderManager.Instance.AudioMixer; if (mixer == null) return; if (mixer.TryGetVoice(__instance, out var voice)) { voice.Stop(RenderManager.Instance.AudioTime); } mixer.RemoveSourceMapping(__instance); } } } /// /// AudioManager.StopAllSounds destroys every source in liveSources (all AudioManager._Play and scrConductor.PlayWithEndTime sources), /// so every effect voice stops at the current render time, including ones scheduled to start later. /// Callers include scrConductor.PlayHitTimes (from its second call), ScrubMusicToTile/Time, KillAllSoundsCoroutine, /// scrController.FailAction and scnGame.Play. /// [HarmonyPatch(typeof(AudioManager), nameof(AudioManager.StopAllSounds))] public static class AudioManagerStopAllSoundsPatch { [HarmonyPrefix] public static void Prefix() { if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio) { RenderManager.Instance.AudioMixer?.StopAllEffects(RenderManager.Instance.AudioTime); } } }