diff --git a/CHANGELOG.md b/CHANGELOG.md index 93eef01..418ebc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Added a new track DiscoKapot. - Added an accessibility option to reduce the intensity of overly distracting visual effects. - Seasonal content like New Year's songs (IkWilJe, Paarden, DiscoKapot) will only be available for selection during their respective seasons. +- Reduced memory usage by almost 400 MB, thanks to loading audio clips on demand (not preloading all tracks at launch). ## MuzikaGromche 1337.9001.3 - v73 Happy New Year Edition diff --git a/MuzikaGromche/AudioClipsCache.cs b/MuzikaGromche/AudioClipsCache.cs new file mode 100644 index 0000000..af7cd28 --- /dev/null +++ b/MuzikaGromche/AudioClipsCache.cs @@ -0,0 +1,170 @@ +using HarmonyLib; +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using UnityEngine; +using UnityEngine.Networking; + +namespace MuzikaGromche; + +internal static class AudioClipsCacheManager +{ + // Cache of file names to loaded AudioClips. + // Cache is cleared at the end of each round. + static readonly Dictionary Cache = []; + + // In-flight requests + static readonly Dictionary> Setters)> Requests = []; + + // Not just isDone status, but also whether all requests have been processed. + public static bool AllDone => Requests.Count == 0; + + public static void LoadAudioTrack(IAudioTrack track) + { + GlobalBehaviour.Instance.StartCoroutine(LoadAudioTrackCoroutine(track)); + } + + static IEnumerator LoadAudioTrackCoroutine(IAudioTrack track) + { + List requests = []; + requests.Capacity = 2; + + LoadAudioClip(requests, track.AudioType, track.FileNameIntro, clip => track.LoadedIntro = clip); + LoadAudioClip(requests, track.AudioType, track.FileNameLoop, clip => track.LoadedLoop = clip); + + yield return new WaitUntil(() => requests.All(request => request.isDone)); + + if (requests.All(request => request.result == UnityWebRequest.Result.Success)) + { + foreach (var request in requests) + { + foreach (var (fileName, (Request, Setters)) in Requests) + { + if (request == Request) + { + Plugin.Log.LogDebug($"Audio clip loaded successfully: {fileName}"); + var clip = DownloadHandlerAudioClip.GetContent(request); + Cache[fileName] = clip; + foreach (var setter in Setters) + { + setter(clip); + } + } + } + } + } + else + { + var failed = Requests.Values.Where(tuple => tuple.Request.result != UnityWebRequest.Result.Success).Select(tuple => tuple.Request.GetUrl()); + Plugin.Log.LogError("Could not load audio file " + string.Join(", ", failed)); + } + + // cleanup + foreach (var request in requests) + { + // collect matching keys first to avoid mutating Requests while iterating it + var fileNames = Requests + .Where(kv => kv.Value.Request == request) + .Select(kv => kv.Key) + .ToArray(); + + foreach (var fileName in fileNames) + { + if (Requests.TryGetValue(fileName, out var tuple) && tuple.Request != null) + { + tuple.Request.Dispose(); + } + + Requests.Remove(fileName); + } + } + } + + static readonly string dir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + + static void LoadAudioClip(List requests, AudioType audioType, string fileName, Action setter) + { + if (Cache.TryGetValue(fileName, out var cachedClip)) + { + Plugin.Log.LogDebug($"Found cached audio clip: {fileName}"); + setter(cachedClip); + } + else if (Requests.TryGetValue(fileName, out var tuple)) + { + Plugin.Log.LogDebug($"Found existing in-flight request for audio clip: {fileName}"); + tuple.Setters.Add(setter); + } + else + { + Plugin.Log.LogDebug($"Sending request to load audio clip: {fileName}"); + var request = UnityWebRequestMultimedia.GetAudioClip($"file://{dir}/{fileName}", audioType); + request.SendWebRequest(); + Requests[fileName] = (request, [setter]); + requests.Add(request); + } + } + + public static void Clear() + { + // Iterate over LoadedClipsCache keys and values, cross join with Plugin.Tracks list, + // find AudioTracks that reference the key (file name), and null their corresponding loaded tracks; + // then destroy tracks and clear the cache. + + Plugin.Log.LogDebug($"Clearing {Cache.Count} cached audio clips and {Requests.Count} pending requests"); + + if (Cache.Count > 0) + { + var allTracks = Plugin.Tracks.SelectMany(t => t.GetTracks()).ToArray(); + + foreach (var (fileName, clip) in Cache) + { + foreach (var track in allTracks) + { + // Null out any references to this clip on matching file names. + if (track.FileNameIntro == fileName) + { + track.LoadedIntro = null; + } + if (track.FileNameLoop == fileName) + { + track.LoadedLoop = null; + } + } + + if (clip != null) + { + UnityEngine.Object.Destroy(clip); + } + } + + Cache.Clear(); + } + + foreach (var (fileName, (Request, Setters)) in Requests) + { + if (Request != null) + { + Request.Abort(); + Request.Dispose(); + } + } + + Requests.Clear(); + } +} + +[HarmonyPatch(typeof(RoundManager))] +static class ClearAudioClipCachePatch +{ + [HarmonyPatch(nameof(RoundManager.DespawnPropsAtEndOfRound))] + [HarmonyPatch(nameof(RoundManager.OnDestroy))] + [HarmonyPrefix] + static void OnDestroy(RoundManager __instance) + { + var _ = __instance; + AudioClipsCacheManager.Clear(); + } +} diff --git a/MuzikaGromche/GlobalBehaviour.cs b/MuzikaGromche/GlobalBehaviour.cs new file mode 100644 index 0000000..2389f75 --- /dev/null +++ b/MuzikaGromche/GlobalBehaviour.cs @@ -0,0 +1,30 @@ +using UnityEngine; + +namespace MuzikaGromche; + +// A global MonoBehaviour instance to run coroutines from non-MonoBehaviour or static context. +internal static class GlobalBehaviour +{ + sealed class AdhocBehaviour : MonoBehaviour; + + static AdhocBehaviour? instance = null; + + public static MonoBehaviour Instance + { + get + { + if (instance == null) + { + var go = new GameObject("MuzikaGromche_GlobalBehaviour", [ + typeof(AdhocBehaviour), + ]) + { + hideFlags = HideFlags.HideAndDontSave + }; + Object.DontDestroyOnLoad(go); + instance = go.GetComponent(); + } + return instance; + } + } +} diff --git a/MuzikaGromche/Plugin.cs b/MuzikaGromche/Plugin.cs index cb4b5e5..7cab543 100644 --- a/MuzikaGromche/Plugin.cs +++ b/MuzikaGromche/Plugin.cs @@ -8,7 +8,6 @@ using LethalConfig.ConfigItems.Options; using System; using System.Collections; using System.Collections.Generic; -using System.IO; using System.Linq; using System.Net.NetworkInformation; using System.Net.Sockets; @@ -17,7 +16,6 @@ using System.Security.Cryptography; using System.Text; using Unity.Netcode; using UnityEngine; -using UnityEngine.Networking; namespace MuzikaGromche { @@ -1023,75 +1021,47 @@ namespace MuzikaGromche // Sort in place by name Array.Sort(Tracks.Select(track => track.Name).ToArray(), Tracks); - string dir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); - Dictionary> Setters)> requests = []; - requests.EnsureCapacity(Tracks.Length * 2); - - foreach (var track in Tracks.SelectMany(track => track.GetTracks())) - { - foreach (var (fileName, setter) in new (string, Action)[] - { - (track.FileNameIntro, clip => track.LoadedIntro = clip), - (track.FileNameLoop, clip => track.LoadedLoop = clip), - }) - { - if (requests.TryGetValue(fileName, out var tuple)) - { - tuple.Setters.Add(setter); - } - else - { - var request = UnityWebRequestMultimedia.GetAudioClip($"file://{dir}/{fileName}", track.AudioType); - request.SendWebRequest(); - requests[fileName] = (request, [setter]); - } - } - } - - while (!requests.Values.All(tuple => tuple.Request.isDone)) { } - - if (requests.Values.All(tuple => tuple.Request.result == UnityWebRequest.Result.Success)) - { - - foreach (var (fileName, tuple) in requests) - { - var clip = DownloadHandlerAudioClip.GetContent(tuple.Request); - foreach (var setter in tuple.Setters) - { - setter(clip); - } - } #if DEBUG - foreach (var track in Tracks) - { - track.Debug(); - } - Exporter.ExportTracksJSON(Tracks); + GlobalBehaviour.Instance.StartCoroutine(PreloadDebugAndExport(Tracks)); #endif - Config = new Config(base.Config); - DiscoBallManager.Load(); - PoweredLightsAnimators.Load(); - Harmony = new Harmony(MyPluginInfo.PLUGIN_NAME); - Harmony.PatchAll(typeof(GameNetworkManagerPatch)); - Harmony.PatchAll(typeof(JesterPatch)); - Harmony.PatchAll(typeof(EnemyAIPatch)); - Harmony.PatchAll(typeof(PoweredLightsAnimatorsPatch)); - Harmony.PatchAll(typeof(AllPoweredLightsPatch)); - Harmony.PatchAll(typeof(DiscoBallTilePatch)); - Harmony.PatchAll(typeof(DiscoBallDespawnPatch)); - Harmony.PatchAll(typeof(SpawnRatePatch)); - Harmony.PatchAll(typeof(DeathScreenGameOverTextResetPatch)); - Harmony.PatchAll(typeof(ScreenFiltersManager.HUDManagerScreenFiltersPatch)); - NetcodePatcher(); - Compatibility.Register(this); - } - else - { - var failed = requests.Values.Where(tuple => tuple.Request.result != UnityWebRequest.Result.Success).Select(tuple => tuple.Request.GetUrl()); - Logger.LogError("Could not load audio file " + string.Join(", ", failed)); - } + Config = new Config(base.Config); + DiscoBallManager.Load(); + PoweredLightsAnimators.Load(); + Harmony = new Harmony(MyPluginInfo.PLUGIN_NAME); + Harmony.PatchAll(typeof(GameNetworkManagerPatch)); + Harmony.PatchAll(typeof(JesterPatch)); + Harmony.PatchAll(typeof(EnemyAIPatch)); + Harmony.PatchAll(typeof(PoweredLightsAnimatorsPatch)); + Harmony.PatchAll(typeof(AllPoweredLightsPatch)); + Harmony.PatchAll(typeof(DiscoBallTilePatch)); + Harmony.PatchAll(typeof(DiscoBallDespawnPatch)); + Harmony.PatchAll(typeof(SpawnRatePatch)); + Harmony.PatchAll(typeof(DeathScreenGameOverTextResetPatch)); + Harmony.PatchAll(typeof(ScreenFiltersManager.HUDManagerScreenFiltersPatch)); + Harmony.PatchAll(typeof(ClearAudioClipCachePatch)); + NetcodePatcher(); + Compatibility.Register(this); } +#if DEBUG + static IEnumerator PreloadDebugAndExport(ISelectableTrack[] tracks) + { + foreach (var track in tracks.SelectMany(track => track.GetTracks())) + { + AudioClipsCacheManager.LoadAudioTrack(track); + } + yield return new WaitUntil(() => AudioClipsCacheManager.AllDone); + Log.LogDebug("All tracks preloaded, exporting to JSON"); + + foreach (var track in tracks) + { + track.Debug(); + } + Exporter.ExportTracksJSON(tracks); + AudioClipsCacheManager.Clear(); + } +#endif + private static void NetcodePatcher() { var types = Assembly.GetExecutingAssembly().GetTypes(); @@ -3038,6 +3008,7 @@ namespace MuzikaGromche Plugin.Log.LogInfo($"SetTrackClientRpc {name}"); if (Plugin.FindTrackNamed(name) is { } track) { + AudioClipsCacheManager.LoadAudioTrack(track); CurrentTrack = Config.OverrideCurrentTrack(track); } } @@ -3117,7 +3088,18 @@ namespace MuzikaGromche if (behaviour.CurrentTrack == null || behaviour.CurrentTrack.LoadedIntro == null || behaviour.CurrentTrack.LoadedLoop == null) { #if DEBUG - Plugin.Log.LogError("CurrentTrack is not set!"); + if (behaviour.CurrentTrack == null) + { + Plugin.Log.LogError("CurrentTrack is not set!"); + } + else if (AudioClipsCacheManager.AllDone) + { + Plugin.Log.LogError("Failed to load audio clips, no in-flight requests running"); + } + else + { + Plugin.Log.LogDebug($"Waiting for audio clips to load"); + } #endif return; }