aboutsummaryrefslogtreecommitdiffziptar.gz
path: root/src/Godosa.Audio/OpenAlAudio.cs
diff options
context:
space:
mode:
authorgodosa <godosa@godosa.eu>2026-10-06 23:39:36 +0200
committergodosa <godosa@godosa.eu>2026-10-06 23:39:36 +0200
commit39066900773e7857faf2d02e7ed51b71d219d97d (patch)
treeffab8e4ddd971626776c3b1f4ce1da2c186c2187 /src/Godosa.Audio/OpenAlAudio.cs
downloadgodosa-engine-39066900773e7857faf2d02e7ed51b71d219d97d.tar.gz
godosa-engine-39066900773e7857faf2d02e7ed51b71d219d97d.zip
godosa-engine: initial public history
Diffstat (limited to 'src/Godosa.Audio/OpenAlAudio.cs')
-rw-r--r--src/Godosa.Audio/OpenAlAudio.cs360
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;
+ }
+}