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>
This commit is contained in:
Erik 2026-04-18 16:38:26 +02:00
parent b04d393329
commit 351723928f
9 changed files with 1072 additions and 0 deletions

View file

@ -0,0 +1,78 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
namespace AcDream.Core.Audio;
/// <summary>
/// DatCollection-backed cache of decoded waves + SoundTable lookups.
///
/// <para>
/// Lazy-loads each Wave / SoundTable on first request, decodes Wave PCM
/// data via <see cref="WaveDecoder"/>, and memoizes the result. Safe to
/// call from any thread — the inner dicts are
/// <see cref="ConcurrentDictionary{TKey,TValue}"/>.
/// </para>
///
/// <para>
/// Returns <c>null</c> for waves whose format tag isn't PCM (MP3 / ADPCM
/// fall through silently until a compressed-decoder path is wired up).
/// </para>
/// </summary>
public sealed class DatSoundCache
{
private readonly DatCollection _dats;
private readonly ConcurrentDictionary<uint, WaveData?> _waves = new();
private readonly ConcurrentDictionary<uint, SoundTable?> _tables = new();
public DatSoundCache(DatCollection dats)
{
_dats = dats;
}
/// <summary>
/// Retrieve decoded PCM data for a Wave dat id. Returns null if the
/// wave is missing, malformed, or uses a currently-unsupported format
/// (MP3 / ADPCM).
/// </summary>
public WaveData? GetWave(uint waveId)
{
if (_waves.TryGetValue(waveId, out var cached)) return cached;
var wave = _dats.Get<Wave>(waveId);
if (wave is null)
{
_waves[waveId] = null;
return null;
}
var decoded = WaveDecoder.Decode(wave.Header, wave.Data);
_waves[waveId] = decoded;
return decoded;
}
/// <summary>
/// Retrieve a SoundTable by dat id. Returns null if the table is
/// missing from the dats.
/// </summary>
public SoundTable? GetSoundTable(uint soundTableId)
{
if (_tables.TryGetValue(soundTableId, out var cached)) return cached;
var table = _dats.Get<SoundTable>(soundTableId);
_tables[soundTableId] = table;
return table;
}
/// <summary>
/// Total number of waves that have been accessed (hit or miss).
/// Useful for diagnostic overlays.
/// </summary>
public int CachedWaveCount => _waves.Count;
/// <summary>
/// Total number of SoundTables that have been accessed.
/// </summary>
public int CachedSoundTableCount => _tables.Count;
}

View file

@ -0,0 +1,83 @@
using System;
using System.Collections.Generic;
using DatReaderWriter.DBObjs;
using DRWSound = DatReaderWriter.Enums.Sound;
namespace AcDream.Core.Audio;
/// <summary>
/// Probabilistic entry picker over a retail <see cref="SoundTable"/>.
///
/// <para>
/// Each <see cref="DatReaderWriter.Enums.Sound"/> key in
/// <c>SoundTable.Sounds</c> maps to a list of
/// <see cref="DatReaderWriter.Types.SoundEntry"/> items each carrying a
/// probability weight. Retail picks one entry per trigger by rolling the
/// cumulative distribution — that's how footsteps sound slightly
/// different each step, how weapon swings have 3 swoosh variants, etc.
/// </para>
///
/// <para>
/// r05 §4: the picker samples a uniform random in [0,1) and walks the
/// entries accumulating probabilities; the first entry whose running total
/// exceeds the sample wins. If all probabilities sum to &lt; 1, the
/// remaining mass means "silence" — the call returns null. If probabilities
/// sum to &gt; 1 the picker still works correctly (it clamps on the last
/// entry).
/// </para>
/// </summary>
public static class SoundCookbook
{
/// <summary>
/// Pick one entry from a sound's variant list, weighted by probability.
/// Returns <c>null</c> when the rolled sample falls into the "silence"
/// remainder of the distribution (probability sum &lt; 1).
/// </summary>
public static DatReaderWriter.Types.SoundEntry? Roll(
IReadOnlyList<DatReaderWriter.Types.SoundEntry> entries,
Random rng)
{
ArgumentNullException.ThrowIfNull(entries);
ArgumentNullException.ThrowIfNull(rng);
if (entries.Count == 0) return null;
if (entries.Count == 1) return entries[0];
float sample = (float)rng.NextDouble();
float cum = 0f;
for (int i = 0; i < entries.Count; i++)
{
cum += Math.Max(0f, entries[i].Probability);
if (sample < cum) return entries[i];
}
// Fell past the last entry — either probabilities sum to >1 (return
// last) or < 1 and we rolled into the "silence" tail (return null).
float total = 0f;
for (int i = 0; i < entries.Count; i++)
total += Math.Max(0f, entries[i].Probability);
return total > 0.999f ? entries[entries.Count - 1] : null;
}
/// <summary>
/// Convenience lookup: given a SoundTable + a retail
/// <see cref="DRWSound"/> key (e.g. Swoosh1, Footstep1), roll the
/// entry list and return the winning entry. Returns null if:
/// <list type="bullet">
/// <item><description><paramref name="table"/> has no mapping for
/// <paramref name="sound"/>.</description></item>
/// <item><description>The mapping's entry list is empty.</description></item>
/// <item><description>The probability roll hits the silence
/// tail.</description></item>
/// </list>
/// </summary>
public static DatReaderWriter.Types.SoundEntry? Roll(
SoundTable table,
DRWSound sound,
Random rng)
{
ArgumentNullException.ThrowIfNull(table);
if (!table.Sounds.TryGetValue(sound, out var soundData)) return null;
return Roll(soundData.Entries, rng);
}
}

View file

@ -0,0 +1,115 @@
using System;
using System.Buffers.Binary;
namespace AcDream.Core.Audio;
/// <summary>
/// Decodes the AC <c>Wave</c> dat (`0x0A000000..0x0A00FFFF`) into playable
/// PCM data.
///
/// <para>
/// On-disk format (r05 §2.1 + ACE.DatLoader.FileTypes.Wave + ACViewer's
/// Wave.cs:32-72):
/// <code>
/// int32 headerSize
/// int32 dataSize
/// byte[headerSize] Header // raw WAVEFORMATEX — NO RIFF wrapper
/// byte[dataSize] Data // raw sample bytes
/// </code>
/// </para>
///
/// <para>
/// Header byte 0 (low byte of <c>WAVEFORMATEX.wFormatTag</c>):
/// <list type="bullet">
/// <item><description><c>0x01</c> linear PCM — feeds straight into OpenAL.</description></item>
/// <item><description><c>0x55</c> MPEG Layer 3 — retail decoded via winmm ACM.</description></item>
/// <item><description><c>0x02</c> Microsoft ADPCM — retail decoded via winmm ACM.</description></item>
/// </list>
/// </para>
///
/// <para>
/// Our decoder currently handles the PCM case (the vast majority of AC's
/// ~3500 waves). MP3 / ADPCM decoding is best handled by a managed
/// decoder added later (NAudio / MediaToolkit / custom). Unsupported
/// formats return <c>null</c> and the caller logs + skips.
/// </para>
/// </summary>
public static class WaveDecoder
{
/// <summary>
/// Format tag from the first 2 bytes of the WAVEFORMATEX header.
/// Retail and ACE map identically; we use these exact values for logs.
/// </summary>
public enum WaveFormatTag : ushort
{
Unknown = 0x0000,
Pcm = 0x0001, // Microsoft PCM
Adpcm = 0x0002, // Microsoft ADPCM (rare in AC)
Mp3 = 0x0055, // MPEGLAYER3 (common for music-ish cues)
}
/// <summary>
/// Parse a <c>Wave</c> dat record into a <see cref="WaveData"/> PCM
/// container. Returns <c>null</c> when the source format is compressed
/// (MP3 / ADPCM) and we don't yet have a decoder wired up, OR when the
/// header is malformed.
/// </summary>
/// <param name="header">Raw WAVEFORMATEX bytes (at least 14 bytes).</param>
/// <param name="data">Raw payload bytes following the header.</param>
public static WaveData? Decode(byte[] header, byte[] data)
{
ArgumentNullException.ThrowIfNull(header);
ArgumentNullException.ThrowIfNull(data);
if (header.Length < 14) return null;
// WAVEFORMATEX fields (little-endian):
// wFormatTag u16 @ 0
// nChannels u16 @ 2
// nSamplesPerSec u32 @ 4
// nAvgBytesPerSec u32 @ 8
// nBlockAlign u16 @12
// wBitsPerSample u16 @14 (present if headerSize >= 16)
// cbSize u16 @16 (optional extra-data length)
var h = header.AsSpan();
ushort fmtTag = BinaryPrimitives.ReadUInt16LittleEndian(h);
ushort channels = BinaryPrimitives.ReadUInt16LittleEndian(h.Slice(2));
uint sampleRate = BinaryPrimitives.ReadUInt32LittleEndian(h.Slice(4));
ushort bitsPer = header.Length >= 16
? BinaryPrimitives.ReadUInt16LittleEndian(h.Slice(14))
: (ushort)16;
if (channels == 0 || sampleRate == 0)
return null;
// Only PCM is natively supported here. Compressed paths require a
// secondary decoder (MP3/ADPCM); caller logs + skips when null.
if ((WaveFormatTag)fmtTag != WaveFormatTag.Pcm)
return null;
// For PCM the Data array IS the sample buffer (no framing needed).
double duration = bitsPer > 0 && channels > 0
? data.Length * 8.0 / (double)(sampleRate * channels * bitsPer)
: 0.0;
return new WaveData
{
ChannelCount = channels,
SampleRate = (int)sampleRate,
BitsPerSample = bitsPer == 0 ? 16 : bitsPer,
PcmBytes = data,
Duration = TimeSpan.FromSeconds(duration),
};
}
/// <summary>
/// Peek only the format tag byte. Useful for logging which formats
/// failed to decode without allocating a <see cref="WaveData"/>.
/// </summary>
public static WaveFormatTag PeekFormat(byte[] header)
{
if (header is null || header.Length < 2) return WaveFormatTag.Unknown;
ushort fmtTag = BinaryPrimitives.ReadUInt16LittleEndian(header);
return (WaveFormatTag)fmtTag;
}
}