1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
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;
}
}
|