Code/GolfAudioZone.cs

An editor/game Component that defines a box audio zone which applies a low-pass filter and volume drop to selectable audio mixers when the scene camera is inside. Tracks all enabled zones, computes the strongest effect per mixer each frame, applies smoothing/fades and supports a global outside muffle request.

Native Interop
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 );
	}
}