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.
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 <bus> <volume>` 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 <bus> <n>` — 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 <bus>` — 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 <bus> <0|1>`.</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,
};
}