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

97 lines
3.3 KiB
C#

using System;
using System.Collections.Generic;
using AcDream.Core.Audio;
using DatReaderWriter.DBObjs;
using DRWSoundEntry = DatReaderWriter.Types.SoundEntry;
using DatReaderWriter.Types;
using DRWSound = DatReaderWriter.Enums.Sound;
using Xunit;
namespace AcDream.Core.Tests.Audio;
public sealed class SoundCookbookTests
{
// Deterministic Random for golden-value tests.
private static Random Seed(int seed) => new Random(seed);
[Fact]
public void Roll_EmptyList_ReturnsNull()
{
Assert.Null(SoundCookbook.Roll(new List<DRWSoundEntry>(), Seed(1)));
}
[Fact]
public void Roll_SingleEntry_AlwaysReturnsIt()
{
var e = new DRWSoundEntry { Probability = 0.5f, Priority = 4f, Volume = 1f };
var entries = new List<DRWSoundEntry> { e };
Assert.Same(e, SoundCookbook.Roll(entries, Seed(1)));
Assert.Same(e, SoundCookbook.Roll(entries, Seed(999)));
}
[Fact]
public void Roll_WeightedEntries_DistributionMatches()
{
// Three entries: 50%, 30%, 20%. Roll 10000 times and verify counts
// are within 5% of expected.
var a = new DRWSoundEntry { Probability = 0.5f };
var b = new DRWSoundEntry { Probability = 0.3f };
var c = new DRWSoundEntry { Probability = 0.2f };
var entries = new List<DRWSoundEntry> { a, b, c };
var rng = new Random(42);
int countA = 0, countB = 0, countC = 0, countNull = 0;
for (int i = 0; i < 10000; i++)
{
var picked = SoundCookbook.Roll(entries, rng);
if (ReferenceEquals(picked, a)) countA++;
else if (ReferenceEquals(picked, b)) countB++;
else if (ReferenceEquals(picked, c)) countC++;
else countNull++;
}
Assert.InRange(countA, 4500, 5500);
Assert.InRange(countB, 2500, 3500);
Assert.InRange(countC, 1500, 2500);
// Probabilities sum to 1.0 → no null rolls.
Assert.True(countNull < 100);
}
[Fact]
public void Roll_SilenceTail_ReturnsNullOccasionally()
{
// Two entries that only cover 60% of the probability mass — the
// remaining 40% should roll as "silence" (null return).
var a = new DRWSoundEntry { Probability = 0.3f };
var b = new DRWSoundEntry { Probability = 0.3f };
var entries = new List<DRWSoundEntry> { a, b };
var rng = new Random(42);
int nullCount = 0;
for (int i = 0; i < 10000; i++)
{
if (SoundCookbook.Roll(entries, rng) is null)
nullCount++;
}
Assert.InRange(nullCount, 3500, 4500); // ~40% ± margin
}
[Fact]
public void Roll_WithSoundTable_LooksUpBySound()
{
var table = new SoundTable();
var footstep = new DRWSoundEntry { Probability = 1f, Volume = 0.7f };
table.Sounds[DRWSound.Footstep1] = new SoundData();
table.Sounds[DRWSound.Footstep1].Entries.Add(footstep);
var picked = SoundCookbook.Roll(table, DRWSound.Footstep1, Seed(1));
Assert.Same(footstep, picked);
}
[Fact]
public void Roll_WithSoundTable_MissingSound_ReturnsNull()
{
var table = new SoundTable(); // no entries at all
Assert.Null(SoundCookbook.Roll(table, DRWSound.Attack1, Seed(1)));
}
}