using System; using System.Collections.Generic; 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 { /// Records in when its audioLib key is not loaded yet. Runs the original always. [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); } } /// /// AudioManager._Play (private; AudioManager.Play forwards to it): registers each scheduled sound effect (hit sounds, PlaySound /// events, hold sounds without an end time) as an effect voice of the offline mixer and maps the returned AudioSource to it. /// Active while capturing (RenderManager.IsCapturingActive) with RenderAudio on. The original always runs: it creates the /// AudioSource the game keeps using (its PlayScheduled call is blocked during rendering by another patch), and the game sets /// time/pitch/SetScheduledEndTime on it afterwards, which the patches forward to the mapped state. /// The game volume is passed through; the start pitch is 1 because _Play sets pitch = 1 before PlayScheduled. /// [HarmonyPatch(typeof(AudioManager), "_Play")] public static class AudioManagerPlayPatch { /// Opens the clip's source so its first chunk starts loading. Returns true (the original runs). [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 Unity thread RenderManager.Instance.AudioMixer?.CacheClip(snd); } return true; // Let the original method execute to prevent NullReferenceException } /// /// Registers the sound at (absolute DSP time, converted to render time) as a non-looping effect voice, /// maps to it and queues the source's game object for deactivation at the end of the frame. /// [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 var mixer = RenderManager.Instance.AudioMixer; if (mixer == null) return; mixer.RegisterSoundEvent(__result, snd, relativeStartTime, -1.0, volume, 1.0f, RenderManager.Instance.AudioTime); EffectSource.DeactivateAtFrameEnd(__result); } } } /// /// scrConductor.PlayWithEndTime: registers a looping effect voice until the end time. The AudioSource it creates with /// AudioManager.MakeSource is mapped to that voice (). /// Active while capturing (RenderManager.IsCapturingActive) with RenderAudio on; the original always runs. The game calls it for /// hold sounds with an end time above 0 (scrConductor hold sound scheduling); it sets loop = true, pitch = 1, PlayScheduled(time) and /// SetScheduledEndTime(endTime) on the new source. pitch and SetScheduledEndTime reach the mapped state through the /// patches; PlayScheduled is blocked during rendering by another patch. /// [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; /// Clears when PlayWithEndTime returns or throws, so it cannot map a later MakeSource call. [HarmonyFinalizer] public static void Finalizer() => PendingState = null; /// /// Opens the clip's source and registers a looping voice from to /// (absolute DSP times, converted to render time); the state waits in . Returns true (the original runs). /// [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 Unity 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 (). /// Active only while a PlayWithEndTime call registered a state (capturing with RenderAudio on); every other MakeSource call /// (including the one inside AudioManager._Play) is left unchanged. /// [HarmonyPatch(typeof(AudioManager), nameof(AudioManager.MakeSource))] public static class AudioManagerMakeSourcePatch { /// /// Takes the pending state, maps to it and queues the source's game object for deactivation at /// the end of the frame. /// [HarmonyPostfix] // ReSharper disable once InconsistentNaming public static void Postfix(AudioSource __result) { var state = PlayWithEndTimePatch.PendingState; if (state == null || !__result) return; PlayWithEndTimePatch.PendingState = null; var mixer = RenderManager.Instance.AudioMixer; if (mixer == null) return; mixer.MapSource(__result, state); EffectSource.DeactivateAtFrameEnd(__result); } } /// /// 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). /// The patches apply only while returns true: rendering is capturing (RenderManager.IsCapturingActive, /// stage Render), the render has an audio mixer (created when the render starts, cleared when it ends), and the source is mapped /// in that mixer (mappings are created only when RenderAudio is on). Otherwise they run the Unity implementation, so sources /// without a mapping (the song, menu sounds) and all sources outside rendering behave as in the game. /// /// The game object of a mapped source is deactivated at the end of the frame it was mapped in: the mixer produces its sound and the /// state answers every member above, so Unity does not process the source. The game activates it again when AudioManager.MakeSource /// reuses it. Active AudioSources add main-thread time per frame in proportion to their count, and dense levels keep thousands of /// scheduled effect sources alive. /// internal static class EffectSource { /// Sources mapped during the current frame, deactivated by . Main thread only. private static readonly List PendingDeactivation = new(); /// Deactivates the game object of at the end of the current frame (). public static void DeactivateAtFrameEnd(AudioSource source) => PendingDeactivation.Add(source); /// /// Deactivates the game objects of the sources mapped this frame that are still mapped and not destroyed. /// Called by RenderManager.OnPostFrame after the audio commands of the frame are committed, which runs only in stage Render with /// an audio mixer; sources are added only by the render-time registrations (AudioManager._Play and scrConductor.PlayWithEndTime /// patches with RenderAudio on). /// public static void DeactivateMappedSources(OfflineAudioMixer mixer) { foreach (var source in PendingDeactivation) { if (source && mixer.TryGetSourceState(source, out _)) source.gameObject.SetActive(false); } PendingDeactivation.Clear(); } /// /// State mapped to in the mixer of the current render, with the current render time in . /// False outside rendering (stage below Render), without a mixer, or for a source that is not mapped. /// 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 { /// When finds a mapped state, uses it and returns false (Unity is skipped); otherwise returns true. [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 { /// When finds a mapped state, uses it and returns false (Unity is skipped); otherwise returns true. [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 { /// When finds a mapped state, uses it and returns false (Unity is skipped); otherwise returns true. [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 while playing; while not playing, /// the last value set through time or timeSamples (0 after Stop()). /// [HarmonyPatch(typeof(AudioSource), "timeSamples", MethodType.Getter)] public static class AudioSourceTimeSamplesGetterPatch { /// When finds a mapped state, uses it and returns false (Unity is skipped); otherwise returns true. [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 { /// When finds a mapped state, uses it and returns false (Unity is skipped); otherwise returns true. [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 { /// When finds a mapped state, uses it and returns false (Unity is skipped); otherwise returns true. [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 { /// When finds a mapped state, uses it and returns false (Unity is skipped); otherwise returns true. [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 { /// When finds a mapped state, uses it and returns false (Unity is skipped); otherwise returns true. [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 { /// When finds a mapped state, uses it and returns false (Unity is skipped); otherwise returns true. [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, scrController.DebugUpdate, scnGame.Play and PauseMenu.Choose. /// Active while capturing (RenderManager.IsCapturingActive) with RenderAudio on; the original always runs (it destroys the sources). /// [HarmonyPatch(typeof(AudioManager), nameof(AudioManager.StopAllSounds))] public static class AudioManagerStopAllSoundsPatch { /// Stops every effect voice while capturing with RenderAudio on. The original always runs (no return value). [HarmonyPrefix] public static void Prefix() { if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio) { RenderManager.Instance.AudioMixer?.StopAllEffects(RenderManager.Instance.AudioTime); } } }