Document Audio Pipeline

This commit is contained in:
copecone committed 2026-10-15 16:17:12 +09:00
1 parent 011c749df4
commit 131fabdbe1
22 files changed
+634 -12

No files matched your search

+48 -3
View File
@@ -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()
{ {
+39 -3
View File
@@ -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;
+21
View File
@@ -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;
+1
View File
@@ -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>
+7
View File
@@ -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));
+55 -1
View File
@@ -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 &gt; 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;
} }
+44 -1
View File
@@ -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;
+23
View File
@@ -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);
+56
View File
@@ -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];
+15
View File
@@ -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;
+15
View File
@@ -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();
} }
+25
View File
@@ -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);
} }
+14
View File
@@ -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;
+3
View File
@@ -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();
+9
View File
@@ -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;
} }
+10
View File
@@ -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;
} }
+20
View File
@@ -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; }
} }
+46
View File
@@ -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 &lt; 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);
+37 -1
View File
@@ -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);
+33 -1
View File
@@ -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;
+74 -2
View File
@@ -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)
+39
View File
@@ -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);