Player/Car/Visuals/CarEngineAudio.cs

Component that plays and controls a looping engine sound for a car. It computes a virtual-gear RPM, handles up/down shifts, rev-limiter and airborne throttle effects, and maps RPM and speed to sound pitch, volume and position each update.

NetworkingFile Access
namespace Machines.Player;

/// <summary>
/// Looping engine sound with virtual gears: RPM saws up per gear then drops on shift for a revving feel
/// </summary>
public sealed class CarEngineAudio : Component
{
	[RequireComponent]
	public Car Car { get; private set; }

	/// <summary>
	/// The looping engine sound event to play.
	/// </summary>
	[Property, Group( "Engine Sound" )]
	public SoundEvent EngineSoundEvent { get; set; }

	/// <summary>
	/// Pitch at idle RPM.
	/// </summary>
	[Property, Group( "Engine Sound" ), Range( 0.3f, 1f )]
	public float MinPitch { get; set; } = 0.6f;

	/// <summary>
	/// Pitch at redline (top of any gear).
	/// </summary>
	[Property, Group( "Engine Sound" ), Range( 1f, 2.5f )]
	public float MaxPitch { get; set; } = 1.4f;

	/// <summary>
	/// Volume at idle.
	/// </summary>
	[Property, Group( "Engine Sound" ), Range( 0f, 1f )]
	public float MinVolume { get; set; } = 0.4f;

	/// <summary>
	/// Volume at redline.
	/// </summary>
	[Property, Group( "Engine Sound" ), Range( 0f, 2f )]
	public float MaxVolume { get; set; } = 1.0f;

	/// <summary>
	/// Speed fraction (0-1) at the top of each gear band; ascending, must end at 1.0
	/// </summary>
	[Property, Group( "Gears" )]
	public List<float> GearThresholds { get; set; } = new() { 0.08f, 0.18f, 0.33f, 0.52f, 0.74f, 1.0f };

	/// <summary>
	/// RPM level (0-1) immediately after an upshift; lower = more dramatic drop
	/// </summary>
	[Property, Group( "Gears" ), Range( 0.2f, 0.7f )]
	public float ShiftDropRatio { get; set; } = 0.35f;

	/// <summary>
	/// Speed fraction below the gear floor required before downshifting; prevents gear hunting
	/// </summary>
	[Property, Group( "Gears" ), Range( 0f, 0.3f )]
	public float DownshiftHysteresis { get; set; } = 0.1f;

	/// <summary>
	/// How fast RPM rises toward its target per second (under throttle).
	/// </summary>
	[Property, Group( "RPM Dynamics" ), Range( 0.5f, 5f )]
	public float RpmRiseRate { get; set; } = 2.0f;

	/// <summary>
	/// How fast RPM falls toward its target per second (off throttle / after shift).
	/// </summary>
	[Property, Group( "RPM Dynamics" ), Range( 0.5f, 5f )]
	public float RpmFallRate { get; set; } = 1.5f;

	/// <summary>
	/// Final pitch smoothing (prevents micro-jitter). Higher = more responsive.
	/// </summary>
	[Property, Group( "RPM Dynamics" ), Range( 1f, 30f )]
	public float PitchSmoothing { get; set; } = 10f;

	/// <summary>
	/// RPM (0-1) above which the rev limiter bounces RPM back for a redline effect
	/// </summary>
	[Property, Group( "RPM Dynamics" ), Range( 0.7f, 1f )]
	public float RevLimiterThreshold { get; set; } = 0.92f;

	/// <summary>
	/// RPM drop when the rev limiter triggers; higher = more dramatic bounce
	/// </summary>
	[Property, Group( "RPM Dynamics" ), Range( 0.05f, 0.3f )]
	public float RevLimiterDrop { get; set; } = 0.12f;

	/// <summary>
	/// Throttle's direct contribution to RPM when grounded; higher = more responsive revving
	/// </summary>
	[Property, Group( "Throttle" ), Range( 0f, 0.5f )]
	public float ThrottleRpmContribution { get; set; } = 0.15f;

	/// <summary>
	/// Extra pitch when airborne under throttle (unloaded engine); scales with throttle
	/// </summary>
	[Property, Group( "Airborne" ), Range( 0f, 0.5f )]
	public float AirbornePitchBoost { get; set; } = 0.2f;

	/// <summary>
	/// How fast the airborne pitch boost ramps in/out per second.
	/// </summary>
	[Property, Group( "Airborne" ), Range( 1f, 10f )]
	public float AirbornePitchRate { get; set; } = 4f;

	private SoundHandle _engineSound;
	private float _currentPitch;
	private float _rpm; // 0-1 within current gear
	private int _currentGear; // 0-based
	private bool _limiterActive;
	private float _airbornePitch; // 0-1 blend for airborne boost

	protected override void OnEnabled()
	{
		_currentPitch = MinPitch;
		_rpm = 0f;
		_currentGear = 0;
		_airbornePitch = 0f;
	}

	protected override void OnUpdate()
	{
		if ( !Car.IsValid() || !Car.Movement.IsValid() )
			return;

		if ( !EngineSoundEvent.IsValid() )
			return;

		// Start the loop if not already playing
		if ( !_engineSound.IsValid() || !_engineSound.IsPlaying )
		{
			_engineSound = Sound.Play( EngineSoundEvent, WorldPosition );
		}

		float dt = Time.Delta;
		float maxSpeed = Car.ActiveStats.MaxSpeed;
		float absSpeed = MathF.Abs( Car.Movement.CurrentSpeed );
		float speedRatio = maxSpeed > 0f ? MathX.Clamp( absSpeed / maxSpeed, 0f, 1f ) : 0f;
		float throttle = MathF.Max( 0f, Car.Movement.ThrottleInput );
		bool isAirborne = !Car.Movement.IsGrounded;

		int gearCount = GearThresholds.Count;
		int targetGear = gearCount - 1;
		for ( int i = 0; i < gearCount; i++ )
		{
			if ( speedRatio <= GearThresholds[i] )
			{
				targetGear = i;
				break;
			}
		}

		// Downshift hysteresis: drop one gear at a time; multi-gear drops skip straight there
		if ( targetGear < _currentGear )
		{
			float currentGearFloor = _currentGear > 0 ? GearThresholds[_currentGear - 1] : 0f;
			float bandSize = GearThresholds[_currentGear] - currentGearFloor;

			if ( _currentGear - targetGear == 1 )
			{
				// Single-gear drop: apply hysteresis to prevent hunting
				if ( speedRatio > currentGearFloor - DownshiftHysteresis * bandSize )
					targetGear = _currentGear;
			}
			// Multi-gear drop (sudden stop/crash): skip straight there
		}

		// Handle gear changes
		if ( targetGear > _currentGear )
		{
			// Upshift - drop RPM
			_currentGear = targetGear;
			_rpm = ShiftDropRatio;
		}
		else if ( targetGear < _currentGear )
		{
			if ( _currentGear - targetGear == 1 )
			{
				// Single downshift - rev blip
				_currentGear = targetGear;
				_rpm = MathX.Clamp( _rpm + 0.3f, 0f, 1f );
			}
			else
			{
				// Big drop (stopped/crashed) - snap to correct gear, reset RPM to idle
				_currentGear = targetGear;
				_rpm = 0f;
			}
		}

		// Rpm target
		float floor = _currentGear > 0 ? GearThresholds[_currentGear - 1] : 0f;
		float ceiling = GearThresholds[_currentGear];
		float gearBandSize = ceiling - floor;
		float posInGear = gearBandSize > 0f
			? MathX.Clamp( (speedRatio - floor) / gearBandSize, 0f, 1f )
			: 0f;

		// Throttle contributes extra RPM; airborne = unloaded, so contribution is much higher
		float throttleContrib = isAirborne
			? throttle * 0.6f
			: throttle * ThrottleRpmContribution;

		float targetRpm = MathX.Clamp( posInGear + throttleContrib, 0f, 1f );

		// Rpm intertia
		float rate = targetRpm > _rpm ? RpmRiseRate : RpmFallRate;

		// Rpm when airborne
		if ( isAirborne && targetRpm > _rpm )
			rate *= 1.5f;

		_rpm = MathX.Approach( _rpm, targetRpm, rate * dt );

		// Rev limiter
		if ( _rpm >= RevLimiterThreshold && throttle > 0.1f )
		{
			if ( !_limiterActive )
			{
				_limiterActive = true;
				_rpm = RevLimiterThreshold - RevLimiterDrop;
			}
		}
		else
		{
			_limiterActive = false;
		}

		// Airborne pitch
		float airbornePitchTarget = (isAirborne && throttle > 0.1f) ? throttle : 0f;
		_airbornePitch = MathX.Approach( _airbornePitch, airbornePitchTarget, AirbornePitchRate * dt );

		// Map rpm to pitch / vol
		// Higher gears get a slight pitch bump so each gear sounds distinct
		float gearPitchBias = _currentGear * 0.04f;
		float rpmForPitch = MathX.Clamp( _rpm + gearPitchBias, 0f, 1f );

		float targetPitch = MathX.Lerp( MinPitch, MaxPitch, rpmForPitch );
		targetPitch += _airbornePitch * AirbornePitchBoost;

		_currentPitch = MathX.Lerp( _currentPitch, targetPitch, PitchSmoothing * dt );

		// Volume blends RPM and speed so high-speed driving is always loud
		float volumeRpm = MathX.Lerp( MinVolume, MaxVolume, _rpm );
		float volumeSpeed = MathX.Lerp( MinVolume, MaxVolume, speedRatio * 0.5f );
		float volume = MathF.Max( volumeRpm, volumeSpeed );

		_engineSound.Pitch = _currentPitch;
		_engineSound.Volume = volume;
		_engineSound.Position = WorldPosition;
	}

	protected override void OnDisabled()
	{
		_engineSound?.Stop();
	}

	protected override void OnDestroy()
	{
		_engineSound?.Stop();
	}
}