From 7ea2d8fe3fdfc7b1b234b5764966456229d87a92b2bfb709d5d4d19329edee10 Mon Sep 17 00:00:00 2001 From: copecone Date: Tue, 13 Oct 2026 02:00:18 +0900 Subject: [PATCH] Resolve Audio Clips From Level Files --- Patch/Engine/AudioPatches.cs | 20 ++++++++++ Util/Audio/Clip/AudioClipPaths.cs | 46 +++++++++++++++++++++ Util/Audio/Clip/AudioClipResolver.cs | 60 ++++++++++++++++++++++++++++ Util/Audio/Clip/AudioClipSource.cs | 5 +++ Util/Audio/Clip/AudioClipStore.cs | 25 ++++++++---- 5 files changed, 149 insertions(+), 7 deletions(-) create mode 100644 Util/Audio/Clip/AudioClipPaths.cs create mode 100644 Util/Audio/Clip/AudioClipResolver.cs diff --git a/Patch/Engine/AudioPatches.cs b/Patch/Engine/AudioPatches.cs index c79495c..5bcf75e 100644 --- a/Patch/Engine/AudioPatches.cs +++ b/Patch/Engine/AudioPatches.cs @@ -1,11 +1,31 @@ using System; using ADOFAIRenderer.Core; +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 +{ + [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); + } +} + [HarmonyPatch(typeof(AudioManager), "_Play")] public static class AudioManagerPlayPatch { diff --git a/Util/Audio/Clip/AudioClipPaths.cs b/Util/Audio/Clip/AudioClipPaths.cs new file mode 100644 index 0000000..1900d18 --- /dev/null +++ b/Util/Audio/Clip/AudioClipPaths.cs @@ -0,0 +1,46 @@ +using System.Collections.Generic; +using System.IO; + +namespace ADOFAIRenderer.Util.Audio.Clip; + +/// +/// File path of every external audio clip the game loads, keyed by the game's audioLib key. +/// +/// The game loads external clips with AudioManager.FindOrLoadAudioClipExternal(path, ...) and stores them in audioLib under +/// Path.GetFileName(path) + "*external" (the same string becomes AudioClip.name). Recorded paths: +/// - Song: Path.Combine(Path.GetDirectoryName(levelPath), levelData.songFilename) (scnGame song loading). +/// - PlaySound event sound: Path.Combine(Path.GetDirectoryName(levelPath), hitsound) (scnGame.LoadSoundCo); +/// ffxPlaySound plays it as hitsound + "*external". +/// is called only when the game actually loads the file (the key is not in audioLib yet), +/// so the recorded path is the file of the clip audioLib holds under that key. +/// Thread-safe. +/// +internal static class AudioClipPaths +{ + /// Suffix of the game's audioLib keys for external clips. + public const string ExternalSuffix = "*external"; + + private static readonly Dictionary Paths = new(); + + /// audioLib key of an external clip loaded from . + public static string KeyOf(string path) => Path.GetFileName(path) + ExternalSuffix; + + /// Records under (path), replacing an earlier path of the same key. + public static void Record(string path) + { + if (string.IsNullOrEmpty(path)) return; + lock (Paths) + { + Paths[KeyOf(path)] = path; + } + } + + /// Recorded path for an audioLib key (or AudioClip.name) of an external clip. + public static bool TryGetPath(string key, out string path) + { + lock (Paths) + { + return Paths.TryGetValue(key, out path!); + } + } +} diff --git a/Util/Audio/Clip/AudioClipResolver.cs b/Util/Audio/Clip/AudioClipResolver.cs new file mode 100644 index 0000000..d62ee06 --- /dev/null +++ b/Util/Audio/Clip/AudioClipResolver.cs @@ -0,0 +1,60 @@ +using System; +using System.IO; +using MelonLoader; +using UnityEngine; + +namespace ADOFAIRenderer.Util.Audio.Clip; + +/// +/// Opens the for a clip the game plays, keyed by the game's audioLib key (also AudioClip.name): +/// - Key recorded in and the file exists: decoded from the file with +/// (mono MP3 through ) +/// (song files and PlaySound files of custom levels). +/// - Otherwise, or when FFmpeg cannot open the file: the Unity clip is copied with +/// (built-in hit sounds from Resources, official/DLC level songs from Addressables). +/// Main thread only (the Unity copy calls AudioClip.GetData). +/// +internal static class AudioClipResolver +{ + /// Store of the current render. + /// audioLib key of the clip (for example "song.ogg*external" or "sndKick"). + /// Loaded Unity clip for the key, used when the clip has no file; may be null when the key has a file. + /// The source, or null when neither the file nor the clip can be read. + public static AudioClipSource? GetOrAdd(AudioClipStore store, string key, AudioClip? clip) + { + if (store.TryGet(key, out var existing)) return existing; + + if (AudioClipPaths.TryGetPath(key, out var path) && File.Exists(path)) + { + IPCMReader? fileReader = null; + try + { + fileReader = OpenFile(path); + if (fileReader is MP3MonoLayoutReader) + Melon.Logger.Warning($"[AudioClipResolver] Mono MP3 '{path}' is rendered like the game: half speed, one octave lower."); + } + catch (Exception e) + { + Melon.Logger.Warning($"[AudioClipResolver] FFmpeg could not open '{path}' for '{key}', using the Unity clip: {e.Message}"); + } + + if (fileReader != null) + { + var source = store.GetOrAdd(key, () => fileReader, () => OpenFile(path)); + if (source == null) fileReader.Dispose(); + return source; + } + } + + if (!clip) return store.GetOrAdd(key, () => null); + return store.GetOrAdd(key, () => UnityClipLoader.Load(clip!, store)); + } + + /// Opens with FFmpeg; mono MP3 files are wrapped in (game layout). + private static IPCMReader OpenFile(string path) + { + var reader = new FFmpegPCMReader(path); + if (!reader.IsMP3 || reader.Channels != 1) return reader; + return new MP3MonoLayoutReader(reader); + } +} diff --git a/Util/Audio/Clip/AudioClipSource.cs b/Util/Audio/Clip/AudioClipSource.cs index 9cb11ae..7f856f2 100644 --- a/Util/Audio/Clip/AudioClipSource.cs +++ b/Util/Audio/Clip/AudioClipSource.cs @@ -545,6 +545,11 @@ internal sealed class AudioClipSource try { reader = _openReader(); + if (reader is { IsForwardOnly: true }) + { + reader.Dispose(); + reader = null; + } if (reader != null) { var stream = new DecodeStream(reader, Frequency); diff --git a/Util/Audio/Clip/AudioClipStore.cs b/Util/Audio/Clip/AudioClipStore.cs index 7c24eba..631c41e 100644 --- a/Util/Audio/Clip/AudioClipStore.cs +++ b/Util/Audio/Clip/AudioClipStore.cs @@ -26,6 +26,9 @@ internal sealed class AudioClipStore : IDisposable /// Longest wait of a consumer between checks when no chunk is loaded, in milliseconds. private const int ConsumerWaitMs = 50; + /// Longest wait for the decoder thread to stop in , in milliseconds. + private const int DecoderStopTimeoutMs = 3000; + private readonly Dictionary _sources = new(); private readonly List _sourceList = new(); private readonly Stack _chunkPool = new(); @@ -128,10 +131,10 @@ internal sealed class AudioClipStore : IDisposable /// /// Returns the source registered under , creating it with on first use. /// A key whose reader could not be created is remembered as null and not retried. - /// For readers that can seek, is kept and called again on the decoder thread - /// to open additional decode streams (see ). + /// For readers that can seek, additional decode streams (see ) are opened on the + /// decoder thread with , or with when it is null. /// - public AudioClipSource? GetOrAdd(string key, Func createReader) + public AudioClipSource? GetOrAdd(string key, Func createReader, Func? openAdditionalReader = null) { lock (_sources) { @@ -147,7 +150,7 @@ internal sealed class AudioClipStore : IDisposable { try { - source = new AudioClipSource(this, key, reader, reader.IsForwardOnly ? null : createReader, OutputRate); + source = new AudioClipSource(this, key, reader, reader.IsForwardOnly ? null : openAdditionalReader ?? createReader, OutputRate); } catch { @@ -315,7 +318,12 @@ internal sealed class AudioClipStore : IDisposable _stopRequested = true; _serviceRequested.Set(); NotifyChunkLoaded(); - _decoderThread?.Join(); + + // A decoder thread blocked inside a read (for example a file on an unresponsive drive) is left running: + // it is a background thread, and its sources keep their readers so the blocked call does not touch freed FFmpeg contexts. + var decoderStopped = _decoderThread == null || _decoderThread.Join(DecoderStopTimeoutMs); + if (!decoderStopped) + Melon.Logger.Warning($"[AudioClipStore] Decoder thread did not stop within {DecoderStopTimeoutMs} ms; its readers are left open."); LogMemoryUsage(); @@ -325,8 +333,11 @@ internal sealed class AudioClipStore : IDisposable _sources.Clear(); } - foreach (var source in sources) - source.DisposeInternal(); + if (decoderStopped) + { + foreach (var source in sources) + source.DisposeInternal(); + } lock (_poolLock) {