SoundBoardManager.cs

Manager for playing sound pads and looping layers. It tracks active SoundHandle voices, supports local and remote plays, stacking loop layers with timed re-fire, caches measured durations to compute loop intervals, exposes state events and status text for UI, and enforces a hard cap on simultaneous voices.

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

namespace SSoundboard;

/// <summary>
/// Playback + LOOP. Loop uses timer re-fire (web audio.loop parity) — not IsPlaying end detection.
/// </summary>
public static class SoundBoardManager
{
	public static bool LoopEnabled { get; private set; }
	public static int ActiveVoiceCount => CountActive();
	public static int LoopLayerCount => _loops.Count;
	/// <summary>Bumps when live pad set changes — cheap UI rebuild signal.</summary>
	public static int VoiceRevision { get; private set; }
	public static string StatusText { get; private set; } = "READY · PRESS ANY BUTTON";
	public static string LastPlayedLabel { get; private set; } = "";

	const float DefaultInterval = 0.35f;
	const float MinInterval = 0.12f;
	/// <summary>Hard cap so loop chaos cannot unbounded-allocate handles.</summary>
	const int MaxVoices = 64;

	static readonly List<Voice> _voices = new();
	static readonly List<LoopLayer> _loops = new();
	static readonly Dictionary<string, float> _durationSec = new( StringComparer.OrdinalIgnoreCase );
	static readonly HashSet<string> _liveIds = new( StringComparer.OrdinalIgnoreCase );

	static int _lastCount = -1;
	static float _killCooldown;
	static int _nextId = 1;
	static float _lastTickNow = -1f;

	public static event Action StateChanged;

	public static bool IsPadPlaying( string soundId )
	{
		if ( string.IsNullOrWhiteSpace( soundId ) )
			return false;

		return _liveIds.Contains( soundId );
	}

	public static void Play( SoundPad pad )
	{
		if ( pad is null || string.IsNullOrWhiteSpace( pad.SoundId ) )
			return;

		try
		{
			SoundPlayTracker.Record( pad.SoundId );
			SoundBoardAchievements.OnPadPressed( pad );
			SoundPreloadService.BumpPriority( pad.SoundId );

			if ( !SoundPreloadService.IsReady( pad.SoundId ) )
			{
				SoundPreloadService.QueuePlayWhenReady( pad );
				SetStatus( $"LOADING · {pad.Label}" );
				StateChanged?.Invoke();
				return;
			}

			PlayLoaded( pad );
		}
		catch ( Exception e )
		{
			Log.Warning( $"[S&Soundboard] Play failed · {pad.Label} · {e.GetType().Name}: {e.Message}" );
			SetStatus( $"ERROR · {pad.Label}" );
			SoundBoardAchievements.OnCreatedError( pad.Label );
			StateChanged?.Invoke();
		}
	}

	/// <summary>
	/// Lobby co-op press from someone else. Plays audio + lights the pad, but does
	/// not record local click achievements / LOOP layers (still counts toward voice depth).
	/// </summary>
	public static void PlayRemote( SoundPad pad, string playerName )
	{
		if ( pad is null || string.IsNullOrWhiteSpace( pad.SoundId ) )
			return;

		try
		{
			SoundPreloadService.BumpPriority( pad.SoundId );

			TrimExcessVoices();

			var pitch = RandomPitch();
			if ( !Fire( pad, pitch, out var handle ) )
			{
				// Missing on this client — still surface the press in status.
				var who = string.IsNullOrWhiteSpace( playerName ) ? "REMOTE" : playerName;
				SetStatus( $"CO-OP · {who} · MISSING {pad.Label}" );
				StateChanged?.Invoke();
				return;
			}

			_voices.Add( new Voice
			{
				Handle = handle,
				SoundId = pad.SoundId,
				Pad = pad,
				Pitch = pitch,
				IsRemote = true
			} );

			// Remotes do not join local LOOP — each client loops only their own presses.
			var whoLive = string.IsNullOrWhiteSpace( playerName ) ? "REMOTE" : playerName;
			LastPlayedLabel = $"{whoLive} · {pad.Label}";
			RebuildLiveIds();
			NotifyLivePlaybackAchievements();
			PushState();
		}
		catch ( Exception e )
		{
			Log.Warning( $"[S&Soundboard] PlayRemote failed · {pad.Label} · {e.GetType().Name}: {e.Message}" );
			StateChanged?.Invoke();
		}
	}

	/// <summary>Stop only lobby-sourced voices (local presses / LOOP stay).</summary>
	public static void KillRemoteVoices()
	{
		var changed = false;
		for ( var i = _voices.Count - 1; i >= 0; i-- )
		{
			var v = _voices[i];
			if ( !v.IsRemote )
				continue;

			if ( v.Handle.IsValid() )
				v.Handle.Stop();

			_voices.RemoveAt( i );
			changed = true;
		}

		if ( !changed )
			return;

		RebuildLiveIds();
		NotifyLivePlaybackAchievements();
		PushState();
	}

	public static void OnSoundReady( string soundId )
	{
		if ( !SoundPreloadService.TryTakePendingPlay( soundId, out var pad ) )
		{
			StateChanged?.Invoke();
			return;
		}

		PlayLoaded( pad );
	}

	static void PlayLoaded( SoundPad pad )
	{
		TrimExcessVoices();

		var pitch = RandomPitch();
		if ( !Fire( pad, pitch, out var handle ) )
		{
			SetStatus( $"MISSING · {pad.Label}" );
			SoundBoardAchievements.OnCreatedError( pad.Label );
			StateChanged?.Invoke();
			return;
		}

		_voices.Add( new Voice
		{
			Handle = handle,
			SoundId = pad.SoundId,
			Pad = pad,
			Pitch = pitch,
			IsRemote = false
		} );

		if ( LoopEnabled )
			AddLoop( pad, pitch, resetAccumulator: true );

		LastPlayedLabel = pad.Label;
		RebuildLiveIds();
		NotifyLivePlaybackAchievements();
		PushState();
	}

	public static void ToggleLoop()
	{
		LoopEnabled = !LoopEnabled;

		if ( LoopEnabled )
		{
			for ( var i = 0; i < _voices.Count; i++ )
			{
				var v = _voices[i];
				if ( v.Pad is null || !IsVoiceLive( v ) )
					continue;

				AddLoop( v.Pad, v.Pitch, resetAccumulator: false );
			}

			SetStatus( _loops.Count > 0
				? $"LOOP ON · {_loops.Count} LAYER{(_loops.Count == 1 ? "" : "S")}"
				: "LOOP ON · PRESS A PAD" );
		}
		else
		{
			_loops.Clear();
			if ( CountLiveVoices() == 0 )
				SetStatus( "READY · PRESS ANY BUTTON" );
		}

		RebuildLiveIds();
		NotifyLivePlaybackAchievements();
		StateChanged?.Invoke();
	}

	public static void KillAll()
	{
		var silenced = _voices.Count;

		for ( var i = 0; i < _voices.Count; i++ )
		{
			if ( _voices[i].Handle.IsValid() )
				_voices[i].Handle.Stop();
		}

		_voices.Clear();
		_loops.Clear();
		_liveIds.Clear();
		_lastCount = 0;
		_killCooldown = 0.15f;
		VoiceRevision++;
		SoundPlayTracker.Flush();
		SoundBoardAchievements.OnKillAll( silenced );
		SetStatus( "☠ SILENCED · KILL ALL" );
		StateChanged?.Invoke();
	}

	public static void Tick()
	{
		// Panel + Bootstrap both call Tick — once per frame only.
		var frameNow = Time.Now;
		if ( MathF.Abs( frameNow - _lastTickNow ) < 0.00001f )
			return;
		_lastTickNow = frameNow;

		var dt = Time.Delta;
		if ( dt <= 0f || dt > 0.25f )
			dt = RealTime.Delta;
		if ( dt <= 0f || dt > 0.25f )
			dt = 1f / 60f;

		if ( _killCooldown > 0f )
			_killCooldown = MathF.Max( 0f, _killCooldown - dt );

		var changed = false;

		for ( var i = _voices.Count - 1; i >= 0; i-- )
		{
			var v = _voices[i];
			v.Age += dt;
			SamplePeak( v );

			if ( IsVoiceLive( v ) )
				continue;

			var measured = v.PeakTime > 0.05f ? v.PeakTime : v.Age;
			if ( measured > 0.08f )
			{
				var natural = measured * MathF.Max( v.Pitch, 0.1f );
				CacheDuration( v.SoundId, natural );
				ApplyDurationToLoops( v.SoundId, v.Pitch, natural );
			}

			_voices.RemoveAt( i );
			changed = true;
		}

		if ( LoopEnabled && _killCooldown <= 0f && _loops.Count > 0 )
		{
			for ( var i = 0; i < _loops.Count; i++ )
			{
				var loop = _loops[i];
				if ( loop.Pad is null )
					continue;

				loop.Accumulator += dt;
				if ( loop.Accumulator < loop.Interval )
					continue;

				loop.Accumulator -= loop.Interval;
				if ( loop.Accumulator > loop.Interval )
					loop.Accumulator = 0f;

				if ( _voices.Count >= MaxVoices )
					continue;

				if ( !Fire( loop.Pad, loop.Pitch, out var handle ) )
					continue;

				_voices.Add( new Voice
				{
					Handle = handle,
					SoundId = loop.SoundId,
					Pad = loop.Pad,
					Pitch = loop.Pitch,
					IsRemote = false
				} );
				// Lifetime LOOP re-fire counter (achievements: loop_1 … loop_1k).
				SoundPlayTracker.RecordLoopPlay();
				changed = true;
			}
		}

		if ( changed )
		{
			RebuildLiveIds();
			NotifyLivePlaybackAchievements();
		}

		var count = CountActive();
		if ( changed || count != _lastCount )
		{
			_lastCount = count;

			if ( count == 0 )
				SetStatus( LoopEnabled ? "LOOP ON · PRESS A PAD" : "READY · PRESS ANY BUTTON" );
			else
				UpdateStatus( count );

			// LOOP stacking grows voices here — must check milestones every count change,
			// not only on manual pad press (PushState).
			SoundBoardAchievements.OnVoiceCountChanged( count );
			StateChanged?.Invoke();
		}
	}

	static void TrimExcessVoices()
	{
		while ( _voices.Count >= MaxVoices )
		{
			var oldest = _voices[0];
			if ( oldest.Handle.IsValid() )
				oldest.Handle.Stop();
			_voices.RemoveAt( 0 );
		}
	}

	static void AddLoop( SoundPad pad, float pitch, bool resetAccumulator )
	{
		for ( var i = 0; i < _loops.Count; i++ )
		{
			var existing = _loops[i];
			if ( existing.SoundId == pad.SoundId && MathF.Abs( existing.Pitch - pitch ) < 0.0001f )
				return;
		}

		var interval = IntervalFor( pad.SoundId, pitch );
		_loops.Add( new LoopLayer
		{
			Id = _nextId++,
			Pad = pad,
			SoundId = pad.SoundId,
			Pitch = pitch,
			Interval = interval,
			Accumulator = resetAccumulator ? 0f : interval * 0.5f
		} );
	}

	static void ApplyDurationToLoops( string soundId, float pitch, float natural )
	{
		var wall = MathF.Max( natural / MathF.Max( pitch, 0.1f ), MinInterval );
		for ( var i = 0; i < _loops.Count; i++ )
		{
			var loop = _loops[i];
			if ( loop.SoundId != soundId || MathF.Abs( loop.Pitch - pitch ) > 0.0001f )
				continue;
			loop.Interval = wall;
		}
	}

	static void RebuildLiveIds()
	{
		_liveIds.Clear();
		for ( var i = 0; i < _voices.Count; i++ )
		{
			var v = _voices[i];
			if ( IsVoiceLive( v ) )
				_liveIds.Add( v.SoundId );
		}

		if ( LoopEnabled )
		{
			for ( var i = 0; i < _loops.Count; i++ )
				_liveIds.Add( _loops[i].SoundId );
		}

		VoiceRevision++;
	}

	/// <summary>
	/// Combo achievements that need concurrent live pads (e.g. dolphin + ocean).
	/// </summary>
	static void NotifyLivePlaybackAchievements()
	{
		// Labels help when ids are opaque but display text says DOLPHIN / OCEAN.
		var labels = new List<string>( _liveIds.Count + 4 );
		for ( var i = 0; i < _voices.Count; i++ )
		{
			var v = _voices[i];
			if ( !IsVoiceLive( v ) )
				continue;
			if ( !string.IsNullOrWhiteSpace( v.Pad?.Label ) )
				labels.Add( v.Pad.Label );
		}

		if ( LoopEnabled )
		{
			for ( var i = 0; i < _loops.Count; i++ )
			{
				var label = _loops[i].Pad?.Label;
				if ( !string.IsNullOrWhiteSpace( label ) )
					labels.Add( label );
			}
		}

		SoundBoardAchievements.OnLivePlaybackChanged( _liveIds, labels );
	}

	static int CountActive()
	{
		// Prefer tracked voice pool size — IsPlaying can lag while LOOP is stacking.
		var tracked = _voices.Count;
		var live = CountLiveVoices();
		var n = Math.Max( tracked, live );
		if ( LoopEnabled && _loops.Count > n )
			n = _loops.Count;
		return n;
	}

	static int CountLiveVoices()
	{
		var n = 0;
		for ( var i = 0; i < _voices.Count; i++ )
		{
			if ( IsVoiceLive( _voices[i] ) )
				n++;
		}

		return n;
	}

	static bool IsVoiceLive( Voice v )
	{
		var h = v.Handle;
		if ( !h.IsValid() )
			return false;

		try
		{
			if ( h.Finished || h.IsStopped )
				return false;
		}
		catch { /* ignore */ }

		if ( v.Age < 0.08f )
			return true;

		try
		{
			return h.IsPlaying;
		}
		catch
		{
			return v.Age < 2f;
		}
	}

	static void SamplePeak( Voice v )
	{
		var h = v.Handle;
		if ( !h.IsValid() )
			return;

		try
		{
			if ( !h.IsPlaying )
				return;
			var t = h.Time;
			if ( t > v.PeakTime )
				v.PeakTime = t;
		}
		catch { /* ignore */ }
	}

	static bool Fire( SoundPad pad, float pitch, out SoundHandle handle )
	{
		handle = default;

		// Try .sound event path first, then bare id. Never let a bad asset throw to UI.
		if ( !TryPlay( ToSoundEventPath( pad.SoundId ), out handle ) )
			TryPlay( NormalizeId( pad.SoundId ), out handle );

		if ( !handle.IsValid() )
			return false;

		// Keep property writes minimal — SpacialBlend / Occlusion / etc. can force
		// per-handle mixer creation (AudioSampler) which glitches the swapchain RT.
		try
		{
			handle.Volume = 0.9f * Math.Clamp( pad.Volume, 0.05f, 4f );
			handle.Pitch = pitch;
		}
		catch ( Exception e )
		{
			Log.Warning( $"[S&Soundboard] Sound handle setup · {pad.SoundId}: {e.Message}" );
		}

		return true;
	}

	static bool TryPlay( string path, out SoundHandle handle )
	{
		handle = default;
		if ( string.IsNullOrWhiteSpace( path ) )
			return false;

		// Prefer the shared master mixer so we never allocate a per-voice mixer.
		// (Per-voice mixers assert in AudioSampler and corrupt the frame buffer.)
		try
		{
			var mixer = Mixer.Master;
			if ( mixer is not null )
			{
				handle = Sound.Play( path, mixer );
				if ( handle.IsValid() )
					return true;
			}
		}
		catch ( Exception e )
		{
			Log.Warning( $"[S&Soundboard] Sound.Play(mixer) · {path}: {e.Message}" );
			handle = default;
		}

		// Fade-in overload (no world position, no new mixer path when possible).
		try
		{
			handle = Sound.Play( path, 0f );
			if ( handle.IsValid() )
				return true;
		}
		catch ( Exception e )
		{
			Log.Warning( $"[S&Soundboard] Sound.Play(fade) · {path}: {e.Message}" );
			handle = default;
		}

		return false;
	}

	static float IntervalFor( string soundId, float pitch )
	{
		var key = NormalizeId( soundId );
		if ( _durationSec.TryGetValue( key, out var natural ) && natural > 0.05f )
			return MathF.Max( natural / MathF.Max( pitch, 0.1f ), MinInterval );
		return DefaultInterval;
	}

	static void CacheDuration( string soundId, float natural )
	{
		if ( natural <= 0.05f )
			return;
		var key = NormalizeId( soundId );
		if ( !_durationSec.TryGetValue( key, out var existing ) || natural > existing )
			_durationSec[key] = natural;
	}

	static void PushState()
	{
		var count = CountActive();
		_lastCount = count;
		UpdateStatus( count );
		SoundBoardAchievements.OnVoiceCountChanged( count );
		StateChanged?.Invoke();
	}

	static void UpdateStatus( int count )
	{
		if ( count <= 0 )
		{
			SetStatus( LoopEnabled ? "LOOP ON · PRESS A PAD" : "READY · PRESS ANY BUTTON" );
			return;
		}

		if ( count >= 6 )
		{
			SetStatus( $"CACOPHONY · {count} LAYERS DEEP" );
			return;
		}

		if ( count == 1 )
		{
			SetStatus( $"▶ {LastPlayedLabel}" );
			return;
		}

		SetStatus( $"▶ {count} FX · {LastPlayedLabel}" );
	}

	static void SetStatus( string text ) => StatusText = text;

	static string NormalizeId( string soundId )
	{
		if ( string.IsNullOrWhiteSpace( soundId ) )
			return soundId;
		soundId = soundId.Replace( '\\', '/' ).Trim();
		return soundId.EndsWith( ".sound", StringComparison.OrdinalIgnoreCase )
			? soundId[..^6]
			: soundId;
	}

	static string ToSoundEventPath( string soundId ) => $"{NormalizeId( soundId )}.sound";

	static float RandomPitch() => 0.82f + (float)(Game.Random.NextDouble() * 0.36);

	sealed class Voice
	{
		public SoundHandle Handle;
		public string SoundId;
		public SoundPad Pad;
		public float Pitch;
		public float Age;
		public float PeakTime;
		public bool IsRemote;
	}

	sealed class LoopLayer
	{
		public int Id;
		public SoundPad Pad;
		public string SoundId;
		public float Pitch;
		public float Interval;
		public float Accumulator;
	}
}