diff --git a/Patch/Engine/AudioPatches.cs b/Patch/Engine/AudioPatches.cs index 0483521..0a518fa 100644 --- a/Patch/Engine/AudioPatches.cs +++ b/Patch/Engine/AudioPatches.cs @@ -1,6 +1,7 @@ using System; using ADOFAIRenderer.Core; using ADOFAIRenderer.Util.Audio.Clip; +using ADOFAIRenderer.Util.Audio.Mixer; using HarmonyLib; using UnityEngine; using UnityEngine.Audio; @@ -55,9 +56,19 @@ public static class AudioManagerPlayPatch } } +/// +/// 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) { @@ -74,13 +85,51 @@ public static class PlayWithEndTimePatch double relativeEndTime = endTime - RenderManager.Instance.dspTimeBase; // Register looping sound event to the offline mixer (default pitch = 1.0f) - mixer.RegisterSoundEvent(null, snd, relativeStartTime, relativeEndTime, volume, 1.0f, RenderManager.Instance.AudioTime); + 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 { @@ -117,6 +166,32 @@ public static class AudioSourceSetScheduledEndTimePatch } } +/// +/// 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 { @@ -126,7 +201,32 @@ public static class AudioSourceStopPatch { if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio) { - RenderManager.Instance.AudioMixer?.RemoveSourceMapping(__instance); + 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); } } } \ No newline at end of file diff --git a/Util/Audio/Mixer/Voice.cs b/Util/Audio/Mixer/Voice.cs index a5206ce..123bf8b 100644 --- a/Util/Audio/Mixer/Voice.cs +++ b/Util/Audio/Mixer/Voice.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Threading; using ADOFAIRenderer.Util.Audio.Clip; namespace ADOFAIRenderer.Util.Audio.Mixer; @@ -64,6 +65,12 @@ internal sealed class Voice internal bool Loop; internal bool Finished; + /// + /// Playback has ended and the voice was released by the render thread. Readable from any thread; + /// the value can lag behind the render thread. + /// + public bool IsFinished => Volatile.Read(ref Finished); + /// Loop length in frames at the output sample rate (source duration * output rate). internal double Period; diff --git a/Util/Audio/OfflineAudioMixer.cs b/Util/Audio/OfflineAudioMixer.cs index 82fbf9a..0fa528b 100644 --- a/Util/Audio/OfflineAudioMixer.cs +++ b/Util/Audio/OfflineAudioMixer.cs @@ -32,6 +32,9 @@ internal sealed class OfflineAudioMixer : IDisposable /// Additional gain of effect sounds (registered with ). private const double EffectVolume = 2.0 / 3.0; + /// Number of tracked effect voices at which finished ones are removed from the list used by . + private const int EffectVoicePruneCount = 1024; + /// Longest wait for a commit before checking for cancellation again, in milliseconds. private const int CommitWaitMs = 50; @@ -45,6 +48,7 @@ internal sealed class OfflineAudioMixer : IDisposable // Main thread. private ConditionalWeakTable _sourceVoices = new(); + private readonly List _effectVoices = new(); private AudioClipSource? _bgmSource; private string _bgmKey = ""; private Voice? _bgmVoice; @@ -182,16 +186,26 @@ internal sealed class OfflineAudioMixer : IDisposable /// Starts an effect sound at . An of 0 or more makes it loop until then. /// (when given) is mapped to the voice for later changes through the AudioSource. /// - public void RegisterSoundEvent(AudioSource? source, string clipName, double startTime, double endTime, float volume, float pitch, double now) + public Voice RegisterSoundEvent(AudioSource? source, string clipName, double startTime, double endTime, float volume, float pitch, double now) { var clipSource = GetOrAddSource(clipName); var loop = endTime >= 0; var voice = _mixer.Play(clipSource, clipName, startTime, volume * ClipVolume * EffectVolume, pitch, loop, now); if (loop) voice.SetEndTime(endTime, now); - if (!source) return; - _sourceVoices.Remove(source!); - _sourceVoices.Add(source!, voice); + if (_effectVoices.Count >= EffectVoicePruneCount) + _effectVoices.RemoveAll(v => v.IsFinished); + _effectVoices.Add(voice); + + if (source) MapSource(source!, voice); + return voice; + } + + /// Maps to for later changes through the AudioSource (replaces an earlier mapping). + public void MapSource(AudioSource source, Voice voice) + { + _sourceVoices.Remove(source); + _sourceVoices.Add(source, voice); } /// Voice started for by . @@ -203,6 +217,19 @@ internal sealed class OfflineAudioMixer : IDisposable public bool RemoveSourceMapping(AudioSource source) => _sourceVoices.Remove(source); + /// + /// Stops every effect sound at , including ones scheduled to start later + /// (AudioManager.StopAllSounds destroys every AudioSource in liveSources, which holds the sources of + /// AudioManager._Play and scrConductor.PlayWithEndTime). The song voice keeps playing. + /// + public void StopAllEffects(double now) + { + foreach (var voice in _effectVoices) + voice.Stop(now); + _effectVoices.Clear(); + _sourceVoices = new ConditionalWeakTable(); + } + /// /// Marks every command with a time before as queued /// (called after each frame with the time of the next frame). @@ -312,5 +339,6 @@ internal sealed class OfflineAudioMixer : IDisposable filter.Reset(); } _sourceVoices = new ConditionalWeakTable(); + _effectVoices.Clear(); } }