SHA256
Document Audio Pipeline
This commit is contained in:
22 files changed
+634
-12
No files matched your search
@@ -18,6 +18,7 @@ namespace ADOFAIRenderer.Patch.Engine;
|
|||||||
[HarmonyPatch(typeof(AudioManager), nameof(AudioManager.FindOrLoadAudioClipExternal))]
|
[HarmonyPatch(typeof(AudioManager), nameof(AudioManager.FindOrLoadAudioClipExternal))]
|
||||||
public static class AudioManagerFindOrLoadAudioClipExternalPatch
|
public static class AudioManagerFindOrLoadAudioClipExternalPatch
|
||||||
{
|
{
|
||||||
|
/// <summary>Records <paramref name="path"/> in <see cref="AudioClipPaths"/> when its audioLib key is not loaded yet. Runs the original always.</summary>
|
||||||
[HarmonyPrefix]
|
[HarmonyPrefix]
|
||||||
// ReSharper disable once InconsistentNaming
|
// ReSharper disable once InconsistentNaming
|
||||||
public static void Prefix(AudioManager __instance, string path)
|
public static void Prefix(AudioManager __instance, string path)
|
||||||
@@ -28,9 +29,18 @@ public static class AudioManagerFindOrLoadAudioClipExternalPatch
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AudioManager._Play (private; AudioManager.Play forwards to it): registers each scheduled effect sound (hit sounds, PlaySound
|
||||||
|
/// events, hold sounds without an end time) as an effect voice of the offline mixer and maps the returned AudioSource to it.
|
||||||
|
/// Active while capturing (RenderManager.IsCapturingActive) with RenderAudio on. The original always runs: it creates the
|
||||||
|
/// AudioSource the game keeps using (its PlayScheduled call is blocked during rendering by another patch), and the game sets
|
||||||
|
/// time/pitch/SetScheduledEndTime on it afterwards, which the <see cref="EffectSource"/> patches forward to the mapped state.
|
||||||
|
/// The game volume is passed through; the start pitch is 1 because _Play sets pitch = 1 before PlayScheduled.
|
||||||
|
/// </summary>
|
||||||
[HarmonyPatch(typeof(AudioManager), "_Play")]
|
[HarmonyPatch(typeof(AudioManager), "_Play")]
|
||||||
public static class AudioManagerPlayPatch
|
public static class AudioManagerPlayPatch
|
||||||
{
|
{
|
||||||
|
/// <summary>Opens the clip's source so its first chunk starts loading. Returns true (the original runs).</summary>
|
||||||
[HarmonyPrefix]
|
[HarmonyPrefix]
|
||||||
public static bool Prefix(string snd, double time, AudioMixerGroup group, float volume, int priority)
|
public static bool Prefix(string snd, double time, AudioMixerGroup group, float volume, int priority)
|
||||||
{
|
{
|
||||||
@@ -42,6 +52,10 @@ public static class AudioManagerPlayPatch
|
|||||||
return true; // Let the original method execute to prevent NullReferenceException
|
return true; // Let the original method execute to prevent NullReferenceException
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers the sound at <paramref name="time"/> (absolute DSP time, converted to render time) as a non-looping effect voice,
|
||||||
|
/// maps <paramref name="__result"/> to it and queues the source's game object for deactivation at the end of the frame.
|
||||||
|
/// </summary>
|
||||||
[HarmonyPostfix]
|
[HarmonyPostfix]
|
||||||
// ReSharper disable once InconsistentNaming
|
// ReSharper disable once InconsistentNaming
|
||||||
public static void Postfix(AudioSource __result, string snd, double time, AudioMixerGroup group, float volume, int priority)
|
public static void Postfix(AudioSource __result, string snd, double time, AudioMixerGroup group, float volume, int priority)
|
||||||
@@ -63,6 +77,10 @@ public static class AudioManagerPlayPatch
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// scrConductor.PlayWithEndTime: registers a looping effect voice until the end time. The AudioSource it creates with
|
/// 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"/>).
|
/// AudioManager.MakeSource is mapped to that voice (<see cref="AudioManagerMakeSourcePatch"/>).
|
||||||
|
/// Active while capturing (RenderManager.IsCapturingActive) with RenderAudio on; the original always runs. The game calls it for
|
||||||
|
/// hold sounds with an end time above 0 (scrConductor hold sound scheduling); it sets loop = true, pitch = 1, PlayScheduled(time) and
|
||||||
|
/// SetScheduledEndTime(endTime) on the new source. pitch and SetScheduledEndTime reach the mapped state through the
|
||||||
|
/// <see cref="EffectSource"/> patches; PlayScheduled is blocked during rendering by another patch.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HarmonyPatch(typeof(scrConductor), "PlayWithEndTime")]
|
[HarmonyPatch(typeof(scrConductor), "PlayWithEndTime")]
|
||||||
public static class PlayWithEndTimePatch
|
public static class PlayWithEndTimePatch
|
||||||
@@ -70,9 +88,14 @@ public static class PlayWithEndTimePatch
|
|||||||
/// <summary>State registered by the running PlayWithEndTime call, until its MakeSource returns. Main thread only.</summary>
|
/// <summary>State registered by the running PlayWithEndTime call, until its MakeSource returns. Main thread only.</summary>
|
||||||
internal static SourceState? PendingState;
|
internal static SourceState? PendingState;
|
||||||
|
|
||||||
|
/// <summary>Clears <see cref="PendingState"/> when PlayWithEndTime returns or throws, so it cannot map a later MakeSource call.</summary>
|
||||||
[HarmonyFinalizer]
|
[HarmonyFinalizer]
|
||||||
public static void Finalizer() => PendingState = null;
|
public static void Finalizer() => PendingState = null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens the clip's source and registers a looping voice from <paramref name="time"/> to <paramref name="endTime"/>
|
||||||
|
/// (absolute DSP times, converted to render time); the state waits in <see cref="PendingState"/>. Returns true (the original runs).
|
||||||
|
/// </summary>
|
||||||
[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)
|
||||||
{
|
{
|
||||||
@@ -96,10 +119,18 @@ public static class PlayWithEndTimePatch
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Maps the AudioSource created inside scrConductor.PlayWithEndTime to its state (<see cref="PlayWithEndTimePatch.PendingState"/>).</summary>
|
/// <summary>
|
||||||
|
/// Maps the AudioSource created inside scrConductor.PlayWithEndTime to its state (<see cref="PlayWithEndTimePatch.PendingState"/>).
|
||||||
|
/// Active only while a PlayWithEndTime call registered a state (capturing with RenderAudio on); every other MakeSource call
|
||||||
|
/// (including the one inside AudioManager._Play) is left unchanged.
|
||||||
|
/// </summary>
|
||||||
[HarmonyPatch(typeof(AudioManager), nameof(AudioManager.MakeSource))]
|
[HarmonyPatch(typeof(AudioManager), nameof(AudioManager.MakeSource))]
|
||||||
public static class AudioManagerMakeSourcePatch
|
public static class AudioManagerMakeSourcePatch
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Takes the pending state, maps <paramref name="__result"/> to it and queues the source's game object for deactivation at
|
||||||
|
/// the end of the frame.
|
||||||
|
/// </summary>
|
||||||
[HarmonyPostfix]
|
[HarmonyPostfix]
|
||||||
// ReSharper disable once InconsistentNaming
|
// ReSharper disable once InconsistentNaming
|
||||||
public static void Postfix(AudioSource __result)
|
public static void Postfix(AudioSource __result)
|
||||||
@@ -173,6 +204,7 @@ internal static class EffectSource
|
|||||||
[HarmonyPatch(typeof(AudioSource), "isPlaying", MethodType.Getter)]
|
[HarmonyPatch(typeof(AudioSource), "isPlaying", MethodType.Getter)]
|
||||||
public static class AudioSourceIsPlayingPatch
|
public static class AudioSourceIsPlayingPatch
|
||||||
{
|
{
|
||||||
|
/// <summary>When <see cref="EffectSource.TryGet"/> finds a mapped state, uses it and returns false (Unity is skipped); otherwise returns true.</summary>
|
||||||
[HarmonyPrefix]
|
[HarmonyPrefix]
|
||||||
// ReSharper disable once InconsistentNaming
|
// ReSharper disable once InconsistentNaming
|
||||||
public static bool Prefix(AudioSource __instance, ref bool __result)
|
public static bool Prefix(AudioSource __instance, ref bool __result)
|
||||||
@@ -187,6 +219,7 @@ public static class AudioSourceIsPlayingPatch
|
|||||||
[HarmonyPatch(typeof(AudioSource), "time", MethodType.Getter)]
|
[HarmonyPatch(typeof(AudioSource), "time", MethodType.Getter)]
|
||||||
public static class AudioSourceTimeGetterPatch
|
public static class AudioSourceTimeGetterPatch
|
||||||
{
|
{
|
||||||
|
/// <summary>When <see cref="EffectSource.TryGet"/> finds a mapped state, uses it and returns false (Unity is skipped); otherwise returns true.</summary>
|
||||||
[HarmonyPrefix]
|
[HarmonyPrefix]
|
||||||
// ReSharper disable once InconsistentNaming
|
// ReSharper disable once InconsistentNaming
|
||||||
public static bool Prefix(AudioSource __instance, ref float __result)
|
public static bool Prefix(AudioSource __instance, ref float __result)
|
||||||
@@ -204,6 +237,7 @@ public static class AudioSourceTimeGetterPatch
|
|||||||
[HarmonyPatch(typeof(AudioSource), "time", MethodType.Setter)]
|
[HarmonyPatch(typeof(AudioSource), "time", MethodType.Setter)]
|
||||||
public static class AudioSourceTimePatch
|
public static class AudioSourceTimePatch
|
||||||
{
|
{
|
||||||
|
/// <summary>When <see cref="EffectSource.TryGet"/> finds a mapped state, uses it and returns false (Unity is skipped); otherwise returns true.</summary>
|
||||||
[HarmonyPrefix]
|
[HarmonyPrefix]
|
||||||
// ReSharper disable once InconsistentNaming
|
// ReSharper disable once InconsistentNaming
|
||||||
public static bool Prefix(AudioSource __instance, float value)
|
public static bool Prefix(AudioSource __instance, float value)
|
||||||
@@ -214,10 +248,14 @@ public static class AudioSourceTimePatch
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>AudioSource.timeSamples getter (<see cref="EffectSource"/>): clip position in samples of the clip.</summary>
|
/// <summary>
|
||||||
|
/// AudioSource.timeSamples getter (<see cref="EffectSource"/>): clip position in samples of the clip while playing; while not playing,
|
||||||
|
/// the last value set through time or timeSamples (0 after Stop()).
|
||||||
|
/// </summary>
|
||||||
[HarmonyPatch(typeof(AudioSource), "timeSamples", MethodType.Getter)]
|
[HarmonyPatch(typeof(AudioSource), "timeSamples", MethodType.Getter)]
|
||||||
public static class AudioSourceTimeSamplesGetterPatch
|
public static class AudioSourceTimeSamplesGetterPatch
|
||||||
{
|
{
|
||||||
|
/// <summary>When <see cref="EffectSource.TryGet"/> finds a mapped state, uses it and returns false (Unity is skipped); otherwise returns true.</summary>
|
||||||
[HarmonyPrefix]
|
[HarmonyPrefix]
|
||||||
// ReSharper disable once InconsistentNaming
|
// ReSharper disable once InconsistentNaming
|
||||||
public static bool Prefix(AudioSource __instance, ref int __result)
|
public static bool Prefix(AudioSource __instance, ref int __result)
|
||||||
@@ -232,6 +270,7 @@ public static class AudioSourceTimeSamplesGetterPatch
|
|||||||
[HarmonyPatch(typeof(AudioSource), "timeSamples", MethodType.Setter)]
|
[HarmonyPatch(typeof(AudioSource), "timeSamples", MethodType.Setter)]
|
||||||
public static class AudioSourceTimeSamplesPatch
|
public static class AudioSourceTimeSamplesPatch
|
||||||
{
|
{
|
||||||
|
/// <summary>When <see cref="EffectSource.TryGet"/> finds a mapped state, uses it and returns false (Unity is skipped); otherwise returns true.</summary>
|
||||||
[HarmonyPrefix]
|
[HarmonyPrefix]
|
||||||
// ReSharper disable once InconsistentNaming
|
// ReSharper disable once InconsistentNaming
|
||||||
public static bool Prefix(AudioSource __instance, int value)
|
public static bool Prefix(AudioSource __instance, int value)
|
||||||
@@ -246,6 +285,7 @@ public static class AudioSourceTimeSamplesPatch
|
|||||||
[HarmonyPatch(typeof(AudioSource), "pitch", MethodType.Getter)]
|
[HarmonyPatch(typeof(AudioSource), "pitch", MethodType.Getter)]
|
||||||
public static class AudioSourcePitchGetterPatch
|
public static class AudioSourcePitchGetterPatch
|
||||||
{
|
{
|
||||||
|
/// <summary>When <see cref="EffectSource.TryGet"/> finds a mapped state, uses it and returns false (Unity is skipped); otherwise returns true.</summary>
|
||||||
[HarmonyPrefix]
|
[HarmonyPrefix]
|
||||||
// ReSharper disable once InconsistentNaming
|
// ReSharper disable once InconsistentNaming
|
||||||
public static bool Prefix(AudioSource __instance, ref float __result)
|
public static bool Prefix(AudioSource __instance, ref float __result)
|
||||||
@@ -260,6 +300,7 @@ public static class AudioSourcePitchGetterPatch
|
|||||||
[HarmonyPatch(typeof(AudioSource), "pitch", MethodType.Setter)]
|
[HarmonyPatch(typeof(AudioSource), "pitch", MethodType.Setter)]
|
||||||
public static class AudioSourcePitchPatch
|
public static class AudioSourcePitchPatch
|
||||||
{
|
{
|
||||||
|
/// <summary>When <see cref="EffectSource.TryGet"/> finds a mapped state, uses it and returns false (Unity is skipped); otherwise returns true.</summary>
|
||||||
[HarmonyPrefix]
|
[HarmonyPrefix]
|
||||||
// ReSharper disable once InconsistentNaming
|
// ReSharper disable once InconsistentNaming
|
||||||
public static bool Prefix(AudioSource __instance, float value)
|
public static bool Prefix(AudioSource __instance, float value)
|
||||||
@@ -274,6 +315,7 @@ public static class AudioSourcePitchPatch
|
|||||||
[HarmonyPatch(typeof(AudioSource), "SetScheduledEndTime", typeof(double))]
|
[HarmonyPatch(typeof(AudioSource), "SetScheduledEndTime", typeof(double))]
|
||||||
public static class AudioSourceSetScheduledEndTimePatch
|
public static class AudioSourceSetScheduledEndTimePatch
|
||||||
{
|
{
|
||||||
|
/// <summary>When <see cref="EffectSource.TryGet"/> finds a mapped state, uses it and returns false (Unity is skipped); otherwise returns true.</summary>
|
||||||
[HarmonyPrefix]
|
[HarmonyPrefix]
|
||||||
// ReSharper disable once InconsistentNaming
|
// ReSharper disable once InconsistentNaming
|
||||||
public static bool Prefix(AudioSource __instance, double time)
|
public static bool Prefix(AudioSource __instance, double time)
|
||||||
@@ -292,6 +334,7 @@ public static class AudioSourceSetScheduledEndTimePatch
|
|||||||
[HarmonyPatch(typeof(AudioSource), "Stop", new Type[0])]
|
[HarmonyPatch(typeof(AudioSource), "Stop", new Type[0])]
|
||||||
public static class AudioSourceStopPatch
|
public static class AudioSourceStopPatch
|
||||||
{
|
{
|
||||||
|
/// <summary>When <see cref="EffectSource.TryGet"/> finds a mapped state, uses it and returns false (Unity is skipped); otherwise returns true.</summary>
|
||||||
[HarmonyPrefix]
|
[HarmonyPrefix]
|
||||||
// ReSharper disable once InconsistentNaming
|
// ReSharper disable once InconsistentNaming
|
||||||
public static bool Prefix(AudioSource __instance)
|
public static bool Prefix(AudioSource __instance)
|
||||||
@@ -306,11 +349,13 @@ public static class AudioSourceStopPatch
|
|||||||
/// AudioManager.StopAllSounds destroys every source in liveSources (all AudioManager._Play and scrConductor.PlayWithEndTime sources),
|
/// 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.
|
/// 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,
|
/// Callers include scrConductor.PlayHitTimes (from its second call), ScrubMusicToTile/Time, KillAllSoundsCoroutine,
|
||||||
/// scrController.FailAction and scnGame.Play.
|
/// scrController.FailAction, scrController.DebugUpdate, scnGame.Play and PauseMenu.Choose.
|
||||||
|
/// Active while capturing (RenderManager.IsCapturingActive) with RenderAudio on; the original always runs (it destroys the sources).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HarmonyPatch(typeof(AudioManager), nameof(AudioManager.StopAllSounds))]
|
[HarmonyPatch(typeof(AudioManager), nameof(AudioManager.StopAllSounds))]
|
||||||
public static class AudioManagerStopAllSoundsPatch
|
public static class AudioManagerStopAllSoundsPatch
|
||||||
{
|
{
|
||||||
|
/// <summary>Stops every effect voice while capturing with RenderAudio on. The original always runs (no return value).</summary>
|
||||||
[HarmonyPrefix]
|
[HarmonyPrefix]
|
||||||
public static void Prefix()
|
public static void Prefix()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -28,14 +28,31 @@ internal static class AudioSpectrumGenerator
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private const double MonoGain = 0.70710678118654752;
|
private const double MonoGain = 0.70710678118654752;
|
||||||
|
|
||||||
|
/// <summary>Store created by this class when the mixer store cannot be shared (disposed by <see cref="Clear"/>); otherwise null.</summary>
|
||||||
private static AudioClipStore? _ownStore;
|
private static AudioClipStore? _ownStore;
|
||||||
|
|
||||||
|
/// <summary>Store the song source belongs to: the mixer store or <see cref="_ownStore"/>.</summary>
|
||||||
private static AudioClipStore? _store;
|
private static AudioClipStore? _store;
|
||||||
|
|
||||||
|
/// <summary>Song source at the output sample rate, or null when not initialized or the song could not be loaded.</summary>
|
||||||
private static AudioClipSource? _source;
|
private static AudioClipSource? _source;
|
||||||
|
|
||||||
|
/// <summary>Cursor on <see cref="_source"/> moved to each analysis window, so the decoder thread loads the chunks there.</summary>
|
||||||
private static AudioCursor? _cursor;
|
private static AudioCursor? _cursor;
|
||||||
|
|
||||||
|
/// <summary>Chunk currently held for <see cref="ReadSample"/> (invalid when none).</summary>
|
||||||
private static ChunkView _view;
|
private static ChunkView _view;
|
||||||
|
|
||||||
|
/// <summary>True after a chunk loading error was logged (logged once per render).</summary>
|
||||||
private static bool _loadErrorLogged;
|
private static bool _loadErrorLogged;
|
||||||
|
|
||||||
|
/// <summary>RenderManager.frameCount of the spectra in <see cref="CachedSpectrums"/>; -1 when empty.</summary>
|
||||||
private static long _cachedFrame = -1;
|
private static long _cachedFrame = -1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Spectra computed in the current render frame, by spectrum length and source channel. Several game objects read the
|
||||||
|
/// spectrum every frame; within one frame the render time does not change, so they receive the same values.
|
||||||
|
/// </summary>
|
||||||
private static readonly Dictionary<(int Size, int Channel), float[]> CachedSpectrums = new();
|
private static readonly Dictionary<(int Size, int Channel), float[]> CachedSpectrums = new();
|
||||||
|
|
||||||
/// <summary>Registers the song clip. Call after the <see cref="OfflineAudioMixer"/> is created (null when audio is not rendered).</summary>
|
/// <summary>Registers the song clip. Call after the <see cref="OfflineAudioMixer"/> is created (null when audio is not rendered).</summary>
|
||||||
@@ -93,6 +110,17 @@ internal static class AudioSpectrumGenerator
|
|||||||
CachedSpectrums.Clear();
|
CachedSpectrums.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fills <paramref name="samples"/> with the magnitude spectrum of the song at the current render time
|
||||||
|
/// (called by the AudioSource.GetSpectrumData patch while capturing with ProcessAudio on, for any AudioSource).
|
||||||
|
/// Bin k covers frequency k * outputRate / (2 * samples.Length) Hz in output time; values are 0 ~ 1.
|
||||||
|
/// Zeros are written when the song is not available, the conductor has not started the song, or the length is not a power of two.
|
||||||
|
/// <paramref name="window"/> is not read: the Blackman-Harris window is always used (every GetSpectrumData call in the game
|
||||||
|
/// passes FFTWindow.BlackmanHarris). Main thread only.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="samples">Destination; its length is the spectrum length (power of two).</param>
|
||||||
|
/// <param name="channel">Requested channel (0 = left, 1 = right), clamped to the song's channels; ignored for mono songs.</param>
|
||||||
|
/// <param name="window">Requested window (not used).</param>
|
||||||
public static void GetSpectrumData(float[] samples, int channel, FFTWindow window)
|
public static void GetSpectrumData(float[] samples, int channel, FFTWindow window)
|
||||||
{
|
{
|
||||||
var n = samples.Length;
|
var n = samples.Length;
|
||||||
@@ -127,6 +155,8 @@ internal static class AudioSpectrumGenerator
|
|||||||
var now = render.dspTimeBase + render.AudioTime;
|
var now = render.dspTimeBase + render.AudioTime;
|
||||||
var songSeconds = (now - conductor.dspTimeSong) * pitch - countdown;
|
var songSeconds = (now - conductor.dspTimeSong) * pitch - countdown;
|
||||||
|
|
||||||
|
// Window of 2n output samples centered on the current time: output sample i reads source frame first + i * pitch,
|
||||||
|
// so the window starts n output samples (n * pitch source frames) before the song position.
|
||||||
var fftSize = 2 * n;
|
var fftSize = 2 * n;
|
||||||
var gain = volume * (_source.Channels == 1 ? MonoGain : 1.0);
|
var gain = volume * (_source.Channels == 1 ? MonoGain : 1.0);
|
||||||
var first = songSeconds * _store.OutputRate - n * pitch;
|
var first = songSeconds * _store.OutputRate - n * pitch;
|
||||||
@@ -134,6 +164,7 @@ internal static class AudioSpectrumGenerator
|
|||||||
var windowSamples = new float[fftSize];
|
var windowSamples = new float[fftSize];
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// Keep the cursor at the window start so the decoder thread loads (and keeps) the chunks the window reads.
|
||||||
_cursor!.Position = Math.Min(Math.Max((long)first, 0), _source.TotalFrames - 1);
|
_cursor!.Position = Math.Min(Math.Max((long)first, 0), _source.TotalFrames - 1);
|
||||||
for (var i = 0; i < fftSize; i++)
|
for (var i = 0; i < fftSize; i++)
|
||||||
{
|
{
|
||||||
@@ -178,6 +209,10 @@ internal static class AudioSpectrumGenerator
|
|||||||
return _view.Data[(int)(frame - _view.StartFrame) * _view.Channels + channel];
|
return _view.Data[(int)(frame - _view.StartFrame) * _view.Channels + channel];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Magnitude spectrum of <paramref name="samples"/> (length n, a power of two) after the Blackman-Harris window:
|
||||||
|
/// writes min(n / 2, spectrum.Length) bins, bin k = min(|X[k]| * 2.5 / n, 1).
|
||||||
|
/// </summary>
|
||||||
private static void CalculateFFT(float[] samples, float[] spectrum)
|
private static void CalculateFFT(float[] samples, float[] spectrum)
|
||||||
{
|
{
|
||||||
var n = samples.Length;
|
var n = samples.Length;
|
||||||
@@ -199,10 +234,10 @@ internal static class AudioSpectrumGenerator
|
|||||||
im[i] = 0f;
|
im[i] = 0f;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cooley-Tukey Radix-2 FFT
|
// Cooley-Tukey Radix-2 FFT (iterative, in place, decimation in time); m = log2(n) stages.
|
||||||
var m = (int)Math.Round(Math.Log(n, 2));
|
var m = (int)Math.Round(Math.Log(n, 2));
|
||||||
|
|
||||||
// Bit-reversal
|
// Bit-reversal: reorder the input so element i moves to the index with the bits of i reversed (j tracks that index).
|
||||||
var j = 0;
|
var j = 0;
|
||||||
for (var i = 0; i < n - 1; i++)
|
for (var i = 0; i < n - 1; i++)
|
||||||
{
|
{
|
||||||
@@ -225,7 +260,8 @@ internal static class AudioSpectrumGenerator
|
|||||||
j += k;
|
j += k;
|
||||||
}
|
}
|
||||||
|
|
||||||
// FFT stages
|
// FFT stages: stage s combines pairs of transforms of length le/2 into length le = 2^s with butterflies.
|
||||||
|
// (ur, ui) is the twiddle factor exp(-i * pi * sub / le2), advanced by multiplying with (wr, wi) = exp(-i * pi / le2).
|
||||||
for (var stage = 1; stage <= m; stage++)
|
for (var stage = 1; stage <= m; stage++)
|
||||||
{
|
{
|
||||||
var le = 1 << stage;
|
var le = 1 << stage;
|
||||||
|
|||||||
@@ -2,29 +2,50 @@ using System;
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.Util.Audio;
|
namespace ADOFAIRenderer.Util.Audio;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Splits the audio sample stream into per-video-frame sample counts so that audio and video stay aligned without drift.
|
||||||
|
///
|
||||||
|
/// The frame rate is converted to an exact fraction Numerator / Denominator (<see cref="RationalUtil.Double2MaxPrecisionInt"/>).
|
||||||
|
/// Frame k receives floor((k + 1) * sampleRate * Denominator / Numerator) - floor(k * sampleRate * Denominator / Numerator) samples:
|
||||||
|
/// the remainder of each division is carried into the next frame, so the sum over any number of frames equals the exact
|
||||||
|
/// sample count of that duration rounded down (for example 44100 Hz at 60 fps gives 735 every frame; at 144 fps three frames of 306
|
||||||
|
/// are followed by one of 307, 306.25 on average).
|
||||||
|
/// Used by the render thread only (RenderManager.WriteAudioForFrame, once per frame sent to the encoder).
|
||||||
|
/// </summary>
|
||||||
internal class AudioSyncCalculator
|
internal class AudioSyncCalculator
|
||||||
{
|
{
|
||||||
|
/// <summary>Audio sample rate in Hz (samples per channel per second).</summary>
|
||||||
private readonly long _sampleRate;
|
private readonly long _sampleRate;
|
||||||
|
|
||||||
|
/// <summary>Video frame rate as a fraction (frames per second = Numerator / Denominator).</summary>
|
||||||
private readonly (long Numerator, long Denominator) _fpsRational;
|
private readonly (long Numerator, long Denominator) _fpsRational;
|
||||||
|
|
||||||
|
/// <summary>Total samples handed out since construction or <see cref="Reset"/> (kept for debugging, not read).</summary>
|
||||||
// ReSharper disable once NotAccessedField.Local
|
// ReSharper disable once NotAccessedField.Local
|
||||||
private long _currentSample;
|
private long _currentSample;
|
||||||
|
|
||||||
|
/// <summary>Remainder of the last division, in units of 1 / Numerator samples (0 ~ Numerator - 1).</summary>
|
||||||
private long _remainder;
|
private long _remainder;
|
||||||
|
|
||||||
|
/// <param name="sampleRate">Audio sample rate in Hz.</param>
|
||||||
|
/// <param name="fps">Video frame rate in frames per second (the encoded frame rate).</param>
|
||||||
public AudioSyncCalculator(long sampleRate, double fps)
|
public AudioSyncCalculator(long sampleRate, double fps)
|
||||||
{
|
{
|
||||||
_sampleRate = sampleRate;
|
_sampleRate = sampleRate;
|
||||||
_fpsRational = RationalUtil.Double2MaxPrecisionInt(fps);
|
_fpsRational = RationalUtil.Double2MaxPrecisionInt(fps);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Restarts the sequence at frame 0 (the remainder is cleared).</summary>
|
||||||
public void Reset()
|
public void Reset()
|
||||||
{
|
{
|
||||||
_currentSample = 0;
|
_currentSample = 0;
|
||||||
_remainder = 0;
|
_remainder = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns the sample count (frames per channel) of the next video frame and advances to the following frame.</summary>
|
||||||
public int GetNextFrameSamples()
|
public int GetNextFrameSamples()
|
||||||
{
|
{
|
||||||
|
// samplesNeeded = floor((sampleRate * Denominator + remainder) / Numerator); the new remainder carries the fraction.
|
||||||
var numerator = _sampleRate * _fpsRational.Denominator + _remainder;
|
var numerator = _sampleRate * _fpsRational.Denominator + _remainder;
|
||||||
var samplesNeeded = numerator / _fpsRational.Numerator;
|
var samplesNeeded = numerator / _fpsRational.Numerator;
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ internal static class AudioClipPaths
|
|||||||
/// <summary>Suffix of the game's audioLib keys for external clips.</summary>
|
/// <summary>Suffix of the game's audioLib keys for external clips.</summary>
|
||||||
public const string ExternalSuffix = "*external";
|
public const string ExternalSuffix = "*external";
|
||||||
|
|
||||||
|
/// <summary>File path by audioLib key; also the lock object. Entries are kept for the whole game session.</summary>
|
||||||
private static readonly Dictionary<string, string> Paths = new();
|
private static readonly Dictionary<string, string> Paths = new();
|
||||||
|
|
||||||
/// <summary>audioLib key of an external clip loaded from <paramref name="path"/>.</summary>
|
/// <summary>audioLib key of an external clip loaded from <paramref name="path"/>.</summary>
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ namespace ADOFAIRenderer.Util.Audio.Clip;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
internal static class AudioClipResolver
|
internal static class AudioClipResolver
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the source registered under <paramref name="key"/> in <paramref name="store"/>, opening it on first use
|
||||||
|
/// (file first, then the Unity clip; see the class summary). A key that could not be opened is remembered as null.
|
||||||
|
/// </summary>
|
||||||
/// <param name="store">Store of the current render.</param>
|
/// <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="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>
|
/// <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>
|
||||||
@@ -38,6 +42,9 @@ internal static class AudioClipResolver
|
|||||||
Melon<RenderMod>.Logger.Warning($"[AudioClipResolver] FFmpeg could not open '{path}' for '{key}', using the Unity clip: {e.Message}");
|
Melon<RenderMod>.Logger.Warning($"[AudioClipResolver] FFmpeg could not open '{path}' for '{key}', using the Unity clip: {e.Message}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The reader opened here becomes the first decode stream; additional streams reopen the file with OpenFile.
|
||||||
|
// When no source results (store disposed, or the source could not be created), the reader is disposed here
|
||||||
|
// (readers allow Dispose more than once).
|
||||||
if (fileReader != null)
|
if (fileReader != null)
|
||||||
{
|
{
|
||||||
var source = store.GetOrAdd(key, () => fileReader, () => OpenFile(path));
|
var source = store.GetOrAdd(key, () => fileReader, () => OpenFile(path));
|
||||||
|
|||||||
@@ -13,9 +13,13 @@ namespace ADOFAIRenderer.Util.Audio.Clip;
|
|||||||
internal sealed class AudioCursor
|
internal sealed class AudioCursor
|
||||||
{
|
{
|
||||||
private readonly AudioClipSource _source;
|
private readonly AudioClipSource _source;
|
||||||
|
|
||||||
|
/// <summary>Backing value of <see cref="Position"/>, accessed with Interlocked (64-bit reads are not atomic on every platform).</summary>
|
||||||
private long _position;
|
private long _position;
|
||||||
|
|
||||||
private volatile bool _loop;
|
private volatile bool _loop;
|
||||||
|
|
||||||
|
/// <summary>Created by <see cref="AudioClipSource.AddCursor"/>.</summary>
|
||||||
internal AudioCursor(AudioClipSource source, long position, bool loop)
|
internal AudioCursor(AudioClipSource source, long position, bool loop)
|
||||||
{
|
{
|
||||||
_source = source;
|
_source = source;
|
||||||
@@ -39,7 +43,10 @@ internal sealed class AudioCursor
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>When true, the chunk after the last chunk is chunk 0.</summary>
|
/// <summary>
|
||||||
|
/// When true, the chunk after the last chunk is chunk 0 (the window wraps to the clip start), and a position past the end
|
||||||
|
/// wraps into the clip. Setting it wakes the decoder thread.
|
||||||
|
/// </summary>
|
||||||
public bool Loop
|
public bool Loop
|
||||||
{
|
{
|
||||||
get => _loop;
|
get => _loop;
|
||||||
@@ -70,8 +77,10 @@ internal readonly struct ChunkView
|
|||||||
/// <summary>Channel count of the stored samples (same as the source clip).</summary>
|
/// <summary>Channel count of the stored samples (same as the source clip).</summary>
|
||||||
public readonly int Channels;
|
public readonly int Channels;
|
||||||
|
|
||||||
|
/// <summary>Index of the chunk in its source (used by <see cref="AudioClipSource.Release"/>).</summary>
|
||||||
public readonly int ChunkIndex;
|
public readonly int ChunkIndex;
|
||||||
|
|
||||||
|
/// <summary>Created by <see cref="AudioClipSource.TryAcquire"/>; default(ChunkView) is the invalid view.</summary>
|
||||||
internal ChunkView(float[] data, long startFrame, int frameCount, int channels, int chunkIndex)
|
internal ChunkView(float[] data, long startFrame, int frameCount, int channels, int chunkIndex)
|
||||||
{
|
{
|
||||||
Data = data;
|
Data = data;
|
||||||
@@ -81,10 +90,13 @@ internal readonly struct ChunkView
|
|||||||
ChunkIndex = chunkIndex;
|
ChunkIndex = chunkIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>True for a view that holds a chunk; false for default(ChunkView).</summary>
|
||||||
public bool IsValid => Data != null;
|
public bool IsValid => Data != null;
|
||||||
|
|
||||||
|
/// <summary>Source frame after the last valid frame (exclusive end).</summary>
|
||||||
public long EndFrame => StartFrame + FrameCount;
|
public long EndFrame => StartFrame + FrameCount;
|
||||||
|
|
||||||
|
/// <summary>True when source frame <paramref name="frame"/> is one of the valid frames of this chunk.</summary>
|
||||||
public bool Contains(long frame) => frame >= StartFrame && frame < StartFrame + FrameCount;
|
public bool Contains(long frame) => frame >= StartFrame && frame < StartFrame + FrameCount;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -171,26 +183,45 @@ internal sealed class AudioClipSource
|
|||||||
internal int DecodeStreamLimit { get; set; } = MaxDecodeStreams;
|
internal int DecodeStreamLimit { get; set; } = MaxDecodeStreams;
|
||||||
|
|
||||||
private readonly AudioClipStore _store;
|
private readonly AudioClipStore _store;
|
||||||
|
|
||||||
|
/// <summary>Reader passed to the constructor (the first decode stream); disposed again in <see cref="DisposeInternal"/>.</summary>
|
||||||
private readonly IPCMReader _firstReader;
|
private readonly IPCMReader _firstReader;
|
||||||
|
|
||||||
|
/// <summary>Opens another reader for an additional decode stream; null when only the first reader can be used.</summary>
|
||||||
private readonly Func<IPCMReader?>? _openReader;
|
private readonly Func<IPCMReader?>? _openReader;
|
||||||
|
|
||||||
// Guarded by _sync: chunk table, hold counts, cursors, demand set, loaded set.
|
// Guarded by _sync: chunk table, hold counts, cursors, demand set, loaded set.
|
||||||
private readonly object _sync = new();
|
private readonly object _sync = new();
|
||||||
|
|
||||||
|
/// <summary>Sample array of each chunk, null while not loaded.</summary>
|
||||||
private readonly float[]?[] _chunks;
|
private readonly float[]?[] _chunks;
|
||||||
|
|
||||||
|
/// <summary>Number of consumers holding each chunk (TryAcquire minus Release); a held chunk is not freed.</summary>
|
||||||
private readonly int[] _holdCounts;
|
private readonly int[] _holdCounts;
|
||||||
|
|
||||||
private readonly List<AudioCursor> _cursors = new();
|
private readonly List<AudioCursor> _cursors = new();
|
||||||
|
|
||||||
|
/// <summary>Chunks a consumer asked for while they were not loaded; an index is removed when the chunk is acquired.</summary>
|
||||||
private readonly HashSet<int> _demanded = new();
|
private readonly HashSet<int> _demanded = new();
|
||||||
|
|
||||||
|
/// <summary>Indices of the loaded chunks (non-null entries of <see cref="_chunks"/>).</summary>
|
||||||
private readonly HashSet<int> _loaded = new();
|
private readonly HashSet<int> _loaded = new();
|
||||||
|
|
||||||
private int _residentLoadedCount; // Resident: chunks [0, _residentLoadedCount) are loaded
|
private int _residentLoadedCount; // Resident: chunks [0, _residentLoadedCount) are loaded
|
||||||
|
|
||||||
// Guarded by _loadLock: decode streams.
|
// Guarded by _loadLock: decode streams.
|
||||||
private readonly object _loadLock = new();
|
private readonly object _loadLock = new();
|
||||||
private readonly List<DecodeStream> _streams = new();
|
private readonly List<DecodeStream> _streams = new();
|
||||||
|
|
||||||
|
/// <summary>Incremented on every load; stored in <see cref="DecodeStream.LastUse"/> for least-recently-used selection.</summary>
|
||||||
private long _streamUseCounter;
|
private long _streamUseCounter;
|
||||||
|
|
||||||
|
/// <summary>True after opening an additional reader failed; no further readers are opened for this source.</summary>
|
||||||
private bool _openFailed;
|
private bool _openFailed;
|
||||||
|
|
||||||
private volatile bool _isFailed;
|
private volatile bool _isFailed;
|
||||||
|
|
||||||
|
/// <summary>Key the source is registered under in the store (the game's audioLib key / AudioClip.name).</summary>
|
||||||
public string Key { get; }
|
public string Key { get; }
|
||||||
|
|
||||||
/// <summary>Channel count of the stored samples (same as the clip).</summary>
|
/// <summary>Channel count of the stored samples (same as the clip).</summary>
|
||||||
@@ -205,12 +236,19 @@ internal sealed class AudioClipSource
|
|||||||
/// <summary>Clip length in seconds: clip frames / clip rate.</summary>
|
/// <summary>Clip length in seconds: clip frames / clip rate.</summary>
|
||||||
public double Duration { get; }
|
public double Duration { get; }
|
||||||
|
|
||||||
|
/// <summary>Frames per chunk: <see cref="ChunkFloats"/> / <see cref="Channels"/> (rounded down).</summary>
|
||||||
public int FramesPerChunk { get; }
|
public int FramesPerChunk { get; }
|
||||||
|
|
||||||
|
/// <summary>Number of chunks: ceil(<see cref="TotalFrames"/> / <see cref="FramesPerChunk"/>); 0 for an empty clip.</summary>
|
||||||
public int ChunkCount { get; }
|
public int ChunkCount { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="ChunkPolicy.Resident"/> for forward-only readers and clips of at most one chunk, otherwise
|
||||||
|
/// <see cref="ChunkPolicy.Windowed"/>. Fixed at construction.
|
||||||
|
/// </summary>
|
||||||
public ChunkPolicy Policy { get; }
|
public ChunkPolicy Policy { get; }
|
||||||
|
|
||||||
|
/// <summary>True when <see cref="Policy"/> is <see cref="ChunkPolicy.Resident"/>.</summary>
|
||||||
public bool IsResident => Policy == ChunkPolicy.Resident;
|
public bool IsResident => Policy == ChunkPolicy.Resident;
|
||||||
|
|
||||||
/// <summary>True after loading failed. A failed source returns no chunks.</summary>
|
/// <summary>True after loading failed. A failed source returns no chunks.</summary>
|
||||||
@@ -279,6 +317,7 @@ internal sealed class AudioClipSource
|
|||||||
return cursor;
|
return cursor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Unregisters <paramref name="cursor"/>; its window chunks become freeable on the decoder thread's next pass.</summary>
|
||||||
public void RemoveCursor(AudioCursor cursor)
|
public void RemoveCursor(AudioCursor cursor)
|
||||||
{
|
{
|
||||||
lock (_sync)
|
lock (_sync)
|
||||||
@@ -288,6 +327,7 @@ internal sealed class AudioClipSource
|
|||||||
_store.RequestService();
|
_store.RequestService();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Wakes the decoder thread after a cursor changed chunk or loop flag (resident sources ignore cursors).</summary>
|
||||||
internal void OnCursorMoved()
|
internal void OnCursorMoved()
|
||||||
{
|
{
|
||||||
if (!IsResident) _store.RequestService();
|
if (!IsResident) _store.RequestService();
|
||||||
@@ -325,6 +365,7 @@ internal sealed class AudioClipSource
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Ends one hold of the chunk of <paramref name="view"/> taken by <see cref="TryAcquire"/>. An invalid view is ignored.</summary>
|
||||||
public void Release(in ChunkView view)
|
public void Release(in ChunkView view)
|
||||||
{
|
{
|
||||||
if (!view.IsValid) return;
|
if (!view.IsValid) return;
|
||||||
@@ -335,6 +376,7 @@ internal sealed class AudioClipSource
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>View of chunk <paramref name="index"/>; the last chunk's frame count stops at <see cref="TotalFrames"/>.</summary>
|
||||||
private ChunkView CreateView(int index, float[] data)
|
private ChunkView CreateView(int index, float[] data)
|
||||||
{
|
{
|
||||||
var start = (long)index * FramesPerChunk;
|
var start = (long)index * FramesPerChunk;
|
||||||
@@ -406,6 +448,8 @@ internal sealed class AudioClipSource
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For each cursor, the first missing chunk of its window; the lowest slot over all cursors wins
|
||||||
|
// (ties go to the earlier cursor).
|
||||||
foreach (var cursor in _cursors)
|
foreach (var cursor in _cursors)
|
||||||
{
|
{
|
||||||
if (!TryGetWindowStart(cursor, out var first, out var loop)) continue;
|
if (!TryGetWindowStart(cursor, out var first, out var loop)) continue;
|
||||||
@@ -441,6 +485,10 @@ internal sealed class AudioClipSource
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// First chunk of the cursor's window: the chunk of its position (negative positions count as 0; positions past the end
|
||||||
|
/// wrap when looping). False when a non-looping cursor is past the end (no window). Caller holds _sync.
|
||||||
|
/// </summary>
|
||||||
private bool TryGetWindowStart(AudioCursor cursor, out int first, out bool loop)
|
private bool TryGetWindowStart(AudioCursor cursor, out int first, out bool loop)
|
||||||
{
|
{
|
||||||
var position = cursor.Position;
|
var position = cursor.Position;
|
||||||
@@ -456,6 +504,7 @@ internal sealed class AudioClipSource
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Chunk index of window slot <paramref name="slot"/> after <paramref name="first"/>; wraps when looping, false past the end otherwise.</summary>
|
||||||
private bool TryWindowIndex(int first, int slot, bool loop, out int index)
|
private bool TryWindowIndex(int first, int slot, bool loop, out int index)
|
||||||
{
|
{
|
||||||
index = first + slot;
|
index = first + slot;
|
||||||
@@ -493,6 +542,7 @@ internal sealed class AudioClipSource
|
|||||||
stream.LastUse = ++_streamUseCounter;
|
stream.LastUse = ++_streamUseCounter;
|
||||||
stream.Fill(start, frames, data);
|
stream.Fill(start, frames, data);
|
||||||
}
|
}
|
||||||
|
// Out of memory fails the whole render (store fatal error); any other error makes only this source silent.
|
||||||
catch (OutOfMemoryException e)
|
catch (OutOfMemoryException e)
|
||||||
{
|
{
|
||||||
ReturnArray(data);
|
ReturnArray(data);
|
||||||
@@ -568,12 +618,14 @@ internal sealed class AudioClipSource
|
|||||||
return oldest!;
|
return oldest!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Marks the source failed (no more chunks are returned or loaded); a resident source also closes its readers now. Caller holds _loadLock.</summary>
|
||||||
private void Fail()
|
private void Fail()
|
||||||
{
|
{
|
||||||
_isFailed = true;
|
_isFailed = true;
|
||||||
if (IsResident) DisposeStreams();
|
if (IsResident) DisposeStreams();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Disposes every decode stream (reader and resampler) and clears the list. Caller holds _loadLock.</summary>
|
||||||
private void DisposeStreams()
|
private void DisposeStreams()
|
||||||
{
|
{
|
||||||
foreach (var stream in _streams)
|
foreach (var stream in _streams)
|
||||||
@@ -581,6 +633,7 @@ internal sealed class AudioClipSource
|
|||||||
_streams.Clear();
|
_streams.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Gives a chunk array back to the store: exact arrays (resident) are uncounted, pooled arrays (windowed) are returned to the pool.</summary>
|
||||||
private void ReturnArray(float[]? data)
|
private void ReturnArray(float[]? data)
|
||||||
{
|
{
|
||||||
if (data == null) return;
|
if (data == null) return;
|
||||||
@@ -609,5 +662,6 @@ internal sealed class AudioClipSource
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>ceil(a / b) for a ≥ 0, b > 0.</summary>
|
||||||
private static long CeilDiv(long a, long b) => (a + b - 1) / b;
|
private static long CeilDiv(long a, long b) => (a + b - 1) / b;
|
||||||
}
|
}
|
||||||
@@ -29,18 +29,34 @@ internal sealed class AudioClipStore : IDisposable
|
|||||||
/// <summary>Longest wait for the decoder thread to stop in <see cref="Dispose"/>, in milliseconds.</summary>
|
/// <summary>Longest wait for the decoder thread to stop in <see cref="Dispose"/>, in milliseconds.</summary>
|
||||||
private const int DecoderStopTimeoutMs = 3000;
|
private const int DecoderStopTimeoutMs = 3000;
|
||||||
|
|
||||||
|
/// <summary>Sources by key; a null value marks a key whose reader could not be created. Also the lock of the source tables.</summary>
|
||||||
private readonly Dictionary<string, AudioClipSource?> _sources = new();
|
private readonly Dictionary<string, AudioClipSource?> _sources = new();
|
||||||
|
|
||||||
|
/// <summary>Non-null sources in creation order, scanned by the decoder thread. Guarded by the <see cref="_sources"/> lock.</summary>
|
||||||
private readonly List<AudioClipSource> _sourceList = new();
|
private readonly List<AudioClipSource> _sourceList = new();
|
||||||
|
|
||||||
|
/// <summary>Free windowed chunk arrays (<see cref="AudioClipSource.ChunkFloats"/> floats each), guarded by <see cref="_poolLock"/>.</summary>
|
||||||
private readonly Stack<float[]> _chunkPool = new();
|
private readonly Stack<float[]> _chunkPool = new();
|
||||||
|
|
||||||
private readonly object _poolLock = new();
|
private readonly object _poolLock = new();
|
||||||
|
|
||||||
|
/// <summary>Monitor the decoder thread pulses after each load (or failure); consumers in <see cref="Acquire"/> wait on it.</summary>
|
||||||
private readonly object _signal = new();
|
private readonly object _signal = new();
|
||||||
|
|
||||||
|
/// <summary>Wakes the decoder thread when a cursor, demand or source changes (<see cref="RequestService"/>).</summary>
|
||||||
private readonly AutoResetEvent _serviceRequested = new(false);
|
private readonly AutoResetEvent _serviceRequested = new(false);
|
||||||
|
|
||||||
private Thread? _decoderThread;
|
private Thread? _decoderThread;
|
||||||
private volatile bool _stopRequested;
|
private volatile bool _stopRequested;
|
||||||
|
|
||||||
|
/// <summary>Bytes of allocated sample arrays (see <see cref="AllocatedBytes"/>), updated with Interlocked.</summary>
|
||||||
private long _allocatedBytes;
|
private long _allocatedBytes;
|
||||||
|
|
||||||
private long _peakBytes;
|
private long _peakBytes;
|
||||||
|
|
||||||
|
/// <summary>First fatal error (out of memory, or an exception that stopped the decoder thread); null while none.</summary>
|
||||||
private volatile Exception? _fatalError;
|
private volatile Exception? _fatalError;
|
||||||
|
|
||||||
private volatile bool _disposed;
|
private volatile bool _disposed;
|
||||||
|
|
||||||
/// <summary>Sample rate every source is resampled to, in Hz.</summary>
|
/// <summary>Sample rate every source is resampled to, in Hz.</summary>
|
||||||
@@ -52,9 +68,12 @@ internal sealed class AudioClipStore : IDisposable
|
|||||||
/// <summary>Highest value of <see cref="AllocatedBytes"/> since creation.</summary>
|
/// <summary>Highest value of <see cref="AllocatedBytes"/> since creation.</summary>
|
||||||
public long PeakBytes => Interlocked.Read(ref _peakBytes);
|
public long PeakBytes => Interlocked.Read(ref _peakBytes);
|
||||||
|
|
||||||
/// <summary>Out-of-memory error raised while loading, or null.</summary>
|
/// <summary>
|
||||||
|
/// Out-of-memory error raised while opening or loading a clip, or the exception that stopped the decoder thread; null while none.
|
||||||
|
/// </summary>
|
||||||
public Exception? FatalError => _fatalError;
|
public Exception? FatalError => _fatalError;
|
||||||
|
|
||||||
|
/// <param name="outputRate">Sample rate in Hz every source is resampled to (positive). Call <see cref="Start"/> to begin decoding.</param>
|
||||||
public AudioClipStore(int outputRate)
|
public AudioClipStore(int outputRate)
|
||||||
{
|
{
|
||||||
if (outputRate <= 0) throw new ArgumentOutOfRangeException(nameof(outputRate));
|
if (outputRate <= 0) throw new ArgumentOutOfRangeException(nameof(outputRate));
|
||||||
@@ -75,6 +94,11 @@ internal sealed class AudioClipStore : IDisposable
|
|||||||
_decoderThread.Start();
|
_decoderThread.Start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decoder thread body: loads one chunk per iteration, or sleeps until <see cref="RequestService"/> (at most
|
||||||
|
/// <see cref="DecoderIdleWaitMs"/> ms) when nothing is missing. An unexpected exception ends the thread and is kept as the
|
||||||
|
/// fatal error, so waiting consumers fail instead of waiting forever.
|
||||||
|
/// </summary>
|
||||||
private void DecoderLoop()
|
private void DecoderLoop()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -124,6 +148,7 @@ internal sealed class AudioClipStore : IDisposable
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Wakes the decoder thread to re-evaluate what to free and load. Any thread.</summary>
|
||||||
internal void RequestService() => _serviceRequested.Set();
|
internal void RequestService() => _serviceRequested.Set();
|
||||||
|
|
||||||
// ── Sources ──
|
// ── Sources ──
|
||||||
@@ -169,6 +194,8 @@ internal sealed class AudioClipStore : IDisposable
|
|||||||
Melon<RenderMod>.Logger.Error($"[AudioClipStore] Failed to open clip '{key}': {e}");
|
Melon<RenderMod>.Logger.Error($"[AudioClipStore] Failed to open clip '{key}': {e}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The reader is created outside the lock (it may decode or copy a whole clip). If another caller registered the
|
||||||
|
// same key meanwhile, its entry wins and this source is discarded.
|
||||||
lock (_sources)
|
lock (_sources)
|
||||||
{
|
{
|
||||||
if (_disposed || _sources.TryGetValue(key, out var raced))
|
if (_disposed || _sources.TryGetValue(key, out var raced))
|
||||||
@@ -188,6 +215,10 @@ internal sealed class AudioClipStore : IDisposable
|
|||||||
return source;
|
return source;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when <paramref name="key"/> has been registered; <paramref name="source"/> is then the source, or null when its reader
|
||||||
|
/// could not be created. Any thread.
|
||||||
|
/// </summary>
|
||||||
public bool TryGet(string key, out AudioClipSource? source)
|
public bool TryGet(string key, out AudioClipSource? source)
|
||||||
{
|
{
|
||||||
lock (_sources)
|
lock (_sources)
|
||||||
@@ -214,6 +245,8 @@ internal sealed class AudioClipStore : IDisposable
|
|||||||
if (source.IsFailed || _disposed) return default;
|
if (source.IsFailed || _disposed) return default;
|
||||||
if (source.TryAcquire(frame, out var view)) return view;
|
if (source.TryAcquire(frame, out var view)) return view;
|
||||||
|
|
||||||
|
// Retry under the signal lock so a load completed between the first try and the wait is not missed;
|
||||||
|
// the timed wait also rechecks cancellation and failure states periodically.
|
||||||
lock (_signal)
|
lock (_signal)
|
||||||
{
|
{
|
||||||
if (source.TryAcquire(frame, out view)) return view;
|
if (source.TryAcquire(frame, out view)) return view;
|
||||||
@@ -224,6 +257,7 @@ internal sealed class AudioClipStore : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Throws <see cref="InvalidOperationException"/> wrapping <see cref="FatalError"/> when one is set.</summary>
|
||||||
private void ThrowIfFatal()
|
private void ThrowIfFatal()
|
||||||
{
|
{
|
||||||
var error = _fatalError;
|
var error = _fatalError;
|
||||||
@@ -231,6 +265,7 @@ internal sealed class AudioClipStore : IDisposable
|
|||||||
throw new InvalidOperationException("Audio clip loading failed: " + error.Message, error);
|
throw new InvalidOperationException("Audio clip loading failed: " + error.Message, error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Wakes every consumer waiting in <see cref="Acquire"/> (after a load, a failure, or on dispose).</summary>
|
||||||
internal void NotifyChunkLoaded()
|
internal void NotifyChunkLoaded()
|
||||||
{
|
{
|
||||||
lock (_signal)
|
lock (_signal)
|
||||||
@@ -239,12 +274,14 @@ internal sealed class AudioClipStore : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Logs a load error of one source (the source is already marked failed and plays silence) and wakes waiting consumers.</summary>
|
||||||
internal void ReportSourceFailure(AudioClipSource source, Exception error)
|
internal void ReportSourceFailure(AudioClipSource source, Exception error)
|
||||||
{
|
{
|
||||||
Melon<RenderMod>.Logger.Error($"[AudioClipStore] Failed to load clip '{source.Key}', it will be silent: {error}");
|
Melon<RenderMod>.Logger.Error($"[AudioClipStore] Failed to load clip '{source.Key}', it will be silent: {error}");
|
||||||
NotifyChunkLoaded();
|
NotifyChunkLoaded();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Logs an out-of-memory error, keeps it as <see cref="FatalError"/> (first one wins) and wakes waiting consumers.</summary>
|
||||||
internal void ReportFatal(AudioClipSource source, OutOfMemoryException error)
|
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}");
|
Melon<RenderMod>.Logger.Error($"[AudioClipStore] Out of memory while loading clip '{source.Key}' ({AllocatedBytes / 1048576.0:F1} MiB allocated): {error}");
|
||||||
@@ -254,6 +291,7 @@ internal sealed class AudioClipStore : IDisposable
|
|||||||
|
|
||||||
// ── Arrays ──
|
// ── Arrays ──
|
||||||
|
|
||||||
|
/// <summary>Takes a pooled array of <see cref="AudioClipSource.ChunkFloats"/> floats, or allocates (and counts) a new one. Contents are undefined.</summary>
|
||||||
internal float[] RentChunkArray()
|
internal float[] RentChunkArray()
|
||||||
{
|
{
|
||||||
lock (_poolLock)
|
lock (_poolLock)
|
||||||
@@ -265,6 +303,7 @@ internal sealed class AudioClipStore : IDisposable
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Puts a rented array back into the pool (it stays counted); after dispose it is dropped and uncounted.</summary>
|
||||||
internal void ReturnChunkArray(float[] data)
|
internal void ReturnChunkArray(float[] data)
|
||||||
{
|
{
|
||||||
if (_disposed)
|
if (_disposed)
|
||||||
@@ -278,6 +317,7 @@ internal sealed class AudioClipStore : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Allocates a counted array of exactly <paramref name="floats"/> floats (resident chunks, copied Unity clip blocks).</summary>
|
||||||
internal float[] AllocateExactArray(int floats)
|
internal float[] AllocateExactArray(int floats)
|
||||||
{
|
{
|
||||||
var data = new float[floats];
|
var data = new float[floats];
|
||||||
@@ -285,8 +325,10 @@ internal sealed class AudioClipStore : IDisposable
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Uncounts an array from <see cref="AllocateExactArray"/>; the array itself is left to the garbage collector.</summary>
|
||||||
internal void FreeExactArray(float[] data) => AddAllocated(-data.LongLength * sizeof(float));
|
internal void FreeExactArray(float[] data) => AddAllocated(-data.LongLength * sizeof(float));
|
||||||
|
|
||||||
|
/// <summary>Adds <paramref name="bytes"/> (negative to subtract) to the allocation counter and raises the peak lock-free.</summary>
|
||||||
private void AddAllocated(long bytes)
|
private void AddAllocated(long bytes)
|
||||||
{
|
{
|
||||||
var current = Interlocked.Add(ref _allocatedBytes, bytes);
|
var current = Interlocked.Add(ref _allocatedBytes, bytes);
|
||||||
@@ -297,6 +339,7 @@ internal sealed class AudioClipStore : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Logs the source count and the current and peak sample memory in MiB.</summary>
|
||||||
public void LogMemoryUsage()
|
public void LogMemoryUsage()
|
||||||
{
|
{
|
||||||
int sourceCount;
|
int sourceCount;
|
||||||
|
|||||||
@@ -18,18 +18,34 @@ internal sealed class DecodeStream : IDisposable
|
|||||||
/// <summary>Input frames read from the reader per resampler call.</summary>
|
/// <summary>Input frames read from the reader per resampler call.</summary>
|
||||||
private const int ReadBlockFrames = 4096;
|
private const int ReadBlockFrames = 4096;
|
||||||
|
|
||||||
|
/// <summary>Resampler from the reader's rate to the output rate; null when the rates are equal (samples are copied).</summary>
|
||||||
private readonly PCMResampler? _resampler;
|
private readonly PCMResampler? _resampler;
|
||||||
|
|
||||||
private readonly int _channels;
|
private readonly int _channels;
|
||||||
private readonly int _outputRate;
|
private readonly int _outputRate;
|
||||||
|
|
||||||
|
/// <summary>Input block of <see cref="ReadBlockFrames"/> frames, interleaved.</summary>
|
||||||
private readonly float[] _readBuffer;
|
private readonly float[] _readBuffer;
|
||||||
|
|
||||||
|
/// <summary>Scratch output for frames discarded after a restart; allocated on first use.</summary>
|
||||||
private float[]? _discardBuffer;
|
private float[]? _discardBuffer;
|
||||||
|
|
||||||
|
/// <summary>True once the reader returned no more input and the resampler is being drained with Flush.</summary>
|
||||||
private bool _flushing;
|
private bool _flushing;
|
||||||
|
|
||||||
|
/// <summary>True once the input and the resampler tail are exhausted; the stream must restart before producing more.</summary>
|
||||||
private bool _ended;
|
private bool _ended;
|
||||||
|
|
||||||
|
/// <summary>Input frames per alignment step: input rate / gcd (1 without resampling).</summary>
|
||||||
private readonly long _inputStep;
|
private readonly long _inputStep;
|
||||||
|
|
||||||
|
/// <summary>Output frames per alignment step: output rate / gcd (1 without resampling).</summary>
|
||||||
private readonly long _outputStep;
|
private readonly long _outputStep;
|
||||||
|
|
||||||
|
/// <summary>Input frames fed before the requested position on a restart, so the filter history matches a continuous run.</summary>
|
||||||
private readonly long _prerollInputFrames;
|
private readonly long _prerollInputFrames;
|
||||||
|
|
||||||
|
/// <summary>Reader of this stream (owned; disposed with the stream).</summary>
|
||||||
public IPCMReader Reader { get; }
|
public IPCMReader Reader { get; }
|
||||||
|
|
||||||
/// <summary>Output frame the stream continues at, or -1 when the stream is not positioned.</summary>
|
/// <summary>Output frame the stream continues at, or -1 when the stream is not positioned.</summary>
|
||||||
@@ -41,6 +57,8 @@ internal sealed class DecodeStream : IDisposable
|
|||||||
/// <summary>Number of times the stream started anew (seek + resampler reset).</summary>
|
/// <summary>Number of times the stream started anew (seek + resampler reset).</summary>
|
||||||
public int RestartCount { get; private set; }
|
public int RestartCount { get; private set; }
|
||||||
|
|
||||||
|
/// <param name="reader">Reader of the clip; ownership passes to the stream.</param>
|
||||||
|
/// <param name="outputRate">Output sample rate in Hz.</param>
|
||||||
public DecodeStream(IPCMReader reader, int outputRate)
|
public DecodeStream(IPCMReader reader, int outputRate)
|
||||||
{
|
{
|
||||||
Reader = reader;
|
Reader = reader;
|
||||||
@@ -58,6 +76,7 @@ internal sealed class DecodeStream : IDisposable
|
|||||||
// History fed before the first kept output frame when a stream starts mid-clip.
|
// 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
|
// 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.
|
// and filter_size * (input rate / output rate) input frames when downsampling.
|
||||||
|
// The preroll is four times that span plus one alignment step (the restart position is rounded down to a step).
|
||||||
var ratio = Math.Max(1L, (reader.SampleRate + outputRate - 1L) / outputRate);
|
var ratio = Math.Max(1L, (reader.SampleRate + outputRate - 1L) / outputRate);
|
||||||
_prerollInputFrames = 4L * _resampler.FilterSize * ratio + _inputStep;
|
_prerollInputFrames = 4L * _resampler.FilterSize * ratio + _inputStep;
|
||||||
}
|
}
|
||||||
@@ -77,6 +96,7 @@ internal sealed class DecodeStream : IDisposable
|
|||||||
var channels = _channels;
|
var channels = _channels;
|
||||||
var filled = 0;
|
var filled = 0;
|
||||||
|
|
||||||
|
// Same rate: output frame = input frame; seek only when the reader is not already at the start.
|
||||||
if (_resampler == null)
|
if (_resampler == null)
|
||||||
{
|
{
|
||||||
if (Reader.Position != start)
|
if (Reader.Position != start)
|
||||||
@@ -115,6 +135,7 @@ internal sealed class DecodeStream : IDisposable
|
|||||||
fixed (float* pIn = _readBuffer)
|
fixed (float* pIn = _readBuffer)
|
||||||
fixed (float* pOut = data)
|
fixed (float* pOut = data)
|
||||||
{
|
{
|
||||||
|
// Produce and drop the output frames between the aligned restart position and the requested start.
|
||||||
while (discard > 0 && !_ended)
|
while (discard > 0 && !_ended)
|
||||||
{
|
{
|
||||||
_discardBuffer ??= new float[ReadBlockFrames * channels];
|
_discardBuffer ??= new float[ReadBlockFrames * channels];
|
||||||
@@ -167,12 +188,14 @@ internal sealed class DecodeStream : IDisposable
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Releases the resampler and the reader.</summary>
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_resampler?.Dispose();
|
_resampler?.Dispose();
|
||||||
Reader.Dispose();
|
Reader.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Greatest common divisor of two positive integers (Euclid).</summary>
|
||||||
private static int GCD(int a, int b)
|
private static int GCD(int a, int b)
|
||||||
{
|
{
|
||||||
while (b != 0) (a, b) = (b, a % b);
|
while (b != 0) (a, b) = (b, a % b);
|
||||||
|
|||||||
@@ -31,12 +31,20 @@ internal sealed unsafe class FFmpegPCMReader : IPCMReader
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private const double SeekMarginSeconds = 0.25;
|
private const double SeekMarginSeconds = 0.25;
|
||||||
|
|
||||||
|
// FFmpeg objects owned by this reader, freed by Dispose (null after).
|
||||||
private AVFormatContext* _format;
|
private AVFormatContext* _format;
|
||||||
private AVCodecContext* _codec;
|
private AVCodecContext* _codec;
|
||||||
|
|
||||||
|
/// <summary>Sample format converter: decoder format to interleaved float, same rate and layout.</summary>
|
||||||
private SwrContext* _swr;
|
private SwrContext* _swr;
|
||||||
|
|
||||||
private AVPacket* _packet;
|
private AVPacket* _packet;
|
||||||
private AVFrame* _frame;
|
private AVFrame* _frame;
|
||||||
|
|
||||||
|
/// <summary>Index of the decoded audio stream in the container; every other stream is discarded by the demuxer.</summary>
|
||||||
private readonly int _streamIndex;
|
private readonly int _streamIndex;
|
||||||
|
|
||||||
|
/// <summary>Time base of the stream's timestamps (seconds per tick).</summary>
|
||||||
private readonly AVRational _timeBase;
|
private readonly AVRational _timeBase;
|
||||||
private readonly long _ptsOrigin; // Stream timestamp of output frame _leadFrames
|
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)
|
private readonly long _leadFrames; // Silent frames placed before the first decoded frame (MP3 header frame, see class summary)
|
||||||
@@ -49,14 +57,27 @@ internal sealed unsafe class FFmpegPCMReader : IPCMReader
|
|||||||
|
|
||||||
private long _nextDecodedFrame; // Frame index of the next decoded frame when counting sequentially
|
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 _syncToTimestamp; // True until the first frame after open/seek sets _nextDecodedFrame from its timestamp
|
||||||
|
// _flushSent: the null packet (drain request) was sent at the end of the file.
|
||||||
|
// _endOfStream: the decoder returned EOF, or nothing more after the drain request.
|
||||||
private bool _flushSent;
|
private bool _flushSent;
|
||||||
private bool _endOfStream;
|
private bool _endOfStream;
|
||||||
|
|
||||||
|
/// <summary>File path the reader was opened with.</summary>
|
||||||
public string Path { get; }
|
public string Path { get; }
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
public int Channels { get; }
|
public int Channels { get; }
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
public int SampleRate { get; }
|
public int SampleRate { get; }
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
public long TotalFrames { get; }
|
public long TotalFrames { get; }
|
||||||
|
|
||||||
|
/// <summary>False: the reader seeks within the file.</summary>
|
||||||
public bool IsForwardOnly => false;
|
public bool IsForwardOnly => false;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
public long Position { get; private set; }
|
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>
|
/// <summary>True when the stream is MP3 and is decoded with the game's MP3 timing (see class summary).</summary>
|
||||||
@@ -65,6 +86,12 @@ internal sealed unsafe class FFmpegPCMReader : IPCMReader
|
|||||||
/// <summary>Number of demuxer seeks performed (for diagnostics).</summary>
|
/// <summary>Number of demuxer seeks performed (for diagnostics).</summary>
|
||||||
public int SeekCount { get; private set; }
|
public int SeekCount { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens <paramref name="path"/>, selects the best audio stream and opens its decoder. For MP3 files the whole file is
|
||||||
|
/// scanned once to count frames (see the class summary).
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="InvalidOperationException">The file cannot be opened, has no decodable audio stream, has no duration
|
||||||
|
/// (non-MP3), or an FFmpeg setup call fails. Everything allocated so far is freed.</exception>
|
||||||
public FFmpegPCMReader(string path)
|
public FFmpegPCMReader(string path)
|
||||||
{
|
{
|
||||||
Path = path;
|
Path = path;
|
||||||
@@ -181,6 +208,11 @@ internal sealed unsafe class FFmpegPCMReader : IPCMReader
|
|||||||
throw new InvalidOperationException($"Failed to rewind '{Path}': {ErrorString(seek)}");
|
throw new InvalidOperationException($"Failed to rewind '{Path}': {ErrorString(seek)}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Frame count from the stream duration, or from the container duration (AV_TIME_BASE units, microseconds) when the stream
|
||||||
|
/// has none.
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="InvalidOperationException">Neither duration is known.</exception>
|
||||||
private long EstimateTotalFrames(AVStream* stream)
|
private long EstimateTotalFrames(AVStream* stream)
|
||||||
{
|
{
|
||||||
if (stream->duration != ffmpeg.AV_NOPTS_VALUE && stream->duration > 0)
|
if (stream->duration != ffmpeg.AV_NOPTS_VALUE && stream->duration > 0)
|
||||||
@@ -190,6 +222,13 @@ internal sealed unsafe class FFmpegPCMReader : IPCMReader
|
|||||||
throw new InvalidOperationException($"Audio file '{Path}' has no duration.");
|
throw new InvalidOperationException($"Audio file '{Path}' has no duration.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Moves to <paramref name="frame"/> (0 ~ <see cref="TotalFrames"/>). A target within the pending block or up to the seek margin
|
||||||
|
/// after it is reached by decoding forward; otherwise the demuxer seeks backward (AVSEEK_FLAG_BACKWARD) to target - margin
|
||||||
|
/// (or to the stream start if that fails) and the decoder is flushed. Decoded samples before the target are discarded by Read.
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="ArgumentOutOfRangeException"><paramref name="frame"/> is outside 0 ~ TotalFrames.</exception>
|
||||||
|
/// <exception cref="InvalidOperationException">The demuxer cannot seek.</exception>
|
||||||
public void Seek(long frame)
|
public void Seek(long frame)
|
||||||
{
|
{
|
||||||
if (frame < 0 || frame > TotalFrames) throw new ArgumentOutOfRangeException(nameof(frame));
|
if (frame < 0 || frame > TotalFrames) throw new ArgumentOutOfRangeException(nameof(frame));
|
||||||
@@ -205,6 +244,7 @@ internal sealed unsafe class FFmpegPCMReader : IPCMReader
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Target timestamp in stream ticks; the lead (silent) frames have no packets, so they are subtracted first.
|
||||||
var targetTs = _ptsOrigin + ffmpeg.av_rescale_q(Math.Max(0, frame - _leadFrames - marginFrames), new AVRational { num = 1, den = SampleRate }, _timeBase);
|
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);
|
var ret = ffmpeg.av_seek_frame(_format, _streamIndex, targetTs, ffmpeg.AVSEEK_FLAG_BACKWARD);
|
||||||
if (ret < 0)
|
if (ret < 0)
|
||||||
@@ -222,6 +262,12 @@ internal sealed unsafe class FFmpegPCMReader : IPCMReader
|
|||||||
Position = frame;
|
Position = frame;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
/// <remarks>
|
||||||
|
/// Frames before the first decoded frame (MP3 lead silence) and gaps between decoded timestamps are returned as zeros.
|
||||||
|
/// Returns fewer frames than requested when decoding ends before <see cref="TotalFrames"/>.
|
||||||
|
/// </remarks>
|
||||||
|
/// <exception cref="InvalidOperationException">Reading, decoding or sample conversion fails.</exception>
|
||||||
public int Read(float[] buffer, int frameCount)
|
public int Read(float[] buffer, int frameCount)
|
||||||
{
|
{
|
||||||
var limit = (int)Math.Min(frameCount, TotalFrames - Position);
|
var limit = (int)Math.Min(frameCount, TotalFrames - Position);
|
||||||
@@ -264,6 +310,10 @@ internal sealed unsafe class FFmpegPCMReader : IPCMReader
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Decodes the next audio frame into the pending buffer. Returns false at the end of the stream.</summary>
|
/// <summary>Decodes the next audio frame into the pending buffer. Returns false at the end of the stream.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Send/receive loop: receive a frame; on EAGAIN read and send the next packet of the stream; at the end of the file send a
|
||||||
|
/// null packet to drain the decoder, then receive until EOF.
|
||||||
|
/// </remarks>
|
||||||
private bool DecodeNextFrame()
|
private bool DecodeNextFrame()
|
||||||
{
|
{
|
||||||
while (!_endOfStream)
|
while (!_endOfStream)
|
||||||
@@ -315,6 +365,10 @@ internal sealed unsafe class FFmpegPCMReader : IPCMReader
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts the decoded frame to interleaved float into the pending buffer and assigns its frame position: from its timestamp
|
||||||
|
/// for the first frame after open/seek (the current position when it has none), sequential afterwards.
|
||||||
|
/// </summary>
|
||||||
private void StoreFrame()
|
private void StoreFrame()
|
||||||
{
|
{
|
||||||
var samples = _frame->nb_samples;
|
var samples = _frame->nb_samples;
|
||||||
@@ -347,6 +401,7 @@ internal sealed unsafe class FFmpegPCMReader : IPCMReader
|
|||||||
_nextDecodedFrame += samples;
|
_nextDecodedFrame += samples;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Frees every FFmpeg object and closes the file. Safe to call more than once and on a partly constructed reader.</summary>
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
if (_frame != null)
|
if (_frame != null)
|
||||||
@@ -381,6 +436,7 @@ internal sealed unsafe class FFmpegPCMReader : IPCMReader
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>FFmpeg error text for <paramref name="error"/> (av_strerror) followed by the numeric code.</summary>
|
||||||
private static string ErrorString(int error)
|
private static string ErrorString(int error)
|
||||||
{
|
{
|
||||||
var buffer = stackalloc byte[1024];
|
var buffer = stackalloc byte[1024];
|
||||||
|
|||||||
@@ -37,8 +37,13 @@ internal static class MP3HeaderFrame
|
|||||||
return SilentFrames(file);
|
return SilentFrames(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="SilentFrames(string)"/> for a seekable stream positioned anywhere (the stream is read from offset 0).
|
||||||
|
/// Only the first valid frame header within <see cref="SyncSearchBytes"/> after the ID3v2 tags is examined.
|
||||||
|
/// </summary>
|
||||||
public static int SilentFrames(Stream stream)
|
public static int SilentFrames(Stream stream)
|
||||||
{
|
{
|
||||||
|
// Skip consecutive ID3v2 tags: 10-byte header + syncsafe size (+ 10-byte footer when flagged).
|
||||||
var tag = new byte[10];
|
var tag = new byte[10];
|
||||||
long offset = 0;
|
long offset = 0;
|
||||||
while (true)
|
while (true)
|
||||||
@@ -65,6 +70,14 @@ internal static class MP3HeaderFrame
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses the 4-byte frame header at <paramref name="i"/>. False for a missing sync or a reserved version, layer, bitrate
|
||||||
|
/// index (15) or sample rate index (3).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="data">Bytes to parse.</param>
|
||||||
|
/// <param name="i">Offset of the first header byte in <paramref name="data"/>.</param>
|
||||||
|
/// <param name="samplesPerFrame">Samples per channel in the frame (384, 576 or 1152).</param>
|
||||||
|
/// <param name="tagOffset">Offset of a Xing/Info tag from the header start: 4 + CRC (2) + Layer III side information (0 for Layers I/II).</param>
|
||||||
private static bool TryParseHeader(byte[] data, int i, out int samplesPerFrame, out int tagOffset)
|
private static bool TryParseHeader(byte[] data, int i, out int samplesPerFrame, out int tagOffset)
|
||||||
{
|
{
|
||||||
samplesPerFrame = 0;
|
samplesPerFrame = 0;
|
||||||
@@ -91,6 +104,7 @@ internal static class MP3HeaderFrame
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>True when the ASCII string <paramref name="tag"/> is at <paramref name="at"/> within the first <paramref name="length"/> bytes.</summary>
|
||||||
private static bool HasTag(byte[] data, int length, int at, string tag)
|
private static bool HasTag(byte[] data, int length, int at, string tag)
|
||||||
{
|
{
|
||||||
if (at < 0 || at + tag.Length > length) return false;
|
if (at < 0 || at + tag.Length > length) return false;
|
||||||
@@ -99,6 +113,7 @@ internal static class MP3HeaderFrame
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Reads until <paramref name="count"/> bytes or the end of the stream; returns the bytes read.</summary>
|
||||||
private static int ReadFully(Stream stream, byte[] buffer, int count)
|
private static int ReadFully(Stream stream, byte[] buffer, int count)
|
||||||
{
|
{
|
||||||
var total = 0;
|
var total = 0;
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ namespace ADOFAIRenderer.Util.Audio.Clip;
|
|||||||
internal sealed class MP3MonoLayoutReader : IPCMReader
|
internal sealed class MP3MonoLayoutReader : IPCMReader
|
||||||
{
|
{
|
||||||
private readonly IPCMReader _inner;
|
private readonly IPCMReader _inner;
|
||||||
|
|
||||||
|
/// <summary>Reused buffer for inner frames, grown to the largest request.</summary>
|
||||||
private float[] _innerBuffer = Array.Empty<float>();
|
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>
|
/// <param name="inner">Mono reader of the MP3 file with the game's MP3 timing (<see cref="FFmpegPCMReader.IsMP3"/>). Owned by this reader.</param>
|
||||||
@@ -26,10 +28,19 @@ internal sealed class MP3MonoLayoutReader : IPCMReader
|
|||||||
Position = inner.Position * 2;
|
Position = inner.Position * 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Always 1.</summary>
|
||||||
public int Channels => 1;
|
public int Channels => 1;
|
||||||
|
|
||||||
|
/// <summary>The inner reader's sample rate (the game plays the doubled data at this rate).</summary>
|
||||||
public int SampleRate => _inner.SampleRate;
|
public int SampleRate => _inner.SampleRate;
|
||||||
|
|
||||||
|
/// <summary>Twice the inner frame count.</summary>
|
||||||
public long TotalFrames => _inner.TotalFrames * 2;
|
public long TotalFrames => _inner.TotalFrames * 2;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
public bool IsForwardOnly => _inner.IsForwardOnly;
|
public bool IsForwardOnly => _inner.IsForwardOnly;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
public long Position { get; private set; }
|
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>
|
/// <summary>Moves to <paramref name="frame"/>; the inner reader moves to the inner frame of the next even position.</summary>
|
||||||
@@ -39,12 +50,14 @@ internal sealed class MP3MonoLayoutReader : IPCMReader
|
|||||||
Position = frame;
|
Position = frame;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
public int Read(float[] buffer, int frameCount)
|
public int Read(float[] buffer, int frameCount)
|
||||||
{
|
{
|
||||||
var limit = (int)Math.Min(frameCount, TotalFrames - Position);
|
var limit = (int)Math.Min(frameCount, TotalFrames - Position);
|
||||||
var written = 0;
|
var written = 0;
|
||||||
while (written < limit)
|
while (written < limit)
|
||||||
{
|
{
|
||||||
|
// Odd position: the zero slot after an inner sample (also the first frame after a seek to an odd frame).
|
||||||
if ((Position & 1) == 1)
|
if ((Position & 1) == 1)
|
||||||
{
|
{
|
||||||
buffer[written++] = 0f;
|
buffer[written++] = 0f;
|
||||||
@@ -52,6 +65,7 @@ internal sealed class MP3MonoLayoutReader : IPCMReader
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Even position: read enough inner frames for the remaining output, then interleave each with a zero.
|
||||||
var want = (limit - written + 1) / 2;
|
var want = (limit - written + 1) / 2;
|
||||||
if (_innerBuffer.Length < want) _innerBuffer = new float[want];
|
if (_innerBuffer.Length < want) _innerBuffer = new float[want];
|
||||||
var got = _inner.Read(_innerBuffer, want);
|
var got = _inner.Read(_innerBuffer, want);
|
||||||
@@ -69,5 +83,6 @@ internal sealed class MP3MonoLayoutReader : IPCMReader
|
|||||||
return written;
|
return written;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Disposes the inner reader.</summary>
|
||||||
public void Dispose() => _inner.Dispose();
|
public void Dispose() => _inner.Dispose();
|
||||||
}
|
}
|
||||||
@@ -11,19 +11,38 @@ namespace ADOFAIRenderer.Util.Audio.Clip;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class MemoryPCMReader : IPCMReader
|
internal sealed class MemoryPCMReader : IPCMReader
|
||||||
{
|
{
|
||||||
|
/// <summary>Block arrays; an entry is set to null when the block is released.</summary>
|
||||||
private readonly float[]?[] _blocks;
|
private readonly float[]?[] _blocks;
|
||||||
|
|
||||||
|
/// <summary>Called with each released block (for example to uncount its memory in the store); may be null.</summary>
|
||||||
private readonly Action<float[]>? _onBlockReleased;
|
private readonly Action<float[]>? _onBlockReleased;
|
||||||
|
|
||||||
private int _releasedCount; // Blocks [0, _releasedCount) are released
|
private int _releasedCount; // Blocks [0, _releasedCount) are released
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
public int Channels { get; }
|
public int Channels { get; }
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
public int SampleRate { get; }
|
public int SampleRate { get; }
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
public long TotalFrames { get; }
|
public long TotalFrames { get; }
|
||||||
|
|
||||||
|
/// <summary>True: blocks are released as they are read.</summary>
|
||||||
public bool IsForwardOnly => true;
|
public bool IsForwardOnly => true;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
public long Position { get; private set; }
|
public long Position { get; private set; }
|
||||||
|
|
||||||
/// <summary>Frames per block (all blocks except the last).</summary>
|
/// <summary>Frames per block (all blocks except the last).</summary>
|
||||||
public int FramesPerBlock { get; }
|
public int FramesPerBlock { get; }
|
||||||
|
|
||||||
|
/// <param name="blocks">Interleaved blocks in frame order; the reader takes ownership of the arrays (the list is copied).</param>
|
||||||
|
/// <param name="framesPerBlock">Frames in every block except possibly the last (positive).</param>
|
||||||
|
/// <param name="channels">Channel count (positive).</param>
|
||||||
|
/// <param name="sampleRate">Sample rate in Hz.</param>
|
||||||
|
/// <param name="totalFrames">Frame count; the blocks must hold at least this many frames.</param>
|
||||||
|
/// <param name="onBlockReleased">Called with each block when it is released; may be null.</param>
|
||||||
public MemoryPCMReader(IReadOnlyList<float[]> blocks, int framesPerBlock, int channels, int sampleRate, long totalFrames,
|
public MemoryPCMReader(IReadOnlyList<float[]> blocks, int framesPerBlock, int channels, int sampleRate, long totalFrames,
|
||||||
Action<float[]>? onBlockReleased)
|
Action<float[]>? onBlockReleased)
|
||||||
{
|
{
|
||||||
@@ -40,6 +59,8 @@ internal sealed class MemoryPCMReader : IPCMReader
|
|||||||
TotalFrames = totalFrames;
|
TotalFrames = totalFrames;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
/// <exception cref="InvalidOperationException"><paramref name="frame"/> lies in a block that was already released.</exception>
|
||||||
public void Seek(long frame)
|
public void Seek(long frame)
|
||||||
{
|
{
|
||||||
if (frame < 0 || frame > TotalFrames) throw new ArgumentOutOfRangeException(nameof(frame));
|
if (frame < 0 || frame > TotalFrames) throw new ArgumentOutOfRangeException(nameof(frame));
|
||||||
@@ -48,6 +69,8 @@ internal sealed class MemoryPCMReader : IPCMReader
|
|||||||
Position = frame;
|
Position = frame;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
/// <remarks>Blocks whose end the position has passed are released after each copy.</remarks>
|
||||||
public int Read(float[] buffer, int frameCount)
|
public int Read(float[] buffer, int frameCount)
|
||||||
{
|
{
|
||||||
var total = (int)Math.Min(frameCount, TotalFrames - Position);
|
var total = (int)Math.Min(frameCount, TotalFrames - Position);
|
||||||
@@ -68,6 +91,7 @@ internal sealed class MemoryPCMReader : IPCMReader
|
|||||||
return written;
|
return written;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Releases every block with an index below <paramref name="blockIndex"/> that is not released yet, in order.</summary>
|
||||||
private void ReleaseBlocksBefore(int blockIndex)
|
private void ReleaseBlocksBefore(int blockIndex)
|
||||||
{
|
{
|
||||||
while (_releasedCount < blockIndex && _releasedCount < _blocks.Length)
|
while (_releasedCount < blockIndex && _releasedCount < _blocks.Length)
|
||||||
@@ -79,5 +103,6 @@ internal sealed class MemoryPCMReader : IPCMReader
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Releases every remaining block. Safe to call more than once.</summary>
|
||||||
public void Dispose() => ReleaseBlocksBefore(_blocks.Length);
|
public void Dispose() => ReleaseBlocksBefore(_blocks.Length);
|
||||||
}
|
}
|
||||||
@@ -11,16 +11,27 @@ namespace ADOFAIRenderer.Util.Audio.Clip;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed unsafe class PCMResampler : IDisposable
|
internal sealed unsafe class PCMResampler : IDisposable
|
||||||
{
|
{
|
||||||
|
/// <summary>Resampler context owned by this instance; null after <see cref="Dispose"/>.</summary>
|
||||||
private SwrContext* _swr;
|
private SwrContext* _swr;
|
||||||
|
|
||||||
|
/// <summary>True after <see cref="Flush"/> until <see cref="Reset"/>.</summary>
|
||||||
private bool _flushed;
|
private bool _flushed;
|
||||||
|
|
||||||
|
/// <summary>Channel count of input and output (interleaved).</summary>
|
||||||
public int Channels { get; }
|
public int Channels { get; }
|
||||||
|
|
||||||
|
/// <summary>Input sample rate in Hz.</summary>
|
||||||
public int InputRate { get; }
|
public int InputRate { get; }
|
||||||
|
|
||||||
|
/// <summary>Output sample rate in Hz.</summary>
|
||||||
public int OutputRate { 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>
|
/// <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 int FilterSize { get; }
|
||||||
|
|
||||||
|
/// <summary>Creates and initializes a float-to-float resampler with the default channel layout for <paramref name="channels"/>.</summary>
|
||||||
|
/// <exception cref="ArgumentOutOfRangeException">A count or rate is not positive.</exception>
|
||||||
|
/// <exception cref="InvalidOperationException">libswresample cannot allocate or initialize the context.</exception>
|
||||||
public PCMResampler(int channels, int inputRate, int outputRate)
|
public PCMResampler(int channels, int inputRate, int outputRate)
|
||||||
{
|
{
|
||||||
if (channels <= 0) throw new ArgumentOutOfRangeException(nameof(channels));
|
if (channels <= 0) throw new ArgumentOutOfRangeException(nameof(channels));
|
||||||
@@ -45,6 +56,8 @@ internal sealed unsafe class PCMResampler : IDisposable
|
|||||||
throw new InvalidOperationException($"swr_alloc_set_opts2 failed ({ret}).");
|
throw new InvalidOperationException($"swr_alloc_set_opts2 failed ({ret}).");
|
||||||
_swr = swr;
|
_swr = swr;
|
||||||
|
|
||||||
|
// Falls back to 32, the libswresample default of "filter_size", when the option cannot be read.
|
||||||
|
// Reference: https://github.com/FFmpeg/FFmpeg/blob/master/libswresample/options.c (filter_size)
|
||||||
long filterSize;
|
long filterSize;
|
||||||
FilterSize = ffmpeg.av_opt_get_int(_swr, "filter_size", 0, &filterSize) >= 0 ? (int)filterSize : 32;
|
FilterSize = ffmpeg.av_opt_get_int(_swr, "filter_size", 0, &filterSize) >= 0 ? (int)filterSize : 32;
|
||||||
|
|
||||||
@@ -96,6 +109,7 @@ internal sealed unsafe class PCMResampler : IDisposable
|
|||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Frees the context. Safe to call more than once.</summary>
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
if (_swr == null) return;
|
if (_swr == null) return;
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ internal static class UnityClipLoader
|
|||||||
if (clip.loadType == AudioClipLoadType.Streaming)
|
if (clip.loadType == AudioClipLoadType.Streaming)
|
||||||
throw new NotSupportedException($"AudioClip '{clip.name}' is a streaming clip; GetData cannot read it.");
|
throw new NotSupportedException($"AudioClip '{clip.name}' is a streaming clip; GetData cannot read it.");
|
||||||
|
|
||||||
|
// Clips with preloadAudioData off start unloaded; LoadAudioData loads the data synchronously, or asynchronously for
|
||||||
|
// clips with loadInBackground (GetData below then fails while the data is still loading).
|
||||||
|
// Reference: https://docs.unity3d.com/ScriptReference/AudioClip.LoadAudioData.html
|
||||||
if (clip.loadState == AudioDataLoadState.Unloaded)
|
if (clip.loadState == AudioDataLoadState.Unloaded)
|
||||||
clip.LoadAudioData();
|
clip.LoadAudioData();
|
||||||
|
|
||||||
|
|||||||
@@ -2,22 +2,31 @@ using System;
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.Util.Audio.Filter;
|
namespace ADOFAIRenderer.Util.Audio.Filter;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Constant gain: every sample is multiplied by 10^(<see cref="Level"/> / 20). Stateless and without latency.
|
||||||
|
/// </summary>
|
||||||
internal class Gain : IAudioFilter
|
internal class Gain : IAudioFilter
|
||||||
{
|
{
|
||||||
|
/// <summary>Gain in dB (0 = unchanged, -1 ≈ ×0.891, -6 ≈ ×0.501). Read on every sample, so changes apply immediately.</summary>
|
||||||
public double Level = 0;
|
public double Level = 0;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
public void Initialize(int sampleRate, int channels) { }
|
public void Initialize(int sampleRate, int channels) { }
|
||||||
|
|
||||||
|
/// <summary>Returns <paramref name="sample"/> × 10^(<see cref="Level"/> / 20); <paramref name="channel"/> is not used.</summary>
|
||||||
public double ProcessSample(double sample, int channel) =>
|
public double ProcessSample(double sample, int channel) =>
|
||||||
sample * Math.Pow(10, Level / 20);
|
sample * Math.Pow(10, Level / 20);
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
public void ProcessBlock(Span<double> 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
public void Reset() { }
|
public void Reset() { }
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
public double Latency => 0.0;
|
public double Latency => 0.0;
|
||||||
}
|
}
|
||||||
@@ -3,15 +3,22 @@ using System.Runtime.CompilerServices;
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.Util.Audio.Filter;
|
namespace ADOFAIRenderer.Util.Audio.Filter;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hard clipper: limits every sample to [-<see cref="Threshold"/>, +<see cref="Threshold"/>]. Stateless and without latency.
|
||||||
|
/// In the chain of <see cref="OfflineAudioMixer"/> it catches the peaks the RMS-based limiter lets through before the final gain.
|
||||||
|
/// </summary>
|
||||||
internal class HardClip : IAudioFilter
|
internal class HardClip : IAudioFilter
|
||||||
{
|
{
|
||||||
|
/// <summary>Clip level as a linear amplitude (1.0 = full scale).</summary>
|
||||||
// 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 double Threshold = 1.0;
|
public double Threshold = 1.0;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
public void Initialize(int sampleRate, int channels) { }
|
public void Initialize(int sampleRate, int channels) { }
|
||||||
|
|
||||||
|
/// <summary>Returns <paramref name="sample"/> clamped to ±<see cref="Threshold"/>; <paramref name="channel"/> is not used.</summary>
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
public double ProcessSample(double sample, int channel)
|
public double ProcessSample(double sample, int channel)
|
||||||
{
|
{
|
||||||
@@ -20,13 +27,16 @@ internal class HardClip : IAudioFilter
|
|||||||
return sample;
|
return sample;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
public void ProcessBlock(Span<double> 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
public void Reset() { }
|
public void Reset() { }
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
public double Latency => 0.0;
|
public double Latency => 0.0;
|
||||||
}
|
}
|
||||||
@@ -2,14 +2,34 @@ using System;
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.Util.Audio.Filter;
|
namespace ADOFAIRenderer.Util.Audio.Filter;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stage of the output filter chain of <see cref="OfflineAudioMixer"/>. Samples are doubles at nominal full scale ±1.0.
|
||||||
|
/// A filter keeps its own state between calls, so consecutive blocks form one continuous signal.
|
||||||
|
/// Used by one thread at a time (the render thread during rendering).
|
||||||
|
/// </summary>
|
||||||
internal interface IAudioFilter
|
internal interface IAudioFilter
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Sets the sample rate (Hz) and interleaved channel count of the signal and allocates per-channel state.
|
||||||
|
/// Called once before the first sample; the state is cleared as by <see cref="Reset"/>.
|
||||||
|
/// </summary>
|
||||||
void Initialize(int sampleRate, int channels);
|
void Initialize(int sampleRate, int channels);
|
||||||
|
|
||||||
|
/// <summary>Processes one sample of channel <paramref name="channel"/> (0 ~ channels - 1) and returns the output sample.</summary>
|
||||||
double ProcessSample(double sample, int channel);
|
double ProcessSample(double sample, int channel);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Processes <paramref name="buffer"/> in place. The buffer holds interleaved frames starting at channel 0
|
||||||
|
/// (length = frames * channels of <see cref="Initialize"/>).
|
||||||
|
/// </summary>
|
||||||
void ProcessBlock(Span<double> buffer);
|
void ProcessBlock(Span<double> buffer);
|
||||||
|
|
||||||
|
/// <summary>Clears the internal state (envelopes, delay lines) without changing the settings.</summary>
|
||||||
void Reset();
|
void Reset();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Delay of the output relative to the input, in seconds (0 for filters without look-ahead).
|
||||||
|
/// <see cref="OfflineAudioMixer"/> sums the latencies of the chain and compensates them.
|
||||||
|
/// </summary>
|
||||||
double Latency { get; }
|
double Latency { get; }
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,24 @@ using System.Runtime.CompilerServices;
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.Util.Audio.Filter;
|
namespace ADOFAIRenderer.Util.Audio.Filter;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Feed-forward RMS compressor with soft knee, attack/release ballistics in the dB domain and optional look-ahead.
|
||||||
|
/// Each channel is detected and gain-reduced independently (no stereo link).
|
||||||
|
///
|
||||||
|
/// Per sample of channel c:
|
||||||
|
/// 1. Mean square: ms[n] = r * ms[n-1] + (1 - r) * x[n]², r = exp(-1 / (sampleRate * <see cref="RmsWindowTime"/>));
|
||||||
|
/// level L = 10 * log10(ms + 1e-12) dB (the 1e-12 floor keeps silence finite, about -120 dB).
|
||||||
|
/// 2. Static curve (gain reduction G ≤ 0 dB, T = <see cref="ThresholdDb"/>, R = <see cref="Ratio"/>, W = <see cref="KneeDb"/>):
|
||||||
|
/// G = (1/R - 1) * (L - T) above T + W/2; G = (1/R - 1) * (L - T + W/2)² / (2W) inside the knee; 0 below T - W/2.
|
||||||
|
/// With W = 0 the knee is hard (G = (1/R - 1) * (L - T) above T). R = infinity gives G = T - L (a limiter at T).
|
||||||
|
/// 3. Ballistics: g[n] = k * g[n-1] + (1 - k) * G, with k = attack coefficient while the reduction grows (G < g[n-1]) and
|
||||||
|
/// release coefficient otherwise; coefficient = exp(-1 / (sampleRate * time)) (one-pole smoothing, time constant in seconds).
|
||||||
|
/// 4. Output = x[n - lookAhead] * 10^(g[n] / 20): the gain computed from the current input is applied to the input delayed by
|
||||||
|
/// <see cref="LookAheadTime"/>, so the reduction starts before the peak reaches the output.
|
||||||
|
///
|
||||||
|
/// Properties may be set before <see cref="Initialize"/> (as in an object initializer); Initialize recomputes every
|
||||||
|
/// coefficient for the actual sample rate and reallocates the delay lines. Not thread-safe.
|
||||||
|
/// </summary>
|
||||||
// ReSharper disable once InconsistentNaming
|
// ReSharper disable once InconsistentNaming
|
||||||
internal class RMSCompressor : IAudioFilter
|
internal class RMSCompressor : IAudioFilter
|
||||||
{
|
{
|
||||||
@@ -13,11 +31,17 @@ internal class RMSCompressor : IAudioFilter
|
|||||||
private double _kneeDb = 5; // in dB
|
private double _kneeDb = 5; // in dB
|
||||||
private double _rmsWindowTime = 0.030; // in seconds
|
private double _rmsWindowTime = 0.030; // in seconds
|
||||||
private double _lookAheadTime; // in seconds
|
private double _lookAheadTime; // in seconds
|
||||||
|
|
||||||
|
/// <summary>Look-ahead delay in samples per channel: round(sampleRate * <see cref="LookAheadTime"/>); 0 disables the delay line.</summary>
|
||||||
private int _lookAheadSamples;
|
private int _lookAheadSamples;
|
||||||
|
|
||||||
|
/// <summary>Sample rate in Hz; 44100 until <see cref="Initialize"/> sets the actual rate.</summary>
|
||||||
private int _sampleRate = 44100;
|
private int _sampleRate = 44100;
|
||||||
|
|
||||||
|
/// <summary>Interleaved channel count; 2 until <see cref="Initialize"/> sets the actual count.</summary>
|
||||||
private int _channels = 2;
|
private int _channels = 2;
|
||||||
|
|
||||||
|
// One-pole smoothing coefficients (0 ~ 1) of attack, release and the mean-square detector: exp(-1 / (sampleRate * time)).
|
||||||
private double _attackCoef;
|
private double _attackCoef;
|
||||||
private double _releaseCoef;
|
private double _releaseCoef;
|
||||||
private double _rmsCoef;
|
private double _rmsCoef;
|
||||||
@@ -28,6 +52,10 @@ internal class RMSCompressor : IAudioFilter
|
|||||||
private double[][] _delayBuffers = Array.Empty<double[]>(); // circular delay buffers
|
private double[][] _delayBuffers = Array.Empty<double[]>(); // circular delay buffers
|
||||||
private int[] _delayWriteIndices = Array.Empty<int>();
|
private int[] _delayWriteIndices = Array.Empty<int>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Look-ahead delay in seconds, clamped to 0 ~ 1. Setting it reallocates (and clears) the delay lines.
|
||||||
|
/// Also reported as <see cref="Latency"/>.
|
||||||
|
/// </summary>
|
||||||
public double LookAheadTime
|
public double LookAheadTime
|
||||||
{
|
{
|
||||||
get => _lookAheadTime;
|
get => _lookAheadTime;
|
||||||
@@ -38,20 +66,24 @@ internal class RMSCompressor : IAudioFilter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Output delay in seconds: <see cref="LookAheadTime"/>.</summary>
|
||||||
public double Latency => _lookAheadTime;
|
public double Latency => _lookAheadTime;
|
||||||
|
|
||||||
|
/// <summary>Threshold in dB relative to full scale (RMS level of a constant ±1.0 signal = 0 dB).</summary>
|
||||||
public double ThresholdDb
|
public double ThresholdDb
|
||||||
{
|
{
|
||||||
get => _thresholdDb;
|
get => _thresholdDb;
|
||||||
set => _thresholdDb = value;
|
set => _thresholdDb = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Compression ratio (input dB above threshold : output dB above threshold), at least 1. Infinity limits at the threshold.</summary>
|
||||||
public double Ratio
|
public double Ratio
|
||||||
{
|
{
|
||||||
get => _ratio;
|
get => _ratio;
|
||||||
set => _ratio = Math.Max(1, value);
|
set => _ratio = Math.Max(1, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Attack time constant in seconds, at least 0.0001 (smaller values, including 0, are raised to it).</summary>
|
||||||
public double AttackTime
|
public double AttackTime
|
||||||
{
|
{
|
||||||
get => _attackTime;
|
get => _attackTime;
|
||||||
@@ -62,6 +94,7 @@ internal class RMSCompressor : IAudioFilter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Release time constant in seconds, at least 0.0001.</summary>
|
||||||
public double ReleaseTime
|
public double ReleaseTime
|
||||||
{
|
{
|
||||||
get => _releaseTime;
|
get => _releaseTime;
|
||||||
@@ -72,12 +105,14 @@ internal class RMSCompressor : IAudioFilter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Total knee width in dB, centered on the threshold, at least 0 (0 = hard knee).</summary>
|
||||||
public double KneeDb
|
public double KneeDb
|
||||||
{
|
{
|
||||||
get => _kneeDb;
|
get => _kneeDb;
|
||||||
set => _kneeDb = Math.Max(0, value);
|
set => _kneeDb = Math.Max(0, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Time constant of the mean-square detector in seconds, at least 0.0001.</summary>
|
||||||
public double RmsWindowTime
|
public double RmsWindowTime
|
||||||
{
|
{
|
||||||
get => _rmsWindowTime;
|
get => _rmsWindowTime;
|
||||||
@@ -88,6 +123,7 @@ internal class RMSCompressor : IAudioFilter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
public void Initialize(int sampleRate, int channels)
|
public void Initialize(int sampleRate, int channels)
|
||||||
{
|
{
|
||||||
_sampleRate = sampleRate;
|
_sampleRate = sampleRate;
|
||||||
@@ -110,6 +146,7 @@ internal class RMSCompressor : IAudioFilter
|
|||||||
ReallocateDelayBuffers();
|
ReallocateDelayBuffers();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Allocates one zeroed circular buffer of <see cref="_lookAheadSamples"/> samples per channel.</summary>
|
||||||
private void ReallocateDelayBuffers()
|
private void ReallocateDelayBuffers()
|
||||||
{
|
{
|
||||||
if (_channels <= 0) return;
|
if (_channels <= 0) return;
|
||||||
@@ -139,6 +176,10 @@ internal class RMSCompressor : IAudioFilter
|
|||||||
_rmsCoef = Math.Exp(-1.0 / (_sampleRate * _rmsWindowTime));
|
_rmsCoef = Math.Exp(-1.0 / (_sampleRate * _rmsWindowTime));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Processes one sample of <paramref name="channel"/> (see the class summary). A channel outside 0 ~ channels - 1 is
|
||||||
|
/// returned unchanged and does not touch any state.
|
||||||
|
/// </summary>
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
public double ProcessSample(double sample, int channel)
|
public double ProcessSample(double sample, int channel)
|
||||||
{
|
{
|
||||||
@@ -162,10 +203,12 @@ internal class RMSCompressor : IAudioFilter
|
|||||||
{
|
{
|
||||||
if (rmsDb > _thresholdDb + _kneeDb * 0.5)
|
if (rmsDb > _thresholdDb + _kneeDb * 0.5)
|
||||||
{
|
{
|
||||||
|
// Above the knee: full ratio.
|
||||||
gStatic = (1 / _ratio - 1) * (rmsDb - _thresholdDb);
|
gStatic = (1 / _ratio - 1) * (rmsDb - _thresholdDb);
|
||||||
}
|
}
|
||||||
else if (rmsDb >= _thresholdDb - _kneeDb * 0.5)
|
else if (rmsDb >= _thresholdDb - _kneeDb * 0.5)
|
||||||
{
|
{
|
||||||
|
// Inside the knee: quadratic transition from 0 dB at T - W/2 to the full-ratio line at T + W/2.
|
||||||
double diff = rmsDb - _thresholdDb + _kneeDb * 0.5;
|
double diff = rmsDb - _thresholdDb + _kneeDb * 0.5;
|
||||||
gStatic = (1 / _ratio - 1) * diff * diff / (2 * _kneeDb);
|
gStatic = (1 / _ratio - 1) * diff * diff / (2 * _kneeDb);
|
||||||
}
|
}
|
||||||
@@ -194,6 +237,7 @@ internal class RMSCompressor : IAudioFilter
|
|||||||
|
|
||||||
if (_lookAheadSamples > 0 && _delayBuffers.Length > channel && _delayBuffers[channel].Length > 0)
|
if (_lookAheadSamples > 0 && _delayBuffers.Length > channel && _delayBuffers[channel].Length > 0)
|
||||||
{
|
{
|
||||||
|
// Circular delay line: read the sample written _lookAheadSamples calls ago, then overwrite it with the current one.
|
||||||
var buffer = _delayBuffers[channel];
|
var buffer = _delayBuffers[channel];
|
||||||
int writeIdx = _delayWriteIndices[channel];
|
int writeIdx = _delayWriteIndices[channel];
|
||||||
double delayedSample = buffer[writeIdx];
|
double delayedSample = buffer[writeIdx];
|
||||||
@@ -205,6 +249,7 @@ internal class RMSCompressor : IAudioFilter
|
|||||||
return sample * linearGain;
|
return sample * linearGain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
public void ProcessBlock(Span<double> buffer)
|
public void ProcessBlock(Span<double> buffer)
|
||||||
{
|
{
|
||||||
for (var i = 0; i < buffer.Length; i++)
|
for (var i = 0; i < buffer.Length; i++)
|
||||||
@@ -214,6 +259,7 @@ internal class RMSCompressor : IAudioFilter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Clears the detector, the gain envelope and the delay lines (the delay outputs zeros for the next look-ahead samples).</summary>
|
||||||
public void Reset()
|
public void Reset()
|
||||||
{
|
{
|
||||||
Array.Clear(_rmsState, 0, _rmsState.Length);
|
Array.Clear(_rmsState, 0, _rmsState.Length);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ namespace ADOFAIRenderer.Util.Audio.Mixer;
|
|||||||
///
|
///
|
||||||
/// Main thread: the Set methods queue a change that takes effect at a given time (seconds relative to the render start,
|
/// 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.
|
/// 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).
|
/// Render thread: <see cref="VoiceMixer.Mix"/> applies each change at sample floor(time * OutputRate) (<see cref="VoiceMixer.ToSample"/>).
|
||||||
///
|
///
|
||||||
/// Playback model (output samples n, clip positions in frames of the source at the output sample rate):
|
/// 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.
|
/// - The voice is silent before its start sample and from its end sample on.
|
||||||
@@ -21,20 +21,37 @@ namespace ADOFAIRenderer.Util.Audio.Mixer;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class Voice
|
internal sealed class Voice
|
||||||
{
|
{
|
||||||
|
/// <summary>Kind of a queued <see cref="Change"/>; the meaning of <see cref="Change.Value"/> depends on it.</summary>
|
||||||
internal enum ChangeKind
|
internal enum ChangeKind
|
||||||
{
|
{
|
||||||
|
/// <summary>Value: playback rate, at least 0.</summary>
|
||||||
Pitch,
|
Pitch,
|
||||||
|
|
||||||
|
/// <summary>Value: linear gain.</summary>
|
||||||
Volume,
|
Volume,
|
||||||
|
|
||||||
|
/// <summary>Value: 1 = loop on, 0 = loop off.</summary>
|
||||||
Loop,
|
Loop,
|
||||||
|
|
||||||
|
/// <summary>Value: clip position in seconds, at least 0.</summary>
|
||||||
Position,
|
Position,
|
||||||
|
|
||||||
|
/// <summary>Value: end time in seconds relative to the render start.</summary>
|
||||||
EndTime,
|
EndTime,
|
||||||
|
|
||||||
|
/// <summary>Value: unused (0). Ends playback at the sample the change is applied at.</summary>
|
||||||
Stop
|
Stop
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>One timed change of a voice, queued by the main thread and applied by the render thread.</summary>
|
||||||
internal readonly struct Change
|
internal readonly struct Change
|
||||||
{
|
{
|
||||||
public readonly ChangeKind Kind;
|
public readonly ChangeKind Kind;
|
||||||
|
|
||||||
|
/// <summary>Output sample at which the change takes effect (<see cref="VoiceMixer.ToSample"/> of the change time).</summary>
|
||||||
public readonly long Sample;
|
public readonly long Sample;
|
||||||
|
|
||||||
|
/// <summary>Argument of the change (see <see cref="ChangeKind"/>).</summary>
|
||||||
public readonly double Value;
|
public readonly double Value;
|
||||||
|
|
||||||
public Change(ChangeKind kind, long sample, double value)
|
public Change(ChangeKind kind, long sample, double value)
|
||||||
@@ -45,6 +62,7 @@ internal sealed class Voice
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Mixer that owns this voice; the Set methods queue their changes on it.</summary>
|
||||||
private readonly VoiceMixer _mixer;
|
private readonly VoiceMixer _mixer;
|
||||||
|
|
||||||
// ── Render thread state ──
|
// ── Render thread state ──
|
||||||
@@ -58,11 +76,22 @@ internal sealed class Voice
|
|||||||
/// <summary>Output sample at which playback stops (exclusive); long.MaxValue when not set.</summary>
|
/// <summary>Output sample at which playback stops (exclusive); long.MaxValue when not set.</summary>
|
||||||
internal long EndSample = long.MaxValue;
|
internal long EndSample = long.MaxValue;
|
||||||
|
|
||||||
|
/// <summary>Output sample of the current anchor (see the class summary).</summary>
|
||||||
internal long AnchorSample;
|
internal long AnchorSample;
|
||||||
|
|
||||||
|
/// <summary>Clip position at <see cref="AnchorSample"/>, in frames at the output sample rate (may be fractional).</summary>
|
||||||
internal double AnchorPosition;
|
internal double AnchorPosition;
|
||||||
|
|
||||||
|
/// <summary>Clip frames advanced per output sample (1 = original speed), at least 0.</summary>
|
||||||
internal double Pitch;
|
internal double Pitch;
|
||||||
|
|
||||||
|
/// <summary>Linear gain applied to the clip samples.</summary>
|
||||||
internal double Volume;
|
internal double Volume;
|
||||||
|
|
||||||
|
/// <summary>True while the clip repeats at <see cref="Period"/>.</summary>
|
||||||
internal bool Loop;
|
internal bool Loop;
|
||||||
|
|
||||||
|
/// <summary>Set by the render thread when playback has ended; the voice is then released and removed from the mixer.</summary>
|
||||||
internal bool Finished;
|
internal bool Finished;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -74,7 +103,10 @@ internal sealed class Voice
|
|||||||
/// <summary>Loop length in frames at the output sample rate (source duration * output rate).</summary>
|
/// <summary>Loop length in frames at the output sample rate (source duration * output rate).</summary>
|
||||||
internal double Period;
|
internal double Period;
|
||||||
|
|
||||||
|
/// <summary>Read position registered on <see cref="Source"/> so the decoder thread keeps the chunks ahead loaded; null without a usable source.</summary>
|
||||||
internal AudioCursor? Cursor;
|
internal AudioCursor? Cursor;
|
||||||
|
|
||||||
|
/// <summary>Chunk of <see cref="Source"/> currently held for reading (invalid when none). Render thread only.</summary>
|
||||||
internal ChunkView View;
|
internal ChunkView View;
|
||||||
|
|
||||||
internal Voice(VoiceMixer mixer, AudioClipSource? source, string name)
|
internal Voice(VoiceMixer mixer, AudioClipSource? source, string name)
|
||||||
@@ -128,6 +160,9 @@ internal sealed class Voice
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
internal void Apply(in Change change, long sample, int outputRate)
|
internal void Apply(in Change change, long sample, int outputRate)
|
||||||
{
|
{
|
||||||
|
// Anchor changes made before the start take effect at the start sample (playback starts from the new state).
|
||||||
|
// EndTime uses the change's own value; Stop ends at the sample it is applied at, so a Stop before the start
|
||||||
|
// ends the voice before it plays.
|
||||||
var at = Math.Max(sample, StartSample);
|
var at = Math.Max(sample, StartSample);
|
||||||
switch (change.Kind)
|
switch (change.Kind)
|
||||||
{
|
{
|
||||||
@@ -155,6 +190,7 @@ internal sealed class Voice
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Moves the anchor to <paramref name="sample"/>, keeping the (wrapped) position there, before a pitch or loop change.</summary>
|
||||||
private void Reanchor(long sample)
|
private void Reanchor(long sample)
|
||||||
{
|
{
|
||||||
AnchorPosition = WrappedPositionAt(sample);
|
AnchorPosition = WrappedPositionAt(sample);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ namespace ADOFAIRenderer.Util.Audio.Mixer;
|
|||||||
/// Mixes every <see cref="Voice"/> into interleaved double samples at the output sample rate and channel count.
|
/// 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
|
/// 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).
|
/// to <see cref="Play"/> and the Voice Set methods). A time t maps to sample floor(t * OutputRate) (<see cref="ToSample"/>).
|
||||||
///
|
///
|
||||||
/// Threads:
|
/// Threads:
|
||||||
/// - Main thread: <see cref="Play"/> and the Voice Set methods queue commands under a lock. Commands must be queued in
|
/// - Main thread: <see cref="Play"/> and the Voice Set methods queue commands under a lock. Commands must be queued in
|
||||||
@@ -25,16 +25,23 @@ namespace ADOFAIRenderer.Util.Audio.Mixer;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class VoiceMixer : IDisposable
|
internal sealed class VoiceMixer : IDisposable
|
||||||
{
|
{
|
||||||
|
/// <summary>Kind of a queued main-thread command.</summary>
|
||||||
private enum CommandKind
|
private enum CommandKind
|
||||||
{
|
{
|
||||||
|
/// <summary>Adds a new voice to the mixed set (created by <see cref="VoiceMixer.Play"/>).</summary>
|
||||||
Play,
|
Play,
|
||||||
|
|
||||||
|
/// <summary>Appends a <see cref="Voice.Change"/> to the voice's pending queue.</summary>
|
||||||
Change
|
Change
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Command queued by the main thread and taken by the render thread at the start of <see cref="Mix"/>.</summary>
|
||||||
private readonly struct Command
|
private readonly struct Command
|
||||||
{
|
{
|
||||||
public readonly CommandKind Kind;
|
public readonly CommandKind Kind;
|
||||||
public readonly Voice Voice;
|
public readonly Voice Voice;
|
||||||
|
|
||||||
|
/// <summary>The change for <see cref="CommandKind.Change"/>; default for <see cref="CommandKind.Play"/>.</summary>
|
||||||
public readonly Voice.Change Change;
|
public readonly Voice.Change Change;
|
||||||
|
|
||||||
public Command(CommandKind kind, Voice voice, Voice.Change change)
|
public Command(CommandKind kind, Voice voice, Voice.Change change)
|
||||||
@@ -48,6 +55,8 @@ internal sealed class VoiceMixer : IDisposable
|
|||||||
private readonly AudioClipStore _store;
|
private readonly AudioClipStore _store;
|
||||||
private readonly CancellationToken _cancellationToken;
|
private readonly CancellationToken _cancellationToken;
|
||||||
|
|
||||||
|
// Double-buffered command lists: the main thread appends to _commands under _commandLock; the render thread swaps the
|
||||||
|
// two lists under the lock and processes _takenCommands outside it, then clears it for the next swap.
|
||||||
private readonly object _commandLock = new();
|
private readonly object _commandLock = new();
|
||||||
private List<Command> _commands = new();
|
private List<Command> _commands = new();
|
||||||
private List<Command> _takenCommands = new();
|
private List<Command> _takenCommands = new();
|
||||||
@@ -68,8 +77,10 @@ internal sealed class VoiceMixer : IDisposable
|
|||||||
_cancellationToken = cancellationToken;
|
_cancellationToken = cancellationToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Output sample rate in Hz (the store's <see cref="AudioClipStore.OutputRate"/>).</summary>
|
||||||
public int OutputRate { get; }
|
public int OutputRate { get; }
|
||||||
|
|
||||||
|
/// <summary>Interleaved channel count of the mixed output (1 or more).</summary>
|
||||||
public int OutputChannels { get; }
|
public int OutputChannels { get; }
|
||||||
|
|
||||||
/// <summary>Voices currently mixed (started or waiting to start). Render thread only.</summary>
|
/// <summary>Voices currently mixed (started or waiting to start). Render thread only.</summary>
|
||||||
@@ -113,6 +124,10 @@ internal sealed class VoiceMixer : IDisposable
|
|||||||
return voice;
|
return voice;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Queues a change of <paramref name="voice"/> that takes effect at sample <see cref="ToSample"/>(<paramref name="time"/>).
|
||||||
|
/// Called by the Voice Set methods on the main thread.
|
||||||
|
/// </summary>
|
||||||
internal void Enqueue(Voice voice, Voice.ChangeKind kind, double time, double value)
|
internal void Enqueue(Voice voice, Voice.ChangeKind kind, double time, double value)
|
||||||
{
|
{
|
||||||
var change = new Voice.Change(kind, ToSample(time, OutputRate), value);
|
var change = new Voice.Change(kind, ToSample(time, OutputRate), value);
|
||||||
@@ -138,10 +153,13 @@ internal sealed class VoiceMixer : IDisposable
|
|||||||
|
|
||||||
TakeCommands();
|
TakeCommands();
|
||||||
|
|
||||||
|
// Iterated backwards so finished voices can be removed in place.
|
||||||
for (var i = _voices.Count - 1; i >= 0; i--)
|
for (var i = _voices.Count - 1; i >= 0; i--)
|
||||||
{
|
{
|
||||||
var voice = _voices[i];
|
var voice = _voices[i];
|
||||||
var sample = firstSample;
|
var sample = firstSample;
|
||||||
|
// Split the block into segments at the samples of pending changes: each segment is mixed with constant
|
||||||
|
// voice state, and a change is applied when the segment reaches its sample (or at once when its sample is past).
|
||||||
while (!voice.Finished)
|
while (!voice.Finished)
|
||||||
{
|
{
|
||||||
var segmentEnd = endSample;
|
var segmentEnd = endSample;
|
||||||
@@ -162,6 +180,8 @@ internal sealed class VoiceMixer : IDisposable
|
|||||||
if (sample >= endSample) break;
|
if (sample >= endSample) break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The end sample is inside or before this block: nothing more will be mixed. Changes still pending for this voice
|
||||||
|
// (after the end) are dropped with it.
|
||||||
if (!voice.Finished && endSample >= voice.EndSample)
|
if (!voice.Finished && endSample >= voice.EndSample)
|
||||||
voice.Finished = true;
|
voice.Finished = true;
|
||||||
if (voice.Finished)
|
if (voice.Finished)
|
||||||
@@ -172,6 +192,10 @@ internal sealed class VoiceMixer : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Takes every queued command: Play adds the voice (with a cursor at frame 0 when its source is usable), Change appends to
|
||||||
|
/// the voice's pending queue (dropped for finished voices). Render thread only.
|
||||||
|
/// </summary>
|
||||||
private void TakeCommands()
|
private void TakeCommands()
|
||||||
{
|
{
|
||||||
lock (_commandLock)
|
lock (_commandLock)
|
||||||
@@ -203,6 +227,7 @@ internal sealed class VoiceMixer : IDisposable
|
|||||||
var end = Math.Min(to, voice.EndSample);
|
var end = Math.Min(to, voice.EndSample);
|
||||||
if (start >= end) return;
|
if (start >= end) return;
|
||||||
|
|
||||||
|
// A voice whose source is missing or failed is silent; it finishes at its first segment inside [start, end).
|
||||||
var source = voice.Source;
|
var source = voice.Source;
|
||||||
if (source == null || source.IsFailed)
|
if (source == null || source.IsFailed)
|
||||||
{
|
{
|
||||||
@@ -222,6 +247,7 @@ internal sealed class VoiceMixer : IDisposable
|
|||||||
var volume = voice.Volume;
|
var volume = voice.Volume;
|
||||||
var offset = (int)(start - from) * channels;
|
var offset = (int)(start - from) * channels;
|
||||||
|
|
||||||
|
// Move the cursor to the segment start so the decoder thread keeps the chunks ahead of it loaded.
|
||||||
UpdateCursor(voice, (long)voice.WrappedPositionAt(start));
|
UpdateCursor(voice, (long)voice.WrappedPositionAt(start));
|
||||||
|
|
||||||
for (var n = start; n < end; n++, offset += channels)
|
for (var n = start; n < end; n++, offset += channels)
|
||||||
@@ -234,6 +260,8 @@ internal sealed class VoiceMixer : IDisposable
|
|||||||
voice.Finished = true;
|
voice.Finished = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Looping: a wrapped position is below Period, and Period (Duration * rate) is at most TotalFrames
|
||||||
|
// (TotalFrames rounds up), so this is a guard that reads the last frame.
|
||||||
position = totalFrames - 1;
|
position = totalFrames - 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,6 +271,7 @@ internal sealed class VoiceMixer : IDisposable
|
|||||||
if (!EnsureView(voice, index)) return;
|
if (!EnsureView(voice, index)) return;
|
||||||
var view = voice.View;
|
var view = voice.View;
|
||||||
|
|
||||||
|
// Integer position (pitch 1 with an integer anchor): copy the stored sample.
|
||||||
if (fraction == 0.0)
|
if (fraction == 0.0)
|
||||||
{
|
{
|
||||||
for (var c = 0; c < channels; c++)
|
for (var c = 0; c < channels; c++)
|
||||||
@@ -250,6 +279,7 @@ internal sealed class VoiceMixer : IDisposable
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Linear interpolation; the next frame may lie in the following chunk, at the loop start or past the end.
|
||||||
var nextIndex = index + 1;
|
var nextIndex = index + 1;
|
||||||
var nextInView = view.Contains(nextIndex);
|
var nextInView = view.Contains(nextIndex);
|
||||||
for (var c = 0; c < channels; c++)
|
for (var c = 0; c < channels; c++)
|
||||||
@@ -303,6 +333,7 @@ internal sealed class VoiceMixer : IDisposable
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Moves the voice's cursor to <paramref name="frame"/> and copies the voice's loop flag (writes only on change).</summary>
|
||||||
private static void UpdateCursor(Voice voice, long frame)
|
private static void UpdateCursor(Voice voice, long frame)
|
||||||
{
|
{
|
||||||
var cursor = voice.Cursor;
|
var cursor = voice.Cursor;
|
||||||
@@ -311,6 +342,7 @@ internal sealed class VoiceMixer : IDisposable
|
|||||||
if (cursor.Position != frame) cursor.Position = frame;
|
if (cursor.Position != frame) cursor.Position = frame;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Releases the voice's held chunk and cursor and drops its pending changes. Render thread only.</summary>
|
||||||
private static void ReleaseVoice(Voice voice)
|
private static void ReleaseVoice(Voice voice)
|
||||||
{
|
{
|
||||||
var source = voice.Source;
|
var source = voice.Source;
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ namespace ADOFAIRenderer.Util.Audio;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class OfflineAudioMixer : IDisposable
|
internal sealed class OfflineAudioMixer : IDisposable
|
||||||
{
|
{
|
||||||
/// <summary>Gain applied to every clip.</summary>
|
/// <summary>Linear gain applied to every clip (song and effects), before the filter chain (0.5 ≈ -6 dB headroom).</summary>
|
||||||
private const double ClipVolume = 0.5;
|
private const double ClipVolume = 0.5;
|
||||||
|
|
||||||
/// <summary>Additional gain of effect sounds (registered with <see cref="RegisterSoundEvent"/>).</summary>
|
/// <summary>Additional gain of effect sounds (registered with <see cref="RegisterSoundEvent"/>).</summary>
|
||||||
@@ -41,21 +41,48 @@ internal sealed class OfflineAudioMixer : IDisposable
|
|||||||
/// <summary>Longest wait for a commit before checking for cancellation again, in milliseconds.</summary>
|
/// <summary>Longest wait for a commit before checking for cancellation again, in milliseconds.</summary>
|
||||||
private const int CommitWaitMs = 50;
|
private const int CommitWaitMs = 50;
|
||||||
|
|
||||||
|
/// <summary>Output channel count (interleaved).</summary>
|
||||||
private readonly int _channels;
|
private readonly int _channels;
|
||||||
|
|
||||||
|
/// <summary>Output sample rate in Hz.</summary>
|
||||||
private readonly int _sampleRate;
|
private readonly int _sampleRate;
|
||||||
|
|
||||||
private readonly AudioClipStore _store;
|
private readonly AudioClipStore _store;
|
||||||
private readonly VoiceMixer _mixer;
|
private readonly VoiceMixer _mixer;
|
||||||
|
|
||||||
|
/// <summary>Canceled by <see cref="Cancel"/>/<see cref="Dispose"/>; ends commit waits and chunk waits on the render thread.</summary>
|
||||||
private readonly CancellationTokenSource _cancellation = new();
|
private readonly CancellationTokenSource _cancellation = new();
|
||||||
|
|
||||||
|
/// <summary>Filter chain applied in list order to every mixed block.</summary>
|
||||||
private readonly List<IAudioFilter> _filters = new();
|
private readonly List<IAudioFilter> _filters = new();
|
||||||
|
|
||||||
|
/// <summary>Sum of the filter latencies in output samples: round(sum of <see cref="IAudioFilter.Latency"/> * sample rate).</summary>
|
||||||
private readonly int _latencySamples;
|
private readonly int _latencySamples;
|
||||||
|
|
||||||
// Main thread.
|
// Main thread.
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mapping of game AudioSources to the state of the effect sound last started on them. Weak keys: a destroyed source
|
||||||
|
/// does not keep its state alive. Replaced by a new table to drop every mapping at once.
|
||||||
|
/// </summary>
|
||||||
private ConditionalWeakTable<AudioSource, SourceState> _sourceStates = new();
|
private ConditionalWeakTable<AudioSource, SourceState> _sourceStates = new();
|
||||||
|
|
||||||
|
/// <summary>Effect voices started since the last <see cref="StopAllEffects"/> (finished ones are pruned periodically).</summary>
|
||||||
private readonly List<Voice> _effectVoices = new();
|
private readonly List<Voice> _effectVoices = new();
|
||||||
|
|
||||||
|
/// <summary>Count of <see cref="_effectVoices"/> at which the next prune runs.</summary>
|
||||||
private int _effectVoicePruneAt = EffectVoicePruneCount;
|
private int _effectVoicePruneAt = EffectVoicePruneCount;
|
||||||
|
|
||||||
|
/// <summary>Song source, or null when there is no song or it could not be loaded.</summary>
|
||||||
private AudioClipSource? _bgmSource;
|
private AudioClipSource? _bgmSource;
|
||||||
|
|
||||||
|
/// <summary>audioLib key (AudioClip.name) of the song.</summary>
|
||||||
private string _bgmKey = "";
|
private string _bgmKey = "";
|
||||||
|
|
||||||
|
/// <summary>Current song voice, created by the first <see cref="UpdateBGM"/> and replaced when the start time changes.</summary>
|
||||||
private Voice? _bgmVoice;
|
private Voice? _bgmVoice;
|
||||||
|
|
||||||
|
// Values last sent to the song voice (start in seconds relative to the render start); NaN before the first update.
|
||||||
private double _bgmStart = double.NaN;
|
private double _bgmStart = double.NaN;
|
||||||
private double _bgmPitch = double.NaN;
|
private double _bgmPitch = double.NaN;
|
||||||
private double _bgmVolume = double.NaN;
|
private double _bgmVolume = double.NaN;
|
||||||
@@ -66,11 +93,26 @@ internal sealed class OfflineAudioMixer : IDisposable
|
|||||||
|
|
||||||
// Render thread (and Dispose, under _renderLock).
|
// Render thread (and Dispose, under _renderLock).
|
||||||
private readonly object _renderLock = new();
|
private readonly object _renderLock = new();
|
||||||
|
|
||||||
|
/// <summary>Index of the next output sample <see cref="GenerateAudioForFrame"/> writes (frames per channel since the render start).</summary>
|
||||||
private long _nextOutputSample;
|
private long _nextOutputSample;
|
||||||
|
|
||||||
|
/// <summary>True once the filters have been fed samples [0, latency).</summary>
|
||||||
private bool _primed;
|
private bool _primed;
|
||||||
|
|
||||||
|
/// <summary>Reused interleaved mix buffer, grown to the largest block requested.</summary>
|
||||||
private double[] _mixBuffer = Array.Empty<double>();
|
private double[] _mixBuffer = Array.Empty<double>();
|
||||||
|
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates the clip store (and starts its decoder thread), the voice mixer and the filter chain.
|
||||||
|
/// Filter settings: RMS compressor (threshold -10 dB, ratio 2, attack/release 0.1 s, knee 10 dB, RMS window 0.02 s),
|
||||||
|
/// limiter (RMS compressor with threshold 0 dB, infinite ratio, attack 0 raised to the 0.0001 s minimum, release 0.05 s,
|
||||||
|
/// hard knee, RMS window 0.02 s, look-ahead 0.01 s), hard clip at ±1.0, then -1 dB gain.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="targetChannels">Output channel count (interleaved).</param>
|
||||||
|
/// <param name="targetSampleRate">Output sample rate in Hz; every clip is resampled to it.</param>
|
||||||
public OfflineAudioMixer(int targetChannels, int targetSampleRate)
|
public OfflineAudioMixer(int targetChannels, int targetSampleRate)
|
||||||
{
|
{
|
||||||
_channels = targetChannels;
|
_channels = targetChannels;
|
||||||
@@ -96,6 +138,7 @@ internal sealed class OfflineAudioMixer : IDisposable
|
|||||||
_filters.Add(new HardClip());
|
_filters.Add(new HardClip());
|
||||||
_filters.Add(new Gain { Level = -1 });
|
_filters.Add(new Gain { Level = -1 });
|
||||||
|
|
||||||
|
// Total delay of the chain; only the limiter's look-ahead contributes (0.01 s).
|
||||||
double latency = 0;
|
double latency = 0;
|
||||||
foreach (var filter in _filters)
|
foreach (var filter in _filters)
|
||||||
{
|
{
|
||||||
@@ -148,6 +191,8 @@ internal sealed class OfflineAudioMixer : IDisposable
|
|||||||
double pitch = conductor.song.pitch;
|
double pitch = conductor.song.pitch;
|
||||||
var countdown = conductor.separateCountdownTime ? conductor.crotchetAtStart / pitch * conductor.adjustedCountdownTicks : 0.0;
|
var countdown = conductor.separateCountdownTime ? conductor.crotchetAtStart / pitch * conductor.adjustedCountdownTicks : 0.0;
|
||||||
var start = conductor.dspTimeSong + countdown - dspTimeBase;
|
var start = conductor.dspTimeSong + countdown - dspTimeBase;
|
||||||
|
// levelData.volume is a percentage (100 = unchanged; scrConductor sets song.volume = volume * 0.01 the same way).
|
||||||
|
// Without a scnGame instance the level volume is 1.0.
|
||||||
var levelVolume = scnGame.instance ? scnGame.instance.levelData.volume * 0.01 : 1.0;
|
var levelVolume = scnGame.instance ? scnGame.instance.levelData.volume * 0.01 : 1.0;
|
||||||
var volume = levelVolume * ClipVolume;
|
var volume = levelVolume * ClipVolume;
|
||||||
|
|
||||||
@@ -181,6 +226,11 @@ internal sealed class OfflineAudioMixer : IDisposable
|
|||||||
GetOrAddSource(clipName);
|
GetOrAddSource(clipName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Source of the game clip <paramref name="clipName"/> (audioLib key): an existing source, or one opened through
|
||||||
|
/// <see cref="AudioClipResolver"/> with the clip from AudioManager.FindOrLoadAudioClip. Null for an empty name or an
|
||||||
|
/// unreadable clip (the key is then remembered as missing by the store). Main thread only.
|
||||||
|
/// </summary>
|
||||||
private AudioClipSource? GetOrAddSource(string clipName)
|
private AudioClipSource? GetOrAddSource(string clipName)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(clipName)) return null;
|
if (string.IsNullOrEmpty(clipName)) return null;
|
||||||
@@ -193,6 +243,14 @@ internal sealed class OfflineAudioMixer : IDisposable
|
|||||||
/// Starts an effect sound at <paramref name="startTime"/>. An <paramref name="endTime"/> of 0 or more makes it loop until then.
|
/// 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.
|
/// <paramref name="source"/> (when given) is mapped to the returned state, which reports and applies later changes through the AudioSource.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="source">Game AudioSource playing the sound, or null to map it later with <see cref="MapSource"/>.</param>
|
||||||
|
/// <param name="clipName">audioLib key of the clip. A clip that cannot be loaded gives a silent voice.</param>
|
||||||
|
/// <param name="startTime">Scheduled start, seconds relative to the render start (a start before <paramref name="now"/> starts at now).</param>
|
||||||
|
/// <param name="endTime">Loop end, seconds relative to the render start; negative for a sound that plays once.</param>
|
||||||
|
/// <param name="volume">Game volume (linear); multiplied by the clip and effect gains.</param>
|
||||||
|
/// <param name="pitch">Playback rate at the start.</param>
|
||||||
|
/// <param name="now">Current render time in seconds.</param>
|
||||||
|
/// <returns>State of the new sound, also used by the AudioSource patches.</returns>
|
||||||
public SourceState RegisterSoundEvent(AudioSource? source, string clipName, double startTime, double endTime, float volume, float pitch, double now)
|
public SourceState RegisterSoundEvent(AudioSource? source, string clipName, double startTime, double endTime, float volume, float pitch, double now)
|
||||||
{
|
{
|
||||||
var clipSource = GetOrAddSource(clipName);
|
var clipSource = GetOrAddSource(clipName);
|
||||||
@@ -200,6 +258,7 @@ internal sealed class OfflineAudioMixer : IDisposable
|
|||||||
var voice = _mixer.Play(clipSource, clipName, startTime, volume * ClipVolume * EffectVolume, pitch, loop, now);
|
var voice = _mixer.Play(clipSource, clipName, startTime, volume * ClipVolume * EffectVolume, pitch, loop, now);
|
||||||
if (loop) voice.SetEndTime(endTime, now);
|
if (loop) voice.SetEndTime(endTime, now);
|
||||||
|
|
||||||
|
// Amortized pruning: the list is scanned only when it has doubled since the last prune.
|
||||||
if (_effectVoices.Count >= _effectVoicePruneAt)
|
if (_effectVoices.Count >= _effectVoicePruneAt)
|
||||||
{
|
{
|
||||||
_effectVoices.RemoveAll(v => v.IsFinished);
|
_effectVoices.RemoveAll(v => v.IsFinished);
|
||||||
@@ -269,7 +328,10 @@ internal sealed class OfflineAudioMixer : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Stops waiting for commits and chunks; the render thread's current or next mix throws OperationCanceledException.</summary>
|
/// <summary>
|
||||||
|
/// Stops waiting for commits and chunks; the render thread's current or next mix throws OperationCanceledException.
|
||||||
|
/// May be called from any thread, more than once.
|
||||||
|
/// </summary>
|
||||||
public void Cancel()
|
public void Cancel()
|
||||||
{
|
{
|
||||||
_cancellation.Cancel();
|
_cancellation.Cancel();
|
||||||
@@ -280,7 +342,10 @@ internal sealed class OfflineAudioMixer : IDisposable
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Writes the next <paramref name="samplesNeeded"/> output frames (interleaved float, continuing from the previous call).
|
/// Writes the next <paramref name="samplesNeeded"/> output frames (interleaved float, continuing from the previous call).
|
||||||
|
/// Blocks until the main thread has committed the voice samples it needs (<see cref="Commit"/>, <see cref="Complete"/>).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="mixedBuffer">Destination; must hold at least samplesNeeded * channels floats. Values beyond that are untouched.</param>
|
||||||
|
/// <param name="samplesNeeded">Frames (samples per channel) to write.</param>
|
||||||
/// <exception cref="InvalidOperationException">Chunk loading failed fatally (out of memory or the decoder thread stopped).</exception>
|
/// <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>
|
/// <exception cref="OperationCanceledException">The render was canceled (<see cref="Cancel"/>).</exception>
|
||||||
public void GenerateAudioForFrame(float[] mixedBuffer, int samplesNeeded)
|
public void GenerateAudioForFrame(float[] mixedBuffer, int samplesNeeded)
|
||||||
@@ -289,6 +354,8 @@ internal sealed class OfflineAudioMixer : IDisposable
|
|||||||
{
|
{
|
||||||
if (_disposed) throw new ObjectDisposedException(nameof(OfflineAudioMixer));
|
if (_disposed) throw new ObjectDisposedException(nameof(OfflineAudioMixer));
|
||||||
|
|
||||||
|
// First call: run voice samples [0, latency) through the filters and discard the output, which is the
|
||||||
|
// zero-filled delay lines. Afterwards output sample n is the filtered voice sample n.
|
||||||
if (!_primed)
|
if (!_primed)
|
||||||
{
|
{
|
||||||
_primed = true;
|
_primed = true;
|
||||||
@@ -323,6 +390,11 @@ internal sealed class OfflineAudioMixer : IDisposable
|
|||||||
return block;
|
return block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Waits until the committed sample reaches <paramref name="endSample"/> (exclusive end of the block to mix), checking for
|
||||||
|
/// cancellation every <see cref="CommitWaitMs"/> ms.
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="OperationCanceledException">The render was canceled.</exception>
|
||||||
private void WaitForCommit(long endSample)
|
private void WaitForCommit(long endSample)
|
||||||
{
|
{
|
||||||
lock (_commitLock)
|
lock (_commitLock)
|
||||||
|
|||||||
@@ -21,15 +21,33 @@ namespace ADOFAIRenderer.Util.Audio;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class SourceState
|
internal sealed class SourceState
|
||||||
{
|
{
|
||||||
|
/// <summary>Start of playback, seconds relative to the render start.</summary>
|
||||||
private readonly double _start;
|
private readonly double _start;
|
||||||
|
|
||||||
|
/// <summary>Render time of the last position or pitch change (at least <see cref="_start"/>), seconds.</summary>
|
||||||
private double _anchorTime;
|
private double _anchorTime;
|
||||||
|
|
||||||
|
/// <summary>Unwrapped clip position at <see cref="_anchorTime"/>, seconds into the clip.</summary>
|
||||||
private double _anchorPosition;
|
private double _anchorPosition;
|
||||||
|
|
||||||
|
/// <summary>Last finite pitch set (reported as is; negative values play as 0).</summary>
|
||||||
private double _pitch;
|
private double _pitch;
|
||||||
|
|
||||||
private readonly bool _loop;
|
private readonly bool _loop;
|
||||||
|
|
||||||
|
/// <summary>Scheduled end time, seconds relative to the render start; infinity when not set.</summary>
|
||||||
private double _endTime = double.PositiveInfinity;
|
private double _endTime = double.PositiveInfinity;
|
||||||
|
|
||||||
|
/// <summary>True after <see cref="Stop"/>; playback does not resume.</summary>
|
||||||
private bool _stopped;
|
private bool _stopped;
|
||||||
|
|
||||||
|
/// <summary>timeSamples reported while not playing: the last value set through time/timeSamples, 0 after Stop.</summary>
|
||||||
private int _offsetSamples;
|
private int _offsetSamples;
|
||||||
|
|
||||||
|
/// <summary>Clip length in seconds.</summary>
|
||||||
private double _length;
|
private double _length;
|
||||||
|
|
||||||
|
/// <summary>Clip sample rate in Hz (at least 1), used to convert between time and timeSamples.</summary>
|
||||||
private int _frequency;
|
private int _frequency;
|
||||||
|
|
||||||
/// <param name="voice">Voice that plays the sound.</param>
|
/// <param name="voice">Voice that plays the sound.</param>
|
||||||
@@ -51,6 +69,7 @@ internal sealed class SourceState
|
|||||||
_frequency = Math.Max(1, frequency);
|
_frequency = Math.Max(1, frequency);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Voice of the <see cref="VoiceMixer"/> that plays the sound; changes made while playing are queued on it.</summary>
|
||||||
public Voice Voice { get; }
|
public Voice Voice { get; }
|
||||||
|
|
||||||
/// <summary>Takes the clip length and sample rate from the clip of the game AudioSource.</summary>
|
/// <summary>Takes the clip length and sample rate from the clip of the game AudioSource.</summary>
|
||||||
@@ -69,26 +88,39 @@ internal sealed class SourceState
|
|||||||
Voice.SetEndTime(endTime, now);
|
Voice.SetEndTime(endTime, now);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>AudioSource.isPlaying at <paramref name="now"/>: not stopped and before both the scheduled end and the natural end.</summary>
|
||||||
public bool IsPlaying(double now) => !_stopped && now < Math.Min(_endTime, NaturalEnd());
|
public bool IsPlaying(double now) => !_stopped && now < Math.Min(_endTime, NaturalEnd());
|
||||||
|
|
||||||
|
/// <summary>AudioSource.time at <paramref name="now"/>: clip position in seconds while playing, otherwise 0.</summary>
|
||||||
public float GetTime(double now) => IsPlaying(now) ? (float)PositionAt(now) : 0f;
|
public float GetTime(double now) => IsPlaying(now) ? (float)PositionAt(now) : 0f;
|
||||||
|
|
||||||
|
/// <summary>AudioSource.timeSamples at <paramref name="now"/>: floor(position * frequency) while playing, otherwise the stored offset.</summary>
|
||||||
public int GetTimeSamples(double now) => IsPlaying(now) ? (int)Math.Floor(PositionAt(now) * _frequency) : _offsetSamples;
|
public int GetTimeSamples(double now) => IsPlaying(now) ? (int)Math.Floor(PositionAt(now) * _frequency) : _offsetSamples;
|
||||||
|
|
||||||
|
/// <summary>AudioSource.pitch: the last finite value set.</summary>
|
||||||
public float GetPitch() => (float)_pitch;
|
public float GetPitch() => (float)_pitch;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AudioSource.time setter: stores floor(seconds * frequency) as the offset and, while playing, moves the position to
|
||||||
|
/// <paramref name="seconds"/> (negative values become 0) from <paramref name="now"/> (or from the start when not started yet).
|
||||||
|
/// </summary>
|
||||||
public void SetTime(float seconds, double now)
|
public void SetTime(float seconds, double now)
|
||||||
{
|
{
|
||||||
_offsetSamples = (int)Math.Floor((double)seconds * _frequency);
|
_offsetSamples = (int)Math.Floor((double)seconds * _frequency);
|
||||||
Seek(seconds, now);
|
Seek(seconds, now);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>AudioSource.timeSamples setter: as <see cref="SetTime"/> with samples / frequency seconds.</summary>
|
||||||
public void SetTimeSamples(int samples, double now)
|
public void SetTimeSamples(int samples, double now)
|
||||||
{
|
{
|
||||||
_offsetSamples = samples;
|
_offsetSamples = samples;
|
||||||
Seek((double)samples / _frequency, now);
|
Seek((double)samples / _frequency, now);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AudioSource.pitch setter: ignores NaN and infinity. While playing, the position up to <paramref name="now"/> is kept
|
||||||
|
/// (new anchor) and the voice changes rate at <paramref name="now"/>; otherwise only the reported value changes.
|
||||||
|
/// </summary>
|
||||||
public void SetPitch(float pitch, double now)
|
public void SetPitch(float pitch, double now)
|
||||||
{
|
{
|
||||||
if (float.IsNaN(pitch) || float.IsInfinity(pitch)) return;
|
if (float.IsNaN(pitch) || float.IsInfinity(pitch)) return;
|
||||||
@@ -98,6 +130,7 @@ internal sealed class SourceState
|
|||||||
if (playing) Voice.SetPitch(pitch, now);
|
if (playing) Voice.SetPitch(pitch, now);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>AudioSource.Stop(): stops the voice at <paramref name="now"/> when playing, marks the state stopped and clears the offset.</summary>
|
||||||
public void Stop(double now)
|
public void Stop(double now)
|
||||||
{
|
{
|
||||||
if (IsPlaying(now)) Voice.Stop(now);
|
if (IsPlaying(now)) Voice.Stop(now);
|
||||||
@@ -105,8 +138,13 @@ internal sealed class SourceState
|
|||||||
_offsetSamples = 0;
|
_offsetSamples = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Rate used for playback: <see cref="_pitch"/> with negative values treated as 0 (as in <see cref="Voice.SetPitch"/>).</summary>
|
||||||
private double PlaybackPitch => Math.Max(0.0, _pitch);
|
private double PlaybackPitch => Math.Max(0.0, _pitch);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// While playing, sets the clip position to <paramref name="seconds"/> (at least 0) from max(now, start) and queues the same
|
||||||
|
/// position change on the voice.
|
||||||
|
/// </summary>
|
||||||
private void Seek(double seconds, double now)
|
private void Seek(double seconds, double now)
|
||||||
{
|
{
|
||||||
if (!IsPlaying(now)) return;
|
if (!IsPlaying(now)) return;
|
||||||
@@ -116,6 +154,7 @@ internal sealed class SourceState
|
|||||||
Voice.SetPosition(position, now);
|
Voice.SetPosition(position, now);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Moves the anchor to max(now, start), keeping the current position, before a pitch change.</summary>
|
||||||
private void Reanchor(double now)
|
private void Reanchor(double now)
|
||||||
{
|
{
|
||||||
var time = Math.Max(now, _start);
|
var time = Math.Max(now, _start);
|
||||||
|
|||||||
Reference in new issue
Block a user