Sound: two-layer engine architecture + rotary pipeline for the RX-7
CI / quality (push) Successful in 43s

- SoundController rebuilt around idle+rev loop crossfading (real racing
  game approach): idle layer barely pitch-shifts (keeps engine character),
  rev layer carries the speed range; crossfade window 5%-45% of top speed
- Per-car CAR_SOUNDS table so the RX-7 gets real 13B rotary loops while
  other cars keep the generic set
- docs/ROTARY_SOUND.md: full record->loop->upload pipeline for capturing
  the owner's REAL RX-7 (extract audio from video, zero-crossing cut +
  equal-power crossfade seam, normalize, ogg, Studio upload, wire IDs)
- Toolbox rotary rips evaluated and rejected: unauditionabe off-platform,
  short/unloopable, murky rights; the real car beats any rip
This commit is contained in:
2026-07-22 01:57:16 -04:00
parent 2ae3bf8bdb
commit b2cc917942
3 changed files with 145 additions and 50 deletions
+61 -45
View File
@@ -2,24 +2,24 @@
--[[
SoundController.luau (CLIENT)
Engine + tyre audio while the local player drives. Purely presentational —
reads the car's physics state each frame and shapes two looping sounds:
reads the car's physics state each frame and shapes the loops:
Engine loop: pitch rises with speed (idle ~0.7 -> redline ~1.9), volume
swells slightly with throttle. Cheap single-loop "engine", reads
surprisingly well with the pitch curve.
Tyre squeal: fades in when the car is sliding — lateral velocity across
the body's right axis beyond a threshold (drift/hard corner/handbrake
moments), or heavy braking while still moving.
TWO-LAYER ENGINE (how real racing games do it):
- Idle layer: low-RPM loop, full volume at standstill, fades out with speed.
- Rev layer: high-RPM loop, fades in with speed, pitch tracks speed.
Crossfading two real loops keeps the engine's actual character (e.g. a
rotary's brap) instead of pitch-stretching one loop into a vacuum cleaner.
Tyre squeal: fades in when the car is sliding — lateral velocity across
the body's right axis beyond a threshold — or under heavy braking.
Sounds attach to the car BODY (3D, rolloff), so other players' cars are
audible too when their sounds replicate — but this controller only shapes
the LOCAL car's sounds (we own its physics).
audible too when their sounds replicate.
Asset notes (both from safe sources):
- Engine: "Car-Engine-Loop" by Roblox Resources (official account)
- Squeal: "Vehicle Skids Long Heavy Tire Squeal" by ProSoundEffects
(Roblox's licensed SFX library)
Swap IDs freely in the SOUNDS table below if better loops are found.
ROTARY NOTE (rx7_fc): the RX-7 should sound like a 13B rotary. The right
source is the owner's own recordings — see docs/ROTARY_SOUND.md for the
record -> loop -> upload pipeline. Once uploaded, set the two asset IDs in
CAR_SOUNDS below. Until then the generic loops are used for every car.
]]
local Players = game:GetService("Players")
@@ -34,15 +34,30 @@ local player = Players.LocalPlayer
local SoundController = {}
local SOUNDS = {
Engine = "rbxassetid://80317022777602",
Squeal = "rbxassetid://9120399077",
type EngineSounds = { Idle: string, Rev: string }
-- Default (generic) sounds --------------------------------------------------
local DEFAULT_SOUNDS: EngineSounds = {
Idle = "rbxassetid://80317022777602", -- Roblox Resources engine loop
Rev = "rbxassetid://80317022777602", -- same loop until real rev samples exist
}
local SQUEAL_ID = "rbxassetid://9120399077" -- ProSoundEffects tyre squeal
-- Per-car engine sounds. Fill rx7_fc with the owner's real 13B recordings
-- once uploaded (docs/ROTARY_SOUND.md). Cars not listed use DEFAULT_SOUNDS.
local CAR_SOUNDS: { [string]: EngineSounds } = {
-- rx7_fc = { Idle = "rbxassetid://<13B_IDLE>", Rev = "rbxassetid://<13B_REV>" },
}
-- Tunables --------------------------------------------------------------------
local ENGINE_MIN_PITCH = 0.7
local ENGINE_MAX_PITCH = 1.9
local ENGINE_BASE_VOLUME = 0.35
local IDLE_PITCH_MIN = 0.95 -- idle layer barely pitch-shifts (keeps character)
local IDLE_PITCH_MAX = 1.25
local REV_PITCH_MIN = 0.75
local REV_PITCH_MAX = 1.55 -- narrower than the old 1.9: two layers carry the
-- range now, so neither loop gets chipmunked
local CROSSFADE_START = 0.05 -- speedFrac where rev layer starts fading in
local CROSSFADE_END = 0.45 -- speedFrac where idle layer is fully out
local ENGINE_BASE_VOLUME = 0.4
local ENGINE_THROTTLE_SWELL = 0.15
local SLIP_START = 14 -- studs/sec lateral slip where squeal begins
local SLIP_FULL = 34 -- lateral slip for max squeal volume
@@ -51,8 +66,7 @@ local RESPONSIVENESS = 8 -- volume/pitch smoothing (1/sec)
local heartbeatConn: RBXScriptConnection? = nil
local seatedConn: RBXScriptConnection? = nil
local engineSound: Sound? = nil
local squealSound: Sound? = nil
local activeSounds: { Sound } = {}
local function smoothAlpha(k: number, dt: number): number
return 1 - math.exp(-k * dt)
@@ -68,6 +82,7 @@ local function makeSound(name: string, assetId: string, volume: number, parent:
s.RollOffMinDistance = 15
s.RollOffMaxDistance = 220
s.Parent = parent
table.insert(activeSounds, s)
return s
end
@@ -84,13 +99,14 @@ local function startAudio(seat: VehicleSeat)
end
local tuning = Chassis.tuningFor(car)
local bodyPart = body :: BasePart
local engineSet = (carId and CAR_SOUNDS[carId]) or DEFAULT_SOUNDS
engineSound = makeSound("EngineLoop", SOUNDS.Engine, ENGINE_BASE_VOLUME, bodyPart)
squealSound = makeSound("TyreSqueal", SOUNDS.Squeal, 0, bodyPart)
local engine = engineSound :: Sound
local squeal = squealSound :: Sound
engine:Play()
squeal:Play() -- runs at 0 volume; we fade it, never stop/start (no pops)
local idle = makeSound("EngineIdle", engineSet.Idle, ENGINE_BASE_VOLUME, bodyPart)
local rev = makeSound("EngineRev", engineSet.Rev, 0, bodyPart)
local squeal = makeSound("TyreSqueal", SQUEAL_ID, 0, bodyPart)
idle:Play()
rev:Play()
squeal:Play() -- all loops run constantly; we fade volumes (no start/stop pops)
heartbeatConn = RunService.Heartbeat:Connect(function(dt)
if not bodyPart.Parent then
@@ -99,20 +115,26 @@ local function startAudio(seat: VehicleSeat)
local velocity = bodyPart.AssemblyLinearVelocity
local speed = velocity.Magnitude
local speedFrac = math.clamp(speed / tuning.MaxSpeed, 0, 1)
-- Engine: pitch from speed (slight curve so low speeds feel torquey),
-- volume swells with throttle input.
local pitchTarget = ENGINE_MIN_PITCH + (ENGINE_MAX_PITCH - ENGINE_MIN_PITCH) * (speedFrac ^ 0.8)
local volTarget = ENGINE_BASE_VOLUME + ENGINE_THROTTLE_SWELL * math.abs(seat.ThrottleFloat)
local a = smoothAlpha(RESPONSIVENESS, dt)
engine.PlaybackSpeed += (pitchTarget - engine.PlaybackSpeed) * a
engine.Volume += (volTarget - engine.Volume) * a
local swell = ENGINE_THROTTLE_SWELL * math.abs(seat.ThrottleFloat)
-- Crossfade: idle out / rev in across the CROSSFADE window ------------
local fade = math.clamp((speedFrac - CROSSFADE_START) / (CROSSFADE_END - CROSSFADE_START), 0, 1)
local idleVolTarget = (ENGINE_BASE_VOLUME + swell) * (1 - fade)
local revVolTarget = (ENGINE_BASE_VOLUME + swell) * fade
local idlePitchTarget = IDLE_PITCH_MIN + (IDLE_PITCH_MAX - IDLE_PITCH_MIN) * fade
local revPitchTarget = REV_PITCH_MIN + (REV_PITCH_MAX - REV_PITCH_MIN) * (speedFrac ^ 0.8)
idle.Volume += (idleVolTarget - idle.Volume) * a
idle.PlaybackSpeed += (idlePitchTarget - idle.PlaybackSpeed) * a
rev.Volume += (revVolTarget - rev.Volume) * a
rev.PlaybackSpeed += (revPitchTarget - rev.PlaybackSpeed) * a
-- Tyre squeal: lateral slip = velocity component along the body's
-- right vector (sideways travel = sliding, not rolling).
local lateral = math.abs(velocity:Dot(bodyPart.CFrame.RightVector))
local slipFrac = math.clamp((lateral - SLIP_START) / (SLIP_FULL - SLIP_START), 0, 1)
-- Also squeal under hard braking from speed (throttle released fast).
local braking = (seat.ThrottleFloat == 0 and speed > tuning.MaxSpeed * 0.5) and 0.25 or 0
local squealTarget = math.max(slipFrac, braking) * SQUEAL_MAX_VOLUME
squeal.Volume += (squealTarget - squeal.Volume) * a
@@ -124,16 +146,10 @@ local function stopAudio()
heartbeatConn:Disconnect()
heartbeatConn = nil
end
-- Sounds are parented to the car body; destroy our references cleanly
-- (the car model itself may already be despawning).
if engineSound then
engineSound:Destroy()
engineSound = nil
end
if squealSound then
squealSound:Destroy()
squealSound = nil
for _, s in ipairs(activeSounds) do
s:Destroy()
end
table.clear(activeSounds)
end
function SoundController.init()