Audio/Sounds.cs

Static helper that synthesizes HorizonRP's sound effects at first use. Builds PCM samples for notifications, phone tones, engine, horn, tire and crash sounds, caches them as SoundFile, and plays them locally or at a world position.

namespace HorizonRP;

/// <summary>
/// Les sons d'Horizon RP, fabriqués par calcul au premier usage : notes et
/// enveloppes pour les notifications, bruit filtré pour les déclics, harmoniques
/// pour le moteur. Aucun fichier à livrer ni à compiler : SoundFile.FromPcm du
/// moteur prend directement les échantillons (16 bits, mono, 44,1 kHz).
///
/// Rien ne se joue sur le serveur dédié (pas de carte son : le moteur refuse
/// alors de créer le son, et ces méthodes ne font rien).
/// </summary>
public static class Sounds
{
	const int Rate = 44100;

	static readonly Dictionary<string, SoundFile> cache = new();

	/// <summary>Un son « d'interface » : joué dans les oreilles, sans position.</summary>
	public static SoundHandle Play( string name, float volume = 0.6f )
	{
		var file = Get( name );
		if ( file is null )
			return null;

		var handle = Sound.PlayFile( file, volume );
		if ( handle is not null )
		{
			handle.ListenLocal = true;
			handle.Position = Vector3.Forward * 10f;
		}
		return handle;
	}

	/// <summary>Un fichier son du projet joué dans les oreilles, sans position (bruits de l'habitacle).</summary>
	public static SoundHandle PlayFile( string path, float volume = 0.6f )
	{
		var file = SoundFile.Load( path );
		if ( file is null )
			return null;
		var handle = Sound.PlayFile( file, volume );
		if ( handle is not null )
		{
			handle.ListenLocal = true;
			handle.Position = Vector3.Forward * 10f;
		}
		return handle;
	}

	/// <summary>Un son du monde, à une position (véhicule, porte).</summary>
	public static SoundHandle PlayAt( string name, Vector3 position, float volume = 0.8f )
	{
		var file = Get( name );
		if ( file is null )
			return null;

		var handle = Sound.PlayFile( file, volume );
		if ( handle is not null )
			handle.Position = position;
		return handle;
	}

	/// <summary>Un fichier son du projet (mp3, wav) joué à une position ; null s'il manque.</summary>
	public static SoundHandle PlayFileAt( string path, Vector3 position, float volume = 0.8f, float pitch = 1f )
	{
		var file = SoundFile.Load( path );
		if ( file is null )
			return null;
		var handle = Sound.PlayFile( file, volume );
		if ( handle is not null )
		{
			handle.Position = position;
			handle.Pitch = pitch;
		}
		return handle;
	}

	/// <summary>Le son d'une notification du HUD, selon sa sorte.</summary>
	public static void ForNotification( string kind )
	{
		// Le téléphone (SMS, appel manqué) : le son de notification fourni par Julien.
		if ( kind is "sms" or "telephone" )
		{
			PlayFile( "sounds/telephone/notification.mp3", 0.5f );
			return;
		}
		Play( kind switch
		{
			"argent" => "argent",
			"erreur" => "erreur",
			"annonce" => "annonce",
			"sms" => "sms",
			_ => "info",
		}, 0.45f );
	}

	static SoundFile Get( string name )
	{
		if ( Application.IsHeadless )
			return null;

		if ( cache.TryGetValue( name, out var file ) )
			return file;

		var (samples, loop) = Build( name );
		if ( samples is null )
			return null;

		var bytes = new byte[samples.Length * 2];
		for ( var i = 0; i < samples.Length; i++ )
		{
			var v = (short)(Math.Clamp( samples[i], -1f, 1f ) * 30000);
			bytes[i * 2] = (byte)(v & 0xFF);
			bytes[i * 2 + 1] = (byte)((v >> 8) & 0xFF);
		}

		file = SoundFile.FromPcm( $"horizon/{name}.vsnd", bytes, new SoundFile.PcmOptions { Channels = 1, Rate = Rate, Bits = 16, Loop = loop } );
		cache[name] = file;
		return file;
	}

	// Fabrication

	static (float[] samples, bool loop) Build( string name ) => name switch
	{
		"info" => (Notes( (880, 0.09f), (1318.5f, 0.16f) ), false),
		"argent" => (Bell( new[] { 1975.5f, 2637f }, 0.45f ), false),
		"erreur" => (Buzz( 0.12f, 0.06f, 2 ), false),
		"annonce" => (Notes( (523.3f, 0.11f), (659.3f, 0.11f), (784f, 0.24f) ), false),
		// Fin de la création du personnage : un arpège montant, pendant que la musique du menu s'éteint.
		"bienvenue" => (Notes( (523.3f, 0.12f), (659.3f, 0.12f), (784f, 0.12f), (1046.5f, 0.42f) ), false),
		"sms" => (Chirp( 900, 1700, 0.09f, 0.05f, 2 ), false),
		"sonnerie" => (Ringtone(), true),
		"raccroche" => (Notes( (620, 0.12f), (0, 0.06f), (465, 0.18f) ), false),
		"verrou" => (Beeps( 2200, 0.07f, 0.06f, 2 ), false),
		"deverrou" => (Beeps( 2200, 0.09f, 0f, 1 ), false),
		"porte" => (Click(), false),
		"pneu" => (Hiss(), false),
		"acouphene" => (Tinnitus(), false),
		"moteur" => (Engine(), true),
		"klaxon" => (Horn(), true),
		"crissement" => (Squeal(), true),
		"choc" => (Crash( 0.9f ), false),
		"demarreur" => (Starter(), false),
		"choc_leger" => (Crash( 0.35f ), false),
		// Le téléphone : la touche de l'écran, le verrou, l'envoi d'un message, la tonalité d'appel.
		"tap" => (Tap(), false),
		"obturateur" => (Shutter(), false),
		"verrouille_tel" => (LockClick( false ), false),
		"deverrouille_tel" => (LockClick( true ), false),
		"envoye" => (Swoosh(), false),
		"tonalite" => (Ringback(), true),
		var d when d.StartsWith( "dtmf_" ) && d.Length == 6 => (Dtmf( d[5] ), false),
		_ => (null, false),
	};

	/// <summary>Le « tic » d'une touche d'écran tactile : un bruit très bref, aigu, et un rien de corps.</summary>
	static float[] Tap()
	{
		var out_ = Buffer( 0.035f );
		var random = new Random( 3 );
		var low = 0f;
		for ( var i = 0; i < out_.Length; i++ )
		{
			var t = i / (float)Rate;
			var noise = (float)(random.NextDouble() * 2 - 1);
			low += (noise - low) * 0.5f;
			out_[i] = ((noise - low) * 0.5f + MathF.Sin( 2 * MathF.PI * 1800 * t ) * 0.25f) * MathF.Exp( -t * 260 );
		}
		return out_;
	}

	/// <summary>L'obturateur d'un téléphone : deux déclics secs rapprochés, un souffle de bruit entre eux.</summary>
	static float[] Shutter()
	{
		var out_ = Buffer( 0.22f );
		var random = new Random( 21 );
		var low = 0f;
		for ( var i = 0; i < out_.Length; i++ )
		{
			var t = i / (float)Rate;
			var noise = (float)(random.NextDouble() * 2 - 1);
			low += (noise - low) * 0.45f;
			var first = low * MathF.Exp( -t * 120 );
			var second = t > 0.09f ? (noise - low) * MathF.Exp( -(t - 0.09f) * 90 ) * 0.9f : 0;
			var body = MathF.Sin( 2 * MathF.PI * 320 * t ) * MathF.Exp( -t * 60 ) * 0.25f;
			out_[i] = (first + second) * 0.85f + body;
		}
		return out_;
	}

	/// <summary>Le verrou de l'écran : un double déclic sec (fermeture) ou un déclic doux qui monte (ouverture).</summary>
	static float[] LockClick( bool unlock )
	{
		var out_ = Buffer( 0.16f );
		var random = new Random( unlock ? 5 : 9 );
		var low = 0f;
		for ( var i = 0; i < out_.Length; i++ )
		{
			var t = i / (float)Rate;
			var noise = (float)(random.NextDouble() * 2 - 1);
			low += (noise - low) * 0.3f;
			var first = low * MathF.Exp( -t * 150 );
			var second = t > 0.05f ? low * MathF.Exp( -(t - 0.05f) * 180 ) * 0.7f : 0;
			var tone = MathF.Sin( 2 * MathF.PI * (unlock ? 1200 + 900 * t : 600) * t ) * MathF.Exp( -t * 40 ) * 0.2f;
			out_[i] = (unlock ? first * 0.5f + tone : first + second) * 0.8f;
		}
		return out_;
	}

	/// <summary>Le message qui part : un souffle filtré qui balaie vers l'aigu.</summary>
	static float[] Swoosh()
	{
		var out_ = Buffer( 0.32f );
		var random = new Random( 13 );
		float a = 0, b = 0;
		for ( var i = 0; i < out_.Length; i++ )
		{
			var t = i / (float)Rate;
			var k = t / 0.32f;
			var noise = (float)(random.NextDouble() * 2 - 1);
			var cut = 0.04f + 0.5f * k;
			a += (noise - a) * cut;
			b += (a - b) * cut;
			out_[i] = (a - b) * MathF.Sin( MathF.PI * k ) * 1.4f;
		}
		return out_;
	}

	/// <summary>La tonalité de retour d'appel (norme nord-américaine) : 440 + 480 Hz, 2 s sur 6, en boucle.</summary>
	static float[] Ringback()
	{
		var out_ = Buffer( 6f );
		for ( var i = 0; i < 2 * Rate; i++ )
		{
			var t = i / (float)Rate;
			var env = MathF.Min( 1, t * 200 ) * MathF.Min( 1, (2 - t) * 200 );
			out_[i] = env * 0.18f * (MathF.Sin( 2 * MathF.PI * 440 * t ) + MathF.Sin( 2 * MathF.PI * 480 * t ));
		}
		return out_;
	}

	/// <summary>La tonalité d'une touche du clavier (DTMF : une fréquence de ligne, une de colonne).</summary>
	static float[] Dtmf( char key )
	{
		const string keys = "123456789*0#";
		var index = keys.IndexOf( key );
		if ( index < 0 )
			return null;
		float[] rows = { 697, 770, 852, 941 };
		float[] cols = { 1209, 1336, 1477 };
		float low = rows[index / 3], high = cols[index % 3];
		var out_ = Buffer( 0.16f );
		for ( var i = 0; i < out_.Length; i++ )
		{
			var t = i / (float)Rate;
			var env = MathF.Min( 1, t * 300 ) * MathF.Min( 1, (0.16f - t) * 300 );
			out_[i] = env * 0.22f * (MathF.Sin( 2 * MathF.PI * low * t ) + MathF.Sin( 2 * MathF.PI * high * t ));
		}
		return out_;
	}

	static float[] Buffer( float seconds ) => new float[(int)(seconds * Rate)];

	/// <summary>Une suite de notes douces (sinus + octave légère), fréquence 0 = silence.</summary>
	static float[] Notes( params (float hz, float seconds)[] notes )
	{
		var out_ = Buffer( notes.Sum( x => x.seconds ) + 0.15f );
		var start = 0;
		foreach ( var (hz, seconds) in notes )
		{
			var length = (int)(seconds * Rate);
			if ( hz > 0 )
			{
				for ( var i = 0; i < length + Rate / 8 && start + i < out_.Length; i++ )
				{
					var t = i / (float)Rate;
					var env = MathF.Min( 1, t * 200 ) * MathF.Exp( -t * 9 );
					out_[start + i] += env * 0.55f * (MathF.Sin( 2 * MathF.PI * hz * t ) + 0.25f * MathF.Sin( 4 * MathF.PI * hz * t ));
				}
			}
			start += length;
		}
		return out_;
	}

	/// <summary>Une cloche (pièce) : partiels qui s'éteignent à des vitesses différentes.</summary>
	static float[] Bell( float[] partials, float seconds )
	{
		var out_ = Buffer( seconds );
		for ( var i = 0; i < out_.Length; i++ )
		{
			var t = i / (float)Rate;
			var v = 0f;
			for ( var p = 0; p < partials.Length; p++ )
				v += MathF.Sin( 2 * MathF.PI * partials[p] * t ) * MathF.Exp( -t * (7 + p * 5) );
			out_[i] = v * 0.45f * MathF.Min( 1, t * 400 );
		}
		return out_;
	}

	/// <summary>Un bourdonnement grave et doux, en impulsions (erreur).</summary>
	static float[] Buzz( float on, float off, int count )
	{
		var out_ = Buffer( (on + off) * count );
		for ( var n = 0; n < count; n++ )
		{
			var start = (int)(n * (on + off) * Rate);
			for ( var i = 0; i < on * Rate; i++ )
			{
				var t = i / (float)Rate;
				var env = MathF.Min( 1, t * 300 ) * MathF.Min( 1, (on - t) * 300 );
				var v = MathF.Sin( 2 * MathF.PI * 196 * t ) + 0.6f * MathF.Sin( 2 * MathF.PI * 207.6f * t ) + 0.3f * MathF.Sin( 2 * MathF.PI * 392 * t );
				out_[start + i] = env * v * 0.28f;
			}
		}
		return out_;
	}

	/// <summary>Un glissando montant, répété (SMS).</summary>
	static float[] Chirp( float from, float to, float seconds, float gap, int count )
	{
		var out_ = Buffer( (seconds + gap) * count );
		for ( var n = 0; n < count; n++ )
		{
			var start = (int)(n * (seconds + gap) * Rate);
			var phase = 0f;
			for ( var i = 0; i < seconds * Rate; i++ )
			{
				var k = i / (seconds * Rate);
				phase += 2 * MathF.PI * MathX.Lerp( from, to, k ) / Rate;
				out_[start + i] = MathF.Sin( phase ) * MathF.Sin( MathF.PI * k ) * 0.5f;
			}
		}
		return out_;
	}

	/// <summary>La sonnerie : un petit motif de marimba, en boucle (2,4 s).</summary>
	static float[] Ringtone()
	{
		var out_ = Buffer( 2.4f );
		float[] pattern = { 659.3f, 784f, 987.8f, 1318.5f, 987.8f, 784f };
		for ( var n = 0; n < pattern.Length; n++ )
		{
			var start = (int)(n * 0.16f * Rate);
			for ( var i = 0; i < 0.5f * Rate && start + i < out_.Length; i++ )
			{
				var t = i / (float)Rate;
				var env = MathF.Min( 1, t * 500 ) * MathF.Exp( -t * 11 );
				out_[start + i] += env * 0.4f * (MathF.Sin( 2 * MathF.PI * pattern[n] * t ) + 0.4f * MathF.Sin( 2 * MathF.PI * pattern[n] * 4 * t ) * MathF.Exp( -t * 30 ));
			}
		}
		return out_;
	}

	/// <summary>Le bip du verrouillage à distance.</summary>
	static float[] Beeps( float hz, float on, float off, int count )
	{
		var out_ = Buffer( (on + off) * count + 0.05f );
		for ( var n = 0; n < count; n++ )
		{
			var start = (int)(n * (on + off) * Rate);
			for ( var i = 0; i < on * Rate; i++ )
			{
				var t = i / (float)Rate;
				var env = MathF.Min( 1, t * 600 ) * MathF.Min( 1, (on - t) * 600 );
				var square = MathF.Sin( 2 * MathF.PI * hz * t ) + 0.3f * MathF.Sin( 6 * MathF.PI * hz * t );
				out_[start + i] = env * square * 0.35f;
			}
		}
		return out_;
	}

	/// <summary>L'oreille qui siffle après une grenade assourdissante : 3,1 kHz pur, qui s'éteint lentement.</summary>
	static float[] Tinnitus()
	{
		var out_ = Buffer( 4.5f );
		for ( var i = 0; i < out_.Length; i++ )
		{
			var t = i / (float)Rate;
			out_[i] = MathF.Sin( 2 * MathF.PI * 3100 * t ) * MathF.Min( 1, t * 20 ) * MathF.Exp( -t * 0.7f ) * 0.35f;
		}
		return out_;
	}

	/// <summary>Un pneu crevé : le claquement de la balle, puis l'air qui s'échappe en sifflant et s'épuise.</summary>
	static float[] Hiss()
	{
		var out_ = Buffer( 1.6f );
		var random = new Random( 11 );
		var low = 0f;
		for ( var i = 0; i < out_.Length; i++ )
		{
			var t = i / (float)Rate;
			var noise = (float)(random.NextDouble() * 2 - 1);
			low += (noise - low) * 0.25f;
			var high = noise - low;   // le souffle : le haut du bruit
			var pop = low * MathF.Exp( -t * 70 ) * 0.9f;
			var hiss = high * MathF.Min( 1, t * 30 ) * MathF.Exp( -t * 1.9f ) * 0.45f;
			out_[i] = pop + hiss;
		}
		return out_;
	}

	/// <summary>Le déclic d'une serrure : un bruit bref, filtré, et un petit choc sourd.</summary>
	static float[] Click()
	{
		var out_ = Buffer( 0.12f );
		var random = new Random( 7 );
		var low = 0f;
		for ( var i = 0; i < out_.Length; i++ )
		{
			var t = i / (float)Rate;
			var noise = (float)(random.NextDouble() * 2 - 1);
			low += (noise - low) * 0.35f;
			var snap = low * MathF.Exp( -t * 90 );
			var thump = MathF.Sin( 2 * MathF.PI * 140 * t ) * MathF.Exp( -t * 45 );
			var second = t > 0.045f ? low * MathF.Exp( -(t - 0.045f) * 120 ) * 0.6f : 0;
			out_[i] = (snap + second) * 0.7f + thump * 0.4f;
		}
		return out_;
	}

	/// <summary>
	/// Le klaxon, en boucle (tenu tant que la touche l'est) : deux trompes accordées à une
	/// tierce (400 et 500 Hz, comme les avertisseurs de voiture), en onde riche et un peu
	/// saturée. Fréquences entières : la boucle d'une seconde se raccorde sans clic.
	/// </summary>
	static float[] Horn()
	{
		var out_ = Buffer( 1f );
		for ( var i = 0; i < out_.Length; i++ )
		{
			var t = i / (float)Rate;
			var v = 0f;
			foreach ( var hz in new[] { 400f, 500f } )
				for ( var h = 1; h <= 6; h++ )
					v += MathF.Sin( 2 * MathF.PI * hz * h * t ) / h;
			out_[i] = MathF.Tanh( v * 0.9f ) * 0.4f;
		}
		return out_;
	}

	/// <summary>
	/// Le démarreur : le moteur électrique qui lance (un bourdonnement qui tourne en saccades,
	/// trois compressions), puis le moteur qui prend, plus grave et qui s'installe.
	/// </summary>
	static float[] Starter()
	{
		var out_ = Buffer( 1.3f );
		var random = new Random( 23 );
		float rumble = 0;
		for ( var i = 0; i < out_.Length; i++ )
		{
			var t = i / (float)Rate;
			var crank = t < 0.75f ? (0.55f + 0.45f * MathF.Sin( 2 * MathF.PI * 6.5f * t )) * MathF.Sin( 2 * MathF.PI * (120 + 30 * MathF.Sin( 2 * MathF.PI * 6.5f * t )) * t ) * 0.5f : 0;
			rumble += ((float)(random.NextDouble() * 2 - 1) - rumble) * 0.06f;
			var caught = t > 0.7f ? MathF.Min( 1, (t - 0.7f) * 8 ) * MathF.Exp( -(t - 0.7f) * 1.5f ) : 0;
			var engine = (MathF.Sin( 2 * MathF.PI * 48 * t ) + 0.5f * MathF.Sin( 2 * MathF.PI * 96 * t )) * (0.6f + 0.4f * MathF.Sin( 2 * MathF.PI * 24 * t ));
			out_[i] = MathF.Tanh( crank * 1.2f + (engine * 0.45f + rumble * 0.5f) * caught ) * 0.6f;
		}
		return out_;
	}

	/// <summary>
	/// Un choc de carrosserie : un coup sourd (la masse), un fracas de tôle (bruit large qui
	/// s'éteint vite), des résonances métalliques désaccordées, et pour un gros choc des débris
	/// qui retombent. <paramref name="force"/> de 0 à 1 règle la durée et la part de fracas.
	/// </summary>
	static float[] Crash( float force )
	{
		var seconds = 0.35f + force * 0.9f;
		var out_ = Buffer( seconds );
		var random = new Random( 17 );
		float lp = 0, hp = 0;
		float[] rings = { 312f, 467f, 731f, 1123f, 1587f };
		for ( var i = 0; i < out_.Length; i++ )
		{
			var t = i / (float)Rate;
			var noise = (float)(random.NextDouble() * 2 - 1);
			lp += (noise - lp) * 0.3f;
			hp = noise - lp;
			var thump = MathF.Sin( 2 * MathF.PI * (55 + 40 * MathF.Exp( -t * 30 )) * t ) * MathF.Exp( -t * 14 );
			var crunch = (lp * 0.8f + hp * 0.5f) * MathF.Exp( -t * (9 - force * 5) );
			var metal = 0f;
			for ( var k = 0; k < rings.Length; k++ )
				metal += MathF.Sin( 2 * MathF.PI * rings[k] * t + k ) * MathF.Exp( -t * (6 + k * 3) ) / (k + 1);
			// Les débris : de petits claquements épars, seulement pour un choc violent.
			var debris = force > 0.5f && t > 0.15f && random.NextDouble() < 0.0016 * force ? (float)(random.NextDouble() * 2 - 1) * 2.5f * MathF.Exp( -(t - 0.15f) * 3 ) : 0;
			out_[i] = MathF.Tanh( (thump * 0.9f + crunch * (0.6f + force) + metal * 0.35f * force + debris) * 1.4f ) * 0.55f;
		}
		return out_;
	}

	/// <summary>
	/// Le crissement des pneus, en boucle : un bruit filtré autour de 1,4 kHz, modulé, avec
	/// un sifflement aigu qui ondule. Le volume suit la glisse (Vehicle).
	/// </summary>
	static float[] Squeal()
	{
		var out_ = Buffer( 1f );
		var random = new Random( 11 );
		float lp = 0, bp = 0;
		for ( var i = 0; i < out_.Length; i++ )
		{
			var t = i / (float)Rate;
			var noise = (float)(random.NextDouble() * 2 - 1);
			lp += (noise - lp) * 0.25f;
			bp += (lp - bp) * 0.08f;
			var hiss = (lp - bp) * (0.7f + 0.3f * MathF.Sin( 2 * MathF.PI * 7 * t ));
			var whistle = MathF.Sin( 2 * MathF.PI * 1400 * t + 3 * MathF.Sin( 2 * MathF.PI * 5 * t ) ) * 0.25f;
			out_[i] = (hiss * 0.9f + whistle) * 0.45f;
		}
		return out_;
	}

	/// <summary>
	/// Le moteur, en boucle d'une seconde : une fondamentale grave, ses harmoniques
	/// et les impulsions d'un quatre-cylindres. Le régime se règle par la hauteur
	/// (SoundHandle.Pitch) : toutes les fréquences sont des multiples entiers de
	/// 1 Hz pour que la boucle se raccorde sans clic.
	/// </summary>
	static float[] Engine()
	{
		var out_ = Buffer( 1f );
		var random = new Random( 3 );
		var rumble = 0f;
		for ( var i = 0; i < out_.Length; i++ )
		{
			var t = i / (float)Rate;
			var firing = 0.6f + 0.4f * MathF.Sin( 2 * MathF.PI * 110 * t );
			var tone = MathF.Sin( 2 * MathF.PI * 55 * t ) + 0.5f * MathF.Sin( 2 * MathF.PI * 110 * t ) + 0.25f * MathF.Sin( 2 * MathF.PI * 165 * t ) + 0.12f * MathF.Sin( 2 * MathF.PI * 330 * t );
			rumble += ((float)(random.NextDouble() * 2 - 1) - rumble) * 0.05f;
			out_[i] = (tone * firing * 0.32f) + rumble * 0.25f;
		}
		return out_;
	}
}