mirror of
https://github.com/SK8-ENGINE/skate-3-rust-engine.git
synced 2026-10-26 19:47:44 +00:00
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).
This commit is contained in:
8 files changed
+443
-97
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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<usize> = 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]
|
||||
|
||||
Binary file not shown.
@@ -8,7 +8,7 @@
|
||||
//! (docs/hails-additions/11-audio.md); EA's AEMS event runtime is not
|
||||
//! re-implemented.
|
||||
//!
|
||||
//! Loudness is deliberately conservative: master volume defaults to 25%,
|
||||
//! Loudness is deliberately conservative: master volume defaults to 75%,
|
||||
//! a cue may raise a quiet clip only until the clip's own peak reaches full
|
||||
//! scale (at most x4) before the master and category volumes (both <= 1)
|
||||
//! scale it down (voices.rs), sounds fade in, voice counts are capped, and nothing plays while
|
||||
@@ -39,7 +39,7 @@ struct SavedSettings {
|
||||
}
|
||||
impl Default for SavedSettings {
|
||||
fn default() -> 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<crate::config::Config>, 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<usize>)> = 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 });
|
||||
}
|
||||
|
||||
@@ -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>) -> 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<usize> = (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);
|
||||
|
||||
@@ -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<Vec3>,
|
||||
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<Voice>,
|
||||
next: u64,
|
||||
last_start: std::collections::HashMap<std::sync::Arc<str>, 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::<PlaybackSettings>(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::<PlaybackSettings>(entity).unwrap().paused);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requested_levels_are_clamped() {
|
||||
assert_eq!(clamp_volume(3.0, 1.0), 1.0);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<AudioSource>`, 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/<surface>_hard/<band>` | 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`)
|
||||
|
||||
|
||||
Reference in new issue
Block a user