Vehicle/EngineAudio.cs

Component attached to a vehicle that manages positional engine audio. It starts and maintains one or two SoundHandle loops (low/idle and optional high/rev), pitches them from RPM, scales volume by throttle plus a master volume ConVar, supports a global override to force a single loop, and performs equal-power crossfades between layered samples.

External Download
namespace VehicleProto;

/// <summary>
/// Engine audio: a positional engine loop that follows the car, pitched by live RPM and swelled by
/// throttle. Created on every car root by <see cref="VehicleFactory.Spawn"/>. Two models share this
/// component:
///
///  - LAYERED (real recorded sets): a class with both <see cref="CarDefinition.EngineSoundEvent"/>
///    (low/idle layer) and <see cref="CarDefinition.EngineSoundEventHigh"/> (high/rev layer) blends
///    the two by normalized RPM. The low layer carries idle and fades out as revs climb; the high
///    layer fades in over the top (an equal-power crossfade, so combined loudness stays steady). The
///    key trait: EACH layer only pitch-shifts a NARROW band around its own recorded pitch, so no
///    layer is ever stretched far from how it was recorded — that far stretch is what makes a single
///    loop screech at redline.
///
///  - LEGACY single loop: a class with no high layer (e.g. the kart's single synth purr) plays one
///    handle across a wider pitch sweep, exactly as before. The <c>vp_engine_sound</c> dev override
///    also forces this single-loop path so any one loop can be auditioned on every car.
///
/// SoundEvent has no loop flag, so each loop is held code-side: keep the handle, follow the car with
/// it, and re-trigger the moment it stops playing. Volume rides throttle from an idle floor to full.
///
/// Two console dials tune it live:
///   <c>vp_engine_sound</c> — dev override: empty = per-class default; a short name or a full event
///     path forces one single loop on every car (single-loop mode).
///   <c>vp_engine_volume</c> — master scale (0 = mute), applied live.
/// </summary>
public sealed class EngineAudio : Component
{
	/// <summary>Dev override for which engine loop plays. Empty (default) = each car uses its own
	/// per-class loop(s). A short name (<c>a b c real_low real_high real_truck_low real_truck_high</c>)
	/// or a full event path forces that single loop on every car for A/B listening, in single-loop
	/// mode. Read every frame, so changing it in the console swaps the running loop live.</summary>
	[ConVar( "vp_engine_sound" )] public static string SoundOverride { get; set; } = "";

	/// <summary>Master engine-volume scale, applied on top of the throttle swell. 1 = full, 0 = mute.
	/// Read every frame so it takes effect live.</summary>
	[ConVar( "vp_engine_volume" )] public static float MasterVolume { get; set; } = 1f;

	// Legacy single-loop pitch band: idle..redline RPM maps onto this, then scaled by class base pitch.
	// Narrow so redline reads as one engine rather than a chipmunk sweep.
	const float LegacyMinPitch = 0.7f;
	const float LegacyMaxPitch = 1.3f;

	// Layered crossfade bands over normalized RPM t (0 = idle, 1 = redline). The low layer fades out
	// across [LowFadeStart..LowFadeEnd]; the high layer fades in across [HighFadeStart..HighFadeEnd].
	// They overlap in the middle, where both play (equal-power blended).
	const float LowFadeStart = 0.0f;
	const float LowFadeEnd = 0.7f;
	const float HighFadeStart = 0.3f;
	const float HighFadeEnd = 1.0f;

	// Per-layer pitch bands — each stays close to 1.0 (its recorded pitch) so no layer is stretched
	// far enough to screech. Mapped across the full RPM sweep, but each layer is only audible over
	// its own fade band, so its extreme pitch ends are barely heard.
	const float LowLayerMinPitch = 0.9f;
	const float LowLayerMaxPitch = 1.25f;
	const float HighLayerMinPitch = 0.85f;
	const float HighLayerMaxPitch = 1.2f;

	// Throttle volume swell: idle floor (so a coasting car is still audible) up to full at pinned throttle.
	const float IdleVolume = 0.55f;
	const float FullVolume = 1.0f;

	VehicleController _controller;

	// Low/idle layer (also the sole handle in single-loop mode); high/rev layer (layered mode only).
	SoundHandle _low;
	SoundHandle _high;
	string _lowEvent;
	string _highEvent; // null/empty => single-loop mode is active
	TimeSince _log;

	protected override void OnStart()
	{
		_controller = Components.Get<VehicleController>();

		ResolveEvents( SoundOverride, _controller?.Definition, out string lo, out string hi );
		float basePitch = _controller?.Definition?.EnginePitchBase ?? 1f;
		// The override persists in editor config across sessions, so a stale dev value silently
		// masks every per-class loop on every car — warn loudly whenever it is in effect.
		if ( !string.IsNullOrEmpty( SoundOverride ) )
			Log.Warning( $"[vp] engine audio: vp_engine_sound override '{SoundOverride}' ACTIVE - per-class loops suppressed on ALL cars (clear with: vp_engine_sound \"\")" );
		if ( string.IsNullOrEmpty( hi ) )
			Log.Info( $"[vp] engine audio: single loop {lo} pitch {LegacyMinPitch * basePitch:F2}-{LegacyMaxPitch * basePitch:F2}" );
		else
			Log.Info( $"[vp] engine audio: layered low={lo} high={hi} basePitch {basePitch:F2}" );
	}

	protected override void OnUpdate()
	{
		if ( _controller is null )
			return;

		// Master mute: stop both loops and release the handles; they restart when volume returns.
		if ( MasterVolume <= 0f )
		{
			StopHandles();
			return;
		}

		// Live loop switch: if the per-class default or the console override selects a different
		// low/high pairing, drop the handles so the block below re-triggers with the new events.
		ResolveEvents( SoundOverride, _controller.Definition, out string lowEv, out string highEv );
		if ( lowEv != _lowEvent || highEv != _highEvent )
			StopHandles();

		float t = NormalizedRpm();
		float basePitch = _controller.Definition?.EnginePitchBase ?? 1f;
		float swell = VolumeForThrottle();
		bool layered = !string.IsNullOrEmpty( highEv );

		// Hold the low/idle loop alive: (re-)trigger whenever nothing is playing (first frame, after a
		// mute, or when the finite sample reaches its end). Positional so it tracks the car.
		if ( _low is null || !_low.IsPlaying )
		{
			_low = Sound.Play( lowEv, WorldPosition );
			_lowEvent = lowEv;
			_highEvent = highEv;
		}

		if ( _low is not null )
		{
			_low.Position = WorldPosition;
			if ( layered )
			{
				_low.Pitch = basePitch * MathX.Lerp( LowLayerMinPitch, LowLayerMaxPitch, t );
				_low.Volume = swell * LowLayerGain( t );
			}
			else
			{
				// Single-loop model: one handle, wider pitch sweep.
				_low.Pitch = basePitch * MathX.Lerp( LegacyMinPitch, LegacyMaxPitch, t );
				_low.Volume = swell;
			}
		}

		if ( layered )
		{
			// High/rev layer: same hold-alive + positional-follow pattern, blended in over the top.
			if ( _high is null || !_high.IsPlaying )
			{
				_high = Sound.Play( highEv, WorldPosition );
				_highEvent = highEv;
			}

			if ( _high is not null )
			{
				_high.Position = WorldPosition;
				_high.Pitch = basePitch * MathX.Lerp( HighLayerMinPitch, HighLayerMaxPitch, t );
				_high.Volume = swell * HighLayerGain( t );
			}
		}
		else if ( _high is not null )
		{
			// Switched from layered to single-loop (e.g. override engaged): drop the stale high layer.
			_high.Stop( 0.05f );
			_high = null;
		}

		// Low-rate diagnostic (1 Hz while under throttle or moving): live RPM, the pitch/volume of each
		// active layer, and whether the loops are playing. Mirrors the controller's telemetry cadence.
		if ( _log > 1f && (_controller.Throttle > 0f || _controller.SpeedMs > 0.5f) )
		{
			_log = 0f;
			float rpm = _controller.Drivetrain?.Rpm ?? 0f;
			if ( layered )
				Log.Info( $"[vp] engine audio: rpm {rpm:F0} t {t:F2} lo(pitch {_low?.Pitch ?? 0:F2} vol {_low?.Volume ?? 0:F2}) hi(pitch {_high?.Pitch ?? 0:F2} vol {_high?.Volume ?? 0:F2}) playing {((_low?.IsPlaying ?? false) ? 1 : 0)}" );
			else
				Log.Info( $"[vp] engine audio: rpm {rpm:F0} pitch {_low?.Pitch ?? 0:F2} vol {_low?.Volume ?? 0:F2} playing {((_low?.IsPlaying ?? false) ? 1 : 0)}" );
		}
	}

	protected override void OnDestroy() => StopHandles();

	void StopHandles()
	{
		_low?.Stop( 0.05f );
		_low = null;
		_high?.Stop( 0.05f );
		_high = null;
	}

	/// <summary>Normalized RPM in 0..1 (idle..redline), read from the car's own definition so a
	/// low-revving truck and a high-revving kart both map their full band onto 0..1.</summary>
	float NormalizedRpm()
	{
		var def = _controller.Definition;
		var drivetrain = _controller.Drivetrain;
		if ( def is null || drivetrain is null )
			return 0f;

		float span = MathF.Max( 1f, def.RedlineRpm - def.IdleRpm );
		return Math.Clamp( (drivetrain.Rpm - def.IdleRpm) / span, 0f, 1f );
	}

	/// <summary>Low-layer weight: full at idle, smoothly fading to 0 by <see cref="LowFadeEnd"/>.
	/// Equal-power normalized against the high layer so their combined acoustic power stays ~constant
	/// through the crossfade (no dip or bump in perceived loudness mid-blend).</summary>
	static float LowLayerGain( float t )
	{
		float lowRaw = 1f - Smooth01( LowFadeStart, LowFadeEnd, t );
		float highRaw = Smooth01( HighFadeStart, HighFadeEnd, t );
		return EqualPower( lowRaw, highRaw, lowRaw );
	}

	/// <summary>High-layer weight: 0 until <see cref="HighFadeStart"/>, smoothly rising to full at
	/// redline; equal-power normalized against the low layer.</summary>
	static float HighLayerGain( float t )
	{
		float lowRaw = 1f - Smooth01( LowFadeStart, LowFadeEnd, t );
		float highRaw = Smooth01( HighFadeStart, HighFadeEnd, t );
		return EqualPower( lowRaw, highRaw, highRaw );
	}

	/// <summary>Normalize <paramref name="which"/> so that the two raw weights combine to unit power
	/// (lowGain² + highGain² = 1), i.e. an equal-power crossfade rather than a linear one.</summary>
	static float EqualPower( float lowRaw, float highRaw, float which )
	{
		float norm = MathF.Sqrt( (lowRaw * lowRaw) + (highRaw * highRaw) );
		return norm < 1e-4f ? 0f : which / norm;
	}

	/// <summary>Smoothstep of x within [edge0, edge1] → 0..1 (0 below edge0, 1 above edge1).</summary>
	static float Smooth01( float edge0, float edge1, float x )
	{
		float s = Math.Clamp( (x - edge0) / MathF.Max( 1e-4f, edge1 - edge0 ), 0f, 1f );
		return s * s * (3f - (2f * s));
	}

	/// <summary>Idle floor up to full at pinned throttle, then scaled by the master volume dial so
	/// lift-off is an audible dip rather than silence.</summary>
	float VolumeForThrottle()
	{
		float swell = MathX.Lerp( IdleVolume, FullVolume, Math.Clamp( _controller.Throttle, 0f, 1f ) );
		return swell * MathF.Max( 0f, MasterVolume );
	}

	/// <summary>Resolve the low/high event paths for this frame. The <c>vp_engine_sound</c> override
	/// wins when set — it forces a single loop (high = null) on every car for A/B listening. Otherwise
	/// the car plays its own <see cref="CarDefinition.EngineSoundEvent"/> (low) and, when it has one,
	/// <see cref="CarDefinition.EngineSoundEventHigh"/> (high, = layered mode). The <c>.sound</c>
	/// extension is required — the runtime resolves the event by its full resource path.</summary>
	static void ResolveEvents( string overrideVal, CarDefinition def, out string low, out string high )
	{
		string o = (overrideVal ?? "").Trim();
		if ( o.Length > 0 )
		{
			low = ResolveOverride( o );
			high = null; // override always forces single-loop mode
			return;
		}

		low = def?.EngineSoundEvent ?? "sounds/engine/engine_real_low.sound";
		string hi = def?.EngineSoundEventHigh;
		high = string.IsNullOrWhiteSpace( hi ) ? null : hi;
	}

	/// <summary>Map a <c>vp_engine_sound</c> override value (short name or full path) to an event
	/// path, ensuring the required <c>.sound</c> extension.</summary>
	static string ResolveOverride( string o )
	{
		string lo = o.ToLowerInvariant();
		// A full/partial path override is used verbatim (just ensure the required .sound extension).
		if ( lo.Contains( '/' ) )
			return lo.EndsWith( ".sound" ) ? o : o + ".sound";

		return lo switch
		{
			"b" => "sounds/engine/engine_b_sport_purr.sound",
			"low" or "real_low" => "sounds/engine/engine_real_low.sound",
			"high" or "real_high" => "sounds/engine/engine_real_high.sound",
			"truck_low" or "real_truck_low" => "sounds/engine/engine_real_truck_low.sound",
			"truck_high" or "real_truck_high" => "sounds/engine/engine_real_truck_high.sound",
			_ => "sounds/engine/engine_real_low.sound",
		};
	}
}