using System; using System.Collections.Generic; using System.Numerics; using AcDream.Core.Audio; using Silk.NET.OpenAL; namespace AcDream.App.Audio; /// /// OpenAL-backed audio engine (Phase E.2) — faithful to retail's /// 16-voice pool and inverse-square falloff behaviour (r05 §5.3). /// /// /// Architecture: /// /// /// Single + bound to the /// system default device. Cross-platform (WASAPI / WinMM / /// PulseAudio / CoreAudio — whichever OpenAL-Soft picks). /// /// /// Fixed 16-source pool for 3D positional sounds. When all 16 are /// busy, new Play3D calls evict the slot whose currently-playing /// sound has lower effective gain than the incoming sound /// (matches retail FUN_00550ad0 first-free-then-evict-quieter /// algorithm at chunk_00550000.c:527). /// /// /// Separate UI source pool (4 sources) for flat 2D UI clicks / /// wooshes — not subject to the 3D eviction game. /// /// /// PCM buffer cache keyed by Wave dat id so the same footstep isn't /// re-uploaded to the GL-equivalent AL buffers on every hit. Bounded /// by a byte budget () enforced /// with LRU eviction — see . A /// buffer still attached to a live source is never evicted (AL /// rejects deleting a bound buffer); eviction re-queries live AL /// source state rather than tracking a second copy of it. /// /// /// /// /// /// Thread-safety: the engine is called only from the render thread /// (the same thread that drives TickAnimations). No locks inside. /// /// /// /// Fail-open: when the OpenAL driver can't be initialised (missing /// library on a headless CI box, or explicitly disabled via /// ACDREAM_NO_AUDIO=1), is false and all /// Play* calls are no-ops. This lets the rest of the client run /// unaffected. /// /// internal interface IWorldAudioQuiescence { void SuspendWorldAudio(); void ResumeWorldAudio(); } public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescence { // ── Backends ───────────────────────────────────────────────────────────── private AL? _al; private OpenAlResourceLifetime? _resources; private bool _available; private bool _disposed; // ── Pools ──────────────────────────────────────────────────────────────── private const int PoolSize3D = 16; // retail 16-slot voice pool private const int PoolSizeUi = 4; // Slot state per 3D source; mirrors retail's g_poolVols array (the // EFFECTIVE gain at play-start time, used for eviction comparisons). private sealed class Slot3D { public uint SourceId; public uint OwnerId; public float PlayingGain; // gain at play time (for eviction compare) public bool InUse; // The DAT-authored priority, a float in [0,1] — NOT an 0..7 int. 4,100 // of the shipped entries carry a sub-1.0 priority that an int cast // collapsed to 0, which flattened the eviction ordering this field // exists for. A2 makes eviction compare it. public float Priority; } private readonly Slot3D[] _pool3D = CreateWorldSlots(); private int _pool3DCursor; // round-robin start private bool _worldAudioSuspended; private readonly uint[] _poolUi = new uint[PoolSizeUi]; // ── Buffer cache (Wave dat id → AL buffer) ─────────────────────────────── // Budget rationale: decoded PCM waves run ~100-500 KB each (same sizing // as DatSoundCache's payload LRU, which this cache re-uploads from). 48 // MiB gives comfortable headroom for the working set of a play session // (roughly 100-480 resident buffers) while keeping the native AL-side // copy from growing without bound over a long-uptime process (the // 30-bot headless-fleet target). internal const long DefaultBufferByteBudget = 48L * 1024 * 1024; // 48 MiB private readonly Dictionary _bufferByWaveId = new(); private readonly AlBufferBudgetTracker _bufferBudget = new(DefaultBufferByteBudget); // ── Ambient handles (StartAmbient/StopAmbient) ─────────────────────────── private readonly Dictionary _ambientSources = new(); private int _nextAmbientHandle = 1; // ── Public volume knobs ────────────────────────────────────────────────── public float MasterVolume { get; set; } = 1f; public float SfxVolume { get; set; } = 1f; public float MusicVolume { get; set; } = 0.7f; public float AmbientVolume{ get; set; } = 0.8f; public bool IsAvailable => _available; /// Estimated bytes currently resident in the AL buffer cache. Diagnostic use only. public long ResidentBufferBytes => _bufferBudget.ResidentBytes; /// Number of AL buffers currently resident. Diagnostic use only. public int ResidentBufferCount => _bufferBudget.Count; public OpenAlAudioEngine() : this(new SilkOpenAlResourceApiFactory()) { } internal OpenAlAudioEngine(IOpenAlResourceApiFactory apiFactory) { ArgumentNullException.ThrowIfNull(apiFactory); IOpenAlResourceApi api; try { api = apiFactory.Create(); } catch { return; } _al = api.AudioApi; _resources = new OpenAlResourceLifetime(api); try { if (!_resources.TryOpenDevice()) { return; } if (!_resources.TryCreateContext()) { DisableAfterInitializationFailure( new InvalidOperationException("OpenAL could not create a context.")); return; } if (!_resources.TryMakeCurrent()) { DisableAfterInitializationFailure( new InvalidOperationException("OpenAL could not activate its context.")); return; } // Initialise 3D source pool. for (int i = 0; i < PoolSize3D; i++) { uint src = _resources.Create3DSource(); _pool3D[i].SourceId = src; } // UI sources are source-relative (attached to listener) so they // ignore 3D position. for (int i = 0; i < PoolSizeUi; i++) { uint src = _resources.CreateUiSource(); _poolUi[i] = src; } // Global distance model = inverse-square clamped (classic retail feel). api.SelectRetailDistanceModel(); _available = true; } catch (OpenAlInitializationException) { throw; } catch (Exception failure) { DisableAfterInitializationFailure(failure); } } public void Dispose() { if (_disposed) return; _available = false; _resources?.RetryCleanup(); _disposed = _resources is null || _resources.IsCleanupComplete; } internal bool IsDisposalComplete => _disposed || _resources is null || _resources.IsCleanupComplete; private void DisableAfterInitializationFailure(Exception failure) { _available = false; if (_resources is null) return; try { _resources.RetryCleanup(); } catch (AggregateException cleanupFailure) { throw new OpenAlInitializationException( failure, _resources, cleanupFailure); } _al = null; } // ── IAudioEngine ───────────────────────────────────────────────────────── public void SetListener( float posX, float posY, float posZ, float forwardX, float forwardY, float forwardZ, float upX, float upY, float upZ) { if (!_available || _al is null) return; _al.SetListenerProperty(ListenerVector3.Position, posX, posY, posZ); // AL expects a 6-float orientation (fwd then up). Span ori = stackalloc float[6] { forwardX, forwardY, forwardZ, upX, upY, upZ }; fixed (float* p = ori) _al.SetListenerProperty(ListenerFloatArray.Orientation, p); _al.SetListenerProperty(ListenerFloat.Gain, MasterVolume); } /// /// Not exposed on IAudioEngine but used by the hook sink — play a raw /// WaveData blob at a 3D position with full priority/volume controls. /// Returns true on success, false if the buffer was rejected. /// public bool Play3DWave( uint ownerId, uint waveId, WaveData wave, Vector3 position, float volume, float priority) { if (_worldAudioSuspended || !_available || _al is null) return false; float effectiveGain = volume * SfxVolume; if (effectiveGain < 0.001f) return false; // silent; skip uint buffer = EnsureBuffer(waveId, wave); if (buffer == 0) return false; // Pick a slot: first free, else evict quieter one, else drop. int slotIdx = -1; for (int i = 0; i < PoolSize3D; i++) { int idx = (_pool3DCursor + i) & (PoolSize3D - 1); var s = _pool3D[idx]; if (!s.InUse || !IsStillPlaying(s.SourceId)) { slotIdx = idx; break; } } if (slotIdx < 0) { for (int i = 0; i < PoolSize3D; i++) { int idx = (_pool3DCursor + i) & (PoolSize3D - 1); if (_pool3D[idx].PlayingGain < effectiveGain) { slotIdx = idx; break; } } } if (slotIdx < 0) return false; // no slot quieter than us — drop var slot = _pool3D[slotIdx]; _al.SourceStop(slot.SourceId); _al.SetSourceProperty(slot.SourceId, SourceInteger.Buffer, 0); // detach old _al.SetSourceProperty(slot.SourceId, SourceInteger.Buffer, (int)buffer); _al.SetSourceProperty(slot.SourceId, SourceFloat.Gain, effectiveGain); // No pitch: retail never calls SetFrequency on a sound buffer, so // there is no per-play pitch variation to reproduce. _al.SetSourceProperty(slot.SourceId, SourceVector3.Position, position.X, position.Y, position.Z); _al.SetSourceProperty(slot.SourceId, SourceBoolean.SourceRelative, false); _al.SetSourceProperty(slot.SourceId, SourceBoolean.Looping, false); _al.SourcePlay(slot.SourceId); slot.PlayingGain = effectiveGain; slot.InUse = true; slot.OwnerId = ownerId; slot.Priority = priority; _pool3DCursor = (slotIdx + 1) & (PoolSize3D - 1); return true; } /// /// Stops every world-space voice while preserving the independent UI /// source pool. Retail suppresses ambient/object audio while cell loading /// blocks world maintenance; stopped voices are not resumed afterward. /// public void SuspendWorldAudio() { _worldAudioSuspended = true; for (int i = 0; i < _pool3D.Length; i++) StopWorldSlot(_pool3D[i]); } public void ResumeWorldAudio() => _worldAudioSuspended = false; internal void StopAllForOwner(uint ownerId) { if (ownerId == 0) return; for (int i = 0; i < _pool3D.Length; i++) { Slot3D slot = _pool3D[i]; if (slot.InUse && slot.OwnerId == ownerId) StopWorldSlot(slot); } } /// /// Play a raw WaveData blob as a 2D UI sound (no falloff, ignores /// listener position). /// public bool PlayUiWave(uint waveId, WaveData wave, float volume = 1f) { if (!_available || _al is null) return false; uint buffer = EnsureBuffer(waveId, wave); if (buffer == 0) return false; // UI pool: find a free source (first not-playing), else round-robin. int slotIdx = -1; for (int i = 0; i < PoolSizeUi; i++) { if (!IsStillPlaying(_poolUi[i])) { slotIdx = i; break; } } if (slotIdx < 0) slotIdx = 0; // always replace slot 0 as a last resort uint src = _poolUi[slotIdx]; _al.SourceStop(src); _al.SetSourceProperty(src, SourceInteger.Buffer, 0); _al.SetSourceProperty(src, SourceInteger.Buffer, (int)buffer); _al.SetSourceProperty(src, SourceFloat.Gain, Math.Clamp(volume, 0f, 1f) * SfxVolume); _al.SourcePlay(src); return true; } // IAudioEngine implementations — the enum-based overloads are less // useful than the raw-Wave overloads above, since the hook sink already // has access to decoded WaveData. Left as no-ops for now; R5 defines // SoundId as a sparse subset of retail enums. public void PlayUi(SoundId id) { /* handled via AudioHookSink */ } public void Play3D(SoundId id, float x, float y, float z) { /* handled via AudioHookSink */ } public int StartAmbient(SoundId id, float x, float y, float z) { // Looping ambient — needs a decoded wave + WaveId. The hook sink // doesn't route ambient; a separate landblock-attached ambient // system (outside R5) will drive this. For now: reserve a handle. int handle = _nextAmbientHandle++; return handle; } public void StopAmbient(int handle) { if (!_available || _al is null) return; if (_ambientSources.TryGetValue(handle, out var src)) { _al.SourceStop(src); _ambientSources.Remove(handle); } } public void PlayMusic(string resourceName, bool loop) { /* R5 §6 MIDI — not ported */ } public void StopMusic() { /* ditto */ } // ── Private helpers ────────────────────────────────────────────────────── private uint EnsureBuffer(uint waveId, WaveData wave) { if (!_available || _al is null) return 0; if (_bufferByWaveId.TryGetValue(waveId, out var existing)) { // Buffer id 0 is the "unsupported format" negative marker — no // payload, not tracked by the budget, nothing to touch. if (existing != 0) _bufferBudget.Touch(waveId); return existing; } uint buf = _al.GenBuffer(); _resources!.OwnBuffer(buf); BufferFormat fmt = PickFormat(wave); if (fmt == 0) { _resources.ReleaseBuffer(buf); _bufferByWaveId[waveId] = 0; return 0; } fixed (byte* p = wave.PcmBytes) _al.BufferData(buf, fmt, p, wave.PcmBytes.Length, wave.SampleRate); _bufferByWaveId[waveId] = buf; _bufferBudget.RecordCreated(waveId, buf, wave.PcmBytes.Length); // The buffer we just created is protected for this call: the // caller hasn't attached it to a source yet, so live AL state // would (wrongly) report it as evictable. EvictBuffersOverBudget(protectedBufferId: buf); return buf; } /// /// Evict least-recently-used AL buffers until the resident-byte budget /// is satisfied again. A buffer still bound to a live source (3D pool, /// UI pool, or an ambient source) is protected — alDeleteBuffers /// fails on a buffer that's still attached to a source — so eviction /// never targets one; nor does it target , /// the buffer just created for this call and /// hasn't attached to a source yet. If every resident buffer is /// protected the budget is temporarily exceeded rather than looping /// forever; the bounded pool sizes (16 + 4 + ambient) cap how large /// that overage can get. Evicted waves replay through /// again on next use — re-upload from /// , identical to a first play. /// private void EvictBuffersOverBudget(uint protectedBufferId) { while (_bufferBudget.ResidentBytes > _bufferBudget.MaxBytes) { bool IsProtected(uint bufferId) => bufferId == protectedBufferId || IsBufferAttachedToAnySource(bufferId); if (!_bufferBudget.TryEvictOldestUnprotected( IsProtected, out uint evictedWaveId, out uint evictedBufferId)) { break; } _bufferByWaveId.Remove(evictedWaveId); _resources!.ReleaseBuffer(evictedBufferId); } } /// /// True when is currently bound to any pool /// source. Queried live from AL (AL_BUFFER on each source) /// rather than tracked locally: AL's per-source state is the only /// thing that actually determines whether alDeleteBuffers would /// fail, and several call sites (, /// ) set a source's buffer directly, so a /// second local copy would be one more place to keep in sync. /// private bool IsBufferAttachedToAnySource(uint bufferId) { if (_al is null) return false; for (int i = 0; i < PoolSize3D; i++) { if (IsSourceBoundTo(_pool3D[i].SourceId, bufferId)) return true; } for (int i = 0; i < PoolSizeUi; i++) { if (IsSourceBoundTo(_poolUi[i], bufferId)) return true; } foreach (uint sourceId in _ambientSources.Values) { if (IsSourceBoundTo(sourceId, bufferId)) return true; } return false; } private bool IsSourceBoundTo(uint sourceId, uint bufferId) { _al!.GetSourceProperty(sourceId, GetSourceInteger.Buffer, out int attached); return (uint)attached == bufferId; } private static BufferFormat PickFormat(WaveData w) { return (w.ChannelCount, w.BitsPerSample) switch { (1, 8) => BufferFormat.Mono8, (1, 16) => BufferFormat.Mono16, (2, 8) => BufferFormat.Stereo8, (2, 16) => BufferFormat.Stereo16, _ => 0, }; } private bool IsStillPlaying(uint sourceId) { if (_al is null) return false; _al.GetSourceProperty(sourceId, GetSourceInteger.SourceState, out int state); return state == (int)SourceState.Playing; } private void StopWorldSlot(Slot3D slot) { if (_available && _al is not null && slot.SourceId != 0) { _al.SourceStop(slot.SourceId); _al.SetSourceProperty(slot.SourceId, SourceInteger.Buffer, 0); } slot.OwnerId = 0; slot.PlayingGain = 0f; slot.Priority = 0f; slot.InUse = false; } private static Slot3D[] CreateWorldSlots() { var slots = new Slot3D[PoolSize3D]; for (int i = 0; i < slots.Length; i++) slots[i] = new Slot3D(); return slots; } }