1
0
Fork 0

Load audio clips on demand, implement cache

Reduces cold-boot memory usage by 400 MB for the current playlist of
58 audio files (27.8 MB).
This commit is contained in:
ivan tkachenko 2026-01-11 16:06:45 +02:00
parent f83f2a72ba
commit a4cee92d00
4 changed files with 251 additions and 68 deletions

View File

@ -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

View File

@ -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<string, AudioClip> Cache = [];
// In-flight requests
static readonly Dictionary<string, (UnityWebRequest Request, List<Action<AudioClip>> 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<UnityWebRequest> 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<UnityWebRequest> requests, AudioType audioType, string fileName, Action<AudioClip> 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();
}
}

View File

@ -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<AdhocBehaviour>();
}
return instance;
}
}
}

View File

@ -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<string, (UnityWebRequest Request, List<Action<AudioClip>> Setters)> requests = [];
requests.EnsureCapacity(Tracks.Length * 2);
foreach (var track in Tracks.SelectMany(track => track.GetTracks()))
{
foreach (var (fileName, setter) in new (string, Action<AudioClip>)[]
{
(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;
}