using System; using ADOFAIRenderer.Core; using ADOFAIRenderer.Util.Audio; using ADOFAIRenderer.Util.Audio.Clip; 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 { /// State registered by the running PlayWithEndTime call, until its MakeSource returns. Main thread only. internal static SourceState? PendingState; [HarmonyFinalizer] public static void Finalizer() => PendingState = 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) PendingState = 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 state (). [HarmonyPatch(typeof(AudioManager), nameof(AudioManager.MakeSource))] public static class AudioManagerMakeSourcePatch { [HarmonyPostfix] // ReSharper disable once InconsistentNaming public static void Postfix(AudioSource __result) { var state = PlayWithEndTimePatch.PendingState; if (state == null || !__result) return; PlayWithEndTimePatch.PendingState = null; RenderManager.Instance.AudioMixer?.MapSource(__result, state); } } /// /// AudioSource members of an effect source mapped to a during rendering: the patches below skip the /// Unity implementation and read or change the state instead (isPlaying, time, timeSamples, pitch, SetScheduledEndTime, Stop). /// Sources without a mapping (the song, menu sounds) use the Unity implementation. /// internal static class EffectSource { /// State mapped to while capturing, with the current render time in . public static bool TryGet(AudioSource source, out SourceState state, out double now) { state = null!; now = 0; if (!RenderManager.IsCapturingActive) return false; var mixer = RenderManager.Instance.AudioMixer; if (mixer == null || !mixer.TryGetSourceState(source, out state)) return false; now = RenderManager.Instance.AudioTime; return true; } } /// /// AudioSource.isPlaying getter (): true from the scheduling call until Stop(), the scheduled end time or /// the end of the clip, including before the scheduled start. AudioManager.Update returns a source to its reusable pool once this is false. /// [HarmonyPatch(typeof(AudioSource), "isPlaying", MethodType.Getter)] public static class AudioSourceIsPlayingPatch { [HarmonyPrefix] // ReSharper disable once InconsistentNaming public static bool Prefix(AudioSource __instance, ref bool __result) { if (!EffectSource.TryGet(__instance, out var state, out var now)) return true; __result = state.IsPlaying(now); return false; } } /// AudioSource.time getter (): clip position in seconds while playing, 0 while not playing. [HarmonyPatch(typeof(AudioSource), "time", MethodType.Getter)] public static class AudioSourceTimeGetterPatch { [HarmonyPrefix] // ReSharper disable once InconsistentNaming public static bool Prefix(AudioSource __instance, ref float __result) { if (!EffectSource.TryGet(__instance, out var state, out var now)) return true; __result = state.GetTime(now); return false; } } /// /// AudioSource.time setter (): moves the playback position (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 { [HarmonyPrefix] // ReSharper disable once InconsistentNaming public static bool Prefix(AudioSource __instance, float value) { if (!EffectSource.TryGet(__instance, out var state, out var now)) return true; state.SetTime(value, now); return false; } } /// AudioSource.timeSamples getter (): clip position in samples of the clip. [HarmonyPatch(typeof(AudioSource), "timeSamples", MethodType.Getter)] public static class AudioSourceTimeSamplesGetterPatch { [HarmonyPrefix] // ReSharper disable once InconsistentNaming public static bool Prefix(AudioSource __instance, ref int __result) { if (!EffectSource.TryGet(__instance, out var state, out var now)) return true; __result = state.GetTimeSamples(now); return false; } } /// AudioSource.timeSamples setter (): moves the playback position (samples of the clip). [HarmonyPatch(typeof(AudioSource), "timeSamples", MethodType.Setter)] public static class AudioSourceTimeSamplesPatch { [HarmonyPrefix] // ReSharper disable once InconsistentNaming public static bool Prefix(AudioSource __instance, int value) { if (!EffectSource.TryGet(__instance, out var state, out var now)) return true; state.SetTimeSamples(value, now); return false; } } /// AudioSource.pitch getter (): the last pitch set. [HarmonyPatch(typeof(AudioSource), "pitch", MethodType.Getter)] public static class AudioSourcePitchGetterPatch { [HarmonyPrefix] // ReSharper disable once InconsistentNaming public static bool Prefix(AudioSource __instance, ref float __result) { if (!EffectSource.TryGet(__instance, out var state, out _)) return true; __result = state.GetPitch(); return false; } } /// AudioSource.pitch setter (): changes the playback rate from the current render time. [HarmonyPatch(typeof(AudioSource), "pitch", MethodType.Setter)] public static class AudioSourcePitchPatch { [HarmonyPrefix] // ReSharper disable once InconsistentNaming public static bool Prefix(AudioSource __instance, float value) { if (!EffectSource.TryGet(__instance, out var state, out var now)) return true; state.SetPitch(value, now); return false; } } /// AudioSource.SetScheduledEndTime (): DSP time (absolute) at which playback stops. [HarmonyPatch(typeof(AudioSource), "SetScheduledEndTime", typeof(double))] public static class AudioSourceSetScheduledEndTimePatch { [HarmonyPrefix] // ReSharper disable once InconsistentNaming public static bool Prefix(AudioSource __instance, double time) { if (!EffectSource.TryGet(__instance, out var state, out var now)) return true; state.SetEndTime(time - RenderManager.Instance.dspTimeBase, now); return false; } } /// /// AudioSource.Stop() (): stops playback at the current render time. The mapping stays, so the source /// keeps reporting the stopped state. AudioManager.Update calls Stop() on sources it returns to the pool, which are no longer /// playing, so their voices are left unchanged. /// [HarmonyPatch(typeof(AudioSource), "Stop", new Type[0])] public static class AudioSourceStopPatch { [HarmonyPrefix] // ReSharper disable once InconsistentNaming public static bool Prefix(AudioSource __instance) { if (!EffectSource.TryGet(__instance, out var state, out var now)) return true; state.Stop(now); return false; } } /// /// 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); } } }