SHA256
Merge pull request 'Rewrite Audio' (#5) from feature/audio_rewrite into main
Reviewed-on: #5
This commit is contained in:
commit
fab189ff3b
24 files changed
+3529
-672
No files matched your search
+52
-25
@@ -8,6 +8,7 @@ using System.Diagnostics;
|
|||||||
using ADOFAIRenderer.API;
|
using ADOFAIRenderer.API;
|
||||||
using ADOFAIRenderer.Core.Data;
|
using ADOFAIRenderer.Core.Data;
|
||||||
using ADOFAIRenderer.Data;
|
using ADOFAIRenderer.Data;
|
||||||
|
using ADOFAIRenderer.Patch.Engine;
|
||||||
using ADOFAIRenderer.UI;
|
using ADOFAIRenderer.UI;
|
||||||
using ADOFAIRenderer.UI.Window;
|
using ADOFAIRenderer.UI.Window;
|
||||||
using ADOFAIRenderer.Util;
|
using ADOFAIRenderer.Util;
|
||||||
@@ -165,6 +166,9 @@ internal class RenderManager : MonoBehaviour
|
|||||||
public int frameCountBase;
|
public int frameCountBase;
|
||||||
|
|
||||||
public OfflineAudioMixer? AudioMixer { get; private set; }
|
public OfflineAudioMixer? AudioMixer { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Render time of the current frame in seconds (frameCount / RenderFPS), the time base of audio commands.</summary>
|
||||||
|
public double AudioTime => frameCount / Configuration.RenderFPS;
|
||||||
private AudioSyncCalculator? SyncCalculator { get; set; }
|
private AudioSyncCalculator? SyncCalculator { get; set; }
|
||||||
|
|
||||||
private FrameReader? _frameReader;
|
private FrameReader? _frameReader;
|
||||||
@@ -276,8 +280,10 @@ internal class RenderManager : MonoBehaviour
|
|||||||
if (stage != RenderStage.None)
|
if (stage != RenderStage.None)
|
||||||
MarkFailed("the game exited or RenderManager was destroyed during rendering.");
|
MarkFailed("the game exited or RenderManager was destroyed during rendering.");
|
||||||
ResetRenderState(success: false);
|
ResetRenderState(success: false);
|
||||||
|
|
||||||
AudioSpectrumGenerator.Clear();
|
AudioSpectrumGenerator.Clear();
|
||||||
|
// Cancels the render thread's audio waits, then releases the clip store (decoder thread, readers, chunks).
|
||||||
|
AudioMixer?.Dispose(); AudioMixer = null;
|
||||||
|
|
||||||
ResetFrameHookState();
|
ResetFrameHookState();
|
||||||
|
|
||||||
@@ -476,7 +482,17 @@ internal class RenderManager : MonoBehaviour
|
|||||||
// ReSharper disable once Unity.IncorrectMethodSignature
|
// ReSharper disable once Unity.IncorrectMethodSignature
|
||||||
public void OnPostFrame(RenderTexture? texture)
|
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]
|
// [Backpressure Throttling]
|
||||||
// The main thread waits while ImageQueue holds more than QueueFrameLimit frames. Only the render thread
|
// 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;
|
scrController.instance.levelWasSkipped = true;
|
||||||
|
|
||||||
if (!scrConductor.instance || !scrConductor.instance.song) yield break;
|
if (!scrConductor.instance || !scrConductor.instance.song) yield break;
|
||||||
if (Configuration.AdvancedConfig.ProcessAudio)
|
|
||||||
{
|
|
||||||
AudioSpectrumGenerator.Initialize(scrConductor.instance.song.clip);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Configuration.AudioConfig.RenderAudio)
|
if (Configuration.AudioConfig.RenderAudio)
|
||||||
{
|
{
|
||||||
AudioMixer = new OfflineAudioMixer(Configuration.AudioConfig.AudioChannels, Configuration.AudioConfig.AudioSampleRate);
|
AudioMixer = new OfflineAudioMixer(Configuration.AudioConfig.AudioChannels, Configuration.AudioConfig.AudioSampleRate);
|
||||||
AudioMixer.InitializeBGM(scrConductor.instance.song.clip);
|
AudioMixer.InitializeBGM(scrConductor.instance.song.clip);
|
||||||
|
|
||||||
var effectiveVideoFps = Math.Min(Configuration.VideoFPS, Configuration.RenderFPS);
|
var effectiveVideoFps = Math.Min(Configuration.VideoFPS, Configuration.RenderFPS);
|
||||||
SyncCalculator = new AudioSyncCalculator(Configuration.AudioConfig.AudioSampleRate, effectiveVideoFps);
|
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.clip = null;
|
||||||
scrConductor.instance.song.time = -1000;
|
scrConductor.instance.song.time = -1000;
|
||||||
@@ -786,23 +802,35 @@ internal class RenderManager : MonoBehaviour
|
|||||||
|
|
||||||
endSuccessful = !IsRenderAborted;
|
endSuccessful = !IsRenderAborted;
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException) when (IsRenderAborted)
|
||||||
|
{
|
||||||
|
// Audio waits end with OperationCanceledException when the render is cancelled or has failed (OfflineAudioMixer.Cancel).
|
||||||
|
Melon<RenderMod>.Logger.Msg("Render thread stopped: the render was aborted.");
|
||||||
|
DisposeEncoderAfterStop(videoFFmpeg);
|
||||||
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Melon<RenderMod>.Logger.Error($"FFmpeg rendering thread error: {ex}");
|
Melon<RenderMod>.Logger.Error($"FFmpeg rendering thread error: {ex}");
|
||||||
|
|
||||||
MarkFailed("encoder thread error. See the log for details.");
|
MarkFailed("encoder thread error. See the log for details.");
|
||||||
try
|
DisposeEncoderAfterStop(videoFFmpeg);
|
||||||
{
|
|
||||||
videoFFmpeg.Dispose();
|
|
||||||
}
|
|
||||||
catch (Exception disposeEx)
|
|
||||||
{
|
|
||||||
Melon<RenderMod>.Logger.Error($"Encoder dispose after thread error also failed: {disposeEx.Message}");
|
|
||||||
}
|
|
||||||
UnregisterEncoder(videoFFmpeg);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Releases the encoder after the render thread stopped early (abort or error) and removes it from the encoder list.</summary>
|
||||||
|
private void DisposeEncoderAfterStop(FFmpegNativeEncoder videoFFmpeg)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
videoFFmpeg.Dispose();
|
||||||
|
}
|
||||||
|
catch (Exception disposeEx)
|
||||||
|
{
|
||||||
|
Melon<RenderMod>.Logger.Error($"Encoder dispose after the render thread stopped also failed: {disposeEx.Message}");
|
||||||
|
}
|
||||||
|
UnregisterEncoder(videoFFmpeg);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Takes frames from ImageQueue in passCount order and sends each one to the encoder, followed by the audio
|
/// 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.
|
/// 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;
|
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 channels = Configuration.AudioConfig.AudioChannels;
|
||||||
var samplesNeeded = syncCalculator.GetNextFrameSamples();
|
var samplesNeeded = syncCalculator.GetNextFrameSamples();
|
||||||
|
|
||||||
var audioBuffer = ArrayPool<float>.Shared.Rent(samplesNeeded * channels);
|
var audioBuffer = ArrayPool<float>.Shared.Rent(samplesNeeded * channels);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
AudioMixer?.GenerateAudioForFrame(audioBuffer, samplesNeeded, frameTime, dspTimeBase);
|
AudioMixer?.GenerateAudioForFrame(audioBuffer, samplesNeeded);
|
||||||
videoFFmpeg.WriteAudioSamples(audioBuffer, samplesNeeded);
|
videoFFmpeg.WriteAudioSamples(audioBuffer, samplesNeeded);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -945,6 +969,9 @@ internal class RenderManager : MonoBehaviour
|
|||||||
{
|
{
|
||||||
MarkCancelled();
|
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();
|
FrameAvailableEvent.Set();
|
||||||
skipPressToStart = false;
|
skipPressToStart = false;
|
||||||
_renderSetup = false;
|
_renderSetup = false;
|
||||||
@@ -1087,10 +1114,10 @@ internal class RenderManager : MonoBehaviour
|
|||||||
|
|
||||||
_frameReader?.Dispose();
|
_frameReader?.Dispose();
|
||||||
ResetFrameHookState();
|
ResetFrameHookState();
|
||||||
AudioMixer?.Reset(); AudioMixer = null;
|
|
||||||
SyncCalculator = null;
|
|
||||||
|
|
||||||
AudioSpectrumGenerator.Clear();
|
AudioSpectrumGenerator.Clear();
|
||||||
|
AudioMixer?.Dispose(); AudioMixer = null;
|
||||||
|
SyncCalculator = null;
|
||||||
|
|
||||||
RenderContext.Reset();
|
RenderContext.Reset();
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+235
-25
@@ -1,11 +1,33 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using ADOFAIRenderer.Core;
|
using ADOFAIRenderer.Core;
|
||||||
|
using ADOFAIRenderer.Util.Audio;
|
||||||
|
using ADOFAIRenderer.Util.Audio.Clip;
|
||||||
using HarmonyLib;
|
using HarmonyLib;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
using UnityEngine.Audio;
|
using UnityEngine.Audio;
|
||||||
|
|
||||||
namespace ADOFAIRenderer.Patch.Engine;
|
namespace ADOFAIRenderer.Patch.Engine;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[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")]
|
[HarmonyPatch(typeof(AudioManager), "_Play")]
|
||||||
public static class AudioManagerPlayPatch
|
public static class AudioManagerPlayPatch
|
||||||
{
|
{
|
||||||
@@ -30,14 +52,27 @@ public static class AudioManagerPlayPatch
|
|||||||
double relativeStartTime = time - RenderManager.Instance.dspTimeBase;
|
double relativeStartTime = time - RenderManager.Instance.dspTimeBase;
|
||||||
|
|
||||||
// Register event to the offline mixer (default pitch = 1.0f) and map it to the AudioSource
|
// 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// scrConductor.PlayWithEndTime: registers a looping effect voice until the end time. The AudioSource it creates with
|
||||||
|
/// AudioManager.MakeSource is mapped to that voice (<see cref="AudioManagerMakeSourcePatch"/>).
|
||||||
|
/// </summary>
|
||||||
[HarmonyPatch(typeof(scrConductor), "PlayWithEndTime")]
|
[HarmonyPatch(typeof(scrConductor), "PlayWithEndTime")]
|
||||||
public static class PlayWithEndTimePatch
|
public static class PlayWithEndTimePatch
|
||||||
{
|
{
|
||||||
|
/// <summary>State registered by the running PlayWithEndTime call, until its MakeSource returns. Main thread only.</summary>
|
||||||
|
internal static SourceState? PendingState;
|
||||||
|
|
||||||
|
[HarmonyFinalizer]
|
||||||
|
public static void Finalizer() => PendingState = null;
|
||||||
|
|
||||||
[HarmonyPrefix]
|
[HarmonyPrefix]
|
||||||
public static bool Prefix(string snd, double time, double endTime, float volume, int priority)
|
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;
|
double relativeEndTime = endTime - RenderManager.Instance.dspTimeBase;
|
||||||
|
|
||||||
// Register looping sound event to the offline mixer (default pitch = 1.0f)
|
// 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
|
return true; // Let the original method execute
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Maps the AudioSource created inside scrConductor.PlayWithEndTime to its state (<see cref="PlayWithEndTimePatch.PendingState"/>).</summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AudioSource members of an effect source mapped to a <see cref="SourceState"/> 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 <see cref="TryGet"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
internal static class EffectSource
|
||||||
|
{
|
||||||
|
/// <summary>Sources mapped during the current frame, deactivated by <see cref="DeactivateMappedSources"/>. Main thread only.</summary>
|
||||||
|
private static readonly List<AudioSource> PendingDeactivation = new();
|
||||||
|
|
||||||
|
/// <summary>Deactivates the game object of <paramref name="source"/> at the end of the current frame (<see cref="DeactivateMappedSources"/>).</summary>
|
||||||
|
public static void DeactivateAtFrameEnd(AudioSource source) => PendingDeactivation.Add(source);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
public static void DeactivateMappedSources(OfflineAudioMixer mixer)
|
||||||
|
{
|
||||||
|
foreach (var source in PendingDeactivation)
|
||||||
|
{
|
||||||
|
if (source && mixer.TryGetSourceState(source, out _)) source.gameObject.SetActive(false);
|
||||||
|
}
|
||||||
|
PendingDeactivation.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// State mapped to <paramref name="source"/> in the mixer of the current render, with the current render time in <paramref name="now"/>.
|
||||||
|
/// False outside rendering (stage below Render), without a mixer, or for a source that is not mapped.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AudioSource.isPlaying getter (<see cref="EffectSource"/>): 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.
|
||||||
|
/// </summary>
|
||||||
|
[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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>AudioSource.time getter (<see cref="EffectSource"/>): clip position in seconds while playing, 0 while not playing.</summary>
|
||||||
|
[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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AudioSource.time setter (<see cref="EffectSource"/>): 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.
|
||||||
|
/// </summary>
|
||||||
|
[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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>AudioSource.timeSamples getter (<see cref="EffectSource"/>): clip position in samples of the clip.</summary>
|
||||||
|
[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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>AudioSource.timeSamples setter (<see cref="EffectSource"/>): moves the playback position (samples of the clip).</summary>
|
||||||
|
[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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>AudioSource.pitch getter (<see cref="EffectSource"/>): the last pitch set.</summary>
|
||||||
|
[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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>AudioSource.pitch setter (<see cref="EffectSource"/>): changes the playback rate from the current render time.</summary>
|
||||||
[HarmonyPatch(typeof(AudioSource), "pitch", MethodType.Setter)]
|
[HarmonyPatch(typeof(AudioSource), "pitch", MethodType.Setter)]
|
||||||
public static class AudioSourcePitchPatch
|
public static class AudioSourcePitchPatch
|
||||||
{
|
{
|
||||||
[HarmonyPostfix]
|
[HarmonyPrefix]
|
||||||
// ReSharper disable once InconsistentNaming
|
// 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)
|
if (!EffectSource.TryGet(__instance, out var state, out var now)) return true;
|
||||||
{
|
state.SetPitch(value, now);
|
||||||
var mixer = RenderManager.Instance.AudioMixer;
|
return false;
|
||||||
if (mixer != null && mixer.TryGetSoundEvent(__instance, out var soundEvent))
|
|
||||||
{
|
|
||||||
soundEvent.Pitch = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>AudioSource.SetScheduledEndTime (<see cref="EffectSource"/>): DSP time (absolute) at which playback stops.</summary>
|
||||||
[HarmonyPatch(typeof(AudioSource), "SetScheduledEndTime", typeof(double))]
|
[HarmonyPatch(typeof(AudioSource), "SetScheduledEndTime", typeof(double))]
|
||||||
public static class AudioSourceSetScheduledEndTimePatch
|
public static class AudioSourceSetScheduledEndTimePatch
|
||||||
{
|
{
|
||||||
[HarmonyPostfix]
|
[HarmonyPrefix]
|
||||||
// ReSharper disable once InconsistentNaming
|
// 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)
|
if (!EffectSource.TryGet(__instance, out var state, out var now)) return true;
|
||||||
{
|
state.SetEndTime(time - RenderManager.Instance.dspTimeBase, now);
|
||||||
var mixer = RenderManager.Instance.AudioMixer;
|
return false;
|
||||||
if (mixer != null && mixer.TryGetSoundEvent(__instance, out var soundEvent))
|
|
||||||
{
|
|
||||||
soundEvent.EndTime = time - RenderManager.Instance.dspTimeBase;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AudioSource.Stop() (<see cref="EffectSource"/>): 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.
|
||||||
|
/// </summary>
|
||||||
[HarmonyPatch(typeof(AudioSource), "Stop", new Type[0])]
|
[HarmonyPatch(typeof(AudioSource), "Stop", new Type[0])]
|
||||||
public static class AudioSourceStopPatch
|
public static class AudioSourceStopPatch
|
||||||
{
|
{
|
||||||
[HarmonyPostfix]
|
[HarmonyPrefix]
|
||||||
// ReSharper disable once InconsistentNaming
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[HarmonyPatch(typeof(AudioManager), nameof(AudioManager.StopAllSounds))]
|
||||||
|
public static class AudioManagerStopAllSoundsPatch
|
||||||
|
{
|
||||||
|
[HarmonyPrefix]
|
||||||
|
public static void Prefix()
|
||||||
{
|
{
|
||||||
if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio)
|
if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio)
|
||||||
{
|
{
|
||||||
RenderManager.Instance.AudioMixer?.RemoveSourceMapping(__instance);
|
RenderManager.Instance.AudioMixer?.StopAllEffects(RenderManager.Instance.AudioTime);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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<float>();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,164 +1,188 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using ADOFAIRenderer.Core;
|
using ADOFAIRenderer.Core;
|
||||||
|
using ADOFAIRenderer.Util.Audio.Clip;
|
||||||
|
using MelonLoader;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
|
|
||||||
namespace ADOFAIRenderer.Util.Audio;
|
namespace ADOFAIRenderer.Util.Audio;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Replacement for AudioSource.GetSpectrumData during rendering: the spectrum of the song as the song AudioSource would output it.
|
||||||
|
///
|
||||||
|
/// Signal: the song clip from an <see cref="AudioClipStore"/> 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 <see cref="MonoGain"/>.
|
||||||
|
/// Window: twice the requested spectrum length in output samples, centered on the current render time.
|
||||||
|
///
|
||||||
|
/// The song source is shared with the <see cref="OfflineAudioMixer"/> 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.
|
||||||
|
/// </summary>
|
||||||
internal static class AudioSpectrumGenerator
|
internal static class AudioSpectrumGenerator
|
||||||
{
|
{
|
||||||
// ReSharper disable MemberCanBePrivate.Global
|
/// <summary>
|
||||||
public static float[]? AudioClipData { get; private set; }
|
/// Gain of a mono clip relative to one channel of a stereo clip with the same samples.
|
||||||
public static int Channels { get; private set; }
|
/// 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).
|
||||||
public static int Frequency { get; private set; }
|
/// </summary>
|
||||||
public static int TotalSamples { get; private set; }
|
private const double MonoGain = 0.70710678118654752;
|
||||||
// ReSharper restore MemberCanBePrivate.Global
|
|
||||||
|
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 long _cachedFrame = -1;
|
||||||
private static readonly Dictionary<int, float[]> CachedSpectrums = new();
|
private static readonly Dictionary<(int Size, int Channel), float[]> CachedSpectrums = new();
|
||||||
|
|
||||||
public static void Initialize(AudioClip? clip)
|
/// <summary>Registers the song clip. Call after the <see cref="OfflineAudioMixer"/> is created (null when audio is not rendered).</summary>
|
||||||
|
public static void Initialize(AudioClip? clip, OfflineAudioMixer? mixer)
|
||||||
{
|
{
|
||||||
if (!clip)
|
Clear();
|
||||||
{
|
if (!clip) return;
|
||||||
Clear();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Channels = clip.channels;
|
var rate = AudioSettings.outputSampleRate;
|
||||||
Frequency = clip.frequency;
|
if (mixer != null && mixer.Store.OutputRate == rate)
|
||||||
int samplesCount = clip.samples;
|
|
||||||
|
|
||||||
float[] rawSamples = new float[samplesCount * Channels];
|
|
||||||
clip.GetData(rawSamples, 0);
|
|
||||||
|
|
||||||
if (Channels == 1)
|
|
||||||
{
|
{
|
||||||
AudioClipData = rawSamples;
|
_store = mixer.Store;
|
||||||
TotalSamples = samplesCount;
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
AudioClipData = new float[samplesCount];
|
_ownStore = new AudioClipStore(rate);
|
||||||
TotalSamples = samplesCount;
|
_ownStore.Start();
|
||||||
for (int i = 0; i < samplesCount; i++)
|
_store = _ownStore;
|
||||||
{
|
|
||||||
float sum = 0f;
|
|
||||||
for (int c = 0; c < Channels; c++)
|
|
||||||
{
|
|
||||||
sum += rawSamples[i * Channels + c];
|
|
||||||
}
|
|
||||||
AudioClipData[i] = sum / Channels;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
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<RenderMod>.Logger.Error($"[AudioSpectrumGenerator] Song clip '{clip.name}' could not be loaded, the spectrum will be empty.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_cursor = _source.AddCursor(0, false);
|
||||||
|
Melon<RenderMod>.Logger.Msg($"[AudioSpectrumGenerator] Song '{clip.name}': {_source.Channels} ch at {rate} Hz ({(_ownStore != null ? "own store" : "shared with the mixer")})");
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
MelonLoader.MelonLogger.Error($"[AudioSpectrumGenerator] Failed to initialize audio cache: {e}");
|
Melon<RenderMod>.Logger.Error($"[AudioSpectrumGenerator] Failed to initialize: {e}");
|
||||||
Clear();
|
Clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Releases the chunk, the cursor and the own store. Call before the mixer store is disposed.</summary>
|
||||||
public static void Clear()
|
public static void Clear()
|
||||||
{
|
{
|
||||||
AudioClipData = null;
|
if (_source != null)
|
||||||
TotalSamples = 0;
|
|
||||||
Channels = 0;
|
|
||||||
Frequency = 0;
|
|
||||||
lock (CachedSpectrums)
|
|
||||||
{
|
{
|
||||||
_cachedFrame = -1;
|
_source.Release(_view);
|
||||||
CachedSpectrums.Clear();
|
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)
|
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;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var currentFrame = RenderManager.Instance ? RenderManager.Instance.frameCount : -1;
|
var render = RenderManager.Instance;
|
||||||
|
var currentFrame = render.frameCount;
|
||||||
lock (CachedSpectrums)
|
if (currentFrame != _cachedFrame)
|
||||||
{
|
{
|
||||||
if (currentFrame != _cachedFrame)
|
_cachedFrame = currentFrame;
|
||||||
{
|
CachedSpectrums.Clear();
|
||||||
_cachedFrame = currentFrame;
|
|
||||||
CachedSpectrums.Clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (CachedSpectrums.TryGetValue(n, out var cached))
|
|
||||||
{
|
|
||||||
Array.Copy(cached, samples, n);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 fftSize = 2 * n;
|
||||||
var frequency = Frequency;
|
var gain = volume * (_source.Channels == 1 ? MonoGain : 1.0);
|
||||||
var totalSamples = TotalSamples;
|
var first = songSeconds * _store.OutputRate - n * pitch;
|
||||||
var currentSampleIndex = (long)(songTime * frequency);
|
|
||||||
|
|
||||||
var windowSamples = new float[fftSize];
|
var windowSamples = new float[fftSize];
|
||||||
for (var i = 0; i < fftSize; i++)
|
try
|
||||||
{
|
{
|
||||||
var idx = currentSampleIndex - fftSize / 2 + i;
|
_cursor!.Position = Math.Min(Math.Max((long)first, 0), _source.TotalFrames - 1);
|
||||||
if (idx >= 0 && idx < totalSamples)
|
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<RenderMod>.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];
|
var spectrum = new float[n];
|
||||||
CalculateFFT(windowSamples, spectrum);
|
CalculateFFT(windowSamples, spectrum);
|
||||||
|
CachedSpectrums[(n, sourceChannel)] = spectrum;
|
||||||
|
Array.Copy(spectrum, samples, n);
|
||||||
|
}
|
||||||
|
|
||||||
lock (CachedSpectrums)
|
/// <summary>Sample of source channel <paramref name="channel"/> at <paramref name="frame"/>; 0 outside the clip or when the source failed.</summary>
|
||||||
|
/// <exception cref="InvalidOperationException">Chunk loading failed fatally (see <see cref="AudioClipStore.Acquire"/>).</exception>
|
||||||
|
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;
|
source.Release(_view);
|
||||||
Array.Copy(spectrum, samples, n);
|
_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)
|
private static void CalculateFFT(float[] samples, float[] spectrum)
|
||||||
{
|
{
|
||||||
var n = samples.Length;
|
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 a0 = 0.35875f;
|
||||||
const float a1 = 0.48829f;
|
const float a1 = 0.48829f;
|
||||||
const float a2 = 0.14128f;
|
const float a2 = 0.14128f;
|
||||||
@@ -169,7 +193,7 @@ internal static class AudioSpectrumGenerator
|
|||||||
|
|
||||||
for (var i = 0; i < n; i++)
|
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));
|
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;
|
re[i] = samples[i] * w;
|
||||||
im[i] = 0f;
|
im[i] = 0f;
|
||||||
@@ -230,12 +254,13 @@ internal static class AudioSpectrumGenerator
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalize and fill spectrum
|
// Magnitude scale 2.5 / n (n = FFT size, twice the spectrum length), clamped to 1.0: the scale of Unity GetSpectrumData.
|
||||||
var factor = 2.0f / n;
|
// 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);
|
var outLen = Math.Min(spectrum.Length, n / 2);
|
||||||
for (var i = 0; i < outLen; i++)
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
|
||||||
|
namespace ADOFAIRenderer.Util.Audio.Clip;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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".
|
||||||
|
/// <see cref="Record"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
internal static class AudioClipPaths
|
||||||
|
{
|
||||||
|
/// <summary>Suffix of the game's audioLib keys for external clips.</summary>
|
||||||
|
public const string ExternalSuffix = "*external";
|
||||||
|
|
||||||
|
private static readonly Dictionary<string, string> Paths = new();
|
||||||
|
|
||||||
|
/// <summary>audioLib key of an external clip loaded from <paramref name="path"/>.</summary>
|
||||||
|
public static string KeyOf(string path) => Path.GetFileName(path) + ExternalSuffix;
|
||||||
|
|
||||||
|
/// <summary>Records <paramref name="path"/> under <see cref="KeyOf"/>(path), replacing an earlier path of the same key.</summary>
|
||||||
|
public static void Record(string path)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(path)) return;
|
||||||
|
lock (Paths)
|
||||||
|
{
|
||||||
|
Paths[KeyOf(path)] = path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Recorded path for an audioLib key (or AudioClip.name) of an external clip.</summary>
|
||||||
|
public static bool TryGetPath(string key, out string path)
|
||||||
|
{
|
||||||
|
lock (Paths)
|
||||||
|
{
|
||||||
|
return Paths.TryGetValue(key, out path!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using MelonLoader;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace ADOFAIRenderer.Util.Audio.Clip;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens the <see cref="AudioClipSource"/> for a clip the game plays, keyed by the game's audioLib key (also AudioClip.name):
|
||||||
|
/// - Key recorded in <see cref="AudioClipPaths"/> and the file exists: decoded from the file with <see cref="FFmpegPCMReader"/>
|
||||||
|
/// (mono MP3 through <see cref="MP3MonoLayoutReader"/>)
|
||||||
|
/// (song files and PlaySound files of custom levels).
|
||||||
|
/// - Otherwise, or when FFmpeg cannot open the file: the Unity clip is copied with <see cref="UnityClipLoader"/>
|
||||||
|
/// (built-in hit sounds from Resources, official/DLC level songs from Addressables).
|
||||||
|
/// Main thread only (the Unity copy calls AudioClip.GetData).
|
||||||
|
/// </summary>
|
||||||
|
internal static class AudioClipResolver
|
||||||
|
{
|
||||||
|
/// <param name="store">Store of the current render.</param>
|
||||||
|
/// <param name="key">audioLib key of the clip (for example "song.ogg*external" or "sndKick").</param>
|
||||||
|
/// <param name="clip">Loaded Unity clip for the key, used when the clip has no file; may be null when the key has a file.</param>
|
||||||
|
/// <returns>The source, or null when neither the file nor the clip can be read.</returns>
|
||||||
|
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<RenderMod>.Logger.Warning($"[AudioClipResolver] Mono MP3 '{path}' is rendered like the game: half speed, one octave lower.");
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Melon<RenderMod>.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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Opens <paramref name="path"/> with FFmpeg; mono MP3 files are wrapped in <see cref="MP3MonoLayoutReader"/> (game layout).</summary>
|
||||||
|
private static IPCMReader OpenFile(string path)
|
||||||
|
{
|
||||||
|
var reader = new FFmpegPCMReader(path);
|
||||||
|
if (!reader.IsMP3 || reader.Channels != 1) return reader;
|
||||||
|
return new MP3MonoLayoutReader(reader);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,613 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using MelonLoader;
|
||||||
|
|
||||||
|
namespace ADOFAIRenderer.Util.Audio.Clip;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read position registered on an <see cref="AudioClipSource"/> by one consumer (a playing voice or the spectrum generator).
|
||||||
|
/// For a windowed source, the chunk containing <see cref="Position"/> and the following chunks
|
||||||
|
/// (<see cref="AudioClipSource.SlotCount"/> chunks in total) are kept loaded.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>When true, the chunk after the last chunk is chunk 0.</summary>
|
||||||
|
public bool Loop
|
||||||
|
{
|
||||||
|
get => _loop;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
_loop = value;
|
||||||
|
_source.OnCursorMoved();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One loaded chunk of an <see cref="AudioClipSource"/>, held by a consumer between
|
||||||
|
/// <see cref="AudioClipSource.TryAcquire"/> and <see cref="AudioClipSource.Release"/>.
|
||||||
|
/// The chunk is not freed while held.
|
||||||
|
/// </summary>
|
||||||
|
internal readonly struct ChunkView
|
||||||
|
{
|
||||||
|
/// <summary>Interleaved samples. Frame f of the source is at (f - StartFrame) * Channels.</summary>
|
||||||
|
public readonly float[] Data;
|
||||||
|
|
||||||
|
/// <summary>Source frame of the first frame in <see cref="Data"/>.</summary>
|
||||||
|
public readonly long StartFrame;
|
||||||
|
|
||||||
|
/// <summary>Valid frames in this chunk. Frames past the end of the source are not included.</summary>
|
||||||
|
public readonly int FrameCount;
|
||||||
|
|
||||||
|
/// <summary>Channel count of the stored samples (same as the source clip).</summary>
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sample of <paramref name="frame"/> mapped to output channel <paramref name="outChannel"/> of <paramref name="outChannels"/>.
|
||||||
|
/// 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.
|
||||||
|
/// <paramref name="frame"/> must be inside this chunk.
|
||||||
|
/// </summary>
|
||||||
|
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)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How an <see cref="AudioClipSource"/> keeps its chunks.
|
||||||
|
/// </summary>
|
||||||
|
internal enum ChunkPolicy
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
Windowed,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
Resident
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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
|
||||||
|
/// <see cref="FramesPerChunk"/> = <see cref="ChunkFloats"/> / 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 <see cref="ChunkPolicy"/> 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 (<see cref="AudioClipStore"/>), through
|
||||||
|
/// <see cref="FreeUnused"/>, <see cref="TryGetNextLoad"/> and <see cref="LoadChunk"/>.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class AudioClipSource
|
||||||
|
{
|
||||||
|
/// <summary>Floats per chunk array: 2^22 floats = 16 MiB.</summary>
|
||||||
|
public const int ChunkFloats = 1 << 22;
|
||||||
|
|
||||||
|
/// <summary>Chunks kept per cursor of a windowed source: the chunk containing the cursor and the next SlotCount - 1 chunks.</summary>
|
||||||
|
public const int SlotCount = 3;
|
||||||
|
|
||||||
|
/// <summary>Load priority of a chunk a consumer is waiting for (lowest value is loaded first).</summary>
|
||||||
|
internal const int PriorityDemanded = 0;
|
||||||
|
|
||||||
|
/// <summary>Load priority of slot s of a cursor window is PriorityWindow + s.</summary>
|
||||||
|
internal const int PriorityWindow = 1;
|
||||||
|
|
||||||
|
/// <summary>Load priority of resident chunks nobody is waiting for.</summary>
|
||||||
|
internal const int PriorityResident = PriorityWindow + SlotCount;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
public const int MaxDecodeStreams = 4;
|
||||||
|
|
||||||
|
/// <summary>Decode stream limit of this source (<see cref="MaxDecodeStreams"/> unless changed for tests).</summary>
|
||||||
|
internal int DecodeStreamLimit { get; set; } = MaxDecodeStreams;
|
||||||
|
|
||||||
|
private readonly AudioClipStore _store;
|
||||||
|
private readonly IPCMReader _firstReader;
|
||||||
|
private readonly Func<IPCMReader?>? _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<AudioCursor> _cursors = new();
|
||||||
|
private readonly HashSet<int> _demanded = new();
|
||||||
|
private readonly HashSet<int> _loaded = new();
|
||||||
|
private int _residentLoadedCount; // Resident: chunks [0, _residentLoadedCount) are loaded
|
||||||
|
|
||||||
|
// Guarded by _loadLock: decode streams.
|
||||||
|
private readonly object _loadLock = new();
|
||||||
|
private readonly List<DecodeStream> _streams = new();
|
||||||
|
private long _streamUseCounter;
|
||||||
|
private bool _openFailed;
|
||||||
|
|
||||||
|
private volatile bool _isFailed;
|
||||||
|
|
||||||
|
public string Key { get; }
|
||||||
|
|
||||||
|
/// <summary>Channel count of the stored samples (same as the clip).</summary>
|
||||||
|
public int Channels { get; }
|
||||||
|
|
||||||
|
/// <summary>Sample rate of the stored samples in Hz (the output sample rate).</summary>
|
||||||
|
public int Frequency { get; }
|
||||||
|
|
||||||
|
/// <summary>Frame count at <see cref="Frequency"/>: ceil(clip frames * Frequency / clip rate).</summary>
|
||||||
|
public long TotalFrames { get; }
|
||||||
|
|
||||||
|
/// <summary>Clip length in seconds: clip frames / clip rate.</summary>
|
||||||
|
public double Duration { get; }
|
||||||
|
|
||||||
|
public int FramesPerChunk { get; }
|
||||||
|
|
||||||
|
public int ChunkCount { get; }
|
||||||
|
|
||||||
|
public ChunkPolicy Policy { get; }
|
||||||
|
|
||||||
|
public bool IsResident => Policy == ChunkPolicy.Resident;
|
||||||
|
|
||||||
|
/// <summary>True after loading failed. A failed source returns no chunks.</summary>
|
||||||
|
public bool IsFailed => _isFailed;
|
||||||
|
|
||||||
|
/// <summary>True when every chunk of a resident source is loaded.</summary>
|
||||||
|
public bool IsFullyLoaded
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_sync) return _loaded.Count == ChunkCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Number of chunk loads performed (for diagnostics).</summary>
|
||||||
|
public int LoadCount { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Decode streams opened so far (for diagnostics).</summary>
|
||||||
|
public int StreamCount
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_loadLock) return _streams.Count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <param name="store">Store that owns this source.</param>
|
||||||
|
/// <param name="key">Clip name or path the source is registered under.</param>
|
||||||
|
/// <param name="reader">First reader; also defines the clip format.</param>
|
||||||
|
/// <param name="openReader">Opens another reader of the same clip for an additional decode stream, or null when not possible.</param>
|
||||||
|
/// <param name="outputRate">Output sample rate in Hz.</param>
|
||||||
|
internal AudioClipSource(AudioClipStore store, string key, IPCMReader reader, Func<IPCMReader?>? 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 ──
|
||||||
|
|
||||||
|
/// <summary>Registers a read position. The decoder thread loads the chunks it requires.</summary>
|
||||||
|
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 ──
|
||||||
|
|
||||||
|
/// <summary>Chunk index containing <paramref name="frame"/> (0 ~ TotalFrames - 1).</summary>
|
||||||
|
public int ChunkIndexOf(long frame) => (int)(frame / FramesPerChunk);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Holds the chunk containing <paramref name="frame"/> 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 <see cref="Release"/>.
|
||||||
|
/// </summary>
|
||||||
|
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 ──
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Windowed: frees loaded chunks that are not required (see <see cref="ChunkPolicy.Windowed"/>) and not held.
|
||||||
|
/// Resident: does nothing. Decoder thread only.
|
||||||
|
/// </summary>
|
||||||
|
internal void FreeUnused()
|
||||||
|
{
|
||||||
|
if (IsResident) return;
|
||||||
|
|
||||||
|
List<float[]>? freed = null;
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
if (_loaded.Count == 0) return;
|
||||||
|
List<int>? remove = null;
|
||||||
|
foreach (var index in _loaded)
|
||||||
|
{
|
||||||
|
if (_holdCounts[index] > 0 || IsRequired(index)) continue;
|
||||||
|
(remove ??= new List<int>()).Add(index);
|
||||||
|
}
|
||||||
|
if (remove == null) return;
|
||||||
|
|
||||||
|
foreach (var index in remove)
|
||||||
|
{
|
||||||
|
(freed ??= new List<float[]>()).Add(_chunks[index]!);
|
||||||
|
_chunks[index] = null;
|
||||||
|
_loaded.Remove(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var data in freed!)
|
||||||
|
_store.ReturnChunkArray(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <see cref="PriorityDemanded"/> while a consumer is waiting on this source.
|
||||||
|
/// Decoder thread only.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>True when a demand or a cursor window covers <paramref name="index"/>. Caller holds _sync.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads chunk <paramref name="index"/> if it is not loaded. Resident sources load only the next chunk in order.
|
||||||
|
/// Decoder thread only.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>True when this call loaded the chunk.</returns>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decode stream for a load starting at output frame <paramref name="start"/>:
|
||||||
|
/// the stream positioned at <paramref name="start"/>; otherwise a new stream while fewer than <see cref="DecodeStreamLimit"/>
|
||||||
|
/// are open (windowed sources only); otherwise the least recently used stream. Caller holds _loadLock.
|
||||||
|
/// </summary>
|
||||||
|
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<RenderMod>.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Releases all chunk arrays and readers. Called by the store after the decoder thread stopped.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using MelonLoader;
|
||||||
|
|
||||||
|
namespace ADOFAIRenderer.Util.Audio.Clip;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 (<see cref="Start"/>): 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): <see cref="Acquire"/> waits for the decoder thread and never loads.
|
||||||
|
/// - Main thread: <see cref="GetOrAdd"/> creates readers (for Unity clips this copies the clip data).
|
||||||
|
///
|
||||||
|
/// Memory has no limit; current and peak usage are tracked and logged by <see cref="LogMemoryUsage"/>.
|
||||||
|
/// An <see cref="OutOfMemoryException"/> while loading is kept in <see cref="FatalError"/> and rethrown by <see cref="Acquire"/>.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class AudioClipStore : IDisposable
|
||||||
|
{
|
||||||
|
/// <summary>Longest wait of the decoder thread between checks when nothing signals it, in milliseconds.</summary>
|
||||||
|
private const int DecoderIdleWaitMs = 50;
|
||||||
|
|
||||||
|
/// <summary>Longest wait of a consumer between checks when no chunk is loaded, in milliseconds.</summary>
|
||||||
|
private const int ConsumerWaitMs = 50;
|
||||||
|
|
||||||
|
/// <summary>Longest wait for the decoder thread to stop in <see cref="Dispose"/>, in milliseconds.</summary>
|
||||||
|
private const int DecoderStopTimeoutMs = 3000;
|
||||||
|
|
||||||
|
private readonly Dictionary<string, AudioClipSource?> _sources = new();
|
||||||
|
private readonly List<AudioClipSource> _sourceList = new();
|
||||||
|
private readonly Stack<float[]> _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;
|
||||||
|
|
||||||
|
/// <summary>Sample rate every source is resampled to, in Hz.</summary>
|
||||||
|
public int OutputRate { get; }
|
||||||
|
|
||||||
|
/// <summary>Bytes of sample arrays currently allocated (loaded chunks, pooled chunk arrays and copied Unity clip data).</summary>
|
||||||
|
public long AllocatedBytes => Interlocked.Read(ref _allocatedBytes);
|
||||||
|
|
||||||
|
/// <summary>Highest value of <see cref="AllocatedBytes"/> since creation.</summary>
|
||||||
|
public long PeakBytes => Interlocked.Read(ref _peakBytes);
|
||||||
|
|
||||||
|
/// <summary>Out-of-memory error raised while loading, or null.</summary>
|
||||||
|
public Exception? FatalError => _fatalError;
|
||||||
|
|
||||||
|
public AudioClipStore(int outputRate)
|
||||||
|
{
|
||||||
|
if (outputRate <= 0) throw new ArgumentOutOfRangeException(nameof(outputRate));
|
||||||
|
OutputRate = outputRate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Decoder thread ──
|
||||||
|
|
||||||
|
/// <summary>Starts the decoder thread. Called once.</summary>
|
||||||
|
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<RenderMod>.Logger.Error($"[AudioClipStore] Decoder thread stopped: {e}");
|
||||||
|
_fatalError ??= e;
|
||||||
|
NotifyChunkLoaded();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>True when a chunk was loaded or a load was attempted.</returns>
|
||||||
|
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 ──
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the source registered under <paramref name="key"/>, creating it with <paramref name="createReader"/> 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 <see cref="AudioClipSource.MaxDecodeStreams"/>) are opened on the
|
||||||
|
/// decoder thread with <paramref name="openAdditionalReader"/>, or with <paramref name="createReader"/> when it is null.
|
||||||
|
/// </summary>
|
||||||
|
public AudioClipSource? GetOrAdd(string key, Func<IPCMReader?> createReader, Func<IPCMReader?>? 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<RenderMod>.Logger.Error($"[AudioClipStore] Out of memory while opening clip '{key}' ({AllocatedBytes / 1048576.0:F1} MiB allocated): {e}");
|
||||||
|
_fatalError ??= e;
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Melon<RenderMod>.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<RenderMod>.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 ──
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Holds the chunk containing <paramref name="frame"/>, 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="InvalidOperationException">Loading ran out of memory or the decoder thread stopped.</exception>
|
||||||
|
/// <exception cref="OperationCanceledException"><paramref name="cancellationToken"/> was canceled while waiting.</exception>
|
||||||
|
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<RenderMod>.Logger.Error($"[AudioClipStore] Failed to load clip '{source.Key}', it will be silent: {error}");
|
||||||
|
NotifyChunkLoaded();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void ReportFatal(AudioClipSource source, OutOfMemoryException error)
|
||||||
|
{
|
||||||
|
Melon<RenderMod>.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<RenderMod>.Logger.Msg($"[AudioClipStore] Clips: {sourceCount}, sample memory now {AllocatedBytes / 1048576.0:F1} MiB, peak {PeakBytes / 1048576.0:F1} MiB");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Stops the decoder thread, then releases every source and pooled array.</summary>
|
||||||
|
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<RenderMod>.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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace ADOFAIRenderer.Util.Audio.Clip;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One reader plus its resampler, producing output-rate frames of a clip as a continuous stream.
|
||||||
|
/// Consecutive <see cref="Fill"/> 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).
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class DecodeStream : IDisposable
|
||||||
|
{
|
||||||
|
/// <summary>Input frames read from the reader per resampler call.</summary>
|
||||||
|
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; }
|
||||||
|
|
||||||
|
/// <summary>Output frame the stream continues at, or -1 when the stream is not positioned.</summary>
|
||||||
|
public long NextOutFrame { get; private set; } = -1;
|
||||||
|
|
||||||
|
/// <summary>Value of the owner's use counter at the last <see cref="Fill"/> (for least-recently-used selection).</summary>
|
||||||
|
public long LastUse { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Number of times the stream started anew (seek + resampler reset).</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes output frames [start, start + frames) into <paramref name="data"/> and zeros the rest of the array.
|
||||||
|
/// Frames past the end of the decoded input are zero.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes 1 ~ <paramref name="capacity"/> resampled frames to <paramref name="output"/>, reading input as needed.
|
||||||
|
/// Returns 0 and sets _ended once the input and the resampler's flushed tail are exhausted.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
using System;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using FFmpeg.AutoGen;
|
||||||
|
|
||||||
|
namespace ADOFAIRenderer.Util.Audio.Clip;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="IPCMReader"/> 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 <see cref="SeekMarginSeconds"/> before the target and discards decoded samples before the target.
|
||||||
|
///
|
||||||
|
/// <see cref="TotalFrames"/> 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 <see cref="MP3HeaderFrame"/>): 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. <see cref="TotalFrames"/> 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)
|
||||||
|
/// </summary>
|
||||||
|
internal sealed unsafe class FFmpegPCMReader : IPCMReader
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<float>();
|
||||||
|
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; }
|
||||||
|
|
||||||
|
/// <summary>True when the stream is MP3 and is decoded with the game's MP3 timing (see class summary).</summary>
|
||||||
|
public bool IsMP3 { get; }
|
||||||
|
|
||||||
|
/// <summary>Number of demuxer seeks performed (for diagnostics).</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Decodes the next audio frame into the pending buffer. Returns false at the end of the stream.</summary>
|
||||||
|
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})";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace ADOFAIRenderer.Util.Audio.Clip;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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). <see cref="IDisposable.Dispose"/> may be called more than once.
|
||||||
|
/// </summary>
|
||||||
|
internal interface IPCMReader : IDisposable
|
||||||
|
{
|
||||||
|
/// <summary>Channel count of the decoded audio (1 or more).</summary>
|
||||||
|
int Channels { get; }
|
||||||
|
|
||||||
|
/// <summary>Sample rate of the decoded audio in Hz.</summary>
|
||||||
|
int SampleRate { get; }
|
||||||
|
|
||||||
|
/// <summary>Total frame count of the clip at <see cref="SampleRate"/>.</summary>
|
||||||
|
long TotalFrames { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 (<see cref="ChunkPolicy.Resident"/>).
|
||||||
|
/// </summary>
|
||||||
|
bool IsForwardOnly { get; }
|
||||||
|
|
||||||
|
/// <summary>Frame index that the next <see cref="Read"/> starts at.</summary>
|
||||||
|
long Position { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Moves the read position to <paramref name="frame"/> (0 ~ <see cref="TotalFrames"/>).
|
||||||
|
/// A forward-only reader accepts only frames whose data has not been released.
|
||||||
|
/// </summary>
|
||||||
|
void Seek(long frame);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads up to <paramref name="frameCount"/> frames into <paramref name="buffer"/> starting at index 0 and advances the position.
|
||||||
|
/// <paramref name="buffer"/> must hold at least frameCount * Channels floats.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Frames read. 0 at the end of the clip.</returns>
|
||||||
|
int Read(float[] buffer, int frameCount);
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
|
||||||
|
namespace ADOFAIRenderer.Util.Audio.Clip;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
internal static class MP3HeaderFrame
|
||||||
|
{
|
||||||
|
/// <summary>Bytes searched for the first frame sync after the ID3v2 tags.</summary>
|
||||||
|
private const int SyncSearchBytes = 64 * 1024;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Frames of silence MP3Sharp outputs for the header frame of <paramref name="path"/>:
|
||||||
|
/// the samples per frame of the first frame when it is a Xing/Info/VBRI header frame, otherwise 0.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace ADOFAIRenderer.Util.Audio.Clip;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="IPCMReader"/> 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.
|
||||||
|
/// <see cref="TotalFrames"/> = 2 * inner TotalFrames.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class MP3MonoLayoutReader : IPCMReader
|
||||||
|
{
|
||||||
|
private readonly IPCMReader _inner;
|
||||||
|
private float[] _innerBuffer = Array.Empty<float>();
|
||||||
|
|
||||||
|
/// <param name="inner">Mono reader of the MP3 file with the game's MP3 timing (<see cref="FFmpegPCMReader.IsMP3"/>). Owned by this reader.</param>
|
||||||
|
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; }
|
||||||
|
|
||||||
|
/// <summary>Moves to <paramref name="frame"/>; the inner reader moves to the inner frame of the next even position.</summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace ADOFAIRenderer.Util.Audio.Clip;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Forward-only <see cref="IPCMReader"/> 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 <see cref="Dispose"/>.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class MemoryPCMReader : IPCMReader
|
||||||
|
{
|
||||||
|
private readonly float[]?[] _blocks;
|
||||||
|
private readonly Action<float[]>? _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; }
|
||||||
|
|
||||||
|
/// <summary>Frames per block (all blocks except the last).</summary>
|
||||||
|
public int FramesPerBlock { get; }
|
||||||
|
|
||||||
|
public MemoryPCMReader(IReadOnlyList<float[]> blocks, int framesPerBlock, int channels, int sampleRate, long totalFrames,
|
||||||
|
Action<float[]>? 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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
using System;
|
||||||
|
using FFmpeg.AutoGen;
|
||||||
|
|
||||||
|
namespace ADOFAIRenderer.Util.Audio.Clip;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <see cref="Convert"/> calls form one continuous stream.
|
||||||
|
/// Reference: https://ffmpeg.org/doxygen/trunk/group__lswr.html
|
||||||
|
/// </summary>
|
||||||
|
internal sealed unsafe class PCMResampler : IDisposable
|
||||||
|
{
|
||||||
|
private SwrContext* _swr;
|
||||||
|
private bool _flushed;
|
||||||
|
|
||||||
|
public int Channels { get; }
|
||||||
|
public int InputRate { get; }
|
||||||
|
public int OutputRate { get; }
|
||||||
|
|
||||||
|
/// <summary>Value of the SwrContext "filter_size" option (filter length per phase in taps at a 1:1 ratio).</summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Discards all buffered input and filter history. The next call starts a new stream.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Feeds <paramref name="inputFrames"/> frames and writes up to <paramref name="outputCapacity"/> frames.
|
||||||
|
/// Input that does not fit in the output is buffered inside the context and returned by later calls.
|
||||||
|
/// <paramref name="inputFrames"/> may be 0 to take only buffered output.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Frames written to <paramref name="output"/>.</returns>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ends the input stream and writes up to <paramref name="outputCapacity"/> remaining frames.
|
||||||
|
/// Call repeatedly until it returns 0. <see cref="Convert"/> is not allowed afterwards until <see cref="Reset"/>.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace ADOFAIRenderer.Util.Audio.Clip;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Copies a Unity <see cref="AudioClip"/> into memory for clips without a file on disk
|
||||||
|
/// (built-in hit sounds from Resources, official/DLC level audio).
|
||||||
|
/// <see cref="AudioClip.GetData(float[], int)"/> 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 <see cref="MemoryPCMReader"/>.
|
||||||
|
/// Streaming clips (<see cref="AudioClipLoadType.Streaming"/>) are rejected because GetData does not read them.
|
||||||
|
/// Reference: https://docs.unity3d.com/ScriptReference/AudioClip.GetData.html
|
||||||
|
/// </summary>
|
||||||
|
internal static class UnityClipLoader
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Copies <paramref name="clip"/> in blocks of <see cref="AudioClipSource.ChunkFloats"/> floats or less
|
||||||
|
/// (FramesPerBlock = ChunkFloats / channels frames per GetData call). Main thread only.
|
||||||
|
/// Block memory is counted in <paramref name="store"/> and released block by block as the decoder thread reads it.
|
||||||
|
/// </summary>
|
||||||
|
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<float[]>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,14 +4,14 @@ namespace ADOFAIRenderer.Util.Audio.Filter;
|
|||||||
|
|
||||||
internal class Gain : IAudioFilter
|
internal class Gain : IAudioFilter
|
||||||
{
|
{
|
||||||
public float Level = 0f;
|
public double Level = 0;
|
||||||
|
|
||||||
public void Initialize(int sampleRate, int channels) { }
|
public void Initialize(int sampleRate, int channels) { }
|
||||||
|
|
||||||
public float ProcessSample(float sample, int channel) =>
|
public double ProcessSample(double sample, int channel) =>
|
||||||
sample * (float)Math.Pow(10, Level / 20);
|
sample * Math.Pow(10, Level / 20);
|
||||||
|
|
||||||
public void ProcessBlock(Span<float> buffer)
|
public void ProcessBlock(Span<double> buffer)
|
||||||
{
|
{
|
||||||
for (var i = 0; i < buffer.Length; i++)
|
for (var i = 0; i < buffer.Length; i++)
|
||||||
buffer[i] = ProcessSample(buffer[i], 0);
|
buffer[i] = ProcessSample(buffer[i], 0);
|
||||||
|
|||||||
@@ -8,19 +8,19 @@ internal class HardClip : IAudioFilter
|
|||||||
// ReSharper disable once MemberCanBePrivate.Global
|
// ReSharper disable once MemberCanBePrivate.Global
|
||||||
// ReSharper disable once FieldCanBeMadeReadOnly.Global
|
// ReSharper disable once FieldCanBeMadeReadOnly.Global
|
||||||
// ReSharper disable once ConvertToConstant.Global
|
// ReSharper disable once ConvertToConstant.Global
|
||||||
public float Threshold = 1.0f;
|
public double Threshold = 1.0;
|
||||||
|
|
||||||
public void Initialize(int sampleRate, int channels) { }
|
public void Initialize(int sampleRate, int channels) { }
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[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;
|
||||||
if (sample < -Threshold) return -Threshold;
|
if (sample < -Threshold) return -Threshold;
|
||||||
return sample;
|
return sample;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ProcessBlock(Span<float> buffer)
|
public void ProcessBlock(Span<double> buffer)
|
||||||
{
|
{
|
||||||
for (var i = 0; i < buffer.Length; i++)
|
for (var i = 0; i < buffer.Length; i++)
|
||||||
buffer[i] = ProcessSample(buffer[i], 0);
|
buffer[i] = ProcessSample(buffer[i], 0);
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ internal interface IAudioFilter
|
|||||||
{
|
{
|
||||||
void Initialize(int sampleRate, int channels);
|
void Initialize(int sampleRate, int channels);
|
||||||
|
|
||||||
float ProcessSample(float sample, int channel);
|
double ProcessSample(double sample, int channel);
|
||||||
void ProcessBlock(Span<float> buffer);
|
void ProcessBlock(Span<double> buffer);
|
||||||
|
|
||||||
void Reset();
|
void Reset();
|
||||||
|
|
||||||
|
|||||||
@@ -6,84 +6,84 @@ namespace ADOFAIRenderer.Util.Audio.Filter;
|
|||||||
// ReSharper disable once InconsistentNaming
|
// ReSharper disable once InconsistentNaming
|
||||||
internal class RMSCompressor : IAudioFilter
|
internal class RMSCompressor : IAudioFilter
|
||||||
{
|
{
|
||||||
private float _thresholdDb = -20f;
|
private double _thresholdDb = -20;
|
||||||
private float _ratio = 4f;
|
private double _ratio = 4;
|
||||||
private float _attackTime = 0.010f; // in seconds
|
private double _attackTime = 0.010; // in seconds
|
||||||
private float _releaseTime = 0.100f; // in seconds
|
private double _releaseTime = 0.100; // in seconds
|
||||||
private float _kneeDb = 5f; // in dB
|
private double _kneeDb = 5; // in dB
|
||||||
private float _rmsWindowTime = 0.030f; // in seconds
|
private double _rmsWindowTime = 0.030; // in seconds
|
||||||
private float _lookAheadTime; // in seconds
|
private double _lookAheadTime; // in seconds
|
||||||
private int _lookAheadSamples;
|
private int _lookAheadSamples;
|
||||||
|
|
||||||
private int _sampleRate = 44100;
|
private int _sampleRate = 44100;
|
||||||
private int _channels = 2;
|
private int _channels = 2;
|
||||||
|
|
||||||
private float _attackCoef;
|
private double _attackCoef;
|
||||||
private float _releaseCoef;
|
private double _releaseCoef;
|
||||||
private float _rmsCoef;
|
private double _rmsCoef;
|
||||||
|
|
||||||
// Per-channel state
|
// Per-channel state
|
||||||
private float[] _rmsState = Array.Empty<float>(); // running mean-square (MS) state
|
private double[] _rmsState = Array.Empty<double>(); // running mean-square (MS) state
|
||||||
private float[] _gainState = Array.Empty<float>(); // smoothed gain reduction in dB
|
private double[] _gainState = Array.Empty<double>(); // smoothed gain reduction in dB
|
||||||
private float[][] _delayBuffers = Array.Empty<float[]>(); // circular delay buffers
|
private double[][] _delayBuffers = Array.Empty<double[]>(); // circular delay buffers
|
||||||
private int[] _delayWriteIndices = Array.Empty<int>();
|
private int[] _delayWriteIndices = Array.Empty<int>();
|
||||||
|
|
||||||
public float LookAheadTime
|
public double LookAheadTime
|
||||||
{
|
{
|
||||||
get => _lookAheadTime;
|
get => _lookAheadTime;
|
||||||
set
|
set
|
||||||
{
|
{
|
||||||
_lookAheadTime = Math.Max(0f, Math.Min(1.0f, value));
|
_lookAheadTime = Math.Max(0, Math.Min(1.0, value));
|
||||||
UpdateLookAheadSettings();
|
UpdateLookAheadSettings();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public double Latency => _lookAheadTime;
|
public double Latency => _lookAheadTime;
|
||||||
|
|
||||||
public float ThresholdDb
|
public double ThresholdDb
|
||||||
{
|
{
|
||||||
get => _thresholdDb;
|
get => _thresholdDb;
|
||||||
set => _thresholdDb = value;
|
set => _thresholdDb = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
public float Ratio
|
public double Ratio
|
||||||
{
|
{
|
||||||
get => _ratio;
|
get => _ratio;
|
||||||
set => _ratio = Math.Max(1f, value);
|
set => _ratio = Math.Max(1, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
public float AttackTime
|
public double AttackTime
|
||||||
{
|
{
|
||||||
get => _attackTime;
|
get => _attackTime;
|
||||||
set
|
set
|
||||||
{
|
{
|
||||||
_attackTime = Math.Max(0.0001f, value);
|
_attackTime = Math.Max(0.0001, value);
|
||||||
UpdateAttackCoef();
|
UpdateAttackCoef();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public float ReleaseTime
|
public double ReleaseTime
|
||||||
{
|
{
|
||||||
get => _releaseTime;
|
get => _releaseTime;
|
||||||
set
|
set
|
||||||
{
|
{
|
||||||
_releaseTime = Math.Max(0.0001f, value);
|
_releaseTime = Math.Max(0.0001, value);
|
||||||
UpdateReleaseCoef();
|
UpdateReleaseCoef();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public float KneeDb
|
public double KneeDb
|
||||||
{
|
{
|
||||||
get => _kneeDb;
|
get => _kneeDb;
|
||||||
set => _kneeDb = Math.Max(0f, value);
|
set => _kneeDb = Math.Max(0, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
public float RmsWindowTime
|
public double RmsWindowTime
|
||||||
{
|
{
|
||||||
get => _rmsWindowTime;
|
get => _rmsWindowTime;
|
||||||
set
|
set
|
||||||
{
|
{
|
||||||
_rmsWindowTime = Math.Max(0.0001f, value);
|
_rmsWindowTime = Math.Max(0.0001, value);
|
||||||
UpdateRmsCoef();
|
UpdateRmsCoef();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,8 +98,8 @@ internal class RMSCompressor : IAudioFilter
|
|||||||
UpdateRmsCoef();
|
UpdateRmsCoef();
|
||||||
UpdateLookAheadSettings();
|
UpdateLookAheadSettings();
|
||||||
|
|
||||||
_rmsState = new float[channels];
|
_rmsState = new double[channels];
|
||||||
_gainState = new float[channels];
|
_gainState = new double[channels];
|
||||||
|
|
||||||
Reset();
|
Reset();
|
||||||
}
|
}
|
||||||
@@ -114,33 +114,33 @@ internal class RMSCompressor : IAudioFilter
|
|||||||
{
|
{
|
||||||
if (_channels <= 0) return;
|
if (_channels <= 0) return;
|
||||||
|
|
||||||
_delayBuffers = new float[_channels][];
|
_delayBuffers = new double[_channels][];
|
||||||
_delayWriteIndices = new int[_channels];
|
_delayWriteIndices = new int[_channels];
|
||||||
|
|
||||||
for (int i = 0; i < _channels; i++)
|
for (int i = 0; i < _channels; i++)
|
||||||
{
|
{
|
||||||
_delayBuffers[i] = _lookAheadSamples > 0 ? new float[_lookAheadSamples] : Array.Empty<float>();
|
_delayBuffers[i] = _lookAheadSamples > 0 ? new double[_lookAheadSamples] : Array.Empty<double>();
|
||||||
_delayWriteIndices[i] = 0;
|
_delayWriteIndices[i] = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateAttackCoef()
|
private void UpdateAttackCoef()
|
||||||
{
|
{
|
||||||
_attackCoef = (float)Math.Exp(-1.0 / (_sampleRate * _attackTime));
|
_attackCoef = Math.Exp(-1.0 / (_sampleRate * _attackTime));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateReleaseCoef()
|
private void UpdateReleaseCoef()
|
||||||
{
|
{
|
||||||
_releaseCoef = (float)Math.Exp(-1.0 / (_sampleRate * _releaseTime));
|
_releaseCoef = Math.Exp(-1.0 / (_sampleRate * _releaseTime));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateRmsCoef()
|
private void UpdateRmsCoef()
|
||||||
{
|
{
|
||||||
_rmsCoef = (float)Math.Exp(-1.0 / (_sampleRate * _rmsWindowTime));
|
_rmsCoef = Math.Exp(-1.0 / (_sampleRate * _rmsWindowTime));
|
||||||
}
|
}
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
public float ProcessSample(float sample, int channel)
|
public double ProcessSample(double sample, int channel)
|
||||||
{
|
{
|
||||||
if (channel < 0 || channel >= _channels)
|
if (channel < 0 || channel >= _channels)
|
||||||
{
|
{
|
||||||
@@ -149,54 +149,54 @@ internal class RMSCompressor : IAudioFilter
|
|||||||
|
|
||||||
// Calculate running Mean-Square (MS) level
|
// Calculate running Mean-Square (MS) level
|
||||||
// v_c[n] = alpha * v_c[n-1] + (1 - alpha) * x^2[n]
|
// v_c[n] = alpha * v_c[n-1] + (1 - alpha) * x^2[n]
|
||||||
float sq = sample * sample;
|
double sq = sample * sample;
|
||||||
_rmsState[channel] = _rmsCoef * _rmsState[channel] + (1.0f - _rmsCoef) * sq;
|
_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))
|
// 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)
|
// Calculate static envelope curve (gain reduction in dB)
|
||||||
float gStatic = 0f;
|
double gStatic = 0;
|
||||||
if (_kneeDb > 0f)
|
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;
|
double diff = rmsDb - _thresholdDb + _kneeDb * 0.5;
|
||||||
gStatic = (1f / _ratio - 1f) * diff * diff / (2f * _kneeDb);
|
gStatic = (1 / _ratio - 1) * diff * diff / (2 * _kneeDb);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
if (rmsDb > _thresholdDb)
|
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
|
// Apply ballistics (attack and release) on the dB gain reduction
|
||||||
float lastGain = _gainState[channel];
|
double lastGain = _gainState[channel];
|
||||||
float targetGain = gStatic;
|
double targetGain = gStatic;
|
||||||
|
|
||||||
// Note: Since targetGain and lastGain are <= 0 (gain reduction in dB),
|
// Note: Since targetGain and lastGain are <= 0 (gain reduction in dB),
|
||||||
// targetGain < lastGain means target gain reduction is larger (more negative),
|
// targetGain < lastGain means target gain reduction is larger (more negative),
|
||||||
// so we are in the attack phase.
|
// so we are in the attack phase.
|
||||||
float coef = targetGain < lastGain ? _attackCoef : _releaseCoef;
|
double coef = targetGain < lastGain ? _attackCoef : _releaseCoef;
|
||||||
float currentGain = coef * lastGain + (1f - coef) * targetGain;
|
double currentGain = coef * lastGain + (1 - coef) * targetGain;
|
||||||
_gainState[channel] = currentGain;
|
_gainState[channel] = currentGain;
|
||||||
|
|
||||||
// Convert back to linear gain multiplier
|
// 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)
|
if (_lookAheadSamples > 0 && _delayBuffers.Length > channel && _delayBuffers[channel].Length > 0)
|
||||||
{
|
{
|
||||||
var buffer = _delayBuffers[channel];
|
var buffer = _delayBuffers[channel];
|
||||||
int writeIdx = _delayWriteIndices[channel];
|
int writeIdx = _delayWriteIndices[channel];
|
||||||
float delayedSample = buffer[writeIdx];
|
double delayedSample = buffer[writeIdx];
|
||||||
buffer[writeIdx] = sample;
|
buffer[writeIdx] = sample;
|
||||||
_delayWriteIndices[channel] = (writeIdx + 1) % _lookAheadSamples;
|
_delayWriteIndices[channel] = (writeIdx + 1) % _lookAheadSamples;
|
||||||
return delayedSample * linearGain;
|
return delayedSample * linearGain;
|
||||||
@@ -205,7 +205,7 @@ internal class RMSCompressor : IAudioFilter
|
|||||||
return sample * linearGain;
|
return sample * linearGain;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ProcessBlock(Span<float> buffer)
|
public void ProcessBlock(Span<double> buffer)
|
||||||
{
|
{
|
||||||
for (var i = 0; i < buffer.Length; i++)
|
for (var i = 0; i < buffer.Length; i++)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using ADOFAIRenderer.Util.Audio.Clip;
|
||||||
|
|
||||||
|
namespace ADOFAIRenderer.Util.Audio.Mixer;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One playing sound of a <see cref="VoiceMixer"/> (the song, or one effect sound), created by <see cref="VoiceMixer.Play"/>.
|
||||||
|
///
|
||||||
|
/// 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: <see cref="VoiceMixer.Mix"/> 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 (<see cref="AudioClipSource.Duration"/> * output rate, fractional).
|
||||||
|
/// Not looping: the voice ends when the position reaches <see cref="AudioClipSource.TotalFrames"/>.
|
||||||
|
/// </summary>
|
||||||
|
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 ──
|
||||||
|
|
||||||
|
/// <summary>Changes not yet applied, in time order. Render thread only.</summary>
|
||||||
|
internal readonly Queue<Change> Pending = new();
|
||||||
|
|
||||||
|
/// <summary>First output sample of playback.</summary>
|
||||||
|
internal long StartSample;
|
||||||
|
|
||||||
|
/// <summary>Output sample at which playback stops (exclusive); long.MaxValue when not set.</summary>
|
||||||
|
internal long EndSample = long.MaxValue;
|
||||||
|
|
||||||
|
internal long AnchorSample;
|
||||||
|
internal double AnchorPosition;
|
||||||
|
internal double Pitch;
|
||||||
|
internal double Volume;
|
||||||
|
internal bool Loop;
|
||||||
|
internal bool Finished;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Playback has ended and the voice was released by the render thread. Readable from any thread;
|
||||||
|
/// the value can lag behind the render thread.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsFinished => Volatile.Read(ref Finished);
|
||||||
|
|
||||||
|
/// <summary>Loop length in frames at the output sample rate (source duration * output rate).</summary>
|
||||||
|
internal double Period;
|
||||||
|
|
||||||
|
internal AudioCursor? Cursor;
|
||||||
|
internal ChunkView View;
|
||||||
|
|
||||||
|
internal Voice(VoiceMixer mixer, AudioClipSource? source, string name)
|
||||||
|
{
|
||||||
|
_mixer = mixer;
|
||||||
|
Source = source;
|
||||||
|
Name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Clip played by this voice; null plays silence (the clip could not be loaded).</summary>
|
||||||
|
public AudioClipSource? Source { get; }
|
||||||
|
|
||||||
|
/// <summary>Name for logs (the game's clip key).</summary>
|
||||||
|
public string Name { get; }
|
||||||
|
|
||||||
|
/// <summary>Sets the playback rate (1 = original speed). Negative values are treated as 0.</summary>
|
||||||
|
public void SetPitch(double pitch, double time) => _mixer.Enqueue(this, ChangeKind.Pitch, time, Math.Max(0.0, pitch));
|
||||||
|
|
||||||
|
/// <summary>Sets the linear gain applied to the clip samples.</summary>
|
||||||
|
public void SetVolume(double volume, double time) => _mixer.Enqueue(this, ChangeKind.Volume, time, volume);
|
||||||
|
|
||||||
|
/// <summary>Turns looping on or off. Turning it off lets the current repetition play to the end of the clip.</summary>
|
||||||
|
public void SetLoop(bool loop, double time) => _mixer.Enqueue(this, ChangeKind.Loop, time, loop ? 1.0 : 0.0);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Moves playback to <paramref name="clipSeconds"/> seconds into the clip.
|
||||||
|
/// Before the voice starts, this sets the position playback starts from.
|
||||||
|
/// </summary>
|
||||||
|
public void SetPosition(double clipSeconds, double time) => _mixer.Enqueue(this, ChangeKind.Position, time, Math.Max(0.0, clipSeconds));
|
||||||
|
|
||||||
|
/// <summary>Stops playback at <paramref name="endTime"/> (seconds relative to the render start).</summary>
|
||||||
|
public void SetEndTime(double endTime, double time) => _mixer.Enqueue(this, ChangeKind.EndTime, time, endTime);
|
||||||
|
|
||||||
|
/// <summary>Stops playback at <paramref name="time"/>.</summary>
|
||||||
|
public void Stop(double time) => _mixer.Enqueue(this, ChangeKind.Stop, time, 0.0);
|
||||||
|
|
||||||
|
/// <summary>Unwrapped clip position at output sample <paramref name="sample"/> (sample at or after the anchor).</summary>
|
||||||
|
internal double PositionAt(long sample) => AnchorPosition + (sample - AnchorSample) * Pitch;
|
||||||
|
|
||||||
|
/// <summary>Clip position at <paramref name="sample"/>, wrapped into the loop when looping.</summary>
|
||||||
|
internal double WrappedPositionAt(long sample)
|
||||||
|
{
|
||||||
|
var position = PositionAt(sample);
|
||||||
|
if (Loop && Period > 0 && position >= Period) position %= Period;
|
||||||
|
return position;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies <paramref name="change"/> at output sample <paramref name="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.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using ADOFAIRenderer.Util.Audio.Clip;
|
||||||
|
|
||||||
|
namespace ADOFAIRenderer.Util.Audio.Mixer;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mixes every <see cref="Voice"/> 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 <see cref="Play"/> and the Voice Set methods). A time t maps to sample round(t * OutputRate).
|
||||||
|
///
|
||||||
|
/// Threads:
|
||||||
|
/// - Main thread: <see cref="Play"/> and the Voice Set methods queue commands under a lock. Commands must be queued in
|
||||||
|
/// non-decreasing time order per voice.
|
||||||
|
/// - Render thread: <see cref="Mix"/> 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 <see cref="AudioClipStore.Acquire"/>, 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 <see cref="ChunkView.GetSample"/>.
|
||||||
|
/// </summary>
|
||||||
|
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<Command> _commands = new();
|
||||||
|
private List<Command> _takenCommands = new();
|
||||||
|
|
||||||
|
// Render thread only.
|
||||||
|
private readonly List<Voice> _voices = new();
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
/// <param name="store">Store the voices' sources belong to; its OutputRate is the mixer's sample rate.</param>
|
||||||
|
/// <param name="outputChannels">Interleaved channel count of the mixed output.</param>
|
||||||
|
/// <param name="cancellationToken">Cancels waits for chunks in <see cref="Mix"/>.</param>
|
||||||
|
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; }
|
||||||
|
|
||||||
|
/// <summary>Voices currently mixed (started or waiting to start). Render thread only.</summary>
|
||||||
|
public int ActiveVoiceCount => _voices.Count;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Output sample containing <paramref name="seconds"/> (seconds × rate rounded down), the sample at which
|
||||||
|
/// AudioSource.PlayScheduled starts a sound scheduled between two output samples.
|
||||||
|
/// </summary>
|
||||||
|
public static long ToSample(double seconds, int rate) => (long)Math.Floor(seconds * rate);
|
||||||
|
|
||||||
|
// ── Main thread ──
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Schedules <paramref name="source"/> to start at <paramref name="startTime"/>.
|
||||||
|
/// A start time before <paramref name="time"/> (the current time) starts the voice at <paramref name="time"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="source">Clip to play; null plays silence.</param>
|
||||||
|
/// <param name="name">Name for logs.</param>
|
||||||
|
/// <param name="startTime">Start of playback, seconds relative to the render start.</param>
|
||||||
|
/// <param name="volume">Linear gain.</param>
|
||||||
|
/// <param name="pitch">Playback rate (1 = original speed); negative values are treated as 0.</param>
|
||||||
|
/// <param name="loop">Repeats the clip until the voice is stopped.</param>
|
||||||
|
/// <param name="time">Current time, seconds relative to the render start.</param>
|
||||||
|
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 ──
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds the mix of output samples [<paramref name="firstSample"/>, firstSample + frames) to <paramref name="destination"/>
|
||||||
|
/// (interleaved, frames = destination.Length / OutputChannels). Blocks are expected in increasing sample order.
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="InvalidOperationException">Chunk loading failed fatally (see <see cref="AudioClipStore.Acquire"/>).</exception>
|
||||||
|
/// <exception cref="OperationCanceledException">The cancellation token was canceled while waiting for a chunk.</exception>
|
||||||
|
public void Mix(long firstSample, Span<double> 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Mixes <paramref name="voice"/> over samples [from, to) with its current state into <paramref name="destination"/> (starting at sample from).</summary>
|
||||||
|
private void MixSegment(Voice voice, long from, long to, Span<double> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Sample of frame <paramref name="frame"/> outside the voice's current view (next chunk, loop start, or past the end).</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Makes voice.View the chunk containing <paramref name="frame"/>. False when the source cannot provide it (voice finished).</summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Releases every voice's chunk and cursor. Render thread (or after the render thread stopped).</summary>
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
_disposed = true;
|
||||||
|
foreach (var voice in _voices)
|
||||||
|
ReleaseVoice(voice);
|
||||||
|
_voices.Clear();
|
||||||
|
lock (_commandLock)
|
||||||
|
{
|
||||||
|
_commands.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+301
-359
@@ -1,416 +1,358 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Threading;
|
||||||
|
using ADOFAIRenderer.Util.Audio.Clip;
|
||||||
using ADOFAIRenderer.Util.Audio.Filter;
|
using ADOFAIRenderer.Util.Audio.Filter;
|
||||||
|
using ADOFAIRenderer.Util.Audio.Mixer;
|
||||||
using MelonLoader;
|
using MelonLoader;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
|
|
||||||
namespace ADOFAIRenderer.Util.Audio;
|
namespace ADOFAIRenderer.Util.Audio;
|
||||||
|
|
||||||
internal class AudioClipCache
|
/// <summary>
|
||||||
|
/// Audio of one render: the song and effect sounds as <see cref="Voice"/>s of a <see cref="VoiceMixer"/>, clips from an
|
||||||
|
/// <see cref="AudioClipStore"/>, 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 (<see cref="IAudioFilter.Latency"/>), 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: <see cref="InitializeBGM"/>, <see cref="CacheClip"/>, <see cref="RegisterSoundEvent"/>, <see cref="UpdateBGM"/>,
|
||||||
|
/// source/voice mapping, and <see cref="Commit"/> after each frame (all commands with an earlier time have been queued).
|
||||||
|
/// - Render thread: <see cref="GenerateAudioForFrame"/> waits until the main thread has committed the end of the samples it mixes,
|
||||||
|
/// so every command takes effect at its own sample. <see cref="Complete"/> ends the waiting when the main thread stops.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class OfflineAudioMixer : IDisposable
|
||||||
{
|
{
|
||||||
private readonly Dictionary<string, CachedClipData> _cache = new();
|
/// <summary>Gain applied to every clip.</summary>
|
||||||
|
private const double ClipVolume = 0.5;
|
||||||
|
|
||||||
public class CachedClipData
|
/// <summary>Additional gain of effect sounds (registered with <see cref="RegisterSoundEvent"/>).</summary>
|
||||||
{
|
private const double EffectVolume = 2.0 / 3.0;
|
||||||
public float[] Samples = null!; // Pre-resampled to target channels and sample rate
|
|
||||||
public double Duration;
|
|
||||||
}
|
|
||||||
|
|
||||||
public CachedClipData? GetOrCache(string clipName, int targetChannels, int targetSampleRate, bool allowLoad = true)
|
/// <summary>
|
||||||
{
|
/// Smallest number of tracked effect voices at which finished ones are removed from the list used by <see cref="StopAllEffects"/>.
|
||||||
if (string.IsNullOrEmpty(clipName)) return null;
|
/// After each removal the next threshold is twice the remaining count (at least this value).
|
||||||
|
/// </summary>
|
||||||
|
private const int EffectVoicePruneCount = 1024;
|
||||||
|
|
||||||
lock (_cache)
|
/// <summary>Longest wait for a commit before checking for cancellation again, in milliseconds.</summary>
|
||||||
{
|
private const int CommitWaitMs = 50;
|
||||||
if (_cache.TryGetValue(clipName, out var cached))
|
|
||||||
return cached;
|
|
||||||
|
|
||||||
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<IAudioFilter> _filters = new();
|
||||||
|
private readonly int _latencySamples;
|
||||||
|
|
||||||
if (!AudioManager.Instance) return null;
|
// Main thread.
|
||||||
|
private ConditionalWeakTable<AudioSource, SourceState> _sourceStates = new();
|
||||||
|
private readonly List<Voice> _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);
|
// Shared: committed output sample (voices are final before it), guarded by _commitLock.
|
||||||
if (!clip) return null;
|
private readonly object _commitLock = new();
|
||||||
|
private long _committedSample = long.MinValue;
|
||||||
|
|
||||||
try
|
// Render thread (and Dispose, under _renderLock).
|
||||||
{
|
private readonly object _renderLock = new();
|
||||||
var totalSamples = clip.samples;
|
private long _nextOutputSample;
|
||||||
var channels = clip.channels;
|
private bool _primed;
|
||||||
var rawSamples = new float[totalSamples * channels];
|
private double[] _mixBuffer = Array.Empty<double>();
|
||||||
clip.GetData(rawSamples, 0);
|
private bool _disposed;
|
||||||
|
|
||||||
// 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<SoundEvent> _activeEvents = new();
|
|
||||||
private ConditionalWeakTable<AudioSource, SoundEvent> _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<IAudioFilter> _filters = new();
|
|
||||||
|
|
||||||
// BGM Cached Samples (Pre-resampled)
|
|
||||||
private float[]? _bgmSamples;
|
|
||||||
private double _bgmDuration;
|
|
||||||
|
|
||||||
// Clip Cache
|
|
||||||
private readonly AudioClipCache _clipCache = new();
|
|
||||||
private bool _isPrimed;
|
|
||||||
|
|
||||||
public OfflineAudioMixer(int targetChannels, int targetSampleRate)
|
public OfflineAudioMixer(int targetChannels, int targetSampleRate)
|
||||||
{
|
{
|
||||||
_targetChannels = targetChannels;
|
_channels = targetChannels;
|
||||||
_targetSampleRate = targetSampleRate;
|
_sampleRate = targetSampleRate;
|
||||||
|
_store = new AudioClipStore(targetSampleRate);
|
||||||
|
_mixer = new VoiceMixer(_store, targetChannels, _cancellation.Token);
|
||||||
|
|
||||||
var rmsCompressor = new RMSCompressor
|
var rmsCompressor = new RMSCompressor
|
||||||
{
|
{
|
||||||
ThresholdDb = -10, Ratio = 2,
|
ThresholdDb = -10, Ratio = 2,
|
||||||
AttackTime = 0.1f, ReleaseTime = 0.1f,
|
AttackTime = 0.1, ReleaseTime = 0.1,
|
||||||
KneeDb = 10, RmsWindowTime = 0.02f
|
KneeDb = 10, RmsWindowTime = 0.02
|
||||||
};
|
};
|
||||||
rmsCompressor.Initialize(_targetSampleRate, _targetChannels);
|
|
||||||
_filters.AddLast(rmsCompressor);
|
|
||||||
|
|
||||||
var limiter = new RMSCompressor
|
var limiter = new RMSCompressor
|
||||||
{
|
{
|
||||||
ThresholdDb = 0, Ratio = float.PositiveInfinity,
|
ThresholdDb = 0, Ratio = double.PositiveInfinity,
|
||||||
AttackTime = 0f, ReleaseTime = 0.05f,
|
AttackTime = 0, ReleaseTime = 0.05,
|
||||||
KneeDb = 0, RmsWindowTime = 0.02f,
|
KneeDb = 0, RmsWindowTime = 0.02,
|
||||||
LookAheadTime = 0.01f
|
LookAheadTime = 0.01
|
||||||
};
|
};
|
||||||
limiter.Initialize(_targetSampleRate, _targetChannels);
|
_filters.Add(rmsCompressor);
|
||||||
_filters.AddLast(limiter);
|
_filters.Add(limiter);
|
||||||
|
_filters.Add(new HardClip());
|
||||||
var hardClip = new HardClip();
|
_filters.Add(new Gain { Level = -1 });
|
||||||
hardClip.Initialize(_targetSampleRate, _targetChannels);
|
|
||||||
_filters.AddLast(hardClip);
|
|
||||||
|
|
||||||
var truePeakSafetyGain = new Gain { Level = -1f };
|
double latency = 0;
|
||||||
truePeakSafetyGain.Initialize(_targetSampleRate, _targetChannels);
|
foreach (var filter in _filters)
|
||||||
_filters.AddLast(truePeakSafetyGain);
|
{
|
||||||
|
filter.Initialize(_sampleRate, _channels);
|
||||||
|
latency += filter.Latency;
|
||||||
|
}
|
||||||
|
_latencySamples = (int)Math.Round(latency * _sampleRate);
|
||||||
|
|
||||||
|
_store.Start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Clip store of this render (output rate = mixer sample rate), also read by <see cref="AudioSpectrumGenerator"/>.</summary>
|
||||||
|
internal AudioClipStore Store => _store;
|
||||||
|
|
||||||
|
// ── Main thread ──
|
||||||
|
|
||||||
|
/// <summary>Registers the song clip. The song voice is created and kept in sync by <see cref="UpdateBGM"/>.</summary>
|
||||||
public void InitializeBGM(AudioClip? clip)
|
public void InitializeBGM(AudioClip? clip)
|
||||||
{
|
{
|
||||||
lock (_lock)
|
if (!clip)
|
||||||
{
|
{
|
||||||
if (!clip)
|
_bgmSource = null;
|
||||||
{
|
return;
|
||||||
_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<RenderMod>.Logger.Msg($"[OfflineAudioMixer] Initialized resampled BGM: {clip.name}, Samples: {_bgmSamples.Length / _targetChannels}, TargetFreq: {_targetSampleRate}Hz, TargetChannels: {_targetChannels}");
|
|
||||||
}
|
|
||||||
catch (Exception e)
|
|
||||||
{
|
|
||||||
Melon<RenderMod>.Logger.Error($"[OfflineAudioMixer] Failed to initialize and resample BGM: {e}");
|
|
||||||
_bgmSamples = null;
|
|
||||||
_bgmDuration = 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_bgmKey = clip!.name;
|
||||||
|
_bgmSource = AudioClipResolver.GetOrAdd(_store, _bgmKey, clip);
|
||||||
|
if (_bgmSource == null)
|
||||||
|
{
|
||||||
|
Melon<RenderMod>.Logger.Error($"[OfflineAudioMixer] Song clip '{_bgmKey}' could not be loaded, the song will be silent.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Melon<RenderMod>.Logger.Msg($"[OfflineAudioMixer] Song '{_bgmKey}': {_bgmSource.Channels} ch, {_bgmSource.Duration:F2} s, {_bgmSource.Policy}");
|
||||||
}
|
}
|
||||||
|
|
||||||
public void CacheClip(string clipName)
|
/// <summary>
|
||||||
|
/// 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 <paramref name="now"/>.
|
||||||
|
/// Start time: dspTimeSong + separate countdown (crotchetAtStart / pitch * adjustedCountdownTicks when separateCountdownTime),
|
||||||
|
/// the time scrConductor.StartMusicCo schedules the song at.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="now">Current time, seconds relative to the render start.</param>
|
||||||
|
/// <param name="dspTimeBase">AudioSettings.dspTime at the render start.</param>
|
||||||
|
public void UpdateBGM(double now, double dspTimeBase)
|
||||||
{
|
{
|
||||||
_clipCache.GetOrCache(clipName, _targetChannels, _targetSampleRate, allowLoad: true);
|
if (_bgmSource == null) return;
|
||||||
}
|
|
||||||
|
|
||||||
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<AudioSource, SoundEvent>();
|
|
||||||
_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<float>.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<float>.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<RenderMod>.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;
|
|
||||||
|
|
||||||
var conductor = scrConductor.instance;
|
var conductor = scrConductor.instance;
|
||||||
if (!conductor) return;
|
if (!conductor) return;
|
||||||
|
|
||||||
double pitch = conductor.song.pitch;
|
double pitch = conductor.song.pitch;
|
||||||
var dspTimeSong = conductor.dspTimeSong;
|
var countdown = conductor.separateCountdownTime ? conductor.crotchetAtStart / pitch * conductor.adjustedCountdownTicks : 0.0;
|
||||||
var separateCountdownOffset = conductor.separateCountdownTime
|
var start = conductor.dspTimeSong + countdown - dspTimeBase;
|
||||||
? conductor.crotchetAtStart / pitch * conductor.adjustedCountdownTicks
|
var levelVolume = scnGame.instance ? scnGame.instance.levelData.volume * 0.01 : 1.0;
|
||||||
: 0.0;
|
var volume = levelVolume * ClipVolume;
|
||||||
|
|
||||||
// BGM Playback start time relative to dspTimeBase
|
if (_bgmVoice == null || start != _bgmStart)
|
||||||
var time1 = dspTimeSong + separateCountdownOffset;
|
|
||||||
var relativeTime1 = time1 - dspTimeBase;
|
|
||||||
var adjustedRelativeTime1 = relativeTime1 - totalLatency;
|
|
||||||
|
|
||||||
var totalSamples = _bgmSamples.Length / _targetChannels;
|
|
||||||
|
|
||||||
for (int s = 0; s < samplesNeeded; s++)
|
|
||||||
{
|
{
|
||||||
double sampleTime = frameStartTime + (double)s / _targetSampleRate;
|
// Song position at time t is (t - start) * pitch, also when the start is already past.
|
||||||
double clipTime = (sampleTime - adjustedRelativeTime1) * pitch;
|
_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)
|
if (pitch != _bgmPitch)
|
||||||
{
|
{
|
||||||
double sampleIdxDouble = clipTime * _targetSampleRate;
|
_bgmVoice.SetPitch(pitch, now);
|
||||||
long idxLow = (long)Math.Floor(sampleIdxDouble);
|
_bgmPitch = pitch;
|
||||||
long idxHigh = idxLow + 1;
|
}
|
||||||
float t = (float)(sampleIdxDouble - idxLow);
|
if (volume != _bgmVolume)
|
||||||
|
{
|
||||||
if (idxHigh >= totalSamples) idxHigh = totalSamples - 1;
|
_bgmVoice.SetVolume(volume, now);
|
||||||
if (idxLow >= totalSamples) idxLow = totalSamples - 1;
|
_bgmVolume = volume;
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void MixEffectSounds(float[] mixedBuffer, int samplesNeeded, double frameStartTime, double totalLatency)
|
/// <summary>Prepares the source of the game clip <paramref name="clipName"/> (audioLib key) so its first chunk starts loading.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Starts an effect sound at <paramref name="startTime"/>. An <paramref name="endTime"/> of 0 or more makes it loop until then.
|
||||||
|
/// <paramref name="source"/> (when given) is mapped to the returned state, which reports and applies later changes through the AudioSource.
|
||||||
|
/// </summary>
|
||||||
|
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];
|
_effectVoices.RemoveAll(v => v.IsFinished);
|
||||||
var clipData = _clipCache.GetOrCache(ev.ClipName, _targetChannels, _targetSampleRate, allowLoad: false);
|
_effectVoicePruneAt = Math.Max(EffectVoicePruneCount, _effectVoices.Count * 2);
|
||||||
if (clipData == null)
|
}
|
||||||
{
|
_effectVoices.Add(voice);
|
||||||
_activeEvents.RemoveAt(i);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var isSoundFinished = false;
|
var state = new SourceState(voice, Math.Max(startTime, now), pitch, loop, loop ? endTime : double.PositiveInfinity,
|
||||||
var totalSamples = clipData.Samples.Length / _targetChannels;
|
clipSource?.Duration ?? 0.0, _sampleRate);
|
||||||
double adjustedStartTime = ev.StartTime - totalLatency;
|
if (source) MapSource(source!, state);
|
||||||
double adjustedEndTime = ev.EndTime >= 0 ? ev.EndTime - totalLatency : -1.0;
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
for (var s = 0; s < samplesNeeded; s++)
|
/// <summary>
|
||||||
{
|
/// Maps <paramref name="source"/> to <paramref name="state"/> (replaces an earlier mapping) and takes the clip length and
|
||||||
var sampleTime = frameStartTime + (double)s / _targetSampleRate;
|
/// sample rate from the clip of the source.
|
||||||
|
/// </summary>
|
||||||
|
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.
|
/// <summary>State of the effect sound last started for <paramref name="source"/>. The mapping stays after the sound ends or stops.</summary>
|
||||||
if (adjustedEndTime >= 0 && sampleTime >= adjustedEndTime)
|
public bool TryGetSourceState(AudioSource? source, out SourceState state)
|
||||||
{
|
{
|
||||||
isSoundFinished = true;
|
state = null!;
|
||||||
break;
|
return source != null && _sourceStates.TryGetValue(source, out state);
|
||||||
}
|
}
|
||||||
|
|
||||||
var elapsed = sampleTime - adjustedStartTime;
|
/// <summary>
|
||||||
if (elapsed < 0) continue; // Sound hasn't started yet
|
/// Stops every effect sound at <paramref name="now"/>, 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.
|
||||||
|
/// </summary>
|
||||||
|
public void StopAllEffects(double now)
|
||||||
|
{
|
||||||
|
foreach (var voice in _effectVoices)
|
||||||
|
voice.Stop(now);
|
||||||
|
_effectVoices.Clear();
|
||||||
|
_sourceStates = new ConditionalWeakTable<AudioSource, SourceState>();
|
||||||
|
}
|
||||||
|
|
||||||
var soundTime = elapsed * ev.Pitch;
|
/// <summary>
|
||||||
|
/// Marks every command with a time before <paramref name="time"/> as queued
|
||||||
if (soundTime >= clipData.Duration)
|
/// (called after each frame with the time of the next frame).
|
||||||
{
|
/// </summary>
|
||||||
if (ev.EndTime < 0) // One-shot
|
public void Commit(double time)
|
||||||
{
|
{
|
||||||
isSoundFinished = true;
|
var sample = VoiceMixer.ToSample(time, _sampleRate);
|
||||||
break;
|
lock (_commitLock)
|
||||||
}
|
{
|
||||||
|
if (sample <= _committedSample) return;
|
||||||
// Looping
|
_committedSample = sample;
|
||||||
soundTime %= clipData.Duration;
|
Monitor.PulseAll(_commitLock);
|
||||||
}
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
/// <summary>No more commands will be queued (render ending): mixing no longer waits for commits.</summary>
|
||||||
|
public void Complete()
|
||||||
|
{
|
||||||
|
lock (_commitLock)
|
||||||
|
{
|
||||||
|
_committedSample = long.MaxValue;
|
||||||
|
Monitor.PulseAll(_commitLock);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Stops waiting for commits and chunks; the render thread's current or next mix throws OperationCanceledException.</summary>
|
||||||
|
public void Cancel()
|
||||||
|
{
|
||||||
|
_cancellation.Cancel();
|
||||||
|
Complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render thread ──
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes the next <paramref name="samplesNeeded"/> output frames (interleaved float, continuing from the previous call).
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="InvalidOperationException">Chunk loading failed fatally (out of memory or the decoder thread stopped).</exception>
|
||||||
|
/// <exception cref="OperationCanceledException">The render was canceled (<see cref="Cancel"/>).</exception>
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Mixes voices at samples [first, first + frames) into the reused double buffer, after the main thread committed them.</summary>
|
||||||
|
private Span<double> 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cancels waits, then releases the voices, the clip store (decoder thread) and the filter state.
|
||||||
|
/// Waits for a running <see cref="GenerateAudioForFrame"/> to return.
|
||||||
|
/// </summary>
|
||||||
|
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<AudioSource, SourceState>();
|
||||||
|
_effectVoices.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
using System;
|
||||||
|
using ADOFAIRenderer.Util.Audio.Mixer;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace ADOFAIRenderer.Util.Audio;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Playback state of a game AudioSource mapped to an effect <see cref="Voice"/>, 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.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// <param name="voice">Voice that plays the sound.</param>
|
||||||
|
/// <param name="start">Start of playback (seconds relative to the render start, not before the time it was scheduled).</param>
|
||||||
|
/// <param name="pitch">Pitch at the start.</param>
|
||||||
|
/// <param name="loop">Repeats the clip.</param>
|
||||||
|
/// <param name="endTime">Scheduled end time (seconds relative to the render start), or infinity.</param>
|
||||||
|
/// <param name="length">Clip length in seconds (replaced by the game clip in <see cref="BindClip"/>).</param>
|
||||||
|
/// <param name="frequency">Clip sample rate for timeSamples (replaced by the game clip in <see cref="BindClip"/>).</param>
|
||||||
|
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; }
|
||||||
|
|
||||||
|
/// <summary>Takes the clip length and sample rate from the clip of the game AudioSource.</summary>
|
||||||
|
public void BindClip(AudioClip? clip)
|
||||||
|
{
|
||||||
|
if (!clip) return;
|
||||||
|
_length = clip!.length;
|
||||||
|
_frequency = Math.Max(1, clip.frequency);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Scheduled end time (see AudioSource.SetScheduledEndTime), seconds relative to the render start.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Clip position at <paramref name="now"/> (the start position before the start), wrapped when looping.</summary>
|
||||||
|
private double PositionAt(double now)
|
||||||
|
{
|
||||||
|
var position = _anchorPosition + (Math.Max(now, _anchorTime) - _anchorTime) * PlaybackPitch;
|
||||||
|
if (_loop && _length > 0 && position >= _length) position %= _length;
|
||||||
|
return position;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Time at which the clip position reaches the clip length (infinity when looping or at pitch 0).</summary>
|
||||||
|
private double NaturalEnd()
|
||||||
|
{
|
||||||
|
if (_loop || PlaybackPitch <= 0) return double.PositiveInfinity;
|
||||||
|
return _anchorTime + Math.Max(0.0, _length - _anchorPosition) / PlaybackPitch;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user