acdream/src/AcDream.App/Audio/AudioHookSink.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

156 lines
5.6 KiB
C#

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;
}