Audio/MixerBus.cs

Audio mixer utilities and console commands for the game. Defines named mixer bus constants, lookup and helpers to send an existing SoundHandle to a mixer, and several ConCmds to inspect and modify mixer volumes, max voice counts, solo/mute buses, and to print commands to persist changes.

NetworkingFile Access
using Sandbox;
using Sandbox.Audio;
using System;
using System.Collections.Generic;
using System.Linq;

namespace NZombies;

/// <summary>
/// THE MIXING DESK — the named buses in `ProjectSettings/Mixer.config`, and live control of them.
///
/// ⛔ THE PROJECT HAD NO MIXER AT ALL UNTIL 2026-09-15, AND THAT IS WHY SOUNDS CUT EACH OTHER
/// OFF. All 2,067 `.sound` assets shipped `"DefaultMixer": { "Name": "unknown", "Id": "000…0" }`,
/// so every sound in the game landed on the one implicit master mixer. A mixer has a `MaxVoices`
/// — 64 by default — and the surplus does not queue, it LOSES A PRIORITY RACE. The engine's own
/// words, on `SoundHandle.BuildSampleOnlyVoiceState`:
///
///     "Sample-only voice for a handle that lost the per-mixer priority race. SampleVoices still
///      advances the sampler; Mixer.ShouldPlay rejects SourceCount == 0 so it never gets mixed."
///
/// So the loser keeps running on schedule and is simply never heard — which from the player's
/// side is a sound stopping in the middle, or never starting. A horde of zombies, a shotgun, the
/// announcer and the music were all spending one 64-voice budget.
///
/// ⚠️ `MaxVoices` IS PER MIXER AND DOES NOT CAP THE SUBTREE. `Mixer.MixVoices` mixes "snapshot
/// voices THAT TARGET THIS MIXER" and `MixChildren` recurses separately, so a parent's budget
/// does not constrain its children. Splitting into buses therefore does two things at once: it
/// raises the total headroom, and — the half that actually matters — it ISOLATES the races, so a
/// firefight can no longer silence the announcer.
///
/// ⚠️ THE ROUTE LIVES IN THE ASSET, NOT IN THE PLAY CALL, because 92 call sites use
/// `Sound.Play(...)` directly and never touch `NZSound`. `Tools/sound_buses.py` stamps
/// `DefaultMixer` into every `.sound`; this class is for the handful of sounds whose asset is not
/// ours to stamp (engine surface impacts) and for balancing the buses by ear in game.
/// </summary>
public static class MixerBus
{
	public const string Master = "Master";
	public const string Music = "Music";
	public const string Announcer = "Announcer";
	public const string Ui = "UI";
	public const string Game = "Game";
	public const string Weapons = "Weapons";
	public const string Impacts = "Impacts";
	public const string Zombies = "Zombies";
	public const string Player = "Player";
	public const string Voice = "Voice";
	public const string World = "World";

	/// <summary>Every bus name, in the order the desk reads best.</summary>
	public static readonly string[] All =
	{
		Master, Music, Announcer, Ui, Game,
		Weapons, Impacts, Zombies, Player, Voice, World,
	};

	/// <summary>
	/// The bus with that name, or null.
	///
	/// ⛔ NOT CACHED, AND THAT WAS TRIED FIRST. A `Mixer` reference outlives the graph it came
	/// from when the project settings reload, and a stale one ACCEPTS a volume change that nothing
	/// can hear — which is indistinguishable in game from the bug this class exists to fix. The
	/// engine's own lookup is a name walk over at most eleven mixers; it is not worth a cache that
	/// can be wrong.
	///
	/// ⚠️ `Mixer.FindMixerByName` TAKES A NAME AND NOTHING ELSE — verified against the compiler,
	/// not assumed. The XML docs describe a two-argument form `(Mixer root, string name)` that is
	/// not what ships; the shipping API is one argument and searches from the master. There is no
	/// public `Children`, so a hand-rolled walk is not an option anyway.
	/// </summary>
	public static Mixer Find( string name )
		=> string.IsNullOrEmpty( name ) ? null : Mixer.FindMixerByName( name );

	/// <summary>
	/// How many voices this bus is mixing right now, or -1 if the meter cannot say.
	///
	/// ⚠️ WRAPPED, BECAUSE A DIAGNOSTIC MUST NOT BE THE THING THAT THROWS. The meter is
	/// written by the mix thread and a bus that has never played anything may not have one yet;
	/// a report that crashes on an idle bus is worse than a column of dashes.
	/// </summary>
	public static int LiveVoices( Mixer m )
	{
		if ( m is null ) return -1;

		try { return m.Meter.Current.VoiceCount; }
		catch { return -1; }
	}

	/// <summary>
	/// Send a already-playing handle to a bus.
	///
	/// ⚠️ FOR THE SOUNDS WHOSE ASSET IS NOT OURS TO STAMP. Bullet impacts come from the SURFACE's
	/// own `PrefabCollection`, which is engine content — `Tools/sound_buses.py` cannot reach it,
	/// and impacts are the highest-frequency sound in the game. Without this they would all land
	/// on `Game` and the `Impacts` bus would sit empty.
	///
	/// ⚠️ SILENT WHEN THE BUS IS MISSING, deliberately. A wrong name here must not cost the sound
	/// — it just keeps the default bus, which is where it was before any of this existed.
	/// </summary>
	public static void Send( SoundHandle handle, string bus )
	{
		if ( !handle.IsValid() ) return;

		var m = Find( bus );
		if ( m is null ) return;

		handle.TargetMixer = m;
	}
}

/// <summary>
/// `nz_mix` — the desk, live. Balance by ear, then bake.
///
/// ⛔ THIS IS THE ANSWER TO "VOLUME BALANCING", AND THE OLD ONE COULD NOT HAVE WORKED. `nz_vol`
/// scales ONE CUE at a time and there are 2,067 of them, 1,893 of which are weapons. Nobody
/// balances a game one asset at a time. A bus is the unit the ear actually judges — "guns are too
/// loud against the zombies" is one number, not nineteen hundred.
/// </summary>
public static class MixerCommands
{
	/// <summary>
	/// `nz_mix` lists the desk. `nz_mix &lt;bus&gt; &lt;volume&gt;` sets one.
	///
	/// ⚠️ TAKES EFFECT IMMEDIATELY AND ON SOUNDS ALREADY PLAYING, unlike `nz_vol` — a bus scales
	/// its whole output rather than seeding a voice as it starts. That is what makes it usable by
	/// ear: hold the trigger down and turn the knob.
	/// </summary>
	[ConCmd( "nz_mix" )]
	public static void Mix( string bus = "", float volume = -1f )
	{
		if ( string.IsNullOrWhiteSpace( bus ) )
		{
			Report();
			return;
		}

		var m = MixerBus.Find( bus );
		if ( m is null )
		{
			Log.Warning( $"[nz-mix] no bus '{bus}' — bare nz_mix lists them. "
				+ "If they are ALL missing, ProjectSettings/Mixer.config did not load." );
			return;
		}

		if ( volume < 0f )
		{
			Log.Info( $"[nz-mix] {m.Name} {m.Volume:0.00}" );
			return;
		}

		m.Volume = volume;
		Log.Info( $"[nz-mix] {m.Name} -> {volume:0.00}   (live, including sounds already playing)" );
	}

	static void Report()
	{
		// ⚠️ `Mixer.Master` IS THE TEST FOR "IS THERE A DESK AT ALL". It is the root of whatever
		// graph loaded, so a null here means no Mixer.config — not a missing bus.
		if ( Mixer.Master is null )
		{
			// ⛔ THE ONE FAILURE WORTH SPELLING OUT. No desk means every sound is back on the
			// single implicit master and cutting itself off again, and the symptom in game is
			// identical to having a desk that is merely mis-balanced.
			Log.Warning( "[nz-mix] NO MIXER GRAPH — ProjectSettings/Mixer.config is missing or "
				+ "failed to load. Every sound is on the one default mixer." );
			return;
		}

		// ⚠️ THE LIVE VOICE COUNT IS THE COLUMN THAT ANSWERS THE QUESTION. A bus sitting AT its
		// maxvox is a bus that is dropping sounds right now, and that is the one measurement the
		// old per-cue tooling could not make - it counted what was ASKED FOR, not what got mixed.
		Log.Info( "[nz-mix] bus            vol    voices" );

		foreach ( var name in MixerBus.All )
		{
			var m = MixerBus.Find( name );
			if ( m is null ) continue;

			var flags = (m.Mute ? " MUTE" : "") + (m.Solo ? " SOLO" : "");
			var live = MixerBus.LiveVoices( m );
			var full = live >= m.MaxVoices ? "  <- FULL, dropping" : "";

			Log.Info( $"[nz-mix]   {m.Name,-12} {m.Volume,5:0.00}  {live,3}/{m.MaxVoices,-3}"
				+ $"{flags}{full}" );
		}

		Log.Info( "[nz-mix] nz_mix <bus> <vol> to balance, nz_mix_solo <bus> to isolate, "
			+ "nz_mix_bake when it sounds right" );
	}

	/// <summary>`nz_mix_voices &lt;bus&gt; &lt;n&gt;` — the per-bus voice budget, live.</summary>
	[ConCmd( "nz_mix_voices" )]
	public static void Voices( string bus = "", int max = -1 )
	{
		var m = MixerBus.Find( bus );
		if ( m is null ) { Log.Warning( $"[nz-mix] no bus '{bus}'" ); return; }

		if ( max < 1 ) { Log.Info( $"[nz-mix] {m.Name} maxvoices {m.MaxVoices}" ); return; }

		m.MaxVoices = max;
		Log.Info( $"[nz-mix] {m.Name} maxvoices -> {max}"
			+ "   (raise this if that group is still cutting itself off)" );
	}

	/// <summary>
	/// `nz_mix_solo &lt;bus&gt;` — hear one group on its own. Bare command clears it.
	///
	/// ⚠️ THE FASTEST WAY TO ANSWER "IS THIS CUE EVEN PLAYING". A sound that is being starved of
	/// voices and one that is simply too quiet are indistinguishable in the full mix; on its own
	/// bus, with everything else muted, they are not.
	/// </summary>
	[ConCmd( "nz_mix_solo" )]
	public static void Solo( string bus = "" )
	{
		foreach ( var name in MixerBus.All )
		{
			var m = MixerBus.Find( name );
			if ( m is not null ) m.Solo = false;
		}

		if ( string.IsNullOrWhiteSpace( bus ) )
		{
			Log.Info( "[nz-mix] solo cleared" );
			return;
		}

		var t = MixerBus.Find( bus );
		if ( t is null ) { Log.Warning( $"[nz-mix] no bus '{bus}'" ); return; }

		t.Solo = true;
		Log.Info( $"[nz-mix] soloing {t.Name} — nz_mix_solo with no argument to clear" );
	}

	/// <summary>`nz_mix_mute &lt;bus&gt; &lt;0|1&gt;`.</summary>
	[ConCmd( "nz_mix_mute" )]
	public static void Mute( string bus = "", int on = 1 )
	{
		var m = MixerBus.Find( bus );
		if ( m is null ) { Log.Warning( $"[nz-mix] no bus '{bus}'" ); return; }

		m.Mute = on != 0;
		Log.Info( $"[nz-mix] {m.Name} {(m.Mute ? "muted" : "unmuted")}" );
	}

	/// <summary>
	/// `nz_mix_bake` — print what to persist, and the command that persists it.
	///
	/// ⚠️ REPORTS, DOES NOT WRITE, which is the same rule `nz_vol_bake` follows and for the same
	/// reason: `ProjectSettings/Mixer.config` is a source file, and a console command quietly
	/// rewriting one is a change nobody reviewed. It also cannot — game code does not get to
	/// write into the project's settings folder.
	/// </summary>
	[ConCmd( "nz_mix_bake" )]
	public static void Bake()
	{
		var moved = new List<string>();

		foreach ( var name in MixerBus.All )
		{
			var m = MixerBus.Find( name );
			if ( m is null ) continue;

			// ⚠️ MaxVoices GOES IN THE BAKE TOO. It is as much part of the settled mix as the
			// volume is — a bus that needed 40 voices to stop cutting out needs them next session
			// as well, and losing that on restart would read as the fix having not worked.
			if ( MathF.Abs( m.Volume - 1f ) > 0.005f )
				moved.Add( $"{m.Name}={m.Volume:0.###}" );

			if ( m.MaxVoices != DefaultVoices( m.Name ) )
				moved.Add( $"{m.Name}:voices={m.MaxVoices}" );
		}

		if ( moved.Count == 0 )
		{
			Log.Info( "[nz-mix] nothing moved — every bus is at 1.00 and its authored voice count" );
			return;
		}

		Log.Info( $"[nz-mix] {moved.Count} change(s). Run this at the working folder to make "
			+ "them permanent:" );
		Log.Info( "[nz-mix]   python \"Tools/mixer_set.py\" " + string.Join( " ", moved ) );
	}

	/// <summary>
	/// The voice counts `Mixer.config` was authored with, so the bake can report only what moved.
	///
	/// ⛔ A SECOND COPY OF A NUMBER THAT LIVES IN A FILE, AND IT IS THE LESSER EVIL. The honest
	/// alternative is re-reading `Mixer.config` from game code, which cannot reach the project's
	/// settings folder at runtime. This is only ever used to decide whether to PRINT a line, so
	/// the failure mode of it drifting is a redundant entry in the bake list — not a wrong mix.
	/// </summary>
	static int DefaultVoices( string bus ) => bus switch
	{
		MixerBus.Master => 32,
		MixerBus.Music => 4,
		MixerBus.Announcer => 3,
		MixerBus.Ui => 8,

		// ⚠️ THE CATCH-ALL, AND IT CATCHES MORE THAN IT LOOKS. Footsteps are played by the
		// engine's own player controller from SURFACE sounds — `NZPlayer` only sets its
		// `FootstepVolume` — so they have no asset of ours to stamp and land here.
		MixerBus.Game => 24,

		MixerBus.Weapons => 32,
		MixerBus.Impacts => 10,

		// ⛔ 48 BECAUSE `SoundGate` MUST BIND BEFORE `MaxVoices` DOES, AND AT 32 IT DID NOT.
		// The gate allows 15 concurrent each of hit, spawn and death — 45 — before the idle
		// groans and the footsteps that share this bus. The two limits do the same job and only
		// one of them does it well: the gate drops BEFORE the sound starts, cheaply, and CHOOSES
		// which copy to drop; the bus race drops after, having already paid to start it, by
		// creation time. Whichever number is smaller is the one actually shaping the mix, so the
		// bus is set above the gate and left as the safety net it should be.
		MixerBus.Zombies => 48,

		MixerBus.Player => 12,
		MixerBus.Voice => 6,
		MixerBus.World => 24,
		_ => -1,
	};
}