using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Numerics; using System.Threading; using Silk.NET.OpenAL; namespace Godosa.Audio; /// /// OpenAL Soft output on its own thread (every AL call happens there; other threads post /// sounds, loops, streams and the listener pose). Mix policy comes from the game's /// . No device or any AL failure → silent (logged, never throws). /// public sealed unsafe partial class OpenAlAudio : IDisposable { private const int SourceCount = 32; private const int MaxQueued = 64; private const int UpdateMs = 10; public const float ReferenceDistance = 2f, MaxDistance = 60f; // ALC_SOFT_HRTF (not wrapped by Silk.NET): context attribute + status query. private const int AlcHrtfSoft = 0x1992, AlcHrtfStatusSoft = 0x1993, AlcDontCareSoft = 0x0002; // Mix at our buffers' rate: no per-source resampling (~15 % of the mixer, measured). private const int AlcFrequency = 0x1007; public static OpenAlAudio? Current { get; private set; } /// A one-shot sound. runs once per /// (the buffer is cached); no position = heard in the head (2D). Falloff: inverse distance, /// gain = Reference / distance, distance clamped to [Reference, Max] (world units of the game). /// (distance → gain factor, audio thread) replaces that curve for a positioned sound: /// OpenAL only pans it, the factor is applied every update (the game's own attenuation). public readonly record struct Sound(string Key, Func Render, int Channel, Vector3? Position = null, float Gain = 1f, float Pitch = 1f, float Reference = ReferenceDistance, float Max = MaxDistance, Func? Falloff = null); /// A looping sound that follows something (a projectile in flight). Posted as the /// full set every frame: new keys start, missing keys stop. public readonly record struct LoopEmitter(object Key, Sound Sound, Vector3 Position, Vector3 Velocity); private readonly IAudioMix _mix; private readonly object _loopLock = new(); private List _loopsPosted = new(), _loopsWorking = new(); private readonly Dictionary _loopSlots = new(ReferenceEqualityComparer.Instance); private readonly HashSet _loopSeen = new(ReferenceEqualityComparer.Instance); private readonly List _loopGone = new(); private readonly ConcurrentQueue _queue = new(); private readonly ConcurrentQueue _stops = new(); private readonly Thread _thread; private readonly object _poseLock = new(); private volatile bool _running = true; private Vector3 _listenerPosition, _listenerForward = -Vector3.UnitZ, _listenerUp = Vector3.UnitY; private int _state; // AudioPauseState, written by the UI thread private ALContext? _alc; private AL? _al; private Device* _device; private Context* _context; private HrtfMode _hrtfApplied; private readonly uint[] _sources = new uint[SourceCount]; private readonly int[] _channel = new int[SourceCount]; private readonly float[] _gain = new float[SourceCount]; private readonly float[] _appliedGain = new float[SourceCount]; // last gain sent to AL (skip repeats) private readonly Func?[] _falloff = new Func?[SourceCount]; // null = OpenAL's curve private readonly Vector3[] _position = new Vector3[SourceCount]; private Vector3 _appliedPosition, _appliedForward; private readonly bool[] _pausedByUs = new bool[SourceCount]; private readonly Dictionary _buffers = new(); public static OpenAlAudio Start(IAudioMix mix) => Current = new OpenAlAudio(mix); private OpenAlAudio(IAudioMix mix) { _mix = mix; _thread = new Thread(Run) { IsBackground = true, Name = "audio" }; _thread.Start(); } public AudioPauseState State { set => Volatile.Write(ref _state, (int)value); } public void Play(in Sound sound) { if (_queue.Count < MaxQueued) _queue.Enqueue(sound); } /// Stops the one-shot sounds playing on (loops and streams keep going). Applied on /// the audio thread before any sound queued after this call starts, so stop-then-play cuts cleanly (a voice line /// replaced by the next one). public void StopChannel(int channel) => _stops.Enqueue(channel); /// Replaces the set of loop emitters (UI thread, every rendered frame). public void SetLoops(List loops) { lock (_loopLock) { _loopsPosted.Clear(); _loopsPosted.AddRange(loops); } } /// Listener = the camera (UI thread, every rendered frame). public void SetListener(Vector3 position, Vector3 forward, Vector3 up) { lock (_poseLock) (_listenerPosition, _listenerForward, _listenerUp) = (position, forward, up); } public void Dispose() { _running = false; _thread.Join(500); if (ReferenceEquals(Current, this)) Current = null; } private void Run() { try { Open(); } catch (Exception e) { Console.Error.WriteLine($"audio: disabled ({e.Message})"); Close(); } while (_running) { try { if (_al is not null) Update(); else _queue.Clear(); } catch (Exception e) { Console.Error.WriteLine($"audio: disabled after error ({e.Message})"); Close(); } Thread.Sleep(UpdateMs); } Close(); } private void Open() { _alc = ALContext.GetApi(soft: true); _al = AL.GetApi(soft: true); _device = _alc.OpenDevice(""); if (_device == null) throw new InvalidOperationException("no audio device"); CreateContext(_mix.Hrtf); } private void CreateContext(HrtfMode mode) { int hrtf = mode switch { HrtfMode.On => 1, HrtfMode.Off => 0, _ => AlcDontCareSoft }; int* attributes = stackalloc int[] { AlcHrtfSoft, hrtf, AlcFrequency, Pcm.SampleRate, 0 }; _context = _alc!.CreateContext(_device, attributes); if (_context == null) throw new InvalidOperationException("no audio context"); _alc.MakeContextCurrent(_context); _hrtfApplied = mode; var al = _al!; al.DistanceModel(DistanceModel.InverseDistanceClamped); fixed (uint* ids = _sources) al.GenSources(SourceCount, ids); foreach (uint s in _sources) { al.SetSourceProperty(s, SourceFloat.ReferenceDistance, ReferenceDistance); al.SetSourceProperty(s, SourceFloat.MaxDistance, MaxDistance); al.SetSourceProperty(s, SourceFloat.RolloffFactor, 1f); } Array.Clear(_pausedByUs); Array.Fill(_appliedGain, -1f); (_appliedPosition, _appliedForward) = (new Vector3(float.NaN), Vector3.Zero); InitReverb(); int status = -1; _alc.GetContextProperty(_device, (GetContextInteger)AlcHrtfStatusSoft, 1, &status); if (EngineDebug.Audio) EngineDebug.Write("audio", $"device '{_alc.GetContextProperty(_device, GetContextString.DeviceSpecifier)}' hrtf request {mode} status {status}"); } /// HRTF only changes on a new context; buffers belong to the device and stay. private void DestroyContext() { if (_context == null) return; ReleaseStreams(); ReleaseReverb(); fixed (uint* ids = _sources) _al!.DeleteSources(SourceCount, ids); _loopSlots.Clear(); _alc!.MakeContextCurrent(null); _alc.DestroyContext(_context); _context = null; } private void Update() { var al = _al!; if (_mix.Hrtf != _hrtfApplied) { DestroyContext(); CreateContext(_mix.Hrtf); } var state = (AudioPauseState)Volatile.Read(ref _state); Vector3 position, forward, up; lock (_poseLock) (position, forward, up) = (_listenerPosition, _listenerForward, _listenerUp); if (position != _appliedPosition || forward != _appliedForward) { al.SetListenerProperty(ListenerVector3.Position, position); float* orientation = stackalloc float[] { forward.X, forward.Y, forward.Z, up.X, up.Y, up.Z }; al.SetListenerProperty(ListenerFloatArray.Orientation, orientation); (_appliedPosition, _appliedForward) = (position, forward); } bool pauseEffects = _mix.WorldPaused(state); for (int i = 0; i < SourceCount; i++) { var s = SourceState(_sources[i]); if (s is not (Silk.NET.OpenAL.SourceState.Playing or Silk.NET.OpenAL.SourceState.Paused)) continue; SetGain(i, _gain[i] * Attenuation(i, position) * _mix.ChannelGain(_channel[i], state)); if (!_mix.Pausable(_channel[i])) continue; if (pauseEffects && s == Silk.NET.OpenAL.SourceState.Playing) { al.SourcePause(_sources[i]); _pausedByUs[i] = true; } else if (!pauseEffects && _pausedByUs[i]) { al.SourcePlay(_sources[i]); _pausedByUs[i] = false; } } while (_stops.TryDequeue(out int stop)) for (int i = 0; i < SourceCount; i++) if (_channel[i] == stop && !_streamSlot[i] && !_loopSlots.ContainsValue(i)) al.SourceStop(_sources[i]); while (_queue.TryDequeue(out var sound)) { if (pauseEffects && _mix.Pausable(sound.Channel)) continue; // a paused world makes no new sounds int slot = FreeSource(); if (slot < 0) break; Start(slot, sound, state); } UpdateLoops(state); UpdateStreams(state); UpdateReverb(); } private void UpdateLoops(AudioPauseState state) { var al = _al!; lock (_loopLock) (_loopsPosted, _loopsWorking) = (_loopsWorking, _loopsPosted); _loopSeen.Clear(); foreach (var loop in _loopsWorking) { _loopSeen.Add(loop.Key); if (!_loopSlots.TryGetValue(loop.Key, out int slot)) { if (_mix.WorldPaused(state) && _mix.Pausable(loop.Sound.Channel)) continue; slot = FreeSource(); if (slot < 0) continue; Start(slot, loop.Sound, state, looping: true); _loopSlots[loop.Key] = slot; } _gain[slot] = loop.Sound.Gain; // a fleeing critter gets louder and higher al.SetSourceProperty(_sources[slot], SourceFloat.Pitch, loop.Sound.Pitch); al.SetSourceProperty(_sources[slot], SourceVector3.Position, loop.Position); _position[slot] = loop.Position; al.SetSourceProperty(_sources[slot], SourceVector3.Velocity, loop.Velocity); // doppler } _loopGone.Clear(); foreach (var (key, slot) in _loopSlots) if (!_loopSeen.Contains(key)) { al.SourceStop(_sources[slot]); al.SetSourceProperty(_sources[slot], SourceBoolean.Looping, false); al.SetSourceProperty(_sources[slot], SourceVector3.Velocity, Vector3.Zero); _loopGone.Add(key); } foreach (var key in _loopGone) _loopSlots.Remove(key); } /// The slot's own falloff at the listener; 1 without one. private float Attenuation(int slot, Vector3 listener) => _falloff[slot] is { } f ? f(Vector3.Distance(_position[slot], listener)) : 1f; private void SetGain(int slot, float gain) { if (gain == _appliedGain[slot]) return; _al!.SetSourceProperty(_sources[slot], SourceFloat.Gain, gain); _appliedGain[slot] = gain; } private Silk.NET.OpenAL.SourceState SourceState(uint source) { _al!.GetSourceProperty(source, GetSourceInteger.SourceState, out int s); return (Silk.NET.OpenAL.SourceState)s; } private int FreeSource() { for (int i = 0; i < SourceCount; i++) { if (_streamSlot[i]) continue; // a starved stream stops, but its source stays taken var s = SourceState(_sources[i]); if (s is Silk.NET.OpenAL.SourceState.Initial or Silk.NET.OpenAL.SourceState.Stopped) return i; } return -1; } private void Start(int slot, in Sound sound, AudioPauseState state, bool looping = false) { var al = _al!; uint source = _sources[slot]; if (!_buffers.TryGetValue(sound.Key, out uint buffer)) { buffer = al.GenBuffer(); var clip = sound.Render(); al.BufferData(buffer, BufferFormat.Mono16, clip.Samples, clip.SampleRate); _buffers[sound.Key] = buffer; } _channel[slot] = sound.Channel; _gain[slot] = sound.Gain; _pausedByUs[slot] = false; al.SetSourceProperty(source, SourceInteger.Buffer, buffer); al.SetSourceProperty(source, SourceFloat.Pitch, sound.Pitch); al.SetSourceProperty(source, SourceFloat.ReferenceDistance, sound.Reference); al.SetSourceProperty(source, SourceFloat.MaxDistance, sound.Max); bool positioned = sound.Position is not null; _falloff[slot] = positioned ? sound.Falloff : null; _position[slot] = sound.Position ?? Vector3.Zero; al.SetSourceProperty(source, SourceFloat.RolloffFactor, _falloff[slot] is null ? 1f : 0f); _appliedGain[slot] = -1f; // force SetGain(slot, sound.Gain * Attenuation(slot, _appliedPosition) * _mix.ChannelGain(sound.Channel, state)); al.SetSourceProperty(source, SourceBoolean.SourceRelative, !positioned); var at = sound.Position ?? Vector3.Zero; al.SetSourceProperty(source, SourceVector3.Position, at); al.SetSourceProperty(source, SourceBoolean.Looping, looping); SendToReverb(source, positioned); al.SourcePlay(source); if (EngineDebug.Audio) EngineDebug.Write("audio", $"play {sound.Key} {_mix.ChannelName(sound.Channel)} {(positioned ? at.ToString("F1", null) : "2D")}"); } private void Close() { if (_al is not null && _alc is not null && _device != null) { if (_context != null) foreach (uint b in _buffers.Values) _al.DeleteBuffer(b); // AL calls need the context _buffers.Clear(); DestroyContext(); _alc.CloseDevice(_device); } _device = null; _al = null; } }