A static SoundGate utility for audio in the NZombies project. It enforces per-category policies limiting how many sounds may start per frame, how many may be concurrently alive, and a minimum interval between starts; it tracks live SoundHandle instances, counts dropped attempts, and exposes console commands to report and tune policies.
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
namespace NZombies;
/// <summary>
/// LIMITS ON HOW MANY COPIES OF A CUE CAN PLAY, per frame and at once.
///
/// ⛔ NOT A QUEUE, AND THE DIFFERENCE MATTERS. The obvious fix for "everything plays at the same
/// time" is to defer the surplus over the next few frames, and for both cases here that is worse
/// than dropping it:
///
/// • IMPACTS ARE ONE EVENT. Bullets penetrate and hitscan resolves the whole chain in a single
/// frame, so one trigger pull through thirteen zombies asks for thirteen impact sounds. Spread
/// over thirteen frames that is a machine-gun rattle from one shot — audibly a different gun.
/// Dropped to one or two it sounds like what happened.
///
/// • VOICES LAST SECONDS. A zombie groan runs for a second or more, so starting thirteen of them
/// across thirteen frames leaves thirteen playing anyway. Deferring reduces neither the
/// concurrency nor the cost; only a cap does.
///
/// ⛔ AND DROPPING SOUNDS BETTER, NOT JUST CHEAPER. Thirteen copies of one impact cue fired in the
/// same frame at nearly the same place comb-filter into a smeared mess — the identical-sample phase
/// problem. One copy is the sound the player expects to hear.
///
/// ⚠️ PERMISSIVE BY DEFAULT. A cue with no policy is never limited, so adding this changed nothing
/// until a policy was written for a specific cue. Everything is reported by `nz_sound_gate`.
/// </summary>
public static class SoundGate
{
/// <summary>Master switch — `nz_sound_gate 0` to measure without it.</summary>
public static bool Enabled { get; set; } = true;
/// <summary>
/// How many copies of a cue may start in one frame, and how many may be alive at once.
/// 0 means unlimited.
/// </summary>
public readonly record struct Policy( int PerFrame, int Concurrent, float MinInterval );
// ── categories ───────────────────────────────────────────────────────────
//
// ⛔ THE CAPS ARE PER CATEGORY, NOT PER CUE, AND THAT CHANGE IS THE WHOLE POINT. Capping each
// cue separately let the TOTAL run away: 15 hit + 15 spawn + 15 death was already 45 on a bus
// that mixes 48, before a single uncapped idle groan, footstep or attack. Past the bus limit the
// engine does not refuse the new sound — it drops one by creation time, so an older voice is cut
// mid-clip to make room. Reported as "any new voice is replaced by a newer one".
//
// ⚠️ WHICH IS WHY THE TOTAL MATTERS MORE THAN ANY SINGLE NUMBER. 10 + 10 + 4 = 24 against a
// 48-voice bus leaves the gate as the only thing shaping the mix, and the bus as the safety net
// it was always meant to be. Raising any of these without checking that sum re-creates the bug.
/// <summary>Everything a zombie or hound says — groans, snarls, spawns, hits, deaths.</summary>
public const string Voice = "zombie.voice";
/// <summary>Footfalls, walking and running.</summary>
public const string Step = "zombie.step";
/// <summary>The swing itself.</summary>
public const string Attack = "zombie.attack";
/// <summary>
/// A zombie answering the PLAYER'S shot — the hit grunt and the death cry.
///
/// ⛔ SPLIT OUT OF `Voice` BECAUSE AMBIENCE WAS STARVING FEEDBACK. Sharing one
/// one-per-0.3s slot with idle groaning meant a groan that happened to start first refused the
/// death cry of the zombie you had just killed — the player's own trigger pull going unanswered
/// because something across the room was grunting. `ZombieAI` had already written the rule at
/// its death call site, before there was a category able to honour it: *"a death is feedback on
/// the player's own shot — the one voice cue you must never drop to make room for ambient
/// groaning"*.
///
/// ⚠️ SO IT IS GROUPED WITH `impact`, NOT WITH THE OTHER ZOMBIE CUES. The dividing line in this
/// table is not what makes the sound, it is who caused it: cues the player triggered get a
/// per-frame guard and no rate limit, cues the world makes on its own get a rate limit.
/// </summary>
public const string Feedback = "zombie.feedback";
/// <summary>Bullets meeting a surface. Not a zombie cue and not rate limited — see below.</summary>
public const string Impact = "impact";
// ⛔ NULLABLE-BACKED — a static's VALUE survives a hotload but its initialiser does not re-run,
// so a plain `= new(){...}` would come back with whatever was in it before a code edit.
//
// ⛔ AND THE NAME CARRIES A VERSION SUFFIX FOR THE SAME REASON, WHICH NULLABLE-BACKING ALONE DOES
// NOT SOLVE. Being nullable stops a MISSING value from being wrong; it does nothing when the value
// is already populated and the DEFAULTS change underneath it. Raising the voice caps below would
// have had no effect in a running editor — `_policies` was already holding the old dictionary,
// `??=` would not fire, and `nz_sound_gate` would have reported 6 while the source said 15.
// Bumping the suffix is what forces the new table to be built.
// ⚠️ SUFFIX BUMPED AGAIN (V4) BECAUSE A CATEGORY WAS ADDED AND TWO CUES MOVED. A running editor is
// still holding the V2 dictionary; `??=` would not fire and the old per-cue caps would stay live
// while this file said otherwise. The comment above explains why that trap is real.
static Dictionary<string, Policy> _policiesV4;
// cue -> category, for callers that do not name one. Built the same nullable way.
static Dictionary<string, string> _categoriesV4;
/// <summary>
/// The caps, per cue.
///
/// ⚠️ EVERY NUMBER HERE IS A STARTING GUESS AND IS MEANT TO BE MEASURED. `nz_sound_gate_set`
/// changes them live and `nz_sound_gate` reports what has actually been dropped — the point is
/// to tune them against a log rather than defend them.
/// </summary>
public static Dictionary<string, Policy> Policies => _policiesV4 ??= new()
{
// One shot through a line of bodies is ONE impact you should hear. 2 rather than 1 so a
// genuine double-tap on two separate surfaces still reads as two.
//
// ⚠️ AND NO MinInterval, DELIBERATELY. This is the player's own trigger pull answering them;
// a rate limit here would swallow the feedback for a fast weapon. The per-frame cap already
// fixes the only problem impacts have, which is thirteen copies in ONE frame comb-filtering.
[Impact] = new( PerFrame: 2, Concurrent: 6, MinInterval: 0f ),
// ⛔ ONE NEW VOICE EVERY 0.3s, AND NOTHING EVICTED. Ten is the concurrency; the interval is
// what stops ten of them starting together and then leaving a hole when they all end
// together. A horde reaches the full ten in three seconds, and past that every slot that
// frees is refilled by the next zombie to ask — so the bed stays continuous.
[Voice] = new( PerFrame: 0, Concurrent: 10, MinInterval: 0.3f ),
[Step] = new( PerFrame: 0, Concurrent: 10, MinInterval: 0.3f ),
[Attack] = new( PerFrame: 0, Concurrent: 4, MinInterval: 0.3f ),
// ⚠️ NO MinInterval, AND A PER-FRAME CAP INSTEAD — the same shape as `impact` above, for the
// same reason. The only problem these have is thirteen of them in ONE frame when a penetrating
// shot resolves a whole line of bodies at once, and PerFrame is what fixes that. A rate limit
// would instead make a fast weapon feel like it was hitting nothing.
[Feedback] = new( PerFrame: 2, Concurrent: 8, MinInterval: 0f ),
};
/// <summary>
/// Which category a cue belongs to, when the caller does not say.
///
/// ⛔ THE CALLER SAYING IS THE RELIABLE PATH, AND THIS IS THE FALLBACK. `ZombieVariant` exposes
/// `IdleSound`, `StepSound`, `AttackSound` and the rest as authored `[Property]` strings, so a
/// boss or a hellhound plays whatever cue a prefab names. No table here can know those. Every
/// zombie call site in `ZombieAI` therefore passes its category explicitly; this map only has to
/// cover the built-in names, for replayed network cues and anything added later that forgets.
/// </summary>
public static Dictionary<string, string> Categories => _categoriesV4 ??= new()
{
[NZSound.ZombieIdle] = Voice,
[NZSound.ZombieSprint] = Voice,
[NZSound.ZombieTaunt] = Voice,
[NZSound.ZombieBehind] = Voice,
[NZSound.ZombieSpawn] = Voice,
[NZSound.HoundIdle] = Voice,
[NZSound.HoundClose] = Voice,
[NZSound.HoundBark] = Voice,
[NZSound.HoundSpawn] = Voice,
// ⚠️ A SPAWN STAYS IN `Voice` WHILE A HIT AND A DEATH DO NOT. The map spawns zombies on its
// own schedule, so that cue is world noise; the other two exist only because the player
// pulled a trigger.
[NZSound.ZombieHit] = Feedback,
[NZSound.ZombieDeath] = Feedback,
[NZSound.HoundHit] = Feedback,
[NZSound.HoundDeath] = Feedback,
[NZSound.ZombieStep] = Step,
[NZSound.ZombieStepRun] = Step,
[NZSound.HoundStep] = Step,
[NZSound.ZombieAttack] = Attack,
[NZSound.HoundAttack] = Attack,
[Impact] = Impact,
};
/// <summary>The category to gate this call under, or null for ungated.</summary>
static string Resolve( string cue, string category )
{
if ( !string.IsNullOrEmpty( category ) ) return category;
if ( string.IsNullOrEmpty( cue ) ) return null;
return Categories.TryGetValue( cue, out var c ) ? c : null;
}
static readonly Dictionary<string, int> _thisFrame = new();
static readonly Dictionary<string, List<SoundHandle>> _alive = new();
static readonly Dictionary<string, int> _dropped = new();
/// <summary>When each category last STARTED something — the 0.3s spacing.</summary>
static readonly Dictionary<string, float> _lastStart = new();
// ⚠️ Time.Now, NOT Time.Tick — there is no Time.Tick in this engine. Time.Now is constant for
// the whole of a frame, so a change in it IS the frame boundary, which is all this needs.
static float _frameAt = -1f;
/// <summary>
/// May this cue start now? Counts it if so.
///
/// ⚠️ THE FRAME COUNTER RESETS LAZILY, on the first call of a new frame, rather than from an
/// update hook. There is no component here to hang one on, and a gate that only worked while
/// some other object existed would be the PerfLog driver problem again.
/// </summary>
public static bool Allow( string cue, string category = null )
{
if ( !Enabled ) return true;
var cat = Resolve( cue, category );
if ( cat is null ) return true;
if ( !Policies.TryGetValue( cat, out var p ) ) return true;
if ( _frameAt != Time.Now )
{
_frameAt = Time.Now;
_thisFrame.Clear();
}
var n = 0;
if ( p.PerFrame > 0 )
{
_thisFrame.TryGetValue( cat, out n );
if ( n >= p.PerFrame ) { Drop( cat ); return false; }
}
// ⛔ THE SPACING IS CHECKED, NEVER ENFORCED BY STOPPING ANYTHING. Every refusal in here
// declines the NEW sound; nothing already playing is ever touched. That is the difference
// between this and the mixer's own limit, which drops an OLD voice to fit a new one and is
// what made zombies cut each other off.
if ( p.MinInterval > 0f
&& _lastStart.TryGetValue( cat, out var last )
&& Time.Now - last < p.MinInterval )
{
Drop( cat );
return false;
}
if ( p.Concurrent > 0 && Live( cat ) >= p.Concurrent ) { Drop( cat ); return false; }
// ⚠️ STAMPED ONLY ONCE EVERY CHECK HAS PASSED, AND IT WAS NOT. The per-frame counter used to
// increment before the concurrency test, so a sound refused for concurrency still spent a
// per-frame slot — two budgets consumed by a sound that never played.
if ( p.PerFrame > 0 ) _thisFrame[cat] = n + 1;
_lastStart[cat] = Time.Now;
return true;
}
/// <summary>Track a handle so concurrency can be counted.</summary>
public static void Note( string cue, SoundHandle handle, string category = null )
{
if ( !Enabled || !handle.IsValid() ) return;
var cat = Resolve( cue, category );
if ( cat is null || !Policies.ContainsKey( cat ) ) return;
if ( !_alive.TryGetValue( cat, out var list ) )
_alive[cat] = list = new List<SoundHandle>();
list.Add( handle );
}
/// <summary>
/// How many copies of this cue are still playing.
///
/// ⚠️ PRUNES WHILE COUNTING. Nothing tells us when a sound ends, so the list is cleaned here —
/// which means it is cleaned exactly as often as it is consulted, and a cue that stops being
/// played stops being tracked rather than growing forever.
/// </summary>
static int Live( string cue )
{
if ( !_alive.TryGetValue( cue, out var list ) ) return 0;
for ( int i = list.Count - 1; i >= 0; i-- )
if ( !list[i].IsValid() || list[i].IsStopped )
list.RemoveAt( i );
return list.Count;
}
static void Drop( string cue )
{
_dropped.TryGetValue( cue, out var n );
_dropped[cue] = n + 1;
}
// ── commands ─────────────────────────────────────────────────────────────
/// <summary>`nz_sound_gate [0|1]` — the caps, what is live, and what has been dropped.</summary>
[ConCmd( "nz_sound_gate" )]
public static void Report( int on = -1 )
{
if ( on >= 0 ) Enabled = on != 0;
Log.Info( $"[nz-audio] sound gate {(Enabled ? "ON" : "off")}" );
Log.Info( $"[nz-audio] {"category",-16} {"every",6} {"concur",7} {"live",5} {"perFrame",9} {"dropped",8}" );
var live = 0;
foreach ( var (cat, p) in Policies )
{
_dropped.TryGetValue( cat, out var d );
var n = Live( cat );
live += n;
Log.Info( $"[nz-audio] {cat,-16} {( p.MinInterval > 0f ? $"{p.MinInterval:0.##}s" : "-" ),6}"
+ $" {( p.Concurrent > 0 ? p.Concurrent.ToString() : "inf" ),7} {n,5}"
+ $" {( p.PerFrame > 0 ? p.PerFrame.ToString() : "-" ),9} {d,8}" );
}
// ⛔ THE SUM AGAINST THE BUS IS THE NUMBER THAT PREDICTS THE BUG. Each cap can look modest
// while the total sits over `MaxVoices`, and past that the engine cuts an OLD voice to fit a
// new one instead of refusing it — which is heard as sounds replacing each other, not as
// sounds missing. Printing the sum is the only way this readout can show that coming.
//
// ⚠️ THE ZOMBIE CATEGORIES ONLY, AND THE FIRST VERSION OF THIS LINE SUMMED ALL OF THEM.
// `impact` is routed to the Impacts bus, not Zombies, so including its 6 described a
// contention that does not exist — a readout that misattributes is worse than none, because
// it is the thing you reach for when deciding whether a cap is safe to raise.
var zombie = new[] { Voice, Step, Attack, Feedback }
.Where( Policies.ContainsKey )
.Sum( c => Policies[c].Concurrent );
Log.Info( $"[nz-audio] {live} live now; zombie categories allow {zombie} at once"
+ " — keep under the Zombies bus MaxVoices (nz_mix) or the bus evicts instead of refusing" );
var total = _dropped.Values.Sum();
Log.Info( total == 0
? "[nz-audio] nothing dropped yet — the caps have not been reached"
: $"[nz-audio] {total} sound(s) dropped in total"
+ " (nz_sound_gate 0 then re-measure to see what they cost)" );
}
/// <summary>
/// `nz_sound_gate_set <category> <concurrent> [interval] [perFrame]` — tune a cap live.
///
/// ⚠️ CATEGORY, NOT CUE, SINCE THE CAPS MOVED. The old form took a cue name and would now
/// silently create a policy nothing is gated under, so the argument order changed with it:
/// concurrency first, because it is the one worth reaching for.
/// </summary>
[ConCmd( "nz_sound_gate_set" )]
public static void Set( string category = "", int concurrent = -1, float interval = -1f,
int perFrame = -1 )
{
if ( string.IsNullOrWhiteSpace( category ) )
{
Log.Info( "[nz-audio] nz_sound_gate_set <category> <concurrent> [interval] [perFrame]"
+ " (0 = unlimited)" );
foreach ( var k in Policies.Keys ) Log.Info( $"[nz-audio] {k}" );
return;
}
if ( !Policies.ContainsKey( category ) )
{
// ⚠️ REFUSES AN UNKNOWN NAME RATHER THAN ADDING IT. A typo used to write a live policy
// for a category nothing resolves to, which reads as "the setting did nothing".
Log.Warning( $"[nz-audio] no category '{category}' — "
+ string.Join( ", ", Policies.Keys ) );
return;
}
var p = Policies[category];
Policies[category] = new(
perFrame >= 0 ? perFrame : p.PerFrame,
concurrent >= 0 ? concurrent : p.Concurrent,
interval >= 0f ? interval : p.MinInterval );
p = Policies[category];
Log.Info( $"[nz-audio] {category}: concurrent {p.Concurrent}"
+ $", one new every {p.MinInterval:0.##}s"
+ $", perFrame {( p.PerFrame > 0 ? p.PerFrame.ToString() : "unlimited" )}" );
}
/// <summary>`nz_sound_gate_reset` — clear the drop counters before a measurement.</summary>
[ConCmd( "nz_sound_gate_reset" )]
public static void ResetCounts()
{
_dropped.Clear();
Log.Info( "[nz-audio] drop counters cleared" );
}
}