using System; using System.Collections.Generic; using System.Threading; using MelonLoader; namespace ADOFAIRenderer.Util.Audio.Clip; /// /// Clip sources of one render, keyed by clip name or path, with a shared pool of chunk arrays and one decoder thread. /// /// Threads: /// - Decoder thread (): the only thread that decodes, resamples, loads and frees chunks. /// Each step loads one chunk, the one with the lowest priority value over all sources /// (a chunk a consumer waits for, then cursor windows by slot, then resident chunks in order). /// - Consumers (render thread, main thread for the spectrum): waits for the decoder thread and never loads. /// - Main thread: creates readers (for Unity clips this copies the clip data). /// /// Memory has no limit; current and peak usage are tracked and logged by . /// An while loading is kept in and rethrown by . /// internal sealed class AudioClipStore : IDisposable { /// Longest wait of the decoder thread between checks when nothing signals it, in milliseconds. private const int DecoderIdleWaitMs = 50; /// Longest wait of a consumer between checks when no chunk is loaded, in milliseconds. private const int ConsumerWaitMs = 50; private readonly Dictionary _sources = new(); private readonly List _sourceList = new(); private readonly Stack _chunkPool = new(); private readonly object _poolLock = new(); private readonly object _signal = new(); private readonly AutoResetEvent _serviceRequested = new(false); private Thread? _decoderThread; private volatile bool _stopRequested; private long _allocatedBytes; private long _peakBytes; private volatile Exception? _fatalError; private volatile bool _disposed; /// Sample rate every source is resampled to, in Hz. public int OutputRate { get; } /// Bytes of sample arrays currently allocated (loaded chunks, pooled chunk arrays and copied Unity clip data). public long AllocatedBytes => Interlocked.Read(ref _allocatedBytes); /// Highest value of since creation. public long PeakBytes => Interlocked.Read(ref _peakBytes); /// Out-of-memory error raised while loading, or null. public Exception? FatalError => _fatalError; public AudioClipStore(int outputRate) { if (outputRate <= 0) throw new ArgumentOutOfRangeException(nameof(outputRate)); OutputRate = outputRate; } // ── Decoder thread ── /// Starts the decoder thread. Called once. public void Start() { if (_decoderThread != null) throw new InvalidOperationException("Decoder thread already started."); _decoderThread = new Thread(DecoderLoop) { Name = "Audio Clip Decoder", IsBackground = true }; _decoderThread.Start(); } private void DecoderLoop() { try { while (!_stopRequested) { if (!LoadNext()) _serviceRequested.WaitOne(DecoderIdleWaitMs); } } catch (Exception e) { Melon.Logger.Error($"[AudioClipStore] Decoder thread stopped: {e}"); _fatalError ??= e; NotifyChunkLoaded(); } } /// /// Frees unused windowed chunks, then loads the single chunk with the lowest priority value over all sources. /// Runs on the decoder thread (tests may call it directly while the thread is not started). /// /// True when a chunk was loaded or a load was attempted. internal bool LoadNext() { AudioClipSource[] sources; lock (_sources) { if (_disposed) return false; sources = _sourceList.ToArray(); } AudioClipSource? best = null; var bestIndex = -1; var bestPriority = int.MaxValue; foreach (var source in sources) { source.FreeUnused(); if (!source.TryGetNextLoad(out var index, out var priority) || priority >= bestPriority) continue; best = source; bestIndex = index; bestPriority = priority; } if (best == null) return false; best.LoadChunk(bestIndex); return true; } internal void RequestService() => _serviceRequested.Set(); // ── Sources ── /// /// Returns the source registered under , creating it with on first use. /// A key whose reader could not be created is remembered as null and not retried. /// For readers that can seek, is kept and called again on the decoder thread /// to open additional decode streams (see ). /// public AudioClipSource? GetOrAdd(string key, Func createReader) { lock (_sources) { if (_disposed) return null; if (_sources.TryGetValue(key, out var existing)) return existing; } AudioClipSource? source = null; try { var reader = createReader(); if (reader != null) { try { source = new AudioClipSource(this, key, reader, reader.IsForwardOnly ? null : createReader, OutputRate); } catch { reader.Dispose(); throw; } } } catch (OutOfMemoryException e) { Melon.Logger.Error($"[AudioClipStore] Out of memory while opening clip '{key}' ({AllocatedBytes / 1048576.0:F1} MiB allocated): {e}"); _fatalError ??= e; } catch (Exception e) { Melon.Logger.Error($"[AudioClipStore] Failed to open clip '{key}': {e}"); } lock (_sources) { if (_disposed || _sources.TryGetValue(key, out var raced)) { source?.DisposeInternal(); return _disposed ? null : _sources[key]; } _sources[key] = source; if (source != null) _sourceList.Add(source); } if (source != null) { Melon.Logger.Msg($"[AudioClipStore] Opened clip '{key}': {source.Channels}ch, {source.Duration:F3}s, {source.TotalFrames} frames at {OutputRate}Hz, {source.ChunkCount} chunk(s), {source.Policy}"); RequestService(); } return source; } public bool TryGet(string key, out AudioClipSource? source) { lock (_sources) { return _sources.TryGetValue(key, out source); } } // ── Consumers ── /// /// Holds the chunk containing , waiting for the decoder thread until it is loaded. /// Returns an invalid view when the source failed, the frame is outside the source, or the store was disposed. /// /// Loading ran out of memory or the decoder thread stopped. /// was canceled while waiting. public ChunkView Acquire(AudioClipSource source, long frame, CancellationToken cancellationToken = default) { if (frame < 0 || frame >= source.TotalFrames) return default; while (true) { ThrowIfFatal(); if (source.IsFailed || _disposed) return default; if (source.TryAcquire(frame, out var view)) return view; lock (_signal) { if (source.TryAcquire(frame, out view)) return view; if (source.IsFailed || _fatalError != null || _disposed) continue; Monitor.Wait(_signal, ConsumerWaitMs); } cancellationToken.ThrowIfCancellationRequested(); } } private void ThrowIfFatal() { var error = _fatalError; if (error != null) throw new InvalidOperationException("Audio clip loading failed: " + error.Message, error); } internal void NotifyChunkLoaded() { lock (_signal) { Monitor.PulseAll(_signal); } } internal void ReportSourceFailure(AudioClipSource source, Exception error) { Melon.Logger.Error($"[AudioClipStore] Failed to load clip '{source.Key}', it will be silent: {error}"); NotifyChunkLoaded(); } internal void ReportFatal(AudioClipSource source, OutOfMemoryException error) { Melon.Logger.Error($"[AudioClipStore] Out of memory while loading clip '{source.Key}' ({AllocatedBytes / 1048576.0:F1} MiB allocated): {error}"); _fatalError ??= error; NotifyChunkLoaded(); } // ── Arrays ── internal float[] RentChunkArray() { lock (_poolLock) { if (_chunkPool.Count > 0) return _chunkPool.Pop(); } var data = new float[AudioClipSource.ChunkFloats]; AddAllocated(data.LongLength * sizeof(float)); return data; } internal void ReturnChunkArray(float[] data) { if (_disposed) { AddAllocated(-data.LongLength * sizeof(float)); return; } lock (_poolLock) { _chunkPool.Push(data); } } internal float[] AllocateExactArray(int floats) { var data = new float[floats]; AddAllocated(data.LongLength * sizeof(float)); return data; } internal void FreeExactArray(float[] data) => AddAllocated(-data.LongLength * sizeof(float)); private void AddAllocated(long bytes) { var current = Interlocked.Add(ref _allocatedBytes, bytes); long peak; while (current > (peak = Interlocked.Read(ref _peakBytes))) { if (Interlocked.CompareExchange(ref _peakBytes, current, peak) == peak) break; } } public void LogMemoryUsage() { int sourceCount; lock (_sources) sourceCount = _sourceList.Count; Melon.Logger.Msg($"[AudioClipStore] Clips: {sourceCount}, sample memory now {AllocatedBytes / 1048576.0:F1} MiB, peak {PeakBytes / 1048576.0:F1} MiB"); } /// Stops the decoder thread, then releases every source and pooled array. public void Dispose() { AudioClipSource[] sources; lock (_sources) { if (_disposed) return; _disposed = true; sources = _sourceList.ToArray(); } _stopRequested = true; _serviceRequested.Set(); NotifyChunkLoaded(); _decoderThread?.Join(); LogMemoryUsage(); lock (_sources) { _sourceList.Clear(); _sources.Clear(); } foreach (var source in sources) source.DisposeInternal(); lock (_poolLock) { while (_chunkPool.Count > 0) AddAllocated(-_chunkPool.Pop().LongLength * sizeof(float)); } } }