From 7c5c6b2c5130f02a3308b33bb62699bad7cffcbd Mon Sep 17 00:00:00 2001 From: Hails <26784904+Hailey-Ross@users.noreply.github.com> Date: Fri, 2 Oct 2026 08:16:21 -0600 Subject: [PATCH] Game audio: retail-measured layers, transient-safe one-shots, 75% default volume - Pop, flip-catch and landing cues built from the layers retail plays per moment (bank, record, level, delay, envelope measured from TU3 traces), with hollow/normal landing tables and per-kind chances. - One-shots start at their final level (no fade-in eating the transient); loops still fade in. - Riding bed from the measured statistics. - Master volume defaults to 75 % (100 % is unity gain; 25 % was too quiet to judge retail parity). --- crates/skate-game/src/game_audio/ambience.rs | 2 +- crates/skate-game/src/game_audio/cues.rs | 270 ++++++++++++++---- crates/skate-game/src/game_audio/library.rs | Bin 9477 -> 10202 bytes crates/skate-game/src/game_audio/mod.rs | 14 +- .../skate-game/src/game_audio/skate_events.rs | 119 ++++++-- crates/skate-game/src/game_audio/voices.rs | 87 +++++- crates/skate-game/src/game_audio/water.rs | 2 +- docs/hails-additions/11-audio.md | 46 ++- 8 files changed, 443 insertions(+), 97 deletions(-) diff --git a/crates/skate-game/src/game_audio/ambience.rs b/crates/skate-game/src/game_audio/ambience.rs index 532a95d3..16e9dc7b 100644 --- a/crates/skate-game/src/game_audio/ambience.rs +++ b/crates/skate-game/src/game_audio/ambience.rs @@ -65,7 +65,7 @@ pub(super) fn update( return; }; let Some(clip) = library.ambience(&mut assets, bed) else { return }; - let play = Play { category: Category::Ambience, volume: LEVEL, pitch: 1.0, position: None, looping: true, fade_in: CROSSFADE }; + let play = Play { category: Category::Ambience, volume: LEVEL, pitch: 1.0, position: None, looping: true, fade_in: CROSSFADE, envelope: None }; match voices.play(&mut commands, &clip, play, time.elapsed_secs_f64()) { Some(voice) => { info!("Ambience: {bed} for map {:?}", map.name); diff --git a/crates/skate-game/src/game_audio/cues.rs b/crates/skate-game/src/game_audio/cues.rs index 24179991..a1a8121b 100644 --- a/crates/skate-game/src/game_audio/cues.rs +++ b/crates/skate-game/src/game_audio/cues.rs @@ -1,11 +1,15 @@ -//! Which decoded retail samples play for which gameplay cue. Retail picks them -//! through compiled AEMS event scripts we do not run; these picks are our own, -//! made from the decoded banks by duration/attack/brightness and then by ear -//! (docs/hails-additions/11-audio.md lists the candidates and how to audition -//! them). Bank names and indices refer to assets/private/audio/audio_manifest.json. +//! Which decoded retail sounds play for which gameplay cue. Retail picks them +//! through compiled AEMS event scripts we do not run. Since 2026-10-01 the +//! impact/contact cues use the retail patches (SPLC records/containers) that a +//! local recompiled build was measured playing for the same moments, at levels +//! in retail's measured ratios (docs/hails-additions/11-audio.md, "Measured +//! retail mapping"). The rest are our own picks, made by ear. Bank names and +//! indices refer to assets/private/audio/audio_manifest.json. //! //! Levels are linear and deliberately low: many raw samples peak at 0 dBFS //! (retail mixes them down at runtime). Final gain = level x effects x master. +//! Retail-measured cues use level = measured retail level x RETAIL_SCALE, +//! calibrated so rolling (retail p90 0.065) lands on our tested ROLL_LEVEL. /// A set of interchangeable samples: one is picked at random per play. pub(super) struct Cue { @@ -14,6 +18,43 @@ pub(super) struct Cue { pub level: f32, } +/// A retail patch to play as its data describes (layers, probabilities, gain +/// ranges): record `id` of an SPLC bank, or a container (id >= record count), +/// which picks one of its records each time. +pub(super) struct Record { + pub bank: &'static str, + pub id: usize, + pub level: f32, + /// Seconds after the event (retail spreads a pop's layers over ~60 ms). + pub delay: f32, + /// Retail's gain over time for this layer (see the *_ENV curves). + pub envelope: Option<&'static [(f32, f32)]>, +} +impl Record { + const fn after(self, delay: f32) -> Self { + Self { delay, ..self } + } + const fn shaped(self, envelope: &'static [(f32, f32)]) -> Self { + Self { envelope: Some(envelope), ..self } + } +} + +// Retail's per-layer gain over time (relative to the layer's peak), measured +// from the recomp's gain modules for ~2,300 pop/landing voices in a clean +// session (music and speech off): long samples are faded down within ~100 ms, +// which is what makes retail's impacts short and punchy instead of ringing. +/// Deck knocks and truck/wheel contacts: full for 50 ms, ~0.35 by 300 ms. +pub(super) const KNOCK_ENV: &[(f32, f32)] = + &[(0.0, 1.0), (0.05, 1.0), (0.1, 0.88), (0.15, 0.58), (0.2, 0.45), (0.3, 0.35), (0.5, 0.3)]; +/// Tail impacts and the hollow (deep) sets: held longer. +pub(super) const TAIL_ENV: &[(f32, f32)] = &[(0.0, 1.0), (0.1, 1.0), (0.15, 0.84), (0.2, 0.75), (0.3, 0.75), (0.5, 0.6)]; +/// The body set: a 50 ms swell, gone by 150 ms. +pub(super) const BODY_ENV: &[(f32, f32)] = &[(0.0, 0.4), (0.05, 1.0), (0.1, 0.2), (0.15, 0.04)]; +/// Wheels-down (~1 s samples): cut to a fifth within 100 ms. +pub(super) const WHEELS_ENV: &[(f32, f32)] = &[(0.0, 1.0), (0.02, 0.6), (0.05, 0.5), (0.1, 0.2), (0.15, 0.18)]; +/// The pop crack: a click, down to 14 % after 20 ms. +pub(super) const CRACK_ENV: &[(f32, f32)] = &[(0.0, 1.0), (0.02, 0.14), (0.1, 0.08)]; + macro_rules! range { ($a:literal..=$b:literal) => {{ const N: usize = $b - $a + 1; @@ -30,27 +71,117 @@ macro_rules! range { }}; } -/// Skate_Collisions 1066-1092 were picked out by ear as "heard a lot while -/// skating": short knocks with most energy at 400-2500 Hz (the wooden deck), -/// unlike the low thumps on either side (1060-1065, 1093-1100). -/// Ollie / nollie pop (tail strike): the sharpest, shortest of them. -pub(super) const POP: Cue = Cue { bank: "Skate_Collisions", samples: range!(1074..=1078), level: 0.45 }; -/// Board flip/spin whoosh when a trick registers in the air. -pub(super) const FLIP: Cue = Cue { bank: "Sk8_Air_Flip_Tricks", samples: range!(10..=13), level: 0.3 }; -/// Landing (scaled by impact speed): the longer deck slaps of that family. -/// (Rejected before: 35-40 harsh; 0-2 is kept for setting the board down.) -pub(super) const LAND: Cue = Cue { bank: "Skate_Collisions", samples: range!(1088..=1092), level: 0.9 }; -/// Extra weight for hard landings and jumping onto the board: the low thumps -/// right after the deck family (trucks/wheels), replacing the guessed 18-20. -pub(super) const LAND_HEAVY: Cue = Cue { bank: "Skate_Collisions", samples: range!(1097..=1100), level: 1.0 }; -/// Grind/slide loop on rails and ledges. -/// GRINDS holds every material's grind loops (all 1.4-2.6 s). 9 and 11 (noisy, -/// low tonality) sounded like wood on a metal rail in play: they stay for -/// ledges and other non-metal surfaces. -pub(super) const GRIND: Cue = Cue { bank: "GRINDS", samples: &[9, 11], level: 0.3 }; -/// Metal rails: 54-56 are the most tonal, brightest (4-5.6 kHz) loops in the -/// bank — a ringing metal scrape. Picked by measurement; not yet confirmed by ear. -pub(super) const GRIND_METAL: Cue = Cue { bank: "GRINDS", samples: range!(54..=56), level: 0.3 }; +/// Measured retail level -> our level (ROLL_LEVEL 0.15 / retail rolling p90 0.065, rounded down). +pub(super) const RETAIL_SCALE: f32 = 2.0; +const fn retail(bank: &'static str, id: usize, measured: f32) -> Record { + Record { bank, id, level: measured * RETAIL_SCALE, delay: 0.0, envelope: None } +} +const SC: &str = "Skate_Collisions"; + +// Measured sequence for an ollie/kickflip (scripted recomp run, times from the +// flick): +0.24 s pop = containers 1096 + 1098/1099 with the flip object; +// +0.3-0.6 s board knocks 1112/1115/1118 (feet catching the board); +0.95 s +// landing = 1095 + one of the 1052/1055/1058 impact families + wheel skid. + +// Pop and landing recipes from two recomp sessions (57 pops, 83 landings) and +// the real game sound captured with them (.local/recomp/compare_events.py): +// which retail sets play at each moment, and their median level there. +// (Earlier versions picked sets by their loudest moments, then by per-layer +// medians without the low thuds; both "did not sound close" to the user — +// the captured retail mix showed the missing low end.) + +/// Pop, all at once: deck knock (1112, 548-552, ~0.42, the click), body +/// (1111, 173-182, ~0.16), low thuds (876: 199-201 ~0.18, 878: 193-195 ~0.08, +/// 891: 936/937 ~0.16), the pop crack (1096: 259/260 + 623-626, ~0.10) and one +/// of three tail impacts (1097/1098/1099, ~0.16 each). +/// Delays are retail's typical offsets from the knock (traced pops): ground +/// thud 193-195 at once, pop crack and tail impact ~+20 ms, body ~+30 ms, +/// low ground thud 199-201 ~+40 ms. +pub(super) const POP: &[Record] = &[ + retail(SC, 1112, 0.42).shaped(KNOCK_ENV), retail(SC, 878, 0.08), retail(SC, 891, 0.16), + retail(SC, 1096, 0.10).after(0.02).shaped(CRACK_ENV), retail(SC, 1111, 0.16).after(0.03).shaped(BODY_ENV), + retail(SC, 876, 0.18).after(0.04), +]; +pub(super) const POP_TAIL: &[Record] = + &[ + retail(SC, 1097, 0.16).after(0.02).shaped(TAIL_ENV), + retail(SC, 1098, 0.16).after(0.02).shaped(TAIL_ENV), + retail(SC, 1099, 0.16).after(0.02).shaped(TAIL_ENV), + ]; +/// Feet catching the board (1115: 548-552 + 530-535, ~0.43): retail plays it +/// ~0.2-0.3 s after the pop (scripted ollie: pop +0.24 s, knocks +0.42/+0.52 s). +pub(super) const CATCH: Record = retail(SC, 1115, 0.40).after(0.22).shaped(KNOCK_ENV); +/// Flip/spin whoosh when a trick registers: retail's Class_Flips plays +/// Sk8_Air_Flip_Tricks 1-3 (measured ~0.15-0.3). +pub(super) const FLIP: Cue = Cue { bank: "Sk8_Air_Flip_Tricks", samples: range!(1..=3), level: 0.45 }; +/// Landing: the wheels-down set (1095, 396-404, ~0.09) always, plus retail's +/// board contact sets, ported from the game's own rule (recomp traces +/// 2026-10-01, 340 picks; read as reference only): +/// - a table per tier: tier 2 when the surface under the board is hollow +/// (observed: Wood_1 park ramps; the game reads a per-material flag, we take +/// the wood materials), tier 0 otherwise (tier 1 = during a bail); +/// - per contact kind (which wheels/part touched) three variants (light / +/// medium / hard). At a landing retail fires ~1.5 kinds: kind 0 44 %, +/// kind 1 47 %, kind 2 16 %, kind 3 46 % of landings; +/// - variant: retail uses the heaviest per-wheel impact class (not decoded); +/// ours follows the impact speed (`land_tier`). +/// Levels: the set's median level at landings in a clean session (music and +/// speech off, 118 landings; 1052 carries 22 % of the energy) x RETAIL_SCALE. +pub(super) const LAND: Record = retail(SC, 1095, 0.17).shaped(WHEELS_ENV); +/// [kind][variant] for tier 0 (normal) and tier 2 (hollow wood). +pub(super) const LAND_NORMAL: [[Record; 3]; 4] = [ + [retail(SC, 1051, 0.32).shaped(KNOCK_ENV), retail(SC, 1052, 0.48).shaped(KNOCK_ENV), retail(SC, 1053, 0.35).shaped(KNOCK_ENV)], + [retail(SC, 1054, 0.18).shaped(KNOCK_ENV), retail(SC, 1055, 0.41).shaped(KNOCK_ENV), retail(SC, 1056, 0.31).shaped(KNOCK_ENV)], + [retail(SC, 1057, 0.19).shaped(KNOCK_ENV), retail(SC, 1058, 0.33).shaped(KNOCK_ENV), retail(SC, 1059, 0.30).shaped(KNOCK_ENV)], + [retail(SC, 1060, 0.22).shaped(KNOCK_ENV), retail(SC, 1061, 0.42).shaped(KNOCK_ENV), retail(SC, 1127, 0.30).shaped(KNOCK_ENV)], +]; +pub(super) const LAND_HOLLOW: [[Record; 3]; 4] = [ + [retail(SC, 1062, 0.26).shaped(TAIL_ENV), retail(SC, 1063, 0.47).shaped(TAIL_ENV), retail(SC, 1053, 0.35).shaped(TAIL_ENV)], + [retail(SC, 1065, 0.16).shaped(TAIL_ENV), retail(SC, 1066, 0.25).shaped(TAIL_ENV), retail(SC, 1056, 0.31).shaped(TAIL_ENV)], + [retail(SC, 1068, 0.19).shaped(TAIL_ENV), retail(SC, 1069, 0.25).shaped(TAIL_ENV), retail(SC, 1070, 0.25).shaped(TAIL_ENV)], + [retail(SC, 1071, 0.19).shaped(TAIL_ENV), retail(SC, 1131, 0.30).shaped(TAIL_ENV), retail(SC, 1132, 0.30).shaped(TAIL_ENV)], +]; +/// Chance each contact kind sounds at a landing (retail, 100 landings). +pub(super) const LAND_KIND_CHANCE: [f32; 4] = [0.44, 0.47, 0.16, 0.46]; +/// Cloth with the landing (sk8_foley 44-57, retail ~0.11). +pub(super) const LAND_CLOTH: Record = retail("sk8_foley", 95, 0.11).after(0.04); +/// Variant: light < 3, medium < 6, hard from 6 m/s impact (ours). +pub(super) fn land_tier(impact: f32) -> usize { + match impact { + i if i >= 6.0 => 2, + i if i >= 3.0 => 1, + _ => 0, + } +} +/// Retail contact loudness rises gently with board speed (median 0.19-0.23 +/// slow, ~0.33 above 11 m/s): 0.7 standing to 1.0 from 11 m/s. +pub(super) fn land_scale(speed: f32) -> f32 { + let speed = if speed.is_finite() { speed.abs() } else { 0.0 }; + 0.7 + 0.3 * (speed / 11.0).min(1.0) +} +/// Hollow surfaces for the tier-2 contact table: the wood materials +/// (Wood_Ramp 6, Plywood 7, Wood_1-4 41-46, Wood_5 90 in our audio ids; +/// retail's numbering is ours - 1). +pub(super) fn hollow(audio_surface: u32) -> bool { + matches!(audio_surface, 6 | 7 | 41..=46 | 90) +} +/// Putting the board down / stepping on (measured when getting on the board): +/// the foot-on-deck set (1126: 422-426, 0.23-0.76) with a deck knock (1119) and +/// a light wheel touch (1054); jumping on (caveman) adds the landing set 1095. +pub(super) const BOARD_DOWN: Record = retail(SC, 1126, 0.30); +pub(super) const BOARD_DOWN_KNOCK: Record = retail(SC, 1119, 0.27).shaped(KNOCK_ENV); +pub(super) const BOARD_DOWN_TOUCH: Record = retail(SC, 1054, 0.14); +/// Grind start on ledges and other non-metal surfaces (954: 880-882) ... +pub(super) const GRIND_START: Record = retail(SC, 954, 0.25); +/// ... then retail re-fires short scrape pieces (955: 202-206, 0.04-0.24 s) +/// about every 63 ms while grinding (measured median gap), instead of a loop. +pub(super) const GRIND_PIECES: Record = retail(SC, 955, 0.15); +pub(super) const GRIND_PIECE_INTERVAL: f32 = 0.063; +/// Metal rails: the rail object's start (1146: 953-959, measured 0.49) ... +pub(super) const GRIND_METAL_START: Record = retail(SC, 1146, 0.40); +/// ... over the GRINDS metal loop (retail's Class_grind plays GRINDS quietly, +/// <= 0.13). 54-56 are the most tonal, brightest loops in the bank. +pub(super) const GRIND_METAL: Cue = Cue { bank: "GRINDS", samples: range!(54..=56), level: 0.26 }; /// Whether a grind's audio surface (`grinds.audio_surface_216`) is metal: /// Metal (9), the solid/hollow/square/complex metal shapes (11-40), manhole @@ -65,35 +196,24 @@ pub(super) const FOOT_DRAG: Cue = Cue { bank: "FOOT_DRAG", samples: &[36, 37, 38 /// (seconds between shuffled pieces, fade-in of each piece). pub(super) const POWERSLIDE_SHUFFLE: (f32, f32) = (0.08, 0.01); pub(super) const FOOT_DRAG_SHUFFLE: (f32, f32) = (0.11, 0.05); -/// Putting the board down: wheels touching after stepping on (softer, shorter impacts). -pub(super) const BOARD_DOWN: Cue = Cue { bank: "Skate_Collisions", samples: range!(0..=2), level: 0.3 }; -/// Bail: body sliding/hitting the ground. -/// Bodyslide 0-15 comes in three blocks of five — one longer slide, four -/// hits — taken as soft/medium/hard tiers; 15 joins the hard tier. In play -/// they sound like additions to a fall, not the fall itself, so they are a -/// quieter layer until a main body-impact sound is picked -/// (`.local/audio-audition/bail.html`). `bail_tier` picks by impact speed. +/// Bail: retail's body hits (1074: records 667-671, samples 215-219 + 861-874, +/// measured <= 0.26) ... +pub(super) const BAIL_HIT: Record = retail(SC, 1074, 0.26); +/// ... with the Bodyslide layer under them (retail <= 0.19: an addition to the +/// fall, as heard in play). 0-15 come in three blocks of five — one longer +/// slide, four hits — taken as soft/medium/hard tiers; 15 joins the hard tier. pub(super) const BAIL: Cue = Cue { bank: "Bodyslide", samples: range!(0..=15), level: 0.25 }; pub(super) const BAIL_TIERS: [&[usize]; 3] = [range!(0..=4), range!(5..=9), range!(10..=15)]; /// Body speed (m/s) at the start of a bail for the medium and hard tiers. pub(super) fn bail_tier(speed: f32) -> usize { if speed >= 7.0 { 2 } else if speed >= 3.5 { 1 } else { 0 } } -/// A retail patch to play as its data describes (layers, probabilities, gain -/// ranges): record `id` of an SPLC bank, or a container (id >= record count). -pub(super) struct Record { - pub bank: &'static str, - pub id: usize, - pub level: f32, -} /// Falling into water. The user found Skate_Collisions 476 by ear; the bank's /// patch tree puts 476-478 in record 869 with two more layers: 169 (played 75 % /// of the time) and one of 171/172 — retail's full splash. Level follows the /// fall speed. (Rejected: water_misc 0/4, Skate_Collisions 741-762, 473-475.) -pub(super) const SPLASH: Record = Record { bank: "Skate_Collisions", id: 869, level: 0.5 }; -/// Samples of SPLASH, for preloading. -pub(super) const SPLASH_SAMPLES: Cue = Cue { bank: "Skate_Collisions", samples: &[169, 171, 172, 476, 477, 478], level: 0.5 }; +pub(super) const SPLASH: Record = Record { bank: SC, id: 869, level: 0.5, delay: 0.0, envelope: None }; /// Pushing: foot hitting the ground. pub(super) const PUSH: Cue = Cue { bank: "fstep_skateshoe1_sm", samples: range!(74..=84), level: 0.6 }; /// On-foot steps (walk) and heavier steps (run, landing on foot). @@ -102,8 +222,49 @@ pub(super) const PUSH: Cue = Cue { bank: "fstep_skateshoe1_sm", samples: range!( pub(super) const STEP: Cue = Cue { bank: "fstep_skateshoe1_sm", samples: range!(74..=84), level: 0.85 }; pub(super) const RUN_STEP: Cue = Cue { bank: "fstep_skateshoe1_sm", samples: range!(6..=17), level: 0.68 }; -/// Every one-shot/loop cue, for preloading. -pub(super) const ALL: &[&Cue] = &[&POP, &FLIP, &LAND, &LAND_HEAVY, &BOARD_DOWN, &GRIND, &GRIND_METAL, &POWERSLIDE, &FOOT_DRAG, &BAIL, &SPLASH_SAMPLES, &PUSH, &STEP, &RUN_STEP]; +/// The riding bed: what retail plays continuously while rolling, away from +/// pops and landings (clean recomp session, 618 s; rates are starts per second +/// at typical riding speed, scaled by `bed_rate`; levels = median x +/// RETAIL_SCALE). Without it impacts sat on silence ("just a sound playing"). +/// Surface collision pieces (SFXObj_Collision; ~2.1/s), cloth/body foley +/// (sk8_foley, ~2.9/s) and the odd deck knock. +pub(super) const BED_RECORDS: &[(Record, f32)] = &[ + (retail(SC, 955, 0.09), 0.50), (retail(SC, 954, 0.13), 0.45), (retail(SC, 956, 0.15), 0.42), + (retail(SC, 958, 0.07), 0.23), (retail(SC, 947, 0.11), 0.19), (retail(SC, 952, 0.14), 0.17), + (retail(SC, 968, 0.24), 0.17), (retail(SC, 948, 0.15), 0.11), + (retail("sk8_foley", 95, 0.06), 0.78), (retail("sk8_foley", 94, 0.06), 0.52), (retail("sk8_foley", 74, 0.26), 0.32), + (retail("sk8_foley", 73, 0.21), 0.12), (retail("sk8_foley", 62, 0.20), 0.33), (retail("sk8_foley", 84, 0.18), 0.17), + (retail("sk8_foley", 88, 0.16), 0.12), (retail("sk8_foley", 85, 0.23), 0.17), (retail("sk8_foley", 89, 0.07), 0.12), + (retail(SC, 1112, 0.19).shaped(KNOCK_ENV), 0.11), (retail(SC, 1115, 0.26).shaped(KNOCK_ENV), 0.08), + (retail(SC, 1119, 0.32).shaped(KNOCK_ENV), 0.02), +]; +/// Object-bank layers of the bed (retail's most used samples): truck rattles, +/// seams/cracks, speed wind and rolling-surface patches. +pub(super) const BED_CUES: &[(Cue, f32)] = &[ + (Cue { bank: "Rolling_Rattles", samples: &[11, 8, 7, 14, 9, 13], level: 0.44 }, 0.31), + (Cue { bank: "Seams_Bank", samples: &[44, 144, 46, 40, 156, 140, 47, 161, 159, 153, 42, 36], level: 0.09 }, 0.31), + (Cue { bank: "sense_of_speed", samples: &[4, 3, 0, 16, 1], level: 0.08 }, 0.5), + (Cue { bank: "PatchBank_Rolling_Surfaces", samples: &[11, 1, 15, 13, 3], level: 0.14 }, 0.14), +]; +/// Bed rate multiplier for board speed (m/s): none below 0.5, 1.0 at 6 m/s. +pub(super) fn bed_rate(speed: f32) -> f32 { + let speed = if speed.is_finite() { speed.abs() } else { 0.0 }; + if speed < 0.5 { 0.0 } else { (speed / 6.0).min(1.5) } +} + +/// Every one-shot/loop sample cue, for preloading. +pub(super) const ALL: &[&Cue] = &[&FLIP, &GRIND_METAL, &POWERSLIDE, &FOOT_DRAG, &BAIL, &PUSH, &STEP, &RUN_STEP]; +/// Every retail patch cue, for preloading (all samples they can pick). +pub(super) const RECORDS: &[&Record] = &[ + &POP[0], &POP[1], &POP[2], &POP[3], &POP[4], &POP[5], &POP_TAIL[0], &POP_TAIL[1], &POP_TAIL[2], + &CATCH, &LAND, &LAND_CLOTH, + &LAND_NORMAL[0][0], &LAND_NORMAL[0][1], &LAND_NORMAL[0][2], &LAND_NORMAL[1][0], &LAND_NORMAL[1][1], &LAND_NORMAL[1][2], + &LAND_NORMAL[2][0], &LAND_NORMAL[2][1], &LAND_NORMAL[2][2], &LAND_NORMAL[3][0], &LAND_NORMAL[3][1], &LAND_NORMAL[3][2], + &LAND_HOLLOW[0][0], &LAND_HOLLOW[0][1], &LAND_HOLLOW[1][0], &LAND_HOLLOW[1][1], &LAND_HOLLOW[2][0], &LAND_HOLLOW[2][1], + &LAND_HOLLOW[2][2], &LAND_HOLLOW[3][0], &LAND_HOLLOW[3][1], &LAND_HOLLOW[3][2], + &BOARD_DOWN, &BOARD_DOWN_KNOCK, &BOARD_DOWN_TOUCH, &GRIND_START, &GRIND_PIECES, &GRIND_METAL_START, + &BAIL_HIT, &SPLASH, +]; /// Shortest time between two plays of one cue (s): a safety net against /// state flags that stay set for many ticks. @@ -124,8 +285,8 @@ pub(super) const ROLL_LEVEL: f32 = 0.15; pub(super) const AIR_WHEELS: (&str, f32) = ("Whls_spins_Jump_1", 0.15); /// Rolling grain for an audio material ID (low 7 bits of the collision -/// surface tag; names from the map tooling's audio surface table). Only the -/// "hard" grains exist for every material, so those are used. +/// surface tag; names from the map tooling's audio surface table). Retail was +/// measured playing these "hard" grains too. pub(super) fn grain_for(audio_surface: u32) -> &'static str { match audio_surface { 1 => "asphalt_smooth_hard", @@ -146,19 +307,26 @@ mod tests { #[test] fn cue_ranges_and_levels_are_sane() { - assert_eq!(POP.samples, &[1074, 1075, 1076, 1077, 1078]); + assert_eq!(FLIP.samples, &[1, 2, 3]); assert_eq!(STEP.samples.len(), 11); - for cue in [&POP, &FLIP, &LAND, &LAND_HEAVY, &BOARD_DOWN, &GRIND, &GRIND_METAL, &POWERSLIDE, &FOOT_DRAG, &BAIL, &SPLASH_SAMPLES, &PUSH, &STEP, &RUN_STEP] { + for cue in ALL { assert!(!cue.samples.is_empty()); assert!(cue.level > 0.0 && cue.level <= 4.0, "{} out of range", cue.bank); } + for record in RECORDS { + assert!(record.level > 0.0 && record.level <= 1.0, "{}:{} out of range", record.bank, record.id); + } } #[test] - fn bail_tiers_cover_the_bank_and_rise_with_speed() { + fn tiers_rise_with_impact_and_speed() { let all: Vec = BAIL_TIERS.iter().flat_map(|t| t.iter().copied()).collect(); assert_eq!(all, BAIL.samples); assert_eq!((bail_tier(1.0), bail_tier(4.0), bail_tier(9.0)), (0, 1, 2)); + assert_eq!((land_tier(1.0), land_tier(4.0), land_tier(8.0)), (0, 1, 2)); + assert!(land_scale(0.0) == 0.7 && land_scale(11.0) == 1.0 && land_scale(f32::NAN) == 0.7); + assert!(hollow(41) && hollow(6) && !hollow(3) && !hollow(66)); + assert!(bed_rate(0.2) == 0.0 && bed_rate(6.0) == 1.0 && bed_rate(30.0) == 1.5); } #[test] diff --git a/crates/skate-game/src/game_audio/library.rs b/crates/skate-game/src/game_audio/library.rs index 289f2771536e329bb67b4ae0603ddfca58af1dca..c516c8acd37bb797ca33acf25d664004004c5e4b 100644 GIT binary patch delta 710 zcmZuuy>1jS5Uv5GqCtv~28xp{U3}0kxpXu%6i74(?fLB4!@_G@V{f8xciM_4$cun@ z8^i;o&kJDh?u7txV|zUF{rT&gpKrh29QUu!kuD+1HdurplllZJqgUV?Sgob|yaFX1 z1S>CrNo#>ndD5}GOHke=X&lo0=pp2_SS$bsz}$}nEw*bUK8MB=1VZbjHf|%v?$E8- z)vqUy`oda|5+a*Lw9Om}cLlSUXbwhK&_(kFIh>rqdsL^x?F_afP@%S%;GJ)g4IVIZ zSVBP()14JS4rn97HoHh2xdK&HuEz_;VgKrHu!GU%mZbrNRd=ysVxzEa!il;vTlthR6IK3g?g&ho=7(X@k z2buwYEbgWs7cVlb^46ltdhbLvX%^8_syi2xTwA=iL!<7(eJ`WP{n3x> Self { - Self { master: 25, ambience: 100, effects: 100 } + Self { master: 75, ambience: 100, effects: 100 } } } impl SavedSettings { @@ -154,7 +154,13 @@ fn setup(mut commands: Commands, config: Res, mut assets: commands.spawn((GameAudioListener, SpatialListener::new(0.2), Transform::default())); match Library::load(&config.asset_root) { Ok(mut library) => { - let mut samples: Vec<(&str, &[usize])> = cues::ALL.iter().map(|c| (c.bank, c.samples)).collect(); + let records: Vec<(&str, Vec)> = cues::RECORDS.iter().copied() + .chain(cues::BED_RECORDS.iter().map(|(r, _)| r)) + .map(|r| (r.bank, library.patch_samples(r.bank, r.id))).collect(); + let mut samples: Vec<(&str, &[usize])> = cues::ALL.iter().copied() + .chain(cues::BED_CUES.iter().map(|(c, _)| c)) + .map(|c| (c.bank, c.samples)).collect(); + samples.extend(records.iter().map(|(bank, ids)| (*bank, ids.as_slice()))); samples.extend(water::PRELOAD); let started = std::time::Instant::now(); let count = library.preload(&mut assets, &samples); @@ -201,7 +207,7 @@ mod tests { #[test] fn defaults_are_quiet_and_saved_values_are_bounded() { - assert_eq!(SavedSettings::default().master, 25); + assert_eq!(SavedSettings::default().master, 75); let loaded: SavedSettings = serde_json::from_str(r#"{"master":400,"ambience":33,"effects":7}"#).unwrap(); assert_eq!(loaded.validated(), SavedSettings { master: 100, ambience: 30, effects: 5 }); } diff --git a/crates/skate-game/src/game_audio/skate_events.rs b/crates/skate-game/src/game_audio/skate_events.rs index a18bc37f..e8e8e98a 100644 --- a/crates/skate-game/src/game_audio/skate_events.rs +++ b/crates/skate-game/src/game_audio/skate_events.rs @@ -13,8 +13,6 @@ use skate_core::{ /// Board ground speed (m/s) mapped to the fastest rolling band. const FULL_ROLL_SPEED: f32 = 10.0; -/// Landing impact speed (m/s) for full landing level. -const FULL_IMPACT: f32 = 12.0; /// Horizontal speed (m/s) above which on-foot steps use the running set. const RUN_SPEED: f32 = 3.0; /// Downward speed (m/s) a body needs for a water-entry splash, and the @@ -337,6 +335,10 @@ pub(super) struct Loops { /// Seconds the wheels have been off the ground (or the board stopped). roll_off: f32, grind: Loop, + /// Grinding last tick (start patch on the rising edge), and the countdown + /// to the next re-fired scrape piece on non-metal grinds. + was_grinding: bool, + grind_piece_next: f32, /// Countdown to the next shuffled powerslide / foot-drag piece. slide_next: f32, drag_next: f32, @@ -347,6 +349,8 @@ pub(super) struct Loops { patch_order: std::collections::HashMap<(&'static str, usize, usize), usize>, /// Last start time per one-shot cue, for `cues::min_gap`. last: std::collections::HashMap<&'static str, f64>, + /// Delayed retail layers: (due time, name, record, scale, position). + delayed: Vec<(f64, &'static str, &'static cues::Record, f32, Vec3)>, } fn random(rng: &mut u32) -> f32 { @@ -376,6 +380,17 @@ fn band_for(fraction: f32, bands: usize, current: Option) -> usize { } } +/// Play a retail layer now, or queue it for its retail offset (`Record::delay`). +#[allow(clippy::too_many_arguments)] +fn layer(player: &mut Player, loops: &mut Loops, now: f64, name: &'static str, record: &'static cues::Record, + scale: f32, at: Vec3, rng: &mut u32) { + if record.delay > 0.0 { + loops.delayed.push((now + f64::from(record.delay), name, record, scale, at)); + } else { + player.play_record(name, record, scale, at, rng, &mut loops.patch_order); + } +} + struct Player<'a, 'w, 's> { commands: Commands<'w, 's>, library: &'a mut Library, @@ -447,11 +462,14 @@ impl Player<'_, '_, '_> { let volume = record.level * scale.clamp(0.0, 1.0) * gain; let mut play = Play::effect(volume, at); play.pitch = pitch; + play.envelope = record.envelope; if self.voices.play(&mut self.commands, &clip, play, self.now).is_some() { played.push(sample); } } - info!("AUDIO_CUE {name} {}:record{id} samples={played:?}", record.bank); + if !name.is_empty() { + info!("AUDIO_CUE {name} {}:record{id} samples={played:?}", record.bank); + } } /// One-shot at `pitch` (playback speed), with the same small random spread. @@ -532,6 +550,12 @@ pub(super) fn play( let dt = time.delta_secs().clamp(0.0, 0.25); let now = time.elapsed_secs_f64(); let mut player = Player { commands, library: &mut library, voices: &mut voices, assets: &mut assets, now }; + // Layers whose retail offset has come due (frame-granular, <= one frame late). + let due: Vec<_> = loops.delayed.iter().filter(|d| d.0 <= now).cloned().collect(); + loops.delayed.retain(|d| d.0 > now); + for (_, name, record, scale, at) in due { + player.play_record(name, record, scale, at, &mut rng, &mut loops.patch_order); + } for event in events { // Safety net: a cue never repeats faster than its minimum gap. let name = event.name(); @@ -540,29 +564,55 @@ pub(super) fn play( } loops.last.insert(name, now); match event { - Event::Pop(at) => player.once("pop", &cues::POP, 1.0, at, &mut rng), - Event::Flip(at) => player.once("flip", &cues::FLIP, 1.0, at, &mut rng), - Event::Land { at, impact } => { - let scale = 0.3 + 0.7 * (impact / FULL_IMPACT).clamp(0.0, 1.0); - player.once("land", &cues::LAND, scale, at, &mut rng); - // Weight layer: from 1.5 m/s, louder and deeper (down to -20 % - // pitch) as the impact grows. - let heavy = ((impact - 1.5) / 7.5).clamp(0.0, 1.0); - if heavy > 0.0 { - player.once_pitched("land_heavy", &cues::LAND_HEAVY, 0.4 + 0.6 * heavy, 1.0 - 0.2 * heavy, at, &mut rng); + // Retail's measured layers per moment (cues.rs): pop crack + tail + // impact + deck knock; flip whoosh + feet catching the board; the + // wheels-down set + an impact family on landing. + Event::Pop(at) => { + for record in cues::POP { + layer(&mut player, loops, now, "pop", record, 1.0, at, &mut rng); } + let tail = &cues::POP_TAIL[(random(&mut rng) * 3.0) as usize % 3]; + layer(&mut player, loops, now, "pop_tail", tail, 1.0, at, &mut rng); + } + Event::Flip(at) => { + player.once("flip", &cues::FLIP, 1.0, at, &mut rng); + layer(&mut player, loops, now, "catch", &cues::CATCH, 1.0, at, &mut rng); + } + Event::Land { at, impact } => { + // Retail's board contact rule (cues.rs): hollow (wood) table or + // normal, each contact kind with its retail chance, variant by + // impact, gentle loudness rise with board speed. + let riding = cues.riding; + let hollow = cues::hollow(riding.surface); + let table = if hollow { &cues::LAND_HOLLOW } else { &cues::LAND_NORMAL }; + let variant = cues::land_tier(impact); + let scale = cues::land_scale(riding.speed); + let mut kinds: Vec = (0..4).filter(|&k| random(&mut rng) < cues::LAND_KIND_CHANCE[k]).collect(); + if kinds.is_empty() { + kinds.push(1); + } + info!("AUDIO_EVENT land impact={impact:.2} variant={variant} hollow={hollow} kinds={kinds:?} scale={scale:.2}"); + for kind in kinds { + layer(&mut player, loops, now, "land_impact", &table[kind][variant], scale, at, &mut rng); + } + layer(&mut player, loops, now, "land_cloth", &cues::LAND_CLOTH, scale, at, &mut rng); + player.play_record("land", &cues::LAND, scale, at, &mut rng, &mut loops.patch_order); } Event::BoardDown { at, impact } => { - // Set down gently: the soft sample, quietly. Jumped on (caveman): - // louder, plus a landing impact layer that grows with the impact. + // Set down gently: foot on deck, knock, light wheel touch. + // Jumped on (caveman): louder, plus the landing set. let (soft, heavy) = board_down_levels(impact); - player.once("board_down", &cues::BOARD_DOWN, soft, at, &mut rng); + player.play_record("board_down", &cues::BOARD_DOWN, soft, at, &mut rng, &mut loops.patch_order); + player.play_record("board_down_knock", &cues::BOARD_DOWN_KNOCK, soft, at, &mut rng, &mut loops.patch_order); + player.play_record("board_down_touch", &cues::BOARD_DOWN_TOUCH, soft, at, &mut rng, &mut loops.patch_order); if heavy > 0.0 { - player.once("board_down_heavy", &cues::LAND_HEAVY, heavy, at, &mut rng); + player.play_record("board_down_heavy", &cues::LAND, heavy, at, &mut rng, &mut loops.patch_order); } } Event::Bail { at, speed } => { let tier = cues::bail_tier(speed); + let scale = [0.6, 0.8, 1.0][tier]; + player.play_record("bail_hit", &cues::BAIL_HIT, scale, at, &mut rng, &mut loops.patch_order); let cue = cues::Cue { samples: cues::BAIL_TIERS[tier], ..cues::BAIL }; player.once(["bail_soft", "bail_medium", "bail_hard"][tier], &cue, 1.0, at, &mut rng); } @@ -639,8 +689,39 @@ pub(super) fn play( // Grinds, powerslides and foot drag loop while the state lasts. Braking is // reported in pulses, so it is held briefly. let dragging = r.braking && !r.airborne && !r.grinding; - let grind = if cues::metal(r.grind_surface) { &cues::GRIND_METAL } else { &cues::GRIND }; - player.hold(&mut loops.grind, r.grinding, grind, grind.level * (0.5 + 0.5 * fraction), r.board, &mut rng); + // Riding bed: retail's continuous small sounds while rolling (cues.rs), + // each source a random process at its measured rate x board speed. + let bed = cues::bed_rate(r.speed) * if r.rolling && !r.grinding && !r.airborne { 1.0 } else { 0.0 }; + if bed > 0.0 { + for (record, rate) in cues::BED_RECORDS { + if random(&mut rng) < rate * bed * dt { + player.play_record("", record, 1.0, r.board, &mut rng, &mut loops.patch_order); + } + } + for (cue, rate) in cues::BED_CUES { + if random(&mut rng) < rate * bed * dt { + player.once_pitched("", cue, 1.0, 1.0, r.board, &mut rng); + } + } + } + + // Grinds: retail's start patch, then on metal the GRINDS loop; on ledges + // and other surfaces short scrape pieces re-fired every ~63 ms. + let metal = cues::metal(r.grind_surface); + if r.grinding && !loops.was_grinding { + let start = if metal { &cues::GRIND_METAL_START } else { &cues::GRIND_START }; + player.play_record("grind_start", start, 0.5 + 0.5 * fraction, r.board, &mut rng, &mut loops.patch_order); + loops.grind_piece_next = cues::GRIND_PIECE_INTERVAL; + } + loops.was_grinding = r.grinding; + player.hold(&mut loops.grind, r.grinding && metal, &cues::GRIND_METAL, cues::GRIND_METAL.level * (0.5 + 0.5 * fraction), r.board, &mut rng); + if r.grinding && !metal { + loops.grind_piece_next -= dt; + if loops.grind_piece_next <= 0.0 { + loops.grind_piece_next += cues::GRIND_PIECE_INTERVAL; + player.play_record("", &cues::GRIND_PIECES, 0.5 + 0.5 * fraction, r.board, &mut rng, &mut loops.patch_order); + } + } let sliding = r.sliding && !r.grinding; player.shuffle("powerslide", &mut loops.slide_next, sliding, dt, &cues::POWERSLIDE, cues::POWERSLIDE_SHUFFLE, fraction, r.board, &mut rng); player.shuffle("foot_drag", &mut loops.drag_next, dragging, dt, &cues::FOOT_DRAG, cues::FOOT_DRAG_SHUFFLE, 0.3 + 0.7 * fraction, r.board, &mut rng); diff --git a/crates/skate-game/src/game_audio/voices.rs b/crates/skate-game/src/game_audio/voices.rs index 95453cf9..fb7e44a5 100644 --- a/crates/skate-game/src/game_audio/voices.rs +++ b/crates/skate-game/src/game_audio/voices.rs @@ -6,7 +6,10 @@ //! - a requested volume may raise a quiet clip only until the clip's own peak //! reaches full scale (and never more than x4); category and master volumes //! (both <= 1) then scale it down. No clip plays hotter than full scale x master; -//! - sounds start silent and fade in over at least `MIN_FADE`; +//! - loops and sounds asking for a fade start silent and fade in (>= `MIN_FADE`); +//! one-shots start at their level on the first sample — impacts/knocks are +//! 25-35 ms long, and a fade or per-frame gain ramp ate their attack (the +//! user heard pops/landings lose their layering); //! - at most `MAX_VOICES` voices, `MAX_PER_CLIP` of one sound, and a repeat of //! the same sound needs `MIN_REPEAT` seconds. use super::{AudioSettings, library::Clip}; @@ -39,10 +42,13 @@ pub(crate) struct Play { pub position: Option, pub looping: bool, pub fade_in: f32, + /// Gain over time after the start, (seconds, relative gain) points with + /// linear interpolation (retail's per-layer fader curves); None = flat. + pub envelope: Option<&'static [(f32, f32)]>, } impl Play { pub(crate) fn effect(volume: f32, position: Vec3) -> Self { - Self { category: Category::Effects, volume, pitch: 1.0, position: Some(position), looping: false, fade_in: MIN_FADE } + Self { category: Category::Effects, volume, pitch: 1.0, position: Some(position), looping: false, fade_in: MIN_FADE, envelope: None } } } @@ -65,6 +71,26 @@ struct Voice { attack_elapsed: f32, stopping: Option<(f32, f32)>, pending: f32, + envelope: Option<&'static [(f32, f32)]>, + /// Seconds played (not counting silenced time), for the envelope. + age: f32, +} + +/// Relative gain of `envelope` at `age` seconds (linear between points, +/// held before the first and after the last). +fn envelope_at(envelope: Option<&[(f32, f32)]>, age: f32) -> f32 { + let Some(points) = envelope.filter(|p| !p.is_empty()) else { return 1.0 }; + if age <= points[0].0 { + return points[0].1; + } + for pair in points.windows(2) { + let ((t0, g0), (t1, g1)) = (pair[0], pair[1]); + if age <= t1 { + let span = (t1 - t0).max(1e-6); + return g0 + (g1 - g0) * (age - t0) / span; + } + } + points[points.len() - 1].1 } #[derive(Resource, Default)] @@ -72,6 +98,10 @@ pub(crate) struct Voices { voices: Vec, next: u64, last_start: std::collections::HashMap, f64>, + /// Category x master gain per category and the silenced flag, as of the + /// last `sync`, so one-shots can start at their final level. + mix: [f32; 2], + silenced: bool, } /// Largest volume for a clip whose loudest sample is `peak` (0..1 of full scale). @@ -101,12 +131,21 @@ impl Voices { } self.last_start.insert(clip.key.clone(), now); let speed = clamp_pitch(play.pitch); + let max = max_volume(clip.peak); + let volume = clamp_volume(play.volume, max); + // One-shots start at their final level (no fade, no ramp); loops and + // faded sounds start silent until `sync` applies the faded-in gain. + let instant = !play.looping && play.fade_in <= MIN_FADE; + let initial = if instant { + volume * envelope_at(play.envelope, 0.0) * self.mix[play.category as usize] + } else { + 0.0 + }; let settings = PlaybackSettings { mode: if play.looping { PlaybackMode::Loop } else { PlaybackMode::Once }, - // Silent until `sync` applies the faded-in gain. - volume: Volume::Linear(0.0), + volume: Volume::Linear(initial), speed, - paused: true, + paused: !instant || self.silenced, spatial: play.position.is_some(), spatial_scale: Some(SpatialScale::new(SPATIAL_SCALE)), ..Default::default() @@ -117,9 +156,10 @@ impl Voices { self.next += 1; self.voices.push(Voice { id, entity, key: clip.key.clone(), category: play.category, looping: play.looping, - volume: clamp_volume(play.volume, max_volume(clip.peak)), max_volume: max_volume(clip.peak), pitch: speed, gain: 0.0, speed, - position: play.position, attack: play.fade_in.max(MIN_FADE), attack_elapsed: 0.0, - stopping: None, pending: 0.0, + volume, max_volume: max, pitch: speed, gain: if instant { volume } else { 0.0 }, speed, + position: play.position, attack: play.fade_in.max(MIN_FADE), + attack_elapsed: if instant { MIN_FADE } else { 0.0 }, + stopping: None, pending: 0.0, envelope: play.envelope, age: 0.0, }); Some(id) } @@ -184,6 +224,10 @@ pub(super) fn sync( let dt = time.delta_secs().clamp(0.0, 0.25); let silenced = super::silenced(menu.as_deref(), &replay); let master = settings.master().clamp(0.0, 1.0); + for category in [Category::Ambience, Category::Effects] { + voices.mix[category as usize] = settings.category(category).clamp(0.0, 1.0) * master; + } + voices.silenced = silenced; let mut finished = Vec::new(); for v in &mut voices.voices { let Ok((sink, spatial, mut playback, mut transform)) = sinks.get_mut(v.entity) else { @@ -193,7 +237,7 @@ pub(super) fn sync( continue; }; if let Some(position) = v.position { transform.translation = position; } - if !silenced { v.attack_elapsed += dt; } + if !silenced { v.attack_elapsed += dt; v.age += dt; } let attack = (v.attack_elapsed / v.attack).min(1.0); let mut release = 1.0; if let Some((remaining, total)) = &mut v.stopping { @@ -205,7 +249,8 @@ pub(super) fn sync( v.gain += (v.volume - v.gain) * (1.0 - (-dt / 0.012).exp()); v.speed += (v.pitch - v.speed) * (1.0 - (-dt / 0.025).exp()); let category = settings.category(v.category).clamp(0.0, 1.0); - let gain = (v.gain * attack * release).clamp(0.0, v.max_volume) * category * master; + let envelope = envelope_at(v.envelope, v.age); + let gain = (v.gain * attack * release * envelope).clamp(0.0, v.max_volume) * category * master; let speed = v.speed.clamp(0.25, 4.0); // Before Bevy creates the sink, keep its initial settings current. playback.volume = Volume::Linear(gain); @@ -244,6 +289,28 @@ mod tests { Clip { handle: Handle::default(), key: name.into(), peak: 1.0 } } + #[test] + fn one_shots_start_at_their_level_and_loops_fade_in() { + let mut world = World::new(); + let mut voices = Voices { mix: [0.5, 0.25], ..Default::default() }; + let mut queue = bevy::ecs::world::CommandQueue::default(); + let mut commands = Commands::new(&mut queue, &world); + let shot = voices.play(&mut commands, &clip("knock"), Play::effect(0.8, Vec3::ZERO), 0.0).unwrap(); + let mut looped = Play::effect(0.8, Vec3::ZERO); + looped.looping = true; + let lp = voices.play(&mut commands, &clip("loop"), looped, 0.0).unwrap(); + queue.apply(&mut world); + let level = |id| voices.voices.iter().find(|v| v.id == id).map(|v| (v.gain, v.entity)).unwrap(); + let (gain, entity) = level(shot); + let settings = world.get::(entity).unwrap(); + assert_eq!(gain, 0.8); + assert_eq!(settings.volume, Volume::Linear(0.8 * 0.25)); + assert!(!settings.paused); + let (gain, entity) = level(lp); + assert_eq!(gain, 0.0); + assert!(world.get::(entity).unwrap().paused); + } + #[test] fn requested_levels_are_clamped() { assert_eq!(clamp_volume(3.0, 1.0), 1.0); diff --git a/crates/skate-game/src/game_audio/water.rs b/crates/skate-game/src/game_audio/water.rs index 2a60a41e..8b9bb5ba 100644 --- a/crates/skate-game/src/game_audio/water.rs +++ b/crates/skate-game/src/game_audio/water.rs @@ -188,7 +188,7 @@ pub(super) fn update( if emitter.voice.as_ref().is_some_and(|(id, _)| !voices.playing(*id)) { emitter.voice = None; } - let mut play = Play { category: Category::Ambience, volume, pitch: 1.0, position: Some(at), looping: false, fade_in: 0.4 }; + let mut play = Play { category: Category::Ambience, volume, pitch: 1.0, position: Some(at), looping: false, fade_in: 0.4, envelope: None }; // Short pieces, overlapping with soft fade-ins, so the water keeps // moving: fountains splash and flow (not still-water lapping, which // the user rejected for fountains), canals and lakes lap. diff --git a/docs/hails-additions/11-audio.md b/docs/hails-additions/11-audio.md index 0d36053a..e1c199d3 100644 --- a/docs/hails-additions/11-audio.md +++ b/docs/hails-additions/11-audio.md @@ -72,7 +72,7 @@ archives readable with `tools/owned_game/big.py`. | File | Role | |---|---| -| `mod.rs` | `AudioSettings` (master **25 % by default**, ambience 100 %, effects 100 %, 5 % steps) saved to `settings/audio.json`; menu rows in GRAPHICS; `--mute`; `GlobalVolume` follows the master so mod audio obeys it; the single `SpatialListener` (moved out of `modding/audio.rs`) follows the gameplay camera; preloads every cue sample, grain band, wheel spin and water piece at startup (~207 clips, ~35 ms) so riding never reads the disk. | +| `mod.rs` | `AudioSettings` (master **75 % by default** — 100 % is unity gain; 25 % (−12 dB) was too quiet to judge retail parity in play, user 2026-10-02 —, ambience 100 %, effects 100 %, 5 % steps) saved to `settings/audio.json`; menu rows in GRAPHICS; `--mute`; `GlobalVolume` follows the master so mod audio obeys it; the single `SpatialListener` (moved out of `modding/audio.rs`) follows the gameplay camera; preloads every cue sample, grain band, wheel spin and water piece at startup (~207 clips, ~35 ms) so riding never reads the disk. | | `library.rs` | Loads the manifest; reads WAVs into `Assets`, measuring each clip's peak; rejects paths outside the audio folder; warns once per missing file. | | `voices.rs` | Voice pool and **loudness rules** (below). Everything pauses while the menu is open or a replay runs. | | `cues.rs` | The sample table, per-cue minimum gaps, metal-surface test, surface → grain table. | @@ -96,7 +96,10 @@ Outside the module (observation only; physics never reads any of it): - A requested volume may raise a quiet clip only until the clip's own peak reaches full scale, and never more than ×4; category and master volumes (both ≤ 1) then scale it down. No clip plays hotter than full scale × master. -- Sounds start silent and fade in (≥ 10 ms); at most 32 voices, 3 of one sound, same sound again only +- One-shots start at their final level on the first sample; loops and sounds that ask for a fade start + silent and fade in (≥ 10 ms). (Until 2026-10-01 every sound faded in and its gain was raised once per + frame from 0, which ate the attack of 25–35 ms impacts and knocks — pops and landings lost their + layering.) Retail layer offsets are kept with `Record::delay` (frame-granular). At most 32 voices, 3 of one sound, same sound again only after 40 ms; each cue also has a minimum gap (`cues::min_gap`: step 0.2 s, run step 0.15, push 0.3, bail 0.5, splash 1.0, others 0.12) as a safety net against flags that stay set for many ticks. - One-shots get a random ±4 % pitch. Spatial attenuation scale 0.1 (Bevy's attenuation never amplifies). @@ -107,18 +110,17 @@ Outside the module (observation only; physics never reads any of it): | Cue | Trigger (per physics tick) | Samples | Level × scale | In play | |---|---|---|---|---| -| Pop | `ground_animation.launched` rising | `Skate_Collisions` 1074–1078 | 0.45 | **Confirmed** | -| Flip / grab whoosh | new trick name while airborne (a held grab re-announced by scoring stays silent) | `Sk8_Air_Flip_Tricks` 10–13 | 0.3 | Behaviour **confirmed**; samples never auditioned separately | -| Land | filtered category Air → Ground, air not begun on foot; impact = `riding.ground.maximum_closing_speed` | `Skate_Collisions` 1088–1092 | 0.9 × (0.3 + 0.7·impact/12) | **Confirmed** | -| Land weight layer | same, impact > 1.5 m/s | `Skate_Collisions` 1097–1100 | 1.0 × (0.4–1.0 over 1.5–9 m/s), pitch 1.0 → 0.8 | Not yet confirmed ("not thumpy enough" before the last change) | -| Board down | Air → Ground when the air phase began on foot (on foot within the last 0.5 s) | `Skate_Collisions` 0–2 | 0.3 × (0.3–1.0 over 0–5 m/s) | Gentle set-down **confirmed** | -| Board down heavy | same, impact > 2.5 m/s ("caveman" jump-on) | `Skate_Collisions` 1097–1100 | 1.0 × (impact − 2.5)/4 | Not yet confirmed | -| Grind (loop) | `grinds.grinding_316` or a grind state, and not `leaving_317`; stop fade 0.06 s | `GRINDS` 54–56 on metal (`grinds.audio_surface_216` ∈ 9, 11–40, 67–69, 85, 89, 91), else 9/11 | 0.3 × (0.5 + 0.5·fraction) | Timing "mostly right"; metal pick (by measurement) not yet confirmed | +| Pop | `ground_animation.launched` rising | retail recipe: knock 1112 + 878 + 891 at once, crack 1096 + one of 1097-1099 at +20 ms, body 1111 +30 ms, low thud 876 +40 ms (traced offsets) | retail median × 2 | Rebuilt from 57 traced pops + captured sound; not yet confirmed | +| Flip / grab whoosh + catch | new trick name while airborne (a held grab re-announced by scoring stays silent) | `Sk8_Air_Flip_Tricks` 1–3 (retail's Class_Flips) + container 1115 (feet catching the board) | 0.45 / 0.8 | Retail mapping new | +| Land | filtered category Air → Ground, air not begun on foot; impact = `riding.ground.maximum_closing_speed` | container 1095 (wheels down, ~1 s) + impact family 1055 / 1058 / 1052 by impact (< 3, < 6, ≥ 6 m/s) | (0.6; 0.5 / 0.64 / 0.72) × (0.3 + 0.7·impact/12) | Retail mapping new; the impact-family choice is ours | +| Board down | Air → Ground when the air phase began on foot (on foot within the last 0.5 s) | container 1126 (foot on deck) + 1119 (knock) + 1054 (wheel touch) | (0.6 / 0.54 / 0.28) × (0.3–1.0 over 0–5 m/s) | Retail mapping new | +| Board down heavy | same, impact > 2.5 m/s ("caveman" jump-on) | + landing set 1095 | 0.6 × (impact − 2.5)/4 | Retail mapping new | +| Grind | `grinds.grinding_316` or a grind state, and not `leaving_317` | start: container 954 (ledge etc.) or 1146 (metal rail object); then metal: `GRINDS` 54–56 loop; other surfaces: container 955 pieces (202–206) re-fired every 63 ms | start 0.5 / 0.8, loop 0.26, pieces 0.3; × (0.5 + 0.5·fraction) | Retail mapping new | | Powerslide | state 101 SlideGround: random piece every ~0.08 s (±20 %), 10 ms fade-in | `WHEEL_SKID_BANK` 0–7 | 0.2 × fraction | Not yet confirmed | | Foot drag | between `brake_contact` foot-down ticks (ends at foot-up or 0.1 s after the last foot-down), riding on the ground above 0.5 m/s; piece every ~0.11 s, 50 ms fade-in | `FOOT_DRAG` 36–39, 44–47, 60–64 | 0.25 × (0.3 + 0.7·fraction) | **Confirmed** | | Push | `push_contact` rising edge | `fstep_skateshoe1_sm` 74–84 | 0.6 | Plays at a sane rate (no flood); sound not judged separately | | Step / run step | foot strike (below) while on foot (states 500/501) and moving > 0.3 m/s; run set above 3 m/s | `fstep_skateshoe1_sm` 74–84 / 6–17 | 0.85 / 0.68 × (`AudibleFootStepStrength`/4, 0.3–1.0) | **Confirmed** ("almost perfect") | -| Bail | entering WipeoutGround (300), not in water and no splash in the last 0.5 s; tier by body speed (≥ 3.5 / ≥ 7 m/s) | `Bodyslide` 0–4 / 5–9 / 10–15 | 0.25 (sweetener layer) | Tiers heard as right; main body-impact sound still open | +| Bail | entering WipeoutGround (300), not in water and no splash in the last 0.5 s; tier by body speed (≥ 3.5 / ≥ 7 m/s) | body hits container 1074 (records 667–671) + `Bodyslide` 0–4 / 5–9 / 10–15 layer | 0.52 × (0.6 / 0.8 / 1.0) + 0.25 | Bodyslide tiers heard as right; body hits = retail mapping, new | | Splash | body water contact (`collision_feedback.flags.material_12`) rising while falling > 1 m/s (1.5 s cooldown); or entering a wipeout in water with no splash in the last 1 s (speed ≥ 2) | `Skate_Collisions` 476–478 | 0.5 × (0.4 + 0.6·speed/8) | **Confirmed** (found by the user); the wading-wipeout path not yet confirmed | | Rolling (loop) | wheels in contact, not grinding, > 0.3 m/s; grain by majority wheel audio surface; speed band | `grains/_hard/` | 0.15 × min(speed/1.5, 1) | **Confirmed** | | Wheel spin | take-off above 1 m/s, cut on landing (0.05 s) | `Whls_spins_Jump_1` | 0.15 × fraction | Not yet confirmed | @@ -137,7 +139,29 @@ within 2 cm. `AudibleFootStepStrength` only scales the volume. `tools/vendor/university/…/owned_world_material_addon/__init__.py`): 1 asphalt_smooth; 2 asphalt_rough; 4, 66 concrete_rough; 5, 8, 10, 55, 56 concrete_aggregate (aggregate, dirt, grass, leaves, bush); 6, 7, 41–46, 90 wood_ramp; metal IDs → metal_smooth; everything else (Concrete_Polished = 3, curbs, tile, -marble, unlisted) concrete_smooth. Only the "hard" grains exist for every surface, so those are used. +marble, unlisted) concrete_smooth. Only the "hard" grains exist for every surface, so those are used; +retail was measured playing the same "hard" grains (concrete_smooth/wood_ramp/asphalt_rough/metal_smooth). + +### Measured retail mapping (2026-10-01) +The pop/flip/landing/board-down/grind/bail cues above use the retail SPLC patches that a **local +recompiled build** of the game (github.com/mchughalex/skate3recomp, built and run locally only, no code +taken) was measured playing for the same moments. The recomp was instrumented to log every sample start +with its final gain (gain modules × send), every SPLC patch request with the gameplay function that asked, +and every audio-object post; the user played normally while it logged, and the events were grouped by +the game's own requesting function. Exact moments came from a scripted ollie/kickflip: + +| time after the flick | retail plays | +|---|---| +| +0.24 s (pop) | containers 1096 + 1098/1099, flip object (Sk8_Air_Flip_Tricks 1–3) | +| +0.3–0.6 s (catch) | deck knocks 1112 / 1115 / 1118 (548–552 + 530–535) | +| +0.95 s (landing) | 1095 (396–404, ~1 s) + one impact family of 1052/1055/1058 + wheel skid | +| getting on the board | 1126 (422–426) + 1054 + 1095 + knock 1119 | + +Measured levels (linear, retail mix): knocks 0.36–0.57, pop/landing sets 0.2–0.46, board-down 1126 up to +0.76, ledge grind pieces ≤ 0.35, rail start 0.49, bail hits ≤ 0.26, Bodyslide ≤ 0.19, GRINDS ≤ 0.13, +rolling grains p90 0.065. Our levels = retail × 2.0 (`cues::RETAIL_SCALE`), calibrated so retail's +rolling level lands on our play-tested `ROLL_LEVEL`. Not decoded yet: how retail picks among the impact +families 1050–1059 (surface/force); we choose by impact. ### Water emitters (`water.rs`)