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
|
|
@ -13,6 +13,9 @@
|
||||||
<PackageReference Include="Silk.NET.OpenGL" Version="2.23.0" />
|
<PackageReference Include="Silk.NET.OpenGL" Version="2.23.0" />
|
||||||
<PackageReference Include="Silk.NET.Windowing" Version="2.23.0" />
|
<PackageReference Include="Silk.NET.Windowing" Version="2.23.0" />
|
||||||
<PackageReference Include="Silk.NET.Input" Version="2.23.0" />
|
<PackageReference Include="Silk.NET.Input" Version="2.23.0" />
|
||||||
|
<PackageReference Include="Silk.NET.OpenAL" Version="2.23.0" />
|
||||||
|
<PackageReference Include="Silk.NET.OpenAL.Extensions.Creative" Version="2.23.0" />
|
||||||
|
<PackageReference Include="Silk.NET.OpenAL.Extensions.EXT" Version="2.23.0" />
|
||||||
<PackageReference Include="Serilog" Version="4.0.2" />
|
<PackageReference Include="Serilog" Version="4.0.2" />
|
||||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||||
<PackageReference Include="StbTrueTypeSharp" Version="1.26.12" />
|
<PackageReference Include="StbTrueTypeSharp" Version="1.26.12" />
|
||||||
|
|
|
||||||
156
src/AcDream.App/Audio/AudioHookSink.cs
Normal file
156
src/AcDream.App/Audio/AudioHookSink.cs
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.Core.Audio;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
using DatReaderWriter.DBObjs;
|
||||||
|
using DatReaderWriter.Types;
|
||||||
|
using DRWSound = DatReaderWriter.Enums.Sound;
|
||||||
|
|
||||||
|
namespace AcDream.App.Audio;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="IAnimationHookSink"/> that routes sound-bearing animation
|
||||||
|
/// hooks (<see cref="SoundHook"/>, <see cref="SoundTableHook"/>,
|
||||||
|
/// <see cref="SoundTweakedHook"/>) into the
|
||||||
|
/// <see cref="OpenAlAudioEngine"/>.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Wiring:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description>
|
||||||
|
/// <see cref="SoundHook"/> → direct play of <c>SoundHook.Id</c> (a
|
||||||
|
/// Wave dat id) at the entity's world position. Used for custom /
|
||||||
|
/// per-animation audio like weapon swoosh or spell chant.
|
||||||
|
/// </description></item>
|
||||||
|
/// <item><description>
|
||||||
|
/// <see cref="SoundTableHook"/> → look up the entity's SoundTable +
|
||||||
|
/// the hook's <c>SoundType</c>, roll one
|
||||||
|
/// <see cref="DatReaderWriter.Types.SoundEntry"/> via
|
||||||
|
/// <see cref="SoundCookbook"/>, play its wave. Retail's "footstep
|
||||||
|
/// that varies slightly" mechanism — also how attack / damage sounds
|
||||||
|
/// pick a creature-specific variant.
|
||||||
|
/// </description></item>
|
||||||
|
/// <item><description>
|
||||||
|
/// <see cref="SoundTweakedHook"/> → same as SoundHook but with
|
||||||
|
/// pitch / volume overrides baked into the hook.
|
||||||
|
/// </description></item>
|
||||||
|
/// </list>
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Entity → SoundTable id is resolved via an <see cref="IEntitySoundTable"/>
|
||||||
|
/// callback passed in at construction; the renderer's per-entity state
|
||||||
|
/// bag knows the PhysicsObj's <c>SoundTableId</c> (retail:
|
||||||
|
/// <c>PhysicsObj.soundtable_id</c>).
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AudioHookSink : IAnimationHookSink
|
||||||
|
{
|
||||||
|
private readonly OpenAlAudioEngine _engine;
|
||||||
|
private readonly DatSoundCache _cache;
|
||||||
|
private readonly IEntitySoundTable _entitySoundTables;
|
||||||
|
private readonly Random _rng;
|
||||||
|
|
||||||
|
public AudioHookSink(
|
||||||
|
OpenAlAudioEngine engine,
|
||||||
|
DatSoundCache cache,
|
||||||
|
IEntitySoundTable entitySoundTables,
|
||||||
|
Random? rng = null)
|
||||||
|
{
|
||||||
|
_engine = engine ?? throw new ArgumentNullException(nameof(engine));
|
||||||
|
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
|
||||||
|
_entitySoundTables = entitySoundTables ?? throw new ArgumentNullException(nameof(entitySoundTables));
|
||||||
|
_rng = rng ?? Random.Shared;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnHook(uint entityId, Vector3 entityWorldPosition, AnimationHook hook)
|
||||||
|
{
|
||||||
|
if (!_engine.IsAvailable) return;
|
||||||
|
|
||||||
|
switch (hook)
|
||||||
|
{
|
||||||
|
case SoundHook s:
|
||||||
|
Play(entityId, entityWorldPosition, (uint)s.Id, volume: 1f, priority: 4, pitch: 1f);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SoundTableHook st:
|
||||||
|
PlayFromSoundTable(entityId, entityWorldPosition, st.SoundType);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SoundTweakedHook stw:
|
||||||
|
// SoundTweakedHook is a direct wave play with volume +
|
||||||
|
// priority overrides baked into the hook itself (NOT a
|
||||||
|
// SoundTable lookup — that's SoundTableHook). Retail uses
|
||||||
|
// this for the rare "explicit wave + explicit volume" case.
|
||||||
|
Play(entityId, entityWorldPosition,
|
||||||
|
waveId: (uint)stw.SoundId,
|
||||||
|
volume: Math.Clamp(stw.Volume > 0 ? stw.Volume : 1f, 0f, 1f),
|
||||||
|
priority: stw.Priority,
|
||||||
|
pitch: 1f);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// All the visual-only hooks (Scale, Luminous, Diffuse, …)
|
||||||
|
// are for other sinks to handle.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PlayFromSoundTable(
|
||||||
|
uint entityId, Vector3 worldPos, DRWSound sound,
|
||||||
|
float volumeMult = 1f, float pitchMult = 1f)
|
||||||
|
{
|
||||||
|
uint tableId = _entitySoundTables.GetSoundTableId(entityId);
|
||||||
|
if (tableId == 0) return;
|
||||||
|
|
||||||
|
SoundTable? table = _cache.GetSoundTable(tableId);
|
||||||
|
if (table is null) return;
|
||||||
|
|
||||||
|
var entry = SoundCookbook.Roll(table, sound, _rng);
|
||||||
|
if (entry is null) return;
|
||||||
|
|
||||||
|
Play(
|
||||||
|
entityId, worldPos,
|
||||||
|
waveId: (uint)entry.Id,
|
||||||
|
volume: Math.Clamp(entry.Volume * volumeMult, 0f, 1f),
|
||||||
|
priority: entry.Priority,
|
||||||
|
pitch: Math.Max(0.5f, Math.Min(2.0f, pitchMult)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Play(uint entityId, Vector3 worldPos, uint waveId,
|
||||||
|
float volume, float priority, float pitch)
|
||||||
|
{
|
||||||
|
if (waveId == 0) return;
|
||||||
|
WaveData? wave = _cache.GetWave(waveId);
|
||||||
|
if (wave is null) return;
|
||||||
|
_engine.Play3DWave(waveId, wave, worldPos, volume, priority, pitch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Callback the renderer uses to resolve the sound-table id for an
|
||||||
|
/// entity. Retail stores this on <c>PhysicsObj.soundtable_id</c>; our
|
||||||
|
/// renderer keeps per-entity state that includes it.
|
||||||
|
/// </summary>
|
||||||
|
public interface IEntitySoundTable
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Return the SoundTable dat id (0x20xxxxxx) for <paramref name="entityId"/>,
|
||||||
|
/// or 0 if no table is known (e.g. a static prop without audio).
|
||||||
|
/// </summary>
|
||||||
|
uint GetSoundTableId(uint entityId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Simple dictionary-backed <see cref="IEntitySoundTable"/>; the renderer
|
||||||
|
/// assigns entries as it hydrates entities.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DictionaryEntitySoundTable : IEntitySoundTable
|
||||||
|
{
|
||||||
|
private readonly Dictionary<uint, uint> _table = new();
|
||||||
|
|
||||||
|
public void Set(uint entityId, uint soundTableId) => _table[entityId] = soundTableId;
|
||||||
|
public void Remove(uint entityId) => _table.Remove(entityId);
|
||||||
|
|
||||||
|
public uint GetSoundTableId(uint entityId) =>
|
||||||
|
_table.TryGetValue(entityId, out var id) ? id : 0;
|
||||||
|
}
|
||||||
375
src/AcDream.App/Audio/OpenAlAudioEngine.cs
Normal file
375
src/AcDream.App/Audio/OpenAlAudioEngine.cs
Normal file
|
|
@ -0,0 +1,375 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.Core.Audio;
|
||||||
|
using Silk.NET.OpenAL;
|
||||||
|
|
||||||
|
namespace AcDream.App.Audio;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OpenAL-backed audio engine (Phase E.2) — faithful to retail's
|
||||||
|
/// 16-voice pool and inverse-square falloff behaviour (r05 §5.3).
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Architecture:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description>
|
||||||
|
/// Single <see cref="ALContext"/> + <see cref="AL"/> bound to the
|
||||||
|
/// system default device. Cross-platform (WASAPI / WinMM /
|
||||||
|
/// PulseAudio / CoreAudio — whichever OpenAL-Soft picks).
|
||||||
|
/// </description></item>
|
||||||
|
/// <item><description>
|
||||||
|
/// Fixed 16-source pool for 3D positional sounds. When all 16 are
|
||||||
|
/// busy, new Play3D calls evict the slot whose currently-playing
|
||||||
|
/// sound has lower effective gain than the incoming sound
|
||||||
|
/// (matches retail <c>FUN_00550ad0</c> first-free-then-evict-quieter
|
||||||
|
/// algorithm at <c>chunk_00550000.c:527</c>).
|
||||||
|
/// </description></item>
|
||||||
|
/// <item><description>
|
||||||
|
/// Separate UI source pool (4 sources) for flat 2D UI clicks /
|
||||||
|
/// wooshes — not subject to the 3D eviction game.
|
||||||
|
/// </description></item>
|
||||||
|
/// <item><description>
|
||||||
|
/// PCM buffer cache keyed by Wave dat id so the same footstep isn't
|
||||||
|
/// re-uploaded to the GL-equivalent AL buffers on every hit.
|
||||||
|
/// </description></item>
|
||||||
|
/// </list>
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Thread-safety: the engine is called only from the render thread
|
||||||
|
/// (the same thread that drives <c>TickAnimations</c>). No locks inside.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Fail-open: when the OpenAL driver can't be initialised (missing
|
||||||
|
/// library on a headless CI box, or explicitly disabled via
|
||||||
|
/// <c>ACDREAM_NO_AUDIO=1</c>), <see cref="IsAvailable"/> is false and all
|
||||||
|
/// Play* calls are no-ops. This lets the rest of the client run
|
||||||
|
/// unaffected.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed unsafe class OpenAlAudioEngine : IAudioEngine
|
||||||
|
{
|
||||||
|
// ── Backends ─────────────────────────────────────────────────────────────
|
||||||
|
private readonly ALContext? _alc;
|
||||||
|
private readonly AL? _al;
|
||||||
|
private readonly Device* _device;
|
||||||
|
private readonly Context* _context;
|
||||||
|
private readonly bool _available;
|
||||||
|
|
||||||
|
// ── Pools ────────────────────────────────────────────────────────────────
|
||||||
|
private const int PoolSize3D = 16; // retail 16-slot voice pool
|
||||||
|
private const int PoolSizeUi = 4;
|
||||||
|
|
||||||
|
// Slot state per 3D source; mirrors retail's g_poolVols array (the
|
||||||
|
// EFFECTIVE gain at play-start time, used for eviction comparisons).
|
||||||
|
private sealed class Slot3D
|
||||||
|
{
|
||||||
|
public uint SourceId;
|
||||||
|
public float PlayingGain; // gain at play time (for eviction compare)
|
||||||
|
public bool InUse;
|
||||||
|
public uint PriorityBase; // raw priority from SoundEntry.Priority
|
||||||
|
}
|
||||||
|
private readonly Slot3D[] _pool3D = new Slot3D[PoolSize3D];
|
||||||
|
private int _pool3DCursor; // round-robin start
|
||||||
|
|
||||||
|
private readonly uint[] _poolUi = new uint[PoolSizeUi];
|
||||||
|
|
||||||
|
// ── Buffer cache (Wave dat id → AL buffer) ───────────────────────────────
|
||||||
|
private readonly Dictionary<uint, uint> _bufferByWaveId = new();
|
||||||
|
|
||||||
|
// ── Ambient handles (StartAmbient/StopAmbient) ───────────────────────────
|
||||||
|
private readonly Dictionary<int, uint> _ambientSources = new();
|
||||||
|
private int _nextAmbientHandle = 1;
|
||||||
|
|
||||||
|
// ── Public volume knobs ──────────────────────────────────────────────────
|
||||||
|
public float MasterVolume { get; set; } = 1f;
|
||||||
|
public float SfxVolume { get; set; } = 1f;
|
||||||
|
public float MusicVolume { get; set; } = 0.7f;
|
||||||
|
public float AmbientVolume{ get; set; } = 0.8f;
|
||||||
|
public bool IsAvailable => _available;
|
||||||
|
|
||||||
|
public OpenAlAudioEngine()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_alc = ALContext.GetApi(soft: true);
|
||||||
|
_al = AL.GetApi(soft: true);
|
||||||
|
_device = _alc.OpenDevice(string.Empty);
|
||||||
|
if (_device == null)
|
||||||
|
{
|
||||||
|
_available = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_context = _alc.CreateContext(_device, null);
|
||||||
|
if (_context == null)
|
||||||
|
{
|
||||||
|
_alc.CloseDevice(_device);
|
||||||
|
_device = null;
|
||||||
|
_available = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!_alc.MakeContextCurrent(_context))
|
||||||
|
{
|
||||||
|
_alc.DestroyContext(_context);
|
||||||
|
_context = null;
|
||||||
|
_alc.CloseDevice(_device);
|
||||||
|
_device = null;
|
||||||
|
_available = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialise 3D source pool.
|
||||||
|
for (int i = 0; i < PoolSize3D; i++)
|
||||||
|
{
|
||||||
|
uint src = _al.GenSource();
|
||||||
|
_al.SetSourceProperty(src, SourceFloat.Gain, 1f);
|
||||||
|
_al.SetSourceProperty(src, SourceFloat.MaxDistance, 1000f);
|
||||||
|
_al.SetSourceProperty(src, SourceFloat.RolloffFactor, 1f);
|
||||||
|
_al.SetSourceProperty(src, SourceFloat.ReferenceDistance, 2f);
|
||||||
|
_al.SetSourceProperty(src, SourceBoolean.Looping, false);
|
||||||
|
_pool3D[i] = new Slot3D { SourceId = src, InUse = false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// UI sources are source-relative (attached to listener) so they
|
||||||
|
// ignore 3D position.
|
||||||
|
for (int i = 0; i < PoolSizeUi; i++)
|
||||||
|
{
|
||||||
|
uint src = _al.GenSource();
|
||||||
|
_al.SetSourceProperty(src, SourceBoolean.SourceRelative, true);
|
||||||
|
_al.SetSourceProperty(src, SourceFloat.Gain, 1f);
|
||||||
|
_al.SetSourceProperty(src, SourceBoolean.Looping, false);
|
||||||
|
_poolUi[i] = src;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global distance model = inverse-square clamped (classic retail feel).
|
||||||
|
_al.DistanceModel(DistanceModel.InverseDistanceClamped);
|
||||||
|
|
||||||
|
_available = true;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// OpenAL driver unavailable (headless CI, missing libopenal).
|
||||||
|
_available = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (!_available || _al is null) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
for (int i = 0; i < PoolSize3D; i++)
|
||||||
|
{
|
||||||
|
var slot = _pool3D[i];
|
||||||
|
if (slot is null) continue;
|
||||||
|
_al.SourceStop(slot.SourceId);
|
||||||
|
_al.DeleteSource(slot.SourceId);
|
||||||
|
}
|
||||||
|
for (int i = 0; i < PoolSizeUi; i++)
|
||||||
|
{
|
||||||
|
_al.SourceStop(_poolUi[i]);
|
||||||
|
_al.DeleteSource(_poolUi[i]);
|
||||||
|
}
|
||||||
|
foreach (var kv in _ambientSources)
|
||||||
|
{
|
||||||
|
_al.SourceStop(kv.Value);
|
||||||
|
_al.DeleteSource(kv.Value);
|
||||||
|
}
|
||||||
|
foreach (var buf in _bufferByWaveId.Values)
|
||||||
|
{
|
||||||
|
_al.DeleteBuffer(buf);
|
||||||
|
}
|
||||||
|
if (_context != null && _alc is not null)
|
||||||
|
{
|
||||||
|
_alc.MakeContextCurrent(null);
|
||||||
|
_alc.DestroyContext(_context);
|
||||||
|
}
|
||||||
|
if (_device != null && _alc is not null)
|
||||||
|
_alc.CloseDevice(_device);
|
||||||
|
}
|
||||||
|
catch { /* shutdown — ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── IAudioEngine ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public void SetListener(
|
||||||
|
float posX, float posY, float posZ,
|
||||||
|
float forwardX, float forwardY, float forwardZ,
|
||||||
|
float upX, float upY, float upZ)
|
||||||
|
{
|
||||||
|
if (!_available || _al is null) return;
|
||||||
|
|
||||||
|
_al.SetListenerProperty(ListenerVector3.Position, posX, posY, posZ);
|
||||||
|
// AL expects a 6-float orientation (fwd then up).
|
||||||
|
Span<float> ori = stackalloc float[6]
|
||||||
|
{
|
||||||
|
forwardX, forwardY, forwardZ,
|
||||||
|
upX, upY, upZ
|
||||||
|
};
|
||||||
|
fixed (float* p = ori)
|
||||||
|
_al.SetListenerProperty(ListenerFloatArray.Orientation, p);
|
||||||
|
_al.SetListenerProperty(ListenerFloat.Gain, MasterVolume);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Not exposed on IAudioEngine but used by the hook sink — play a raw
|
||||||
|
/// WaveData blob at a 3D position with full priority/volume controls.
|
||||||
|
/// Returns true on success, false if the buffer was rejected.
|
||||||
|
/// </summary>
|
||||||
|
public bool Play3DWave(
|
||||||
|
uint waveId,
|
||||||
|
WaveData wave,
|
||||||
|
Vector3 position,
|
||||||
|
float volume,
|
||||||
|
float priority,
|
||||||
|
float pitch = 1.0f)
|
||||||
|
{
|
||||||
|
if (!_available || _al is null) return false;
|
||||||
|
|
||||||
|
float effectiveGain = volume * SfxVolume;
|
||||||
|
if (effectiveGain < 0.001f) return false; // silent; skip
|
||||||
|
|
||||||
|
uint buffer = EnsureBuffer(waveId, wave);
|
||||||
|
if (buffer == 0) return false;
|
||||||
|
|
||||||
|
// Pick a slot: first free, else evict quieter one, else drop.
|
||||||
|
int slotIdx = -1;
|
||||||
|
for (int i = 0; i < PoolSize3D; i++)
|
||||||
|
{
|
||||||
|
int idx = (_pool3DCursor + i) & (PoolSize3D - 1);
|
||||||
|
var s = _pool3D[idx];
|
||||||
|
if (!s.InUse || !IsStillPlaying(s.SourceId)) { slotIdx = idx; break; }
|
||||||
|
}
|
||||||
|
if (slotIdx < 0)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < PoolSize3D; i++)
|
||||||
|
{
|
||||||
|
int idx = (_pool3DCursor + i) & (PoolSize3D - 1);
|
||||||
|
if (_pool3D[idx].PlayingGain < effectiveGain) { slotIdx = idx; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (slotIdx < 0) return false; // no slot quieter than us — drop
|
||||||
|
|
||||||
|
var slot = _pool3D[slotIdx];
|
||||||
|
_al.SourceStop(slot.SourceId);
|
||||||
|
_al.SetSourceProperty(slot.SourceId, SourceInteger.Buffer, 0); // detach old
|
||||||
|
_al.SetSourceProperty(slot.SourceId, SourceInteger.Buffer, (int)buffer);
|
||||||
|
_al.SetSourceProperty(slot.SourceId, SourceFloat.Gain, effectiveGain);
|
||||||
|
_al.SetSourceProperty(slot.SourceId, SourceFloat.Pitch, pitch);
|
||||||
|
_al.SetSourceProperty(slot.SourceId, SourceVector3.Position, position.X, position.Y, position.Z);
|
||||||
|
_al.SetSourceProperty(slot.SourceId, SourceBoolean.SourceRelative, false);
|
||||||
|
_al.SetSourceProperty(slot.SourceId, SourceBoolean.Looping, false);
|
||||||
|
_al.SourcePlay(slot.SourceId);
|
||||||
|
|
||||||
|
slot.PlayingGain = effectiveGain;
|
||||||
|
slot.InUse = true;
|
||||||
|
slot.PriorityBase = (uint)Math.Clamp((int)priority, 0, 7);
|
||||||
|
_pool3DCursor = (slotIdx + 1) & (PoolSize3D - 1);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Play a raw WaveData blob as a 2D UI sound (no falloff, ignores
|
||||||
|
/// listener position).
|
||||||
|
/// </summary>
|
||||||
|
public bool PlayUiWave(uint waveId, WaveData wave, float volume = 1f, float pitch = 1f)
|
||||||
|
{
|
||||||
|
if (!_available || _al is null) return false;
|
||||||
|
|
||||||
|
uint buffer = EnsureBuffer(waveId, wave);
|
||||||
|
if (buffer == 0) return false;
|
||||||
|
|
||||||
|
// UI pool: find a free source (first not-playing), else round-robin.
|
||||||
|
int slotIdx = -1;
|
||||||
|
for (int i = 0; i < PoolSizeUi; i++)
|
||||||
|
{
|
||||||
|
if (!IsStillPlaying(_poolUi[i])) { slotIdx = i; break; }
|
||||||
|
}
|
||||||
|
if (slotIdx < 0) slotIdx = 0; // always replace slot 0 as a last resort
|
||||||
|
|
||||||
|
uint src = _poolUi[slotIdx];
|
||||||
|
_al.SourceStop(src);
|
||||||
|
_al.SetSourceProperty(src, SourceInteger.Buffer, 0);
|
||||||
|
_al.SetSourceProperty(src, SourceInteger.Buffer, (int)buffer);
|
||||||
|
_al.SetSourceProperty(src, SourceFloat.Gain, Math.Clamp(volume, 0f, 1f) * SfxVolume);
|
||||||
|
_al.SetSourceProperty(src, SourceFloat.Pitch, pitch);
|
||||||
|
_al.SourcePlay(src);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// IAudioEngine implementations — the enum-based overloads are less
|
||||||
|
// useful than the raw-Wave overloads above, since the hook sink already
|
||||||
|
// has access to decoded WaveData. Left as no-ops for now; R5 defines
|
||||||
|
// SoundId as a sparse subset of retail enums.
|
||||||
|
|
||||||
|
public void PlayUi(SoundId id) { /* handled via AudioHookSink */ }
|
||||||
|
|
||||||
|
public void Play3D(SoundId id, float x, float y, float z) { /* handled via AudioHookSink */ }
|
||||||
|
|
||||||
|
public int StartAmbient(SoundId id, float x, float y, float z)
|
||||||
|
{
|
||||||
|
// Looping ambient — needs a decoded wave + WaveId. The hook sink
|
||||||
|
// doesn't route ambient; a separate landblock-attached ambient
|
||||||
|
// system (outside R5) will drive this. For now: reserve a handle.
|
||||||
|
int handle = _nextAmbientHandle++;
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void StopAmbient(int handle)
|
||||||
|
{
|
||||||
|
if (!_available || _al is null) return;
|
||||||
|
if (_ambientSources.TryGetValue(handle, out var src))
|
||||||
|
{
|
||||||
|
_al.SourceStop(src);
|
||||||
|
_ambientSources.Remove(handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void PlayMusic(string resourceName, bool loop) { /* R5 §6 MIDI — not ported */ }
|
||||||
|
public void StopMusic() { /* ditto */ }
|
||||||
|
|
||||||
|
// ── Private helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private uint EnsureBuffer(uint waveId, WaveData wave)
|
||||||
|
{
|
||||||
|
if (!_available || _al is null) return 0;
|
||||||
|
if (_bufferByWaveId.TryGetValue(waveId, out var existing)) return existing;
|
||||||
|
|
||||||
|
uint buf = _al.GenBuffer();
|
||||||
|
BufferFormat fmt = PickFormat(wave);
|
||||||
|
if (fmt == 0)
|
||||||
|
{
|
||||||
|
_al.DeleteBuffer(buf);
|
||||||
|
_bufferByWaveId[waveId] = 0;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
fixed (byte* p = wave.PcmBytes)
|
||||||
|
_al.BufferData(buf, fmt, p, wave.PcmBytes.Length, wave.SampleRate);
|
||||||
|
|
||||||
|
_bufferByWaveId[waveId] = buf;
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BufferFormat PickFormat(WaveData w)
|
||||||
|
{
|
||||||
|
return (w.ChannelCount, w.BitsPerSample) switch
|
||||||
|
{
|
||||||
|
(1, 8) => BufferFormat.Mono8,
|
||||||
|
(1, 16) => BufferFormat.Mono16,
|
||||||
|
(2, 8) => BufferFormat.Stereo8,
|
||||||
|
(2, 16) => BufferFormat.Stereo16,
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsStillPlaying(uint sourceId)
|
||||||
|
{
|
||||||
|
if (_al is null) return false;
|
||||||
|
_al.GetSourceProperty(sourceId, GetSourceInteger.SourceState, out int state);
|
||||||
|
return state == (int)SourceState.Playing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -129,6 +129,13 @@ public sealed class GameWindow : IDisposable
|
||||||
// per-entity tick loop can just call it unconditionally.
|
// per-entity tick loop can just call it unconditionally.
|
||||||
private readonly AcDream.Core.Physics.AnimationHookRouter _hookRouter = new();
|
private readonly AcDream.Core.Physics.AnimationHookRouter _hookRouter = new();
|
||||||
|
|
||||||
|
// Phase E.2 audio. Null when ACDREAM_NO_AUDIO=1 or the OpenAL driver
|
||||||
|
// failed to init; all three are set together.
|
||||||
|
private AcDream.App.Audio.OpenAlAudioEngine? _audioEngine;
|
||||||
|
private AcDream.Core.Audio.DatSoundCache? _soundCache;
|
||||||
|
private AcDream.App.Audio.DictionaryEntitySoundTable? _entitySoundTables;
|
||||||
|
private AcDream.App.Audio.AudioHookSink? _audioSink;
|
||||||
|
|
||||||
// Phase B.2: player movement mode.
|
// Phase B.2: player movement mode.
|
||||||
private AcDream.App.Input.PlayerMovementController? _playerController;
|
private AcDream.App.Input.PlayerMovementController? _playerController;
|
||||||
private AcDream.App.Rendering.ChaseCamera? _chaseCamera;
|
private AcDream.App.Rendering.ChaseCamera? _chaseCamera;
|
||||||
|
|
@ -559,6 +566,33 @@ public sealed class GameWindow : IDisposable
|
||||||
_dats = new DatCollection(_datDir, DatAccessType.Read);
|
_dats = new DatCollection(_datDir, DatAccessType.Read);
|
||||||
_animLoader = new AcDream.Core.Physics.DatCollectionLoader(_dats);
|
_animLoader = new AcDream.Core.Physics.DatCollectionLoader(_dats);
|
||||||
|
|
||||||
|
// Phase E.2 audio: init OpenAL + hook sink. Suppressible via
|
||||||
|
// ACDREAM_NO_AUDIO=1 for headless tests / broken audio drivers.
|
||||||
|
if (Environment.GetEnvironmentVariable("ACDREAM_NO_AUDIO") != "1")
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_soundCache = new AcDream.Core.Audio.DatSoundCache(_dats);
|
||||||
|
_audioEngine = new AcDream.App.Audio.OpenAlAudioEngine();
|
||||||
|
_entitySoundTables = new AcDream.App.Audio.DictionaryEntitySoundTable();
|
||||||
|
if (_audioEngine.IsAvailable)
|
||||||
|
{
|
||||||
|
_audioSink = new AcDream.App.Audio.AudioHookSink(
|
||||||
|
_audioEngine, _soundCache, _entitySoundTables);
|
||||||
|
_hookRouter.Register(_audioSink);
|
||||||
|
Console.WriteLine("audio: OpenAL engine ready (16 voices, 3D positional)");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.WriteLine("audio: OpenAL unavailable (driver missing / headless) — audio disabled");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"audio: init failed: {ex.Message} — audio disabled");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
uint centerLandblockId = 0xA9B4FFFFu;
|
uint centerLandblockId = 0xA9B4FFFFu;
|
||||||
Console.WriteLine($"loading world view centered on 0x{centerLandblockId:X8}");
|
Console.WriteLine($"loading world view centered on 0x{centerLandblockId:X8}");
|
||||||
|
|
||||||
|
|
@ -1114,6 +1148,17 @@ public sealed class GameWindow : IDisposable
|
||||||
CurrFrame = idleCycle.LowFrame,
|
CurrFrame = idleCycle.LowFrame,
|
||||||
Sequencer = sequencer,
|
Sequencer = sequencer,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Phase E.2: register entity's SoundTable so SoundTableHook can
|
||||||
|
// resolve creature-specific sounds (footsteps, attack vocalizations,
|
||||||
|
// damage grunts, etc). Server-sent SoundTable override would take
|
||||||
|
// precedence here when the wire layer delivers it.
|
||||||
|
if (_entitySoundTables is not null)
|
||||||
|
{
|
||||||
|
uint soundTableId = (uint)setup.DefaultSoundTable;
|
||||||
|
if (soundTableId != 0)
|
||||||
|
_entitySoundTables.Set(entity.Id, soundTableId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dump a summary periodically so we can see drop breakdowns without
|
// Dump a summary periodically so we can see drop breakdowns without
|
||||||
|
|
@ -2702,6 +2747,21 @@ public sealed class GameWindow : IDisposable
|
||||||
// Extract camera world position from the inverse of the view matrix.
|
// Extract camera world position from the inverse of the view matrix.
|
||||||
System.Numerics.Matrix4x4.Invert(camera.View, out var invView);
|
System.Numerics.Matrix4x4.Invert(camera.View, out var invView);
|
||||||
var camPos = new System.Numerics.Vector3(invView.M41, invView.M42, invView.M43);
|
var camPos = new System.Numerics.Vector3(invView.M41, invView.M42, invView.M43);
|
||||||
|
|
||||||
|
// Phase E.2 audio: update listener pose so 3D sounds pan/attenuate
|
||||||
|
// correctly relative to where we're looking. Fwd = -Z of the view
|
||||||
|
// matrix (OpenGL convention), up = +Y. Both live in the inverse
|
||||||
|
// view matrix's basis vectors.
|
||||||
|
if (_audioEngine is not null && _audioEngine.IsAvailable)
|
||||||
|
{
|
||||||
|
var fwd = new System.Numerics.Vector3(-invView.M31, -invView.M32, -invView.M33);
|
||||||
|
var up = new System.Numerics.Vector3( invView.M21, invView.M22, invView.M23);
|
||||||
|
_audioEngine.SetListener(
|
||||||
|
camPos.X, camPos.Y, camPos.Z,
|
||||||
|
fwd.X, fwd.Y, fwd.Z,
|
||||||
|
up.X, up.Y, up.Z);
|
||||||
|
}
|
||||||
|
|
||||||
var visibility = _cellVisibility.ComputeVisibility(camPos);
|
var visibility = _cellVisibility.ComputeVisibility(camPos);
|
||||||
bool cameraInsideCell = visibility?.CameraCell is not null;
|
bool cameraInsideCell = visibility?.CameraCell is not null;
|
||||||
|
|
||||||
|
|
@ -3166,6 +3226,7 @@ public sealed class GameWindow : IDisposable
|
||||||
// _dats; Dispose cancels the token and waits up to 2s for the thread.
|
// _dats; Dispose cancels the token and waits up to 2s for the thread.
|
||||||
_streamer?.Dispose();
|
_streamer?.Dispose();
|
||||||
_liveSession?.Dispose();
|
_liveSession?.Dispose();
|
||||||
|
_audioEngine?.Dispose(); // Phase E.2: stop all voices, close AL context
|
||||||
_staticMesh?.Dispose();
|
_staticMesh?.Dispose();
|
||||||
_textureCache?.Dispose();
|
_textureCache?.Dispose();
|
||||||
_meshShader?.Dispose();
|
_meshShader?.Dispose();
|
||||||
|
|
|
||||||
78
src/AcDream.Core/Audio/DatSoundCache.cs
Normal file
78
src/AcDream.Core/Audio/DatSoundCache.cs
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using DatReaderWriter;
|
||||||
|
using DatReaderWriter.DBObjs;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Audio;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DatCollection-backed cache of decoded waves + SoundTable lookups.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Lazy-loads each Wave / SoundTable on first request, decodes Wave PCM
|
||||||
|
/// data via <see cref="WaveDecoder"/>, and memoizes the result. Safe to
|
||||||
|
/// call from any thread — the inner dicts are
|
||||||
|
/// <see cref="ConcurrentDictionary{TKey,TValue}"/>.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Returns <c>null</c> for waves whose format tag isn't PCM (MP3 / ADPCM
|
||||||
|
/// fall through silently until a compressed-decoder path is wired up).
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DatSoundCache
|
||||||
|
{
|
||||||
|
private readonly DatCollection _dats;
|
||||||
|
private readonly ConcurrentDictionary<uint, WaveData?> _waves = new();
|
||||||
|
private readonly ConcurrentDictionary<uint, SoundTable?> _tables = new();
|
||||||
|
|
||||||
|
public DatSoundCache(DatCollection dats)
|
||||||
|
{
|
||||||
|
_dats = dats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieve decoded PCM data for a Wave dat id. Returns null if the
|
||||||
|
/// wave is missing, malformed, or uses a currently-unsupported format
|
||||||
|
/// (MP3 / ADPCM).
|
||||||
|
/// </summary>
|
||||||
|
public WaveData? GetWave(uint waveId)
|
||||||
|
{
|
||||||
|
if (_waves.TryGetValue(waveId, out var cached)) return cached;
|
||||||
|
|
||||||
|
var wave = _dats.Get<Wave>(waveId);
|
||||||
|
if (wave is null)
|
||||||
|
{
|
||||||
|
_waves[waveId] = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var decoded = WaveDecoder.Decode(wave.Header, wave.Data);
|
||||||
|
_waves[waveId] = decoded;
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieve a SoundTable by dat id. Returns null if the table is
|
||||||
|
/// missing from the dats.
|
||||||
|
/// </summary>
|
||||||
|
public SoundTable? GetSoundTable(uint soundTableId)
|
||||||
|
{
|
||||||
|
if (_tables.TryGetValue(soundTableId, out var cached)) return cached;
|
||||||
|
|
||||||
|
var table = _dats.Get<SoundTable>(soundTableId);
|
||||||
|
_tables[soundTableId] = table;
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Total number of waves that have been accessed (hit or miss).
|
||||||
|
/// Useful for diagnostic overlays.
|
||||||
|
/// </summary>
|
||||||
|
public int CachedWaveCount => _waves.Count;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Total number of SoundTables that have been accessed.
|
||||||
|
/// </summary>
|
||||||
|
public int CachedSoundTableCount => _tables.Count;
|
||||||
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
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