using System; using System.Collections.Generic; using System.Threading; using MelonLoader; namespace ADOFAIRenderer.Util.Audio.Clip; /// /// Read position registered on an by one consumer (a playing voice or the spectrum generator). /// For a windowed source, the chunk containing and the following chunks /// ( chunks in total) are kept loaded. /// internal sealed class AudioCursor { private readonly AudioClipSource _source; /// Backing value of , accessed with Interlocked (64-bit reads are not atomic on every platform). private long _position; private volatile bool _loop; /// Created by . internal AudioCursor(AudioClipSource source, long position, bool loop) { _source = source; _position = position; _loop = loop; } /// /// Frame in the source at the output sample rate (0 ~ TotalFrames - 1). /// Written by the consumer that reads the source; read by the decoder thread. /// Moving into another chunk wakes the decoder thread. /// public long Position { get => Interlocked.Read(ref _position); set { var previous = Interlocked.Exchange(ref _position, value); if (previous / _source.FramesPerChunk != value / _source.FramesPerChunk) _source.OnCursorMoved(); } } /// /// 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. /// public bool Loop { get => _loop; set { _loop = value; _source.OnCursorMoved(); } } } /// /// One loaded chunk of an , held by a consumer between /// and . /// The chunk is not freed while held. /// internal readonly struct ChunkView { /// Interleaved samples. Frame f of the source is at (f - StartFrame) * Channels. public readonly float[] Data; /// Source frame of the first frame in . public readonly long StartFrame; /// Valid frames in this chunk. Frames past the end of the source are not included. public readonly int FrameCount; /// Channel count of the stored samples (same as the source clip). public readonly int Channels; /// Index of the chunk in its source (used by ). public readonly int ChunkIndex; /// Created by ; default(ChunkView) is the invalid view. internal ChunkView(float[] data, long startFrame, int frameCount, int channels, int chunkIndex) { Data = data; StartFrame = startFrame; FrameCount = frameCount; Channels = channels; ChunkIndex = chunkIndex; } /// True for a view that holds a chunk; false for default(ChunkView). public bool IsValid => Data != null; /// Source frame after the last valid frame (exclusive end). public long EndFrame => StartFrame + FrameCount; /// True when source frame is one of the valid frames of this chunk. public bool Contains(long frame) => frame >= StartFrame && frame < StartFrame + FrameCount; /// /// Sample of mapped to output channel of . /// Mapping: mono output averages all source channels; a mono source is copied to every output channel; /// otherwise output channel c takes source channel c, and output channels beyond the source take source channel 0. /// must be inside this chunk. /// public float GetSample(long frame, int outChannel, int outChannels) { var baseIndex = (int)(frame - StartFrame) * Channels; if (Channels == 1) return Data[baseIndex]; if (outChannels == 1) { var sum = 0f; for (var c = 0; c < Channels; c++) sum += Data[baseIndex + c]; return sum / Channels; } return Data[baseIndex + (outChannel < Channels ? outChannel : 0)]; } } /// /// How an keeps its chunks. /// internal enum ChunkPolicy { /// /// Only the chunks required by registered cursors (current chunk and the next SlotCount - 1 chunks per cursor), /// chunks requested by a waiting consumer, and chunks currently held are kept. Other chunks are freed and reloaded on demand. /// Used for readers that can seek (files decoded with FFmpeg) when the clip is longer than one chunk. /// Windowed, /// /// All chunks are loaded once, in order from chunk 0, starting when the source is created, and kept until the store is disposed. /// Used for forward-only readers (clip data copied from Unity, released as it is consumed) and for clips of one chunk or less. /// Resident } /// /// Decoded audio of one clip, resampled to the output sample rate and stored in fixed-size chunks. /// Channel count stays as in the clip. /// /// Chunks: chunk k covers output frames [k * FramesPerChunk, (k + 1) * FramesPerChunk), where /// = / Channels (rounded down; a frame never spans two chunks). /// Windowed chunks use pooled arrays of ChunkFloats floats; resident chunks use arrays of their exact length. /// See for which chunks are kept. /// /// Threads: cursors and TryAcquire/Release may be used from any thread. /// Loading and freeing run only on the store's decoder thread (), through /// , and . /// internal sealed class AudioClipSource { /// Floats per chunk array: 2^22 floats = 16 MiB. public const int ChunkFloats = 1 << 22; /// Chunks kept per cursor of a windowed source: the chunk containing the cursor and the next SlotCount - 1 chunks. public const int SlotCount = 3; /// Load priority of a chunk a consumer is waiting for (lowest value is loaded first). internal const int PriorityDemanded = 0; /// Load priority of slot s of a cursor window is PriorityWindow + s. internal const int PriorityWindow = 1; /// Load priority of resident chunks nobody is waiting for. internal const int PriorityResident = PriorityWindow + SlotCount; /// /// Most decode streams (reader + resampler) a windowed source opens. A load continues the stream positioned at the chunk start, /// otherwise opens another stream up to this count, otherwise restarts the least recently used stream. /// Each stream holds one open reader (file handle and decoder state, measured 0.3 ~ 2.7 MiB for ogg/flac/MP3 with the clip harness). /// public const int MaxDecodeStreams = 4; /// Decode stream limit of this source ( unless changed for tests). internal int DecodeStreamLimit { get; set; } = MaxDecodeStreams; private readonly AudioClipStore _store; /// Reader passed to the constructor (the first decode stream); disposed again in . private readonly IPCMReader _firstReader; /// Opens another reader for an additional decode stream; null when only the first reader can be used. private readonly Func? _openReader; // Guarded by _sync: chunk table, hold counts, cursors, demand set, loaded set. private readonly object _sync = new(); /// Sample array of each chunk, null while not loaded. private readonly float[]?[] _chunks; /// Number of consumers holding each chunk (TryAcquire minus Release); a held chunk is not freed. private readonly int[] _holdCounts; private readonly List _cursors = new(); /// Chunks a consumer asked for while they were not loaded; an index is removed when the chunk is acquired. private readonly HashSet _demanded = new(); /// Indices of the loaded chunks (non-null entries of ). private readonly HashSet _loaded = new(); private int _residentLoadedCount; // Resident: chunks [0, _residentLoadedCount) are loaded // Guarded by _loadLock: decode streams. private readonly object _loadLock = new(); private readonly List _streams = new(); /// Incremented on every load; stored in for least-recently-used selection. private long _streamUseCounter; /// True after opening an additional reader failed; no further readers are opened for this source. private bool _openFailed; private volatile bool _isFailed; /// Key the source is registered under in the store (the game's audioLib key / AudioClip.name). public string Key { get; } /// Channel count of the stored samples (same as the clip). public int Channels { get; } /// Sample rate of the stored samples in Hz (the output sample rate). public int Frequency { get; } /// Frame count at : ceil(clip frames * Frequency / clip rate). public long TotalFrames { get; } /// Clip length in seconds: clip frames / clip rate. public double Duration { get; } /// Frames per chunk: / (rounded down). public int FramesPerChunk { get; } /// Number of chunks: ceil( / ); 0 for an empty clip. public int ChunkCount { get; } /// /// for forward-only readers and clips of at most one chunk, otherwise /// . Fixed at construction. /// public ChunkPolicy Policy { get; } /// True when is . public bool IsResident => Policy == ChunkPolicy.Resident; /// True after loading failed. A failed source returns no chunks. public bool IsFailed => _isFailed; /// True when every chunk of a resident source is loaded. public bool IsFullyLoaded { get { lock (_sync) return _loaded.Count == ChunkCount; } } /// Number of chunk loads performed (for diagnostics). public int LoadCount { get; private set; } /// Decode streams opened so far (for diagnostics). public int StreamCount { get { lock (_loadLock) return _streams.Count; } } /// Store that owns this source. /// Clip name or path the source is registered under. /// First reader; also defines the clip format. /// Opens another reader of the same clip for an additional decode stream, or null when not possible. /// Output sample rate in Hz. internal AudioClipSource(AudioClipStore store, string key, IPCMReader reader, Func? openReader, int outputRate) { if (reader.Channels <= 0) throw new ArgumentException("Reader channel count must be positive.", nameof(reader)); if (reader.SampleRate <= 0) throw new ArgumentException("Reader sample rate must be positive.", nameof(reader)); if (reader.TotalFrames < 0) throw new ArgumentException("Reader frame count must not be negative.", nameof(reader)); _store = store; _firstReader = reader; _openReader = openReader; Key = key; Channels = reader.Channels; Frequency = outputRate; Duration = (double)reader.TotalFrames / reader.SampleRate; TotalFrames = CeilDiv(reader.TotalFrames * outputRate, reader.SampleRate); FramesPerChunk = ChunkFloats / Channels; ChunkCount = (int)CeilDiv(TotalFrames, FramesPerChunk); Policy = reader.IsForwardOnly || ChunkCount <= 1 ? ChunkPolicy.Resident : ChunkPolicy.Windowed; _chunks = new float[ChunkCount][]; _holdCounts = new int[ChunkCount]; _streams.Add(new DecodeStream(reader, outputRate)); } // ── Cursors ── /// Registers a read position. The decoder thread loads the chunks it requires. public AudioCursor AddCursor(long position, bool loop) { var cursor = new AudioCursor(this, position, loop); lock (_sync) { _cursors.Add(cursor); } _store.RequestService(); return cursor; } /// Unregisters ; its window chunks become freeable on the decoder thread's next pass. public void RemoveCursor(AudioCursor cursor) { lock (_sync) { _cursors.Remove(cursor); } _store.RequestService(); } /// Wakes the decoder thread after a cursor changed chunk or loop flag (resident sources ignore cursors). internal void OnCursorMoved() { if (!IsResident) _store.RequestService(); } // ── Consumer access ── /// Chunk index containing (0 ~ TotalFrames - 1). public int ChunkIndexOf(long frame) => (int)(frame / FramesPerChunk); /// /// Holds the chunk containing when it is loaded. /// When it is not loaded, records a load request (kept until the chunk is acquired), wakes the decoder thread and returns false. /// Every successful call must be paired with . /// public bool TryAcquire(long frame, out ChunkView view) { view = default; if (_isFailed || frame < 0 || frame >= TotalFrames) return false; var index = ChunkIndexOf(frame); lock (_sync) { var data = _chunks[index]; if (data != null) { _demanded.Remove(index); _holdCounts[index]++; view = CreateView(index, data); return true; } _demanded.Add(index); } _store.RequestService(); return false; } /// Ends one hold of the chunk of taken by . An invalid view is ignored. public void Release(in ChunkView view) { if (!view.IsValid) return; lock (_sync) { if (_holdCounts[view.ChunkIndex] > 0) _holdCounts[view.ChunkIndex]--; } } /// View of chunk ; the last chunk's frame count stops at . private ChunkView CreateView(int index, float[] data) { var start = (long)index * FramesPerChunk; var count = (int)Math.Min(FramesPerChunk, TotalFrames - start); return new ChunkView(data, start, count, Channels, index); } // ── Decoder thread ── /// /// Windowed: frees loaded chunks that are not required (see ) and not held. /// Resident: does nothing. Decoder thread only. /// internal void FreeUnused() { if (IsResident) return; List? freed = null; lock (_sync) { if (_loaded.Count == 0) return; List? remove = null; foreach (var index in _loaded) { if (_holdCounts[index] > 0 || IsRequired(index)) continue; (remove ??= new List()).Add(index); } if (remove == null) return; foreach (var index in remove) { (freed ??= new List()).Add(_chunks[index]!); _chunks[index] = null; _loaded.Remove(index); } } foreach (var data in freed!) _store.ReturnChunkArray(data); } /// /// Next chunk to load and its priority (lower loads first), or false when nothing is missing. /// Windowed: demanded chunks, then each cursor's window by slot. /// Resident: the next chunk in order; priority is while a consumer is waiting on this source. /// Decoder thread only. /// internal bool TryGetNextLoad(out int index, out int priority) { index = -1; priority = int.MaxValue; if (_isFailed) return false; lock (_sync) { if (IsResident) { if (_residentLoadedCount >= ChunkCount) return false; index = _residentLoadedCount; priority = _demanded.Count > 0 ? PriorityDemanded : PriorityResident; return true; } foreach (var demanded in _demanded) { if (_chunks[demanded] != null) continue; index = demanded; priority = PriorityDemanded; 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; for (var slot = 0; slot < SlotCount; slot++) { if (!TryWindowIndex(first, slot, loop, out var candidate)) break; if (_chunks[candidate] != null) continue; if (PriorityWindow + slot < priority) { index = candidate; priority = PriorityWindow + slot; } break; } } return index >= 0; } } /// True when a demand or a cursor window covers . Caller holds _sync. private bool IsRequired(int index) { if (_demanded.Contains(index)) return true; foreach (var cursor in _cursors) { if (!TryGetWindowStart(cursor, out var first, out var loop)) continue; for (var slot = 0; slot < SlotCount; slot++) { if (!TryWindowIndex(first, slot, loop, out var candidate)) break; if (candidate == index) return true; } } return false; } /// /// 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. /// private bool TryGetWindowStart(AudioCursor cursor, out int first, out bool loop) { var position = cursor.Position; loop = cursor.Loop; first = 0; if (position < 0) position = 0; if (position >= TotalFrames) { if (!loop || TotalFrames == 0) return false; position %= TotalFrames; } first = ChunkIndexOf(position); return true; } /// Chunk index of window slot after ; wraps when looping, false past the end otherwise. private bool TryWindowIndex(int first, int slot, bool loop, out int index) { index = first + slot; if (index < ChunkCount) return true; if (!loop) return false; index %= ChunkCount; return true; } /// /// Loads chunk if it is not loaded. Resident sources load only the next chunk in order. /// Decoder thread only. /// /// True when this call loaded the chunk. internal bool LoadChunk(int index) { if (_isFailed || index < 0 || index >= ChunkCount) return false; lock (_loadLock) { lock (_sync) { if (_chunks[index] != null) return false; if (IsResident && index != _residentLoadedCount) throw new InvalidOperationException($"Resident clip '{Key}' loads chunks in order: expected {_residentLoadedCount}, got {index}."); } float[]? data = null; try { var start = (long)index * FramesPerChunk; var frames = (int)Math.Min(FramesPerChunk, TotalFrames - start); data = IsResident ? _store.AllocateExactArray(frames * Channels) : _store.RentChunkArray(); var stream = SelectStream(start); 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); Fail(); _store.ReportFatal(this, e); return false; } catch (Exception e) { ReturnArray(data); Fail(); _store.ReportSourceFailure(this, e); return false; } lock (_sync) { _chunks[index] = data; _loaded.Add(index); if (IsResident) _residentLoadedCount++; } LoadCount++; // A resident source never reads again after its last chunk; the reader releases its data now. if (IsResident && _residentLoadedCount == ChunkCount) DisposeStreams(); } _store.NotifyChunkLoaded(); return true; } /// /// Decode stream for a load starting at output frame : /// the stream positioned at ; otherwise a new stream while fewer than /// are open (windowed sources only); otherwise the least recently used stream. Caller holds _loadLock. /// private DecodeStream SelectStream(long start) { DecodeStream? oldest = null; foreach (var stream in _streams) { if (stream.NextOutFrame == start) return stream; if (oldest == null || stream.LastUse < oldest.LastUse) oldest = stream; } if (!IsResident && !_openFailed && _openReader != null && _streams.Count < DecodeStreamLimit) { IPCMReader? reader = null; try { reader = _openReader(); if (reader is { IsForwardOnly: true }) { reader.Dispose(); reader = null; } if (reader != null) { var stream = new DecodeStream(reader, Frequency); _streams.Add(stream); return stream; } } catch (Exception e) { reader?.Dispose(); Melon.Logger.Warning($"[AudioClipSource] Could not open another decode stream for '{Key}', reusing open streams: {e.Message}"); } _openFailed = true; } return oldest!; } /// Marks the source failed (no more chunks are returned or loaded); a resident source also closes its readers now. Caller holds _loadLock. private void Fail() { _isFailed = true; if (IsResident) DisposeStreams(); } /// Disposes every decode stream (reader and resampler) and clears the list. Caller holds _loadLock. private void DisposeStreams() { foreach (var stream in _streams) stream.Dispose(); _streams.Clear(); } /// Gives a chunk array back to the store: exact arrays (resident) are uncounted, pooled arrays (windowed) are returned to the pool. private void ReturnArray(float[]? data) { if (data == null) return; if (IsResident) _store.FreeExactArray(data); else _store.ReturnChunkArray(data); } /// Releases all chunk arrays and readers. Called by the store after the decoder thread stopped. internal void DisposeInternal() { lock (_loadLock) { lock (_sync) { foreach (var index in _loaded) { ReturnArray(_chunks[index]); _chunks[index] = null; } _loaded.Clear(); _cursors.Clear(); _demanded.Clear(); } DisposeStreams(); _firstReader.Dispose(); } } /// ceil(a / b) for a ≥ 0, b > 0. private static long CeilDiv(long a, long b) => (a + b - 1) / b; }