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