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. /// /// /// /// /// /// 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. /// /// public sealed unsafe class OpenAlAudioEngine : IAudioEngine { // ── Backends ───────────────────────────────────────────────────────────── private readonly ALContext? _alc; private readonly AL? _al; private readonly Device* _device; private readonly Context* _context; private readonly bool _available; // ── 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 float PlayingGain; // gain at play time (for eviction compare) public bool InUse; public uint PriorityBase; // raw priority from SoundEntry.Priority } private readonly Slot3D[] _pool3D = new Slot3D[PoolSize3D]; private int _pool3DCursor; // round-robin start private readonly uint[] _poolUi = new uint[PoolSizeUi]; // ── Buffer cache (Wave dat id → AL buffer) ─────────────────────────────── private readonly Dictionary _bufferByWaveId = new(); // ── 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; public OpenAlAudioEngine() { try { _alc = ALContext.GetApi(soft: true); _al = AL.GetApi(soft: true); _device = _alc.OpenDevice(string.Empty); if (_device == null) { _available = false; return; } _context = _alc.CreateContext(_device, null); if (_context == null) { _alc.CloseDevice(_device); _device = null; _available = false; return; } if (!_alc.MakeContextCurrent(_context)) { _alc.DestroyContext(_context); _context = null; _alc.CloseDevice(_device); _device = null; _available = false; return; } // Initialise 3D source pool. for (int i = 0; i < PoolSize3D; i++) { uint src = _al.GenSource(); _al.SetSourceProperty(src, SourceFloat.Gain, 1f); _al.SetSourceProperty(src, SourceFloat.MaxDistance, 1000f); _al.SetSourceProperty(src, SourceFloat.RolloffFactor, 1f); _al.SetSourceProperty(src, SourceFloat.ReferenceDistance, 2f); _al.SetSourceProperty(src, SourceBoolean.Looping, false); _pool3D[i] = new Slot3D { SourceId = src, InUse = false }; } // UI sources are source-relative (attached to listener) so they // ignore 3D position. for (int i = 0; i < PoolSizeUi; i++) { uint src = _al.GenSource(); _al.SetSourceProperty(src, SourceBoolean.SourceRelative, true); _al.SetSourceProperty(src, SourceFloat.Gain, 1f); _al.SetSourceProperty(src, SourceBoolean.Looping, false); _poolUi[i] = src; } // Global distance model = inverse-square clamped (classic retail feel). _al.DistanceModel(DistanceModel.InverseDistanceClamped); _available = true; } catch { // OpenAL driver unavailable (headless CI, missing libopenal). _available = false; } } public void Dispose() { if (!_available || _al is null) return; try { for (int i = 0; i < PoolSize3D; i++) { var slot = _pool3D[i]; if (slot is null) continue; _al.SourceStop(slot.SourceId); _al.DeleteSource(slot.SourceId); } for (int i = 0; i < PoolSizeUi; i++) { _al.SourceStop(_poolUi[i]); _al.DeleteSource(_poolUi[i]); } foreach (var kv in _ambientSources) { _al.SourceStop(kv.Value); _al.DeleteSource(kv.Value); } foreach (var buf in _bufferByWaveId.Values) { _al.DeleteBuffer(buf); } if (_context != null && _alc is not null) { _alc.MakeContextCurrent(null); _alc.DestroyContext(_context); } if (_device != null && _alc is not null) _alc.CloseDevice(_device); } catch { /* shutdown — ignore */ } } // ── 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 waveId, WaveData wave, Vector3 position, float volume, float priority, float pitch = 1.0f) { if (!_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); _al.SetSourceProperty(slot.SourceId, SourceFloat.Pitch, pitch); _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.PriorityBase = (uint)Math.Clamp((int)priority, 0, 7); _pool3DCursor = (slotIdx + 1) & (PoolSize3D - 1); return true; } /// /// 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, float pitch = 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.SetSourceProperty(src, SourceFloat.Pitch, pitch); _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)) return existing; uint buf = _al.GenBuffer(); BufferFormat fmt = PickFormat(wave); if (fmt == 0) { _al.DeleteBuffer(buf); _bufferByWaveId[waveId] = 0; return 0; } fixed (byte* p = wave.PcmBytes) _al.BufferData(buf, fmt, p, wave.PcmBytes.Length, wave.SampleRate); _bufferByWaveId[waveId] = buf; return buf; } 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; } }