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>
375 lines
14 KiB
C#
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;
|
|
}
|
|
}
|