using System; using System.Collections.Generic; using System.Numerics; namespace AcDream.Core.Audio; /// /// One ambient firing, as the scheduler hands it to the audio backend. /// /// The instance that fired (its descriptor names the slot). /// The volume to play at — crossfaded for a continuous bed. /// /// The world position, or null for a continuous bed — retail plays those through /// PlayAmbientSoundFromCenter, with no position, no pan and no distance /// attenuation: a stereo bed centred on the listener. /// public readonly record struct AmbientSoundFiring( AmbientSoundInstance Instance, float Volume, Vector3? Position); /// /// 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 min_rate seconds, /// which is why an AL_LOOPING port sounds wrong (no re-randomised table /// pick, no re-rolled crossfade volume, no gap). /// /// /// Ports Ambient::UpdatePlayQueue @ 0x551A50, /// Ambient::Play @ 0x5517A0 and Ambient::UseTime @ /// 0x551880. /// /// public sealed class AmbientSoundScheduler { private readonly PriorityQueue _queue = new(); private readonly List _instances = []; private readonly ISoundRandom _rng; public AmbientSoundScheduler(ISoundRandom? rng = null) => _rng = rng ?? new SoundRandom(); /// Every live instance, in accumulation order. public IReadOnlyList Instances => _instances; /// Instances currently holding a deadline. Diagnostic use. public int QueuedCount => _queue.Count; /// Sum of every instance's weight — the crossfade denominator. public float TotalSoundCount { get; private set; } /// /// Begin a rebuild. Ambient::InitSounds @ 0x5515D0 resets EVERY /// existing instance's counters before the accumulation pass — skipping that /// leaves stale bearings and probabilities alive indefinitely, because /// IntermitSound::UpdateSound never clears them itself. /// public void BeginRebuild() { foreach (AmbientSoundInstance instance in _instances) instance.ResetCount(); TotalSoundCount = 0f; } /// /// Register an instance for this world, if it is not already tracked. Called /// while walking the contributing land cells. /// 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; } /// /// Contribute one land cell to every ambient its sound table authors — /// Ambient::AddSound @ 0x551610. /// /// /// The cell's weight lands in exactly ONCE, /// however many descriptors the table carries. Retail adds w to /// total_sound_count 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. /// /// public void ContributeCell( Vector3 offset, TTable table, Func 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); } } /// /// Contribute one land cell's weight to a single instance. Test seam and the /// single-descriptor case; is the /// production path and owns the shared denominator. /// 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; } /// /// Finish a rebuild: recompute every instance's crossfade against the shared /// denominator, then arm any audible instance that is not already queued. /// /// /// The on_queue 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. /// /// public void EndRebuild( double now, ICollection? 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); } } /// /// Drain every deadline that has come due — Ambient::UseTime. 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. /// public void Tick(double now, ICollection 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); } } /// /// Drop every instance and deadline — Ambient::FlushSoundTables / /// ReleaseSoundTables, reached from CellManager::Reset. /// public void Clear() { foreach (AmbientSoundInstance instance in _instances) instance.OnQueue = false; _instances.Clear(); _queue.Clear(); TotalSoundCount = 0f; } /// /// Ambient::Play @ 0x5517A0: roll, emit if it fires, then /// re-arm at cur_time + GetPlayInterval() regardless. /// private void Fire( AmbientSoundInstance instance, double now, ICollection? 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; } }