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:
parent
b04d393329
commit
351723928f
9 changed files with 1072 additions and 0 deletions
97
tests/AcDream.Core.Tests/Audio/SoundCookbookTests.cs
Normal file
97
tests/AcDream.Core.Tests/Audio/SoundCookbookTests.cs
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
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)));
|
||||
}
|
||||
}
|
||||
104
tests/AcDream.Core.Tests/Audio/WaveDecoderTests.cs
Normal file
104
tests/AcDream.Core.Tests/Audio/WaveDecoderTests.cs
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
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!));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue