diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 4aba047..c05ccc8 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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. diff --git a/docs/ROTARY_SOUND.md b/docs/ROTARY_SOUND.md new file mode 100644 index 0000000..5065de2 --- /dev/null +++ b/docs/ROTARY_SOUND.md @@ -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://", + Rev = "rbxassetid://", + }, +} +``` +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. diff --git a/src/client/SoundController.luau b/src/client/SoundController.luau index c75b828..98106e7 100644 --- a/src/client/SoundController.luau +++ b/src/client/SoundController.luau @@ -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()