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
115
src/AcDream.Core/Audio/WaveDecoder.cs
Normal file
115
src/AcDream.Core/Audio/WaveDecoder.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue