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>
156 lines
5.6 KiB
C#
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;
|
|
}
|