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
83
src/AcDream.Core/Audio/SoundCookbook.cs
Normal file
83
src/AcDream.Core/Audio/SoundCookbook.cs
Normal 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 < 1, the
|
||||
/// remaining mass means "silence" — the call returns null. If probabilities
|
||||
/// sum to > 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 < 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue