ScienceAudio.cs

A component that procedurally generates short PCM audio clips at runtime and streams them via SoundStream. It synthesizes various one-shot effects (click, sweep, whoosh, explosion, etc.), caches generated clips, pools active voices, and rate-limits retriggering per key.

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

namespace Science;

/// <summary>
/// Fully procedural sound engine. The project ships with no audio assets, so
/// every effect here is synthesised into 16-bit PCM at runtime and streamed
/// through <see cref="SoundStream"/>. Clips are generated once and cached; a
/// small voice pool feeds each active clip across frames so short one-shots
/// never underrun. Calls are rate-limited per channel so a chain reaction of
/// explosions can't turn into a wall of noise.
/// </summary>
public sealed class ScienceAudio : Component
{
	const int SampleRate = 44100;
	const int MaxVoices = 10;

	public bool Muted { get; set; }
	public float MasterVolume { get; set; } = 0.55f;

	readonly Dictionary<string, short[]> _clips = new();
	readonly List<Voice> _voices = new();
	readonly Random _rng = new();
	readonly Dictionary<string, float> _lastPlayed = new();

	sealed class Voice
	{
		public SoundStream Stream;
		public short[] Buffer;
		public int Cursor;
	}

	protected override void OnUpdate()
	{
		for ( int i = _voices.Count - 1; i >= 0; i-- )
		{
			var v = _voices[i];
			if ( v.Stream == null )
			{
				_voices.RemoveAt( i );
				continue;
			}

			if ( v.Cursor < v.Buffer.Length )
			{
				int canWrite = v.Stream.MaxWriteSampleCount;
				if ( canWrite > 0 )
				{
					int n = Math.Min( canWrite, v.Buffer.Length - v.Cursor );
					v.Stream.WriteData( v.Buffer.AsSpan( v.Cursor, n ) );
					v.Cursor += n;
				}
			}
			else if ( v.Stream.QueuedSampleCount <= 0 )
			{
				try { v.Stream.Close(); } catch { }
				_voices.RemoveAt( i );
			}
		}
	}

	protected override void OnDisabled()
	{
		foreach ( var v in _voices )
			try { v.Stream?.Close(); } catch { }
		_voices.Clear();
	}

	// =====================================================================
	//  Public one-shot triggers (called from the game / HUD)
	// =====================================================================
	public void Click() => Play( "click", 1f, 0.07f );
	public void Select() => Play( "select", 1f, 0.06f );
	public void Paint() => Play( "paint", 1f, 0.045f, pitchJitter: 0.10f );
	public void Erase() => Play( "erase", 1f, 0.05f, pitchJitter: 0.06f );
	public void Discovery() => Play( "discovery", 1f, 0.0f );
	public void Milestone() => Play( "milestone", 1f, 0.0f );
	public void Complete() => Play( "complete", 1f, 0.0f );
	public void Ignite() => Play( "ignite", 1f, 0.09f, pitchJitter: 0.12f );
	public void Quench() => Play( "quench", 1f, 0.10f, pitchJitter: 0.10f );

	public void Explosion( int radius )
	{
		// Bigger blasts are louder, lower-pitched and allowed to retrigger faster.
		float vol = Math.Clamp( 0.55f + radius * 0.03f, 0.55f, 1f );
		float pitch = Math.Clamp( 1.15f - radius * 0.02f, 0.7f, 1.15f );
		Play( "explosion", pitch, 0.07f, volume: vol );
	}

	// =====================================================================
	//  Playback
	// =====================================================================
	void Play( string key, float pitch, float minInterval, float volume = 1f, float pitchJitter = 0f )
	{
		if ( Muted || MasterVolume <= 0f )
			return;

		float now = Time.Now;
		if ( minInterval > 0f && _lastPlayed.TryGetValue( key, out var last ) && now - last < minInterval )
			return;
		_lastPlayed[key] = now;

		var clip = GetClip( key );
		if ( clip == null || clip.Length == 0 )
			return;

		// Cap polyphony: drop the oldest voice if we're saturated.
		if ( _voices.Count >= MaxVoices )
		{
			try { _voices[0].Stream?.Close(); } catch { }
			_voices.RemoveAt( 0 );
		}

		if ( pitchJitter > 0f )
			pitch *= 1f + ((float)_rng.NextDouble() - 0.5f) * 2f * pitchJitter;

		SoundStream stream;
		try
		{
			stream = new SoundStream( SampleRate, 1 );
		}
		catch ( Exception e )
		{
			Log.Warning( $"Science audio stream failed: {e.Message}" );
			return;
		}

		var voice = new Voice { Stream = stream, Buffer = clip, Cursor = 0 };

		// Prime the first chunk before playback so the clip starts cleanly.
		int prime = Math.Min( stream.MaxWriteSampleCount, clip.Length );
		if ( prime > 0 )
		{
			stream.WriteData( clip.AsSpan( 0, prime ) );
			voice.Cursor = prime;
		}

		stream.Play( Math.Clamp( volume * MasterVolume, 0f, 1f ), Math.Clamp( pitch, 0.5f, 2f ) );
		_voices.Add( voice );
	}

	short[] GetClip( string key )
	{
		if ( _clips.TryGetValue( key, out var cached ) )
			return cached;

		var clip = key switch
		{
			"click" => Blip( 660f, 0.045f, 0.22f, Wave.Square, 0.18f ),
			"select" => Sweep( 420f, 720f, 0.07f, 0.20f, Wave.Triangle ),
			"paint" => SoftNoise( 0.035f, 0.10f, 0.28f ),
			"erase" => SoftNoise( 0.05f, 0.09f, 0.55f ),
			"ignite" => Whoosh( 0.18f, 0.20f, rising: true ),
			"quench" => Whoosh( 0.22f, 0.22f, rising: false ),
			"discovery" => Sequence( new[] { 523.25f, 784f }, 0.085f, 0.24f, Wave.Triangle ),
			"milestone" => Sequence( new[] { 523.25f, 659.25f, 880f }, 0.10f, 0.26f, Wave.Triangle ),
			"complete" => Sequence( new[] { 392f, 523.25f, 659.25f, 1046.5f }, 0.13f, 0.28f, Wave.Triangle ),
			"explosion" => Explosion(),
			_ => Array.Empty<short>()
		};

		_clips[key] = clip;
		return clip;
	}

	// =====================================================================
	//  Synthesis
	// =====================================================================
	enum Wave { Sine, Square, Triangle, Saw }

	static float Osc( Wave wave, float phase )
	{
		// phase in [0,1)
		return wave switch
		{
			Wave.Sine => MathF.Sin( phase * MathF.PI * 2f ),
			Wave.Square => phase < 0.5f ? 1f : -1f,
			Wave.Triangle => 4f * MathF.Abs( phase - 0.5f ) - 1f,
			Wave.Saw => 2f * phase - 1f,
			_ => 0f
		};
	}

	short[] Blip( float freq, float dur, float amp, Wave wave, float attack )
	{
		int count = (int)(dur * SampleRate);
		var buf = new short[count];
		float phase = 0f;
		float step = freq / SampleRate;
		for ( int i = 0; i < count; i++ )
		{
			float t = i / (float)count;
			float env = Envelope( t, attack );
			float s = Osc( wave, phase ) * env * amp;
			buf[i] = ToPcm( s );
			phase += step;
			if ( phase >= 1f ) phase -= 1f;
		}
		return buf;
	}

	short[] Sweep( float fromHz, float toHz, float dur, float amp, Wave wave )
	{
		int count = (int)(dur * SampleRate);
		var buf = new short[count];
		float phase = 0f;
		for ( int i = 0; i < count; i++ )
		{
			float t = i / (float)count;
			float freq = MathX.Lerp( fromHz, toHz, t );
			float env = Envelope( t, 0.12f );
			buf[i] = ToPcm( Osc( wave, phase ) * env * amp );
			phase += freq / SampleRate;
			if ( phase >= 1f ) phase -= 1f;
		}
		return buf;
	}

	short[] Sequence( float[] notes, float noteDur, float amp, Wave wave )
	{
		var parts = new List<short[]>();
		foreach ( var n in notes )
			parts.Add( Blip( n, noteDur, amp, wave, 0.10f ) );

		int total = 0;
		foreach ( var p in parts ) total += p.Length;
		var buf = new short[total];
		int o = 0;
		foreach ( var p in parts )
		{
			Array.Copy( p, 0, buf, o, p.Length );
			o += p.Length;
		}
		return buf;
	}

	short[] SoftNoise( float dur, float amp, float decay )
	{
		int count = (int)(dur * SampleRate);
		var buf = new short[count];
		float last = 0f;
		for ( int i = 0; i < count; i++ )
		{
			float t = i / (float)count;
			float env = MathF.Exp( -t / Math.Max( 0.01f, decay ) ) * (t < 0.04f ? t / 0.04f : 1f);
			float white = (float)_rng.NextDouble() * 2f - 1f;
			// One-pole low-pass for a softer, granular tick.
			last = MathX.Lerp( last, white, 0.35f );
			buf[i] = ToPcm( last * env * amp );
		}
		return buf;
	}

	short[] Whoosh( float dur, float amp, bool rising )
	{
		int count = (int)(dur * SampleRate);
		var buf = new short[count];
		float last = 0f;
		for ( int i = 0; i < count; i++ )
		{
			float t = i / (float)count;
			float env = MathF.Sin( t * MathF.PI ) * amp;          // smooth swell
			float cutoff = rising ? MathX.Lerp( 0.04f, 0.45f, t ) : MathX.Lerp( 0.45f, 0.04f, t );
			float white = (float)_rng.NextDouble() * 2f - 1f;
			last = MathX.Lerp( last, white, cutoff );
			buf[i] = ToPcm( last * env );
		}
		return buf;
	}

	short[] Explosion()
	{
		float dur = 0.55f;
		int count = (int)(dur * SampleRate);
		var buf = new short[count];
		float last = 0f;
		float rumblePhase = 0f;
		for ( int i = 0; i < count; i++ )
		{
			float t = i / (float)count;
			float env = MathF.Exp( -t * 7f );                     // sharp transient, long tail
			float white = (float)_rng.NextDouble() * 2f - 1f;
			last = MathX.Lerp( last, white, 0.5f );                // body noise

			// Sub-bass rumble that drops in pitch as it decays.
			float rf = MathX.Lerp( 90f, 38f, t );
			rumblePhase += rf / SampleRate;
			if ( rumblePhase >= 1f ) rumblePhase -= 1f;
			float rumble = MathF.Sin( rumblePhase * MathF.PI * 2f ) * MathF.Exp( -t * 4f );

			float s = (last * 0.7f + rumble * 0.6f) * env;
			buf[i] = ToPcm( s * 0.9f );
		}
		return buf;
	}

	static float Envelope( float t, float attack )
	{
		float a = t < attack ? t / Math.Max( 0.0001f, attack ) : 1f;
		float decay = MathF.Exp( -(t - attack) * 5f );
		return a * (t < attack ? 1f : decay);
	}

	static short ToPcm( float s )
	{
		s = Math.Clamp( s, -1f, 1f );
		return (short)(s * 32000f);
	}
}