acdream/tests/AcDream.Core.Tests/Audio/WaveDecoderTests.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

104 lines
3.5 KiB
C#

using AcDream.Core.Audio;
using Xunit;
namespace AcDream.Core.Tests.Audio;
public sealed class WaveDecoderTests
{
// A minimal WAVEFORMATEX header:
// wFormatTag u16 = 0x0001 (PCM)
// nChannels u16 = 1 (mono)
// nSamplesPerSec u32 = 22050
// nAvgBytesPerSec u32 = 44100
// nBlockAlign u16 = 2
// wBitsPerSample u16 = 16
// cbSize u16 = 0
private static byte[] MakePcmHeader(ushort channels = 1, uint rate = 22050, ushort bits = 16)
{
byte[] h = new byte[18];
h[0] = 0x01; h[1] = 0x00; // fmtTag PCM
h[2] = (byte)channels; h[3] = (byte)(channels >> 8);
h[4] = (byte)rate; h[5] = (byte)(rate >> 8);
h[6] = (byte)(rate >> 16); h[7] = (byte)(rate >> 24);
uint avg = rate * channels * (uint)(bits / 8);
h[8] = (byte)avg; h[9] = (byte)(avg >> 8);
h[10] = (byte)(avg >> 16); h[11] = (byte)(avg >> 24);
ushort blockAlign = (ushort)(channels * (bits / 8));
h[12] = (byte)blockAlign; h[13] = (byte)(blockAlign >> 8);
h[14] = (byte)bits; h[15] = (byte)(bits >> 8);
// cbSize = 0 (padding at 16..17)
return h;
}
[Fact]
public void Decode_PcmHeader_ReturnsWaveData()
{
byte[] header = MakePcmHeader(channels: 1, rate: 22050, bits: 16);
byte[] data = new byte[22050 * 2]; // 1 second of 16-bit mono
var decoded = WaveDecoder.Decode(header, data);
Assert.NotNull(decoded);
Assert.Equal(1, decoded!.ChannelCount);
Assert.Equal(22050, decoded.SampleRate);
Assert.Equal(16, decoded.BitsPerSample);
Assert.Same(data, decoded.PcmBytes);
// Duration should be ~1 second.
Assert.InRange(decoded.Duration.TotalSeconds, 0.99, 1.01);
}
[Fact]
public void Decode_StereoPcm_Works()
{
byte[] header = MakePcmHeader(channels: 2, rate: 44100, bits: 16);
byte[] data = new byte[44100 * 2 * 2]; // 1 second stereo
var decoded = WaveDecoder.Decode(header, data);
Assert.NotNull(decoded);
Assert.Equal(2, decoded!.ChannelCount);
Assert.Equal(44100, decoded.SampleRate);
}
[Fact]
public void Decode_Mp3Header_ReturnsNull()
{
// wFormatTag = 0x0055 (MPEGLAYER3) — not yet supported.
byte[] header = new byte[30];
header[0] = 0x55; header[1] = 0x00;
header[2] = 0x01; header[4] = 0x44; header[5] = 0xAC; // stereo 44.1kHz
byte[] data = new byte[1024];
var decoded = WaveDecoder.Decode(header, data);
Assert.Null(decoded);
}
[Fact]
public void Decode_AdpcmHeader_ReturnsNull()
{
byte[] header = new byte[20];
header[0] = 0x02; header[1] = 0x00; // ADPCM
byte[] data = new byte[1024];
var decoded = WaveDecoder.Decode(header, data);
Assert.Null(decoded);
}
[Fact]
public void Decode_TruncatedHeader_ReturnsNull()
{
byte[] header = new byte[5];
byte[] data = new byte[100];
Assert.Null(WaveDecoder.Decode(header, data));
}
[Fact]
public void PeekFormat_ReturnsCorrectTag()
{
Assert.Equal(WaveDecoder.WaveFormatTag.Pcm, WaveDecoder.PeekFormat(MakePcmHeader()));
byte[] mp3Header = new byte[4] { 0x55, 0x00, 0x01, 0x00 };
Assert.Equal(WaveDecoder.WaveFormatTag.Mp3, WaveDecoder.PeekFormat(mp3Header));
Assert.Equal(WaveDecoder.WaveFormatTag.Unknown, WaveDecoder.PeekFormat(null!));
}
}