aboutsummaryrefslogtreecommitdiffziptar.gz
path: root/src/Godosa.Audio
diff options
context:
space:
mode:
Diffstat (limited to 'src/Godosa.Audio')
-rw-r--r--src/Godosa.Audio/Godosa.Audio.csproj18
-rw-r--r--src/Godosa.Audio/OpenAlAudio.Reverb.cs83
-rw-r--r--src/Godosa.Audio/OpenAlAudio.Streams.cs119
-rw-r--r--src/Godosa.Audio/OpenAlAudio.cs360
4 files changed, 580 insertions, 0 deletions
diff --git a/src/Godosa.Audio/Godosa.Audio.csproj b/src/Godosa.Audio/Godosa.Audio.csproj
new file mode 100644
index 0000000..a478730
--- /dev/null
+++ b/src/Godosa.Audio/Godosa.Audio.csproj
@@ -0,0 +1,18 @@
+<Project Sdk="Microsoft.NET.Sdk">
+ <PropertyGroup>
+ <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
+ </PropertyGroup>
+ <ItemGroup>
+ <Using Include="Godosa.Core.Audio" />
+ <Using Include="Godosa.Core.Diagnostics" />
+ </ItemGroup>
+ <ItemGroup>
+ <PackageReference Include="Silk.NET.OpenAL" />
+ <PackageReference Include="Silk.NET.OpenAL.Extensions.Creative" />
+ <PackageReference Include="Silk.NET.OpenAL.Extensions.Soft" />
+ <PackageReference Include="Silk.NET.OpenAL.Soft.Native" />
+ </ItemGroup>
+ <ItemGroup>
+ <ProjectReference Include="../Godosa.Core/Godosa.Core.csproj" />
+ </ItemGroup>
+</Project>
diff --git a/src/Godosa.Audio/OpenAlAudio.Reverb.cs b/src/Godosa.Audio/OpenAlAudio.Reverb.cs
new file mode 100644
index 0000000..b93e74a
--- /dev/null
+++ b/src/Godosa.Audio/OpenAlAudio.Reverb.cs
@@ -0,0 +1,83 @@
+using Silk.NET.OpenAL.Extensions.Creative;
+
+namespace Godosa.Audio;
+
+/// <summary>One EFX reverb for the region (audio spec §2): positioned sources send to it, 2D
+/// ones (beds, music, interface) don't. Air absorption dulls distant sounds.</summary>
+public sealed unsafe partial class OpenAlAudio
+{
+ private EffectExtension? _efx;
+ private uint _effect, _effectSlot;
+ private ReverbPreset? _reverbApplied;
+ private ReverbPreset? _reverbWanted;
+ private bool _reverbInitialised;
+
+ /// <summary>The current region's reverb, or null for none (UI thread).</summary>
+ public ReverbPreset? Reverb
+ {
+ set { lock (_poseLock) _reverbWanted = value; }
+ }
+
+ private void InitReverb()
+ {
+ if (!_al!.TryGetExtension(out _efx)) _efx = null;
+ if (_efx is null)
+ {
+ if (EngineDebug.Audio) EngineDebug.Write("audio", "no EFX: no reverb");
+ return;
+ }
+ _effect = _efx.GenEffects(1)[0];
+ _efx.SetEffectProperty(_effect, EffectInteger.EffectType, (int)EffectType.Reverb);
+ _effectSlot = _efx.GenAuxiliaryEffectSlots(1)[0];
+ _reverbApplied = null;
+ _reverbInitialised = false;
+ }
+
+ private void UpdateReverb()
+ {
+ if (_efx is null) return;
+ ReverbPreset? wanted;
+ lock (_poseLock) wanted = _reverbWanted;
+ if (_reverbInitialised && _reverbApplied == wanted) return;
+ _reverbInitialised = true;
+ _reverbApplied = wanted;
+ if (wanted is not { } p)
+ {
+ // No effect in the slot: sends to it are dropped and the mixer runs no reverb.
+ _efx.SetAuxiliaryEffectSlotProperty(_effectSlot, EffectSlotInteger.Effect, 0);
+ if (EngineDebug.Audio) EngineDebug.Write("audio", "reverb off");
+ return;
+ }
+ _efx.SetEffectProperty(_effect, EffectFloat.ReverbDensity, p.Density);
+ _efx.SetEffectProperty(_effect, EffectFloat.ReverbDiffusion, p.Diffusion);
+ _efx.SetEffectProperty(_effect, EffectFloat.ReverbGain, p.Gain);
+ _efx.SetEffectProperty(_effect, EffectFloat.ReverbGainHF, p.GainHF);
+ _efx.SetEffectProperty(_effect, EffectFloat.ReverbDecayTime, p.DecayTime);
+ _efx.SetEffectProperty(_effect, EffectFloat.ReverbDecayHFRatio, p.DecayHFRatio);
+ _efx.SetEffectProperty(_effect, EffectFloat.ReverbReflectionsGain, p.ReflectionsGain);
+ _efx.SetEffectProperty(_effect, EffectFloat.ReverbReflectionsDelay, p.ReflectionsDelay);
+ _efx.SetEffectProperty(_effect, EffectFloat.ReverbLateReverbGain, p.LateReverbGain);
+ _efx.SetEffectProperty(_effect, EffectFloat.ReverbLateReverbDelay, p.LateReverbDelay);
+ _efx.SetEffectProperty(_effect, EffectFloat.ReverbAirAbsorptionGainHF, p.AirAbsorptionGainHF);
+ _efx.SetEffectProperty(_effect, EffectFloat.ReverbRoomRolloffFactor, p.RoomRolloffFactor);
+ _efx.SetEffectProperty(_effect, EffectInteger.ReverbDecayHFLimit, p.DecayHFLimit ? 1 : 0);
+ _efx.SetAuxiliaryEffectSlotProperty(_effectSlot, EffectSlotInteger.Effect, (int)_effect); // re-attach applies the change
+ if (EngineDebug.Audio) EngineDebug.Write("audio", $"reverb {(p == ReverbPreset.Forest ? "forest" : p == ReverbPreset.Cave ? "cave" : "plain")}");
+ }
+
+ private void SendToReverb(uint source, bool positioned)
+ {
+ if (_efx is null) return;
+ _efx.SetSourceProperty(source, EFXSourceInteger3.AuxiliarySendFilter, positioned ? (int)_effectSlot : 0, 0, 0);
+ _efx.SetSourceProperty(source, EFXSourceFloat.AirAbsorptionFactor, positioned ? 1f : 0f);
+ }
+
+ private void ReleaseReverb()
+ {
+ if (_efx is null) return;
+ _efx.SetAuxiliaryEffectSlotProperty(_effectSlot, EffectSlotInteger.Effect, 0);
+ _efx.DeleteAuxiliaryEffectSlot(_effectSlot);
+ _efx.DeleteEffect(_effect);
+ _efx = null;
+ }
+}
diff --git a/src/Godosa.Audio/OpenAlAudio.Streams.cs b/src/Godosa.Audio/OpenAlAudio.Streams.cs
new file mode 100644
index 0000000..435c058
--- /dev/null
+++ b/src/Godosa.Audio/OpenAlAudio.Streams.cs
@@ -0,0 +1,119 @@
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Threading;
+using Silk.NET.OpenAL;
+
+namespace Godosa.Audio;
+
+/// <summary>Endless stereo streams (ambience beds, music — audio spec §5–§6): each holds one
+/// source with a queue of buffers the audio thread refills from its generator.</summary>
+public sealed unsafe partial class OpenAlAudio
+{
+ private const int StreamBuffers = 4, StreamFrames = 2048; // ~190 ms queued
+
+ public sealed class StreamHandle
+ {
+ internal StreamHandle(IStereoStream source, int channel) => (Source, Channel) = (source, channel);
+ internal IStereoStream Source { get; }
+ internal int Channel { get; }
+ internal int Slot = -1;
+ internal uint[] Buffers = System.Array.Empty<uint>();
+ private int _gainBits = System.BitConverter.SingleToInt32Bits(1f); // written by the UI thread
+
+ public float Gain
+ {
+ get => System.BitConverter.Int32BitsToSingle(Volatile.Read(ref _gainBits));
+ set => Volatile.Write(ref _gainBits, System.BitConverter.SingleToInt32Bits(value));
+ }
+ }
+
+ private readonly bool[] _streamSlot = new bool[SourceCount];
+ private readonly List<StreamHandle> _streams = new(); // audio thread only
+ private readonly ConcurrentQueue<(StreamHandle Stream, bool Add)> _streamCommands = new();
+ private readonly float[] _streamMix = new float[StreamFrames * 2];
+ private readonly short[] _streamPcm = new short[StreamFrames * 2];
+
+ public StreamHandle AddStream(IStereoStream source, int channel, float gain = 1f)
+ {
+ var handle = new StreamHandle(source, channel) { Gain = gain };
+ _streamCommands.Enqueue((handle, true));
+ return handle;
+ }
+
+ public void RemoveStream(StreamHandle handle) => _streamCommands.Enqueue((handle, false));
+
+ private void UpdateStreams(AudioPauseState state)
+ {
+ var al = _al!;
+ while (_streamCommands.TryDequeue(out var command))
+ {
+ if (command.Add) _streams.Add(command.Stream);
+ else if (_streams.Remove(command.Stream)) Release(command.Stream);
+ }
+ foreach (var s in _streams)
+ {
+ if (s.Slot < 0 && !Claim(s)) continue;
+ uint source = _sources[s.Slot];
+ _gain[s.Slot] = s.Gain;
+ SetGain(s.Slot, s.Gain * _mix.ChannelGain(s.Channel, state));
+ al.GetSourceProperty(source, GetSourceInteger.BuffersProcessed, out int processed);
+ for (int i = 0; i < processed; i++)
+ {
+ uint buffer;
+ al.SourceUnqueueBuffers(source, 1, &buffer);
+ Fill(s, buffer);
+ al.SourceQueueBuffers(source, 1, &buffer);
+ }
+ if (SourceState(source) != Silk.NET.OpenAL.SourceState.Playing) al.SourcePlay(source); // start, or recover from a starve
+ }
+ }
+
+ private bool Claim(StreamHandle s)
+ {
+ var al = _al!;
+ int slot = FreeSource();
+ if (slot < 0) return false;
+ s.Slot = slot;
+ _streamSlot[slot] = true;
+ _channel[slot] = s.Channel;
+ _appliedGain[slot] = -1f;
+ _pausedByUs[slot] = false;
+ uint source = _sources[slot];
+ al.SetSourceProperty(source, SourceInteger.Buffer, 0);
+ al.SetSourceProperty(source, SourceBoolean.Looping, false);
+ al.SetSourceProperty(source, SourceBoolean.SourceRelative, true);
+ al.SetSourceProperty(source, SourceVector3.Position, System.Numerics.Vector3.Zero);
+ al.SetSourceProperty(source, SourceFloat.Pitch, 1f);
+ SendToReverb(source, positioned: false);
+ s.Buffers = al.GenBuffers(StreamBuffers);
+ foreach (uint b in s.Buffers) Fill(s, b);
+ fixed (uint* ids = s.Buffers) al.SourceQueueBuffers(source, StreamBuffers, ids);
+ if (EngineDebug.Audio) EngineDebug.Write("audio", $"stream {s.Source.GetType().Name} {s.Channel} on source {slot}");
+ return true;
+ }
+
+ private void Fill(StreamHandle s, uint buffer)
+ {
+ s.Source.Render(_streamMix, StreamFrames);
+ for (int i = 0; i < _streamMix.Length; i++) _streamPcm[i] = Pcm.ToShort(_streamMix[i]);
+ _al!.BufferData(buffer, BufferFormat.Stereo16, _streamPcm, Pcm.SampleRate);
+ }
+
+ private void Release(StreamHandle s)
+ {
+ if (s.Slot < 0) return;
+ var al = _al!;
+ uint source = _sources[s.Slot];
+ al.SourceStop(source);
+ al.SetSourceProperty(source, SourceInteger.Buffer, 0); // detaches the queue
+ al.DeleteBuffers(s.Buffers);
+ _streamSlot[s.Slot] = false;
+ s.Slot = -1;
+ }
+
+ /// <summary>Before the context goes (HRTF change): streams re-claim a source afterwards.</summary>
+ private void ReleaseStreams()
+ {
+ foreach (var s in _streams) Release(s);
+ }
+}
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;
+ }
+}