Manager component that builds and runs placed looping sound spots from the active config. It creates scene marker objects, tracks Voice entries with SoundHandles, ranks nearest N spots per local player, starts/stops/fades handles to maintain seamless looping and sweeps orphaned emitters.
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
namespace NZombies;
/// <summary>
/// Builds the config's hand-placed looping sounds.
///
/// ⚠️ A POINT SOURCE, UNLIKE THE DAMAGE WALL'S. `DamageWallVolume` walks its emitter to the nearest
/// part of the volume each frame, because lava is an AREA and a centre-anchored emitter would be a
/// thousand units from lava you are standing on. A placed sound is a POINT by definition — a vent, a
/// generator, a dripping pipe — so it stays where it was put and needs no per-frame work at all.
///
/// # MAPPORT: sound placeable
/// </summary>
public sealed class SoundSpotManager : Component
{
public static SoundSpotManager Instance { get; private set; }
protected override void OnAwake() => Instance = this;
public static SoundSpotManager Ensure( Scene scene = null )
{
if ( Instance.IsValid() ) return Instance;
scene ??= Game.ActiveScene;
if ( !scene.IsValid() ) return null;
var go = scene.CreateObject();
go.Name = "Sound Spot Manager";
go.Flags |= GameObjectFlags.NotSaved;
return go.Components.Create<SoundSpotManager>();
}
readonly List<GameObject> _built = new();
public int Built => _built.Count( g => g.IsValid() );
public void Rebuild()
{
// ⛔ SILENCE BEFORE DROPPING THE LIST. `_live` is the ONLY reference to the playing handles;
// clearing it without stopping them leaves audio running that nothing can reach — the same
// failure as the orphaned emitters, one layer further down.
foreach ( var v in _live ) Silence( v );
_live.Clear();
foreach ( var g in _built ) g?.Destroy();
_built.Clear();
SweepStrays();
var list = ActiveConfig.Current?.Sounds;
if ( list is null || list.Count == 0 ) return;
var missing = 0;
for ( int i = 0; i < list.Count; i++ )
if ( !Build( i, list[i] ) ) missing++;
Log.Info( $"[nz-sound] {Built} of {list.Count} placed sound(s) built"
+ ( missing > 0 ? $" ⚠ {missing} with no sound event set" : "" ) );
// ⛔ THE MIXER'S BUDGET IS 64 VOICES AND THE SURPLUS IS NOT QUEUED, IT IS DROPPED. Every
// looping spot holds a voice for as long as it plays, so a map with more of them than the
// bus allows loses the difference to a priority race — silently, and not always the same
// ones. See MixerBus: "the loser keeps running on schedule and is simply never heard".
//
// ⚠️ AND THE BUS IS SHARED. World also carries doors, machines and impacts, so ambience
// filling it does not just cut itself off — it silences everything else routed there.
if ( Built > 48 )
Log.Warning( $"[nz-sound] ⚠ {Built} looping emitters on one mixer — its budget is 64 "
+ "VOICES and the surplus is dropped, not queued. Consider fewer spots with a "
+ "larger Distance, or let a damage wall's own chasing emitter cover the area." );
}
/// <summary>
/// Destroy any emitter in the scene this manager is not holding.
///
/// ⛔ BECAUSE A PREVIOUS MANAGER'S EMITTERS OUTLIVED IT, AND THEY WERE STILL AUDIBLE. The
/// spots were built at the scene root, so destroying a manager left all 79 of them standing with
/// nothing holding a reference: `nz_sound_voices_restart` threw the manager away WITHOUT
/// rebuilding it first, the replacement built a second full set, and the scene ended up with 159
/// SoundPointComponents where the config asks for 80. The orphans answer to no cap, no distance
/// check and no stop — they are exactly the sounds that cannot be silenced.
///
/// ⚠️ PARENTING THEM (see Build) STOPS NEW ONES LEAKING; THIS CLEARS WHAT ALREADY LEAKED.
/// Both are needed: the parenting only governs emitters built after it shipped, and a session
/// that has been running across a hotload still has the old flat ones in it.
/// </summary>
void SweepStrays()
{
// ⛔ AND IT ATE ITS OWN MANAGER. The manager object is called "Sound Spot Manager", which
// STARTS WITH "Sound Spot " — so the first version of this swept the very component running
// it, taking the 79 emitters it had just built down with it and leaving the scene with one
// SoundPointComponent. A prefix is not a category: `--only=lights` matched `models/HardLight`
// in the material tool for the same reason, and a name test always needs its own exclusions.
var strays = Scene.GetAllObjects( false )
.Where( g => g.IsValid() && g != GameObject )
.Where( g => g.Name != null && g.Name.StartsWith( "Sound Spot " )
&& g.Name != "Sound Spot Manager" )
.Where( g => !_built.Contains( g ) )
.ToList();
if ( strays.Count == 0 ) return;
foreach ( var g in strays ) g.Destroy();
Log.Warning( $"[nz-sound] swept {strays.Count} orphaned emitter(s) left by an earlier manager"
+ " — those were playing outside the voice cap" );
}
bool Build( int index, SoundSpot spot )
{
// ⚠️ A SPOT WITH NO EVENT IS STILL A PLACED THING. It gets no object, but it stays in the
// config and the count above says so — silently dropping it would make a half-finished
// placement look like one that never happened.
if ( string.IsNullOrWhiteSpace( spot.Sound ) ) return false;
var evt = ResourceLibrary.Get<SoundEvent>( spot.Sound );
if ( evt is null )
{
Log.Warning( $"[nz-sound] #{index}: '{spot.Sound}' did not load — silent" );
return false;
}
var go = Scene.CreateObject();
go.Name = $"Sound Spot {index}";
go.Flags |= GameObjectFlags.NotSaved;
go.NetworkMode = NetworkMode.Never; // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)
// ⛔ PARENTED TO THE MANAGER, AND IT WAS NOT. Created at the scene root, these outlive the
// manager that owns them: recycling it ORPHANED 79 emitters and the new one built 79 more,
// leaving 159 SoundPointComponents in the scene. The orphans still carried the older code's
// `PlayOnStart`, so they went on sounding while the new set reported itself silent — the
// audio you could hear and the list you were reading were describing different objects.
//
// ⚠️ AND THE POSITION IS SET AFTER PARENTING. `SetParent` keeps the WORLD transform, so the
// order matters — the damage wall's emitter already sat at the world origin for exactly this.
go.SetParent( GameObject );
go.WorldPosition = spot.Position;
// ⛔ THE OBJECT IS A MARKER NOW, AND CARRIES NO SoundPointComponent. Playback is handles
// owned by this manager (see Voice). The object stays because it is what makes a placed spot
// findable in the scene tree, and because `SweepStrays` cleans up by name.
// ⛔ `spot.Repeat` AND ITS INTERVAL ARE NO LONGER USED, AND THAT IS THE FIX. The engine's
// repeat scheduler did not survive this manager's `StopSound`/`StartSound`: a spot granted a
// slot played its clip ONCE and then sat silent for as long as it held that slot, which is
// the reported *"the sound ends and just stays like that"*. `RepeatMin` is still read — as
// the CLIP LENGTH, which is what the tool writes into it — but the looping itself is now
// scheduled in `Maintain` against the handle's own elapsed time.
_built.Add( go );
_live.Add( new Voice
{
Spot = spot,
Event = evt,
Length = MathF.Max( 0.2f, spot.RepeatMin ),
} );
return true;
}
// ── nearest-N voice management ──────────────────────────────────────────
/// <summary>
/// One placed spot, and the handles currently sounding it.
///
/// ⛔ HANDLES, NOT A `SoundPointComponent`, BECAUSE THE COMPONENT CANNOT BE ASKED ANYTHING.
/// It has no `IsPlaying`, no `Finished`, no elapsed time, and its `SoundHandle` is `protected`
/// — all verified by compiling against it. You can tell it to start and you can tell it to stop,
/// and nothing else. So `Playing` was only ever a record of having CALLED StartSound, and when
/// the clip ran out and its `Repeat` did not re-fire, the manager went on believing it was
/// sounding: reported as *"the sound ends and just stays like that"* while the report said
/// PLAYING. A `SoundHandle` from `Sound.Play` answers all three questions.
/// </summary>
sealed class Voice
{
public SoundSpot Spot;
public SoundEvent Event;
public float Length;
/// <summary>Live handles — briefly TWO, where the next pass has started under the tail
/// of the one it replaces. That overlap is the thing that removes the seam.</summary>
public readonly List<SoundHandle> Handles = new();
public bool Playing;
}
readonly List<Voice> _live = new();
/// <summary>
/// `nz_sound_voices` — how many placed sounds may play at once, per player.
///
/// ⛔ A HARD CAP EXISTS BECAUSE THE MIXER'S DOES NOT BEHAVE LIKE ONE. `World` allows 64 voices
/// and the surplus is not queued — it loses a priority race, keeps running on schedule, and is
/// never heard. That makes over-subscription silent, intermittent, and not always the same
/// emitters. Capping well under the budget means the ones we DO play can never lose that race,
/// and leaves room for the doors, machines and impacts sharing the bus.
/// </summary>
[ConVar( "nz_sound_voices" )] public static int MaxVoices { get; set; } = 6;
/// <summary>
/// How much closer a rival must be before it takes a playing emitter's slot.
///
/// ⛔ WITHOUT THIS, TWO SPOTS AT NEARLY EQUAL RANGE SWAP EVERY TICK AND BOTH STUTTER. Walking
/// the boundary between them, the ranking flips back and forth and each restart cuts the clip
/// off at the top — which is precisely the "stopping in the middle" this whole change is meant
/// to remove. A playing emitter is ranked as if 15% nearer, so it keeps its slot until something
/// is clearly closer rather than marginally.
/// </summary>
[ConVar( "nz_sound_hysteresis" )] public static float Hysteresis { get; set; } = 0.85f;
/// <summary>
/// `nz_sound_overlap` — seconds the next pass starts BEFORE the current one ends.
///
/// ⚠️ THIS IS THE "CUT THE CORNERS" KNOB. The loop is an mp3, which carries no loop points, so
/// seamlessness has to be manufactured: the next pass begins while the previous is still
/// sounding and they sum for this long. On a noise bed — lava, wind, machinery — that is
/// inaudible; on anything with a beat or a pitched tail it would double briefly, which is why it
/// is a knob and not a constant.
/// </summary>
[ConVar( "nz_sound_overlap" )] public static float Overlap { get; set; } = 1.5f;
/// <summary>
/// `nz_sound_fade` — seconds to fade a voice out when a nearer spot takes its slot.
///
/// ⚠️ A HARD STOP MID-CLIP IS A CLICK, and the cap makes those happen constantly as the
/// player walks. `SoundHandle.Stop( float )` ramps instead.
/// </summary>
[ConVar( "nz_sound_fade" )] public static float FadeOut { get; set; } = 0.4f;
TimeSince _sinceVoices;
/// <summary>
/// How many times the voice loop has run.
///
/// ⛔ THE ONE FACT THAT SEPARATES "THE RANKING IS WRONG" FROM "THE LOOP IS NOT RUNNING", and
/// nothing else in the readout distinguishes them: a manager whose `OnUpdate` never fires looks
/// exactly like one whose candidates are all out of range — valid instance, populated list, a
/// report that works because a ConCmd does not need the hook.
/// </summary>
int _ticks;
/// <summary>
/// `nz_sound_voices_restart` — throw the manager away and build it again.
///
/// ⛔ A HOTLOAD KEEPS A COMPONENT INSTANCE BUT NOT HOOKS IT NEVER HAD. `OnUpdate` was added to
/// this class while an instance was already live, and the engine went on running the old
/// registration — so the voice tick never fired and every spot reported silent with twelve of
/// them in range. Nothing about that is visible: the manager is valid, its list is populated,
/// and the report works, because the report is a ConCmd and does not depend on the hook.
///
/// ⚠️ WHICH MAKES THIS THE FIRST THING TO TRY when a manager stops responding after a code
/// change, rather than restarting play and losing what is placed.
/// </summary>
[ConCmd( "nz_sound_voices_restart" )]
public static void VoicesRestart()
{
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) { Log.Warning( "[nz-sound] no scene" ); return; }
// ⛔ REBUILDS IN PLACE, AND IT USED TO DESTROY THE MANAGER FIRST. That destroy is what
// orphaned 79 emitters: it threw away the only object holding them WITHOUT rebuilding it
// first, so nothing ever ran their `_built` cleanup and the replacement built a second full
// set on top. Rebuild() drops this manager's own emitters and sweeps anything else, which is
// the whole point of the command with none of the lifetime risk.
var mgr = Ensure( scene );
if ( !mgr.IsValid() ) { Log.Warning( "[nz-sound] no manager" ); return; }
mgr.Rebuild();
Log.Info( "[nz-sound] rebuilt" );
}
/// <summary>
/// `nz_sound_voices_report` — which spots are sounding right now, and why.
///
/// ⛔ THE ONLY WAY TO SEE A CAP WORKING. With 79 placed and 6 audible, "I cannot hear that one"
/// is the expected behaviour and a bug look identical from inside the game. This prints the
/// ranking the manager actually used, so the answer is a list rather than a guess.
/// </summary>
[ConCmd( "nz_sound_voices_report" )]
public static void VoicesReport()
{
var mgr = Instance;
if ( !mgr.IsValid() ) { Log.Warning( "[nz-sound] no manager" ); return; }
var p = NZPlayer.Local;
if ( !p.IsValid() ) { Log.Warning( "[nz-sound] no local player — all silenced" ); return; }
var ear = p.WorldPosition;
var rows = mgr._live
.Select( v => new { V = v, Raw = ear.Distance( v.Spot.Position ) } )
.OrderBy( x => x.Raw )
.Take( 12 )
.ToList();
// ⚠️ COUNTS LIVE HANDLES, NOT THE FLAG. The flag says this manager believes the voice is
// sounding; the handles say whether anything actually is. When those two disagree the loop
// has stalled, which is exactly the fault that hid behind the old readout.
var handles = mgr._live.Sum( v => v.Handles.Count( h => h.IsValid() && h.IsPlaying ) );
Log.Info( $"[nz-sound] {mgr._live.Count( v => v.Playing )} of {mgr._live.Count} sounding"
+ $" {handles} live handle(s)"
+ $" cap {MaxVoices} overlap {Overlap:0.##}s"
+ $" hysteresis {Hysteresis:0.##} ticks {mgr._ticks}" );
if ( mgr._ticks == 0 )
Log.Warning( "[nz-sound] ⚠ the voice loop has NEVER RUN — OnUpdate is not firing on this "
+ "manager. `nz_sound_voices_restart`." );
foreach ( var r in rows )
Log.Info( $"[nz-sound] {( r.V.Playing ? "▶ PLAYING" : " silent " )}"
+ $" {r.V.Handles.Count( h => h.IsValid() && h.IsPlaying )}h"
+ $" {r.Raw:0}u"
+ $" range {r.V.Spot.Distance:0}"
+ ( r.Raw > r.V.Spot.Distance ? " (out of range)" : "" ) );
}
protected override void OnUpdate()
{
// ⚠️ TEN TIMES A SECOND, NOT EVERY FRAME. This is a distance sort over every placed spot;
// at 0.1s a sprinting player has moved ~30 units, far less than the hysteresis margin, so
// nothing is audibly late.
if ( _sinceVoices < 0.1f ) return;
_sinceVoices = 0f;
_ticks++;
TickVoices();
}
/// <summary>
/// Keep the nearest <see cref="MaxVoices"/> emitters sounding and silence the rest.
///
/// ⚠️ THE LOCAL PLAYER, AND THAT IS NOT A MULTIPLAYER SLIP. Which sounds a machine can hear is
/// that machine's business — each client ranks against its own listener and there is nothing to
/// network. `NZPlayer.Local` is the right accessor for exactly this.
/// </summary>
void TickVoices()
{
var p = NZPlayer.Local;
// ⚠️ NO LISTENER MEANS SILENCE, NOT "LEAVE IT AS IT WAS". In the lobby, or between spawns,
// leaving whatever was playing running would carry one map's ambience into the menu.
if ( !p.IsValid() )
{
foreach ( var v in _live ) Silence( v );
return;
}
var ear = p.WorldPosition;
// ⛔ OUT OF ITS OWN RANGE IS NOT A CANDIDATE AT ALL. "The six nearest" on an empty stretch
// of map would otherwise hold slots open for emitters nobody can hear, while a spot that
// just came into earshot waits behind them.
var ranked = _live
.Select( v => new
{
V = v,
Raw = ear.Distance( v.Spot.Position ),
} )
.Where( x => x.Raw <= x.V.Spot.Distance )
.OrderBy( x => x.Raw * ( x.V.Playing ? Hysteresis : 1f ) )
.ToList();
var keep = new HashSet<Voice>();
for ( int i = 0; i < ranked.Count && i < MaxVoices; i++ )
keep.Add( ranked[i].V );
foreach ( var v in _live )
{
if ( keep.Contains( v ) ) Maintain( v );
else Silence( v );
}
}
/// <summary>
/// Keep one voice sounding, without a gap and without a restart.
///
/// ⛔ THIS IS WHERE THE LOOP LIVES NOW. The engine's `Repeat` was doing this job and stopped
/// doing it the moment the manager took over starting and stopping, with no way to notice: the
/// component cannot be asked whether it is still playing. Scheduling it here against
/// `SoundHandle.ElapsedTime` means the next pass is started by something that can SEE the
/// current one.
/// </summary>
void Maintain( Voice v )
{
// Drop handles that have run out. `Finished` and `IsPlaying` are both real on a handle —
// which is the whole reason playback moved off the component.
v.Handles.RemoveAll( h => !h.IsValid() || h.Finished || !h.IsPlaying );
var newest = v.Handles.Count > 0 ? v.Handles[^1] : null;
// ⚠️ THE SCHEDULED START IS THE ONE THAT MATTERS; THE EMPTY CASE IS A SAFETY NET. If the
// length in the config is wrong, or a handle dies early, `newest == null` catches it within
// one tick — a 0.1s gap rather than a permanent one. Relying on that alone would be audible
// on every pass, which is why the overlap below exists.
// ⚠️ `Time`, NOT `ElapsedTime` — the latter compiles and is marked obsolete in this engine
// build. Same value, and the deprecated one is the sort that disappears in an update.
var due = newest is null || newest.Time >= v.Length - Overlap;
if ( !due ) { v.Playing = true; return; }
var h = Sound.Play( v.Event, v.Spot.Position );
if ( !h.IsValid() )
{
// The mixer refused it. Saying so beats a voice that silently never returns.
if ( v.Playing ) Log.Warning( $"[nz-sound] '{v.Spot.Sound}' would not start — mixer full?" );
v.Playing = false;
return;
}
h.Volume = v.Spot.Volume;
h.DistanceAttenuation = true;
h.Distance = v.Spot.Distance;
h.OcclusionEnabled = true;
v.Handles.Add( h );
v.Playing = true;
}
/// <summary>Fade a voice out and forget its handles.</summary>
void Silence( Voice v )
{
if ( v.Handles.Count == 0 ) { v.Playing = false; return; }
// ⛔ `Stop( fade )`, NOT `Stop()`. The cap swaps slots constantly as the player walks, so a
// hard cut here is not a rare edge case — it is a click every few seconds.
foreach ( var h in v.Handles )
if ( h.IsValid() ) h.Stop( FadeOut );
v.Handles.Clear();
v.Playing = false;
}
/// <summary>
/// ⛔ HANDLES ARE NOT OWNED BY A GAMEOBJECT, SO NOTHING ELSE WILL EVER STOP THEM. Destroying
/// this manager destroys its marker objects and leaves every playing handle running with no
/// reference to it anywhere — the same unstoppable-audio failure the orphaned emitters caused,
/// in a form the scene tree cannot even show.
/// </summary>
protected override void OnDestroy()
{
foreach ( var v in _live ) Silence( v );
_live.Clear();
if ( Instance == this ) Instance = null;
}
}