SHA256
Document Audio Pipeline
This commit is contained in:
1 parent
7bb47e4974
commit
19d0e258ac
22 files changed
+634
-12
No files matched your search
@@ -20,6 +20,7 @@ internal static class AudioClipPaths
|
||||
/// <summary>Suffix of the game's audioLib keys for external clips.</summary>
|
||||
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();
|
||||
|
||||
/// <summary>audioLib key of an external clip loaded from <paramref name="path"/>.</summary>
|
||||
|
||||
@@ -16,6 +16,10 @@ namespace ADOFAIRenderer.Util.Audio.Clip;
|
||||
/// </summary>
|
||||
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="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>
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
// 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)
|
||||
{
|
||||
var source = store.GetOrAdd(key, () => fileReader, () => OpenFile(path));
|
||||
|
||||
@@ -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 > 0.</summary>
|
||||
private static long CeilDiv(long a, long b) => (a + b - 1) / b;
|
||||
}
|
||||
@@ -29,18 +29,34 @@ internal sealed class AudioClipStore : IDisposable
|
||||
/// <summary>Longest wait for the decoder thread to stop in <see cref="Dispose"/>, in milliseconds.</summary>
|
||||
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;
|
||||
|
||||
@@ -18,18 +18,34 @@ internal sealed class DecodeStream : IDisposable
|
||||
/// <summary>Input frames read from the reader per resampler call.</summary>
|
||||
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 int _channels;
|
||||
private readonly int _outputRate;
|
||||
|
||||
/// <summary>Input block of <see cref="ReadBlockFrames"/> frames, interleaved.</summary>
|
||||
private readonly float[] _readBuffer;
|
||||
|
||||
/// <summary>Scratch output for frames discarded after a restart; allocated on first use.</summary>
|
||||
private float[]? _discardBuffer;
|
||||
|
||||
/// <summary>True once the reader returned no more input and the resampler is being drained with Flush.</summary>
|
||||
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;
|
||||
|
||||
/// <summary>Input frames per alignment step: input rate / gcd (1 without resampling).</summary>
|
||||
private readonly long _inputStep;
|
||||
|
||||
/// <summary>Output frames per alignment step: output rate / gcd (1 without resampling).</summary>
|
||||
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;
|
||||
|
||||
/// <summary>Reader of this stream (owned; disposed with the stream).</summary>
|
||||
public IPCMReader Reader { get; }
|
||||
|
||||
/// <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>
|
||||
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)
|
||||
{
|
||||
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.
|
||||
// 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.
|
||||
// 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);
|
||||
_prerollInputFrames = 4L * _resampler.FilterSize * ratio + _inputStep;
|
||||
}
|
||||
@@ -77,6 +96,7 @@ internal sealed class DecodeStream : IDisposable
|
||||
var channels = _channels;
|
||||
var filled = 0;
|
||||
|
||||
// Same rate: output frame = input frame; seek only when the reader is not already at the start.
|
||||
if (_resampler == null)
|
||||
{
|
||||
if (Reader.Position != start)
|
||||
@@ -115,6 +135,7 @@ internal sealed class DecodeStream : IDisposable
|
||||
fixed (float* pIn = _readBuffer)
|
||||
fixed (float* pOut = data)
|
||||
{
|
||||
// Produce and drop the output frames between the aligned restart position and the requested start.
|
||||
while (discard > 0 && !_ended)
|
||||
{
|
||||
_discardBuffer ??= new float[ReadBlockFrames * channels];
|
||||
@@ -167,12 +188,14 @@ internal sealed class DecodeStream : IDisposable
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>Releases the resampler and the reader.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_resampler?.Dispose();
|
||||
Reader.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>Greatest common divisor of two positive integers (Euclid).</summary>
|
||||
private static int GCD(int a, int b)
|
||||
{
|
||||
while (b != 0) (a, b) = (b, a % b);
|
||||
|
||||
@@ -31,12 +31,20 @@ internal sealed unsafe class FFmpegPCMReader : IPCMReader
|
||||
/// </summary>
|
||||
private const double SeekMarginSeconds = 0.25;
|
||||
|
||||
// FFmpeg objects owned by this reader, freed by Dispose (null after).
|
||||
private AVFormatContext* _format;
|
||||
private AVCodecContext* _codec;
|
||||
|
||||
/// <summary>Sample format converter: decoder format to interleaved float, same rate and layout.</summary>
|
||||
private SwrContext* _swr;
|
||||
|
||||
private AVPacket* _packet;
|
||||
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;
|
||||
|
||||
/// <summary>Time base of the stream's timestamps (seconds per tick).</summary>
|
||||
private readonly AVRational _timeBase;
|
||||
private readonly long _ptsOrigin; // Stream timestamp of output frame _leadFrames
|
||||
private readonly long _leadFrames; // Silent frames placed before the first decoded frame (MP3 header frame, see class summary)
|
||||
@@ -49,14 +57,27 @@ internal sealed unsafe class FFmpegPCMReader : IPCMReader
|
||||
|
||||
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
|
||||
// _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 _endOfStream;
|
||||
|
||||
/// <summary>File path the reader was opened with.</summary>
|
||||
public string Path { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public int Channels { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public int SampleRate { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public long TotalFrames { get; }
|
||||
|
||||
/// <summary>False: the reader seeks within the file.</summary>
|
||||
public bool IsForwardOnly => false;
|
||||
|
||||
/// <inheritdoc/>
|
||||
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>
|
||||
@@ -65,6 +86,12 @@ internal sealed unsafe class FFmpegPCMReader : IPCMReader
|
||||
/// <summary>Number of demuxer seeks performed (for diagnostics).</summary>
|
||||
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)
|
||||
{
|
||||
Path = path;
|
||||
@@ -181,6 +208,11 @@ internal sealed unsafe class FFmpegPCMReader : IPCMReader
|
||||
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)
|
||||
{
|
||||
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.");
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
if (frame < 0 || frame > TotalFrames) throw new ArgumentOutOfRangeException(nameof(frame));
|
||||
@@ -205,6 +244,7 @@ internal sealed unsafe class FFmpegPCMReader : IPCMReader
|
||||
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 ret = ffmpeg.av_seek_frame(_format, _streamIndex, targetTs, ffmpeg.AVSEEK_FLAG_BACKWARD);
|
||||
if (ret < 0)
|
||||
@@ -222,6 +262,12 @@ internal sealed unsafe class FFmpegPCMReader : IPCMReader
|
||||
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)
|
||||
{
|
||||
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>
|
||||
/// <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()
|
||||
{
|
||||
while (!_endOfStream)
|
||||
@@ -315,6 +365,10 @@ internal sealed unsafe class FFmpegPCMReader : IPCMReader
|
||||
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()
|
||||
{
|
||||
var samples = _frame->nb_samples;
|
||||
@@ -347,6 +401,7 @@ internal sealed unsafe class FFmpegPCMReader : IPCMReader
|
||||
_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()
|
||||
{
|
||||
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)
|
||||
{
|
||||
var buffer = stackalloc byte[1024];
|
||||
|
||||
@@ -37,8 +37,13 @@ internal static class MP3HeaderFrame
|
||||
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)
|
||||
{
|
||||
// Skip consecutive ID3v2 tags: 10-byte header + syncsafe size (+ 10-byte footer when flagged).
|
||||
var tag = new byte[10];
|
||||
long offset = 0;
|
||||
while (true)
|
||||
@@ -65,6 +70,14 @@ internal static class MP3HeaderFrame
|
||||
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)
|
||||
{
|
||||
samplesPerFrame = 0;
|
||||
@@ -91,6 +104,7 @@ internal static class MP3HeaderFrame
|
||||
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)
|
||||
{
|
||||
if (at < 0 || at + tag.Length > length) return false;
|
||||
@@ -99,6 +113,7 @@ internal static class MP3HeaderFrame
|
||||
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)
|
||||
{
|
||||
var total = 0;
|
||||
|
||||
@@ -16,6 +16,8 @@ namespace ADOFAIRenderer.Util.Audio.Clip;
|
||||
internal sealed class MP3MonoLayoutReader : IPCMReader
|
||||
{
|
||||
private readonly IPCMReader _inner;
|
||||
|
||||
/// <summary>Reused buffer for inner frames, grown to the largest request.</summary>
|
||||
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>
|
||||
@@ -26,10 +28,19 @@ internal sealed class MP3MonoLayoutReader : IPCMReader
|
||||
Position = inner.Position * 2;
|
||||
}
|
||||
|
||||
/// <summary>Always 1.</summary>
|
||||
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;
|
||||
|
||||
/// <summary>Twice the inner frame count.</summary>
|
||||
public long TotalFrames => _inner.TotalFrames * 2;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool IsForwardOnly => _inner.IsForwardOnly;
|
||||
|
||||
/// <inheritdoc/>
|
||||
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>
|
||||
@@ -39,12 +50,14 @@ internal sealed class MP3MonoLayoutReader : IPCMReader
|
||||
Position = frame;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public int Read(float[] buffer, int frameCount)
|
||||
{
|
||||
var limit = (int)Math.Min(frameCount, TotalFrames - Position);
|
||||
var written = 0;
|
||||
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)
|
||||
{
|
||||
buffer[written++] = 0f;
|
||||
@@ -52,6 +65,7 @@ internal sealed class MP3MonoLayoutReader : IPCMReader
|
||||
continue;
|
||||
}
|
||||
|
||||
// Even position: read enough inner frames for the remaining output, then interleave each with a zero.
|
||||
var want = (limit - written + 1) / 2;
|
||||
if (_innerBuffer.Length < want) _innerBuffer = new float[want];
|
||||
var got = _inner.Read(_innerBuffer, want);
|
||||
@@ -69,5 +83,6 @@ internal sealed class MP3MonoLayoutReader : IPCMReader
|
||||
return written;
|
||||
}
|
||||
|
||||
/// <summary>Disposes the inner reader.</summary>
|
||||
public void Dispose() => _inner.Dispose();
|
||||
}
|
||||
@@ -11,19 +11,38 @@ namespace ADOFAIRenderer.Util.Audio.Clip;
|
||||
/// </summary>
|
||||
internal sealed class MemoryPCMReader : IPCMReader
|
||||
{
|
||||
/// <summary>Block arrays; an entry is set to null when the block is released.</summary>
|
||||
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 int _releasedCount; // Blocks [0, _releasedCount) are released
|
||||
|
||||
/// <inheritdoc/>
|
||||
public int Channels { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public int SampleRate { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public long TotalFrames { get; }
|
||||
|
||||
/// <summary>True: blocks are released as they are read.</summary>
|
||||
public bool IsForwardOnly => true;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public long Position { get; private set; }
|
||||
|
||||
/// <summary>Frames per block (all blocks except the last).</summary>
|
||||
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,
|
||||
Action<float[]>? onBlockReleased)
|
||||
{
|
||||
@@ -40,6 +59,8 @@ internal sealed class MemoryPCMReader : IPCMReader
|
||||
TotalFrames = totalFrames;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <exception cref="InvalidOperationException"><paramref name="frame"/> lies in a block that was already released.</exception>
|
||||
public void Seek(long frame)
|
||||
{
|
||||
if (frame < 0 || frame > TotalFrames) throw new ArgumentOutOfRangeException(nameof(frame));
|
||||
@@ -48,6 +69,8 @@ internal sealed class MemoryPCMReader : IPCMReader
|
||||
Position = frame;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>Blocks whose end the position has passed are released after each copy.</remarks>
|
||||
public int Read(float[] buffer, int frameCount)
|
||||
{
|
||||
var total = (int)Math.Min(frameCount, TotalFrames - Position);
|
||||
@@ -68,6 +91,7 @@ internal sealed class MemoryPCMReader : IPCMReader
|
||||
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)
|
||||
{
|
||||
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);
|
||||
}
|
||||
@@ -11,16 +11,27 @@ namespace ADOFAIRenderer.Util.Audio.Clip;
|
||||
/// </summary>
|
||||
internal sealed unsafe class PCMResampler : IDisposable
|
||||
{
|
||||
/// <summary>Resampler context owned by this instance; null after <see cref="Dispose"/>.</summary>
|
||||
private SwrContext* _swr;
|
||||
|
||||
/// <summary>True after <see cref="Flush"/> until <see cref="Reset"/>.</summary>
|
||||
private bool _flushed;
|
||||
|
||||
/// <summary>Channel count of input and output (interleaved).</summary>
|
||||
public int Channels { get; }
|
||||
|
||||
/// <summary>Input sample rate in Hz.</summary>
|
||||
public int InputRate { get; }
|
||||
|
||||
/// <summary>Output sample rate in Hz.</summary>
|
||||
public int OutputRate { get; }
|
||||
|
||||
/// <summary>Value of the SwrContext "filter_size" option (filter length per phase in taps at a 1:1 ratio).</summary>
|
||||
public int FilterSize { get; }
|
||||
|
||||
/// <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)
|
||||
{
|
||||
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}).");
|
||||
_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;
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Frees the context. Safe to call more than once.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_swr == null) return;
|
||||
|
||||
@@ -25,6 +25,9 @@ internal static class UnityClipLoader
|
||||
if (clip.loadType == AudioClipLoadType.Streaming)
|
||||
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)
|
||||
clip.LoadAudioData();
|
||||
|
||||
|
||||
Reference in new issue
Block a user