A component that defines a box-shaped audio zone which mutes/muffles selected audio mixers when the scene camera is inside. It tracks all enabled zones, computes per-mixer target low-pass alpha and volume (choosing the strongest zone per mixer), applies fades, and supports an external per-frame muffle request that can muffle all mixers including the default Game mixer.
using Sandbox;
using Sandbox.Audio;
using System;
using System.Collections.Generic;
/// <summary>
/// An underground audio zone: a box (the Box Collider on the same object, Is Trigger on, used only
/// for its size) that muffles the world while the local player's camera is inside it. The music,
/// ambience, golf sound and shelling mixers (<see cref="GolfMixers"/>) each get the engine's
/// low-pass processor and a volume drop, faded in over Fade Time on entering and out again on
/// leaving. The guns play on the default mixer and are untouched. Where zones overlap, the
/// strongest wins per mixer: the lowest cutoff and the biggest drop among the zones the camera is
/// in. The camera is tested against the box directly every frame, so it works for every camera,
/// the player view, the fly-over, the free camera and the menu orbit; nothing is networked.
///
/// The engine's low-pass is a one-pole filter whose Cutoff is the per-sample smoothing factor,
/// not a frequency, so Cutoff Hz is converted: alpha = 1 - exp( -2 pi f / sample rate ). A factor
/// of 1 passes the sound through unchanged, which is what the fade starts from and returns to.
/// Drawn in the editor as a translucent blue box.
/// </summary>
[Title( "Golf Audio Zone" ), Category( "Golf" ), Icon( "hearing" )]
public sealed class GolfAudioZone : Component
{
/// <summary>Low-pass cutoff inside the zone, Hz. Lower is more muffled.</summary>
[Property, Group( "Filter" ), Range( 50f, 8000f ), Title( "Cutoff Hz" )]
public float CutoffHz { get; set; } = 600f;
/// <summary>Seconds the filter and the drop take to fade in or out.</summary>
[Property, Group( "Filter" ), Range( 0f, 5f ), Title( "Fade Time" )]
public float FadeTime { get; set; } = 0.6f;
/// <summary>How much of the volume is taken away inside, 0 to 1; 0.2 leaves 80 percent.</summary>
[Property, Group( "Filter" ), Range( 0f, 1f ), Title( "Volume Drop" )]
public float VolumeDrop { get; set; } = 0.2f;
[Property, Group( "Mixers" ), Title( "Music" )]
public bool AffectMusic { get; set; } = true;
[Property, Group( "Mixers" ), Title( "Ambience" )]
public bool AffectAmbience { get; set; } = true;
[Property, Group( "Mixers" ), Title( "Golf Sounds" )]
public bool AffectSounds { get; set; } = true;
[Property, Group( "Mixers" ), Title( "Shelling" )]
public bool AffectShelling { get; set; } = true;
// Every enabled zone; the first one drives the mixers for all of them.
static readonly List<GolfAudioZone> Zones = new();
// Per mixer: the processor, the current smoothing factor (1 = untouched) and volume (1 = full).
sealed class Channel
{
public string Name;
public LowPassProcessor Filter;
public Mixer Mixer;
public float Alpha = 1f;
public float Volume = 1f;
// Zones muffle this mixer; the default "Game" mixer (guns, footsteps) is only muffled by an outside request.
public bool ZoneControlled = true;
}
static readonly Channel[] Channels =
{
new() { Name = GolfMixers.MusicName },
new() { Name = GolfMixers.AmbienceName },
new() { Name = GolfMixers.SoundsName },
new() { Name = GolfMixers.ShellingName },
new() { Name = "Game", ZoneControlled = false },
};
// An outside request to muffle everything (the distraction hit): cutoff factor, volume and how
// strong it is right now, 0 to 1. Made fresh every frame by the requester; it lapses by itself.
static float _muffleAlpha = 1f, _muffleVolume = 1f, _muffleStrength;
static float _muffleFrame = -1f;
/// <summary>
/// Muffle every mixer, the guns included, this frame: a low-pass at this cutoff and a volume
/// drop, scaled by strength (0 none, 1 full). Call it every frame it should apply; the caller
/// shapes the fade in and out through the strength. Overlapping audio zones still take the strongest.
/// </summary>
public static void SetMuffle( float cutoffHz, float volumeDrop, float strength )
{
_muffleAlpha = AlphaFor( cutoffHz );
_muffleVolume = 1f - Math.Clamp( volumeDrop, 0f, 1f );
_muffleStrength = Math.Clamp( strength, 0f, 1f );
_muffleFrame = Time.Now;
}
/// <summary>
/// Runs the mixer update when no zone is there to do it, so an outside muffle request works in
/// scenes without audio zones. A zone that exists drives the mixers itself.
/// </summary>
public static void TickWithoutZones()
{
if ( Zones.Count > 0 ) return;
Tick();
}
protected override void OnEnabled()
{
if ( !Zones.Contains( this ) ) Zones.Add( this );
}
protected override void OnDisabled()
{
Zones.Remove( this );
if ( Zones.Count == 0 ) ResetMixers();
}
/// <summary>True when a world point is inside this zone's box.</summary>
public bool Contains( Vector3 worldPosition )
{
var local = WorldTransform.PointToLocal( worldPosition );
return Box().Contains( local );
}
bool Affects( int channel ) => channel switch { 0 => AffectMusic, 1 => AffectAmbience, 2 => AffectSounds, _ => AffectShelling };
BBox Box()
{
var box = GetComponent<BoxCollider>( true );
return box is not null
? BBox.FromPositionAndSize( box.Center, box.Scale )
: BBox.FromPositionAndSize( new Vector3( 0f, 0f, 200f ), new Vector3( 400f, 400f, 400f ) );
}
protected override void OnUpdate()
{
if ( Scene.IsEditor ) return;
// One zone does the work for all of them, so overlapping zones blend into one answer.
if ( Zones.Count == 0 || Zones[0] != this ) return;
Tick();
}
/// <summary>
/// One update of every mixer: the strongest zone the camera is in per mixer, fading at that
/// zone's Fade Time, and the outside muffle request on top, which follows its own strength at once.
/// </summary>
static void Tick()
{
var scene = Game.ActiveScene;
var camera = scene?.Camera;
var position = camera.IsValid() ? camera.WorldPosition : Vector3.Zero;
var muffling = _muffleFrame >= Time.Now - 0.1f && _muffleStrength > 0f;
for ( var i = 0; i < Channels.Length; i++ )
{
var channel = Channels[i];
// The strongest zone the camera is in, per mixer.
var targetAlpha = 1f;
var targetVolume = 1f;
var fade = 0.6f;
if ( channel.ZoneControlled )
{
foreach ( var zone in Zones )
{
if ( !zone.IsValid() || !zone.Affects( i ) ) continue;
if ( !camera.IsValid() || !zone.Contains( position ) ) continue;
var alpha = AlphaFor( zone.CutoffHz );
if ( alpha < targetAlpha ) { targetAlpha = alpha; fade = zone.FadeTime; }
targetVolume = Math.Min( targetVolume, 1f - Math.Clamp( zone.VolumeDrop, 0f, 1f ) );
}
}
// The outside request wins where it is stronger, and is followed at once: its caller shapes the fade.
var instant = false;
if ( muffling )
{
var alpha = MathX.Lerp( 1f, _muffleAlpha, _muffleStrength );
var volume = MathX.Lerp( 1f, _muffleVolume, _muffleStrength );
if ( alpha < targetAlpha ) { targetAlpha = alpha; instant = true; }
if ( volume < targetVolume ) { targetVolume = volume; instant = true; }
}
else if ( !channel.ZoneControlled || Zones.Count == 0 )
{
instant = true; // nothing asks for anything: straight back to clean
}
var step = instant || fade <= 0f ? 1f : Math.Clamp( Time.Delta / fade, 0f, 1f );
// Linear in the smoothing factor sounds right: it sweeps the muffling smoothly.
channel.Alpha = MathX.Lerp( channel.Alpha, targetAlpha, step );
channel.Volume = MathX.Lerp( channel.Volume, targetVolume, step );
if ( Math.Abs( channel.Alpha - targetAlpha ) < 0.002f ) channel.Alpha = targetAlpha;
if ( Math.Abs( channel.Volume - targetVolume ) < 0.002f ) channel.Volume = targetVolume;
Apply( channel );
}
}
/// <summary>The mixer's sample rate; the engine's own figure is not exposed to game code.</summary>
const float SampleRate = 44100f;
/// <summary>The one-pole smoothing factor that gives this cutoff at the mix sample rate.</summary>
static float AlphaFor( float hz )
{
return Math.Clamp( 1f - MathF.Exp( -2f * MathF.PI * Math.Max( 10f, hz ) / SampleRate ), 0.001f, 1f );
}
static void Apply( Channel channel )
{
if ( channel.Mixer is null )
{
channel.Mixer = GolfMixers.Get( channel.Name );
if ( channel.Mixer is null ) return;
}
var untouched = channel.Alpha >= 0.999f;
if ( channel.Filter is null )
{
if ( untouched ) { channel.Mixer.Volume = channel.Volume; return; }
channel.Filter = channel.Mixer.GetProcessor<LowPassProcessor>();
if ( channel.Filter is null )
{
channel.Filter = new LowPassProcessor { Cutoff = 1f };
channel.Mixer.AddProcessor( channel.Filter );
}
}
channel.Filter.Cutoff = channel.Alpha;
channel.Filter.Enabled = !untouched;
channel.Mixer.Volume = channel.Volume;
}
/// <summary>No zones left (scene change, all disabled): every mixer back to clean and full.</summary>
static void ResetMixers()
{
foreach ( var channel in Channels )
{
channel.Alpha = 1f;
channel.Volume = 1f;
if ( channel.Mixer is null ) continue;
if ( channel.Filter is not null )
{
channel.Filter.Cutoff = 1f;
channel.Filter.Enabled = false;
}
channel.Mixer.Volume = 1f;
}
}
protected override void DrawGizmos()
{
var bounds = Box();
var alpha = Gizmo.IsSelected ? 0.35f : (Gizmo.IsHovered ? 0.25f : 0.12f);
var blue = new Color( 0.25f, 0.55f, 1f );
Gizmo.Draw.Color = blue.WithAlpha( alpha );
Gizmo.Draw.SolidBox( bounds );
Gizmo.Draw.LineThickness = Gizmo.IsSelected ? 2f : 1f;
Gizmo.Draw.Color = blue.WithAlpha( Gizmo.IsSelected ? 0.9f : 0.5f );
Gizmo.Draw.LineBBox( bounds );
}
}