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;
}
}