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
+7 -5
View File
@@ -25,11 +25,13 @@ to owner + son. Cars persist between sessions. CI green on a self-hosted runner.
- [x] **Chase camera**: scriptable follow cam while seated — damped chase with - [x] **Chase camera**: scriptable follow cam while seated — damped chase with
look-ahead, flat horizon (ignores car pitch/roll), speed-based FOV kick. look-ahead, flat horizon (ignores car pitch/roll), speed-based FOV kick.
`src/client/CameraController.luau`. Tune constants after playtest. `src/client/CameraController.luau`. Tune constants after playtest.
- [x] Sound: engine loop (pitch tracks speed, volume swells with throttle) + - [x] Sound: two-layer engine (idle + rev loops crossfaded by speed — keeps
tyre squeal on lateral slip / hard braking. `src/client/SoundController.luau`. engine character instead of chipmunking one loop) + tyre squeal on lateral
Asset IDs are placeholders from safe sources (Roblox Resources engine loop, slip / hard braking. `src/client/SoundController.luau` with per-car sound
ProSoundEffects squeal) — audition in Studio and swap in the SOUNDS table sets (`CAR_SOUNDS`).
if they don't fit. Boost sound deferred until boost exists. - [ ] **RX-7 rotary audio**: record the REAL car's 13B (owner has it + video),
process into idle/rev loops on the workstation, upload, fill `CAR_SOUNDS`.
Full pipeline: `docs/ROTARY_SOUND.md`. Generic loop is the stand-in.
**Exit test:** the RX-7 is genuinely fun to just drive around. Ship nothing else **Exit test:** the RX-7 is genuinely fun to just drive around. Ship nothing else
until this is true. until this is true.
+77
View File
@@ -0,0 +1,77 @@
# Real 13B rotary engine sound (rx7_fc)
The RX-7 must sound like a rotary. The pipeline: the owner's own recordings →
processed into two seamless loops on this workstation → uploaded in Studio →
IDs pasted into `SoundController.CAR_SOUNDS`.
## What the game needs (two loops)
| Loop | Content | Length |
|---|---|---|
| **Idle** | Engine idling, steady. The rotary lope/brap at rest. | 5–10 s |
| **Rev** | Held at steady mid-high RPM (~4–6k), NOT a sweep. | 5–10 s |
Steady-state matters: the game pitch-shifts and crossfades these, so a loop
recorded during rising RPM will "wander" audibly. A long recording containing
steady sections is fine — the good seconds get cut out in processing.
## Step 1 — Record (or use existing video)
Any source works: phone video, GoPro footage, a Zoom H5 recording. Existing
videos of the car are fine — audio gets extracted. Best results:
- Record OUTSIDE the car, mic a few feet from the exhaust.
- Get ≥10 s of steady idle and ≥5 s of held revs.
- Avoid wind over the mic and people talking.
Drop the files on the NAS next to the reference photos:
```
unraid-1:/mnt/user/data/yy-roblox-games/0-fast-n-furious-sim/001-1993 Mazda RX-7/sound/
```
## Step 2 — Process into seamless loops (this workstation)
Hermes does this — the box already has ffmpeg + numpy/scipy (`avsync-research`
venv). Ask and it happens; for reference the recipe is:
1. Extract audio: `ffmpeg -i clip.mp4 -vn -ac 1 -ar 48000 raw.wav`
2. Find a steady window (visually via spectrogram, or scripted RMS/pitch scan).
3. Cut the loop at zero-crossings and apply a short equal-power crossfade from
the tail onto the head so the seam is inaudible:
loop-body + (last ~150 ms faded out, overlaid on first ~150 ms faded in).
4. Normalize to about -3 dBFS peak, export `.ogg` (smaller upload, Roblox-native):
`ffmpeg -i loop.wav -c:a libvorbis -q:a 6 rx7_idle_loop.ogg`
5. Verify: play the file on repeat (`ffplay -loop 0`) — no click, no wander.
Outputs: `rx7_idle_loop.ogg` + `rx7_rev_loop.ogg`, copied back to the NAS
`sound/` folder and kept in `assets/audio/` locally (small enough to commit).
## Step 3 — Upload in Studio (owner)
1. Studio → **View → Asset Manager → Import** (or the Creator Dashboard →
Development Items → Audio) → upload both `.ogg` files.
2. Audio uploads require an ID/phone-verified account; private audio uploaded
to YOUR account is playable in YOUR experiences — exactly our case.
3. Copy each asset ID after moderation clears (usually minutes).
## Step 4 — Wire in
In `src/client/SoundController.luau`, uncomment and fill:
```lua
local CAR_SOUNDS: { [string]: EngineSounds } = {
rx7_fc = {
Idle = "rbxassetid://<idle id>",
Rev = "rbxassetid://<rev id>",
},
}
```
Rebuild, playtest, adjust `IDLE_PITCH_*` / `REV_PITCH_*` / `CROSSFADE_*` to
taste. Done — the RX-7 braps.
## Why not a toolbox rotary?
Searched; candidates exist (e.g. "13B ROTARY ENGINE SOUND", id 118059219867199)
but can't be auditioned outside Roblox (asset downloads require auth), most are
short unloopable rips, and rights are murky. The owner HAS the real car — its
actual engine is strictly better than any stranger's rip. Toolbox is the
fallback only if recording proves impractical: audition in Studio before
committing to one.
+61 -45
View File
@@ -2,24 +2,24 @@
--[[ --[[
SoundController.luau (CLIENT) SoundController.luau (CLIENT)
Engine + tyre audio while the local player drives. Purely presentational — 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 TWO-LAYER ENGINE (how real racing games do it):
swells slightly with throttle. Cheap single-loop "engine", reads - Idle layer: low-RPM loop, full volume at standstill, fades out with speed.
surprisingly well with the pitch curve. - Rev layer: high-RPM loop, fades in with speed, pitch tracks speed.
Tyre squeal: fades in when the car is sliding — lateral velocity across Crossfading two real loops keeps the engine's actual character (e.g. a
the body's right axis beyond a threshold (drift/hard corner/handbrake rotary's brap) instead of pitch-stretching one loop into a vacuum cleaner.
moments), or heavy braking while still moving.
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 Sounds attach to the car BODY (3D, rolloff), so other players' cars are
audible too when their sounds replicate — but this controller only shapes audible too when their sounds replicate.
the LOCAL car's sounds (we own its physics).
Asset notes (both from safe sources): ROTARY NOTE (rx7_fc): the RX-7 should sound like a 13B rotary. The right
- Engine: "Car-Engine-Loop" by Roblox Resources (official account) source is the owner's own recordings — see docs/ROTARY_SOUND.md for the
- Squeal: "Vehicle Skids Long Heavy Tire Squeal" by ProSoundEffects record -> loop -> upload pipeline. Once uploaded, set the two asset IDs in
(Roblox's licensed SFX library) CAR_SOUNDS below. Until then the generic loops are used for every car.
Swap IDs freely in the SOUNDS table below if better loops are found.
]] ]]
local Players = game:GetService("Players") local Players = game:GetService("Players")
@@ -34,15 +34,30 @@ local player = Players.LocalPlayer
local SoundController = {} local SoundController = {}
local SOUNDS = { type EngineSounds = { Idle: string, Rev: string }
Engine = "rbxassetid://80317022777602",
Squeal = "rbxassetid://9120399077", -- 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 -------------------------------------------------------------------- -- Tunables --------------------------------------------------------------------
local ENGINE_MIN_PITCH = 0.7 local IDLE_PITCH_MIN = 0.95 -- idle layer barely pitch-shifts (keeps character)
local ENGINE_MAX_PITCH = 1.9 local IDLE_PITCH_MAX = 1.25
local ENGINE_BASE_VOLUME = 0.35 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 ENGINE_THROTTLE_SWELL = 0.15
local SLIP_START = 14 -- studs/sec lateral slip where squeal begins local SLIP_START = 14 -- studs/sec lateral slip where squeal begins
local SLIP_FULL = 34 -- lateral slip for max squeal volume 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 heartbeatConn: RBXScriptConnection? = nil
local seatedConn: RBXScriptConnection? = nil local seatedConn: RBXScriptConnection? = nil
local engineSound: Sound? = nil local activeSounds: { Sound } = {}
local squealSound: Sound? = nil
local function smoothAlpha(k: number, dt: number): number local function smoothAlpha(k: number, dt: number): number
return 1 - math.exp(-k * dt) return 1 - math.exp(-k * dt)
@@ -68,6 +82,7 @@ local function makeSound(name: string, assetId: string, volume: number, parent:
s.RollOffMinDistance = 15 s.RollOffMinDistance = 15
s.RollOffMaxDistance = 220 s.RollOffMaxDistance = 220
s.Parent = parent s.Parent = parent
table.insert(activeSounds, s)
return s return s
end end
@@ -84,13 +99,14 @@ local function startAudio(seat: VehicleSeat)
end end
local tuning = Chassis.tuningFor(car) local tuning = Chassis.tuningFor(car)
local bodyPart = body :: BasePart local bodyPart = body :: BasePart
local engineSet = (carId and CAR_SOUNDS[carId]) or DEFAULT_SOUNDS
engineSound = makeSound("EngineLoop", SOUNDS.Engine, ENGINE_BASE_VOLUME, bodyPart) local idle = makeSound("EngineIdle", engineSet.Idle, ENGINE_BASE_VOLUME, bodyPart)
squealSound = makeSound("TyreSqueal", SOUNDS.Squeal, 0, bodyPart) local rev = makeSound("EngineRev", engineSet.Rev, 0, bodyPart)
local engine = engineSound :: Sound local squeal = makeSound("TyreSqueal", SQUEAL_ID, 0, bodyPart)
local squeal = squealSound :: Sound idle:Play()
engine:Play() rev:Play()
squeal:Play() -- runs at 0 volume; we fade it, never stop/start (no pops) squeal:Play() -- all loops run constantly; we fade volumes (no start/stop pops)
heartbeatConn = RunService.Heartbeat:Connect(function(dt) heartbeatConn = RunService.Heartbeat:Connect(function(dt)
if not bodyPart.Parent then if not bodyPart.Parent then
@@ -99,20 +115,26 @@ local function startAudio(seat: VehicleSeat)
local velocity = bodyPart.AssemblyLinearVelocity local velocity = bodyPart.AssemblyLinearVelocity
local speed = velocity.Magnitude local speed = velocity.Magnitude
local speedFrac = math.clamp(speed / tuning.MaxSpeed, 0, 1) 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) local a = smoothAlpha(RESPONSIVENESS, dt)
engine.PlaybackSpeed += (pitchTarget - engine.PlaybackSpeed) * a local swell = ENGINE_THROTTLE_SWELL * math.abs(seat.ThrottleFloat)
engine.Volume += (volTarget - engine.Volume) * a
-- 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 -- Tyre squeal: lateral slip = velocity component along the body's
-- right vector (sideways travel = sliding, not rolling). -- right vector (sideways travel = sliding, not rolling).
local lateral = math.abs(velocity:Dot(bodyPart.CFrame.RightVector)) local lateral = math.abs(velocity:Dot(bodyPart.CFrame.RightVector))
local slipFrac = math.clamp((lateral - SLIP_START) / (SLIP_FULL - SLIP_START), 0, 1) 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 braking = (seat.ThrottleFloat == 0 and speed > tuning.MaxSpeed * 0.5) and 0.25 or 0
local squealTarget = math.max(slipFrac, braking) * SQUEAL_MAX_VOLUME local squealTarget = math.max(slipFrac, braking) * SQUEAL_MAX_VOLUME
squeal.Volume += (squealTarget - squeal.Volume) * a squeal.Volume += (squealTarget - squeal.Volume) * a
@@ -124,16 +146,10 @@ local function stopAudio()
heartbeatConn:Disconnect() heartbeatConn:Disconnect()
heartbeatConn = nil heartbeatConn = nil
end end
-- Sounds are parented to the car body; destroy our references cleanly for _, s in ipairs(activeSounds) do
-- (the car model itself may already be despawning). s:Destroy()
if engineSound then
engineSound:Destroy()
engineSound = nil
end
if squealSound then
squealSound:Destroy()
squealSound = nil
end end
table.clear(activeSounds)
end end
function SoundController.init() function SoundController.init()