diff options
Diffstat (limited to 'src/Godosa.Audio/OpenAlAudio.cs')
| -rw-r--r-- | src/Godosa.Audio/OpenAlAudio.cs | 360 |
1 files changed, 360 insertions, 0 deletions
diff --git a/src/Godosa.Audio/OpenAlAudio.cs b/src/Godosa.Audio/OpenAlAudio.cs new file mode 100644 index 0000000..7a0d59b --- /dev/null +++ b/src/Godosa.Audio/OpenAlAudio.cs @@ -0,0 +1,360 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Numerics; +using System.Threading; +using Silk.NET.OpenAL; + +namespace Godosa.Audio; + +/// <summary> +/// 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 +/// <see cref="IAudioMix"/>. No device or any AL failure → silent (logged, never throws). +/// </summary> +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; } + + /// <summary>A one-shot sound. <paramref name="Render"/> runs once per <paramref name="Key"/> + /// (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). + /// <paramref name="Falloff"/> (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).</summary> + public readonly record struct Sound(string Key, Func<PcmClip> Render, int Channel, + Vector3? Position = null, float Gain = 1f, float Pitch = 1f, float Reference = ReferenceDistance, float Max = MaxDistance, + Func<float, float>? Falloff = null); + + /// <summary>A looping sound that follows something (a projectile in flight). Posted as the + /// full set every frame: new keys start, missing keys stop.</summary> + public readonly record struct LoopEmitter(object Key, Sound Sound, Vector3 Position, Vector3 Velocity); + + private readonly IAudioMix _mix; + private readonly object _loopLock = new(); + private List<LoopEmitter> _loopsPosted = new(), _loopsWorking = new(); + private readonly Dictionary<object, int> _loopSlots = new(ReferenceEqualityComparer.Instance); + private readonly HashSet<object> _loopSeen = new(ReferenceEqualityComparer.Instance); + private readonly List<object> _loopGone = new(); + private readonly ConcurrentQueue<Sound> _queue = new(); + private readonly ConcurrentQueue<int> _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<float, float>?[] _falloff = new Func<float, float>?[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<string, uint> _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); + } + + /// <summary>Stops the one-shot sounds playing on <paramref name="channel"/> (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).</summary> + public void StopChannel(int channel) => _stops.Enqueue(channel); + + /// <summary>Replaces the set of loop emitters (UI thread, every rendered frame).</summary> + public void SetLoops(List<LoopEmitter> loops) + { + lock (_loopLock) + { + _loopsPosted.Clear(); + _loopsPosted.AddRange(loops); + } + } + + /// <summary>Listener = the camera (UI thread, every rendered frame).</summary> + 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}"); + } + + /// <summary>HRTF only changes on a new context; buffers belong to the device and stay.</summary> + 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); + } + + /// <summary>The slot's own falloff at the listener; 1 without one.</summary> + 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; + } +} |
