Sound: two-layer engine architecture + rotary pipeline for the RX-7
CI / quality (push) Successful in 43s
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:
+7
-5
@@ -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
|
||||
look-ahead, flat horizon (ignores car pitch/roll), speed-based FOV kick.
|
||||
`src/client/CameraController.luau`. Tune constants after playtest.
|
||||
- [x] Sound: engine loop (pitch tracks speed, volume swells with throttle) +
|
||||
tyre squeal on lateral slip / hard braking. `src/client/SoundController.luau`.
|
||||
Asset IDs are placeholders from safe sources (Roblox Resources engine loop,
|
||||
ProSoundEffects squeal) — audition in Studio and swap in the SOUNDS table
|
||||
if they don't fit. Boost sound deferred until boost exists.
|
||||
- [x] Sound: two-layer engine (idle + rev loops crossfaded by speed — keeps
|
||||
engine character instead of chipmunking one loop) + tyre squeal on lateral
|
||||
slip / hard braking. `src/client/SoundController.luau` with per-car sound
|
||||
sets (`CAR_SOUNDS`).
|
||||
- [ ] **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
|
||||
until this is true.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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:
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user