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

+55 -1
View File
@@ -13,9 +13,13 @@ namespace ADOFAIRenderer.Util.Audio.Clip;
internal sealed class AudioCursor
{
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 volatile bool _loop;
/// <summary>Created by <see cref="AudioClipSource.AddCursor"/>.</summary>
internal AudioCursor(AudioClipSource source, long position, bool loop)
{
_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
{
get => _loop;
@@ -70,8 +77,10 @@ internal readonly struct ChunkView
/// <summary>Channel count of the stored samples (same as the source clip).</summary>
public readonly int Channels;
/// <summary>Index of the chunk in its source (used by <see cref="AudioClipSource.Release"/>).</summary>
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)
{
Data = data;
@@ -81,10 +90,13 @@ internal readonly struct ChunkView
ChunkIndex = chunkIndex;
}
/// <summary>True for a view that holds a chunk; false for default(ChunkView).</summary>
public bool IsValid => Data != null;
/// <summary>Source frame after the last valid frame (exclusive end).</summary>
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;
/// <summary>
@@ -171,26 +183,45 @@ internal sealed class AudioClipSource
internal int DecodeStreamLimit { get; set; } = MaxDecodeStreams;
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;
/// <summary>Opens another reader for an additional decode stream; null when only the first reader can be used.</summary>
private readonly Func<IPCMReader?>? _openReader;
// Guarded by _sync: chunk table, hold counts, cursors, demand set, loaded set.
private readonly object _sync = new();
/// <summary>Sample array of each chunk, null while not loaded.</summary>
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 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();
/// <summary>Indices of the loaded chunks (non-null entries of <see cref="_chunks"/>).</summary>
private readonly HashSet<int> _loaded = new();
private int _residentLoadedCount; // Resident: chunks [0, _residentLoadedCount) are loaded
// Guarded by _loadLock: decode streams.
private readonly object _loadLock = new();
private readonly List<DecodeStream> _streams = new();
/// <summary>Incremented on every load; stored in <see cref="DecodeStream.LastUse"/> for least-recently-used selection.</summary>
private long _streamUseCounter;
/// <summary>True after opening an additional reader failed; no further readers are opened for this source.</summary>
private bool _openFailed;
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; }
/// <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>
public double Duration { get; }
/// <summary>Frames per chunk: <see cref="ChunkFloats"/> / <see cref="Channels"/> (rounded down).</summary>
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; }
/// <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; }
/// <summary>True when <see cref="Policy"/> is <see cref="ChunkPolicy.Resident"/>.</summary>
public bool IsResident => Policy == ChunkPolicy.Resident;
/// <summary>True after loading failed. A failed source returns no chunks.</summary>
@@ -279,6 +317,7 @@ internal sealed class AudioClipSource
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)
{
lock (_sync)
@@ -288,6 +327,7 @@ internal sealed class AudioClipSource
_store.RequestService();
}
/// <summary>Wakes the decoder thread after a cursor changed chunk or loop flag (resident sources ignore cursors).</summary>
internal void OnCursorMoved()
{
if (!IsResident) _store.RequestService();
@@ -325,6 +365,7 @@ internal sealed class AudioClipSource
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)
{
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)
{
var start = (long)index * FramesPerChunk;
@@ -406,6 +448,8 @@ internal sealed class AudioClipSource
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)
{
if (!TryGetWindowStart(cursor, out var first, out var loop)) continue;
@@ -441,6 +485,10 @@ internal sealed class AudioClipSource
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)
{
var position = cursor.Position;
@@ -456,6 +504,7 @@ internal sealed class AudioClipSource
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)
{
index = first + slot;
@@ -493,6 +542,7 @@ internal sealed class AudioClipSource
stream.LastUse = ++_streamUseCounter;
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)
{
ReturnArray(data);
@@ -568,12 +618,14 @@ internal sealed class AudioClipSource
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()
{
_isFailed = true;
if (IsResident) DisposeStreams();
}
/// <summary>Disposes every decode stream (reader and resampler) and clears the list. Caller holds _loadLock.</summary>
private void DisposeStreams()
{
foreach (var stream in _streams)
@@ -581,6 +633,7 @@ internal sealed class AudioClipSource
_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)
{
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;
}