diff --git a/Core/RenderManager.cs b/Core/RenderManager.cs index 0f05881..bb41f58 100644 --- a/Core/RenderManager.cs +++ b/Core/RenderManager.cs @@ -8,6 +8,7 @@ using System.Diagnostics; using ADOFAIRenderer.API; using ADOFAIRenderer.Core.Data; using ADOFAIRenderer.Data; +using ADOFAIRenderer.Patch.Engine; using ADOFAIRenderer.UI; using ADOFAIRenderer.UI.Window; using ADOFAIRenderer.Util; @@ -165,6 +166,9 @@ internal class RenderManager : MonoBehaviour public int frameCountBase; public OfflineAudioMixer? AudioMixer { get; private set; } + + /// Render time of the current frame in seconds (frameCount / RenderFPS), the time base of audio commands. + public double AudioTime => frameCount / Configuration.RenderFPS; private AudioSyncCalculator? SyncCalculator { get; set; } private FrameReader? _frameReader; @@ -276,8 +280,10 @@ internal class RenderManager : MonoBehaviour if (stage != RenderStage.None) MarkFailed("the game exited or RenderManager was destroyed during rendering."); ResetRenderState(success: false); - + AudioSpectrumGenerator.Clear(); + // Cancels the render thread's audio waits, then releases the clip store (decoder thread, readers, chunks). + AudioMixer?.Dispose(); AudioMixer = null; ResetFrameHookState(); @@ -476,7 +482,17 @@ internal class RenderManager : MonoBehaviour // ReSharper disable once Unity.IncorrectMethodSignature public void OnPostFrame(RenderTexture? texture) { - if (Instance.stage < RenderStage.Render || !texture) return; + if (Instance.stage < RenderStage.Render) return; + + // Audio commands of this frame are queued: update the song voice and let the render thread mix up to the next frame. + if (AudioMixer != null) + { + AudioMixer.UpdateBGM(AudioTime, dspTimeBase); + AudioMixer.Commit((frameCount + 1) / Configuration.RenderFPS); + EffectSource.DeactivateMappedSources(AudioMixer); + } + + if (!texture) return; // [Backpressure Throttling] // The main thread waits while ImageQueue holds more than QueueFrameLimit frames. Only the render thread @@ -651,19 +667,19 @@ internal class RenderManager : MonoBehaviour scrController.instance.levelWasSkipped = true; if (!scrConductor.instance || !scrConductor.instance.song) yield break; - if (Configuration.AdvancedConfig.ProcessAudio) - { - AudioSpectrumGenerator.Initialize(scrConductor.instance.song.clip); - } - if (Configuration.AudioConfig.RenderAudio) { AudioMixer = new OfflineAudioMixer(Configuration.AudioConfig.AudioChannels, Configuration.AudioConfig.AudioSampleRate); AudioMixer.InitializeBGM(scrConductor.instance.song.clip); - + var effectiveVideoFps = Math.Min(Configuration.VideoFPS, Configuration.RenderFPS); SyncCalculator = new AudioSyncCalculator(Configuration.AudioConfig.AudioSampleRate, effectiveVideoFps); } + + if (Configuration.AdvancedConfig.ProcessAudio) + { + AudioSpectrumGenerator.Initialize(scrConductor.instance.song.clip, AudioMixer); + } scrConductor.instance.song.clip = null; scrConductor.instance.song.time = -1000; @@ -786,23 +802,35 @@ internal class RenderManager : MonoBehaviour endSuccessful = !IsRenderAborted; } + catch (OperationCanceledException) when (IsRenderAborted) + { + // Audio waits end with OperationCanceledException when the render is cancelled or has failed (OfflineAudioMixer.Cancel). + Melon.Logger.Msg("Render thread stopped: the render was aborted."); + DisposeEncoderAfterStop(videoFFmpeg); + } catch (Exception ex) { Melon.Logger.Error($"FFmpeg rendering thread error: {ex}"); MarkFailed("encoder thread error. See the log for details."); - try - { - videoFFmpeg.Dispose(); - } - catch (Exception disposeEx) - { - Melon.Logger.Error($"Encoder dispose after thread error also failed: {disposeEx.Message}"); - } - UnregisterEncoder(videoFFmpeg); + DisposeEncoderAfterStop(videoFFmpeg); } } + /// Releases the encoder after the render thread stopped early (abort or error) and removes it from the encoder list. + private void DisposeEncoderAfterStop(FFmpegNativeEncoder videoFFmpeg) + { + try + { + videoFFmpeg.Dispose(); + } + catch (Exception disposeEx) + { + Melon.Logger.Error($"Encoder dispose after the render thread stopped also failed: {disposeEx.Message}"); + } + UnregisterEncoder(videoFFmpeg); + } + /// /// Takes frames from ImageQueue in passCount order and sends each one to the encoder, followed by the audio /// samples of that frame. Waits on FrameAvailableEvent (50ms) while no frame is ready. @@ -892,17 +920,13 @@ internal class RenderManager : MonoBehaviour { if (!Configuration.AudioConfig.RenderAudio || SyncCalculator is not { } syncCalculator) return; - var frameIdx = (int)(passCount - 1); - var effectiveVideoFps = Math.Min(Configuration.VideoFPS, Configuration.RenderFPS); - var frameTime = frameIdx / effectiveVideoFps; - var channels = Configuration.AudioConfig.AudioChannels; var samplesNeeded = syncCalculator.GetNextFrameSamples(); var audioBuffer = ArrayPool.Shared.Rent(samplesNeeded * channels); try { - AudioMixer?.GenerateAudioForFrame(audioBuffer, samplesNeeded, frameTime, dspTimeBase); + AudioMixer?.GenerateAudioForFrame(audioBuffer, samplesNeeded); videoFFmpeg.WriteAudioSamples(audioBuffer, samplesNeeded); } finally @@ -945,6 +969,9 @@ internal class RenderManager : MonoBehaviour { MarkCancelled(); } + // The abort state is set first: the render thread reads it when its audio wait is cancelled. + if (success) AudioMixer?.Complete(); + else AudioMixer?.Cancel(); FrameAvailableEvent.Set(); skipPressToStart = false; _renderSetup = false; @@ -1087,10 +1114,10 @@ internal class RenderManager : MonoBehaviour _frameReader?.Dispose(); ResetFrameHookState(); - AudioMixer?.Reset(); AudioMixer = null; - SyncCalculator = null; - AudioSpectrumGenerator.Clear(); + AudioMixer?.Dispose(); AudioMixer = null; + SyncCalculator = null; + RenderContext.Reset(); } diff --git a/Patch/Engine/AudioPatches.cs b/Patch/Engine/AudioPatches.cs index c79495c..9d9c2f1 100644 --- a/Patch/Engine/AudioPatches.cs +++ b/Patch/Engine/AudioPatches.cs @@ -1,11 +1,33 @@ using System; +using System.Collections.Generic; using ADOFAIRenderer.Core; +using ADOFAIRenderer.Util.Audio; +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 { @@ -30,14 +52,27 @@ public static class AudioManagerPlayPatch double relativeStartTime = time - RenderManager.Instance.dspTimeBase; // Register event to the offline mixer (default pitch = 1.0f) and map it to the AudioSource - RenderManager.Instance.AudioMixer?.RegisterSoundEvent(__result, snd, relativeStartTime, -1.0, volume, 1.0f); + var mixer = RenderManager.Instance.AudioMixer; + if (mixer == null) return; + mixer.RegisterSoundEvent(__result, snd, relativeStartTime, -1.0, volume, 1.0f, RenderManager.Instance.AudioTime); + EffectSource.DeactivateAtFrameEnd(__result); } } } +/// +/// 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 { + /// State registered by the running PlayWithEndTime call, until its MakeSource returns. Main thread only. + internal static SourceState? PendingState; + + [HarmonyFinalizer] + public static void Finalizer() => PendingState = null; + [HarmonyPrefix] public static bool Prefix(string snd, double time, double endTime, float volume, int priority) { @@ -54,59 +89,234 @@ public static class PlayWithEndTimePatch double relativeEndTime = endTime - RenderManager.Instance.dspTimeBase; // Register looping sound event to the offline mixer (default pitch = 1.0f) - mixer.RegisterSoundEvent(snd, relativeStartTime, relativeEndTime, volume, 1.0f); + PendingState = 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 state (). +[HarmonyPatch(typeof(AudioManager), nameof(AudioManager.MakeSource))] +public static class AudioManagerMakeSourcePatch +{ + [HarmonyPostfix] + // ReSharper disable once InconsistentNaming + public static void Postfix(AudioSource __result) + { + var state = PlayWithEndTimePatch.PendingState; + if (state == null || !__result) return; + PlayWithEndTimePatch.PendingState = null; + var mixer = RenderManager.Instance.AudioMixer; + if (mixer == null) return; + mixer.MapSource(__result, state); + EffectSource.DeactivateAtFrameEnd(__result); + } +} + +/// +/// AudioSource members of an effect source mapped to a during rendering: the patches below skip the +/// Unity implementation and read or change the state instead (isPlaying, time, timeSamples, pitch, SetScheduledEndTime, Stop). +/// The patches apply only while returns true: rendering is capturing (RenderManager.IsCapturingActive, +/// stage Render), the render has an audio mixer (created when the render starts, cleared when it ends), and the source is mapped +/// in that mixer (mappings are created only when RenderAudio is on). Otherwise they run the Unity implementation, so sources +/// without a mapping (the song, menu sounds) and all sources outside rendering behave as in the game. +/// +/// The game object of a mapped source is deactivated at the end of the frame it was mapped in: the mixer produces its sound and the +/// state answers every member above, so Unity does not process the source. The game activates it again when AudioManager.MakeSource +/// reuses it. Active AudioSources add main-thread time per frame in proportion to their count, and dense levels keep thousands of +/// scheduled effect sources alive. +/// +internal static class EffectSource +{ + /// Sources mapped during the current frame, deactivated by . Main thread only. + private static readonly List PendingDeactivation = new(); + + /// Deactivates the game object of at the end of the current frame (). + public static void DeactivateAtFrameEnd(AudioSource source) => PendingDeactivation.Add(source); + + /// + /// Deactivates the game objects of the sources mapped this frame that are still mapped and not destroyed. + /// Called by RenderManager.OnPostFrame after the audio commands of the frame are committed, which runs only in stage Render with + /// an audio mixer; sources are added only by the render-time registrations (AudioManager._Play and scrConductor.PlayWithEndTime + /// patches with RenderAudio on). + /// + public static void DeactivateMappedSources(OfflineAudioMixer mixer) + { + foreach (var source in PendingDeactivation) + { + if (source && mixer.TryGetSourceState(source, out _)) source.gameObject.SetActive(false); + } + PendingDeactivation.Clear(); + } + + /// + /// State mapped to in the mixer of the current render, with the current render time in . + /// False outside rendering (stage below Render), without a mixer, or for a source that is not mapped. + /// + public static bool TryGet(AudioSource source, out SourceState state, out double now) + { + state = null!; + now = 0; + if (!RenderManager.IsCapturingActive) return false; + var mixer = RenderManager.Instance.AudioMixer; + if (mixer == null || !mixer.TryGetSourceState(source, out state)) return false; + now = RenderManager.Instance.AudioTime; + return true; + } +} + +/// +/// AudioSource.isPlaying getter (): true from the scheduling call until Stop(), the scheduled end time or +/// the end of the clip, including before the scheduled start. AudioManager.Update returns a source to its reusable pool once this is false. +/// +[HarmonyPatch(typeof(AudioSource), "isPlaying", MethodType.Getter)] +public static class AudioSourceIsPlayingPatch +{ + [HarmonyPrefix] + // ReSharper disable once InconsistentNaming + public static bool Prefix(AudioSource __instance, ref bool __result) + { + if (!EffectSource.TryGet(__instance, out var state, out var now)) return true; + __result = state.IsPlaying(now); + return false; + } +} + +/// AudioSource.time getter (): clip position in seconds while playing, 0 while not playing. +[HarmonyPatch(typeof(AudioSource), "time", MethodType.Getter)] +public static class AudioSourceTimeGetterPatch +{ + [HarmonyPrefix] + // ReSharper disable once InconsistentNaming + public static bool Prefix(AudioSource __instance, ref float __result) + { + if (!EffectSource.TryGet(__instance, out var state, out var now)) return true; + __result = state.GetTime(now); + return false; + } +} + +/// +/// AudioSource.time setter (): moves the playback position (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 +{ + [HarmonyPrefix] + // ReSharper disable once InconsistentNaming + public static bool Prefix(AudioSource __instance, float value) + { + if (!EffectSource.TryGet(__instance, out var state, out var now)) return true; + state.SetTime(value, now); + return false; + } +} + +/// AudioSource.timeSamples getter (): clip position in samples of the clip. +[HarmonyPatch(typeof(AudioSource), "timeSamples", MethodType.Getter)] +public static class AudioSourceTimeSamplesGetterPatch +{ + [HarmonyPrefix] + // ReSharper disable once InconsistentNaming + public static bool Prefix(AudioSource __instance, ref int __result) + { + if (!EffectSource.TryGet(__instance, out var state, out var now)) return true; + __result = state.GetTimeSamples(now); + return false; + } +} + +/// AudioSource.timeSamples setter (): moves the playback position (samples of the clip). +[HarmonyPatch(typeof(AudioSource), "timeSamples", MethodType.Setter)] +public static class AudioSourceTimeSamplesPatch +{ + [HarmonyPrefix] + // ReSharper disable once InconsistentNaming + public static bool Prefix(AudioSource __instance, int value) + { + if (!EffectSource.TryGet(__instance, out var state, out var now)) return true; + state.SetTimeSamples(value, now); + return false; + } +} + +/// AudioSource.pitch getter (): the last pitch set. +[HarmonyPatch(typeof(AudioSource), "pitch", MethodType.Getter)] +public static class AudioSourcePitchGetterPatch +{ + [HarmonyPrefix] + // ReSharper disable once InconsistentNaming + public static bool Prefix(AudioSource __instance, ref float __result) + { + if (!EffectSource.TryGet(__instance, out var state, out _)) return true; + __result = state.GetPitch(); + return false; + } +} + +/// AudioSource.pitch setter (): changes the playback rate from the current render time. [HarmonyPatch(typeof(AudioSource), "pitch", MethodType.Setter)] public static class AudioSourcePitchPatch { - [HarmonyPostfix] + [HarmonyPrefix] // ReSharper disable once InconsistentNaming - public static void Postfix(AudioSource __instance, float value) + public static bool Prefix(AudioSource __instance, float value) { - if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio) - { - var mixer = RenderManager.Instance.AudioMixer; - if (mixer != null && mixer.TryGetSoundEvent(__instance, out var soundEvent)) - { - soundEvent.Pitch = value; - } - } + if (!EffectSource.TryGet(__instance, out var state, out var now)) return true; + state.SetPitch(value, now); + return false; } } +/// AudioSource.SetScheduledEndTime (): DSP time (absolute) at which playback stops. [HarmonyPatch(typeof(AudioSource), "SetScheduledEndTime", typeof(double))] public static class AudioSourceSetScheduledEndTimePatch { - [HarmonyPostfix] + [HarmonyPrefix] // ReSharper disable once InconsistentNaming - public static void Postfix(AudioSource __instance, double time) + public static bool Prefix(AudioSource __instance, double time) { - if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio) - { - var mixer = RenderManager.Instance.AudioMixer; - if (mixer != null && mixer.TryGetSoundEvent(__instance, out var soundEvent)) - { - soundEvent.EndTime = time - RenderManager.Instance.dspTimeBase; - } - } + if (!EffectSource.TryGet(__instance, out var state, out var now)) return true; + state.SetEndTime(time - RenderManager.Instance.dspTimeBase, now); + return false; } } +/// +/// AudioSource.Stop() (): stops playback at the current render time. The mapping stays, so the source +/// keeps reporting the stopped state. AudioManager.Update calls Stop() on sources it returns to the pool, which are no longer +/// playing, so their voices are left unchanged. +/// [HarmonyPatch(typeof(AudioSource), "Stop", new Type[0])] public static class AudioSourceStopPatch { - [HarmonyPostfix] + [HarmonyPrefix] // ReSharper disable once InconsistentNaming - public static void Postfix(AudioSource __instance) + public static bool Prefix(AudioSource __instance) + { + if (!EffectSource.TryGet(__instance, out var state, out var now)) return true; + state.Stop(now); + return false; + } +} + +/// +/// 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?.RemoveSourceMapping(__instance); + RenderManager.Instance.AudioMixer?.StopAllEffects(RenderManager.Instance.AudioTime); } } } \ No newline at end of file diff --git a/Util/Audio/AudioResampler.cs b/Util/Audio/AudioResampler.cs deleted file mode 100644 index 12a8ff0..0000000 --- a/Util/Audio/AudioResampler.cs +++ /dev/null @@ -1,100 +0,0 @@ -using System; - -namespace ADOFAIRenderer.Util.Audio; - -internal static class AudioResampler -{ - public static float[] Resample(float[] inputSamples, int inputChannels, int inputFrequency, int targetChannels, int targetFrequency) - { - // ── Safety Validation ── - if (inputSamples.Length == 0 || - inputChannels <= 0 || targetChannels <= 0 || - inputFrequency <= 0 || targetFrequency <= 0) - { - return Array.Empty(); - } - - // If format is identical, return original samples - if (inputFrequency == targetFrequency && inputChannels == targetChannels) - return inputSamples; - - int inputLength = inputSamples.Length / inputChannels; - int targetLength = (int)Math.Floor((double)inputLength * targetFrequency / inputFrequency); - float[] outputSamples = new float[targetLength * targetChannels]; - - for (int i = 0; i < targetLength; i++) - { - double srcIndex = (double)i * inputFrequency / targetFrequency; - long idxLow = (long)Math.Floor(srcIndex); - long idxHigh = idxLow + 1; - float t = (float)(srcIndex - idxLow); - - if (idxHigh >= inputLength) idxHigh = inputLength - 1; - if (idxLow >= inputLength) idxLow = inputLength - 1; - - if (targetChannels == 1) // 1. Mono Output - { - float valLow, valHigh; - if (inputChannels == 1) // Mono -> Mono - { - valLow = inputSamples[idxLow]; - valHigh = inputSamples[idxHigh]; - } - else // Multi -> Mono (Downmix: Average all channels) - { - float sumLow = 0f; - float sumHigh = 0f; - for (int c = 0; c < inputChannels; c++) - { - sumLow += inputSamples[idxLow * inputChannels + c]; - sumHigh += inputSamples[idxHigh * inputChannels + c]; - } - valLow = sumLow / inputChannels; - valHigh = sumHigh / inputChannels; - } - outputSamples[i] = valLow + t * (valHigh - valLow); - } - else if (targetChannels == 2) // 2. Stereo Output - { - if (inputChannels == 1) // Mono -> Stereo (Duplicate to Left/Right) - { - float valLow = inputSamples[idxLow]; - float valHigh = inputSamples[idxHigh]; - float val = valLow + t * (valHigh - valLow); - outputSamples[i * 2] = val; - outputSamples[i * 2 + 1] = val; - } - else // Stereo/Multi -> Stereo (Map Left/Right, ignore extra channels) - { - float valLowL = inputSamples[idxLow * inputChannels]; - float valHighL = inputSamples[idxHigh * inputChannels]; - outputSamples[i * 2] = valLowL + t * (valHighL - valLowL); - - float valLowR = inputSamples[idxLow * inputChannels + 1]; - float valHighR = inputSamples[idxHigh * inputChannels + 1]; - outputSamples[i * 2 + 1] = valLowR + t * (valHighR - valLowR); - } - } - else // 3. Multi-channel Output (e.g., 5.1 Surround) - { - for (int c = 0; c < targetChannels; c++) - { - float valLow, valHigh; - if (c < inputChannels) - { - valLow = inputSamples[idxLow * inputChannels + c]; - valHigh = inputSamples[idxHigh * inputChannels + c]; - } - else - { - valLow = inputSamples[idxLow * inputChannels]; - valHigh = inputSamples[idxHigh * inputChannels]; - } - outputSamples[i * targetChannels + c] = valLow + t * (valHigh - valLow); - } - } - } - - return outputSamples; - } -} \ No newline at end of file diff --git a/Util/Audio/AudioSpectrumGenerator.cs b/Util/Audio/AudioSpectrumGenerator.cs index 306334a..58d1c4c 100644 --- a/Util/Audio/AudioSpectrumGenerator.cs +++ b/Util/Audio/AudioSpectrumGenerator.cs @@ -1,164 +1,188 @@ using System; using System.Collections.Generic; using ADOFAIRenderer.Core; +using ADOFAIRenderer.Util.Audio.Clip; +using MelonLoader; using UnityEngine; namespace ADOFAIRenderer.Util.Audio; +/// +/// Replacement for AudioSource.GetSpectrumData during rendering: the spectrum of the song as the song AudioSource would output it. +/// +/// Signal: the song clip from an at the output sample rate (AudioSettings.outputSampleRate), +/// played at conductor.song.pitch (linear interpolation between frames) and scaled by conductor.song.volume. +/// Unity analyzes the output signal of the source: bin spacing follows the output rate, pitch moves the frequencies, +/// and volume scales the magnitudes (measured with a Unity 6000.3.10f1 test project). +/// Channel: stereo clips use the requested channel (0 = left, 1 = right); a mono clip is scaled by . +/// Window: twice the requested spectrum length in output samples, centered on the current render time. +/// +/// The song source is shared with the store when its output rate matches; otherwise a separate +/// store is created. A cursor at the analysis position keeps the needed chunks loaded. Main thread only. +/// internal static class AudioSpectrumGenerator { - // ReSharper disable MemberCanBePrivate.Global - public static float[]? AudioClipData { get; private set; } - public static int Channels { get; private set; } - public static int Frequency { get; private set; } - public static int TotalSamples { get; private set; } - // ReSharper restore MemberCanBePrivate.Global + /// + /// Gain of a mono clip relative to one channel of a stereo clip with the same samples. + /// Measured with a Unity 6000.3.10f1 test project: the spectrum of a mono clip is about 0.70 times that of each stereo channel (1/√2). + /// + private const double MonoGain = 0.70710678118654752; + + private static AudioClipStore? _ownStore; + private static AudioClipStore? _store; + private static AudioClipSource? _source; + private static AudioCursor? _cursor; + private static ChunkView _view; + private static bool _loadErrorLogged; private static long _cachedFrame = -1; - private static readonly Dictionary CachedSpectrums = new(); + private static readonly Dictionary<(int Size, int Channel), float[]> CachedSpectrums = new(); - public static void Initialize(AudioClip? clip) + /// Registers the song clip. Call after the is created (null when audio is not rendered). + public static void Initialize(AudioClip? clip, OfflineAudioMixer? mixer) { - if (!clip) - { - Clear(); - return; - } + Clear(); + if (!clip) return; try { - Channels = clip.channels; - Frequency = clip.frequency; - int samplesCount = clip.samples; - - float[] rawSamples = new float[samplesCount * Channels]; - clip.GetData(rawSamples, 0); - - if (Channels == 1) + var rate = AudioSettings.outputSampleRate; + if (mixer != null && mixer.Store.OutputRate == rate) { - AudioClipData = rawSamples; - TotalSamples = samplesCount; + _store = mixer.Store; } else { - AudioClipData = new float[samplesCount]; - TotalSamples = samplesCount; - for (int i = 0; i < samplesCount; i++) - { - float sum = 0f; - for (int c = 0; c < Channels; c++) - { - sum += rawSamples[i * Channels + c]; - } - AudioClipData[i] = sum / Channels; - } + _ownStore = new AudioClipStore(rate); + _ownStore.Start(); + _store = _ownStore; } - MelonLoader.MelonLogger.Msg($"[AudioSpectrumGenerator] Cached audio clip: {clip.name}, Samples: {TotalSamples}, Frequency: {Frequency}Hz, Channels: {Channels}"); + + _source = AudioClipResolver.GetOrAdd(_store, clip!.name, clip); + if (_source == null) + { + Melon.Logger.Error($"[AudioSpectrumGenerator] Song clip '{clip.name}' could not be loaded, the spectrum will be empty."); + return; + } + _cursor = _source.AddCursor(0, false); + Melon.Logger.Msg($"[AudioSpectrumGenerator] Song '{clip.name}': {_source.Channels} ch at {rate} Hz ({(_ownStore != null ? "own store" : "shared with the mixer")})"); } catch (Exception e) { - MelonLoader.MelonLogger.Error($"[AudioSpectrumGenerator] Failed to initialize audio cache: {e}"); + Melon.Logger.Error($"[AudioSpectrumGenerator] Failed to initialize: {e}"); Clear(); } } + /// Releases the chunk, the cursor and the own store. Call before the mixer store is disposed. public static void Clear() { - AudioClipData = null; - TotalSamples = 0; - Channels = 0; - Frequency = 0; - lock (CachedSpectrums) + if (_source != null) { - _cachedFrame = -1; - CachedSpectrums.Clear(); + _source.Release(_view); + if (_cursor != null) _source.RemoveCursor(_cursor); } + _view = default; + _cursor = null; + _source = null; + _store = null; + _ownStore?.Dispose(); + _ownStore = null; + _loadErrorLogged = false; + _cachedFrame = -1; + CachedSpectrums.Clear(); } public static void GetSpectrumData(float[] samples, int channel, FFTWindow window) { - if (AudioClipData == null || AudioClipData.Length == 0 || !scrConductor.instance || !scrConductor.instance.hasSongStarted || !scrConductor.instance.song) - { - Array.Clear(samples, 0, samples.Length); - return; - } - - var conductor = scrConductor.instance; - var pitch = conductor.song.pitch; - var separateCountdownOffset = conductor.separateCountdownTime ? - conductor.crotchetAtStart * conductor.adjustedCountdownTicks : 0.0; - - // Calculate the actual scheduled start time of the song - var songScheduledStartTime = conductor.skipOffset ? - conductor.dspTimeSong + conductor.addoffset / pitch : - conductor.dspTimeSong + separateCountdownOffset / pitch; - - if (conductor.dspTime < songScheduledStartTime) - { - Array.Clear(samples, 0, samples.Length); - return; - } - - var songTime = (conductor.dspTime - conductor.dspTimeSong) * pitch - separateCountdownOffset; - var n = samples.Length; - if (n == 0 || (n & (n - 1)) != 0) // Ensure it's a power of 2 + var conductor = scrConductor.instance; + if (_source == null || _store == null || !conductor || !conductor!.hasSongStarted || !conductor.song + || n == 0 || (n & (n - 1)) != 0) { - Array.Clear(samples, 0, samples.Length); + Array.Clear(samples, 0, n); return; } - var currentFrame = RenderManager.Instance ? RenderManager.Instance.frameCount : -1; - - lock (CachedSpectrums) + var render = RenderManager.Instance; + var currentFrame = render.frameCount; + if (currentFrame != _cachedFrame) { - if (currentFrame != _cachedFrame) - { - _cachedFrame = currentFrame; - CachedSpectrums.Clear(); - } - - if (CachedSpectrums.TryGetValue(n, out var cached)) - { - Array.Copy(cached, samples, n); - return; - } + _cachedFrame = currentFrame; + CachedSpectrums.Clear(); } + var sourceChannel = _source.Channels == 1 ? 0 : Math.Min(Math.Max(channel, 0), _source.Channels - 1); + if (CachedSpectrums.TryGetValue((n, sourceChannel), out var cached)) + { + Array.Copy(cached, samples, n); + return; + } + + // Song position (seconds of song time) at the current render time, as in scrConductor.StartMusicCo: + // the song starts at dspTimeSong + separate countdown (crotchetAtStart * adjustedCountdownTicks in song time). + double pitch = conductor.song.pitch; + double volume = conductor.song.volume; + var countdown = conductor.separateCountdownTime ? conductor.crotchetAtStart * conductor.adjustedCountdownTicks : 0.0; + var now = render.dspTimeBase + render.AudioTime; + var songSeconds = (now - conductor.dspTimeSong) * pitch - countdown; + var fftSize = 2 * n; - var frequency = Frequency; - var totalSamples = TotalSamples; - var currentSampleIndex = (long)(songTime * frequency); + var gain = volume * (_source.Channels == 1 ? MonoGain : 1.0); + var first = songSeconds * _store.OutputRate - n * pitch; var windowSamples = new float[fftSize]; - for (var i = 0; i < fftSize; i++) + try { - var idx = currentSampleIndex - fftSize / 2 + i; - if (idx >= 0 && idx < totalSamples) + _cursor!.Position = Math.Min(Math.Max((long)first, 0), _source.TotalFrames - 1); + for (var i = 0; i < fftSize; i++) { - windowSamples[i] = AudioClipData[idx]; + var position = first + i * pitch; + var index = (long)Math.Floor(position); + var fraction = position - index; + var s0 = ReadSample(index, sourceChannel); + var s1 = fraction > 0 ? ReadSample(index + 1, sourceChannel) : 0.0; + windowSamples[i] = (float)((s0 + (s1 - s0) * fraction) * gain); } - else + } + catch (InvalidOperationException e) + { + if (!_loadErrorLogged) { - windowSamples[i] = 0f; + _loadErrorLogged = true; + Melon.Logger.Error($"[AudioSpectrumGenerator] Song samples are unavailable, the spectrum will be empty: {e.Message}"); } + Array.Clear(samples, 0, n); + return; } var spectrum = new float[n]; CalculateFFT(windowSamples, spectrum); + CachedSpectrums[(n, sourceChannel)] = spectrum; + Array.Copy(spectrum, samples, n); + } - lock (CachedSpectrums) + /// Sample of source channel at ; 0 outside the clip or when the source failed. + /// Chunk loading failed fatally (see ). + private static double ReadSample(long frame, int channel) + { + var source = _source!; + if (frame < 0 || frame >= source.TotalFrames) return 0.0; + if (!_view.IsValid || !_view.Contains(frame)) { - CachedSpectrums[n] = spectrum; - Array.Copy(spectrum, samples, n); + source.Release(_view); + _view = default; + _view = _store!.Acquire(source, frame); + if (!_view.IsValid) return 0.0; } + return _view.Data[(int)(frame - _view.StartFrame) * _view.Channels + channel]; } private static void CalculateFFT(float[] samples, float[] spectrum) { var n = samples.Length; - // Apply Blackman-Harris window + // Blackman-Harris window, periodic form: w(i) = a0 - a1 cos(2πi/n) + a2 cos(4πi/n) - a3 cos(6πi/n), i = 0 ~ n - 1 const float a0 = 0.35875f; const float a1 = 0.48829f; const float a2 = 0.14128f; @@ -169,7 +193,7 @@ internal static class AudioSpectrumGenerator for (var i = 0; i < n; i++) { - var angle = 2.0 * Math.PI * i / (n - 1); + var angle = 2.0 * Math.PI * i / n; var w = (float)(a0 - a1 * Math.Cos(angle) + a2 * Math.Cos(2.0 * angle) - a3 * Math.Cos(3.0 * angle)); re[i] = samples[i] * w; im[i] = 0f; @@ -230,12 +254,13 @@ internal static class AudioSpectrumGenerator } } - // Normalize and fill spectrum - var factor = 2.0f / n; + // Magnitude scale 2.5 / n (n = FFT size, twice the spectrum length), clamped to 1.0: the scale of Unity GetSpectrumData. + // A sine of amplitude A centered on a bin gives A * a0 * 1.25 with the Blackman-Harris window. + var factor = 2.5f / n; var outLen = Math.Min(spectrum.Length, n / 2); for (var i = 0; i < outLen; i++) { - spectrum[i] = (float)Math.Sqrt(re[i] * re[i] + im[i] * im[i]) * factor; + spectrum[i] = Math.Min((float)Math.Sqrt(re[i] * re[i] + im[i] * im[i]) * factor, 1f); } } -} \ No newline at end of file +} 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 new file mode 100644 index 0000000..7f856f2 --- /dev/null +++ b/Util/Audio/Clip/AudioClipSource.cs @@ -0,0 +1,613 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using MelonLoader; + +namespace ADOFAIRenderer.Util.Audio.Clip; + +/// +/// Read position registered on an by one consumer (a playing voice or the spectrum generator). +/// For a windowed source, the chunk containing and the following chunks +/// ( chunks in total) are kept loaded. +/// +internal sealed class AudioCursor +{ + private readonly AudioClipSource _source; + private long _position; + private volatile bool _loop; + + internal AudioCursor(AudioClipSource source, long position, bool loop) + { + _source = source; + _position = position; + _loop = loop; + } + + /// + /// Frame in the source at the output sample rate (0 ~ TotalFrames - 1). + /// Written by the consumer that reads the source; read by the decoder thread. + /// Moving into another chunk wakes the decoder thread. + /// + public long Position + { + get => Interlocked.Read(ref _position); + set + { + var previous = Interlocked.Exchange(ref _position, value); + if (previous / _source.FramesPerChunk != value / _source.FramesPerChunk) + _source.OnCursorMoved(); + } + } + + /// When true, the chunk after the last chunk is chunk 0. + public bool Loop + { + get => _loop; + set + { + _loop = value; + _source.OnCursorMoved(); + } + } +} + +/// +/// One loaded chunk of an , held by a consumer between +/// and . +/// The chunk is not freed while held. +/// +internal readonly struct ChunkView +{ + /// Interleaved samples. Frame f of the source is at (f - StartFrame) * Channels. + public readonly float[] Data; + + /// Source frame of the first frame in . + public readonly long StartFrame; + + /// Valid frames in this chunk. Frames past the end of the source are not included. + public readonly int FrameCount; + + /// Channel count of the stored samples (same as the source clip). + public readonly int Channels; + + public readonly int ChunkIndex; + + internal ChunkView(float[] data, long startFrame, int frameCount, int channels, int chunkIndex) + { + Data = data; + StartFrame = startFrame; + FrameCount = frameCount; + Channels = channels; + ChunkIndex = chunkIndex; + } + + public bool IsValid => Data != null; + + public long EndFrame => StartFrame + FrameCount; + + public bool Contains(long frame) => frame >= StartFrame && frame < StartFrame + FrameCount; + + /// + /// Sample of mapped to output channel of . + /// Mapping: mono output averages all source channels; a mono source is copied to every output channel; + /// otherwise output channel c takes source channel c, and output channels beyond the source take source channel 0. + /// must be inside this chunk. + /// + public float GetSample(long frame, int outChannel, int outChannels) + { + var baseIndex = (int)(frame - StartFrame) * Channels; + if (Channels == 1) + return Data[baseIndex]; + + if (outChannels == 1) + { + var sum = 0f; + for (var c = 0; c < Channels; c++) + sum += Data[baseIndex + c]; + return sum / Channels; + } + + return Data[baseIndex + (outChannel < Channels ? outChannel : 0)]; + } +} + +/// +/// How an keeps its chunks. +/// +internal enum ChunkPolicy +{ + /// + /// Only the chunks required by registered cursors (current chunk and the next SlotCount - 1 chunks per cursor), + /// chunks requested by a waiting consumer, and chunks currently held are kept. Other chunks are freed and reloaded on demand. + /// Used for readers that can seek (files decoded with FFmpeg) when the clip is longer than one chunk. + /// + Windowed, + + /// + /// All chunks are loaded once, in order from chunk 0, starting when the source is created, and kept until the store is disposed. + /// Used for forward-only readers (clip data copied from Unity, released as it is consumed) and for clips of one chunk or less. + /// + Resident +} + +/// +/// Decoded audio of one clip, resampled to the output sample rate and stored in fixed-size chunks. +/// Channel count stays as in the clip. +/// +/// Chunks: chunk k covers output frames [k * FramesPerChunk, (k + 1) * FramesPerChunk), where +/// = / Channels (rounded down; a frame never spans two chunks). +/// Windowed chunks use pooled arrays of ChunkFloats floats; resident chunks use arrays of their exact length. +/// See for which chunks are kept. +/// +/// Threads: cursors and TryAcquire/Release may be used from any thread. +/// Loading and freeing run only on the store's decoder thread (), through +/// , and . +/// +internal sealed class AudioClipSource +{ + /// Floats per chunk array: 2^22 floats = 16 MiB. + public const int ChunkFloats = 1 << 22; + + /// Chunks kept per cursor of a windowed source: the chunk containing the cursor and the next SlotCount - 1 chunks. + public const int SlotCount = 3; + + /// Load priority of a chunk a consumer is waiting for (lowest value is loaded first). + internal const int PriorityDemanded = 0; + + /// Load priority of slot s of a cursor window is PriorityWindow + s. + internal const int PriorityWindow = 1; + + /// Load priority of resident chunks nobody is waiting for. + internal const int PriorityResident = PriorityWindow + SlotCount; + + /// + /// Most decode streams (reader + resampler) a windowed source opens. A load continues the stream positioned at the chunk start, + /// otherwise opens another stream up to this count, otherwise restarts the least recently used stream. + /// Each stream holds one open reader (file handle and decoder state, measured 0.3 ~ 2.7 MiB for ogg/flac/mp3 with the clip harness). + /// + public const int MaxDecodeStreams = 4; + + /// Decode stream limit of this source ( unless changed for tests). + internal int DecodeStreamLimit { get; set; } = MaxDecodeStreams; + + private readonly AudioClipStore _store; + private readonly IPCMReader _firstReader; + private readonly Func? _openReader; + + // Guarded by _sync: chunk table, hold counts, cursors, demand set, loaded set. + private readonly object _sync = new(); + private readonly float[]?[] _chunks; + private readonly int[] _holdCounts; + private readonly List _cursors = new(); + private readonly HashSet _demanded = new(); + private readonly HashSet _loaded = new(); + private int _residentLoadedCount; // Resident: chunks [0, _residentLoadedCount) are loaded + + // Guarded by _loadLock: decode streams. + private readonly object _loadLock = new(); + private readonly List _streams = new(); + private long _streamUseCounter; + private bool _openFailed; + + private volatile bool _isFailed; + + public string Key { get; } + + /// Channel count of the stored samples (same as the clip). + public int Channels { get; } + + /// Sample rate of the stored samples in Hz (the output sample rate). + public int Frequency { get; } + + /// Frame count at : ceil(clip frames * Frequency / clip rate). + public long TotalFrames { get; } + + /// Clip length in seconds: clip frames / clip rate. + public double Duration { get; } + + public int FramesPerChunk { get; } + + public int ChunkCount { get; } + + public ChunkPolicy Policy { get; } + + public bool IsResident => Policy == ChunkPolicy.Resident; + + /// True after loading failed. A failed source returns no chunks. + public bool IsFailed => _isFailed; + + /// True when every chunk of a resident source is loaded. + public bool IsFullyLoaded + { + get + { + lock (_sync) return _loaded.Count == ChunkCount; + } + } + + /// Number of chunk loads performed (for diagnostics). + public int LoadCount { get; private set; } + + /// Decode streams opened so far (for diagnostics). + public int StreamCount + { + get + { + lock (_loadLock) return _streams.Count; + } + } + + /// Store that owns this source. + /// Clip name or path the source is registered under. + /// First reader; also defines the clip format. + /// Opens another reader of the same clip for an additional decode stream, or null when not possible. + /// Output sample rate in Hz. + internal AudioClipSource(AudioClipStore store, string key, IPCMReader reader, Func? openReader, int outputRate) + { + if (reader.Channels <= 0) throw new ArgumentException("Reader channel count must be positive.", nameof(reader)); + if (reader.SampleRate <= 0) throw new ArgumentException("Reader sample rate must be positive.", nameof(reader)); + if (reader.TotalFrames < 0) throw new ArgumentException("Reader frame count must not be negative.", nameof(reader)); + + _store = store; + _firstReader = reader; + _openReader = openReader; + Key = key; + Channels = reader.Channels; + Frequency = outputRate; + Duration = (double)reader.TotalFrames / reader.SampleRate; + TotalFrames = CeilDiv(reader.TotalFrames * outputRate, reader.SampleRate); + FramesPerChunk = ChunkFloats / Channels; + ChunkCount = (int)CeilDiv(TotalFrames, FramesPerChunk); + Policy = reader.IsForwardOnly || ChunkCount <= 1 ? ChunkPolicy.Resident : ChunkPolicy.Windowed; + + _chunks = new float[ChunkCount][]; + _holdCounts = new int[ChunkCount]; + _streams.Add(new DecodeStream(reader, outputRate)); + } + + // ── Cursors ── + + /// Registers a read position. The decoder thread loads the chunks it requires. + public AudioCursor AddCursor(long position, bool loop) + { + var cursor = new AudioCursor(this, position, loop); + lock (_sync) + { + _cursors.Add(cursor); + } + _store.RequestService(); + return cursor; + } + + public void RemoveCursor(AudioCursor cursor) + { + lock (_sync) + { + _cursors.Remove(cursor); + } + _store.RequestService(); + } + + internal void OnCursorMoved() + { + if (!IsResident) _store.RequestService(); + } + + // ── Consumer access ── + + /// Chunk index containing (0 ~ TotalFrames - 1). + public int ChunkIndexOf(long frame) => (int)(frame / FramesPerChunk); + + /// + /// Holds the chunk containing when it is loaded. + /// When it is not loaded, records a load request (kept until the chunk is acquired), wakes the decoder thread and returns false. + /// Every successful call must be paired with . + /// + public bool TryAcquire(long frame, out ChunkView view) + { + view = default; + if (_isFailed || frame < 0 || frame >= TotalFrames) return false; + + var index = ChunkIndexOf(frame); + lock (_sync) + { + var data = _chunks[index]; + if (data != null) + { + _demanded.Remove(index); + _holdCounts[index]++; + view = CreateView(index, data); + return true; + } + _demanded.Add(index); + } + _store.RequestService(); + return false; + } + + public void Release(in ChunkView view) + { + if (!view.IsValid) return; + lock (_sync) + { + if (_holdCounts[view.ChunkIndex] > 0) + _holdCounts[view.ChunkIndex]--; + } + } + + private ChunkView CreateView(int index, float[] data) + { + var start = (long)index * FramesPerChunk; + var count = (int)Math.Min(FramesPerChunk, TotalFrames - start); + return new ChunkView(data, start, count, Channels, index); + } + + // ── Decoder thread ── + + /// + /// Windowed: frees loaded chunks that are not required (see ) and not held. + /// Resident: does nothing. Decoder thread only. + /// + internal void FreeUnused() + { + if (IsResident) return; + + List? freed = null; + lock (_sync) + { + if (_loaded.Count == 0) return; + List? remove = null; + foreach (var index in _loaded) + { + if (_holdCounts[index] > 0 || IsRequired(index)) continue; + (remove ??= new List()).Add(index); + } + if (remove == null) return; + + foreach (var index in remove) + { + (freed ??= new List()).Add(_chunks[index]!); + _chunks[index] = null; + _loaded.Remove(index); + } + } + + foreach (var data in freed!) + _store.ReturnChunkArray(data); + } + + /// + /// Next chunk to load and its priority (lower loads first), or false when nothing is missing. + /// Windowed: demanded chunks, then each cursor's window by slot. + /// Resident: the next chunk in order; priority is while a consumer is waiting on this source. + /// Decoder thread only. + /// + internal bool TryGetNextLoad(out int index, out int priority) + { + index = -1; + priority = int.MaxValue; + if (_isFailed) return false; + + lock (_sync) + { + if (IsResident) + { + if (_residentLoadedCount >= ChunkCount) return false; + index = _residentLoadedCount; + priority = _demanded.Count > 0 ? PriorityDemanded : PriorityResident; + return true; + } + + foreach (var demanded in _demanded) + { + if (_chunks[demanded] != null) continue; + index = demanded; + priority = PriorityDemanded; + return true; + } + + foreach (var cursor in _cursors) + { + if (!TryGetWindowStart(cursor, out var first, out var loop)) continue; + for (var slot = 0; slot < SlotCount; slot++) + { + if (!TryWindowIndex(first, slot, loop, out var candidate)) break; + if (_chunks[candidate] != null) continue; + if (PriorityWindow + slot < priority) + { + index = candidate; + priority = PriorityWindow + slot; + } + break; + } + } + return index >= 0; + } + } + + /// True when a demand or a cursor window covers . Caller holds _sync. + private bool IsRequired(int index) + { + if (_demanded.Contains(index)) return true; + foreach (var cursor in _cursors) + { + if (!TryGetWindowStart(cursor, out var first, out var loop)) continue; + for (var slot = 0; slot < SlotCount; slot++) + { + if (!TryWindowIndex(first, slot, loop, out var candidate)) break; + if (candidate == index) return true; + } + } + return false; + } + + private bool TryGetWindowStart(AudioCursor cursor, out int first, out bool loop) + { + var position = cursor.Position; + loop = cursor.Loop; + first = 0; + if (position < 0) position = 0; + if (position >= TotalFrames) + { + if (!loop || TotalFrames == 0) return false; + position %= TotalFrames; + } + first = ChunkIndexOf(position); + return true; + } + + private bool TryWindowIndex(int first, int slot, bool loop, out int index) + { + index = first + slot; + if (index < ChunkCount) return true; + if (!loop) return false; + index %= ChunkCount; + return true; + } + + /// + /// Loads chunk if it is not loaded. Resident sources load only the next chunk in order. + /// Decoder thread only. + /// + /// True when this call loaded the chunk. + internal bool LoadChunk(int index) + { + if (_isFailed || index < 0 || index >= ChunkCount) return false; + + lock (_loadLock) + { + lock (_sync) + { + if (_chunks[index] != null) return false; + if (IsResident && index != _residentLoadedCount) + throw new InvalidOperationException($"Resident clip '{Key}' loads chunks in order: expected {_residentLoadedCount}, got {index}."); + } + + float[]? data = null; + try + { + var start = (long)index * FramesPerChunk; + var frames = (int)Math.Min(FramesPerChunk, TotalFrames - start); + data = IsResident ? _store.AllocateExactArray(frames * Channels) : _store.RentChunkArray(); + var stream = SelectStream(start); + stream.LastUse = ++_streamUseCounter; + stream.Fill(start, frames, data); + } + catch (OutOfMemoryException e) + { + ReturnArray(data); + Fail(); + _store.ReportFatal(this, e); + return false; + } + catch (Exception e) + { + ReturnArray(data); + Fail(); + _store.ReportSourceFailure(this, e); + return false; + } + + lock (_sync) + { + _chunks[index] = data; + _loaded.Add(index); + if (IsResident) _residentLoadedCount++; + } + LoadCount++; + + // A resident source never reads again after its last chunk; the reader releases its data now. + if (IsResident && _residentLoadedCount == ChunkCount) + DisposeStreams(); + } + + _store.NotifyChunkLoaded(); + return true; + } + + /// + /// Decode stream for a load starting at output frame : + /// the stream positioned at ; otherwise a new stream while fewer than + /// are open (windowed sources only); otherwise the least recently used stream. Caller holds _loadLock. + /// + private DecodeStream SelectStream(long start) + { + DecodeStream? oldest = null; + foreach (var stream in _streams) + { + if (stream.NextOutFrame == start) return stream; + if (oldest == null || stream.LastUse < oldest.LastUse) oldest = stream; + } + + if (!IsResident && !_openFailed && _openReader != null && _streams.Count < DecodeStreamLimit) + { + IPCMReader? reader = null; + try + { + reader = _openReader(); + if (reader is { IsForwardOnly: true }) + { + reader.Dispose(); + reader = null; + } + if (reader != null) + { + var stream = new DecodeStream(reader, Frequency); + _streams.Add(stream); + return stream; + } + } + catch (Exception e) + { + reader?.Dispose(); + Melon.Logger.Warning($"[AudioClipSource] Could not open another decode stream for '{Key}', reusing open streams: {e.Message}"); + } + _openFailed = true; + } + + return oldest!; + } + + private void Fail() + { + _isFailed = true; + if (IsResident) DisposeStreams(); + } + + private void DisposeStreams() + { + foreach (var stream in _streams) + stream.Dispose(); + _streams.Clear(); + } + + private void ReturnArray(float[]? data) + { + if (data == null) return; + if (IsResident) _store.FreeExactArray(data); + else _store.ReturnChunkArray(data); + } + + /// Releases all chunk arrays and readers. Called by the store after the decoder thread stopped. + internal void DisposeInternal() + { + lock (_loadLock) + { + lock (_sync) + { + foreach (var index in _loaded) + { + ReturnArray(_chunks[index]); + _chunks[index] = null; + } + _loaded.Clear(); + _cursors.Clear(); + _demanded.Clear(); + } + DisposeStreams(); + _firstReader.Dispose(); + } + } + + private static long CeilDiv(long a, long b) => (a + b - 1) / b; +} diff --git a/Util/Audio/Clip/AudioClipStore.cs b/Util/Audio/Clip/AudioClipStore.cs new file mode 100644 index 0000000..631c41e --- /dev/null +++ b/Util/Audio/Clip/AudioClipStore.cs @@ -0,0 +1,348 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using MelonLoader; + +namespace ADOFAIRenderer.Util.Audio.Clip; + +/// +/// Clip sources of one render, keyed by clip name or path, with a shared pool of chunk arrays and one decoder thread. +/// +/// Threads: +/// - Decoder thread (): the only thread that decodes, resamples, loads and frees chunks. +/// Each step loads one chunk, the one with the lowest priority value over all sources +/// (a chunk a consumer waits for, then cursor windows by slot, then resident chunks in order). +/// - Consumers (render thread, main thread for the spectrum): waits for the decoder thread and never loads. +/// - Main thread: creates readers (for Unity clips this copies the clip data). +/// +/// Memory has no limit; current and peak usage are tracked and logged by . +/// An while loading is kept in and rethrown by . +/// +internal sealed class AudioClipStore : IDisposable +{ + /// Longest wait of the decoder thread between checks when nothing signals it, in milliseconds. + private const int DecoderIdleWaitMs = 50; + + /// 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(); + private readonly object _poolLock = new(); + private readonly object _signal = new(); + private readonly AutoResetEvent _serviceRequested = new(false); + + private Thread? _decoderThread; + private volatile bool _stopRequested; + private long _allocatedBytes; + private long _peakBytes; + private volatile Exception? _fatalError; + private volatile bool _disposed; + + /// Sample rate every source is resampled to, in Hz. + public int OutputRate { get; } + + /// Bytes of sample arrays currently allocated (loaded chunks, pooled chunk arrays and copied Unity clip data). + public long AllocatedBytes => Interlocked.Read(ref _allocatedBytes); + + /// Highest value of since creation. + public long PeakBytes => Interlocked.Read(ref _peakBytes); + + /// Out-of-memory error raised while loading, or null. + public Exception? FatalError => _fatalError; + + public AudioClipStore(int outputRate) + { + if (outputRate <= 0) throw new ArgumentOutOfRangeException(nameof(outputRate)); + OutputRate = outputRate; + } + + // ── Decoder thread ── + + /// Starts the decoder thread. Called once. + public void Start() + { + if (_decoderThread != null) throw new InvalidOperationException("Decoder thread already started."); + _decoderThread = new Thread(DecoderLoop) + { + Name = "Audio Clip Decoder", + IsBackground = true + }; + _decoderThread.Start(); + } + + private void DecoderLoop() + { + try + { + while (!_stopRequested) + { + if (!LoadNext()) + _serviceRequested.WaitOne(DecoderIdleWaitMs); + } + } + catch (Exception e) + { + Melon.Logger.Error($"[AudioClipStore] Decoder thread stopped: {e}"); + _fatalError ??= e; + NotifyChunkLoaded(); + } + } + + /// + /// Frees unused windowed chunks, then loads the single chunk with the lowest priority value over all sources. + /// Runs on the decoder thread (tests may call it directly while the thread is not started). + /// + /// True when a chunk was loaded or a load was attempted. + internal bool LoadNext() + { + AudioClipSource[] sources; + lock (_sources) + { + if (_disposed) return false; + sources = _sourceList.ToArray(); + } + + AudioClipSource? best = null; + var bestIndex = -1; + var bestPriority = int.MaxValue; + foreach (var source in sources) + { + source.FreeUnused(); + if (!source.TryGetNextLoad(out var index, out var priority) || priority >= bestPriority) continue; + best = source; + bestIndex = index; + bestPriority = priority; + } + + if (best == null) return false; + best.LoadChunk(bestIndex); + return true; + } + + internal void RequestService() => _serviceRequested.Set(); + + // ── Sources ── + + /// + /// 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, additional decode streams (see ) are opened on the + /// decoder thread with , or with when it is null. + /// + public AudioClipSource? GetOrAdd(string key, Func createReader, Func? openAdditionalReader = null) + { + lock (_sources) + { + if (_disposed) return null; + if (_sources.TryGetValue(key, out var existing)) return existing; + } + + AudioClipSource? source = null; + try + { + var reader = createReader(); + if (reader != null) + { + try + { + source = new AudioClipSource(this, key, reader, reader.IsForwardOnly ? null : openAdditionalReader ?? createReader, OutputRate); + } + catch + { + reader.Dispose(); + throw; + } + } + } + catch (OutOfMemoryException e) + { + Melon.Logger.Error($"[AudioClipStore] Out of memory while opening clip '{key}' ({AllocatedBytes / 1048576.0:F1} MiB allocated): {e}"); + _fatalError ??= e; + } + catch (Exception e) + { + Melon.Logger.Error($"[AudioClipStore] Failed to open clip '{key}': {e}"); + } + + lock (_sources) + { + if (_disposed || _sources.TryGetValue(key, out var raced)) + { + source?.DisposeInternal(); + return _disposed ? null : _sources[key]; + } + _sources[key] = source; + if (source != null) _sourceList.Add(source); + } + + if (source != null) + { + Melon.Logger.Msg($"[AudioClipStore] Opened clip '{key}': {source.Channels}ch, {source.Duration:F3}s, {source.TotalFrames} frames at {OutputRate}Hz, {source.ChunkCount} chunk(s), {source.Policy}"); + RequestService(); + } + return source; + } + + public bool TryGet(string key, out AudioClipSource? source) + { + lock (_sources) + { + return _sources.TryGetValue(key, out source); + } + } + + // ── Consumers ── + + /// + /// Holds the chunk containing , waiting for the decoder thread until it is loaded. + /// Returns an invalid view when the source failed, the frame is outside the source, or the store was disposed. + /// + /// Loading ran out of memory or the decoder thread stopped. + /// was canceled while waiting. + public ChunkView Acquire(AudioClipSource source, long frame, CancellationToken cancellationToken = default) + { + if (frame < 0 || frame >= source.TotalFrames) return default; + + while (true) + { + ThrowIfFatal(); + if (source.IsFailed || _disposed) return default; + if (source.TryAcquire(frame, out var view)) return view; + + lock (_signal) + { + if (source.TryAcquire(frame, out view)) return view; + if (source.IsFailed || _fatalError != null || _disposed) continue; + Monitor.Wait(_signal, ConsumerWaitMs); + } + cancellationToken.ThrowIfCancellationRequested(); + } + } + + private void ThrowIfFatal() + { + var error = _fatalError; + if (error != null) + throw new InvalidOperationException("Audio clip loading failed: " + error.Message, error); + } + + internal void NotifyChunkLoaded() + { + lock (_signal) + { + Monitor.PulseAll(_signal); + } + } + + internal void ReportSourceFailure(AudioClipSource source, Exception error) + { + Melon.Logger.Error($"[AudioClipStore] Failed to load clip '{source.Key}', it will be silent: {error}"); + NotifyChunkLoaded(); + } + + internal void ReportFatal(AudioClipSource source, OutOfMemoryException error) + { + Melon.Logger.Error($"[AudioClipStore] Out of memory while loading clip '{source.Key}' ({AllocatedBytes / 1048576.0:F1} MiB allocated): {error}"); + _fatalError ??= error; + NotifyChunkLoaded(); + } + + // ── Arrays ── + + internal float[] RentChunkArray() + { + lock (_poolLock) + { + if (_chunkPool.Count > 0) return _chunkPool.Pop(); + } + var data = new float[AudioClipSource.ChunkFloats]; + AddAllocated(data.LongLength * sizeof(float)); + return data; + } + + internal void ReturnChunkArray(float[] data) + { + if (_disposed) + { + AddAllocated(-data.LongLength * sizeof(float)); + return; + } + lock (_poolLock) + { + _chunkPool.Push(data); + } + } + + internal float[] AllocateExactArray(int floats) + { + var data = new float[floats]; + AddAllocated(data.LongLength * sizeof(float)); + return data; + } + + internal void FreeExactArray(float[] data) => AddAllocated(-data.LongLength * sizeof(float)); + + private void AddAllocated(long bytes) + { + var current = Interlocked.Add(ref _allocatedBytes, bytes); + long peak; + while (current > (peak = Interlocked.Read(ref _peakBytes))) + { + if (Interlocked.CompareExchange(ref _peakBytes, current, peak) == peak) break; + } + } + + public void LogMemoryUsage() + { + int sourceCount; + lock (_sources) sourceCount = _sourceList.Count; + Melon.Logger.Msg($"[AudioClipStore] Clips: {sourceCount}, sample memory now {AllocatedBytes / 1048576.0:F1} MiB, peak {PeakBytes / 1048576.0:F1} MiB"); + } + + /// Stops the decoder thread, then releases every source and pooled array. + public void Dispose() + { + AudioClipSource[] sources; + lock (_sources) + { + if (_disposed) return; + _disposed = true; + sources = _sourceList.ToArray(); + } + + _stopRequested = true; + _serviceRequested.Set(); + NotifyChunkLoaded(); + + // 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(); + + lock (_sources) + { + _sourceList.Clear(); + _sources.Clear(); + } + + if (decoderStopped) + { + foreach (var source in sources) + source.DisposeInternal(); + } + + lock (_poolLock) + { + while (_chunkPool.Count > 0) + AddAllocated(-_chunkPool.Pop().LongLength * sizeof(float)); + } + } +} diff --git a/Util/Audio/Clip/DecodeStream.cs b/Util/Audio/Clip/DecodeStream.cs new file mode 100644 index 0000000..9ff5bf8 --- /dev/null +++ b/Util/Audio/Clip/DecodeStream.cs @@ -0,0 +1,181 @@ +using System; + +namespace ADOFAIRenderer.Util.Audio.Clip; + +/// +/// One reader plus its resampler, producing output-rate frames of a clip as a continuous stream. +/// Consecutive calls whose ranges follow each other continue the stream (reader position and resampler state); +/// any other range starts a new stream near the requested position. +/// +/// Restart: the input position is moved back by a preroll and down to a multiple of in_rate / gcd(in_rate, out_rate), +/// where input frame k * (in_rate / gcd) maps exactly to output frame k * (out_rate / gcd); +/// the resampler is reset and output before the requested frame is discarded. +/// Measured with the clip harness: output is bit-identical to one continuous conversion of the whole clip. +/// Used by one thread at a time (the store's decoder thread). +/// +internal sealed class DecodeStream : IDisposable +{ + /// Input frames read from the reader per resampler call. + private const int ReadBlockFrames = 4096; + + private readonly PCMResampler? _resampler; + private readonly int _channels; + private readonly int _outputRate; + private readonly float[] _readBuffer; + private float[]? _discardBuffer; + private bool _flushing; + private bool _ended; + + private readonly long _inputStep; + private readonly long _outputStep; + private readonly long _prerollInputFrames; + + public IPCMReader Reader { get; } + + /// Output frame the stream continues at, or -1 when the stream is not positioned. + public long NextOutFrame { get; private set; } = -1; + + /// Value of the owner's use counter at the last (for least-recently-used selection). + public long LastUse { get; set; } + + /// Number of times the stream started anew (seek + resampler reset). + public int RestartCount { get; private set; } + + public DecodeStream(IPCMReader reader, int outputRate) + { + Reader = reader; + _channels = reader.Channels; + _outputRate = outputRate; + _readBuffer = new float[ReadBlockFrames * _channels]; + + if (reader.SampleRate != outputRate) + { + _resampler = new PCMResampler(_channels, reader.SampleRate, outputRate); + var gcd = GCD(reader.SampleRate, outputRate); + _inputStep = reader.SampleRate / gcd; + _outputStep = outputRate / gcd; + + // History fed before the first kept output frame when a stream starts mid-clip. + // The filter spans about filter_size input frames per output frame at ratios up to 1:1 + // and filter_size * (input rate / output rate) input frames when downsampling. + var ratio = Math.Max(1L, (reader.SampleRate + outputRate - 1L) / outputRate); + _prerollInputFrames = 4L * _resampler.FilterSize * ratio + _inputStep; + } + else + { + _inputStep = 1; + _outputStep = 1; + } + } + + /// + /// Writes output frames [start, start + frames) into and zeros the rest of the array. + /// Frames past the end of the decoded input are zero. + /// + public unsafe void Fill(long start, int frames, float[] data) + { + var channels = _channels; + var filled = 0; + + if (_resampler == null) + { + if (Reader.Position != start) + { + Reader.Seek(start); + RestartCount++; + } + while (filled < frames) + { + var request = Math.Min(ReadBlockFrames, frames - filled); + var read = Reader.Read(_readBuffer, request); + if (read <= 0) break; + Array.Copy(_readBuffer, 0, data, filled * channels, read * channels); + filled += read; + } + Array.Clear(data, filled * channels, data.Length - filled * channels); + NextOutFrame = start + frames; + return; + } + + long discard = 0; + if (NextOutFrame != start) + { + // Input frame of the requested output frame, moved back by the preroll and down to a multiple of _inputStep. + var inputFrame = start * Reader.SampleRate / _outputRate - _prerollInputFrames; + var step = inputFrame > 0 ? inputFrame / _inputStep : 0; + + _resampler.Reset(); + Reader.Seek(step * _inputStep); + _flushing = false; + _ended = false; + discard = start - step * _outputStep; + RestartCount++; + } + + fixed (float* pIn = _readBuffer) + fixed (float* pOut = data) + { + while (discard > 0 && !_ended) + { + _discardBuffer ??= new float[ReadBlockFrames * channels]; + fixed (float* pDiscard = _discardBuffer) + { + var capacity = (int)Math.Min(ReadBlockFrames, discard); + discard -= Pull(pIn, pDiscard, capacity); + } + } + + while (filled < frames && !_ended) + filled += Pull(pIn, pOut + (long)filled * channels, frames - filled); + } + + Array.Clear(data, filled * channels, data.Length - filled * channels); + NextOutFrame = _ended ? -1 : start + frames; + } + + /// + /// Writes 1 ~ resampled frames to , reading input as needed. + /// Returns 0 and sets _ended once the input and the resampler's flushed tail are exhausted. + /// + private unsafe int Pull(float* input, float* output, int capacity) + { + var resampler = _resampler!; + if (!_flushing) + { + while (true) + { + var produced = resampler.Convert(input, 0, output, capacity); + if (produced > 0) return produced; + + var read = Reader.Read(_readBuffer, ReadBlockFrames); + if (read <= 0) + { + _flushing = true; + break; + } + + produced = resampler.Convert(input, read, output, capacity); + if (produced > 0) return produced; + } + } + + // Input is exhausted: take the resampler's remaining tail until it returns 0. + var flushed = resampler.Flush(output, capacity); + if (flushed > 0) return flushed; + + _ended = true; + return 0; + } + + public void Dispose() + { + _resampler?.Dispose(); + Reader.Dispose(); + } + + private static int GCD(int a, int b) + { + while (b != 0) (a, b) = (b, a % b); + return a; + } +} diff --git a/Util/Audio/Clip/FFmpegPCMReader.cs b/Util/Audio/Clip/FFmpegPCMReader.cs new file mode 100644 index 0000000..3690b6a --- /dev/null +++ b/Util/Audio/Clip/FFmpegPCMReader.cs @@ -0,0 +1,390 @@ +using System; +using System.Runtime.InteropServices; +using FFmpeg.AutoGen; + +namespace ADOFAIRenderer.Util.Audio.Clip; + +/// +/// that decodes the best audio stream of a file with libavformat/libavcodec. +/// Output keeps the stream's sample rate and channel count; libswresample converts only the sample format to interleaved float. +/// +/// Frame positions: frame 0 is the first sample the decoder outputs for the stream's start time. +/// The position of the first frame decoded after opening or seeking is taken from its timestamp +/// (best_effort_timestamp - stream start_time, in samples); following frames are counted sequentially. +/// Seek moves the demuxer to before the target and discards decoded samples before the target. +/// +/// comes from the stream duration (or the container duration) and may differ from the decoded length; +/// reads stop at whichever ends first. +/// +/// MP3: decoded with the game's timing (MP3Sharp, see ): encoder delay and padding are kept +/// (AV_CODEC_FLAG2_SKIP_MANUAL), frame 0 is the first sample of the first audio frame, and one frame of silence is placed +/// before it when the file starts with a Xing/Info/VBRI header frame. is the sum of all packet +/// durations plus that silence, counted once when the file is opened. +/// Reference: https://ffmpeg.org/doxygen/trunk/group__lavc__decoding.html (send/receive API) +/// Reference: https://ffmpeg.org/doxygen/trunk/group__lavf__decoding.html (av_read_frame, av_seek_frame) +/// +internal sealed unsafe class FFmpegPCMReader : IPCMReader +{ + /// + /// Time the demuxer is moved before a seek target, in seconds. Decoders that depend on previous packets + /// (for example the MP3 bit reservoir) produce settled output after this margin; the samples in it are discarded. + /// + private const double SeekMarginSeconds = 0.25; + + private AVFormatContext* _format; + private AVCodecContext* _codec; + private SwrContext* _swr; + private AVPacket* _packet; + private AVFrame* _frame; + private readonly int _streamIndex; + private readonly AVRational _timeBase; + private readonly long _ptsOrigin; // Stream timestamp of output frame _leadFrames + private readonly long _leadFrames; // Silent frames placed before the first decoded frame (MP3 header frame, see class summary) + + // Decoded samples not yet returned: frames [_pendingStart, _pendingStart + _pendingCount) at _pending[_pendingOffset * Channels]. + private float[] _pending = Array.Empty(); + private int _pendingOffset; + private int _pendingCount; + private long _pendingStart; + + private long _nextDecodedFrame; // Frame index of the next decoded frame when counting sequentially + private bool _syncToTimestamp; // True until the first frame after open/seek sets _nextDecodedFrame from its timestamp + private bool _flushSent; + private bool _endOfStream; + + public string Path { get; } + public int Channels { get; } + public int SampleRate { get; } + public long TotalFrames { get; } + public bool IsForwardOnly => false; + public long Position { get; private set; } + + /// True when the stream is MP3 and is decoded with the game's MP3 timing (see class summary). + public bool IsMP3 { get; } + + /// Number of demuxer seeks performed (for diagnostics). + public int SeekCount { get; private set; } + + public FFmpegPCMReader(string path) + { + Path = path; + try + { + AVFormatContext* format = null; + var ret = ffmpeg.avformat_open_input(&format, path, null, null); + if (ret < 0) throw new InvalidOperationException($"Failed to open audio file '{path}': {ErrorString(ret)}"); + _format = format; + + ret = ffmpeg.avformat_find_stream_info(_format, null); + if (ret < 0) throw new InvalidOperationException($"Failed to read stream info of '{path}': {ErrorString(ret)}"); + + AVCodec* decoder = null; + _streamIndex = ffmpeg.av_find_best_stream(_format, AVMediaType.AVMEDIA_TYPE_AUDIO, -1, -1, &decoder, 0); + if (_streamIndex < 0 || decoder == null) throw new InvalidOperationException($"No decodable audio stream in '{path}': {ErrorString(_streamIndex)}"); + + for (var i = 0; i < (int)_format->nb_streams; i++) + { + if (i != _streamIndex) _format->streams[i]->discard = AVDiscard.AVDISCARD_ALL; + } + + var stream = _format->streams[_streamIndex]; + _timeBase = stream->time_base; + _ptsOrigin = stream->start_time != ffmpeg.AV_NOPTS_VALUE ? stream->start_time : 0; + + _codec = ffmpeg.avcodec_alloc_context3(decoder); + if (_codec == null) throw new InvalidOperationException("avcodec_alloc_context3 failed."); + ret = ffmpeg.avcodec_parameters_to_context(_codec, stream->codecpar); + if (ret < 0) throw new InvalidOperationException($"avcodec_parameters_to_context failed: {ErrorString(ret)}"); + _codec->pkt_timebase = _timeBase; + IsMP3 = stream->codecpar->codec_id == AVCodecID.AV_CODEC_ID_MP3; + if (IsMP3) + { + // Keep the encoder delay and padding the decoder would otherwise trim (exported as side data instead). + // Reference: https://ffmpeg.org/doxygen/trunk/group__lavc__core.html (AV_CODEC_FLAG2_SKIP_MANUAL) + _codec->flags2 |= ffmpeg.AV_CODEC_FLAG2_SKIP_MANUAL; + } + ret = ffmpeg.avcodec_open2(_codec, decoder, null); + if (ret < 0) throw new InvalidOperationException($"Failed to open audio decoder for '{path}': {ErrorString(ret)}"); + + Channels = _codec->ch_layout.nb_channels; + SampleRate = _codec->sample_rate; + if (Channels <= 0 || SampleRate <= 0) throw new InvalidOperationException($"Invalid audio format in '{path}': {Channels}ch {SampleRate}Hz."); + + if (IsMP3) + { + _leadFrames = MP3HeaderFrame.SilentFrames(path); + ScanPackets(out _ptsOrigin, out var decodedFrames); + TotalFrames = _leadFrames + decodedFrames; + } + else + { + TotalFrames = EstimateTotalFrames(stream); + } + + SwrContext* swr = null; + ret = ffmpeg.swr_alloc_set_opts2(&swr, + &_codec->ch_layout, AVSampleFormat.AV_SAMPLE_FMT_FLT, SampleRate, + &_codec->ch_layout, _codec->sample_fmt, SampleRate, 0, null); + if (ret < 0 || swr == null) throw new InvalidOperationException($"swr_alloc_set_opts2 failed: {ErrorString(ret)}"); + _swr = swr; + ret = ffmpeg.swr_init(_swr); + if (ret < 0) throw new InvalidOperationException($"swr_init failed: {ErrorString(ret)}"); + + _packet = ffmpeg.av_packet_alloc(); + _frame = ffmpeg.av_frame_alloc(); + if (_packet == null || _frame == null) throw new InvalidOperationException("Failed to allocate packet/frame."); + + _syncToTimestamp = true; + } + catch + { + Dispose(); + throw; + } + } + + /// + /// Reads every packet of the audio stream once (no decoding) and returns the first packet's timestamp and the summed + /// packet durations in frames, then moves the demuxer back to the first packet. Used for MP3, where every packet decodes + /// to a full frame when trimming is off. + /// + private void ScanPackets(out long firstPts, out long frames) + { + firstPts = ffmpeg.AV_NOPTS_VALUE; + long durationTicks = 0; + var packet = ffmpeg.av_packet_alloc(); + try + { + int ret; + while ((ret = ffmpeg.av_read_frame(_format, packet)) >= 0) + { + if (packet->stream_index == _streamIndex) + { + if (firstPts == ffmpeg.AV_NOPTS_VALUE && packet->pts != ffmpeg.AV_NOPTS_VALUE) firstPts = packet->pts; + durationTicks += packet->duration; + } + ffmpeg.av_packet_unref(packet); + } + if (ret != ffmpeg.AVERROR_EOF) + throw new InvalidOperationException($"Failed to read '{Path}': {ErrorString(ret)}"); + } + finally + { + ffmpeg.av_packet_free(&packet); + } + + if (firstPts == ffmpeg.AV_NOPTS_VALUE) firstPts = 0; + frames = ffmpeg.av_rescale_q(durationTicks, _timeBase, new AVRational { num = 1, den = SampleRate }); + + var seek = ffmpeg.av_seek_frame(_format, _streamIndex, firstPts, ffmpeg.AVSEEK_FLAG_BACKWARD); + if (seek < 0) + throw new InvalidOperationException($"Failed to rewind '{Path}': {ErrorString(seek)}"); + } + + private long EstimateTotalFrames(AVStream* stream) + { + if (stream->duration != ffmpeg.AV_NOPTS_VALUE && stream->duration > 0) + return ffmpeg.av_rescale_q(stream->duration, _timeBase, new AVRational { num = 1, den = SampleRate }); + if (_format->duration != ffmpeg.AV_NOPTS_VALUE && _format->duration > 0) + return ffmpeg.av_rescale(_format->duration, SampleRate, ffmpeg.AV_TIME_BASE); + throw new InvalidOperationException($"Audio file '{Path}' has no duration."); + } + + public void Seek(long frame) + { + if (frame < 0 || frame > TotalFrames) throw new ArgumentOutOfRangeException(nameof(frame)); + if (frame == Position) return; + + var marginFrames = (long)(SeekMarginSeconds * SampleRate); + + // Target inside the pending block or less than the seek margin after it: decode forward and discard. + if (!_syncToTimestamp && frame >= _pendingStart + _pendingOffset + && frame <= _pendingStart + _pendingOffset + _pendingCount + marginFrames) + { + Position = frame; + return; + } + + var targetTs = _ptsOrigin + ffmpeg.av_rescale_q(Math.Max(0, frame - _leadFrames - marginFrames), new AVRational { num = 1, den = SampleRate }, _timeBase); + var ret = ffmpeg.av_seek_frame(_format, _streamIndex, targetTs, ffmpeg.AVSEEK_FLAG_BACKWARD); + if (ret < 0) + ret = ffmpeg.av_seek_frame(_format, _streamIndex, _ptsOrigin, ffmpeg.AVSEEK_FLAG_BACKWARD); + if (ret < 0) + throw new InvalidOperationException($"Failed to seek '{Path}' to frame {frame}: {ErrorString(ret)}"); + + ffmpeg.avcodec_flush_buffers(_codec); + SeekCount++; + _pendingCount = 0; + _pendingOffset = 0; + _syncToTimestamp = true; + _flushSent = false; + _endOfStream = false; + Position = frame; + } + + public int Read(float[] buffer, int frameCount) + { + var limit = (int)Math.Min(frameCount, TotalFrames - Position); + var written = 0; + while (written < limit) + { + if (_pendingCount == 0 || Position >= _pendingStart + _pendingOffset + _pendingCount) + { + if (!DecodeNextFrame()) break; + continue; + } + + var pendingPosition = _pendingStart + _pendingOffset; + if (Position > pendingPosition) + { + // Discard decoded samples before the read position (after a seek). + var skip = (int)Math.Min(Position - pendingPosition, _pendingCount); + _pendingOffset += skip; + _pendingCount -= skip; + continue; + } + if (Position < pendingPosition) + { + // A gap in the decoded timestamps: fill with silence up to the next decoded sample. + var gap = (int)Math.Min(pendingPosition - Position, limit - written); + Array.Clear(buffer, written * Channels, gap * Channels); + written += gap; + Position += gap; + continue; + } + + var count = Math.Min(_pendingCount, limit - written); + Array.Copy(_pending, _pendingOffset * Channels, buffer, written * Channels, count * Channels); + _pendingOffset += count; + _pendingCount -= count; + written += count; + Position += count; + } + return written; + } + + /// Decodes the next audio frame into the pending buffer. Returns false at the end of the stream. + private bool DecodeNextFrame() + { + while (!_endOfStream) + { + var ret = ffmpeg.avcodec_receive_frame(_codec, _frame); + if (ret == 0) + { + StoreFrame(); + ffmpeg.av_frame_unref(_frame); + return true; + } + if (ret == ffmpeg.AVERROR_EOF) + { + _endOfStream = true; + break; + } + if (ret != ffmpeg.AVERROR(ffmpeg.EAGAIN)) + throw new InvalidOperationException($"Audio decoding failed in '{Path}': {ErrorString(ret)}"); + + if (_flushSent) + { + _endOfStream = true; + break; + } + + ret = ffmpeg.av_read_frame(_format, _packet); + if (ret < 0) + { + if (ret != ffmpeg.AVERROR_EOF) + throw new InvalidOperationException($"Failed to read '{Path}': {ErrorString(ret)}"); + ffmpeg.avcodec_send_packet(_codec, null); + _flushSent = true; + continue; + } + + try + { + if (_packet->stream_index != _streamIndex) continue; + ret = ffmpeg.avcodec_send_packet(_codec, _packet); + // A corrupt packet is skipped; decoding continues with the next one. + if (ret < 0 && ret != ffmpeg.AVERROR_INVALIDDATA && ret != ffmpeg.AVERROR(ffmpeg.EAGAIN)) + throw new InvalidOperationException($"Failed to send audio packet of '{Path}': {ErrorString(ret)}"); + } + finally + { + ffmpeg.av_packet_unref(_packet); + } + } + return false; + } + + private void StoreFrame() + { + var samples = _frame->nb_samples; + if (samples <= 0) return; + + if (_syncToTimestamp) + { + var pts = _frame->best_effort_timestamp; + _nextDecodedFrame = pts != ffmpeg.AV_NOPTS_VALUE + ? _leadFrames + ffmpeg.av_rescale_q(pts - _ptsOrigin, _timeBase, new AVRational { num = 1, den = SampleRate }) + : Position; + _syncToTimestamp = false; + } + + if (_pending.Length < samples * Channels) + _pending = new float[samples * Channels]; + + fixed (float* pPending = _pending) + { + var outPlane = (byte*)pPending; + var converted = ffmpeg.swr_convert(_swr, &outPlane, samples, _frame->extended_data, samples); + if (converted < 0) + throw new InvalidOperationException($"Sample format conversion failed in '{Path}': {ErrorString(converted)}"); + samples = converted; + } + + _pendingStart = _nextDecodedFrame; + _pendingOffset = 0; + _pendingCount = samples; + _nextDecodedFrame += samples; + } + + public void Dispose() + { + if (_frame != null) + { + var frame = _frame; + ffmpeg.av_frame_free(&frame); + _frame = null; + } + if (_packet != null) + { + var packet = _packet; + ffmpeg.av_packet_free(&packet); + _packet = null; + } + if (_swr != null) + { + var swr = _swr; + ffmpeg.swr_free(&swr); + _swr = null; + } + if (_codec != null) + { + var codec = _codec; + ffmpeg.avcodec_free_context(&codec); + _codec = null; + } + if (_format != null) + { + var format = _format; + ffmpeg.avformat_close_input(&format); + _format = null; + } + } + + private static string ErrorString(int error) + { + var buffer = stackalloc byte[1024]; + ffmpeg.av_strerror(error, buffer, 1024); + return $"{Marshal.PtrToStringAnsi((IntPtr)buffer)} ({error})"; + } +} diff --git a/Util/Audio/Clip/IPCMReader.cs b/Util/Audio/Clip/IPCMReader.cs new file mode 100644 index 0000000..6561218 --- /dev/null +++ b/Util/Audio/Clip/IPCMReader.cs @@ -0,0 +1,42 @@ +using System; + +namespace ADOFAIRenderer.Util.Audio.Clip; + +/// +/// Sequential reader of decoded PCM at the clip's own sample rate and channel count. +/// Samples are interleaved 32-bit float. Positions and counts are in frames (one frame = one sample per channel). +/// Used by one thread at a time (the store's decoder thread). may be called more than once. +/// +internal interface IPCMReader : IDisposable +{ + /// Channel count of the decoded audio (1 or more). + int Channels { get; } + + /// Sample rate of the decoded audio in Hz. + int SampleRate { get; } + + /// Total frame count of the clip at . + long TotalFrames { get; } + + /// + /// True when the data can be read only once, from frame 0 to the end (data is released as it is read). + /// The source then keeps every chunk (). + /// + bool IsForwardOnly { get; } + + /// Frame index that the next starts at. + long Position { get; } + + /// + /// Moves the read position to (0 ~ ). + /// A forward-only reader accepts only frames whose data has not been released. + /// + void Seek(long frame); + + /// + /// Reads up to frames into starting at index 0 and advances the position. + /// must hold at least frameCount * Channels floats. + /// + /// Frames read. 0 at the end of the clip. + int Read(float[] buffer, int frameCount); +} diff --git a/Util/Audio/Clip/MP3HeaderFrame.cs b/Util/Audio/Clip/MP3HeaderFrame.cs new file mode 100644 index 0000000..fe91fb8 --- /dev/null +++ b/Util/Audio/Clip/MP3HeaderFrame.cs @@ -0,0 +1,109 @@ +using System; +using System.IO; + +namespace ADOFAIRenderer.Util.Audio.Clip; + +/// +/// Detects the VBR/info header frame ("Xing", "Info" or "VBRI") at the start of an MP3 file. +/// +/// The game decodes MP3 files with MP3Sharp (Assembly-CSharp-firstpass, MP3Sharp.MP3Stream), which has no header-frame handling: +/// it decodes that frame as one frame of silence and keeps the encoder delay. FFmpeg's MP3 demuxer consumes the header frame +/// without output. Measured with the clip harness (LAME 192k CBR, LAME VBR, 44.1/48 kHz, with and without ID3v2): +/// MP3Sharp output is FFmpeg's untrimmed output preceded by one frame of silence when the header frame exists, +/// and identical in timing when it does not. +/// +/// Frame layout used here (ISO/IEC 11172-3 and 13818-3 frame header; Xing/Info tag after the Layer III side information, +/// VBRI tag 32 bytes after the frame header): +/// - Header: 11 sync bits, version (2 bits: 3 = MPEG-1, 2 = MPEG-2, 0 = MPEG-2.5), layer (2 bits: 1 = III, 2 = II, 3 = I), +/// protection bit (0 = 16-bit CRC follows), bitrate index, sample rate index, padding, private, channel mode (3 = mono). +/// - Layer III side information: MPEG-1 32 bytes (17 mono), MPEG-2/2.5 17 bytes (9 mono). +/// - Samples per frame: Layer I 384, Layer II 1152, Layer III 1152 (MPEG-1) or 576 (MPEG-2/2.5). +/// - ID3v2 tags before the first frame: "ID3", version (2 bytes), flags (bit 0x10 = 10-byte footer), size (4 bytes, 7 bits each). +/// +internal static class MP3HeaderFrame +{ + /// Bytes searched for the first frame sync after the ID3v2 tags. + private const int SyncSearchBytes = 64 * 1024; + + /// + /// Frames of silence MP3Sharp outputs for the header frame of : + /// the samples per frame of the first frame when it is a Xing/Info/VBRI header frame, otherwise 0. + /// + public static int SilentFrames(string path) + { + // The game keeps the song open while it streams it (MP3Sharp.MP3Stream: new FileStream(path, FileMode.Open), + // read/write access with read sharing), so the file is opened with read/write/delete sharing. + using var file = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + return SilentFrames(file); + } + + public static int SilentFrames(Stream stream) + { + var tag = new byte[10]; + long offset = 0; + while (true) + { + stream.Position = offset; + if (ReadFully(stream, tag, 10) < 10 || tag[0] != 'I' || tag[1] != 'D' || tag[2] != '3') break; + var size = (tag[6] & 0x7F) << 21 | (tag[7] & 0x7F) << 14 | (tag[8] & 0x7F) << 7 | (tag[9] & 0x7F); + offset += 10 + size + ((tag[5] & 0x10) != 0 ? 10 : 0); + } + + stream.Position = offset; + var data = new byte[SyncSearchBytes]; + var length = ReadFully(stream, data, data.Length); + for (var i = 0; i + 4 <= length; i++) + { + if (!TryParseHeader(data, i, out var samplesPerFrame, out var tagOffset)) continue; + + // Xing/Info after the side information; VBRI at a fixed offset of 32 bytes after the 4-byte header. + if (HasTag(data, length, i + tagOffset, "Xing") || HasTag(data, length, i + tagOffset, "Info") + || HasTag(data, length, i + 4 + 32, "VBRI")) + return samplesPerFrame; + return 0; + } + return 0; + } + + private static bool TryParseHeader(byte[] data, int i, out int samplesPerFrame, out int tagOffset) + { + samplesPerFrame = 0; + tagOffset = 0; + if (data[i] != 0xFF || (data[i + 1] & 0xE0) != 0xE0) return false; + + var version = (data[i + 1] >> 3) & 0x3; + var layer = (data[i + 1] >> 1) & 0x3; + var hasCrc = (data[i + 1] & 0x1) == 0; + var bitrateIndex = (data[i + 2] >> 4) & 0xF; + var sampleRateIndex = (data[i + 2] >> 2) & 0x3; + var mono = ((data[i + 3] >> 6) & 0x3) == 3; + if (version == 1 || layer == 0 || bitrateIndex == 0xF || sampleRateIndex == 3) return false; + + var mpeg1 = version == 3; + samplesPerFrame = layer switch + { + 3 => 384, + 2 => 1152, + _ => mpeg1 ? 1152 : 576 + }; + var sideInfo = layer == 1 ? (mpeg1 ? (mono ? 17 : 32) : (mono ? 9 : 17)) : 0; + tagOffset = 4 + (hasCrc ? 2 : 0) + sideInfo; + return true; + } + + private static bool HasTag(byte[] data, int length, int at, string tag) + { + if (at < 0 || at + tag.Length > length) return false; + for (var k = 0; k < tag.Length; k++) + if (data[at + k] != tag[k]) return false; + return true; + } + + private static int ReadFully(Stream stream, byte[] buffer, int count) + { + var total = 0; + int read; + while (total < count && (read = stream.Read(buffer, total, count - total)) > 0) total += read; + return total; + } +} diff --git a/Util/Audio/Clip/MP3MonoLayoutReader.cs b/Util/Audio/Clip/MP3MonoLayoutReader.cs new file mode 100644 index 0000000..b1e35ab --- /dev/null +++ b/Util/Audio/Clip/MP3MonoLayoutReader.cs @@ -0,0 +1,73 @@ +using System; + +namespace ADOFAIRenderer.Util.Audio.Clip; + +/// +/// that reproduces the game's output for mono MP3 files. +/// +/// MP3Sharp (Assembly-CSharp-firstpass, MP3Sharp.Buffer16BitStereo) writes every frame in stereo layout and fills only the +/// channel 0 slot for mono streams, while MP3Stream.ChannelCount reports 1. The game creates a 1-channel clip from that data, +/// so a mono MP3 plays as [s0, 0, s1, 0, ...]: twice as long, at half speed and one octave lower +/// (confirmed in game with TestMaps/mp3_mono, 2026-10-13). +/// +/// Output: 1 channel at the inner reader's sample rate. Frame 2k is inner frame k, frame 2k + 1 is 0. +/// = 2 * inner TotalFrames. +/// +internal sealed class MP3MonoLayoutReader : IPCMReader +{ + private readonly IPCMReader _inner; + private float[] _innerBuffer = Array.Empty(); + + /// Mono reader of the MP3 file with the game's MP3 timing (). Owned by this reader. + public MP3MonoLayoutReader(IPCMReader inner) + { + if (inner.Channels != 1) throw new ArgumentException("Inner reader must be mono.", nameof(inner)); + _inner = inner; + Position = inner.Position * 2; + } + + public int Channels => 1; + public int SampleRate => _inner.SampleRate; + public long TotalFrames => _inner.TotalFrames * 2; + public bool IsForwardOnly => _inner.IsForwardOnly; + public long Position { get; private set; } + + /// Moves to ; the inner reader moves to the inner frame of the next even position. + public void Seek(long frame) + { + _inner.Seek((frame + 1) / 2); + Position = frame; + } + + public int Read(float[] buffer, int frameCount) + { + var limit = (int)Math.Min(frameCount, TotalFrames - Position); + var written = 0; + while (written < limit) + { + if ((Position & 1) == 1) + { + buffer[written++] = 0f; + Position++; + continue; + } + + var want = (limit - written + 1) / 2; + if (_innerBuffer.Length < want) _innerBuffer = new float[want]; + var got = _inner.Read(_innerBuffer, want); + if (got == 0) break; + + for (var i = 0; i < got && written < limit; i++) + { + buffer[written++] = _innerBuffer[i]; + Position++; + if (written >= limit) break; + buffer[written++] = 0f; + Position++; + } + } + return written; + } + + public void Dispose() => _inner.Dispose(); +} diff --git a/Util/Audio/Clip/MemoryPCMReader.cs b/Util/Audio/Clip/MemoryPCMReader.cs new file mode 100644 index 0000000..4d62840 --- /dev/null +++ b/Util/Audio/Clip/MemoryPCMReader.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Generic; + +namespace ADOFAIRenderer.Util.Audio.Clip; + +/// +/// Forward-only over PCM held in memory as a list of blocks. +/// Block b holds frames [b * FramesPerBlock, (b + 1) * FramesPerBlock) interleaved; the last block may be shorter. +/// Each block is released (passed to the release callback and dropped) as soon as the read position passes its end, +/// and all remaining blocks are released on . +/// +internal sealed class MemoryPCMReader : IPCMReader +{ + private readonly float[]?[] _blocks; + private readonly Action? _onBlockReleased; + private int _releasedCount; // Blocks [0, _releasedCount) are released + + public int Channels { get; } + public int SampleRate { get; } + public long TotalFrames { get; } + public bool IsForwardOnly => true; + public long Position { get; private set; } + + /// Frames per block (all blocks except the last). + public int FramesPerBlock { get; } + + public MemoryPCMReader(IReadOnlyList blocks, int framesPerBlock, int channels, int sampleRate, long totalFrames, + Action? onBlockReleased) + { + if (channels <= 0) throw new ArgumentOutOfRangeException(nameof(channels)); + if (framesPerBlock <= 0) throw new ArgumentOutOfRangeException(nameof(framesPerBlock)); + if (totalFrames > (long)blocks.Count * framesPerBlock) throw new ArgumentException("Blocks hold fewer frames than totalFrames.", nameof(blocks)); + + _blocks = new float[blocks.Count][]; + for (var i = 0; i < blocks.Count; i++) _blocks[i] = blocks[i]; + _onBlockReleased = onBlockReleased; + FramesPerBlock = framesPerBlock; + Channels = channels; + SampleRate = sampleRate; + TotalFrames = totalFrames; + } + + public void Seek(long frame) + { + if (frame < 0 || frame > TotalFrames) throw new ArgumentOutOfRangeException(nameof(frame)); + if (frame < (long)_releasedCount * FramesPerBlock) + throw new InvalidOperationException($"Frame {frame} was already released by the forward-only reader."); + Position = frame; + } + + public int Read(float[] buffer, int frameCount) + { + var total = (int)Math.Min(frameCount, TotalFrames - Position); + if (total <= 0) return 0; + + var written = 0; + while (written < total) + { + var blockIndex = (int)(Position / FramesPerBlock); + var block = _blocks[blockIndex] ?? throw new InvalidOperationException($"Block {blockIndex} was already released."); + var offset = (int)(Position - (long)blockIndex * FramesPerBlock); + var count = Math.Min(total - written, FramesPerBlock - offset); + Array.Copy(block, offset * Channels, buffer, written * Channels, count * Channels); + written += count; + Position += count; + ReleaseBlocksBefore((int)(Position / FramesPerBlock)); + } + return written; + } + + private void ReleaseBlocksBefore(int blockIndex) + { + while (_releasedCount < blockIndex && _releasedCount < _blocks.Length) + { + var block = _blocks[_releasedCount]; + _blocks[_releasedCount] = null; + _releasedCount++; + if (block != null) _onBlockReleased?.Invoke(block); + } + } + + public void Dispose() => ReleaseBlocksBefore(_blocks.Length); +} diff --git a/Util/Audio/Clip/PCMResampler.cs b/Util/Audio/Clip/PCMResampler.cs new file mode 100644 index 0000000..c9d4ef0 --- /dev/null +++ b/Util/Audio/Clip/PCMResampler.cs @@ -0,0 +1,106 @@ +using System; +using FFmpeg.AutoGen; + +namespace ADOFAIRenderer.Util.Audio.Clip; + +/// +/// Sample rate converter for interleaved 32-bit float PCM (libswresample). +/// Input and output have the same channel count; only the sample rate changes. +/// One instance keeps filter state between calls, so consecutive calls form one continuous stream. +/// Reference: https://ffmpeg.org/doxygen/trunk/group__lswr.html +/// +internal sealed unsafe class PCMResampler : IDisposable +{ + private SwrContext* _swr; + private bool _flushed; + + public int Channels { get; } + public int InputRate { get; } + public int OutputRate { get; } + + /// Value of the SwrContext "filter_size" option (filter length per phase in taps at a 1:1 ratio). + public int FilterSize { get; } + + public PCMResampler(int channels, int inputRate, int outputRate) + { + if (channels <= 0) throw new ArgumentOutOfRangeException(nameof(channels)); + if (inputRate <= 0) throw new ArgumentOutOfRangeException(nameof(inputRate)); + if (outputRate <= 0) throw new ArgumentOutOfRangeException(nameof(outputRate)); + + Channels = channels; + InputRate = inputRate; + OutputRate = outputRate; + + AVChannelLayout layout; + ffmpeg.av_channel_layout_default(&layout, channels); + + SwrContext* swr = null; + var ret = ffmpeg.swr_alloc_set_opts2( + &swr, + &layout, AVSampleFormat.AV_SAMPLE_FMT_FLT, outputRate, + &layout, AVSampleFormat.AV_SAMPLE_FMT_FLT, inputRate, + 0, null); + ffmpeg.av_channel_layout_uninit(&layout); + if (ret < 0 || swr == null) + throw new InvalidOperationException($"swr_alloc_set_opts2 failed ({ret})."); + _swr = swr; + + long filterSize; + FilterSize = ffmpeg.av_opt_get_int(_swr, "filter_size", 0, &filterSize) >= 0 ? (int)filterSize : 32; + + Reset(); + } + + /// Discards all buffered input and filter history. The next call starts a new stream. + public void Reset() + { + // swr_init on an initialized context re-initializes it. + // Reference: https://ffmpeg.org/doxygen/trunk/group__lswr.html (swr_init, swr_close) + var ret = ffmpeg.swr_init(_swr); + if (ret < 0) + throw new InvalidOperationException($"swr_init failed ({ret})."); + _flushed = false; + } + + /// + /// Feeds frames and writes up to frames. + /// Input that does not fit in the output is buffered inside the context and returned by later calls. + /// may be 0 to take only buffered output. + /// + /// Frames written to . + public int Convert(float* input, int inputFrames, float* output, int outputCapacity) + { + if (_flushed) throw new InvalidOperationException("Resampler was flushed. Call Reset first."); + + // swr_convert treats a null input array as a flush request, so a non-null pointer is passed even for 0 frames. + float dummy = 0; + var inPlane = (byte*)(input != null ? input : &dummy); + var outPlane = (byte*)output; + var ret = ffmpeg.swr_convert(_swr, &outPlane, outputCapacity, &inPlane, inputFrames); + if (ret < 0) + throw new InvalidOperationException($"swr_convert failed ({ret})."); + return ret; + } + + /// + /// Ends the input stream and writes up to remaining frames. + /// Call repeatedly until it returns 0. is not allowed afterwards until . + /// + public int Flush(float* output, int outputCapacity) + { + _flushed = true; + var outPlane = (byte*)output; + var ret = ffmpeg.swr_convert(_swr, &outPlane, outputCapacity, null, 0); + if (ret < 0) + throw new InvalidOperationException($"swr_convert (flush) failed ({ret})."); + return ret; + } + + public void Dispose() + { + if (_swr == null) return; + var swr = _swr; + ffmpeg.swr_free(&swr); + _swr = null; + } +} diff --git a/Util/Audio/Clip/UnityClipLoader.cs b/Util/Audio/Clip/UnityClipLoader.cs new file mode 100644 index 0000000..c9acbe5 --- /dev/null +++ b/Util/Audio/Clip/UnityClipLoader.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace ADOFAIRenderer.Util.Audio.Clip; + +/// +/// Copies a Unity into memory for clips without a file on disk +/// (built-in hit sounds from Resources, official/DLC level audio). +/// runs on the main thread only, so the whole clip is copied once on the main thread +/// and the decoder thread reads the copy through a . +/// Streaming clips () are rejected because GetData does not read them. +/// Reference: https://docs.unity3d.com/ScriptReference/AudioClip.GetData.html +/// +internal static class UnityClipLoader +{ + /// + /// Copies in blocks of floats or less + /// (FramesPerBlock = ChunkFloats / channels frames per GetData call). Main thread only. + /// Block memory is counted in and released block by block as the decoder thread reads it. + /// + public static MemoryPCMReader Load(AudioClip clip, AudioClipStore store) + { + if (!clip) throw new ArgumentNullException(nameof(clip)); + if (clip.loadType == AudioClipLoadType.Streaming) + throw new NotSupportedException($"AudioClip '{clip.name}' is a streaming clip; GetData cannot read it."); + + if (clip.loadState == AudioDataLoadState.Unloaded) + clip.LoadAudioData(); + + var channels = clip.channels; + var totalFrames = (long)clip.samples; + var framesPerBlock = AudioClipSource.ChunkFloats / channels; + var blocks = new List(); + try + { + for (long start = 0; start < totalFrames; start += framesPerBlock) + { + var frames = (int)Math.Min(framesPerBlock, totalFrames - start); + var block = store.AllocateExactArray(frames * channels); + blocks.Add(block); + // GetData fills the whole array; the offset is in frames (samples per channel). + if (!clip.GetData(block, (int)start)) + throw new InvalidOperationException($"AudioClip.GetData failed for '{clip.name}' at frame {start} (load state {clip.loadState})."); + } + } + catch + { + foreach (var block in blocks) store.FreeExactArray(block); + throw; + } + + return new MemoryPCMReader(blocks, framesPerBlock, channels, clip.frequency, totalFrames, store.FreeExactArray); + } +} diff --git a/Util/Audio/Filter/Gain.cs b/Util/Audio/Filter/Gain.cs index 3c33a22..bafa79c 100644 --- a/Util/Audio/Filter/Gain.cs +++ b/Util/Audio/Filter/Gain.cs @@ -4,14 +4,14 @@ namespace ADOFAIRenderer.Util.Audio.Filter; internal class Gain : IAudioFilter { - public float Level = 0f; + public double Level = 0; public void Initialize(int sampleRate, int channels) { } - public float ProcessSample(float sample, int channel) => - sample * (float)Math.Pow(10, Level / 20); + public double ProcessSample(double sample, int channel) => + sample * Math.Pow(10, Level / 20); - public void ProcessBlock(Span buffer) + public void ProcessBlock(Span buffer) { for (var i = 0; i < buffer.Length; i++) buffer[i] = ProcessSample(buffer[i], 0); diff --git a/Util/Audio/Filter/HardClip.cs b/Util/Audio/Filter/HardClip.cs index adc58c4..2e20f6c 100644 --- a/Util/Audio/Filter/HardClip.cs +++ b/Util/Audio/Filter/HardClip.cs @@ -8,19 +8,19 @@ internal class HardClip : IAudioFilter // ReSharper disable once MemberCanBePrivate.Global // ReSharper disable once FieldCanBeMadeReadOnly.Global // ReSharper disable once ConvertToConstant.Global - public float Threshold = 1.0f; + public double Threshold = 1.0; public void Initialize(int sampleRate, int channels) { } [MethodImpl(MethodImplOptions.AggressiveInlining)] - public float ProcessSample(float sample, int channel) + public double ProcessSample(double sample, int channel) { if (sample > Threshold) return Threshold; if (sample < -Threshold) return -Threshold; return sample; } - public void ProcessBlock(Span buffer) + public void ProcessBlock(Span buffer) { for (var i = 0; i < buffer.Length; i++) buffer[i] = ProcessSample(buffer[i], 0); diff --git a/Util/Audio/Filter/IAudioFilter.cs b/Util/Audio/Filter/IAudioFilter.cs index 63c684b..ebf9a00 100644 --- a/Util/Audio/Filter/IAudioFilter.cs +++ b/Util/Audio/Filter/IAudioFilter.cs @@ -6,8 +6,8 @@ internal interface IAudioFilter { void Initialize(int sampleRate, int channels); - float ProcessSample(float sample, int channel); - void ProcessBlock(Span buffer); + double ProcessSample(double sample, int channel); + void ProcessBlock(Span buffer); void Reset(); diff --git a/Util/Audio/Filter/RMSCompressor.cs b/Util/Audio/Filter/RMSCompressor.cs index 571f126..ea07d27 100644 --- a/Util/Audio/Filter/RMSCompressor.cs +++ b/Util/Audio/Filter/RMSCompressor.cs @@ -6,84 +6,84 @@ namespace ADOFAIRenderer.Util.Audio.Filter; // ReSharper disable once InconsistentNaming internal class RMSCompressor : IAudioFilter { - private float _thresholdDb = -20f; - private float _ratio = 4f; - private float _attackTime = 0.010f; // in seconds - private float _releaseTime = 0.100f; // in seconds - private float _kneeDb = 5f; // in dB - private float _rmsWindowTime = 0.030f; // in seconds - private float _lookAheadTime; // in seconds + private double _thresholdDb = -20; + private double _ratio = 4; + private double _attackTime = 0.010; // in seconds + private double _releaseTime = 0.100; // in seconds + private double _kneeDb = 5; // in dB + private double _rmsWindowTime = 0.030; // in seconds + private double _lookAheadTime; // in seconds private int _lookAheadSamples; private int _sampleRate = 44100; private int _channels = 2; - private float _attackCoef; - private float _releaseCoef; - private float _rmsCoef; + private double _attackCoef; + private double _releaseCoef; + private double _rmsCoef; // Per-channel state - private float[] _rmsState = Array.Empty(); // running mean-square (MS) state - private float[] _gainState = Array.Empty(); // smoothed gain reduction in dB - private float[][] _delayBuffers = Array.Empty(); // circular delay buffers + private double[] _rmsState = Array.Empty(); // running mean-square (MS) state + private double[] _gainState = Array.Empty(); // smoothed gain reduction in dB + private double[][] _delayBuffers = Array.Empty(); // circular delay buffers private int[] _delayWriteIndices = Array.Empty(); - public float LookAheadTime + public double LookAheadTime { get => _lookAheadTime; set { - _lookAheadTime = Math.Max(0f, Math.Min(1.0f, value)); + _lookAheadTime = Math.Max(0, Math.Min(1.0, value)); UpdateLookAheadSettings(); } } public double Latency => _lookAheadTime; - public float ThresholdDb + public double ThresholdDb { get => _thresholdDb; set => _thresholdDb = value; } - public float Ratio + public double Ratio { get => _ratio; - set => _ratio = Math.Max(1f, value); + set => _ratio = Math.Max(1, value); } - public float AttackTime + public double AttackTime { get => _attackTime; set { - _attackTime = Math.Max(0.0001f, value); + _attackTime = Math.Max(0.0001, value); UpdateAttackCoef(); } } - public float ReleaseTime + public double ReleaseTime { get => _releaseTime; set { - _releaseTime = Math.Max(0.0001f, value); + _releaseTime = Math.Max(0.0001, value); UpdateReleaseCoef(); } } - public float KneeDb + public double KneeDb { get => _kneeDb; - set => _kneeDb = Math.Max(0f, value); + set => _kneeDb = Math.Max(0, value); } - public float RmsWindowTime + public double RmsWindowTime { get => _rmsWindowTime; set { - _rmsWindowTime = Math.Max(0.0001f, value); + _rmsWindowTime = Math.Max(0.0001, value); UpdateRmsCoef(); } } @@ -98,8 +98,8 @@ internal class RMSCompressor : IAudioFilter UpdateRmsCoef(); UpdateLookAheadSettings(); - _rmsState = new float[channels]; - _gainState = new float[channels]; + _rmsState = new double[channels]; + _gainState = new double[channels]; Reset(); } @@ -114,33 +114,33 @@ internal class RMSCompressor : IAudioFilter { if (_channels <= 0) return; - _delayBuffers = new float[_channels][]; + _delayBuffers = new double[_channels][]; _delayWriteIndices = new int[_channels]; for (int i = 0; i < _channels; i++) { - _delayBuffers[i] = _lookAheadSamples > 0 ? new float[_lookAheadSamples] : Array.Empty(); + _delayBuffers[i] = _lookAheadSamples > 0 ? new double[_lookAheadSamples] : Array.Empty(); _delayWriteIndices[i] = 0; } } private void UpdateAttackCoef() { - _attackCoef = (float)Math.Exp(-1.0 / (_sampleRate * _attackTime)); + _attackCoef = Math.Exp(-1.0 / (_sampleRate * _attackTime)); } private void UpdateReleaseCoef() { - _releaseCoef = (float)Math.Exp(-1.0 / (_sampleRate * _releaseTime)); + _releaseCoef = Math.Exp(-1.0 / (_sampleRate * _releaseTime)); } private void UpdateRmsCoef() { - _rmsCoef = (float)Math.Exp(-1.0 / (_sampleRate * _rmsWindowTime)); + _rmsCoef = Math.Exp(-1.0 / (_sampleRate * _rmsWindowTime)); } [MethodImpl(MethodImplOptions.AggressiveInlining)] - public float ProcessSample(float sample, int channel) + public double ProcessSample(double sample, int channel) { if (channel < 0 || channel >= _channels) { @@ -149,54 +149,54 @@ internal class RMSCompressor : IAudioFilter // Calculate running Mean-Square (MS) level // v_c[n] = alpha * v_c[n-1] + (1 - alpha) * x^2[n] - float sq = sample * sample; - _rmsState[channel] = _rmsCoef * _rmsState[channel] + (1.0f - _rmsCoef) * sq; + double sq = sample * sample; + _rmsState[channel] = _rmsCoef * _rmsState[channel] + (1.0 - _rmsCoef) * sq; - float rmsSq = Math.Max(0f, _rmsState[channel]); + double rmsSq = Math.Max(0, _rmsState[channel]); // Convert to dB. 10 * log10(rmsSq) is equivalent to 20 * log10(sqrt(rmsSq)) - float rmsDb = 10.0f * (float)Math.Log10(rmsSq + 1e-12f); + double rmsDb = 10.0 * Math.Log10(rmsSq + 1e-12); // Calculate static envelope curve (gain reduction in dB) - float gStatic = 0f; - if (_kneeDb > 0f) + double gStatic = 0; + if (_kneeDb > 0) { - if (rmsDb > _thresholdDb + _kneeDb * 0.5f) + if (rmsDb > _thresholdDb + _kneeDb * 0.5) { - gStatic = (1f / _ratio - 1f) * (rmsDb - _thresholdDb); + gStatic = (1 / _ratio - 1) * (rmsDb - _thresholdDb); } - else if (rmsDb >= _thresholdDb - _kneeDb * 0.5f) + else if (rmsDb >= _thresholdDb - _kneeDb * 0.5) { - float diff = rmsDb - _thresholdDb + _kneeDb * 0.5f; - gStatic = (1f / _ratio - 1f) * diff * diff / (2f * _kneeDb); + double diff = rmsDb - _thresholdDb + _kneeDb * 0.5; + gStatic = (1 / _ratio - 1) * diff * diff / (2 * _kneeDb); } } else { if (rmsDb > _thresholdDb) { - gStatic = (1f / _ratio - 1f) * (rmsDb - _thresholdDb); + gStatic = (1 / _ratio - 1) * (rmsDb - _thresholdDb); } } // Apply ballistics (attack and release) on the dB gain reduction - float lastGain = _gainState[channel]; - float targetGain = gStatic; + double lastGain = _gainState[channel]; + double targetGain = gStatic; // Note: Since targetGain and lastGain are <= 0 (gain reduction in dB), // targetGain < lastGain means target gain reduction is larger (more negative), // so we are in the attack phase. - float coef = targetGain < lastGain ? _attackCoef : _releaseCoef; - float currentGain = coef * lastGain + (1f - coef) * targetGain; + double coef = targetGain < lastGain ? _attackCoef : _releaseCoef; + double currentGain = coef * lastGain + (1 - coef) * targetGain; _gainState[channel] = currentGain; // Convert back to linear gain multiplier - float linearGain = (float)Math.Pow(10.0, currentGain / 20.0); + double linearGain = Math.Pow(10.0, currentGain / 20.0); if (_lookAheadSamples > 0 && _delayBuffers.Length > channel && _delayBuffers[channel].Length > 0) { var buffer = _delayBuffers[channel]; int writeIdx = _delayWriteIndices[channel]; - float delayedSample = buffer[writeIdx]; + double delayedSample = buffer[writeIdx]; buffer[writeIdx] = sample; _delayWriteIndices[channel] = (writeIdx + 1) % _lookAheadSamples; return delayedSample * linearGain; @@ -205,7 +205,7 @@ internal class RMSCompressor : IAudioFilter return sample * linearGain; } - public void ProcessBlock(Span buffer) + public void ProcessBlock(Span buffer) { for (var i = 0; i < buffer.Length; i++) { diff --git a/Util/Audio/Mixer/Voice.cs b/Util/Audio/Mixer/Voice.cs new file mode 100644 index 0000000..123bf8b --- /dev/null +++ b/Util/Audio/Mixer/Voice.cs @@ -0,0 +1,163 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using ADOFAIRenderer.Util.Audio.Clip; + +namespace ADOFAIRenderer.Util.Audio.Mixer; + +/// +/// One playing sound of a (the song, or one effect sound), created by . +/// +/// Main thread: the Set methods queue a change that takes effect at a given time (seconds relative to the render start, +/// the time at which the game made the change). Changes of one voice must be queued in non-decreasing time order. +/// Render thread: applies each change at sample round(time * OutputRate). +/// +/// Playback model (output samples n, clip positions in frames of the source at the output sample rate): +/// - The voice is silent before its start sample and from its end sample on. +/// - Clip position at sample n = anchor position + (n - anchor sample) * pitch. A position or pitch change sets a new anchor +/// at the sample where it takes effect, so a pitch change keeps the position continuous. +/// - Looping: the position wraps at the clip length ( * output rate, fractional). +/// Not looping: the voice ends when the position reaches . +/// +internal sealed class Voice +{ + internal enum ChangeKind + { + Pitch, + Volume, + Loop, + Position, + EndTime, + Stop + } + + internal readonly struct Change + { + public readonly ChangeKind Kind; + public readonly long Sample; + public readonly double Value; + + public Change(ChangeKind kind, long sample, double value) + { + Kind = kind; + Sample = sample; + Value = value; + } + } + + private readonly VoiceMixer _mixer; + + // ── Render thread state ── + + /// Changes not yet applied, in time order. Render thread only. + internal readonly Queue Pending = new(); + + /// First output sample of playback. + internal long StartSample; + + /// Output sample at which playback stops (exclusive); long.MaxValue when not set. + internal long EndSample = long.MaxValue; + + internal long AnchorSample; + internal double AnchorPosition; + internal double Pitch; + internal double Volume; + 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; + + internal AudioCursor? Cursor; + internal ChunkView View; + + internal Voice(VoiceMixer mixer, AudioClipSource? source, string name) + { + _mixer = mixer; + Source = source; + Name = name; + } + + /// Clip played by this voice; null plays silence (the clip could not be loaded). + public AudioClipSource? Source { get; } + + /// Name for logs (the game's clip key). + public string Name { get; } + + /// Sets the playback rate (1 = original speed). Negative values are treated as 0. + public void SetPitch(double pitch, double time) => _mixer.Enqueue(this, ChangeKind.Pitch, time, Math.Max(0.0, pitch)); + + /// Sets the linear gain applied to the clip samples. + public void SetVolume(double volume, double time) => _mixer.Enqueue(this, ChangeKind.Volume, time, volume); + + /// Turns looping on or off. Turning it off lets the current repetition play to the end of the clip. + public void SetLoop(bool loop, double time) => _mixer.Enqueue(this, ChangeKind.Loop, time, loop ? 1.0 : 0.0); + + /// + /// Moves playback to seconds into the clip. + /// Before the voice starts, this sets the position playback starts from. + /// + public void SetPosition(double clipSeconds, double time) => _mixer.Enqueue(this, ChangeKind.Position, time, Math.Max(0.0, clipSeconds)); + + /// Stops playback at (seconds relative to the render start). + public void SetEndTime(double endTime, double time) => _mixer.Enqueue(this, ChangeKind.EndTime, time, endTime); + + /// Stops playback at . + public void Stop(double time) => _mixer.Enqueue(this, ChangeKind.Stop, time, 0.0); + + /// Unwrapped clip position at output sample (sample at or after the anchor). + internal double PositionAt(long sample) => AnchorPosition + (sample - AnchorSample) * Pitch; + + /// Clip position at , wrapped into the loop when looping. + internal double WrappedPositionAt(long sample) + { + var position = PositionAt(sample); + if (Loop && Period > 0 && position >= Period) position %= Period; + return position; + } + + /// + /// Applies at output sample (its own sample, or a later one when the change + /// arrived after its sample was mixed). Before the start sample, changes replace the starting state. Render thread only. + /// + internal void Apply(in Change change, long sample, int outputRate) + { + var at = Math.Max(sample, StartSample); + switch (change.Kind) + { + case ChangeKind.Pitch: + Reanchor(at); + Pitch = change.Value; + break; + case ChangeKind.Volume: + Volume = change.Value; + break; + case ChangeKind.Loop: + Reanchor(at); + Loop = change.Value != 0.0; + break; + case ChangeKind.Position: + AnchorSample = at; + AnchorPosition = change.Value * outputRate; + break; + case ChangeKind.EndTime: + EndSample = VoiceMixer.ToSample(change.Value, outputRate); + break; + case ChangeKind.Stop: + EndSample = Math.Min(EndSample, sample); + break; + } + } + + private void Reanchor(long sample) + { + AnchorPosition = WrappedPositionAt(sample); + AnchorSample = sample; + } +} diff --git a/Util/Audio/Mixer/VoiceMixer.cs b/Util/Audio/Mixer/VoiceMixer.cs new file mode 100644 index 0000000..b522d4b --- /dev/null +++ b/Util/Audio/Mixer/VoiceMixer.cs @@ -0,0 +1,344 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using ADOFAIRenderer.Util.Audio.Clip; + +namespace ADOFAIRenderer.Util.Audio.Mixer; + +/// +/// Mixes every into interleaved double samples at the output sample rate and channel count. +/// +/// Time: output sample n is at n / OutputRate seconds relative to the render start (the same time base as the times passed +/// to and the Voice Set methods). A time t maps to sample round(t * OutputRate). +/// +/// Threads: +/// - Main thread: and the Voice Set methods queue commands under a lock. Commands must be queued in +/// non-decreasing time order per voice. +/// - Render thread: takes the queued commands and mixes a block of samples. A command takes effect at its sample; +/// a command whose sample is before the block being mixed takes effect at the start of that block. +/// Chunks are read through , which waits for the decoder thread. +/// +/// Sample reading: clip positions are in frames of the source at the output rate (sources are resampled when loaded). +/// An integer position copies the stored sample; a fractional position interpolates linearly between the frame and the next one +/// (the next frame of a looping voice after the last frame is frame 0; after the end of a non-looping clip it is 0). +/// Channel mapping follows . +/// +internal sealed class VoiceMixer : IDisposable +{ + private enum CommandKind + { + Play, + Change + } + + private readonly struct Command + { + public readonly CommandKind Kind; + public readonly Voice Voice; + public readonly Voice.Change Change; + + public Command(CommandKind kind, Voice voice, Voice.Change change) + { + Kind = kind; + Voice = voice; + Change = change; + } + } + + private readonly AudioClipStore _store; + private readonly CancellationToken _cancellationToken; + + private readonly object _commandLock = new(); + private List _commands = new(); + private List _takenCommands = new(); + + // Render thread only. + private readonly List _voices = new(); + private bool _disposed; + + /// Store the voices' sources belong to; its OutputRate is the mixer's sample rate. + /// Interleaved channel count of the mixed output. + /// Cancels waits for chunks in . + public VoiceMixer(AudioClipStore store, int outputChannels, CancellationToken cancellationToken = default) + { + if (outputChannels < 1) throw new ArgumentOutOfRangeException(nameof(outputChannels)); + _store = store; + OutputRate = store.OutputRate; + OutputChannels = outputChannels; + _cancellationToken = cancellationToken; + } + + public int OutputRate { get; } + + public int OutputChannels { get; } + + /// Voices currently mixed (started or waiting to start). Render thread only. + public int ActiveVoiceCount => _voices.Count; + + /// + /// Output sample containing (seconds × rate rounded down), the sample at which + /// AudioSource.PlayScheduled starts a sound scheduled between two output samples. + /// + public static long ToSample(double seconds, int rate) => (long)Math.Floor(seconds * rate); + + // ── Main thread ── + + /// + /// Schedules to start at . + /// A start time before (the current time) starts the voice at . + /// + /// Clip to play; null plays silence. + /// Name for logs. + /// Start of playback, seconds relative to the render start. + /// Linear gain. + /// Playback rate (1 = original speed); negative values are treated as 0. + /// Repeats the clip until the voice is stopped. + /// Current time, seconds relative to the render start. + public Voice Play(AudioClipSource? source, string name, double startTime, double volume, double pitch, bool loop, double time) + { + var voice = new Voice(this, source, name) + { + StartSample = Math.Max(ToSample(startTime, OutputRate), ToSample(time, OutputRate)), + Volume = volume, + Pitch = Math.Max(0.0, pitch), + Loop = loop, + Period = source != null ? source.Duration * OutputRate : 0.0 + }; + voice.AnchorSample = voice.StartSample; + + lock (_commandLock) + { + _commands.Add(new Command(CommandKind.Play, voice, default)); + } + return voice; + } + + internal void Enqueue(Voice voice, Voice.ChangeKind kind, double time, double value) + { + var change = new Voice.Change(kind, ToSample(time, OutputRate), value); + lock (_commandLock) + { + _commands.Add(new Command(CommandKind.Change, voice, change)); + } + } + + // ── Render thread ── + + /// + /// Adds the mix of output samples [, firstSample + frames) to + /// (interleaved, frames = destination.Length / OutputChannels). Blocks are expected in increasing sample order. + /// + /// Chunk loading failed fatally (see ). + /// The cancellation token was canceled while waiting for a chunk. + public void Mix(long firstSample, Span destination) + { + if (_disposed) throw new ObjectDisposedException(nameof(VoiceMixer)); + var frames = destination.Length / OutputChannels; + var endSample = firstSample + frames; + + TakeCommands(); + + for (var i = _voices.Count - 1; i >= 0; i--) + { + var voice = _voices[i]; + var sample = firstSample; + while (!voice.Finished) + { + var segmentEnd = endSample; + if (voice.Pending.Count > 0) + { + var next = voice.Pending.Peek(); + if (next.Sample <= sample) + { + voice.Apply(voice.Pending.Dequeue(), sample, OutputRate); + continue; + } + segmentEnd = Math.Min(segmentEnd, next.Sample); + } + + if (segmentEnd > sample) + MixSegment(voice, sample, segmentEnd, destination.Slice((int)(sample - firstSample) * OutputChannels)); + sample = segmentEnd; + if (sample >= endSample) break; + } + + if (!voice.Finished && endSample >= voice.EndSample) + voice.Finished = true; + if (voice.Finished) + { + ReleaseVoice(voice); + _voices.RemoveAt(i); + } + } + } + + private void TakeCommands() + { + lock (_commandLock) + { + (_commands, _takenCommands) = (_takenCommands, _commands); + } + + foreach (var command in _takenCommands) + { + var voice = command.Voice; + if (command.Kind == CommandKind.Play) + { + if (voice.Source is { IsFailed: false } source) + voice.Cursor = source.AddCursor(0, voice.Loop); + _voices.Add(voice); + } + else if (!voice.Finished) + { + voice.Pending.Enqueue(command.Change); + } + } + _takenCommands.Clear(); + } + + /// Mixes over samples [from, to) with its current state into (starting at sample from). + private void MixSegment(Voice voice, long from, long to, Span destination) + { + var start = Math.Max(from, voice.StartSample); + var end = Math.Min(to, voice.EndSample); + if (start >= end) return; + + var source = voice.Source; + if (source == null || source.IsFailed) + { + voice.Finished = true; + return; + } + + var totalFrames = source.TotalFrames; + if (voice.Volume == 0.0) + { + // Nothing to add; a non-looping voice still ends at the end of the clip. + if (!voice.Loop && voice.PositionAt(end - 1) >= totalFrames) voice.Finished = true; + return; + } + + var channels = OutputChannels; + var volume = voice.Volume; + var offset = (int)(start - from) * channels; + + UpdateCursor(voice, (long)voice.WrappedPositionAt(start)); + + for (var n = start; n < end; n++, offset += channels) + { + var position = voice.WrappedPositionAt(n); + if (position >= totalFrames) + { + if (!voice.Loop) + { + voice.Finished = true; + return; + } + position = totalFrames - 1; + } + + var index = (long)position; + var fraction = position - index; + + if (!EnsureView(voice, index)) return; + var view = voice.View; + + if (fraction == 0.0) + { + for (var c = 0; c < channels; c++) + destination[offset + c] += view.GetSample(index, c, channels) * volume; + continue; + } + + var nextIndex = index + 1; + var nextInView = view.Contains(nextIndex); + for (var c = 0; c < channels; c++) + { + double low = view.GetSample(index, c, channels); + double high = nextInView ? view.GetSample(nextIndex, c, channels) : ReadOutside(voice, nextIndex, c); + destination[offset + c] += (low + fraction * (high - low)) * volume; + } + } + } + + /// Sample of frame outside the voice's current view (next chunk, loop start, or past the end). + private double ReadOutside(Voice voice, long frame, int channel) + { + var source = voice.Source!; + if (frame >= source.TotalFrames) + { + if (!voice.Loop) return 0.0; + frame = 0; + } + + var view = _store.Acquire(source, frame, _cancellationToken); + if (!view.IsValid) return 0.0; + try + { + return view.GetSample(frame, channel, OutputChannels); + } + finally + { + source.Release(view); + } + } + + /// Makes voice.View the chunk containing . False when the source cannot provide it (voice finished). + private bool EnsureView(Voice voice, long frame) + { + if (voice.View.IsValid && voice.View.Contains(frame)) return true; + + var source = voice.Source!; + if (voice.View.IsValid) + { + source.Release(voice.View); + voice.View = default; + } + + UpdateCursor(voice, frame); + voice.View = _store.Acquire(source, frame, _cancellationToken); + if (voice.View.IsValid) return true; + + voice.Finished = true; + return false; + } + + private static void UpdateCursor(Voice voice, long frame) + { + var cursor = voice.Cursor; + if (cursor == null) return; + if (cursor.Loop != voice.Loop) cursor.Loop = voice.Loop; + if (cursor.Position != frame) cursor.Position = frame; + } + + private static void ReleaseVoice(Voice voice) + { + var source = voice.Source; + if (source == null) return; + if (voice.View.IsValid) + { + source.Release(voice.View); + voice.View = default; + } + if (voice.Cursor != null) + { + source.RemoveCursor(voice.Cursor); + voice.Cursor = null; + } + voice.Pending.Clear(); + } + + /// Releases every voice's chunk and cursor. Render thread (or after the render thread stopped). + public void Dispose() + { + if (_disposed) return; + _disposed = true; + foreach (var voice in _voices) + ReleaseVoice(voice); + _voices.Clear(); + lock (_commandLock) + { + _commands.Clear(); + } + } +} diff --git a/Util/Audio/OfflineAudioMixer.cs b/Util/Audio/OfflineAudioMixer.cs index 18190be..3f5ef28 100644 --- a/Util/Audio/OfflineAudioMixer.cs +++ b/Util/Audio/OfflineAudioMixer.cs @@ -1,416 +1,358 @@ using System; using System.Collections.Generic; using System.Runtime.CompilerServices; +using System.Threading; +using ADOFAIRenderer.Util.Audio.Clip; using ADOFAIRenderer.Util.Audio.Filter; +using ADOFAIRenderer.Util.Audio.Mixer; using MelonLoader; using UnityEngine; namespace ADOFAIRenderer.Util.Audio; -internal class AudioClipCache +/// +/// Audio of one render: the song and effect sounds as s of a , clips from an +/// , followed by the filter chain (RMS compressor → look-ahead limiter → hard clip → -1 dB gain). +/// +/// Time: seconds relative to the render start (RenderManager.dspTimeBase); output sample n is at n / sample rate. +/// The limiter delays its output by its look-ahead (), so output sample n is mixed from +/// voices at sample n + latency, and the filters are primed with samples [0, latency) before the first block. +/// +/// Threads: +/// - Main thread: , , , , +/// source/voice mapping, and after each frame (all commands with an earlier time have been queued). +/// - Render thread: waits until the main thread has committed the end of the samples it mixes, +/// so every command takes effect at its own sample. ends the waiting when the main thread stops. +/// +internal sealed class OfflineAudioMixer : IDisposable { - private readonly Dictionary _cache = new(); + /// Gain applied to every clip. + private const double ClipVolume = 0.5; - public class CachedClipData - { - public float[] Samples = null!; // Pre-resampled to target channels and sample rate - public double Duration; - } + /// Additional gain of effect sounds (registered with ). + private const double EffectVolume = 2.0 / 3.0; - public CachedClipData? GetOrCache(string clipName, int targetChannels, int targetSampleRate, bool allowLoad = true) - { - if (string.IsNullOrEmpty(clipName)) return null; + /// + /// Smallest number of tracked effect voices at which finished ones are removed from the list used by . + /// After each removal the next threshold is twice the remaining count (at least this value). + /// + private const int EffectVoicePruneCount = 1024; - lock (_cache) - { - if (_cache.TryGetValue(clipName, out var cached)) - return cached; + /// Longest wait for a commit before checking for cancellation again, in milliseconds. + private const int CommitWaitMs = 50; - if (!allowLoad) return null; + private readonly int _channels; + private readonly int _sampleRate; + private readonly AudioClipStore _store; + private readonly VoiceMixer _mixer; + private readonly CancellationTokenSource _cancellation = new(); + private readonly List _filters = new(); + private readonly int _latencySamples; - if (!AudioManager.Instance) return null; + // Main thread. + private ConditionalWeakTable _sourceStates = new(); + private readonly List _effectVoices = new(); + private int _effectVoicePruneAt = EffectVoicePruneCount; + private AudioClipSource? _bgmSource; + private string _bgmKey = ""; + private Voice? _bgmVoice; + private double _bgmStart = double.NaN; + private double _bgmPitch = double.NaN; + private double _bgmVolume = double.NaN; - var clip = AudioManager.Instance.FindOrLoadAudioClip(clipName); - if (!clip) return null; + // Shared: committed output sample (voices are final before it), guarded by _commitLock. + private readonly object _commitLock = new(); + private long _committedSample = long.MinValue; - try - { - var totalSamples = clip.samples; - var channels = clip.channels; - var rawSamples = new float[totalSamples * channels]; - clip.GetData(rawSamples, 0); - - // Auto-resample during cache generation - var resampled = AudioResampler.Resample(rawSamples, channels, clip.frequency, targetChannels, targetSampleRate); - - var data = new CachedClipData - { - Samples = resampled, - Duration = (double)resampled.Length / (targetChannels * targetSampleRate) - }; - - _cache[clipName] = data; - return data; - } - catch (Exception e) - { - MelonLogger.Error($"[AudioClipCache] Failed to cache and resample clip {clipName}: {e}"); - return null; - } - } - } - - public void Clear() - { - lock (_cache) - { - _cache.Clear(); - } - } -} - -internal class OfflineAudioMixer -{ - public class SoundEvent - { - public string ClipName = null!; - public double StartTime; // In seconds relative to dspTimeBase - public double EndTime; // In seconds relative to dspTimeBase (-1 if one-shot) - public float Volume; - public float Pitch; - } - - private readonly List _activeEvents = new(); - private ConditionalWeakTable _sourceMappings = new(); - private readonly object _lock = new(); - - // Target Audio Settings - private readonly int _targetChannels; - private readonly int _targetSampleRate; - private const float ClipVolume = .5f; - - // Audio Filter Settings - private readonly LinkedList _filters = new(); - - // BGM Cached Samples (Pre-resampled) - private float[]? _bgmSamples; - private double _bgmDuration; - - // Clip Cache - private readonly AudioClipCache _clipCache = new(); - private bool _isPrimed; + // Render thread (and Dispose, under _renderLock). + private readonly object _renderLock = new(); + private long _nextOutputSample; + private bool _primed; + private double[] _mixBuffer = Array.Empty(); + private bool _disposed; public OfflineAudioMixer(int targetChannels, int targetSampleRate) { - _targetChannels = targetChannels; - _targetSampleRate = targetSampleRate; + _channels = targetChannels; + _sampleRate = targetSampleRate; + _store = new AudioClipStore(targetSampleRate); + _mixer = new VoiceMixer(_store, targetChannels, _cancellation.Token); var rmsCompressor = new RMSCompressor { ThresholdDb = -10, Ratio = 2, - AttackTime = 0.1f, ReleaseTime = 0.1f, - KneeDb = 10, RmsWindowTime = 0.02f + AttackTime = 0.1, ReleaseTime = 0.1, + KneeDb = 10, RmsWindowTime = 0.02 }; - rmsCompressor.Initialize(_targetSampleRate, _targetChannels); - _filters.AddLast(rmsCompressor); - var limiter = new RMSCompressor { - ThresholdDb = 0, Ratio = float.PositiveInfinity, - AttackTime = 0f, ReleaseTime = 0.05f, - KneeDb = 0, RmsWindowTime = 0.02f, - LookAheadTime = 0.01f + ThresholdDb = 0, Ratio = double.PositiveInfinity, + AttackTime = 0, ReleaseTime = 0.05, + KneeDb = 0, RmsWindowTime = 0.02, + LookAheadTime = 0.01 }; - limiter.Initialize(_targetSampleRate, _targetChannels); - _filters.AddLast(limiter); - - var hardClip = new HardClip(); - hardClip.Initialize(_targetSampleRate, _targetChannels); - _filters.AddLast(hardClip); + _filters.Add(rmsCompressor); + _filters.Add(limiter); + _filters.Add(new HardClip()); + _filters.Add(new Gain { Level = -1 }); - var truePeakSafetyGain = new Gain { Level = -1f }; - truePeakSafetyGain.Initialize(_targetSampleRate, _targetChannels); - _filters.AddLast(truePeakSafetyGain); + double latency = 0; + foreach (var filter in _filters) + { + filter.Initialize(_sampleRate, _channels); + latency += filter.Latency; + } + _latencySamples = (int)Math.Round(latency * _sampleRate); + + _store.Start(); } + /// Clip store of this render (output rate = mixer sample rate), also read by . + internal AudioClipStore Store => _store; + + // ── Main thread ── + + /// Registers the song clip. The song voice is created and kept in sync by . public void InitializeBGM(AudioClip? clip) { - lock (_lock) + if (!clip) { - if (!clip) - { - _bgmSamples = null; - _bgmDuration = 0; - return; - } - - try - { - int originalSamples = clip.samples; - int originalChannels = clip.channels; - float[] rawSamples = new float[originalSamples * originalChannels]; - clip.GetData(rawSamples, 0); - - // Pre-resample the BGM to the target format - _bgmSamples = AudioResampler.Resample(rawSamples, originalChannels, clip.frequency, _targetChannels, _targetSampleRate); - _bgmDuration = (double)_bgmSamples.Length / (_targetChannels * _targetSampleRate); - - Melon.Logger.Msg($"[OfflineAudioMixer] Initialized resampled BGM: {clip.name}, Samples: {_bgmSamples.Length / _targetChannels}, TargetFreq: {_targetSampleRate}Hz, TargetChannels: {_targetChannels}"); - } - catch (Exception e) - { - Melon.Logger.Error($"[OfflineAudioMixer] Failed to initialize and resample BGM: {e}"); - _bgmSamples = null; - _bgmDuration = 0; - } + _bgmSource = null; + return; } + + _bgmKey = clip!.name; + _bgmSource = AudioClipResolver.GetOrAdd(_store, _bgmKey, clip); + if (_bgmSource == null) + { + Melon.Logger.Error($"[OfflineAudioMixer] Song clip '{_bgmKey}' could not be loaded, the song will be silent."); + return; + } + Melon.Logger.Msg($"[OfflineAudioMixer] Song '{_bgmKey}': {_bgmSource.Channels} ch, {_bgmSource.Duration:F2} s, {_bgmSource.Policy}"); } - public void CacheClip(string clipName) + /// + /// Updates the song voice from the conductor (start time, pitch) and the level volume. + /// A changed start time restarts the song voice at position (now - start) * pitch; pitch and volume changes take effect at . + /// Start time: dspTimeSong + separate countdown (crotchetAtStart / pitch * adjustedCountdownTicks when separateCountdownTime), + /// the time scrConductor.StartMusicCo schedules the song at. + /// + /// Current time, seconds relative to the render start. + /// AudioSettings.dspTime at the render start. + public void UpdateBGM(double now, double dspTimeBase) { - _clipCache.GetOrCache(clipName, _targetChannels, _targetSampleRate, allowLoad: true); - } - - public void RegisterSoundEvent(AudioSource? source, string clipName, double startTime, double endTime, float volume, float pitch) - { - lock (_lock) - { - var ev = new SoundEvent - { - ClipName = clipName, - StartTime = startTime, - EndTime = endTime, - Volume = volume, - Pitch = pitch - }; - _activeEvents.Add(ev); - - if (!source) return; - _sourceMappings.Remove(source); - _sourceMappings.Add(source, ev); - } - } - - public void RegisterSoundEvent(string clipName, double startTime, double endTime, float volume, float pitch) - { - RegisterSoundEvent(null, clipName, startTime, endTime, volume, pitch); - } - - public bool TryGetSoundEvent(AudioSource? source, out SoundEvent ev) - { - if (source == null) - { - ev = null!; - return false; - } - lock (_lock) - { - return _sourceMappings.TryGetValue(source, out ev); - } - } - - public bool RemoveSourceMapping(AudioSource source) - { - lock (_lock) - { - return _sourceMappings.Remove(source); - } - } - - public void Reset() - { - lock (_lock) - { - _activeEvents.Clear(); - _sourceMappings = new ConditionalWeakTable(); - _bgmSamples = null; - _bgmDuration = 0; - _clipCache.Clear(); - _isPrimed = false; - foreach (var filter in _filters) - { - filter.Reset(); - } - } - } - - private void PrimeFilters(double totalLatency, double dspTimeBase) - { - int latencySamples = (int)Math.Round(totalLatency * _targetSampleRate); - if (latencySamples <= 0) return; - - // Rent temporary buffer to prime filters with initial audio - float[] tempBuffer = System.Buffers.ArrayPool.Shared.Rent(latencySamples * _targetChannels); - try - { - Array.Clear(tempBuffer, 0, latencySamples * _targetChannels); - - // Mix starting from -totalLatency to 0 - MixBGM(tempBuffer, latencySamples, -totalLatency, dspTimeBase, totalLatency); - MixEffectSounds(tempBuffer, latencySamples, -totalLatency, totalLatency); - - // Feed the buffer into filters to prime circular delay buffers - foreach (var filter in _filters) - { - filter.ProcessBlock(tempBuffer.AsSpan(0, latencySamples * _targetChannels)); - } - } - finally - { - System.Buffers.ArrayPool.Shared.Return(tempBuffer); - } - } - - public void GenerateAudioForFrame(float[] mixedBuffer, int samplesNeeded, double frameStartTime, double dspTimeBase) - { - // mixedBuffer size must be at least samplesNeeded * _targetChannels - Array.Clear(mixedBuffer, 0, samplesNeeded * _targetChannels); - - lock (_lock) - { - double totalLatency = 0; - foreach (var filter in _filters) - { - totalLatency += filter.Latency; - } - - // Prime filters on first frame execution if latency exists - if (!_isPrimed && totalLatency > 0) - { - PrimeFilters(totalLatency, dspTimeBase); - _isPrimed = true; - } - - // 1. Mix BGM - MixBGM(mixedBuffer, samplesNeeded, frameStartTime, dspTimeBase, totalLatency); - - // 2. Mix Hitsounds/Effect Sounds - MixEffectSounds(mixedBuffer, samplesNeeded, frameStartTime, totalLatency); - - foreach (var filter in _filters) - filter.ProcessBlock(mixedBuffer.AsSpan(0, samplesNeeded * _targetChannels)); - - for (var i = 0; i < samplesNeeded * _targetChannels; i++) - { - if (Math.Abs(mixedBuffer[i]) > 1) Melon.Logger.Msg("Warning Why Audio Sample Is Larger Than 1"); - } - } - } - - private void MixBGM(float[] mixedBuffer, int samplesNeeded, double frameStartTime, double dspTimeBase, double totalLatency) - { - if (_bgmSamples == null || _bgmSamples.Length == 0) return; - + if (_bgmSource == null) return; var conductor = scrConductor.instance; if (!conductor) return; double pitch = conductor.song.pitch; - var dspTimeSong = conductor.dspTimeSong; - var separateCountdownOffset = conductor.separateCountdownTime - ? conductor.crotchetAtStart / pitch * conductor.adjustedCountdownTicks - : 0.0; + var countdown = conductor.separateCountdownTime ? conductor.crotchetAtStart / pitch * conductor.adjustedCountdownTicks : 0.0; + var start = conductor.dspTimeSong + countdown - dspTimeBase; + var levelVolume = scnGame.instance ? scnGame.instance.levelData.volume * 0.01 : 1.0; + var volume = levelVolume * ClipVolume; - // BGM Playback start time relative to dspTimeBase - var time1 = dspTimeSong + separateCountdownOffset; - var relativeTime1 = time1 - dspTimeBase; - var adjustedRelativeTime1 = relativeTime1 - totalLatency; - - var totalSamples = _bgmSamples.Length / _targetChannels; - - for (int s = 0; s < samplesNeeded; s++) + if (_bgmVoice == null || start != _bgmStart) { - double sampleTime = frameStartTime + (double)s / _targetSampleRate; - double clipTime = (sampleTime - adjustedRelativeTime1) * pitch; + // Song position at time t is (t - start) * pitch, also when the start is already past. + _bgmVoice?.Stop(now); + _bgmVoice = _mixer.Play(_bgmSource, _bgmKey, Math.Max(start, now), volume, pitch, false, now); + if (start < now) _bgmVoice.SetPosition((now - start) * pitch, now); + _bgmStart = start; + _bgmPitch = pitch; + _bgmVolume = volume; + return; + } - if (clipTime >= 0 && clipTime < _bgmDuration) - { - double sampleIdxDouble = clipTime * _targetSampleRate; - long idxLow = (long)Math.Floor(sampleIdxDouble); - long idxHigh = idxLow + 1; - float t = (float)(sampleIdxDouble - idxLow); - - if (idxHigh >= totalSamples) idxHigh = totalSamples - 1; - if (idxLow >= totalSamples) idxLow = totalSamples - 1; - - for (var c = 0; c < _targetChannels; c++) - { - float valLow = _bgmSamples[idxLow * _targetChannels + c]; - float valHigh = _bgmSamples[idxHigh * _targetChannels + c]; - mixedBuffer[s * _targetChannels + c] += (valLow + t * (valHigh - valLow)) * (scnGame.instance.levelData.volume * 0.01f) - * ClipVolume; - } - } + if (pitch != _bgmPitch) + { + _bgmVoice.SetPitch(pitch, now); + _bgmPitch = pitch; + } + if (volume != _bgmVolume) + { + _bgmVoice.SetVolume(volume, now); + _bgmVolume = volume; } } - private void MixEffectSounds(float[] mixedBuffer, int samplesNeeded, double frameStartTime, double totalLatency) + /// Prepares the source of the game clip (audioLib key) so its first chunk starts loading. + public void CacheClip(string clipName) { - for (var i = _activeEvents.Count - 1; i >= 0; i--) + GetOrAddSource(clipName); + } + + private AudioClipSource? GetOrAddSource(string clipName) + { + if (string.IsNullOrEmpty(clipName)) return null; + if (_store.TryGet(clipName, out var existing)) return existing; + var clip = AudioManager.Instance ? AudioManager.Instance.FindOrLoadAudioClip(clipName) : null; + return AudioClipResolver.GetOrAdd(_store, clipName, clip); + } + + /// + /// Starts an effect sound at . An of 0 or more makes it loop until then. + /// (when given) is mapped to the returned state, which reports and applies later changes through the AudioSource. + /// + public SourceState 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 (_effectVoices.Count >= _effectVoicePruneAt) { - var ev = _activeEvents[i]; - var clipData = _clipCache.GetOrCache(ev.ClipName, _targetChannels, _targetSampleRate, allowLoad: false); - if (clipData == null) - { - _activeEvents.RemoveAt(i); - continue; - } + _effectVoices.RemoveAll(v => v.IsFinished); + _effectVoicePruneAt = Math.Max(EffectVoicePruneCount, _effectVoices.Count * 2); + } + _effectVoices.Add(voice); - var isSoundFinished = false; - var totalSamples = clipData.Samples.Length / _targetChannels; - double adjustedStartTime = ev.StartTime - totalLatency; - double adjustedEndTime = ev.EndTime >= 0 ? ev.EndTime - totalLatency : -1.0; + var state = new SourceState(voice, Math.Max(startTime, now), pitch, loop, loop ? endTime : double.PositiveInfinity, + clipSource?.Duration ?? 0.0, _sampleRate); + if (source) MapSource(source!, state); + return state; + } - for (var s = 0; s < samplesNeeded; s++) - { - var sampleTime = frameStartTime + (double)s / _targetSampleRate; + /// + /// Maps to (replaces an earlier mapping) and takes the clip length and + /// sample rate from the clip of the source. + /// + public void MapSource(AudioSource source, SourceState state) + { + state.BindClip(source.clip); + _sourceStates.Remove(source); + _sourceStates.Add(source, state); + } - // If it is a looping sound and has reached or exceeded its end time, stop playing. - if (adjustedEndTime >= 0 && sampleTime >= adjustedEndTime) - { - isSoundFinished = true; - break; - } + /// State of the effect sound last started for . The mapping stays after the sound ends or stops. + public bool TryGetSourceState(AudioSource? source, out SourceState state) + { + state = null!; + return source != null && _sourceStates.TryGetValue(source, out state); + } - var elapsed = sampleTime - adjustedStartTime; - if (elapsed < 0) continue; // Sound hasn't started yet + /// + /// 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(); + _sourceStates = new ConditionalWeakTable(); + } - var soundTime = elapsed * ev.Pitch; - - if (soundTime >= clipData.Duration) - { - if (ev.EndTime < 0) // One-shot - { - isSoundFinished = true; - break; - } - - // Looping - soundTime %= clipData.Duration; - } - - // Linear interpolation for pitch shifting in the mixer loop - double sampleIdxDouble = soundTime * _targetSampleRate; - long idxLow = (long)Math.Floor(sampleIdxDouble); - long idxHigh = idxLow + 1; - float t = (float)(sampleIdxDouble - idxLow); - - if (idxHigh >= totalSamples) idxHigh = totalSamples - 1; - if (idxLow >= totalSamples) idxLow = totalSamples - 1; - - for (var c = 0; c < _targetChannels; c++) - { - float valLow = clipData.Samples[idxLow * _targetChannels + c]; - float valHigh = clipData.Samples[idxHigh * _targetChannels + c]; - mixedBuffer[s * _targetChannels + c] += (valLow + t * (valHigh - valLow)) * ev.Volume - * ClipVolume * 2 / 3; - } - } - - // Clean up finished sound events (both one-shot and looping) - if (isSoundFinished) - { - _activeEvents.RemoveAt(i); - } + /// + /// Marks every command with a time before as queued + /// (called after each frame with the time of the next frame). + /// + public void Commit(double time) + { + var sample = VoiceMixer.ToSample(time, _sampleRate); + lock (_commitLock) + { + if (sample <= _committedSample) return; + _committedSample = sample; + Monitor.PulseAll(_commitLock); } } -} \ No newline at end of file + + /// No more commands will be queued (render ending): mixing no longer waits for commits. + public void Complete() + { + lock (_commitLock) + { + _committedSample = long.MaxValue; + Monitor.PulseAll(_commitLock); + } + } + + /// Stops waiting for commits and chunks; the render thread's current or next mix throws OperationCanceledException. + public void Cancel() + { + _cancellation.Cancel(); + Complete(); + } + + // ── Render thread ── + + /// + /// Writes the next output frames (interleaved float, continuing from the previous call). + /// + /// Chunk loading failed fatally (out of memory or the decoder thread stopped). + /// The render was canceled (). + public void GenerateAudioForFrame(float[] mixedBuffer, int samplesNeeded) + { + lock (_renderLock) + { + if (_disposed) throw new ObjectDisposedException(nameof(OfflineAudioMixer)); + + if (!_primed) + { + _primed = true; + if (_latencySamples > 0) + { + var prime = MixBlock(0, _latencySamples); + foreach (var filter in _filters) + filter.ProcessBlock(prime); + } + } + + var block = MixBlock(_nextOutputSample + _latencySamples, samplesNeeded); + foreach (var filter in _filters) + filter.ProcessBlock(block); + _nextOutputSample += samplesNeeded; + + for (var i = 0; i < block.Length; i++) + mixedBuffer[i] = (float)block[i]; + } + } + + /// Mixes voices at samples [first, first + frames) into the reused double buffer, after the main thread committed them. + private Span MixBlock(long first, int frames) + { + WaitForCommit(first + frames); + + var length = frames * _channels; + if (_mixBuffer.Length < length) _mixBuffer = new double[length]; + var block = _mixBuffer.AsSpan(0, length); + block.Clear(); + _mixer.Mix(first, block); + return block; + } + + private void WaitForCommit(long endSample) + { + lock (_commitLock) + { + while (_committedSample < endSample) + { + _cancellation.Token.ThrowIfCancellationRequested(); + Monitor.Wait(_commitLock, CommitWaitMs); + } + } + _cancellation.Token.ThrowIfCancellationRequested(); + } + + /// + /// Cancels waits, then releases the voices, the clip store (decoder thread) and the filter state. + /// Waits for a running to return. + /// + public void Dispose() + { + Cancel(); + lock (_renderLock) + { + if (_disposed) return; + _disposed = true; + _mixer.Dispose(); + _store.Dispose(); + foreach (var filter in _filters) + filter.Reset(); + } + _sourceStates = new ConditionalWeakTable(); + _effectVoices.Clear(); + } +} diff --git a/Util/Audio/SourceState.cs b/Util/Audio/SourceState.cs new file mode 100644 index 0000000..161c3ad --- /dev/null +++ b/Util/Audio/SourceState.cs @@ -0,0 +1,140 @@ +using System; +using ADOFAIRenderer.Util.Audio.Mixer; +using UnityEngine; + +namespace ADOFAIRenderer.Util.Audio; + +/// +/// Playback state of a game AudioSource mapped to an effect , as the AudioSource reports it to the game +/// (isPlaying, time, timeSamples, pitch). Main thread only. Times are seconds relative to the render start (the render clock that +/// also drives AudioSettings.dspTime during rendering); clip positions are seconds into the clip. +/// +/// Reported values: +/// - Playing: from the scheduled start until Stop(), the scheduled end time, or the natural end of the clip (not looping). +/// Before the start, isPlaying is true and time is the position playback will start from. +/// - time while playing: start position + elapsed time * pitch since the last change, wrapped at the clip length when looping. +/// time is 0 while not playing. +/// - timeSamples while playing: floor(time * clip frequency). While not playing: the last value set through time or timeSamples, +/// reset to 0 by Stop(). +/// - pitch: the last finite value set (NaN and infinity are ignored). Playback uses max(0, pitch). +/// Changes made while not playing are stored and do not reach the voice. +/// +internal sealed class SourceState +{ + private readonly double _start; + private double _anchorTime; + private double _anchorPosition; + private double _pitch; + private readonly bool _loop; + private double _endTime = double.PositiveInfinity; + private bool _stopped; + private int _offsetSamples; + private double _length; + private int _frequency; + + /// Voice that plays the sound. + /// Start of playback (seconds relative to the render start, not before the time it was scheduled). + /// Pitch at the start. + /// Repeats the clip. + /// Scheduled end time (seconds relative to the render start), or infinity. + /// Clip length in seconds (replaced by the game clip in ). + /// Clip sample rate for timeSamples (replaced by the game clip in ). + public SourceState(Voice voice, double start, double pitch, bool loop, double endTime, double length, int frequency) + { + Voice = voice; + _start = start; + _anchorTime = start; + _pitch = pitch; + _loop = loop; + _endTime = endTime; + _length = length; + _frequency = Math.Max(1, frequency); + } + + public Voice Voice { get; } + + /// Takes the clip length and sample rate from the clip of the game AudioSource. + public void BindClip(AudioClip? clip) + { + if (!clip) return; + _length = clip!.length; + _frequency = Math.Max(1, clip.frequency); + } + + /// Scheduled end time (see AudioSource.SetScheduledEndTime), seconds relative to the render start. + public void SetEndTime(double endTime, double now) + { + if (!IsPlaying(now)) return; + _endTime = endTime; + Voice.SetEndTime(endTime, now); + } + + public bool IsPlaying(double now) => !_stopped && now < Math.Min(_endTime, NaturalEnd()); + + public float GetTime(double now) => IsPlaying(now) ? (float)PositionAt(now) : 0f; + + public int GetTimeSamples(double now) => IsPlaying(now) ? (int)Math.Floor(PositionAt(now) * _frequency) : _offsetSamples; + + public float GetPitch() => (float)_pitch; + + public void SetTime(float seconds, double now) + { + _offsetSamples = (int)Math.Floor((double)seconds * _frequency); + Seek(seconds, now); + } + + public void SetTimeSamples(int samples, double now) + { + _offsetSamples = samples; + Seek((double)samples / _frequency, now); + } + + public void SetPitch(float pitch, double now) + { + if (float.IsNaN(pitch) || float.IsInfinity(pitch)) return; + var playing = IsPlaying(now); + if (playing) Reanchor(now); + _pitch = pitch; + if (playing) Voice.SetPitch(pitch, now); + } + + public void Stop(double now) + { + if (IsPlaying(now)) Voice.Stop(now); + _stopped = true; + _offsetSamples = 0; + } + + private double PlaybackPitch => Math.Max(0.0, _pitch); + + private void Seek(double seconds, double now) + { + if (!IsPlaying(now)) return; + var position = Math.Max(0.0, seconds); + _anchorTime = Math.Max(now, _start); + _anchorPosition = position; + Voice.SetPosition(position, now); + } + + private void Reanchor(double now) + { + var time = Math.Max(now, _start); + _anchorPosition = PositionAt(time); + _anchorTime = time; + } + + /// Clip position at (the start position before the start), wrapped when looping. + private double PositionAt(double now) + { + var position = _anchorPosition + (Math.Max(now, _anchorTime) - _anchorTime) * PlaybackPitch; + if (_loop && _length > 0 && position >= _length) position %= _length; + return position; + } + + /// Time at which the clip position reaches the clip length (infinity when looping or at pitch 0). + private double NaturalEnd() + { + if (_loop || PlaybackPitch <= 0) return double.PositiveInfinity; + return _anchorTime + Math.Max(0.0, _length - _anchorPosition) / PlaybackPitch; + } +}