Effects/SoundSpotManager.cs

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.

File Access
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;
	}
}