mirror of
https://github.com/TheJordanDev/PEAK-JordanMod.git
synced 2026-10-16 23:56:44 +02:00
0.1.14 | Audio fixes & optimisations
This commit is contained in:
18
CHANGELOG.md
18
CHANGELOG.md
@@ -76,3 +76,21 @@
|
||||
- The "Open Backpack" keybind (default: B) no longer opens the wheel while the backpack is held in your hands. The wheel pulls items out of the backpack's on-back visuals, which the game deactivates while the backpack is in hand, so anything taken out was spawned into a disabled rig and disappeared. Hold-interacting the held backpack still opens it the normal way.
|
||||
- Hardened the Bags for Everyone patch. Its master-client and spawner-name checks now run in the postfix itself instead of inside the method that spawns the bags, and the spawn is wrapped in a try/catch. Because Mono resolves a method's call targets when it JITs that method, a changed game API used to throw before any of those checks could run, taking down every `SingleItemSpawner` in the level — which is what stopped campfires from advancing the biome in 0.1.12. A failure now costs the extra bags and nothing else.
|
||||
- The extra backpacks are registered with the spawner's `SpawnedItemTracker`, so a quicksave records them instead of restoring only the vanilla one, and the patch no longer adds a fresh batch on top every time a run is loaded from a save.
|
||||
|
||||
# v0.1.14 | Audio fixes & optimisations
|
||||
|
||||
- Songs are now kept compressed in memory and decoded during playback instead of being held as raw PCM for the whole session. A 127-file, 110-minute bank measured ~82 MB on disk but around 0.9-1.8 GB once decompressed; it now costs roughly its size on disk, for a little CPU while a bugle is actually sounding.
|
||||
- Fixed the audio bank sync never removing anything. The removal step ran on the sync's background thread and called `FindObjectsByType`/`Object.Destroy`, which are main-thread only, so it threw on the first song it tried to remove and aborted the whole sync — downloads included. Songs dropped from the bank had simply been reloading from disk on every launch ever since.
|
||||
- Syncing now makes the folder match the bank exactly: anything the bank doesn't list is pulled out, including files added by hand and stale copies of a song left behind under a different extension. Nothing is deleted — files are moved to a `_removed` subfolder of the bank directory, which the loader ignores. BingBong voice lines are exempt and never touched.
|
||||
- A sync that can't reach the bank, or gets an empty list back, now cancels instead of treating "no entries" as "nothing belongs here" and clearing out the whole folder.
|
||||
- Fixed the game freezing the first time a sound is played in a session. `DownloadHandlerAudioClip` hands back a `DecompressOnLoad` clip whose PCM data is only decoded on first use, so that decode was landing inside the first `AudioSource.Play()` on the main thread. The clips are now decoded during loading instead, where the cost is expected and spread across frames.
|
||||
- Fixed an empty sounds folder permanently breaking the audio system. With no songs loaded, picking a starting song indexed an empty list and threw, and because that ran from the load-complete callback it left the loading flag stuck on, silently killing every later sync and refresh for the rest of the session. The song index is also clamped now, since a refresh that removes songs could leave it past the end of the list.
|
||||
- Fixed a Bugle picked up off the ground being completely silent. The patch that installs the custom sound only ran for items already in hand, but `ItemState.Ground` is the default and `Item.Start` doesn't set state, so anything spawned into the world never got it — while the vanilla Bugle sound was being muted regardless. The vanilla sound is now only muted where the replacement actually took over.
|
||||
- Fixed BingBong subtitles never resolving: the localisation key was registered in lowercase, but `LocalizedText.GetText` upper-cases ids before looking them up. The entry also had a single string where one per language is required, which threw for every non-English player.
|
||||
- Fixed `SFX_Instance` being created with `new`. It derives from `ScriptableObject`, which has to go through `ScriptableObject.CreateInstance` or it skips Unity's native-side initialisation.
|
||||
- BingBong responses are now built once and shared instead of being rebuilt every time an item is enabled, which was allocating a `ScriptableObject` per response each time and never releasing the previous ones.
|
||||
- Refreshing songs no longer scans the scene for audio sources once per song — a bank of N songs meant N full scene scans — and no longer forces a blocking garbage collection.
|
||||
- File hashing is computed on demand on the sync thread rather than for every clip while loading, where it was reading each file end to end and running SHA256 on the main thread.
|
||||
- The audio bank sync no longer drives `UnityWebRequest` from a background thread, which is main-thread only, and no longer busy-waits on it while blocking a second thread-pool thread.
|
||||
- The song display no longer scans every loaded object looking for its font on every frame. It falls back to the default font immediately and retries for the nicer one on an interval, stopping once found.
|
||||
- The audio bank URL is normalised before use: a missing scheme is filled in (http for localhost, https otherwise) and a trailing slash trimmed. Without a scheme every request failed outright with "Invalid BugleSoundAPIURL in config", since the URL could not be parsed.
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<!-- This is the display name of your mod. Example: BepInEx Template -->
|
||||
<AssemblyTitle>JordanMod</AssemblyTitle>
|
||||
<!-- This is the version number of your mod. -->
|
||||
<Version>0.1.13</Version>
|
||||
<Version>0.1.14</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
@@ -113,8 +113,10 @@ class BetterBugleModule : Module
|
||||
List<string> scrollActionLocalizations = new(LocalizedText.LANGUAGE_COUNT);
|
||||
for (int i = 0; i < LocalizedText.LANGUAGE_COUNT; i++) scrollActionLocalizations.Add("Change Song");
|
||||
|
||||
LocalizedText.mainTable.Add("SONG_LIST", secondaryActionLocalizations);
|
||||
LocalizedText.mainTable.Add("CHANGE_SONG", scrollActionLocalizations);
|
||||
// Indexer, not Add: Add throws if the key is already present, which happens if the game
|
||||
// rebuilds its table (e.g. on a language change) and this runs again.
|
||||
LocalizedText.mainTable["SONG_LIST"] = secondaryActionLocalizations;
|
||||
LocalizedText.mainTable["CHANGE_SONG"] = scrollActionLocalizations;
|
||||
}
|
||||
|
||||
private void OnAllAudioClipsLoaded()
|
||||
@@ -128,8 +130,22 @@ class BetterBugleModule : Module
|
||||
if (Song.Songs.ContainsKey(songKeyName) && !Song.FavoriteSongs.Contains(songKeyName))
|
||||
Song.FavoriteSongs.Add(songKeyName);
|
||||
|
||||
if (!Song.Songs.ContainsKey(AudioSyncWorker.CurrentSongName))
|
||||
AudioSyncWorker.CurrentSongName = Song.GetSongNames_Alphabetically()[AudioSyncWorker.CurrentSongIndex];
|
||||
// Indexing this unguarded threw ArgumentOutOfRangeException with an empty bank (a fresh
|
||||
// install, or a sounds folder that lost its files), and because this runs from
|
||||
// OnAudioLoadComplete it used to leave IsLoading stuck true and kill sync/refresh for
|
||||
// the rest of the session. CurrentSongIndex can also outrun the list after a refresh
|
||||
// removes songs, so clamp rather than assume it is still in range.
|
||||
List<string> songNames = Song.GetSongNames_Alphabetically();
|
||||
if (songNames.Count == 0)
|
||||
{
|
||||
AudioSyncWorker.CurrentSongIndex = 0;
|
||||
AudioSyncWorker.CurrentSongName = "None";
|
||||
}
|
||||
else if (!Song.Songs.ContainsKey(AudioSyncWorker.CurrentSongName))
|
||||
{
|
||||
AudioSyncWorker.CurrentSongIndex = Mathf.Clamp(AudioSyncWorker.CurrentSongIndex, 0, songNames.Count - 1);
|
||||
AudioSyncWorker.CurrentSongName = songNames[AudioSyncWorker.CurrentSongIndex];
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -265,6 +281,10 @@ public class BetterBugleUI : MonoBehaviour
|
||||
|
||||
private bool fontLoaded = false;
|
||||
|
||||
private const float FontSearchInterval = 5f;
|
||||
|
||||
private float lastFontSearchTime = -999f;
|
||||
|
||||
private int offsetX = 0;
|
||||
|
||||
private int offsetY = 70;
|
||||
@@ -294,28 +314,41 @@ public class BetterBugleUI : MonoBehaviour
|
||||
|
||||
private void OnGUI()
|
||||
{
|
||||
if (!fontLoaded)
|
||||
{
|
||||
Font[] array = Resources.FindObjectsOfTypeAll<Font>();
|
||||
foreach (Font val in array)
|
||||
{
|
||||
if (val.name == "Tetsubin Gothic")
|
||||
{
|
||||
customStyle = new GUIStyle(GUI.skin.label);
|
||||
customStyle.font = val;
|
||||
customStyle.fontSize = fontSize;
|
||||
customStyle.alignment = TextAnchor.LowerCenter;
|
||||
customStyle.normal.textColor = Color.white;
|
||||
fontLoaded = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
EnsureStyle();
|
||||
if (customStyle == null) return;
|
||||
RenderSoundDisplay();
|
||||
RenderPlayingDisplay();
|
||||
}
|
||||
|
||||
// This used to run Resources.FindObjectsOfTypeAll<Font>() on every OnGUI call until the font
|
||||
// turned up -- a full scan of every loaded object, several times per frame, forever if the
|
||||
// font was never present in the scene. Now we build a usable style immediately with the
|
||||
// default font and only re-scan for the nicer one on an interval, stopping once found.
|
||||
private void EnsureStyle()
|
||||
{
|
||||
if (fontLoaded) return;
|
||||
if (customStyle != null && Time.unscaledTime - lastFontSearchTime < FontSearchInterval) return;
|
||||
lastFontSearchTime = Time.unscaledTime;
|
||||
|
||||
Font? found = null;
|
||||
foreach (Font val in Resources.FindObjectsOfTypeAll<Font>())
|
||||
{
|
||||
if (val.name == "Tetsubin Gothic") { found = val; break; }
|
||||
}
|
||||
|
||||
customStyle = new GUIStyle(GUI.skin.label)
|
||||
{
|
||||
fontSize = fontSize,
|
||||
alignment = TextAnchor.LowerCenter,
|
||||
normal = { textColor = Color.white }
|
||||
};
|
||||
if (found != null)
|
||||
{
|
||||
customStyle.font = found;
|
||||
fontLoaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void RenderSoundDisplay()
|
||||
{
|
||||
if (customStyle == null) return;
|
||||
|
||||
@@ -12,6 +12,8 @@ class ReplaceBingBongModule : Module
|
||||
{
|
||||
public override string ModuleName => "Replace BingBong Module";
|
||||
|
||||
public const string BingBongSubtitleID = "IDK_FUNNY";
|
||||
|
||||
public static bool HasReplacedSounds = false;
|
||||
public static BingBongResponseData[] OriginalResponsesData = [];
|
||||
|
||||
@@ -23,20 +25,64 @@ class ReplaceBingBongModule : Module
|
||||
public override void Initialize()
|
||||
{
|
||||
base.Initialize();
|
||||
LocalizedText.mainTable.Add("idk_funny", ["Test subtitle!"]);
|
||||
// LocalizedText.GetText upper-cases the id before looking it up, so a lowercase key never
|
||||
// resolved at all. It also indexes the list by CURRENT_LANGUAGE, so a single-entry list
|
||||
// threw IndexOutOfRange (caught and logged by GetText) for every non-English player.
|
||||
List<string> subtitleLocalizations = new(LocalizedText.LANGUAGE_COUNT);
|
||||
for (int i = 0; i < LocalizedText.LANGUAGE_COUNT; i++) subtitleLocalizations.Add("Test subtitle!");
|
||||
LocalizedText.mainTable[BingBongSubtitleID] = subtitleLocalizations;
|
||||
AudioSyncWorker.OnAudioLoadComplete += OnAudioLoadComplete;
|
||||
}
|
||||
|
||||
private static void OnAudioLoadComplete()
|
||||
{
|
||||
if (!HasReplacedSounds) return;
|
||||
Action_AskBingBong[] allBingBongActions = UnityEngine.Object.FindObjectsByType<Action_AskBingBong>(FindObjectsSortMode.None);
|
||||
foreach (Action_AskBingBong askBingBong in allBingBongActions) {
|
||||
ReplaceBingBongResponses(askBingBong);
|
||||
RebuildResponsesForAll();
|
||||
}
|
||||
|
||||
// Build the new set and hand it to every live instance before destroying the old one, so no
|
||||
// Action_AskBingBong is ever left pointing at a destroyed SFX_Instance.
|
||||
private static void RebuildResponsesForAll()
|
||||
{
|
||||
Action_AskBingBong.BingBongResponse[]? previous = _cachedResponses;
|
||||
_cachedResponses = BuildResponses();
|
||||
|
||||
foreach (Action_AskBingBong askBingBong in UnityEngine.Object.FindObjectsByType<Action_AskBingBong>(FindObjectsSortMode.None))
|
||||
{
|
||||
askBingBong.responses = _cachedResponses;
|
||||
}
|
||||
|
||||
if (previous == null) return;
|
||||
foreach (Action_AskBingBong.BingBongResponse response in previous)
|
||||
{
|
||||
if (response.sfx != null) UnityEngine.Object.Destroy(response.sfx);
|
||||
}
|
||||
}
|
||||
|
||||
// SFX_Instance derives from ScriptableObject, which cannot be constructed with `new` --
|
||||
// doing so skips Unity's native-side init, so the object misbehaves and its overloaded
|
||||
// == null check reports oddly. CreateInstance is the only correct way to make one.
|
||||
public static SFX_Instance CreateSFX(string name, AudioClip[] clips)
|
||||
{
|
||||
SFX_Instance instance = ScriptableObject.CreateInstance<SFX_Instance>();
|
||||
instance.name = name;
|
||||
instance.clips = clips;
|
||||
return instance;
|
||||
}
|
||||
|
||||
// Every Action_AskBingBong ends up with the same set -- it is derived purely from static
|
||||
// state (OriginalResponsesData plus the loaded voice lines). This used to be rebuilt on every
|
||||
// OnEnable, allocating a fresh SFX_Instance per response each time and never destroying the
|
||||
// previous ones, so BingBong items cycling in and out of scope leaked steadily.
|
||||
public static void ReplaceBingBongResponses(Action_AskBingBong askBingBong)
|
||||
{
|
||||
_cachedResponses ??= BuildResponses();
|
||||
askBingBong.responses = _cachedResponses;
|
||||
}
|
||||
|
||||
private static Action_AskBingBong.BingBongResponse[]? _cachedResponses;
|
||||
|
||||
private static Action_AskBingBong.BingBongResponse[] BuildResponses()
|
||||
{
|
||||
Action_AskBingBong.BingBongResponse[] currentResponses = new Action_AskBingBong.BingBongResponse[OriginalResponsesData.Length];
|
||||
for (int index = 0; index < OriginalResponsesData.Length; index++)
|
||||
@@ -44,8 +90,6 @@ class ReplaceBingBongModule : Module
|
||||
currentResponses[index] = OriginalResponsesData[index].ToBingBongResponse();
|
||||
}
|
||||
|
||||
askBingBong.responses = [];
|
||||
|
||||
Dictionary<string, SFX_Instance> sfxDict = new();
|
||||
for (int i = 0; i < currentResponses.Length; i++)
|
||||
{
|
||||
@@ -68,14 +112,10 @@ class ReplaceBingBongModule : Module
|
||||
bool isNew = !sfxDict.ContainsKey(voice.Name);
|
||||
if (isNew)
|
||||
{
|
||||
SFX_Instance sFX_Instance = new()
|
||||
{
|
||||
clips = [clip]
|
||||
};
|
||||
Action_AskBingBong.BingBongResponse newResponse = new()
|
||||
{
|
||||
sfx = sFX_Instance,
|
||||
subtitleID = "idk_funny",
|
||||
sfx = CreateSFX(voice.Name, [clip]),
|
||||
subtitleID = BingBongSubtitleID,
|
||||
mouthCurve = null,
|
||||
mouthCurveTime = 1f
|
||||
};
|
||||
@@ -87,11 +127,7 @@ class ReplaceBingBongModule : Module
|
||||
}
|
||||
}
|
||||
|
||||
askBingBong.responses = new Action_AskBingBong.BingBongResponse[currentResponses.Length];
|
||||
for (int i = 0; i < currentResponses.Length; i++)
|
||||
{
|
||||
askBingBong.responses[i] = currentResponses[i];
|
||||
}
|
||||
return currentResponses;
|
||||
}
|
||||
|
||||
|
||||
@@ -111,11 +147,7 @@ public class BingBongResponseData
|
||||
{
|
||||
return new Action_AskBingBong.BingBongResponse
|
||||
{
|
||||
sfx = new SFX_Instance
|
||||
{
|
||||
name = SfxName,
|
||||
clips = (AudioClip[])Clips.Clone()
|
||||
},
|
||||
sfx = ReplaceBingBongModule.CreateSFX(SfxName, (AudioClip[])Clips.Clone()),
|
||||
subtitleID = SubtitleID,
|
||||
mouthCurve = MouthCurve,
|
||||
mouthCurveTime = MouthCurveTime
|
||||
|
||||
@@ -10,16 +10,23 @@ namespace JordanMod.Modules.BetterBugle;
|
||||
public class BetterBuglePatch
|
||||
{
|
||||
|
||||
private static readonly List<string> SupportedItemNames = ["Bugle", "Bugle_Magic", "Megaphone"];
|
||||
|
||||
[HarmonyPatch(typeof(Item), "Start")]
|
||||
[HarmonyPostfix]
|
||||
static void ItemStartPostfix(Item __instance)
|
||||
{
|
||||
if (__instance.itemState != ItemState.Held) return;
|
||||
if (__instance.UIData == null) return;
|
||||
|
||||
List<string> supportedItemNames = ["Bugle", "Bugle_Magic", "Megaphone"];
|
||||
if (!SupportedItemNames.Contains(__instance.UIData.itemName)) return;
|
||||
|
||||
if (!supportedItemNames.Contains(__instance.UIData.itemName)) return;
|
||||
// This used to bail unless itemState was already Held. ItemState.Ground is the enum's
|
||||
// zero value and Item.Start doesn't set state, so anything spawned into the world never
|
||||
// got a BetterBugleSFX -- and since BugleSFXUpdatePostfix silences the vanilla BugleSFX,
|
||||
// a Bugle picked up off the ground was completely silent. It only ever worked because
|
||||
// SpawnItemInHand calls Interact() on the same frame it instantiates, landing
|
||||
// SetState(Held) before Start runs.
|
||||
if (__instance.TryGetComponent<BetterBugleSFX>(out _)) return;
|
||||
{
|
||||
Action secondaryAction = OnRightClick;
|
||||
Action<float> scrollAction = OnScroll;
|
||||
@@ -120,6 +127,9 @@ public class BetterBuglePatch
|
||||
[HarmonyPostfix]
|
||||
static void BugleSFXUpdatePostfix(BugleSFX __instance)
|
||||
{
|
||||
// Only silence the vanilla bugle where our replacement actually took over. Muting
|
||||
// unconditionally meant any bugle we failed to attach to played nothing at all.
|
||||
if (!__instance.TryGetComponent<BetterBugleSFX>(out _)) return;
|
||||
if (__instance.volume > 0f) __instance.volume = 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Threading.Tasks;
|
||||
using JordanMod.Modules.BetterBugle;
|
||||
@@ -14,18 +15,33 @@ namespace JordanMod.Utils;
|
||||
|
||||
class AudioSyncService
|
||||
{
|
||||
public static string API_BASE_URL => ConfigHandler.BugleSoundAPIURL.Value;
|
||||
// Normalised once here rather than at each call site: the configured value is hand-typed, so
|
||||
// it may be missing a scheme or carry a trailing slash, either of which broke the Uri parse.
|
||||
public static string API_BASE_URL => Helper.EnsureScheme(ConfigHandler.BugleSoundAPIURL.Value, "https", "http").TrimEnd('/');
|
||||
|
||||
public async static Task<bool> DownloadAPIAudio(APIAudioFormat apiAudio, string SoundsDirectory, Song? existingSong = null)
|
||||
public static bool DownloadAPIAudio(APIAudioFormat apiAudio, string SoundsDirectory, Song? existingSong = null)
|
||||
{
|
||||
bool success = true;
|
||||
try
|
||||
{
|
||||
// Same audio under a different name in the bank: retire the local copy.
|
||||
if (existingSong != null && apiAudio.Filename != existingSong.Name)
|
||||
{
|
||||
File.Delete(Path.Combine(SoundsDirectory, $"{existingSong.Name}.{existingSong.Extension}"));
|
||||
string renamedFrom = Path.Combine(SoundsDirectory, $"{existingSong.Name}.{existingSong.Extension}");
|
||||
if (File.Exists(renamedFrom)) AudioSyncWorker.TryQuarantine(renamedFrom);
|
||||
}
|
||||
await apiAudio.DownloadToFolder(SoundsDirectory);
|
||||
|
||||
// Same name under a different extension -- a leftover .mp3 when the bank now serves
|
||||
// .ogg, say. Both would be queued by the loader and fight over the same song name, so
|
||||
// retire every variant that isn't the one we're about to write.
|
||||
foreach (string ext in AudioSyncWorker.AudioTypes.Keys)
|
||||
{
|
||||
if (string.Equals(ext, apiAudio.Extension, StringComparison.OrdinalIgnoreCase)) continue;
|
||||
string stale = Path.Combine(SoundsDirectory, $"{apiAudio.Filename}.{ext}");
|
||||
if (File.Exists(stale)) AudioSyncWorker.TryQuarantine(stale);
|
||||
}
|
||||
|
||||
apiAudio.DownloadToFolder(SoundsDirectory);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -46,7 +62,7 @@ class AudioSyncService
|
||||
return audioClips;
|
||||
}
|
||||
|
||||
using var client = new System.Net.WebClient();
|
||||
using var client = new WebClient();
|
||||
try
|
||||
{
|
||||
string json = client.DownloadString(uri);
|
||||
@@ -63,15 +79,19 @@ class AudioSyncService
|
||||
|
||||
public static void ClearAudioClips()
|
||||
{
|
||||
// One scene scan shared by every song. Song.Dispose() used to run its own
|
||||
// FindObjectsByType<AudioSource>() call, so clearing a bank of N songs meant N full
|
||||
// scans of the scene -- the bulk of the freeze on the Bugle's right-click refresh.
|
||||
AudioSource[] audioSources = UnityEngine.Object.FindObjectsByType<AudioSource>(FindObjectsSortMode.None);
|
||||
foreach (Song song in Song.Sounds.Values.ToList())
|
||||
{
|
||||
song.Dispose();
|
||||
song.Dispose(audioSources);
|
||||
}
|
||||
Song.Sounds.Clear();
|
||||
Song.SoundsByHash.Clear();
|
||||
Song.Songs.Clear();
|
||||
Song.BB_VoiceLines.Clear();
|
||||
GC.Collect();
|
||||
// No GC.Collect() here on purpose: the audio memory is native and is already released by
|
||||
// Object.Destroy on each clip, so a blocking full collection only added a stall.
|
||||
}
|
||||
|
||||
public class APIAudioFormat
|
||||
@@ -100,7 +120,10 @@ class AudioSyncService
|
||||
[JsonProperty("owner")]
|
||||
public string Owner { get; set; } = string.Empty;
|
||||
|
||||
public async Task DownloadToFolder(string folderPath)
|
||||
// Plain WebClient rather than UnityWebRequest: this runs on a background thread, and
|
||||
// UnityWebRequest is main-thread only. The old version also spun on `await Task.Yield()`
|
||||
// waiting for an operation that a pool thread never drives, burning a core.
|
||||
public void DownloadToFolder(string folderPath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(Filename) || string.IsNullOrEmpty(Extension))
|
||||
{
|
||||
@@ -117,15 +140,9 @@ class AudioSyncService
|
||||
string url = $"{API_BASE_URL}/audio/{Id}/download?hash={Hash}";
|
||||
Debug.Log($"Downloading audio from URL: {url}");
|
||||
|
||||
using UnityWebRequest www = UnityWebRequest.Get(url);
|
||||
var operation = www.SendWebRequest();
|
||||
while (!operation.isDone) await Task.Yield();
|
||||
if (www.result != UnityWebRequest.Result.Success)
|
||||
{
|
||||
Debug.LogError($"Failed to download API audio: {www.error}");
|
||||
return;
|
||||
}
|
||||
File.WriteAllBytes(filePath, www.downloadHandler.data);
|
||||
using var client = new WebClient();
|
||||
byte[] data = client.DownloadData(url);
|
||||
File.WriteAllBytes(filePath, data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,6 +159,11 @@ class AudioSyncWorker
|
||||
}
|
||||
|
||||
public static readonly string SoundsDirectory = Path.Combine(BepInEx.Paths.BepInExRootPath, "bugleSounds");
|
||||
// Files the sync pulls out of the bank folder are moved here rather than deleted. It's a
|
||||
// subfolder of the bank itself, which is safe because the loader's Directory.GetFiles calls
|
||||
// are non-recursive and will never see it.
|
||||
public static readonly string QuarantineDirectory = Path.Combine(SoundsDirectory, "_removed");
|
||||
|
||||
public static readonly Dictionary<string, AudioType> AudioTypes = new()
|
||||
{
|
||||
{ "wav", AudioType.WAV },
|
||||
@@ -153,6 +175,11 @@ class AudioSyncWorker
|
||||
public static bool IsLoading = false;
|
||||
public static bool IsSyncing = false;
|
||||
|
||||
// How many clips arrived from the download handler without their audio data decoded. Any
|
||||
// number above zero means the first play of each would otherwise have stalled the main
|
||||
// thread; reported once per load so the log can confirm it.
|
||||
public static int ClipsNeedingPreload = 0;
|
||||
|
||||
public static int CurrentSongIndex = 0;
|
||||
public static string CurrentSongName = "None";
|
||||
|
||||
@@ -169,14 +196,66 @@ class AudioSyncWorker
|
||||
public static void TrySyncAndLoadAudioClips()
|
||||
{
|
||||
if (IsLoading || IsSyncing) return;
|
||||
Task.Run(() =>
|
||||
Task.Run(SyncAndLoadAudioClips);
|
||||
}
|
||||
|
||||
// Moves a file out of the bank folder instead of deleting it, so a mistaken sync is always
|
||||
// recoverable. Pure file I/O, safe to call from the sync thread.
|
||||
internal static bool TryQuarantine(string filePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
SyncAndLoadAudioClipsCoroutine().GetAwaiter().GetResult();
|
||||
});
|
||||
Directory.CreateDirectory(QuarantineDirectory);
|
||||
string target = Path.Combine(QuarantineDirectory, Path.GetFileName(filePath));
|
||||
if (File.Exists(target))
|
||||
{
|
||||
string stem = Path.GetFileNameWithoutExtension(filePath);
|
||||
string ext = Path.GetExtension(filePath);
|
||||
target = Path.Combine(QuarantineDirectory, $"{stem}_{DateTime.Now:yyyyMMdd_HHmmss}{ext}");
|
||||
if (File.Exists(target)) File.Delete(target);
|
||||
}
|
||||
File.Move(filePath, target);
|
||||
Debug.Log($"Moved '{Path.GetFileName(filePath)}' out of the bank folder into _removed");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"Could not move '{filePath}' out of the sounds folder: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Everything in the folder that the bank doesn't list gets pulled out, so every player ends
|
||||
// up with an identical set. BingBong voice lines are exempt.
|
||||
private static int QuarantineFilesNotInBank(HashSet<string> bankNames)
|
||||
{
|
||||
Dictionary<string, Song> loaded = new(Song.Sounds);
|
||||
int moved = 0;
|
||||
foreach (string ext in AudioTypes.Keys)
|
||||
{
|
||||
foreach (string file in Directory.GetFiles(SoundsDirectory, $"*.{ext}"))
|
||||
{
|
||||
string name = Path.GetFileNameWithoutExtension(file);
|
||||
if (Song.IsBingBongVoiceLine(name)) continue;
|
||||
if (bankNames.Contains(name)) continue;
|
||||
if (!TryQuarantine(file)) continue;
|
||||
moved++;
|
||||
if (loaded.TryGetValue(name, out Song? song)) Plugin.RunOnMainThread(song.Dispose);
|
||||
}
|
||||
}
|
||||
return moved;
|
||||
}
|
||||
|
||||
// ShowActionbar reads Time.time and touches a MonoBehaviour, so it can't be called straight
|
||||
// from the sync thread.
|
||||
private static void ShowActionbarOnMainThread(string message)
|
||||
{
|
||||
Plugin.RunOnMainThread(() => BetterBugleUI.Instance?.ShowActionbar(message));
|
||||
}
|
||||
|
||||
private static IEnumerator LoadAllAudioClipsCoroutine(string directoryPath, string[]? forceReload = null)
|
||||
{
|
||||
ClipsNeedingPreload = 0;
|
||||
List<(string filePath, string ext, string name)> filesToLoad = new();
|
||||
|
||||
foreach (var ext in AudioTypes.Keys)
|
||||
@@ -212,13 +291,32 @@ class AudioSyncWorker
|
||||
loadedCount += loadCoroutines.Count;
|
||||
BetterBugleUI.Instance?.ShowActionbar($"Loading audio clips... {loadedCount}/{filesToLoad.Count}");
|
||||
}
|
||||
OnAudioLoadComplete?.Invoke();
|
||||
|
||||
if (ClipsNeedingPreload > 0)
|
||||
Debug.Log($"Pre-decoded {ClipsNeedingPreload}/{filesToLoad.Count} clips that would have stalled the main thread on first play.");
|
||||
|
||||
// Clear the flag before notifying: a subscriber that throws must not leave IsLoading
|
||||
// stuck true, which would wedge every sync and refresh for the rest of the session.
|
||||
IsLoading = false;
|
||||
try
|
||||
{
|
||||
OnAudioLoadComplete?.Invoke();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"An audio load handler threw: {ex}");
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerator LoadAudioClipCoroutine(string filePath, string ext, string name, bool forceReload = false)
|
||||
{
|
||||
using UnityWebRequest www = UnityWebRequestMultimedia.GetAudioClip($"file://{filePath}", AudioTypes[ext]);
|
||||
// Keep the clip compressed in memory and let Unity decode it during playback. The default
|
||||
// (DecompressOnLoad) holds raw PCM for the whole session -- roughly 10 MB per minute of
|
||||
// 44.1kHz stereo, so a ~80 MB bank becomes ~1 GB resident. Compressed costs a little CPU
|
||||
// per playing voice, which is nothing when one bugle is sounding, and avoids both the
|
||||
// memory and the big upfront decode. Must be set before SendWebRequest.
|
||||
if (www.downloadHandler is DownloadHandlerAudioClip audioHandler) audioHandler.compressed = true;
|
||||
yield return www.SendWebRequest();
|
||||
|
||||
if (www.result != UnityWebRequest.Result.Success) yield break;
|
||||
@@ -236,11 +334,28 @@ class AudioSyncWorker
|
||||
AudioClip audioClip = DownloadHandlerAudioClip.GetContent(www);
|
||||
if (audioClip == null) yield break;
|
||||
|
||||
// GetContent hands back a DecompressOnLoad clip whose PCM data is decoded lazily, on
|
||||
// first use. Left alone that decode happens inside the first AudioSource.Play(), on the
|
||||
// main thread, freezing the game the first time anyone plays a song. Force it here
|
||||
// instead: we're already on the loading path, where a stall is expected and we can yield
|
||||
// through it. If the clip already arrived loaded this is a no-op.
|
||||
if (audioClip.loadState != AudioDataLoadState.Loaded)
|
||||
{
|
||||
ClipsNeedingPreload++;
|
||||
// loadInBackground is serialized on the asset and read-only at runtime, so this
|
||||
// decodes synchronously. That's the point: the cost lands here rather than mid-game,
|
||||
// and the batching in LoadAllAudioClipsCoroutine already yields between clips.
|
||||
audioClip.LoadAudioData();
|
||||
while (audioClip.loadState == AudioDataLoadState.Loading) yield return null;
|
||||
}
|
||||
|
||||
Song song = new(name, ext, filePath, audioClip);
|
||||
song.Register();
|
||||
}
|
||||
|
||||
private static async Task SyncAndLoadAudioClipsCoroutine()
|
||||
|
||||
// Runs on a thread pool thread via Task.Run. Everything in here must stay off Unity APIs;
|
||||
// hop back through Plugin.RunOnMainThread for anything that touches the engine.
|
||||
private static void SyncAndLoadAudioClips()
|
||||
{
|
||||
if (IsLoading || IsSyncing) return;
|
||||
IsSyncing = true;
|
||||
@@ -248,37 +363,52 @@ class AudioSyncWorker
|
||||
{
|
||||
Dictionary<AudioSyncService.APIAudioFormat, Song?> toDownload = new();
|
||||
|
||||
string[] existingSongNames = [.. Song.Sounds.Keys];
|
||||
AudioSyncService.APIAudioFormat[] existingAPIFormats = [.. AudioSyncService.GetAudioClips()];
|
||||
string[] apiExistingNames = [.. existingAPIFormats.Select(apiAudio => apiAudio.Filename)];
|
||||
|
||||
var songsToRemove = existingSongNames.Except(apiExistingNames).ToArray();
|
||||
foreach (var songName in songsToRemove)
|
||||
// A failed fetch (bad URL, offline, server error) also returns an empty list, and an
|
||||
// empty bank would mean "nothing belongs here" -- quarantining the entire folder. Only
|
||||
// a bank that actually listed something is allowed to drive removals.
|
||||
if (existingAPIFormats.Length == 0)
|
||||
{
|
||||
if (Song.Sounds.TryGetValue(songName, out var songToDispose))
|
||||
{
|
||||
songToDispose.Dispose();
|
||||
songToDispose.DeleteFile();
|
||||
}
|
||||
Debug.LogWarning("Audio bank returned no entries; cancelling sync rather than emptying the folder.");
|
||||
ShowActionbarOnMainThread("Audio bank unreachable or empty, sync cancelled.");
|
||||
IsSyncing = false;
|
||||
return;
|
||||
}
|
||||
|
||||
HashSet<string> bankNames = new(existingAPIFormats.Select(apiAudio => apiAudio.Filename), StringComparer.OrdinalIgnoreCase);
|
||||
int quarantined = QuarantineFilesNotInBank(bankNames);
|
||||
|
||||
// Hashing reads the whole file off disk, so build the lookup here on the background
|
||||
// thread rather than eagerly during loading where it stalled the main thread once
|
||||
// per clip.
|
||||
Dictionary<string, Song> soundsByHash = new();
|
||||
foreach (Song song in Song.Sounds.Values.ToList())
|
||||
{
|
||||
// Skip anything just quarantined: its file has moved, so hashing would only fail
|
||||
// and log noise. Its Dispose is already queued on the main thread.
|
||||
if (!File.Exists(song.FilePath)) continue;
|
||||
string hash = song.Hash;
|
||||
if (!string.IsNullOrEmpty(hash)) soundsByHash[hash] = song;
|
||||
}
|
||||
|
||||
foreach (AudioSyncService.APIAudioFormat apiAudio in existingAPIFormats)
|
||||
{
|
||||
Song? existingSong = Song.SoundsByHash.GetValueOrDefault(apiAudio.Hash);
|
||||
Song? existingSong = soundsByHash.GetValueOrDefault(apiAudio.Hash);
|
||||
if (existingSong == null || existingSong.Hash != apiAudio.Hash)
|
||||
{
|
||||
toDownload.Add(apiAudio, existingSong);
|
||||
}
|
||||
}
|
||||
|
||||
BetterBugleUI.Instance?.ShowActionbar($"Syncing audio bank... {toDownload.Count} changed/new files found.");
|
||||
string quarantineNote = quarantined > 0 ? $", {quarantined} moved to _removed" : "";
|
||||
ShowActionbarOnMainThread($"Syncing audio bank... {toDownload.Count} changed/new files found{quarantineNote}.");
|
||||
|
||||
string[] filesToOverload = [];
|
||||
|
||||
foreach (AudioSyncService.APIAudioFormat apiAudio in toDownload.Keys)
|
||||
{
|
||||
bool success = await AudioSyncService.DownloadAPIAudio(apiAudio, SoundsDirectory, toDownload[apiAudio]);
|
||||
bool success = AudioSyncService.DownloadAPIAudio(apiAudio, SoundsDirectory, toDownload[apiAudio]);
|
||||
if (success)
|
||||
{
|
||||
Debug.Log($"Successfully downloaded audio: {apiAudio.Filename}.{apiAudio.Extension}, adding to forceload");
|
||||
@@ -302,7 +432,6 @@ class AudioSyncWorker
|
||||
public class Song : IDisposable
|
||||
{
|
||||
public static readonly Dictionary<string, Song> Sounds = new();
|
||||
public static readonly Dictionary<string, Song> SoundsByHash = new();
|
||||
|
||||
public static readonly Dictionary<string, Song> Songs = new();
|
||||
public static readonly Dictionary<string, Song> BB_VoiceLines = new();
|
||||
@@ -316,6 +445,8 @@ public class Song : IDisposable
|
||||
.ThenBy(name => name)];
|
||||
}
|
||||
|
||||
public static bool IsBingBongVoiceLine(string name) => name.StartsWith(BingBongPrefix, StringComparison.Ordinal);
|
||||
|
||||
public static void UpdateRealIndices()
|
||||
{
|
||||
var sortedNames = Songs.Keys.OrderBy(name => name).ToList();
|
||||
@@ -328,65 +459,74 @@ public class Song : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
// Voice lines are personal, not part of the shared bank, so the sync never touches them.
|
||||
public const string BingBongPrefix = "SFX_VO_BingBong_";
|
||||
|
||||
public string Name { get; set; }
|
||||
public string Extension { get; set; }
|
||||
public string FilePath { get; set; }
|
||||
public AudioClip AudioClip { get; }
|
||||
public string Hash { get; }
|
||||
public int RealIndex { get; set; }
|
||||
|
||||
private string? _hash;
|
||||
|
||||
// Computed on demand rather than in the constructor: hashing reads the entire file and runs
|
||||
// SHA256 over it, and the constructor is called from the load coroutine on the main thread.
|
||||
// The only consumer is the sync comparison, which already runs on a background thread.
|
||||
public string Hash => _hash ??= GenerateHash(FilePath);
|
||||
|
||||
public Song(string name, string extension, string filePath, AudioClip audioClip)
|
||||
{
|
||||
Name = name;
|
||||
Extension = extension;
|
||||
FilePath = filePath;
|
||||
AudioClip = audioClip;
|
||||
Hash = GenerateHash(filePath);
|
||||
}
|
||||
|
||||
public void Register()
|
||||
{
|
||||
Sounds[Name] = this;
|
||||
SoundsByHash[Hash] = this;
|
||||
if (Name.StartsWith("SFX_VO_BingBong_")) BB_VoiceLines[Name] = this;
|
||||
if (IsBingBongVoiceLine(Name)) BB_VoiceLines[Name] = this;
|
||||
else Songs[Name] = this;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
public void Dispose() => Dispose(null);
|
||||
|
||||
// knownAudioSources lets a bulk clear pass in a single scene scan instead of paying for one
|
||||
// per song.
|
||||
public void Dispose(AudioSource[]? knownAudioSources)
|
||||
{
|
||||
if (AudioClip == null) return;
|
||||
var audioSources = UnityEngine.Object.FindObjectsByType<AudioSource>(FindObjectsSortMode.None);
|
||||
var audioSources = knownAudioSources ?? UnityEngine.Object.FindObjectsByType<AudioSource>(FindObjectsSortMode.None);
|
||||
foreach (var audioSource in audioSources)
|
||||
{
|
||||
if (audioSource.clip == AudioClip)
|
||||
if (audioSource != null && audioSource.clip == AudioClip)
|
||||
{
|
||||
audioSource.Stop();
|
||||
audioSource.clip = null;
|
||||
}
|
||||
}
|
||||
Sounds.Remove(Name);
|
||||
SoundsByHash.Remove(Hash);
|
||||
if (Name.StartsWith("SFX_VO_BingBong_")) BB_VoiceLines.Remove(Name);
|
||||
if (IsBingBongVoiceLine(Name)) BB_VoiceLines.Remove(Name);
|
||||
else Songs.Remove(Name);
|
||||
UnityEngine.Object.Destroy(AudioClip);
|
||||
}
|
||||
|
||||
public void DeleteFile()
|
||||
{
|
||||
if (AudioClip == null) return;
|
||||
var filePath = Path.Combine(AudioSyncWorker.SoundsDirectory, $"{Name}.{Extension}");
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
File.Delete(filePath);
|
||||
Debug.Log($"Deleted local file: {filePath}");
|
||||
}
|
||||
}
|
||||
|
||||
public string GenerateHash(string filePath)
|
||||
{
|
||||
using var hasher = SHA256.Create();
|
||||
var fileBytes = File.ReadAllBytes(filePath);
|
||||
var hashBytes = hasher.ComputeHash(fileBytes);
|
||||
return BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant();
|
||||
try
|
||||
{
|
||||
using var hasher = SHA256.Create();
|
||||
var fileBytes = File.ReadAllBytes(filePath);
|
||||
var hashBytes = hasher.ComputeHash(fileBytes);
|
||||
return BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A missing or locked file just means "no match", which makes the sync re-download it.
|
||||
Debug.LogWarning($"Could not hash '{filePath}': {ex.Message}");
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,25 @@ public static class Helper
|
||||
return item;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Config URLs get typed by hand, so a missing scheme is common. Adds one when there isn't
|
||||
/// already a scheme, picking the insecure variant for loopback since a local dev server
|
||||
/// almost never has TLS in front of it. Call as EnsureScheme(url, "https", "http") or
|
||||
/// EnsureScheme(url, "wss", "ws").
|
||||
/// </summary>
|
||||
public static string EnsureScheme(string? url, string secureScheme, string insecureScheme)
|
||||
{
|
||||
string trimmed = (url ?? "").Trim();
|
||||
if (trimmed.Length == 0) return "";
|
||||
if (trimmed.Contains("://")) return trimmed;
|
||||
|
||||
bool loopback = trimmed.StartsWith("localhost", System.StringComparison.OrdinalIgnoreCase)
|
||||
|| trimmed.StartsWith("127.0.0.1", System.StringComparison.Ordinal)
|
||||
|| trimmed.StartsWith("[::1]", System.StringComparison.Ordinal);
|
||||
|
||||
return (loopback ? insecureScheme : secureScheme) + "://" + trimmed;
|
||||
}
|
||||
|
||||
public static bool IsOnIsland()
|
||||
{
|
||||
return SceneManager.GetActiveScene().name.ToLower().StartsWith("level_") || SceneManager.GetActiveScene().name == "WilIsland";
|
||||
|
||||
Reference in New Issue
Block a user