Document Audio Pipeline

This commit is contained in:
copecone committed 2026-10-15 16:17:12 +09:00
1 parent 7bb47e4974
commit 19d0e258ac
22 files changed
+634 -12

No files matched your search

+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>
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();
/// <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();
/// <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 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();
/// <summary>Wakes the decoder thread when a cursor, demand or source changes (<see cref="RequestService"/>).</summary>
private readonly AutoResetEvent _serviceRequested = new(false);
private Thread? _decoderThread;
private volatile bool _stopRequested;
/// <summary>Bytes of allocated sample arrays (see <see cref="AllocatedBytes"/>), updated with Interlocked.</summary>
private long _allocatedBytes;
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 bool _disposed;
/// <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>
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;
/// <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)
{
if (outputRate <= 0) throw new ArgumentOutOfRangeException(nameof(outputRate));
@@ -75,6 +94,11 @@ internal sealed class AudioClipStore : IDisposable
_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()
{
try
@@ -124,6 +148,7 @@ internal sealed class AudioClipStore : IDisposable
return true;
}
/// <summary>Wakes the decoder thread to re-evaluate what to free and load. Any thread.</summary>
internal void RequestService() => _serviceRequested.Set();
// ── Sources ──
@@ -169,6 +194,8 @@ internal sealed class AudioClipStore : IDisposable
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)
{
if (_disposed || _sources.TryGetValue(key, out var raced))
@@ -188,6 +215,10 @@ internal sealed class AudioClipStore : IDisposable
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)
{
lock (_sources)
@@ -214,6 +245,8 @@ internal sealed class AudioClipStore : IDisposable
if (source.IsFailed || _disposed) return default;
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)
{
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()
{
var error = _fatalError;
@@ -231,6 +265,7 @@ internal sealed class AudioClipStore : IDisposable
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()
{
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)
{
Melon<RenderMod>.Logger.Error($"[AudioClipStore] Failed to load clip '{source.Key}', it will be silent: {error}");
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)
{
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 ──
/// <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()
{
lock (_poolLock)
@@ -265,6 +303,7 @@ internal sealed class AudioClipStore : IDisposable
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)
{
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)
{
var data = new float[floats];
@@ -285,8 +325,10 @@ internal sealed class AudioClipStore : IDisposable
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));
/// <summary>Adds <paramref name="bytes"/> (negative to subtract) to the allocation counter and raises the peak lock-free.</summary>
private void AddAllocated(long 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()
{
int sourceCount;