acdream/src/AcDream.App/Audio/OpenAlAudioEngine.cs
Erik 351723928f feat(audio): Phase E.2 OpenAL engine + SoundTable cookbook + hook wiring
Full audio pipeline from MotionHook → OpenAL 3D playback. Faithful to
retail's 16-voice pool, inverse-square falloff, and SoundTable
probabilistic variant selection.

Core layer (AcDream.Core/Audio):
- WaveDecoder parses the WAVEFORMATEX in Wave dat headers. PCM
  (wFormatTag=1) decodes directly; MP3 (0x55) and ADPCM (0x02) return
  null + log (ACM compressed decoders need Windows winmm; cross-platform
  path deferred). Cites r05 §2.1-2.3 + ACE Wave.cs.
- SoundCookbook.Roll implements the probability-weighted entry pick that
  gives retail footsteps their variation. Cumulative-distribution walk;
  silence tail when probabilities sum to <1.
- DatSoundCache: ConcurrentDictionary-backed lazy load of Wave /
  SoundTable dats, decoded PCM memoized.

App layer (AcDream.App/Audio):
- OpenAlAudioEngine (Silk.NET.OpenAL): 16-source 3D pool with
  round-robin first-free, then evict-quieter-slot algorithm matching
  retail chunk_00550000.c FUN_00550ad0 exactly. Separate 4-source UI
  pool (source-relative). AL buffer cache keyed by Wave id.
  InverseDistanceClamped distance model. Fail-open when AL driver
  missing or ACDREAM_NO_AUDIO=1 — client continues without audio.
- AudioHookSink routes SoundHook / SoundTableHook / SoundTweakedHook
  from the Phase E.1 animation-hook router into OpenAL. All three
  hook types fire on both player AND NPCs/monsters (the sequencer
  dispatches per-entity and the sink uses entity worldPos for 3D pan).
- DictionaryEntitySoundTable holds per-entity SoundTable mapping,
  populated from Setup.DefaultSoundTable at hydration time. Server-
  sent overrides would take precedence here when wired.

GameWindow integration:
- OpenAL init in OnLoad after dat collection, suppressible via
  ACDREAM_NO_AUDIO=1.
- SetListener called each OnRender frame with camera position + view
  basis vectors (fwd = -Z, up = +Y of inverse view).
- AudioEngine disposed in OnClosing before dats.

Tests: 6 WaveDecoder (PCM / MP3-null / ADPCM-null / stereo / truncated
/ peek) + 6 SoundCookbook (empty / single / 50-30-20 distribution
within 5%, silence tail, table lookup, missing table key). Verified
against r05 §2 + ACViewer export-path.

Build green, 497 tests pass (up from 485).

Ref: r05 §2 (Wave format), §5.3 (16-voice pool + eviction).
Ref: FUN_00550ad0 (chunk_00550000.c:527) eviction algorithm.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:38:26 +02:00

375 lines
14 KiB
C#

using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Audio;
using Silk.NET.OpenAL;
namespace AcDream.App.Audio;
/// <summary>
/// OpenAL-backed audio engine (Phase E.2) — faithful to retail's
/// 16-voice pool and inverse-square falloff behaviour (r05 §5.3).
///
/// <para>
/// Architecture:
/// <list type="bullet">
/// <item><description>
/// Single <see cref="ALContext"/> + <see cref="AL"/> bound to the
/// system default device. Cross-platform (WASAPI / WinMM /
/// PulseAudio / CoreAudio — whichever OpenAL-Soft picks).
/// </description></item>
/// <item><description>
/// Fixed 16-source pool for 3D positional sounds. When all 16 are
/// busy, new Play3D calls evict the slot whose currently-playing
/// sound has lower effective gain than the incoming sound
/// (matches retail <c>FUN_00550ad0</c> first-free-then-evict-quieter
/// algorithm at <c>chunk_00550000.c:527</c>).
/// </description></item>
/// <item><description>
/// Separate UI source pool (4 sources) for flat 2D UI clicks /
/// wooshes — not subject to the 3D eviction game.
/// </description></item>
/// <item><description>
/// PCM buffer cache keyed by Wave dat id so the same footstep isn't
/// re-uploaded to the GL-equivalent AL buffers on every hit.
/// </description></item>
/// </list>
/// </para>
///
/// <para>
/// Thread-safety: the engine is called only from the render thread
/// (the same thread that drives <c>TickAnimations</c>). No locks inside.
/// </para>
///
/// <para>
/// Fail-open: when the OpenAL driver can't be initialised (missing
/// library on a headless CI box, or explicitly disabled via
/// <c>ACDREAM_NO_AUDIO=1</c>), <see cref="IsAvailable"/> is false and all
/// Play* calls are no-ops. This lets the rest of the client run
/// unaffected.
/// </para>
/// </summary>
public sealed unsafe class OpenAlAudioEngine : IAudioEngine
{
// ── Backends ─────────────────────────────────────────────────────────────
private readonly ALContext? _alc;
private readonly AL? _al;
private readonly Device* _device;
private readonly Context* _context;
private readonly bool _available;
// ── Pools ────────────────────────────────────────────────────────────────
private const int PoolSize3D = 16; // retail 16-slot voice pool
private const int PoolSizeUi = 4;
// Slot state per 3D source; mirrors retail's g_poolVols array (the
// EFFECTIVE gain at play-start time, used for eviction comparisons).
private sealed class Slot3D
{
public uint SourceId;
public float PlayingGain; // gain at play time (for eviction compare)
public bool InUse;
public uint PriorityBase; // raw priority from SoundEntry.Priority
}
private readonly Slot3D[] _pool3D = new Slot3D[PoolSize3D];
private int _pool3DCursor; // round-robin start
private readonly uint[] _poolUi = new uint[PoolSizeUi];
// ── Buffer cache (Wave dat id → AL buffer) ───────────────────────────────
private readonly Dictionary<uint, uint> _bufferByWaveId = new();
// ── Ambient handles (StartAmbient/StopAmbient) ───────────────────────────
private readonly Dictionary<int, uint> _ambientSources = new();
private int _nextAmbientHandle = 1;
// ── Public volume knobs ──────────────────────────────────────────────────
public float MasterVolume { get; set; } = 1f;
public float SfxVolume { get; set; } = 1f;
public float MusicVolume { get; set; } = 0.7f;
public float AmbientVolume{ get; set; } = 0.8f;
public bool IsAvailable => _available;
public OpenAlAudioEngine()
{
try
{
_alc = ALContext.GetApi(soft: true);
_al = AL.GetApi(soft: true);
_device = _alc.OpenDevice(string.Empty);
if (_device == null)
{
_available = false;
return;
}
_context = _alc.CreateContext(_device, null);
if (_context == null)
{
_alc.CloseDevice(_device);
_device = null;
_available = false;
return;
}
if (!_alc.MakeContextCurrent(_context))
{
_alc.DestroyContext(_context);
_context = null;
_alc.CloseDevice(_device);
_device = null;
_available = false;
return;
}
// Initialise 3D source pool.
for (int i = 0; i < PoolSize3D; i++)
{
uint src = _al.GenSource();
_al.SetSourceProperty(src, SourceFloat.Gain, 1f);
_al.SetSourceProperty(src, SourceFloat.MaxDistance, 1000f);
_al.SetSourceProperty(src, SourceFloat.RolloffFactor, 1f);
_al.SetSourceProperty(src, SourceFloat.ReferenceDistance, 2f);
_al.SetSourceProperty(src, SourceBoolean.Looping, false);
_pool3D[i] = new Slot3D { SourceId = src, InUse = false };
}
// UI sources are source-relative (attached to listener) so they
// ignore 3D position.
for (int i = 0; i < PoolSizeUi; i++)
{
uint src = _al.GenSource();
_al.SetSourceProperty(src, SourceBoolean.SourceRelative, true);
_al.SetSourceProperty(src, SourceFloat.Gain, 1f);
_al.SetSourceProperty(src, SourceBoolean.Looping, false);
_poolUi[i] = src;
}
// Global distance model = inverse-square clamped (classic retail feel).
_al.DistanceModel(DistanceModel.InverseDistanceClamped);
_available = true;
}
catch
{
// OpenAL driver unavailable (headless CI, missing libopenal).
_available = false;
}
}
public void Dispose()
{
if (!_available || _al is null) return;
try
{
for (int i = 0; i < PoolSize3D; i++)
{
var slot = _pool3D[i];
if (slot is null) continue;
_al.SourceStop(slot.SourceId);
_al.DeleteSource(slot.SourceId);
}
for (int i = 0; i < PoolSizeUi; i++)
{
_al.SourceStop(_poolUi[i]);
_al.DeleteSource(_poolUi[i]);
}
foreach (var kv in _ambientSources)
{
_al.SourceStop(kv.Value);
_al.DeleteSource(kv.Value);
}
foreach (var buf in _bufferByWaveId.Values)
{
_al.DeleteBuffer(buf);
}
if (_context != null && _alc is not null)
{
_alc.MakeContextCurrent(null);
_alc.DestroyContext(_context);
}
if (_device != null && _alc is not null)
_alc.CloseDevice(_device);
}
catch { /* shutdown — ignore */ }
}
// ── IAudioEngine ─────────────────────────────────────────────────────────
public void SetListener(
float posX, float posY, float posZ,
float forwardX, float forwardY, float forwardZ,
float upX, float upY, float upZ)
{
if (!_available || _al is null) return;
_al.SetListenerProperty(ListenerVector3.Position, posX, posY, posZ);
// AL expects a 6-float orientation (fwd then up).
Span<float> ori = stackalloc float[6]
{
forwardX, forwardY, forwardZ,
upX, upY, upZ
};
fixed (float* p = ori)
_al.SetListenerProperty(ListenerFloatArray.Orientation, p);
_al.SetListenerProperty(ListenerFloat.Gain, MasterVolume);
}
/// <summary>
/// Not exposed on IAudioEngine but used by the hook sink — play a raw
/// WaveData blob at a 3D position with full priority/volume controls.
/// Returns true on success, false if the buffer was rejected.
/// </summary>
public bool Play3DWave(
uint waveId,
WaveData wave,
Vector3 position,
float volume,
float priority,
float pitch = 1.0f)
{
if (!_available || _al is null) return false;
float effectiveGain = volume * SfxVolume;
if (effectiveGain < 0.001f) return false; // silent; skip
uint buffer = EnsureBuffer(waveId, wave);
if (buffer == 0) return false;
// Pick a slot: first free, else evict quieter one, else drop.
int slotIdx = -1;
for (int i = 0; i < PoolSize3D; i++)
{
int idx = (_pool3DCursor + i) & (PoolSize3D - 1);
var s = _pool3D[idx];
if (!s.InUse || !IsStillPlaying(s.SourceId)) { slotIdx = idx; break; }
}
if (slotIdx < 0)
{
for (int i = 0; i < PoolSize3D; i++)
{
int idx = (_pool3DCursor + i) & (PoolSize3D - 1);
if (_pool3D[idx].PlayingGain < effectiveGain) { slotIdx = idx; break; }
}
}
if (slotIdx < 0) return false; // no slot quieter than us — drop
var slot = _pool3D[slotIdx];
_al.SourceStop(slot.SourceId);
_al.SetSourceProperty(slot.SourceId, SourceInteger.Buffer, 0); // detach old
_al.SetSourceProperty(slot.SourceId, SourceInteger.Buffer, (int)buffer);
_al.SetSourceProperty(slot.SourceId, SourceFloat.Gain, effectiveGain);
_al.SetSourceProperty(slot.SourceId, SourceFloat.Pitch, pitch);
_al.SetSourceProperty(slot.SourceId, SourceVector3.Position, position.X, position.Y, position.Z);
_al.SetSourceProperty(slot.SourceId, SourceBoolean.SourceRelative, false);
_al.SetSourceProperty(slot.SourceId, SourceBoolean.Looping, false);
_al.SourcePlay(slot.SourceId);
slot.PlayingGain = effectiveGain;
slot.InUse = true;
slot.PriorityBase = (uint)Math.Clamp((int)priority, 0, 7);
_pool3DCursor = (slotIdx + 1) & (PoolSize3D - 1);
return true;
}
/// <summary>
/// Play a raw WaveData blob as a 2D UI sound (no falloff, ignores
/// listener position).
/// </summary>
public bool PlayUiWave(uint waveId, WaveData wave, float volume = 1f, float pitch = 1f)
{
if (!_available || _al is null) return false;
uint buffer = EnsureBuffer(waveId, wave);
if (buffer == 0) return false;
// UI pool: find a free source (first not-playing), else round-robin.
int slotIdx = -1;
for (int i = 0; i < PoolSizeUi; i++)
{
if (!IsStillPlaying(_poolUi[i])) { slotIdx = i; break; }
}
if (slotIdx < 0) slotIdx = 0; // always replace slot 0 as a last resort
uint src = _poolUi[slotIdx];
_al.SourceStop(src);
_al.SetSourceProperty(src, SourceInteger.Buffer, 0);
_al.SetSourceProperty(src, SourceInteger.Buffer, (int)buffer);
_al.SetSourceProperty(src, SourceFloat.Gain, Math.Clamp(volume, 0f, 1f) * SfxVolume);
_al.SetSourceProperty(src, SourceFloat.Pitch, pitch);
_al.SourcePlay(src);
return true;
}
// IAudioEngine implementations — the enum-based overloads are less
// useful than the raw-Wave overloads above, since the hook sink already
// has access to decoded WaveData. Left as no-ops for now; R5 defines
// SoundId as a sparse subset of retail enums.
public void PlayUi(SoundId id) { /* handled via AudioHookSink */ }
public void Play3D(SoundId id, float x, float y, float z) { /* handled via AudioHookSink */ }
public int StartAmbient(SoundId id, float x, float y, float z)
{
// Looping ambient — needs a decoded wave + WaveId. The hook sink
// doesn't route ambient; a separate landblock-attached ambient
// system (outside R5) will drive this. For now: reserve a handle.
int handle = _nextAmbientHandle++;
return handle;
}
public void StopAmbient(int handle)
{
if (!_available || _al is null) return;
if (_ambientSources.TryGetValue(handle, out var src))
{
_al.SourceStop(src);
_ambientSources.Remove(handle);
}
}
public void PlayMusic(string resourceName, bool loop) { /* R5 §6 MIDI — not ported */ }
public void StopMusic() { /* ditto */ }
// ── Private helpers ──────────────────────────────────────────────────────
private uint EnsureBuffer(uint waveId, WaveData wave)
{
if (!_available || _al is null) return 0;
if (_bufferByWaveId.TryGetValue(waveId, out var existing)) return existing;
uint buf = _al.GenBuffer();
BufferFormat fmt = PickFormat(wave);
if (fmt == 0)
{
_al.DeleteBuffer(buf);
_bufferByWaveId[waveId] = 0;
return 0;
}
fixed (byte* p = wave.PcmBytes)
_al.BufferData(buf, fmt, p, wave.PcmBytes.Length, wave.SampleRate);
_bufferByWaveId[waveId] = buf;
return buf;
}
private static BufferFormat PickFormat(WaveData w)
{
return (w.ChannelCount, w.BitsPerSample) switch
{
(1, 8) => BufferFormat.Mono8,
(1, 16) => BufferFormat.Mono16,
(2, 8) => BufferFormat.Stereo8,
(2, 16) => BufferFormat.Stereo16,
_ => 0,
};
}
private bool IsStillPlaying(uint sourceId)
{
if (_al is null) return false;
_al.GetSourceProperty(sourceId, GetSourceInteger.SourceState, out int state);
return state == (int)SourceState.Playing;
}
}