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.
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;
}
}