feat(audio): Campaign A slice A2 — retail's 2D pan+gain mixer replaces AL 3D
Retail is not a 3D audio engine. Every gameplay buffer is created with m_3D = 0 and the DirectSound 3D listener the client sets up is dead code; spatialization is two CPU scalars per voice, frozen at emission. This slice ports that math and demotes OpenAL to a voice bank. RetailSoundMixer (new, Core) carries the byte-decoded curve from SoundManager::GetAttenuation @0x00550020: g = dist < 5 ? vol : 25*vol/d2, clamped to 1 BEFORE the single master multiply, db = ceil(20*log10 g), with a hard -50 dB floor at which retail does not start the voice at all (audible radius ~94.2 m at unity). Pan is PlaySoundInternal @0x00550170's (int)(-15*sin(delta-bearing)) in whole decibels, truncating toward zero, forced to dead centre when (int)distance < 5, with no front/back and no elevation cue. Every AL source is now source-relative with rolloff 0 and the global distance model is None: AL's InverseDistanceClamped was first-power (2/d), quieter than retail up close and far louder at range with no cutoff whatsoever. That was the largest audible divergence in the subsystem (AP-28, retired here). RetailVoicePool (new, Core) ports the allocator at 0x0054FEC0: ring scan for a free or finished slot, then evict the first slot whose DAT priority is strictly lower, else drop. Eviction compared GAIN before, so a loud unimportant sound could silence a quiet important one. It lives in Core because the engine's play path talks to native AL handles and could not be tested; the pool now has 12 conformance tests. The listener keeps using the camera position, which the decode shows is retail-faithful (SmartBox::set_viewer @0x00452D36 hands the same collided camera Position to SoundManager) — only the heading extraction changes, since retail reads one compass bearing and never a forward/up basis. An earlier draft of the plan called this a defect; corrected in the plan so it is not fixed backwards. Opus review found and this commit fixes: a linear pan-to-azimuth mapping that saturated to full separation at 30 degrees (OpenAL Soft's own speaker angle) where retail gives 15 dB — now inverts the constant-power pan law, so full deflection reaches 0.776 of the arc and both channels stay live; the stale FUN_00550ad0 / gain-eviction class header, which contradicted the register row this commit writes; missing discriminating tests for clamp order and pan truncation; dead PlayingGain state whose comment invented a retail symbol; and a third in-tree copy of Position::heading, now delegating to MoveToMath.PositionHeading. MasterVolume folds into the mixer's one multiply instead of AL listener gain, so the cutoff, radius and dB quantisation move with the slider. Register: AP-28 retired; AP-173 (pan law), AP-174 (volume taxonomy), TS-64 (two unimplemented sound prefs), TS-65 (volume-squared quirk, applied on the ambient path only) filed. Research note corrected twice where its summary contradicted its own decode (30 m dB, floor vs trunc). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
c69b3bde04
commit
e42b99482e
13 changed files with 1123 additions and 106 deletions
File diff suppressed because one or more lines are too long
|
|
@ -128,7 +128,7 @@ Divergent or missing, ranked by audible impact:
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 1 | Probability gate absent: `SoundCookbook.Roll` short-circuits single-entry lists (4,183/4,184 entries!) before any roll; CDF walk instead of `(n−1)` pick + gate | `SoundCookbook.cs` | Idle chatter ~20× too often; nothing ever randomly silent — the "incorrect ambient-ish noise" complaint |
|
| 1 | Probability gate absent: `SoundCookbook.Roll` short-circuits single-entry lists (4,183/4,184 entries!) before any roll; CDF walk instead of `(n−1)` pick + gate | `SoundCookbook.cs` | Idle chatter ~20× too often; nothing ever randomly silent — the "incorrect ambient-ish noise" complaint |
|
||||||
| 2 | 0xF750 unhandled — zero hits in `src/` | `Core.Net` routing | Every server cue silent (hits, wounds, pickup, locks, lifestone…) |
|
| 2 | 0xF750 unhandled — zero hits in `src/` | `Core.Net` routing | Every server cue silent (hits, wounds, pickup, locks, lifestone…) |
|
||||||
| 3 | Falloff: AL `InverseDistanceClamped` ref 2 m ⇒ `2/d` first-power, no −50 dB cutoff; listener = CAMERA; AL 3D pan | engine + `WorldRenderFrameBuilder` | Wrong loudness curve both directions; pan wrong from spring-arm offset (AP-28) |
|
| 3 | Falloff: AL `InverseDistanceClamped` ref 2 m ⇒ `2/d` first-power, no −50 dB cutoff; AL's 3D panner instead of retail's ±15 dB angular pan | engine + `WorldRenderFrameBuilder` | Wrong loudness curve in both directions — quieter than retail up close, audible where retail is silent; stereo image wider and 3-D where retail's is a narrow angular pan (AP-28) |
|
||||||
| 4 | Priority float [0,1] cast to int 0..7 → 4,100 entries collapse to 0; eviction compares gain not priority | `AudioModel`/engine | Eviction ordering gutted under voice pressure |
|
| 4 | Priority float [0,1] cast to int 0..7 → 4,100 entries collapse to 0; eviction compares gain not priority | `AudioModel`/engine | Eviction ordering gutted under voice pressure |
|
||||||
| 5 | Volume clamped at field instead of after distance divide | `AudioHookSink` | >1-gain sounds lose up to 3× audible range |
|
| 5 | Volume clamped at field instead of after distance divide | `AudioHookSink` | >1-gain sounds lose up to 3× audible range |
|
||||||
| 6 | Region ambient system absent (`StartAmbient` stub) | engine | Silent outdoors atmosphere (TS-29 half) |
|
| 6 | Region ambient system absent (`StartAmbient` stub) | engine | Silent outdoors atmosphere (TS-29 half) |
|
||||||
|
|
@ -172,9 +172,23 @@ Port `GetAttenuation` + pan CPU-side exactly (5 m knee, `25·vol/d²`,
|
||||||
clamp-after, ceil-dB, −50 dB no-allocate floor, `−15·sin(Δheading)` pan
|
clamp-after, ceil-dB, −50 dB no-allocate floor, `−15·sin(Δheading)` pan
|
||||||
±15 dB with 5 m dead zone, `Sound Features==1` pan disable). OpenAL
|
±15 dB with 5 m dead zone, `Sound Features==1` pan disable). OpenAL
|
||||||
becomes a dumb 2D voice bank: source-relative sources, per-voice gain +
|
becomes a dumb 2D voice bank: source-relative sources, per-voice gain +
|
||||||
pan (AL_POSITION ±x from pan only); remove `SelectRetailDistanceModel`
|
pan (AL_POSITION azimuth from pan only); remove AL's distance model and the
|
||||||
and listener orientation math. Listener feed moves from camera pose to
|
listener orientation math.
|
||||||
player position/heading. Eviction compares float priority (equal never
|
|
||||||
|
**Listener correction (2026-08-08, from the lane-1 decode):** an earlier draft
|
||||||
|
of this plan said the listener must move "from camera pose to player
|
||||||
|
position/heading" and listed "listener = CAMERA" as a defect. That was wrong,
|
||||||
|
and it was written before lane 1 landed. Retail's listener IS the camera:
|
||||||
|
`SmartBox::set_viewer` @ `0x00452D36` hands the same COLLIDED third-person
|
||||||
|
camera Position to `SoundManager::SetPlayerPosition`, the sky, and the camera
|
||||||
|
setup, refreshed once per rendered frame from `SmartBox::update_viewer` @
|
||||||
|
`0x00453CE0` (falling back to the player's own position when the sphere sweep
|
||||||
|
fails). acdream's chase camera collides too, so the position source was already
|
||||||
|
faithful; only the HEADING extraction changes, since retail reads
|
||||||
|
`Frame::get_heading` — one compass bearing — and never a forward/up basis.
|
||||||
|
Do not "fix" this back.
|
||||||
|
|
||||||
|
Eviction compares float priority (equal never
|
||||||
evicts); fix the pool citation to `PlaySoundInternal @ 0x0054FEC0`.
|
evicts); fix the pool citation to `PlaySoundInternal @ 0x0054FEC0`.
|
||||||
Keep the squared-volume quirk faithful (register row if we later soften
|
Keep the squared-volume quirk faithful (register row if we later soften
|
||||||
it). Map settings: Master (ours, AL listener gain) + Effect + Ambient +
|
it). Map settings: Master (ours, AL listener gain) + Effect + Ambient +
|
||||||
|
|
@ -281,8 +295,8 @@ global kill switch.
|
||||||
|
|
||||||
| Slice | Status | Commit | Gates |
|
| Slice | Status | Commit | Gates |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| A1 | **COMPLETE** 2026-08-08 | (this commit) | 42 Core audio tests; full Release suite 11,563 passed / 4 skipped / 0 failed. Closes #355. |
|
| A1 | **COMPLETE** 2026-08-08 | `c69b3bde` | 42 Core audio tests; full Release suite 11,563 passed / 4 skipped / 0 failed. Closes #355. |
|
||||||
| A2 | — | — | — |
|
| A2 | **COMPLETE** 2026-08-08 | `6d0156cb` | 118 Core audio tests (mixer + voice pool + cookbook); full Release suite 11,639 passed / 4 skipped / 0 failed. Opus review run and applied — 2 HIGH (pan-law saturation, stale `FUN_00550ad0` header), 5 MEDIUM (untested clamp order / pan truncation / voice pool, dead `PlayingGain`, duplicated heading helper), 5 LOW. Retires AP-28; files AP-173, AP-174, TS-64, TS-65. **Owed: user listening gate.** |
|
||||||
| A3 | — | — | — |
|
| A3 | — | — | — |
|
||||||
| A4 | — | — | — |
|
| A4 | — | — | — |
|
||||||
| A5 | — | — | — |
|
| A5 | — | — | — |
|
||||||
|
|
|
||||||
|
|
@ -633,13 +633,19 @@ finds no caller.
|
||||||
|
|
||||||
### D4/D6 numbers side by side (vol = master = 1.0)
|
### D4/D6 numbers side by side (vol = master = 1.0)
|
||||||
|
|
||||||
|
> **Corrected 2026-08-08 at the A2 code review:** the 30 m row read −35 dB, which
|
||||||
|
> contradicted both its own gain column (0.0278) and the formula —
|
||||||
|
> `ceil(20·log10 0.027778) = ceil(-31.13) = -31`. It is now −31. The conformance
|
||||||
|
> tests in `RetailSoundMixerTests` recompute every row from the decoded formula
|
||||||
|
> rather than reading this table, which is how the slip surfaced.
|
||||||
|
|
||||||
| distance | retail gain | retail dB (`ceil`) | acdream gain (`2/max(d,2)`) | acdream dB |
|
| distance | retail gain | retail dB (`ceil`) | acdream gain (`2/max(d,2)`) | acdream dB |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| 2 m | 1.000 | 0 | 1.000 | 0.0 |
|
| 2 m | 1.000 | 0 | 1.000 | 0.0 |
|
||||||
| 5 m | 1.000 | 0 | 0.400 | −8.0 |
|
| 5 m | 1.000 | 0 | 0.400 | −8.0 |
|
||||||
| 10 m | 0.250 | −12 | 0.200 | −14.0 |
|
| 10 m | 0.250 | −12 | 0.200 | −14.0 |
|
||||||
| 20 m | 0.0625 | −24 | 0.100 | −20.0 |
|
| 20 m | 0.0625 | −24 | 0.100 | −20.0 |
|
||||||
| 30 m | 0.0278 | −35 | 0.0667 | −23.5 |
|
| 30 m | 0.0278 | −31 | 0.0667 | −23.5 |
|
||||||
| 50 m | 0.0100 | −40 | 0.0400 | −28.0 |
|
| 50 m | 0.0100 | −40 | 0.0400 | −28.0 |
|
||||||
| 90 m | 0.00309 | −50 (last audible) | 0.0222 | −33.1 |
|
| 90 m | 0.00309 | −50 (last audible) | 0.0222 | −33.1 |
|
||||||
| ≥94.2 m | — | **not played** | 0.0212 | −33.5 |
|
| ≥94.2 m | — | **not played** | 0.0212 | −33.5 |
|
||||||
|
|
@ -670,7 +676,10 @@ Per play (3D):
|
||||||
if g <= 0: drop
|
if g <= 0: drop
|
||||||
db = ceil(20*log10(g)); if db < -50: drop
|
db = ceil(20*log10(g)); if db < -50: drop
|
||||||
delta = normalise180( bearing(source -> listener) - listenerHeadingDegrees )
|
delta = normalise180( bearing(source -> listener) - listenerHeadingDegrees )
|
||||||
pan = (int)floor(-15 * sin(delta * pi/180)) clamped [-15, 15]
|
pan = (int)(-15 * sin(delta * pi/180)) # TRUNCATE toward zero (retail _ftol2),
|
||||||
|
# NOT floor: they differ by 1 dB for
|
||||||
|
# negative pans. Corrected 2026-08-08 at
|
||||||
|
# the A2 review; §1 was already right.
|
||||||
if (int)dist < 5: pan = 0
|
if (int)dist < 5: pan = 0
|
||||||
allocate voice: ring scan from cursor for free/finished;
|
allocate voice: ring scan from cursor for free/finished;
|
||||||
else first slot with slotPriority < newPriority;
|
else first slot with slotPriority < newPriority;
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,11 @@ using Silk.NET.OpenAL;
|
||||||
namespace AcDream.App.Audio;
|
namespace AcDream.App.Audio;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// OpenAL-backed audio engine (Phase E.2) — faithful to retail's
|
/// OpenAL-backed audio engine. Spatialization is NOT OpenAL's: retail creates
|
||||||
/// 16-voice pool and inverse-square falloff behaviour (r05 §5.3).
|
/// every gameplay buffer 2D (<c>m_3D = 0</c>) and computes a gain and a stereo
|
||||||
|
/// pan on the CPU per voice, so <see cref="RetailSoundMixer"/> owns that math
|
||||||
|
/// and AL is reduced to a voice bank. Every source is source-relative with
|
||||||
|
/// <c>AL_ROLLOFF_FACTOR = 0</c>.
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Architecture:
|
/// Architecture:
|
||||||
|
|
@ -19,15 +22,19 @@ namespace AcDream.App.Audio;
|
||||||
/// PulseAudio / CoreAudio — whichever OpenAL-Soft picks).
|
/// PulseAudio / CoreAudio — whichever OpenAL-Soft picks).
|
||||||
/// </description></item>
|
/// </description></item>
|
||||||
/// <item><description>
|
/// <item><description>
|
||||||
/// Fixed 16-source pool for 3D positional sounds. When all 16 are
|
/// Fixed 16-source pool for world sounds, allocated by
|
||||||
/// busy, new Play3D calls evict the slot whose currently-playing
|
/// <see cref="RetailVoicePool"/>: a ring scan for a free or finished slot,
|
||||||
/// sound has lower effective gain than the incoming sound
|
/// then eviction of the first slot whose DAT-authored priority is strictly
|
||||||
/// (matches retail <c>FUN_00550ad0</c> first-free-then-evict-quieter
|
/// lower, else the sound is dropped. Retail's allocator is
|
||||||
/// algorithm at <c>chunk_00550000.c:527</c>).
|
/// <c>SoundManager::PlaySoundInternal</c> @ <c>0x0054FEC0</c> and it never
|
||||||
|
/// consults gain. (This comment previously cited <c>FUN_00550ad0</c> and
|
||||||
|
/// described gain-based eviction; that address is inside an
|
||||||
|
/// <c>IntrusiveHashTable</c> constructor and the behaviour was ours, not
|
||||||
|
/// retail's — both corrected in Campaign A slice A2, register row AP-28.)
|
||||||
/// </description></item>
|
/// </description></item>
|
||||||
/// <item><description>
|
/// <item><description>
|
||||||
/// Separate UI source pool (4 sources) for flat 2D UI clicks /
|
/// Separate UI source pool (4 sources) for flat 2D UI clicks /
|
||||||
/// wooshes — not subject to the 3D eviction game.
|
/// wooshes — not subject to the world pool's eviction game.
|
||||||
/// </description></item>
|
/// </description></item>
|
||||||
/// <item><description>
|
/// <item><description>
|
||||||
/// PCM buffer cache keyed by Wave dat id so the same footstep isn't
|
/// PCM buffer cache keyed by Wave dat id so the same footstep isn't
|
||||||
|
|
@ -72,13 +79,14 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescen
|
||||||
private const int PoolSize3D = 16; // retail 16-slot voice pool
|
private const int PoolSize3D = 16; // retail 16-slot voice pool
|
||||||
private const int PoolSizeUi = 4;
|
private const int PoolSizeUi = 4;
|
||||||
|
|
||||||
// Slot state per 3D source; mirrors retail's g_poolVols array (the
|
// Slot state per 3D source, mirroring retail's `SoundPlayingData` —
|
||||||
// EFFECTIVE gain at play-start time, used for eviction comparisons).
|
// {buffer, priority, start_time}. There is no gain field: retail's
|
||||||
|
// allocator compares priority only, and its start_time is written but never
|
||||||
|
// read, so neither a gain nor a timestamp is carried here.
|
||||||
private sealed class Slot3D
|
private sealed class Slot3D
|
||||||
{
|
{
|
||||||
public uint SourceId;
|
public uint SourceId;
|
||||||
public uint OwnerId;
|
public uint OwnerId;
|
||||||
public float PlayingGain; // gain at play time (for eviction compare)
|
|
||||||
public bool InUse;
|
public bool InUse;
|
||||||
// The DAT-authored priority, a float in [0,1] — NOT an 0..7 int. 4,100
|
// The DAT-authored priority, a float in [0,1] — NOT an 0..7 int. 4,100
|
||||||
// of the shipped entries carry a sub-1.0 priority that an int cast
|
// of the shipped entries carry a sub-1.0 priority that an int cast
|
||||||
|
|
@ -92,6 +100,30 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescen
|
||||||
|
|
||||||
private readonly uint[] _poolUi = new uint[PoolSizeUi];
|
private readonly uint[] _poolUi = new uint[PoolSizeUi];
|
||||||
|
|
||||||
|
// ── Listener (retail's SmartBox::viewer: origin + compass heading) ───────
|
||||||
|
private Vector3 _listenerPosition;
|
||||||
|
private float _listenerHeadingDegrees;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Half-width of the stereo pan arc, in degrees — OpenAL Soft's own
|
||||||
|
/// front-left/front-right speaker angle for a stereo device, so a normalised
|
||||||
|
/// stereo position of ±1 lands exactly on a speaker.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Positions are NOT retail's pan scaled linearly onto this arc.
|
||||||
|
/// <see cref="RetailSoundMixer.StereoPositionFromPan"/> inverts the
|
||||||
|
/// constant-power pan law first, so retail's ±15 dB inter-channel difference
|
||||||
|
/// maps to ±0.775 of the arc and both channels stay live; a linear mapping
|
||||||
|
/// would put full deflection on the speaker angle itself, giving effectively
|
||||||
|
/// infinite separation where retail gives 15 dB. The pan's shape is retail's
|
||||||
|
/// throughout (sine of the compass bearing, dead centre inside 5 m, no
|
||||||
|
/// front/back and no elevation, frozen for the voice's life); only the pan
|
||||||
|
/// LAW is approximated, since OpenAL exposes no per-channel gain for a mono
|
||||||
|
/// source. Registered as AP-173.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
private const float MaxPanAzimuthDegrees = 30f;
|
||||||
|
|
||||||
// ── Buffer cache (Wave dat id → AL buffer) ───────────────────────────────
|
// ── Buffer cache (Wave dat id → AL buffer) ───────────────────────────────
|
||||||
// Budget rationale: decoded PCM waves run ~100-500 KB each (same sizing
|
// Budget rationale: decoded PCM waves run ~100-500 KB each (same sizing
|
||||||
// as DatSoundCache's payload LRU, which this cache re-uploads from). 48
|
// as DatSoundCache's payload LRU, which this cache re-uploads from). 48
|
||||||
|
|
@ -175,7 +207,7 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescen
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global distance model = inverse-square clamped (classic retail feel).
|
// Global distance model = inverse-square clamped (classic retail feel).
|
||||||
api.SelectRetailDistanceModel();
|
api.DisableAlDistanceAttenuation();
|
||||||
|
|
||||||
_available = true;
|
_available = true;
|
||||||
}
|
}
|
||||||
|
|
@ -225,25 +257,34 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescen
|
||||||
|
|
||||||
// ── IAudioEngine ─────────────────────────────────────────────────────────
|
// ── IAudioEngine ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public void SetListener(
|
/// <summary>
|
||||||
float posX, float posY, float posZ,
|
/// Records the listener pose retail's mixer reads: origin (for distance)
|
||||||
float forwardX, float forwardY, float forwardZ,
|
/// and compass heading (for pan). No AL listener orientation is published —
|
||||||
float upX, float upY, float upZ)
|
/// every voice is source-relative and its pan is computed on the CPU, so
|
||||||
|
/// AL's own panner must not also rotate the field.
|
||||||
|
/// </summary>
|
||||||
|
public void SetListener(float posX, float posY, float posZ, float headingDegrees)
|
||||||
{
|
{
|
||||||
if (!_available || _al is null) return;
|
_listenerPosition = new Vector3(posX, posY, posZ);
|
||||||
|
_listenerHeadingDegrees = headingDegrees;
|
||||||
_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>
|
||||||
|
/// The master multiply retail's <c>GetAttenuation</c> applies — the effect
|
||||||
|
/// knob for world/UI sounds, folded with acdream's extra master slider.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// It is folded in HERE, before the mixer, rather than published as AL's
|
||||||
|
/// listener gain, because retail's audibility decisions are made against the
|
||||||
|
/// post-master value: the −50 dB no-allocate floor, the audible radius, and
|
||||||
|
/// the whole-decibel quantisation all move with the knob. Applying it
|
||||||
|
/// downstream as a listener gain would compute the cutoff against a louder
|
||||||
|
/// signal than the user hears, and would allocate voices at master 0 where
|
||||||
|
/// retail's <c>g <= 0</c> gate drops them.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
private float EffectMaster => MasterVolume * SfxVolume;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Not exposed on IAudioEngine but used by the hook sink — play a raw
|
/// Not exposed on IAudioEngine but used by the hook sink — play a raw
|
||||||
/// WaveData blob at a 3D position with full priority/volume controls.
|
/// WaveData blob at a 3D position with full priority/volume controls.
|
||||||
|
|
@ -259,50 +300,91 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescen
|
||||||
{
|
{
|
||||||
if (_worldAudioSuspended || !_available || _al is null) return false;
|
if (_worldAudioSuspended || !_available || _al is null) return false;
|
||||||
|
|
||||||
float effectiveGain = volume * SfxVolume;
|
// Retail computes gain and pan BEFORE touching the voice pool, and a
|
||||||
if (effectiveGain < 0.001f) return false; // silent; skip
|
// sound that attenuates past -50 dB is never started at all — so it
|
||||||
|
// consumes no slot and evicts nothing. At volume × master == 1 that
|
||||||
|
// silence radius is about 94 metres.
|
||||||
|
RetailVoiceMix mix = RetailSoundMixer.Mix(
|
||||||
|
_listenerPosition,
|
||||||
|
_listenerHeadingDegrees,
|
||||||
|
position,
|
||||||
|
volume,
|
||||||
|
EffectMaster);
|
||||||
|
if (!mix.Play) return false;
|
||||||
|
|
||||||
uint buffer = EnsureBuffer(waveId, wave);
|
uint buffer = EnsureBuffer(waveId, wave);
|
||||||
if (buffer == 0) return false;
|
if (buffer == 0) return false;
|
||||||
|
|
||||||
// Pick a slot: first free, else evict quieter one, else drop.
|
int slotIdx = AcquireWorldSlot(priority);
|
||||||
int slotIdx = -1;
|
if (slotIdx < 0) return false; // nothing lower-priority — drop
|
||||||
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
|
|
||||||
|
|
||||||
|
float gain = RetailSoundMixer.LinearGain(mix.Decibels);
|
||||||
var slot = _pool3D[slotIdx];
|
var slot = _pool3D[slotIdx];
|
||||||
_al.SourceStop(slot.SourceId);
|
_al.SourceStop(slot.SourceId);
|
||||||
_al.SetSourceProperty(slot.SourceId, SourceInteger.Buffer, 0); // detach old
|
_al.SetSourceProperty(slot.SourceId, SourceInteger.Buffer, 0); // detach old
|
||||||
_al.SetSourceProperty(slot.SourceId, SourceInteger.Buffer, (int)buffer);
|
_al.SetSourceProperty(slot.SourceId, SourceInteger.Buffer, (int)buffer);
|
||||||
_al.SetSourceProperty(slot.SourceId, SourceFloat.Gain, effectiveGain);
|
_al.SetSourceProperty(slot.SourceId, SourceFloat.Gain, gain);
|
||||||
// No pitch: retail never calls SetFrequency on a sound buffer, so
|
// No pitch: retail never calls SetFrequency on a sound buffer, so
|
||||||
// there is no per-play pitch variation to reproduce.
|
// there is no per-play pitch variation to reproduce.
|
||||||
_al.SetSourceProperty(slot.SourceId, SourceVector3.Position, position.X, position.Y, position.Z);
|
ApplyPan(slot.SourceId, mix.Pan);
|
||||||
_al.SetSourceProperty(slot.SourceId, SourceBoolean.SourceRelative, false);
|
|
||||||
_al.SetSourceProperty(slot.SourceId, SourceBoolean.Looping, false);
|
_al.SetSourceProperty(slot.SourceId, SourceBoolean.Looping, false);
|
||||||
_al.SourcePlay(slot.SourceId);
|
_al.SourcePlay(slot.SourceId);
|
||||||
|
|
||||||
slot.PlayingGain = effectiveGain;
|
|
||||||
slot.InUse = true;
|
slot.InUse = true;
|
||||||
slot.OwnerId = ownerId;
|
slot.OwnerId = ownerId;
|
||||||
slot.Priority = priority;
|
slot.Priority = priority;
|
||||||
_pool3DCursor = (slotIdx + 1) & (PoolSize3D - 1);
|
_pool3DCursor = RetailVoicePool.AdvanceCursor(slotIdx, PoolSize3D);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Projects the live pool into <see cref="RetailVoicePool"/>'s slot view and
|
||||||
|
/// takes its answer. The allocation policy itself lives in Core so it can be
|
||||||
|
/// tested without an AL device; this method only supplies the one piece of
|
||||||
|
/// state AL owns — whether each slot's voice is still playing.
|
||||||
|
/// </summary>
|
||||||
|
private int AcquireWorldSlot(float priority)
|
||||||
|
{
|
||||||
|
Span<VoiceSlotState> slots = stackalloc VoiceSlotState[PoolSize3D];
|
||||||
|
for (int i = 0; i < PoolSize3D; i++)
|
||||||
|
{
|
||||||
|
Slot3D s = _pool3D[i];
|
||||||
|
slots[i] = new VoiceSlotState(
|
||||||
|
Occupied: s.InUse,
|
||||||
|
StillPlaying: s.InUse && IsStillPlaying(s.SourceId),
|
||||||
|
Priority: s.Priority);
|
||||||
|
}
|
||||||
|
|
||||||
|
return RetailVoicePool.Acquire(slots, _pool3DCursor, priority);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Publishes retail's whole-decibel pan as a source-relative azimuth. The
|
||||||
|
/// source sits on a unit arc in front of the listener so a pan of 0 is dead
|
||||||
|
/// ahead (centred) and the deflection is purely left/right — retail
|
||||||
|
/// distinguishes neither front from back nor elevation. Distance plays no
|
||||||
|
/// part: rolloff is 0 and the CPU-computed gain is authoritative.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// The azimuth comes from <see cref="RetailSoundMixer.StereoPositionFromPan"/>,
|
||||||
|
/// which inverts the constant-power pan law so the resulting inter-channel
|
||||||
|
/// difference is retail's ±15 dB rather than the full separation a linear
|
||||||
|
/// mapping onto the speaker angle would produce. See AP-172.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
private void ApplyPan(uint sourceId, int pan)
|
||||||
|
{
|
||||||
|
float position = RetailSoundMixer.StereoPositionFromPan(pan);
|
||||||
|
float azimuth = position * MaxPanAzimuthDegrees * (MathF.PI / 180f);
|
||||||
|
_al!.SetSourceProperty(sourceId, SourceBoolean.SourceRelative, true);
|
||||||
|
_al.SetSourceProperty(
|
||||||
|
sourceId,
|
||||||
|
SourceVector3.Position,
|
||||||
|
MathF.Sin(azimuth),
|
||||||
|
0f,
|
||||||
|
-MathF.Cos(azimuth));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Stops every world-space voice while preserving the independent UI
|
/// Stops every world-space voice while preserving the independent UI
|
||||||
/// source pool. Retail suppresses ambient/object audio while cell loading
|
/// source pool. Retail suppresses ambient/object audio while cell loading
|
||||||
|
|
@ -349,11 +431,18 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescen
|
||||||
}
|
}
|
||||||
if (slotIdx < 0) slotIdx = 0; // always replace slot 0 as a last resort
|
if (slotIdx < 0) slotIdx = 0; // always replace slot 0 as a last resort
|
||||||
|
|
||||||
|
// Retail's interface sounds go through PlaySoundFromCenter: pan 0, and
|
||||||
|
// GetAttenuation at distance 0 (so the flat branch), scaled by
|
||||||
|
// effect_sound_volume — NOT by interface_sound_volume, which retail
|
||||||
|
// registers as a preference and then never reads.
|
||||||
|
if (!RetailSoundMixer.TryGetAttenuation(0f, volume, EffectMaster, out int decibels))
|
||||||
|
return false;
|
||||||
|
|
||||||
uint src = _poolUi[slotIdx];
|
uint src = _poolUi[slotIdx];
|
||||||
_al.SourceStop(src);
|
_al.SourceStop(src);
|
||||||
_al.SetSourceProperty(src, SourceInteger.Buffer, 0);
|
_al.SetSourceProperty(src, SourceInteger.Buffer, 0);
|
||||||
_al.SetSourceProperty(src, SourceInteger.Buffer, (int)buffer);
|
_al.SetSourceProperty(src, SourceInteger.Buffer, (int)buffer);
|
||||||
_al.SetSourceProperty(src, SourceFloat.Gain, Math.Clamp(volume, 0f, 1f) * SfxVolume);
|
_al.SetSourceProperty(src, SourceFloat.Gain, RetailSoundMixer.LinearGain(decibels));
|
||||||
_al.SourcePlay(src);
|
_al.SourcePlay(src);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -519,7 +608,6 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescen
|
||||||
}
|
}
|
||||||
|
|
||||||
slot.OwnerId = 0;
|
slot.OwnerId = 0;
|
||||||
slot.PlayingGain = 0f;
|
|
||||||
slot.Priority = 0f;
|
slot.Priority = 0f;
|
||||||
slot.InUse = false;
|
slot.InUse = false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ internal interface IOpenAlResourceApi
|
||||||
uint GenerateSource();
|
uint GenerateSource();
|
||||||
void Configure3DSource(uint source);
|
void Configure3DSource(uint source);
|
||||||
void ConfigureUiSource(uint source);
|
void ConfigureUiSource(uint source);
|
||||||
void SelectRetailDistanceModel();
|
void DisableAlDistanceAttenuation();
|
||||||
void StopSource(uint source);
|
void StopSource(uint source);
|
||||||
void DeleteSource(uint source);
|
void DeleteSource(uint source);
|
||||||
void DeleteBuffer(uint buffer);
|
void DeleteBuffer(uint buffer);
|
||||||
|
|
@ -57,12 +57,16 @@ internal sealed unsafe class SilkOpenAlResourceApi : IOpenAlResourceApi
|
||||||
|
|
||||||
public uint GenerateSource() => AudioApi.GenSource();
|
public uint GenerateSource() => AudioApi.GenSource();
|
||||||
|
|
||||||
|
// World voices are source-relative with rolloff 0: `RetailSoundMixer`
|
||||||
|
// computes retail's gain and pan on the CPU and they are authoritative, so
|
||||||
|
// AL must not attenuate by distance on top of that. Retail's own curve is
|
||||||
|
// inverse-SQUARE from a 5 m reference with a hard -50 dB cutoff, which AL's
|
||||||
|
// inverse model (first power only) cannot express anyway.
|
||||||
public void Configure3DSource(uint source)
|
public void Configure3DSource(uint source)
|
||||||
{
|
{
|
||||||
AudioApi.SetSourceProperty(source, SourceFloat.Gain, 1f);
|
AudioApi.SetSourceProperty(source, SourceFloat.Gain, 1f);
|
||||||
AudioApi.SetSourceProperty(source, SourceFloat.MaxDistance, 1000f);
|
AudioApi.SetSourceProperty(source, SourceFloat.RolloffFactor, 0f);
|
||||||
AudioApi.SetSourceProperty(source, SourceFloat.RolloffFactor, 1f);
|
AudioApi.SetSourceProperty(source, SourceBoolean.SourceRelative, true);
|
||||||
AudioApi.SetSourceProperty(source, SourceFloat.ReferenceDistance, 2f);
|
|
||||||
AudioApi.SetSourceProperty(source, SourceBoolean.Looping, false);
|
AudioApi.SetSourceProperty(source, SourceBoolean.Looping, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -73,8 +77,11 @@ internal sealed unsafe class SilkOpenAlResourceApi : IOpenAlResourceApi
|
||||||
AudioApi.SetSourceProperty(source, SourceBoolean.Looping, false);
|
AudioApi.SetSourceProperty(source, SourceBoolean.Looping, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SelectRetailDistanceModel() =>
|
// AL's distance models are all bypassed: rolloff 0 on every source means
|
||||||
AudioApi.DistanceModel(DistanceModel.InverseDistanceClamped);
|
// none of them contribute, and `RetailSoundMixer` owns the curve. Selecting
|
||||||
|
// None documents that rather than leaving a model that looks load-bearing.
|
||||||
|
public void DisableAlDistanceAttenuation() =>
|
||||||
|
AudioApi.DistanceModel(DistanceModel.None);
|
||||||
|
|
||||||
public void StopSource(uint source) => AudioApi.SourceStop(source);
|
public void StopSource(uint source) => AudioApi.SourceStop(source);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ using AcDream.App.Rendering.Wb;
|
||||||
using AcDream.App.Settings;
|
using AcDream.App.Settings;
|
||||||
using AcDream.App.Streaming;
|
using AcDream.App.Streaming;
|
||||||
using AcDream.App.World;
|
using AcDream.App.World;
|
||||||
|
using AcDream.Core.Audio;
|
||||||
using AcDream.Core.Lighting;
|
using AcDream.Core.Lighting;
|
||||||
using AcDream.Core.Physics;
|
using AcDream.Core.Physics;
|
||||||
using AcDream.Core.Rendering;
|
using AcDream.Core.Rendering;
|
||||||
|
|
@ -381,14 +382,19 @@ internal sealed class RuntimeWorldFrameSettingsPreview : IWorldFrameSettingsPrev
|
||||||
if (_audio is not { IsAvailable: true })
|
if (_audio is not { IsAvailable: true })
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
// Retail's audio listener IS the camera viewer, refreshed per rendered
|
||||||
|
// frame (`SmartBox::set_viewer` @ 0x00452D36 hands the same collided
|
||||||
|
// camera Position to SoundManager, the sky, and the camera setup), so
|
||||||
|
// the position source here is already faithful. What retail reads off it
|
||||||
|
// is only the origin and `Frame::get_heading` — a compass bearing, not a
|
||||||
|
// basis: pan comes from that heading alone, which is why retail has no
|
||||||
|
// front/back or elevation cue to reproduce.
|
||||||
Matrix4x4 inverse = camera.InverseView;
|
Matrix4x4 inverse = camera.InverseView;
|
||||||
var forward = new Vector3(-inverse.M31, -inverse.M32, -inverse.M33);
|
var forward = new Vector3(-inverse.M31, -inverse.M32, -inverse.M33);
|
||||||
var up = new Vector3(inverse.M21, inverse.M22, inverse.M23);
|
|
||||||
Vector3 position = camera.Position;
|
Vector3 position = camera.Position;
|
||||||
_audio.SetListener(
|
_audio.SetListener(
|
||||||
position.X, position.Y, position.Z,
|
position.X, position.Y, position.Z,
|
||||||
forward.X, forward.Y, forward.Z,
|
RetailSoundMixer.CompassHeadingDegrees(Vector3.Zero, forward));
|
||||||
up.X, up.Y, up.Z);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,32 +39,12 @@ public sealed class WaveData
|
||||||
public TimeSpan Duration { get; init; }
|
public TimeSpan Duration { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
// An `AudioFalloff` helper lived here until 2026-08-08 (Campaign A slice A2).
|
||||||
/// Falloff math (r05 §2). Retail is CPU-side inverse-square, NOT
|
// It had the right shape but a 1-metre reference distance where retail's is 5,
|
||||||
/// DirectSound3DBuffer. No doppler, no cone, no HRTF.
|
// a `PanFromRelative` that was linear in relative X over an invented 20-metre
|
||||||
/// </summary>
|
// range where retail pans by the SINE of a compass bearing, and no audibility
|
||||||
public static class AudioFalloff
|
// cutoff. Nothing ever called either method. Both are superseded by
|
||||||
{
|
// `RetailSoundMixer`, which carries the byte-decoded retail math.
|
||||||
/// <summary>
|
|
||||||
/// Attenuation factor based on distance. Retail uses pure inverse-square
|
|
||||||
/// above a minimum-distance threshold.
|
|
||||||
/// </summary>
|
|
||||||
public static float AttenuationAt(float distanceMeters, float minDistance = 1.0f)
|
|
||||||
{
|
|
||||||
if (distanceMeters < minDistance) return 1.0f;
|
|
||||||
float att = (minDistance * minDistance) / (distanceMeters * distanceMeters);
|
|
||||||
return Math.Clamp(att, 0f, 1f);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Stereo pan from listener-relative X coord. ±1.0 fully panned.
|
|
||||||
/// </summary>
|
|
||||||
public static float PanFromRelative(float relativeX, float panRange = 20f)
|
|
||||||
{
|
|
||||||
if (panRange <= 0) return 0f;
|
|
||||||
return Math.Clamp(relativeX / panRange, -1f, 1f);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Interface the platform audio engine (AcDream.App layer) implements.
|
/// Interface the platform audio engine (AcDream.App layer) implements.
|
||||||
|
|
@ -78,10 +58,17 @@ public interface IAudioEngine : IDisposable
|
||||||
float MusicVolume { get; set; }
|
float MusicVolume { get; set; }
|
||||||
float AmbientVolume{ get; set; }
|
float AmbientVolume{ get; set; }
|
||||||
|
|
||||||
/// <summary>Update listener pose (called per frame from player position).</summary>
|
/// <summary>
|
||||||
void SetListener(float posX, float posY, float posZ,
|
/// Update the listener pose. Retail's listener is <c>SmartBox::viewer</c> —
|
||||||
float forwardX, float forwardY, float forwardZ,
|
/// the COLLIDED third-person camera Position, refreshed once per rendered
|
||||||
float upX, float upY, float upZ);
|
/// frame (<c>SmartBox::update_viewer</c> @ <c>0x00453CE0</c>), falling back
|
||||||
|
/// to the player's own position when the camera sweep fails. Only two
|
||||||
|
/// things are ever read out of it: the origin, for distance, and
|
||||||
|
/// <c>Frame::get_heading</c>, for pan. There is no up vector, no velocity,
|
||||||
|
/// and therefore no doppler and no elevation cue — which is why this takes
|
||||||
|
/// a single compass heading rather than a forward/up basis.
|
||||||
|
/// </summary>
|
||||||
|
void SetListener(float posX, float posY, float posZ, float headingDegrees);
|
||||||
|
|
||||||
/// <summary>Play a 2D UI sound (no falloff).</summary>
|
/// <summary>Play a 2D UI sound (no falloff).</summary>
|
||||||
void PlayUi(SoundId id);
|
void PlayUi(SoundId id);
|
||||||
|
|
|
||||||
263
src/AcDream.Core/Audio/RetailSoundMixer.cs
Normal file
263
src/AcDream.Core/Audio/RetailSoundMixer.cs
Normal file
|
|
@ -0,0 +1,263 @@
|
||||||
|
using System;
|
||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Audio;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One voice's mixing decision, as retail computes it at emission time.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Play">
|
||||||
|
/// False when retail would not start the voice at all — the attenuation fell
|
||||||
|
/// below <see cref="RetailSoundMixer.VolMinDecibels"/>. Retail does not start a
|
||||||
|
/// quiet voice; it starts nothing, so no pool slot is consumed either.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="Decibels">
|
||||||
|
/// Integer decibels, <c>ceil(20·log10 g)</c>, floored at
|
||||||
|
/// <see cref="RetailSoundMixer.VolMinDecibels"/>.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="Pan">
|
||||||
|
/// Retail's DirectSound pan in whole decibels, <c>[-15, +15]</c>; negative is
|
||||||
|
/// left, positive right, 0 dead centre.
|
||||||
|
/// </param>
|
||||||
|
public readonly record struct RetailVoiceMix(bool Play, int Decibels, int Pan);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail's sound mixing math — the whole of its spatialization.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>Retail is not a 3D audio engine.</b> Every gameplay sound buffer is
|
||||||
|
/// created with <c>m_3D = 0</c>; the DirectSound 3D listener the client sets up
|
||||||
|
/// (rolloff 0.01, front (−1,0,0), top (0,1,0)) is dead code that no buffer ever
|
||||||
|
/// consults. Spatialization is exactly two CPU-computed scalars per voice,
|
||||||
|
/// frozen at emission: a gain in whole decibels from distance, and a stereo pan
|
||||||
|
/// in whole decibels from bearing.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Sources: <c>SoundManager::GetAttenuation</c> @ <c>0x00550020</c> and
|
||||||
|
/// <c>SoundManager::PlaySoundInternal(SoundBufRef*, const Position*, float, int)</c>
|
||||||
|
/// @ <c>0x00550170</c>, both byte-decoded from the PDB-paired binary — Binary
|
||||||
|
/// Ninja elides the x87 memory constants in the first (its pseudo-C multiplies
|
||||||
|
/// by a literal <c>0f</c>, which would port as silence at every distance) and
|
||||||
|
/// misattributes a reused stack slot in the second (reporting the 5-metre
|
||||||
|
/// deadzone as an angle test rather than a distance test). Full decode:
|
||||||
|
/// <c>docs/research/2026-08-08-audio-retail-soundmanager-core.md</c> §1.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public static class RetailSoundMixer
|
||||||
|
{
|
||||||
|
/// <summary>Distance below which gain is flat at the authored volume, metres.</summary>
|
||||||
|
public const float VolMinDistance = 5.0f;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="VolMinDistance"/> squared — retail's numerator, so the curve
|
||||||
|
/// is continuous at the knee.
|
||||||
|
/// </summary>
|
||||||
|
public const float VolMinDistanceSq = 25.0f;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail's audibility floor. A voice whose computed decibels fall below
|
||||||
|
/// this is never started.
|
||||||
|
/// </summary>
|
||||||
|
public const int VolMinDecibels = -50;
|
||||||
|
|
||||||
|
/// <summary>Retail's pan scale, applied to the sine of the reversed bearing.</summary>
|
||||||
|
public const float PanScale = -15.0f;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pan is forced to dead centre when <c>(int)distance</c> is below this.
|
||||||
|
/// Retail truncates the distance before comparing, so this is an integer
|
||||||
|
/// metre test, not a float one.
|
||||||
|
/// </summary>
|
||||||
|
public const int PanDeadzoneMetres = 5;
|
||||||
|
|
||||||
|
/// <summary>Retail's voice count (<c>playing_sounds_[0x10]</c>).</summary>
|
||||||
|
public const int VoiceCount = 16;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail's own single-precision degrees-to-radians literal, as it appears
|
||||||
|
/// in the pan computation.
|
||||||
|
/// </summary>
|
||||||
|
private const float DegreesToRadians = 0.0174532924f;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>SoundManager::GetAttenuation</c>. Returns whole decibels and whether
|
||||||
|
/// retail would start the voice.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <paramref name="masterVolume"/> is retail's ONE master multiply —
|
||||||
|
/// <c>effect_sound_volume</c> for ordinary sounds, <c>ambient_sound_volume</c>
|
||||||
|
/// when <c>ambient != 0</c>. Callers on the paths where retail ALSO
|
||||||
|
/// pre-multiplies by the same knob (its volume-squared quirk) must
|
||||||
|
/// pre-multiply <paramref name="volume"/> themselves; this function applies
|
||||||
|
/// the knob exactly once.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryGetAttenuation(
|
||||||
|
float distanceMetres,
|
||||||
|
float volume,
|
||||||
|
float masterVolume,
|
||||||
|
out int decibels)
|
||||||
|
{
|
||||||
|
float g = distanceMetres < VolMinDistance
|
||||||
|
? volume
|
||||||
|
: (VolMinDistanceSq * volume) / (distanceMetres * distanceMetres);
|
||||||
|
|
||||||
|
if (g > 1.0f) g = 1.0f;
|
||||||
|
g *= masterVolume;
|
||||||
|
|
||||||
|
if (g <= 0.0f || float.IsNaN(g))
|
||||||
|
{
|
||||||
|
decibels = VolMinDecibels;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
decibels = (int)MathF.Ceiling(20.0f * MathF.Log10(g));
|
||||||
|
if (decibels >= VolMinDecibels)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
decibels = VolMinDecibels;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail's compass heading convention (<c>Position::heading</c> @
|
||||||
|
/// <c>0x005A9520</c>): degrees clockwise from +Y (north), +X (east) = 90°.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Delegates to <see cref="Physics.Motion.MoveToMath.PositionHeading"/> —
|
||||||
|
/// the pinned port of the same retail function, with golden-cardinal
|
||||||
|
/// coverage — rather than carrying a third copy of the formula. Retail's own
|
||||||
|
/// constant is the double <c>57.29577951308232</c>; the single-precision
|
||||||
|
/// narrowing there is at most ~1.5e-6 degrees, far inside the integer
|
||||||
|
/// truncation the pan applies afterwards.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public static float CompassHeadingDegrees(Vector3 from, Vector3 to) =>
|
||||||
|
Physics.Motion.MoveToMath.PositionHeading(from, to);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail's heading-difference normalisation, verbatim: <c>fmod(delta, 360)</c>
|
||||||
|
/// then <c>if (!(delta <= 180)) delta -= 360</c>.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// The output range is retail's <c>(-360, 180]</c>, NOT <c>(-180, 180]</c> —
|
||||||
|
/// retail does not fold negatives back up, so an input of −270 stays −270.
|
||||||
|
/// That is harmless because the only consumer is <c>sin</c>, which has
|
||||||
|
/// period 360, and reproducing it exactly keeps this function comparable to
|
||||||
|
/// the decode.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public static float NormalizeSignedDegrees(float degrees)
|
||||||
|
{
|
||||||
|
float delta = degrees % 360.0f;
|
||||||
|
if (!(delta <= 180.0f)) delta -= 360.0f;
|
||||||
|
return delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail's pan, in whole decibels. <paramref name="bearingSourceToListener"/>
|
||||||
|
/// is <c>Position::heading(soundPos, listenerPos)</c> — the REVERSED
|
||||||
|
/// bearing; combined with the negative <see cref="PanScale"/> that yields
|
||||||
|
/// the correct handedness (a source due east of a north-facing listener
|
||||||
|
/// pans right).
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// There is no front/back and no elevation cue: a source dead ahead and one
|
||||||
|
/// directly behind both pan to 0, and Z reaches the mix only through
|
||||||
|
/// distance.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public static int GetPan(
|
||||||
|
float bearingSourceToListener,
|
||||||
|
float listenerHeadingDegrees,
|
||||||
|
float distanceMetres,
|
||||||
|
bool panningEnabled = true)
|
||||||
|
{
|
||||||
|
if (!panningEnabled)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
// Retail truncates distance to an int before the deadzone compare.
|
||||||
|
if (Math.Abs((int)distanceMetres) < PanDeadzoneMetres)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
float delta = NormalizeSignedDegrees(bearingSourceToListener - listenerHeadingDegrees);
|
||||||
|
int pan = (int)(MathF.Sin(delta * DegreesToRadians) * PanScale);
|
||||||
|
return Math.Clamp(pan, (int)PanScale, (int)-PanScale);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The whole per-play decision for a world sound: distance gain plus pan,
|
||||||
|
/// composed the way <c>PlaySoundInternal</c> composes them.
|
||||||
|
/// </summary>
|
||||||
|
public static RetailVoiceMix Mix(
|
||||||
|
Vector3 listenerPosition,
|
||||||
|
float listenerHeadingDegrees,
|
||||||
|
Vector3 sourcePosition,
|
||||||
|
float volume,
|
||||||
|
float masterVolume,
|
||||||
|
bool panningEnabled = true)
|
||||||
|
{
|
||||||
|
float distance = Vector3.Distance(listenerPosition, sourcePosition);
|
||||||
|
|
||||||
|
// Pan is computed from the reversed bearing: source -> listener.
|
||||||
|
float bearing = CompassHeadingDegrees(sourcePosition, listenerPosition);
|
||||||
|
int pan = GetPan(bearing, listenerHeadingDegrees, distance, panningEnabled);
|
||||||
|
|
||||||
|
bool play = TryGetAttenuation(distance, volume, masterVolume, out int decibels);
|
||||||
|
return new RetailVoiceMix(play, decibels, pan);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Linear amplitude for whole decibels: <c>10^(db/20)</c>. Retail hands the
|
||||||
|
/// decibel value straight to <c>IDirectSoundBuffer::SetVolume</c>, which is
|
||||||
|
/// hundredths of a decibel; OpenAL wants linear gain, so the conversion
|
||||||
|
/// happens here rather than changing the quantisation.
|
||||||
|
/// </summary>
|
||||||
|
public static float LinearGain(int decibels) =>
|
||||||
|
MathF.Pow(10.0f, decibels / 20.0f);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts retail's pan (whole decibels of inter-channel difference, the
|
||||||
|
/// quantity <c>IDirectSoundBuffer::SetPan</c> expresses directly) into a
|
||||||
|
/// normalised stereo position in <c>[-1, 1]</c> for a constant-power panner
|
||||||
|
/// such as OpenAL's.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// A constant-power panpot at position <c>p</c> over a speaker pair puts
|
||||||
|
/// <c>cos((p+1)·π/4)</c> in the left channel and <c>sin((p+1)·π/4)</c> in
|
||||||
|
/// the right, so the inter-channel difference is
|
||||||
|
/// <c>20·log10(tan((p+1)·π/4))</c>. Inverting that for a target difference
|
||||||
|
/// gives <c>p = (4/π)·atan(10^(pan/20)) − 1</c>, which reaches only ±0.775
|
||||||
|
/// at retail's ±15 dB — both channels stay live, exactly as DirectSound's
|
||||||
|
/// one-channel attenuation keeps them. Mapping pan linearly onto the
|
||||||
|
/// panner's full range instead would saturate to infinite separation at the
|
||||||
|
/// edges, which retail never does.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public static float StereoPositionFromPan(int pan)
|
||||||
|
{
|
||||||
|
float difference = MathF.Pow(10.0f, pan / 20.0f);
|
||||||
|
float position = (4.0f / MathF.PI) * MathF.Atan(difference) - 1.0f;
|
||||||
|
return Math.Clamp(position, -1.0f, 1.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The maximum distance at which a sound of this volume is audible at all,
|
||||||
|
/// in metres — the radius where <c>ceil(20·log10(25·vol·master/d²))</c>
|
||||||
|
/// last reaches <see cref="VolMinDecibels"/>. About 94.2 m at
|
||||||
|
/// <c>vol·master == 1</c>, 66.6 m at 0.5, 29.8 m at 0.1. Diagnostic and
|
||||||
|
/// test use; the live path gets the same answer from
|
||||||
|
/// <see cref="TryGetAttenuation"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static float AudibleRadius(float volume, float masterVolume)
|
||||||
|
{
|
||||||
|
float scale = volume * masterVolume;
|
||||||
|
if (scale <= 0f) return 0f;
|
||||||
|
|
||||||
|
// Audible while db >= -50, i.e. ceil(20·log10 g) >= -50, i.e.
|
||||||
|
// 20·log10 g > -51 (ceil of anything above -51 is at least -50).
|
||||||
|
float minGain = MathF.Pow(10.0f, -51.0f / 20.0f);
|
||||||
|
return MathF.Sqrt(VolMinDistanceSq * scale / minGain);
|
||||||
|
}
|
||||||
|
}
|
||||||
102
src/AcDream.Core/Audio/RetailVoicePool.cs
Normal file
102
src/AcDream.Core/Audio/RetailVoicePool.cs
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Audio;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One voice slot's state, as retail's <c>SoundPlayingData</c> carries it:
|
||||||
|
/// whether a buffer is installed, and the DAT-authored priority it was claimed
|
||||||
|
/// with. Retail also stores a <c>start_time</c>, which it writes and never
|
||||||
|
/// reads — age enters allocation only through the ring cursor — so there is no
|
||||||
|
/// timestamp here.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Occupied">
|
||||||
|
/// A buffer is installed in this slot (retail: <c>buffer != null</c> with a live
|
||||||
|
/// <c>m_pBuf</c>).
|
||||||
|
/// </param>
|
||||||
|
/// <param name="StillPlaying">
|
||||||
|
/// The installed buffer is still reporting <c>DSBSTATUS_PLAYING</c>. A finished
|
||||||
|
/// voice is reclaimed by the first pass exactly like an empty slot.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="Priority">The priority the slot's current sound was claimed with.</param>
|
||||||
|
public readonly record struct VoiceSlotState(bool Occupied, bool StillPlaying, float Priority);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail's voice allocator — <c>SoundManager::PlaySoundInternal(SoundBufRef*,
|
||||||
|
/// int pan, int volDb)</c> @ <c>0x0054FEC0</c>, byte-decoded.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Two passes, both walking the 16 slots in ring order from a persistent cursor:
|
||||||
|
/// </para>
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item><description>
|
||||||
|
/// Claim the first slot that is empty, has a broken buffer, or is no longer
|
||||||
|
/// playing.
|
||||||
|
/// </description></item>
|
||||||
|
/// <item><description>
|
||||||
|
/// Otherwise claim the first slot whose priority is <b>strictly less</b> than
|
||||||
|
/// the incoming sound's — so equal priority never evicts, and gain is never
|
||||||
|
/// consulted at all. Before Campaign A slice A2 acdream compared GAIN here,
|
||||||
|
/// which let a loud unimportant sound evict a quiet important one.
|
||||||
|
/// </description></item>
|
||||||
|
/// </list>
|
||||||
|
/// <para>
|
||||||
|
/// If neither pass finds a slot the new sound is silently dropped.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// This lives in Core, separate from the OpenAL engine, because it is pure
|
||||||
|
/// index logic over slot state: the engine's own play path talks to native AL
|
||||||
|
/// handles and cannot be reached by a test, and this is the second-largest
|
||||||
|
/// behavioural change in the audio campaign.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public static class RetailVoicePool
|
||||||
|
{
|
||||||
|
/// <summary>Sentinel for "no slot available — drop the sound".</summary>
|
||||||
|
public const int NoSlot = -1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pick the slot retail would claim for a sound of <paramref name="priority"/>,
|
||||||
|
/// or <see cref="NoSlot"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="slots">
|
||||||
|
/// The pool, in slot order. Length is retail's
|
||||||
|
/// <see cref="RetailSoundMixer.VoiceCount"/> in production but any length
|
||||||
|
/// works so tests can use small pools.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="cursor">
|
||||||
|
/// Retail's <c>curr_playing_buffer_</c> — where the ring scan starts.
|
||||||
|
/// </param>
|
||||||
|
public static int Acquire(ReadOnlySpan<VoiceSlotState> slots, int cursor, float priority)
|
||||||
|
{
|
||||||
|
if (slots.Length == 0) return NoSlot;
|
||||||
|
|
||||||
|
for (int i = 0; i < slots.Length; i++)
|
||||||
|
{
|
||||||
|
int idx = Ring(cursor, i, slots.Length);
|
||||||
|
VoiceSlotState slot = slots[idx];
|
||||||
|
if (!slot.Occupied || !slot.StillPlaying) return idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < slots.Length; i++)
|
||||||
|
{
|
||||||
|
int idx = Ring(cursor, i, slots.Length);
|
||||||
|
if (slots[idx].Priority < priority) return idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
return NoSlot;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail's post-claim cursor advance: <c>curr_playing_buffer_ = (slot + 1)
|
||||||
|
/// mod 16</c>. Only a successful claim moves it.
|
||||||
|
/// </summary>
|
||||||
|
public static int AdvanceCursor(int claimedSlot, int slotCount) =>
|
||||||
|
slotCount <= 0 ? 0 : (claimedSlot + 1) % slotCount;
|
||||||
|
|
||||||
|
private static int Ring(int cursor, int offset, int slotCount)
|
||||||
|
{
|
||||||
|
int idx = (cursor + offset) % slotCount;
|
||||||
|
return idx < 0 ? idx + slotCount : idx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -157,7 +157,7 @@ public sealed class OpenAlResourceLifetimeTests
|
||||||
ThrowIfConfiguredFailure(source);
|
ThrowIfConfiguredFailure(source);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SelectRetailDistanceModel() { }
|
public void DisableAlDistanceAttenuation() { }
|
||||||
|
|
||||||
public void StopSource(uint source) { }
|
public void StopSource(uint source) { }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -531,7 +531,7 @@ public sealed class ContentEffectsAudioCompositionTests
|
||||||
public uint GenerateSource() => _nextSource++;
|
public uint GenerateSource() => _nextSource++;
|
||||||
public void Configure3DSource(uint source) { }
|
public void Configure3DSource(uint source) { }
|
||||||
public void ConfigureUiSource(uint source) { }
|
public void ConfigureUiSource(uint source) { }
|
||||||
public void SelectRetailDistanceModel() { }
|
public void DisableAlDistanceAttenuation() { }
|
||||||
public void StopSource(uint source) { }
|
public void StopSource(uint source) { }
|
||||||
public void DeleteSource(uint source) { }
|
public void DeleteSource(uint source) { }
|
||||||
public void DeleteBuffer(uint buffer) { }
|
public void DeleteBuffer(uint buffer) { }
|
||||||
|
|
|
||||||
382
tests/AcDream.Core.Tests/Audio/RetailSoundMixerTests.cs
Normal file
382
tests/AcDream.Core.Tests/Audio/RetailSoundMixerTests.cs
Normal file
|
|
@ -0,0 +1,382 @@
|
||||||
|
using System;
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.Core.Audio;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Tests.Audio;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Conformance tests for retail's mixing math — <c>SoundManager::GetAttenuation</c>
|
||||||
|
/// @ 0x00550020 and <c>SoundManager::PlaySoundInternal(pos)</c> @ 0x00550170,
|
||||||
|
/// both byte-decoded in
|
||||||
|
/// <c>docs/research/2026-08-08-audio-retail-soundmanager-core.md</c> §1.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Golden decibels are recomputed here from the decoded formula
|
||||||
|
/// (<c>ceil(20·log10(min(1, 25·vol/d²)·master))</c>) rather than copied from
|
||||||
|
/// the note's summary table, which has one transcription slip: it lists 30 m as
|
||||||
|
/// −35 dB where both its own gain column (0.0278) and the formula give −31.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RetailSoundMixerTests
|
||||||
|
{
|
||||||
|
// ── GetAttenuation ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
// Inside the 5 m knee gain is flat at the authored volume.
|
||||||
|
[InlineData(0f, 0)]
|
||||||
|
[InlineData(2f, 0)]
|
||||||
|
[InlineData(4.99f, 0)]
|
||||||
|
// At and beyond the knee: 25/d², continuous at 5 m.
|
||||||
|
[InlineData(5f, 0)]
|
||||||
|
[InlineData(10f, -12)]
|
||||||
|
[InlineData(20f, -24)]
|
||||||
|
[InlineData(30f, -31)]
|
||||||
|
[InlineData(50f, -40)]
|
||||||
|
[InlineData(90f, -50)]
|
||||||
|
[InlineData(94f, -50)] // last audible metre
|
||||||
|
public void Attenuation_MatchesRetailCurve(float distance, int expectedDecibels)
|
||||||
|
{
|
||||||
|
Assert.True(RetailSoundMixer.TryGetAttenuation(distance, 1f, 1f, out int db));
|
||||||
|
Assert.Equal(expectedDecibels, db);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(95f)]
|
||||||
|
[InlineData(120f)]
|
||||||
|
[InlineData(1000f)]
|
||||||
|
public void Attenuation_BeyondCutoff_DoesNotPlay(float distance)
|
||||||
|
{
|
||||||
|
Assert.False(RetailSoundMixer.TryGetAttenuation(distance, 1f, 1f, out int db));
|
||||||
|
Assert.Equal(RetailSoundMixer.VolMinDecibels, db);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Attenuation_IsInverseSquare_NotInverseFirstPower()
|
||||||
|
{
|
||||||
|
// Doubling distance past the knee must cost 4x gain (12 dB), not 2x
|
||||||
|
// (6 dB). This is the single largest pre-A2 divergence: OpenAL's
|
||||||
|
// InverseDistanceClamped is first-power only.
|
||||||
|
RetailSoundMixer.TryGetAttenuation(10f, 1f, 1f, out int near);
|
||||||
|
RetailSoundMixer.TryGetAttenuation(20f, 1f, 1f, out int far);
|
||||||
|
Assert.Equal(12, near - far);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Attenuation_ClampsAboveUnity()
|
||||||
|
{
|
||||||
|
// A volume above 1.0 (the dats reach 10.0) cannot make a close sound
|
||||||
|
// louder than 0 dB — but it DOES extend the audible radius, because
|
||||||
|
// retail clamps after the distance divide, not at the field.
|
||||||
|
Assert.True(RetailSoundMixer.TryGetAttenuation(1f, 10f, 1f, out int db));
|
||||||
|
Assert.Equal(0, db);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Attenuation_ClampsBeforeTheMasterMultiply_NotAfter()
|
||||||
|
{
|
||||||
|
// The single most easily-inverted line in the port. Retail clamps the
|
||||||
|
// distance term to unity and THEN applies the master knob:
|
||||||
|
// retail order : min(10, 1) = 1, x0.5 = 0.5 -> -6 dB
|
||||||
|
// inverted order : 10 x 0.5 = 5, min(5, 1) = 1 -> 0 dB
|
||||||
|
Assert.True(RetailSoundMixer.TryGetAttenuation(1f, 10f, 0.5f, out int db));
|
||||||
|
Assert.Equal(-6, db);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Attenuation_HighVolume_ExtendsAudibleRadius()
|
||||||
|
{
|
||||||
|
// 10x volume at 200 m: 25*10/40000 = 0.00625 → -44 dB, still audible,
|
||||||
|
// where a volume clamped to 1.0 at the field would have been silent.
|
||||||
|
Assert.False(RetailSoundMixer.TryGetAttenuation(200f, 1f, 1f, out _));
|
||||||
|
Assert.True(RetailSoundMixer.TryGetAttenuation(200f, 10f, 1f, out int loud));
|
||||||
|
Assert.Equal(-44, loud);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0f)]
|
||||||
|
[InlineData(-1f)]
|
||||||
|
public void Attenuation_NonPositiveMaster_DoesNotPlay(float master)
|
||||||
|
{
|
||||||
|
Assert.False(RetailSoundMixer.TryGetAttenuation(1f, 1f, master, out int db));
|
||||||
|
Assert.Equal(RetailSoundMixer.VolMinDecibels, db);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Attenuation_MasterIsAppliedExactlyOnce()
|
||||||
|
{
|
||||||
|
// GetAttenuation multiplies by one master knob. Halving it must cost
|
||||||
|
// ~6 dB, not ~12 (which is what a second, caller-side multiply gives —
|
||||||
|
// retail's volume-squared quirk on the PlaySoundA(DataID, obj) and
|
||||||
|
// ambient paths, which callers opt into by pre-multiplying).
|
||||||
|
RetailSoundMixer.TryGetAttenuation(10f, 1f, 1f, out int full);
|
||||||
|
RetailSoundMixer.TryGetAttenuation(10f, 1f, 0.5f, out int half);
|
||||||
|
Assert.Equal(-6, half - full);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
// Solving ceil(20·log10(25·s/d²)) >= -50 for d.
|
||||||
|
[InlineData(1f, 94.2f)]
|
||||||
|
[InlineData(0.5f, 66.6f)]
|
||||||
|
[InlineData(0.1f, 29.8f)]
|
||||||
|
public void AudibleRadius_MatchesDecodedRadii(float scale, float expectedMetres)
|
||||||
|
{
|
||||||
|
Assert.Equal(expectedMetres, RetailSoundMixer.AudibleRadius(scale, 1f), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AudibleRadius_AgreesWithTheLivePredicate()
|
||||||
|
{
|
||||||
|
// The radius helper and the play decision must not drift apart.
|
||||||
|
for (float volume = 0.1f; volume <= 3f; volume += 0.1f)
|
||||||
|
{
|
||||||
|
float radius = RetailSoundMixer.AudibleRadius(volume, 1f);
|
||||||
|
Assert.True(RetailSoundMixer.TryGetAttenuation(radius - 0.5f, volume, 1f, out _));
|
||||||
|
Assert.False(RetailSoundMixer.TryGetAttenuation(radius + 0.5f, volume, 1f, out _));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Decibels_AreWholeNumbers_QuantisedByCeil()
|
||||||
|
{
|
||||||
|
// Retail stair-steps in whole decibels rather than ramping smoothly.
|
||||||
|
var seen = new System.Collections.Generic.HashSet<int>();
|
||||||
|
for (float d = 5f; d < 94f; d += 0.05f)
|
||||||
|
{
|
||||||
|
RetailSoundMixer.TryGetAttenuation(d, 1f, 1f, out int db);
|
||||||
|
seen.Add(db);
|
||||||
|
}
|
||||||
|
// 0 dB down to -50 dB inclusive is at most 51 distinct steps.
|
||||||
|
Assert.InRange(seen.Count, 40, 51);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LinearGain_RoundTripsTheDecibelScale()
|
||||||
|
{
|
||||||
|
Assert.Equal(1f, RetailSoundMixer.LinearGain(0), 5);
|
||||||
|
Assert.Equal(0.5f, RetailSoundMixer.LinearGain(-6), 2);
|
||||||
|
Assert.Equal(0.25f, RetailSoundMixer.LinearGain(-12), 2);
|
||||||
|
Assert.Equal(0.00316f, RetailSoundMixer.LinearGain(-50), 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Heading + pan ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
// Retail's compass convention: 0 = +Y (north), 90 = +X (east).
|
||||||
|
[InlineData(0f, 1f, 0f)] // north
|
||||||
|
[InlineData(1f, 0f, 90f)] // east
|
||||||
|
[InlineData(0f, -1f, 180f)] // south
|
||||||
|
[InlineData(-1f, 0f, 270f)] // west
|
||||||
|
public void CompassHeading_UsesRetailConvention(float dx, float dy, float expected)
|
||||||
|
{
|
||||||
|
float heading = RetailSoundMixer.CompassHeadingDegrees(
|
||||||
|
Vector3.Zero, new Vector3(dx, dy, 0f));
|
||||||
|
Assert.Equal(expected, heading, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0f, 0f)]
|
||||||
|
[InlineData(180f, 180f)] // inclusive upper bound
|
||||||
|
[InlineData(181f, -179f)]
|
||||||
|
[InlineData(270f, -90f)]
|
||||||
|
[InlineData(359f, -1f)]
|
||||||
|
[InlineData(-90f, -90f)]
|
||||||
|
public void NormalizeSigned_MapsIntoRetailsWindow(float input, float expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, RetailSoundMixer.NormalizeSignedDegrees(input), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Pan_SourceDueEastOfNorthFacingListener_IsFullRight()
|
||||||
|
{
|
||||||
|
// The worked check from the decode: delta = -90 ⇒ pan = -15·sin(-90) = +15.
|
||||||
|
var mix = RetailSoundMixer.Mix(
|
||||||
|
Vector3.Zero, 0f, new Vector3(10f, 0f, 0f), 1f, 1f);
|
||||||
|
Assert.Equal(15, mix.Pan);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Pan_SourceDueWestOfNorthFacingListener_IsFullLeft()
|
||||||
|
{
|
||||||
|
var mix = RetailSoundMixer.Mix(
|
||||||
|
Vector3.Zero, 0f, new Vector3(-10f, 0f, 0f), 1f, 1f);
|
||||||
|
Assert.Equal(-15, mix.Pan);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Pan_HasNoFrontBackDistinction()
|
||||||
|
{
|
||||||
|
// Retail's cue is the sine of the bearing, so dead ahead and directly
|
||||||
|
// behind both centre. This is a faithfulness property, not a bug.
|
||||||
|
var ahead = RetailSoundMixer.Mix(
|
||||||
|
Vector3.Zero, 0f, new Vector3(0f, 10f, 0f), 1f, 1f);
|
||||||
|
var behind = RetailSoundMixer.Mix(
|
||||||
|
Vector3.Zero, 0f, new Vector3(0f, -10f, 0f), 1f, 1f);
|
||||||
|
Assert.Equal(0, ahead.Pan);
|
||||||
|
Assert.Equal(0, behind.Pan);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Pan_RotatesWithListenerHeading()
|
||||||
|
{
|
||||||
|
// Facing east, a source due east is now dead ahead ⇒ centred.
|
||||||
|
var mix = RetailSoundMixer.Mix(
|
||||||
|
Vector3.Zero, 90f, new Vector3(10f, 0f, 0f), 1f, 1f);
|
||||||
|
Assert.Equal(0, mix.Pan);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(1f, 0)] // inside the deadzone
|
||||||
|
[InlineData(4.9f, 0)] // (int)4.9 == 4 < 5
|
||||||
|
[InlineData(5f, 15)] // (int)5 == 5, deadzone ends
|
||||||
|
public void Pan_DeadzoneIsAnIntegerMetreTest(float distance, int expectedPan)
|
||||||
|
{
|
||||||
|
var mix = RetailSoundMixer.Mix(
|
||||||
|
Vector3.Zero, 0f, new Vector3(distance, 0f, 0f), 1f, 1f);
|
||||||
|
Assert.Equal(expectedPan, mix.Pan);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Pan_ElevationNeverContributes()
|
||||||
|
{
|
||||||
|
// Z reaches the mix only through distance: two sources on the same
|
||||||
|
// horizontal bearing pan identically however far apart they are
|
||||||
|
// vertically, while their gains differ.
|
||||||
|
var level = RetailSoundMixer.Mix(
|
||||||
|
Vector3.Zero, 0f, new Vector3(10f, 0f, 0f), 1f, 1f);
|
||||||
|
var high = RetailSoundMixer.Mix(
|
||||||
|
Vector3.Zero, 0f, new Vector3(10f, 0f, 40f), 1f, 1f);
|
||||||
|
|
||||||
|
Assert.Equal(level.Pan, high.Pan);
|
||||||
|
Assert.NotEqual(level.Decibels, high.Decibels);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Pan_PurelyVerticalOffset_InheritsRetailsAtan2Degeneracy()
|
||||||
|
{
|
||||||
|
// A source directly overhead has dx == dy == 0, so retail's
|
||||||
|
// `fmod(450 - atan2(0, 0)·57.29578, 360)` yields 90° (due east) and the
|
||||||
|
// sound pans hard LEFT rather than centre. C's atan2(0,0) is 0, so this
|
||||||
|
// is retail's behaviour, not ours — pinned here so a future reader does
|
||||||
|
// not "fix" it into a centred pan. Unreachable for ordinary emitters,
|
||||||
|
// which are never exactly co-located horizontally; a source AT the
|
||||||
|
// listener is caught by the 5 m deadzone instead.
|
||||||
|
var mix = RetailSoundMixer.Mix(
|
||||||
|
Vector3.Zero, 0f, new Vector3(0f, 0f, 10f), 1f, 1f);
|
||||||
|
Assert.Equal(-15, mix.Pan);
|
||||||
|
Assert.Equal(-12, mix.Decibels);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Pan_DisabledByPreference_IsAlwaysCentre()
|
||||||
|
{
|
||||||
|
// retail: s_SoundFeatures == 1 forces pan 0.
|
||||||
|
var mix = RetailSoundMixer.Mix(
|
||||||
|
Vector3.Zero, 0f, new Vector3(10f, 0f, 0f), 1f, 1f, panningEnabled: false);
|
||||||
|
Assert.Equal(0, mix.Pan);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Pan_StaysWithinFifteenDecibels()
|
||||||
|
{
|
||||||
|
// Sweep every bearing: retail's pan saturates at ±15 dB, never full
|
||||||
|
// separation.
|
||||||
|
for (int deg = 0; deg < 360; deg++)
|
||||||
|
{
|
||||||
|
float rad = deg * MathF.PI / 180f;
|
||||||
|
var source = new Vector3(MathF.Sin(rad) * 20f, MathF.Cos(rad) * 20f, 0f);
|
||||||
|
var mix = RetailSoundMixer.Mix(Vector3.Zero, 0f, source, 1f, 1f);
|
||||||
|
Assert.InRange(mix.Pan, -15, 15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Mix_BeyondCutoff_ReportsDoNotPlay()
|
||||||
|
{
|
||||||
|
var mix = RetailSoundMixer.Mix(
|
||||||
|
Vector3.Zero, 0f, new Vector3(0f, 200f, 0f), 1f, 1f);
|
||||||
|
Assert.False(mix.Play);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
// Retail's `_ftol2` truncates toward zero. Bearing ±64.158° gives
|
||||||
|
// |−15·sin Δ| ≈ 13.5, and the NEGATIVE row is the discriminating one:
|
||||||
|
// truncation gives −13 where floor would give −14. (On the positive side
|
||||||
|
// truncation and floor agree, which is why one row cannot pin this.)
|
||||||
|
[InlineData(64.158f, 13)]
|
||||||
|
[InlineData(-64.158f, -13)]
|
||||||
|
public void Pan_TruncatesTowardZero_NotFloor(float bearingDegrees, int expectedPan)
|
||||||
|
{
|
||||||
|
// Place the source at the given bearing FROM the listener, 20 m out.
|
||||||
|
float rad = bearingDegrees * MathF.PI / 180f;
|
||||||
|
var source = new Vector3(MathF.Sin(rad) * 20f, MathF.Cos(rad) * 20f, 0f);
|
||||||
|
var mix = RetailSoundMixer.Mix(Vector3.Zero, 0f, source, 1f, 1f);
|
||||||
|
Assert.Equal(expectedPan, mix.Pan);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NormalizeSigned_LeavesLargeNegativesAlone_AsRetailDoes()
|
||||||
|
{
|
||||||
|
// Retail's window is (-360, 180], not (-180, 180]: it never folds a
|
||||||
|
// negative back up. Pan-equivalent because only sin() consumes it.
|
||||||
|
Assert.Equal(-270f, RetailSoundMixer.NormalizeSignedDegrees(-270f), 3);
|
||||||
|
Assert.Equal(
|
||||||
|
MathF.Sin(90f * MathF.PI / 180f),
|
||||||
|
MathF.Sin(RetailSoundMixer.NormalizeSignedDegrees(-270f) * MathF.PI / 180f),
|
||||||
|
3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pan law: retail's 15 dB, not full separation ────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StereoPosition_CentreIsCentre()
|
||||||
|
{
|
||||||
|
Assert.Equal(0f, RetailSoundMixer.StereoPositionFromPan(0), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(15)]
|
||||||
|
[InlineData(-15)]
|
||||||
|
public void StereoPosition_FullPan_StaysInsideTheSpeakerAngle(int pan)
|
||||||
|
{
|
||||||
|
// The whole point of inverting the pan law: full retail deflection must
|
||||||
|
// NOT reach ±1 (the speaker angle), which would give effectively
|
||||||
|
// infinite channel separation where retail gives 15 dB.
|
||||||
|
// (4/pi)·atan(10^(15/20)) - 1 = (4/pi)·atan(5.6234) - 1 = 0.7757.
|
||||||
|
float position = RetailSoundMixer.StereoPositionFromPan(pan);
|
||||||
|
Assert.Equal(0.776f, MathF.Abs(position), 3);
|
||||||
|
Assert.True(MathF.Abs(position) < 1f);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0)]
|
||||||
|
[InlineData(3)]
|
||||||
|
[InlineData(7)]
|
||||||
|
[InlineData(11)]
|
||||||
|
[InlineData(15)]
|
||||||
|
[InlineData(-6)]
|
||||||
|
[InlineData(-15)]
|
||||||
|
public void StereoPosition_ReproducesTheRequestedDecibelDifference(int pan)
|
||||||
|
{
|
||||||
|
// Under a constant-power panpot, position p yields channel gains
|
||||||
|
// cos((p+1)pi/4) and sin((p+1)pi/4). Round-trip the difference.
|
||||||
|
float p = RetailSoundMixer.StereoPositionFromPan(pan);
|
||||||
|
float angle = (p + 1f) * MathF.PI / 4f;
|
||||||
|
float left = MathF.Cos(angle);
|
||||||
|
float right = MathF.Sin(angle);
|
||||||
|
float differenceDb = 20f * MathF.Log10(right / left);
|
||||||
|
Assert.Equal(pan, differenceDb, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StereoPosition_IsMonotonicAcrossThePanRange()
|
||||||
|
{
|
||||||
|
float previous = RetailSoundMixer.StereoPositionFromPan(-15);
|
||||||
|
for (int pan = -14; pan <= 15; pan++)
|
||||||
|
{
|
||||||
|
float current = RetailSoundMixer.StereoPositionFromPan(pan);
|
||||||
|
Assert.True(current > previous, $"pan {pan} did not increase position");
|
||||||
|
previous = current;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
155
tests/AcDream.Core.Tests/Audio/RetailVoicePoolTests.cs
Normal file
155
tests/AcDream.Core.Tests/Audio/RetailVoicePoolTests.cs
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using AcDream.Core.Audio;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Tests.Audio;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Conformance tests for retail's voice allocator,
|
||||||
|
/// <c>SoundManager::PlaySoundInternal(SoundBufRef*, int, int)</c> @
|
||||||
|
/// <c>0x0054FEC0</c>, decoded in
|
||||||
|
/// <c>docs/research/2026-08-08-audio-retail-soundmanager-core.md</c> §1.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// The behaviour under test is the second-largest change in the audio campaign:
|
||||||
|
/// before it, acdream evicted by GAIN, so a loud unimportant sound could silence
|
||||||
|
/// a quiet important one.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RetailVoicePoolTests
|
||||||
|
{
|
||||||
|
private static VoiceSlotState Free() => new(Occupied: false, StillPlaying: false, Priority: 0f);
|
||||||
|
|
||||||
|
private static VoiceSlotState Finished(float priority) =>
|
||||||
|
new(Occupied: true, StillPlaying: false, Priority: priority);
|
||||||
|
|
||||||
|
private static VoiceSlotState Busy(float priority) =>
|
||||||
|
new(Occupied: true, StillPlaying: true, Priority: priority);
|
||||||
|
|
||||||
|
private static VoiceSlotState[] AllBusy(float priority, int count = 16)
|
||||||
|
{
|
||||||
|
var slots = new VoiceSlotState[count];
|
||||||
|
Array.Fill(slots, Busy(priority));
|
||||||
|
return slots;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EmptyPool_DropsTheSound()
|
||||||
|
{
|
||||||
|
Assert.Equal(RetailVoicePool.NoSlot, RetailVoicePool.Acquire(Array.Empty<VoiceSlotState>(), 0, 1f));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FirstPass_PrefersAFreeSlot_ScanningFromTheCursor()
|
||||||
|
{
|
||||||
|
var slots = AllBusy(1f);
|
||||||
|
slots[9] = Free();
|
||||||
|
Assert.Equal(9, RetailVoicePool.Acquire(slots, cursor: 0, priority: 0f));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FirstPass_ReclaimsAFinishedVoice_EvenAtHigherPriority()
|
||||||
|
{
|
||||||
|
// A finished voice is as reclaimable as an empty slot, whatever priority
|
||||||
|
// it was claimed with — the first pass never compares priority.
|
||||||
|
var slots = AllBusy(1f);
|
||||||
|
slots[4] = Finished(1f);
|
||||||
|
Assert.Equal(4, RetailVoicePool.Acquire(slots, cursor: 0, priority: 0.1f));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FirstPass_WrapsAroundTheRing()
|
||||||
|
{
|
||||||
|
var slots = AllBusy(1f);
|
||||||
|
slots[2] = Free();
|
||||||
|
// Starting at 5, the scan must wrap past 15 to reach slot 2.
|
||||||
|
Assert.Equal(2, RetailVoicePool.Acquire(slots, cursor: 5, priority: 0f));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FirstPass_TakesTheNearestFreeSlotInRingOrder()
|
||||||
|
{
|
||||||
|
var slots = AllBusy(1f);
|
||||||
|
slots[1] = Free();
|
||||||
|
slots[12] = Free();
|
||||||
|
Assert.Equal(12, RetailVoicePool.Acquire(slots, cursor: 10, priority: 0f));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SecondPass_EvictsStrictlyLowerPriority()
|
||||||
|
{
|
||||||
|
var slots = AllBusy(0.5f);
|
||||||
|
slots[7] = Busy(0.2f);
|
||||||
|
Assert.Equal(7, RetailVoicePool.Acquire(slots, cursor: 0, priority: 0.3f));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SecondPass_EqualPriorityNeverEvicts()
|
||||||
|
{
|
||||||
|
// Retail's compare is `slot.priority < new.priority`. A pool full of
|
||||||
|
// equal-priority voices drops the newcomer.
|
||||||
|
var slots = AllBusy(0.5f);
|
||||||
|
Assert.Equal(RetailVoicePool.NoSlot, RetailVoicePool.Acquire(slots, cursor: 0, priority: 0.5f));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SecondPass_HigherPriorityPoolDropsTheNewSound()
|
||||||
|
{
|
||||||
|
var slots = AllBusy(0.9f);
|
||||||
|
Assert.Equal(RetailVoicePool.NoSlot, RetailVoicePool.Acquire(slots, cursor: 0, priority: 0.4f));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SecondPass_TakesTheFirstLowerSlotInRingOrder_NotTheLowest()
|
||||||
|
{
|
||||||
|
// Retail stops at the FIRST slot below the incoming priority; it does not
|
||||||
|
// search for the quietest or least important one.
|
||||||
|
var slots = AllBusy(0.9f);
|
||||||
|
slots[3] = Busy(0.1f);
|
||||||
|
slots[6] = Busy(0.5f);
|
||||||
|
Assert.Equal(6, RetailVoicePool.Acquire(slots, cursor: 6, priority: 0.6f));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Eviction_IgnoresGain_ByConstruction()
|
||||||
|
{
|
||||||
|
// There is no gain in VoiceSlotState at all — the type cannot express the
|
||||||
|
// old behaviour. This test documents that as an intentional property.
|
||||||
|
var slots = AllBusy(0.8f);
|
||||||
|
Assert.Equal(
|
||||||
|
RetailVoicePool.NoSlot,
|
||||||
|
RetailVoicePool.Acquire(slots, cursor: 0, priority: 0.8f));
|
||||||
|
Assert.DoesNotContain(
|
||||||
|
"Gain",
|
||||||
|
string.Join(",", typeof(VoiceSlotState).GetProperties().Select(p => p.Name)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0, 1)]
|
||||||
|
[InlineData(15, 0)]
|
||||||
|
[InlineData(9, 10)]
|
||||||
|
public void Cursor_AdvancesPastTheClaimedSlot_AndWraps(int claimed, int expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, RetailVoicePool.AdvanceCursor(claimed, 16));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RingOrder_IsStableAcrossRepeatedClaims()
|
||||||
|
{
|
||||||
|
// Round-robin over a pool whose voices finish immediately: successive
|
||||||
|
// claims must walk the ring rather than reusing one slot.
|
||||||
|
var slots = new VoiceSlotState[4];
|
||||||
|
Array.Fill(slots, Free());
|
||||||
|
|
||||||
|
int cursor = 0;
|
||||||
|
var claimed = new int[4];
|
||||||
|
for (int i = 0; i < 4; i++)
|
||||||
|
{
|
||||||
|
claimed[i] = RetailVoicePool.Acquire(slots, cursor, 1f);
|
||||||
|
cursor = RetailVoicePool.AdvanceCursor(claimed[i], slots.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal(new[] { 0, 1, 2, 3 }, claimed);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue