Audio/SoundGate.cs

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.

File Access
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 &lt;category&gt; &lt;concurrent&gt; [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" );
	}
}