acdream/src/AcDream.Core/Audio/AmbientSoundScheduler.cs
Erik 7c4dd1ade7 feat(audio): Campaign A slice A5 — retail's region ambient soundscape
acdream had no ambient system: StartAmbient minted a handle and played
nothing. Retail's is a weighted-accumulation + timer-queue engine, not
looping voices. On every objcell change (24 m) CellManager::ChangePosition
rebuilds per-sound weights over the 3x3 landblock ring x 64 land cells
each, decoding each cell's terrain word through the region file's
terrain -> scene -> AmbientSTBDesc chain; playback is a min-heap of
absolute deadlines drained from the frame tick, where each pop fires a
one-shot and re-arms.

A continuous bed (base_chance == 0) is non-positional, crossfaded by its
share of the TOTAL weight, and re-fired every min_rate seconds — that
rate is the author's intended loop period, and re-firing is how retail
fakes a sustained bed with no looping voice, re-rolling the variant and
the crossfade each time. An intermittent one keeps its authored volume,
plays at a random accumulated compass bearing at min + (max-min)*t^2,
and is dice-gated. Indoors is silent by design: CEnvCell's contributor is
a folded ret and EnvCell carries no sound data.

The Opus review caught four bugs before this landed, one fatal:

- Cell offsets were built in ABSOLUTE world coordinates and differenced
  against the listener's STREAMED-frame position, so every one of 576
  offsets came out ~32 km, every contribution was culled, and the whole
  feature was silent with nothing logged. Offsets are now landblock-local
  the way Position::get_offset builds them, and the streamed-frame
  position is carried separately for playback, where it belongs.
- The cell's weight was added to the shared denominator once per
  DESCRIPTOR instead of once per CELL, dividing every bed's crossfade by
  the table's entry count — enough to push a typical authored volume
  under the 0.03 audibility floor.
- The drain used  where retail's UseTime is strictly
  below, so a descriptor authored with a zero rate re-armed at the same
  instant and spun the frame forever.
- Arming only enqueued; retail's UpdatePlayQueue PLAYS and then re-arms,
  so a newly audible ambient was silent for a full period after the
  crossing that made it audible.

Also: beds now go through retail's single 16-voice priority pool rather
than acdream's UI pool (retail has one pool; parking beds in the UI pool
let an A4 portal cue chop one mid-wave and discarded the authored
priority), and CalcDir's in-block test is XY-only, since CalcWeight
includes Z on purpose and CalcDir excludes it on purpose.

Two behaviours are knowingly incomplete and registered rather than
guessed at slice end: TS-66 (sky-lit interiors should keep the outdoor
set) and TS-67 (contribution weight is computed in-plane). Retires TS-29.

The frame-loop hook is a typed IAmbientFramePhase, not a callback — the
first attempt used an Action<float> and the architecture guard
ExtractedUpdateOwners_DoNotRetainAnonymousCallbacks correctly rejected it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 22:53:41 +02:00

235 lines
9 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.Core.Audio;
/// <summary>
/// One ambient firing, as the scheduler hands it to the audio backend.
/// </summary>
/// <param name="Instance">The instance that fired (its descriptor names the slot).</param>
/// <param name="Volume">The volume to play at — crossfaded for a continuous bed.</param>
/// <param name="Position">
/// The world position, or null for a continuous bed — retail plays those through
/// <c>PlayAmbientSoundFromCenter</c>, with no position, no pan and no distance
/// attenuation: a stereo bed centred on the listener.
/// </param>
public readonly record struct AmbientSoundFiring(
AmbientSoundInstance Instance,
float Volume,
Vector3? Position);
/// <summary>
/// Retail's ambient playback engine: a min-heap of ABSOLUTE deadlines, drained
/// once per frame, where each pop plays a one-shot and immediately re-arms
/// itself. There is no looping voice anywhere in retail's audio path — a
/// "continuous" ambient is a one-shot re-fired every <c>min_rate</c> seconds,
/// which is why an <c>AL_LOOPING</c> port sounds wrong (no re-randomised table
/// pick, no re-rolled crossfade volume, no gap).
///
/// <para>
/// Ports <c>Ambient::UpdatePlayQueue</c> @ <c>0x551A50</c>,
/// <c>Ambient::Play</c> @ <c>0x5517A0</c> and <c>Ambient::UseTime</c> @
/// <c>0x551880</c>.
/// </para>
/// </summary>
public sealed class AmbientSoundScheduler
{
private readonly PriorityQueue<AmbientSoundInstance, double> _queue = new();
private readonly List<AmbientSoundInstance> _instances = [];
private readonly ISoundRandom _rng;
public AmbientSoundScheduler(ISoundRandom? rng = null) => _rng = rng ?? new SoundRandom();
/// <summary>Every live instance, in accumulation order.</summary>
public IReadOnlyList<AmbientSoundInstance> Instances => _instances;
/// <summary>Instances currently holding a deadline. Diagnostic use.</summary>
public int QueuedCount => _queue.Count;
/// <summary>Sum of every instance's weight — the crossfade denominator.</summary>
public float TotalSoundCount { get; private set; }
/// <summary>
/// Begin a rebuild. <c>Ambient::InitSounds</c> @ <c>0x5515D0</c> resets EVERY
/// existing instance's counters before the accumulation pass — skipping that
/// leaves stale bearings and probabilities alive indefinitely, because
/// <c>IntermitSound::UpdateSound</c> never clears them itself.
/// </summary>
public void BeginRebuild()
{
foreach (AmbientSoundInstance instance in _instances)
instance.ResetCount();
TotalSoundCount = 0f;
}
/// <summary>
/// Register an instance for this world, if it is not already tracked. Called
/// while walking the contributing land cells.
/// </summary>
public AmbientSoundInstance Track(AmbientSoundDescriptor descriptor, uint soundTableDid)
{
foreach (AmbientSoundInstance existing in _instances)
{
if (existing.Descriptor == descriptor && existing.SoundTableDid == soundTableDid)
return existing;
}
var created = new AmbientSoundInstance(descriptor, soundTableDid);
_instances.Add(created);
return created;
}
/// <summary>
/// Contribute one land cell to every ambient its sound table authors —
/// <c>Ambient::AddSound</c> @ <c>0x551610</c>.
///
/// <para>
/// The cell's weight lands in <see cref="TotalSoundCount"/> exactly ONCE,
/// however many descriptors the table carries. Retail adds <c>w</c> to
/// <c>total_sound_count</c> before looping the descriptors, so with an
/// N-entry table the per-instance counts sum to N × the denominator — a
/// deliberate retail property, not an oversight. Adding the weight per
/// descriptor instead divides every bed's crossfade by N, which pushes a
/// typical authored volume under the 0.03 audibility floor and silences it.
/// </para>
/// </summary>
public void ContributeCell<TTable>(
Vector3 offset,
TTable table,
Func<TTable, int, AmbientSoundDescriptor> descriptorAt)
where TTable : DatReaderWriter.Types.AmbientSTBDesc
{
ArgumentNullException.ThrowIfNull(table);
ArgumentNullException.ThrowIfNull(descriptorAt);
float weight = AmbientSoundConstants.CalcWeight(offset);
if (weight <= 0f)
return;
AmbientDirection direction = AmbientSoundConstants.CalcDirection(offset);
TotalSoundCount += weight;
for (int i = 0; i < table.AmbientSounds.Count; i++)
{
AmbientSoundInstance instance = Track(descriptorAt(table, i), table.STBId);
instance.AddTo(weight, offset, direction);
}
}
/// <summary>
/// Contribute one land cell's weight to a single instance. Test seam and the
/// single-descriptor case; <see cref="ContributeCell{TTable}"/> is the
/// production path and owns the shared denominator.
/// </summary>
public void Contribute(AmbientSoundInstance instance, Vector3 offset)
{
ArgumentNullException.ThrowIfNull(instance);
float weight = AmbientSoundConstants.CalcWeight(offset);
if (weight <= 0f)
return;
instance.AddTo(weight, offset, AmbientSoundConstants.CalcDirection(offset));
TotalSoundCount += weight;
}
/// <summary>
/// Finish a rebuild: recompute every instance's crossfade against the shared
/// denominator, then arm any audible instance that is not already queued.
///
/// <para>
/// The <c>on_queue</c> guard is load-bearing in both directions. Re-arming
/// unconditionally restarts every ambient on every 24 m crossing — audible as
/// a machine-gun of one-shots. Never re-arming leaves a newly-audible ambient
/// silent until the next rebuild.
/// </para>
/// </summary>
public void EndRebuild(
double now,
ICollection<AmbientSoundFiring>? firings = null,
Vector3 listenerPosition = default)
{
foreach (AmbientSoundInstance instance in _instances)
instance.UpdateSound(TotalSoundCount);
foreach (AmbientSoundInstance instance in _instances)
{
if (instance.OnQueue || !instance.CanHear())
continue;
// UpdatePlayQueue @ 0x551A50 calls Play for every on_queue == 0
// sound, and Play @ 0x5517A0 PLAYS first and then re-arms. There is
// no initial delay: an ambient that becomes audible at a crossing
// sounds at the crossing, not one min_rate later.
Fire(instance, now, firings, listenerPosition);
}
}
/// <summary>
/// Drain every deadline that has come due — <c>Ambient::UseTime</c>. Each pop
/// either fires (and re-arms) or, if the instance can no longer be heard,
/// leaves the queue entirely until a later rebuild re-arms it.
/// </summary>
public void Tick(double now, ICollection<AmbientSoundFiring> firings, Vector3 listenerPosition)
{
ArgumentNullException.ThrowIfNull(firings);
// Ambient::UseTime @ 0x551880 breaks on !(key < cur_time) — STRICTLY
// below. That is also what stops a descriptor authored with a zero rate
// from re-arming at the same instant and spinning this loop forever.
while (_queue.TryPeek(out _, out double deadline) && deadline < now)
{
AmbientSoundInstance instance = _queue.Dequeue();
instance.OnQueue = false;
// Ambient::Play @ 0x5517A0 — an inaudible instance drops off the
// queue rather than re-arming.
if (!instance.CanHear())
continue;
Fire(instance, now, firings, listenerPosition);
}
}
/// <summary>
/// Drop every instance and deadline — <c>Ambient::FlushSoundTables</c> /
/// <c>ReleaseSoundTables</c>, reached from <c>CellManager::Reset</c>.
/// </summary>
public void Clear()
{
foreach (AmbientSoundInstance instance in _instances)
instance.OnQueue = false;
_instances.Clear();
_queue.Clear();
TotalSoundCount = 0f;
}
/// <summary>
/// <c>Ambient::Play</c> @ <c>0x5517A0</c>: roll, emit if it fires, then
/// re-arm at <c>cur_time + GetPlayInterval()</c> regardless.
/// </summary>
private void Fire(
AmbientSoundInstance instance,
double now,
ICollection<AmbientSoundFiring>? firings,
Vector3 listenerPosition)
{
if (firings is not null && instance.PlayNow(_rng))
{
Vector3? position =
instance.TryGetSoundPosition(listenerPosition, _rng, out Vector3 resolved)
? resolved
: null;
firings.Add(new AmbientSoundFiring(instance, instance.GetVolume(), position));
}
Enqueue(instance, now);
}
private void Enqueue(AmbientSoundInstance instance, double now)
{
_queue.Enqueue(instance, now + instance.GetPlayInterval(_rng));
instance.OnQueue = true;
}
}