diff --git a/crates/skate-audio/src/bus/mod.rs b/crates/skate-audio/src/bus/mod.rs index 5b344fc6..17a4f475 100644 --- a/crates/skate-audio/src/bus/mod.rs +++ b/crates/skate-audio/src/bus/mod.rs @@ -9,6 +9,7 @@ pub mod env; pub mod eqchain; pub mod flange; +pub mod speech_echo; pub mod submix; pub mod zones; @@ -52,6 +53,8 @@ pub struct Buses { pub flange: flange::FlangeReturns, /// The FootStep SubMix graphs (`player::footsteps`). pub submix: submix::FootSubmixes, + /// The speech stream slots' echo submixes (`world::speech_player`). + pub speech_echo: speech_echo::SpeechEchoes, } impl Default for Buses { @@ -65,6 +68,7 @@ impl Default for Buses { flange_active: [false; 2], flange: flange::FlangeReturns::default(), submix: submix::FootSubmixes::default(), + speech_echo: speech_echo::SpeechEchoes::default(), } } } @@ -100,6 +104,7 @@ impl Buses { /// SFX Master. pub fn render(&mut self, master: &mut [[f32; BLOCK]; 6]) { self.submix.render(&mut self.env_in, master); + self.speech_echo.render(&mut self.env_in); if self.flange.enabled() { for (k, r) in self.flange.returns.iter_mut().enumerate() { r.render(&mut self.flange_in[k], self.flange_active[k], &mut self.env_in, master); diff --git a/crates/skate-audio/src/bus/speech_echo.rs b/crates/skate-audio/src/bus/speech_echo.rs new file mode 100644 index 00000000..55cc2a9a --- /dev/null +++ b/crates/skate-audio/src/bus/speech_echo.rs @@ -0,0 +1,132 @@ +//! The speech stream slots' echo submixes (`sub_82C5E2D8`, built once per stream slot; parameters +//! posted every frame by `sub_82C5CEF0`; spec `audio-specs/world-speech.md` "Speech details +//! resolved"): `Sub0 → HI20 → Del0 → LI20 → Pn21 → Sen0 (→ the environment bus)`. +//! +//! The slot's speech voice feeds Sub0 through its pre-gain Send (the owner's level 21 for a ped, +//! 13 for a skater, × the voice's float). Per frame the stream system posts HI20 = the owner's +//! filter 22 (skater 14), LI20 = filter 23 (skater 15) and, when it changed, Del0's delay = the +//! camera distance × the speech record's factor / 344 m/s, at most 0.15 s. Pn21 and Sen0 keep their +//! class defaults (azimuth 0, level 1.0). +//! +//! Modelled mono: the graph's two channels carry the one mono speech signal and the env bus input +//! is mono, so the Pn21 → Sen0 path is a unity tap (the two-channel routing's exact gains are not +//! traced). The graph keeps its filter history and delay line between lines, as retail's persists. +use super::env::Level; +use crate::dsp::biquad::{Iir2, Kind}; +use crate::dsp::delay::Delay; +use crate::{BLOCK, MIX_RATE}; + +/// Echo slots: retail builds one per stream slot (3 speech channels × 2 streams). +pub const SLOTS: usize = 6; +/// Del0's line length (s): the delay is posted at most 0.15 s (`0x820964B4`). +pub const MAX_DELAY: f32 = 0.15; + +#[derive(Clone, Debug)] +pub struct SpeechEcho { + /// Sub0: the voices' sends add here (mono). + pub input: Box<[f32; BLOCK]>, + hpf: Iir2, + delay: Delay, + lpf: Iir2, + env: Level, + /// Something was sent into it since it was built (the graph renders from then on). + active: bool, +} + +impl Default for SpeechEcho { + fn default() -> Self { + Self { + input: Box::new([0.0; BLOCK]), + hpf: Iir2::new(Kind::HighPass), + delay: Delay::new(MAX_DELAY, 1), + lpf: Iir2::new(Kind::LowPass), + env: Level::running(1.0), + active: false, + } + } +} + +/// What the stream system posts to a slot's echo each frame. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct EchoParams { + pub high_pass: f32, + pub low_pass: f32, + /// Del0 (s); None = unchanged since the last post. + pub delay: Option, +} + +impl SpeechEcho { + pub fn set(&mut self, p: &EchoParams) { + self.hpf.cutoff = p.high_pass; + self.lpf.cutoff = p.low_pass; + if let Some(d) = p.delay { + self.delay.delay = d.min(MAX_DELAY); + } + self.active = true; + } + + fn render(&mut self, env_in: &mut [f32; BLOCK]) { + if !self.active { + return; + } + let mut mono = *self.input; + self.input.fill(0.0); + { + let mut planes: [&mut [f32]; 1] = [&mut mono[..]]; + self.hpf.process(&mut planes, MIX_RATE as f32); + self.delay.process(&mut planes); + self.lpf.process(&mut planes, MIX_RATE as f32); + } + self.env.add(&mono, &mut env_in[..]); + } +} + +/// The slots' graphs. +#[derive(Clone, Debug, Default)] +pub struct SpeechEchoes { + pub slots: Vec, +} + +impl SpeechEchoes { + pub fn slot(&mut self, i: usize) -> Option<&mut SpeechEcho> { + if i >= SLOTS { + return None; + } + if self.slots.len() < SLOTS { + self.slots.resize_with(SLOTS, SpeechEcho::default); + } + self.slots.get_mut(i) + } + + pub fn render(&mut self, env_in: &mut [f32; BLOCK]) { + for s in self.slots.iter_mut() { + s.render(env_in); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_echo_delays_and_filters_its_input_into_the_env_bus() { + let mut e = SpeechEchoes::default(); + let s = e.slot(1).unwrap(); + s.set(&EchoParams { high_pass: 0.0, low_pass: 96_000.0, delay: Some(0.01) }); + s.input[0] = 1.0; + let mut out = Vec::new(); + for _ in 0..4 { + let mut env = [0.0f32; BLOCK]; + e.render(&mut env); + out.extend_from_slice(&env); + } + // The impulse comes out 480 samples (10 ms) later. + let at = out.iter().position(|v| v.abs() > 0.5).unwrap(); + assert_eq!(at, 480); + assert!(e.slot(7).is_none()); + // A capped delay. + e.slot(0).unwrap().set(&EchoParams { delay: Some(1.0), ..Default::default() }); + assert_eq!(e.slots[0].delay.delay, MAX_DELAY); + } +} diff --git a/crates/skate-audio/src/mixer.rs b/crates/skate-audio/src/mixer.rs index 3531bd42..9b68b3f0 100644 --- a/crates/skate-audio/src/mixer.rs +++ b/crates/skate-audio/src/mixer.rs @@ -18,6 +18,7 @@ use std::sync::Arc; use crate::dsp::biquad::{Iir2, Kind}; use crate::dsp::gain::Gain; use crate::dsp::pan::{self, Pan2D}; +use crate::dsp::peaking::PeakingIir2; use crate::dsp::resample::{Resampler, ratio}; use crate::dsp::routes::to_six; use crate::dsp::send::{Mode, Send, fold_release}; @@ -122,6 +123,10 @@ struct Voice { /// A direct voice's own Send A level (speech streams, [`Mixer::set_direct_dsp`]): replaces /// master × FXWET0 when set. env_direct: Option, + /// The speech stream graph ([`Mixer::set_stream_dsp`]): `Resample → PI20 → Send A → Gain → + /// HI20 → LI20 → Pan2D1 → Send` (`sub_82C5C318`) instead of the voice graph's filters before + /// Send A. None for every other voice. + stream: Option>, /// Stop fade: (source index where it starts, per-channel last sample). fade: Option<(u64, [f32; 8])>, done: bool, @@ -164,6 +169,23 @@ impl Voice { at as f64 / f64::from(self.header.rate) } + /// The environment send: the channels summed to mono into the env bus at the voice's level. + fn env_send(&mut self, src: &[[f32; BLOCK]; 6], channels: usize, buses: &mut Buses, user: f32) { + self.env.target = match self.env_direct { + Some(level) => level * user, + None => self.master * self.fx * user, + }; + if !self.env.silent() { + let mut mono = [0.0f32; BLOCK]; + for c in src.iter().take(channels.min(5)) { + for (m, x) in mono.iter_mut().zip(c.iter()) { + *m += x; + } + } + self.env.add(&mono, &mut buses.env_in[..]); + } + } + fn render(&mut self, master: &mut [[f32; BLOCK]; 6], buses: &mut Buses, user: f32) { let channels = (self.header.channels as usize).clamp(1, 6); let mut src = [[0.0f32; BLOCK]; 6]; @@ -184,28 +206,49 @@ impl Voice { // `tests/render_alloc.rs`). { let mut planes = src.each_mut().map(|c| &mut c[..]); - self.hpf.process(&mut planes[..channels], MIX_RATE as f32); - self.lpf.process(&mut planes[..channels], MIX_RATE as f32); + match self.stream.as_deref_mut() { + // The speech stream graph: the PEAK first, the filters after the gain. + Some(stream) => stream.peak.process(&mut planes[..channels], MIX_RATE as f32), + None => { + self.hpf.process(&mut planes[..channels], MIX_RATE as f32); + self.lpf.process(&mut planes[..channels], MIX_RATE as f32); + } + } } // Send A: the channels summed to mono (routes N → 1 at unity, LFE dropped), our user volume // applied like on the dry path. - self.env.target = match self.env_direct { - Some(level) => level * user, - None => self.master * self.fx * user, - }; - if !self.env.silent() { - let mut mono = [0.0f32; BLOCK]; - for c in src.iter().take(channels.min(5)) { - for (m, x) in mono.iter_mut().zip(c.iter()) { - *m += x; + if self.stream.is_none() { + self.env_send(&src, channels, buses, user); + } else if let Some(stream) = self.stream.as_deref_mut() + && let Some(slot) = stream.echo_slot + { + // The pre-gain Send into the stream slot's echo submix (mono sum, as Send A). + stream.echo.target = stream.echo_level * user; + if !stream.echo.silent() + && let Some(echo) = buses.speech_echo.slot(usize::from(slot)) + { + let mut mono = [0.0f32; BLOCK]; + for c in src.iter().take(channels.min(5)) { + for (m, x) in mono.iter_mut().zip(c.iter()) { + *m += x; + } } + stream.echo.add(&mono, &mut echo.input[..]); } - self.env.add(&mono, &mut buses.env_in[..]); } { let mut planes = src.each_mut().map(|c| &mut c[..]); self.gain.target = self.master * self.dry; self.gain.process(&mut planes[..channels]); + if self.stream.is_some() { + self.hpf.process(&mut planes[..channels], MIX_RATE as f32); + self.lpf.process(&mut planes[..channels], MIX_RATE as f32); + } + } + // The speech stream's environment send is its post-filter Send (`sub_82C5CEF0` posts it the + // owner's second level; its pre-gain Send feeds the stream slot's echo submix, not ported). + if self.stream.is_some() { + self.env_send(&src, channels, buses, user); } // Send B (`sub_824A3140`: only with an enabled effect record; level posted 0 at open, then // property 11 / 32767), our user volume applied like on the other paths. @@ -262,6 +305,22 @@ impl Voice { } } +//// A speech stream voice's own modules (`sub_82C5C318`'s graph): the PEAK and the pre-gain Send +/// into its slot's echo submix. +#[derive(Clone, Debug)] +struct StreamDsp { + peak: PeakingIir2, + echo_slot: Option, + echo_level: f32, + echo: Level, +} + +impl Default for StreamDsp { + fn default() -> Self { + Self { peak: PeakingIir2::default(), echo_slot: None, echo_level: 0.0, echo: Level::new(0.0) } + } +} + /// A 6-channel send into a mono submix that sends on into its bus's centre: routes 6 → 1 (L, C, /// R, Ls, Rs at unity, LFE dropped; image tables 0x820ED700 / 0x820ED780) then 1 → 6 (centre). const SIX_TO_MONO_CENTRE: &[crate::dsp::routes::Route] = &[(0, 1, 1.0), (1, 1, 1.0), (2, 1, 1.0), (3, 1, 1.0), (4, 1, 1.0)]; @@ -425,6 +484,7 @@ impl Mixer { group, fade: None, env_direct: None, + stream: None, done: false, }); Some(self.next) @@ -452,6 +512,34 @@ impl Mixer { } } + /// A speech stream's per-frame values (`world::speech_player::VoiceParams`): the voice takes + /// the stream graph (PEAK, Send A, Gain, then the filters; `sub_82C5C318`) from now on. + /// `peak` = (centre Hz, linear gain, Q), `env` = Send A. + pub fn set_stream_dsp(&mut self, voice: u32, hpf: f32, lpf: f32, env: f32, peak: [f32; 3]) { + if let Some(v) = self.voice(voice) { + v.hpf.cutoff = hpf; + v.lpf.cutoff = lpf; + v.env_direct = Some(env); + let s = v.stream.get_or_insert_with(Default::default); + s.peak.freq = peak[0]; + s.peak.gain = peak[1]; + s.peak.q = peak[2]; + } + } + + /// A speech stream's echo send: its pre-gain Send into echo slot `slot` (`crate::bus::speech_echo`) + /// at `level`, and the slot's posted values. + pub fn set_stream_echo(&mut self, voice: u32, slot: u8, level: f32, params: &crate::bus::speech_echo::EchoParams) { + if let Some(v) = self.voice(voice) { + let s = v.stream.get_or_insert_with(Default::default); + s.echo_slot = Some(slot); + s.echo_level = level; + } + if let Some(e) = self.buses.speech_echo.slot(usize::from(slot)) { + e.set(params); + } + } + /// Register (or replace) one sample of a bank: streamed sounds (speech takes) are decoded on /// demand instead of with the whole bank. pub fn set_bank_sample(&mut self, bank: usize, slot: u16, header: SampleHeader, pcm: Arc) { @@ -593,6 +681,7 @@ impl VoiceHost for Mixer { group, fade: None, env_direct: None, + stream: None, done: false, }); Some(self.next) diff --git a/crates/skate-audio/src/world/mod.rs b/crates/skate-audio/src/world/mod.rs index 4c1d4afa..b83d1f11 100644 --- a/crates/skate-audio/src/world/mod.rs +++ b/crates/skate-audio/src/world/mod.rs @@ -13,7 +13,8 @@ //! `SFXObj_TrafficHorn` (`TRAFFIC_HORN`, `c_car_alarm`) and `SFXObj_TrafficSkids` //! (`TRAFFIC_SKID`); //! - [`peds`]: `SFXObj_PedestrianSFX`'s footsteps (`livingword_footstep` packets and the -//! `sk8_foley` Splice steps) and `SFXObj_PedestrianSpeech`'s speech requests; +//! `sk8_foley` Splice steps), `SFXObj_PedestrianSpeech`'s speech requests (and its phone ring), +//! `SFXObj_PedBodyFall`'s fall one-shots and `SFXObj_Tazer`'s `c_tazer` packet; //! - [`skaters`]: NPC (AI) skaters' board sounds — the local player's components for the Player //! slot's second instance, which retail gives to one NPC skater within 30 m of the camera; //! - [`speech`]: the streamed speech archives (`livingworldspeech.big`): the clip index and the @@ -32,6 +33,7 @@ pub mod keys; pub mod owners; pub mod peds; +pub mod skater_speech; pub mod skaters; pub mod speech; pub mod speech_manager; @@ -52,6 +54,8 @@ pub enum WorldSlot { Skid, /// `livingword_footstep`: foot A (PedestrianSFX `+40`) = 0, foot B (`+224`) = 1. PedFootstep(u8), + /// `c_tazer` (SFXObj_Tazer `+36`, while the ped audio state's tazer byte `+80` is set). + PedTazer, } /// A world owner: the game object (vehicle or ped id the engine system hands out) and the slot. diff --git a/crates/skate-audio/src/world/peds.rs b/crates/skate-audio/src/world/peds.rs index 96f7318f..96fbd784 100644 --- a/crates/skate-audio/src/world/peds.rs +++ b/crates/skate-audio/src/world/peds.rs @@ -10,13 +10,18 @@ //! these (recomp `all_20261002_164620`: 1017 events). //! - [`PedSpeech`] = `SFXObj_PedestrianSpeech` (vtable `0x822FBF40`): process `sub_824D9908` //! hands the speech manager a request whenever the ped's speech value (`SendSpeechEvent`'s -//! `speechvalue` in the state graphs) changes ([`super::speech`] resolves it to a clip). +//! `speechvalue` in the state graphs) changes ([`super::speech`] resolves it to a clip); value +//! 49 rings a phone first and asks for 64 when the ring ends, value 29 repeats on a timer; +//! - [`PedBodyFall`] = `SFXObj_PedBodyFall`: a Skate_Collisions fall sound per `BodyFallType` +//! animation event; +//! - [`PedTazer`] = `SFXObj_Tazer`: the `c_tazer` packet while the ped tazes. //! //! The ped side ([`PedState`]) is the record the objects read: the ped audio state `[object+32]` -//! (feet `+73`/`+74`, footsteps on `+68`, kind `+96`, class `+132`, speech value `+136`, -//! materials `+140`/`+144`, the speech distance pair `+148`/`+156`) and the owner `[object+28]` -//! (speed `+128`, weight `+144`). Which LW ped fields fill them is not traced; a ped system fills -//! [`PedState`] from its own animation (foot plants) and AI (speech values). +//! (feet `+73`/`+74`, footsteps on `+68`, `BodyFallType` `+76`, tazer `+80`, kind `+96`, class +//! `+132`, speech value `+136`, materials `+140`/`+144`, the speech distance pair `+148`/`+156`) +//! and the owner `[object+28]` (speed `+128`, weight `+144`); the manager `sub_824F2890` fills it +//! from the packed ped audio entries (spec G2). A ped system fills [`PedState`] from its own +//! animation (foot plants, fall events) and AI (speech values, tazing). use super::{WorldCommand, WorldSlot}; use crate::player::contacts::SpliceHost; use crate::player::footsteps::{Curve, footstep_surface}; @@ -107,6 +112,13 @@ pub struct PedState { pub speaker: super::speech_manager::Speaker, /// Which PedestrianSpeech outputs a line plays at ([`super::speech_player::ped_level_ids`]). pub level_select: super::speech_player::PedLevelSelect, + /// `S+80`: the ped is tazing (the manager's bit 17 of the ped audio entry `+4`; set while the + /// state graph's `TazeEntity` state runs, `pedestrian_wanttotaze.xml`): SFXObj_Tazer holds its + /// `c_tazer` packet while it is set ([`PedTazer`]). + pub tazing: bool, + /// `S+76`: the ped animation's `BodyFallType` event value (entry `+16`, a float; 0 = none). Each + /// change to a non-zero value starts one PedBodyFall sound ([`PedBodyFall`]). + pub body_fall: f32, } impl Default for PedState { @@ -127,6 +139,51 @@ impl Default for PedState { voice: 0, speaker: super::speech_manager::Speaker::default(), level_select: super::speech_player::PedLevelSelect::default(), + tazing: false, + body_fall: 0.0, + } + } +} + +/// The ped objects' one-shot values (setup export `world_tuning.ped_objects`; the defaults are the +/// shipped records and the image's constants, as [`PedFootstepTuning`]'s are). Mods can override +/// every field through the world tuning. +#[derive(Clone, Debug, PartialEq)] +pub struct PedObjectTuning { + /// SFXObj_PedBodyFall's Skate_Collisions containers by `BodyFallType` [8, 9, any other] + /// (vault class `923CCB46EF5BF5BA` record `DFEFC9212E0CBD2C`, fields `552899F3BF9927CC` / + /// `A3E8A9381F4222E1` / `C0FFD1E535F218E2`; holder `*(0x830CFDA4)+124`). + pub body_fall_ids: [u32; 3], + /// Their Splice bank (retail's table index 0). + pub body_fall_bank: String, + /// The eEQChain bus they resolve (eEQChain holder field `B4C4F86A53963BA2`, absent from the + /// shipped database: the lookup's default 0), created on resolve. + pub body_fall_eq: u8, + /// The phone ring of speech value 49 (`sub_824D9C70`): Splice bank index 6 and its container + /// (class `C1831BDB6CB1B1EA` record `cellphone` field `031EFDF991638985`). + pub ring_bank: String, + pub ring_id: u32, + /// The speech value requested when the ring ends (`sub_824D9AD8`: 64 → `4402_cell_greet`). + pub ring_answer: i32, + /// The photographer's repeat (value 29 while the game flag is set): the request repeats each + /// time this many seconds have accumulated (the image's 1.0 at `0x8231A844`). + pub photo_repeat: f32, + /// How long a tazer hold lasts when the engine does not say (the state graph's `TazerCycTime`, + /// `pedestrian_wanttotaze.xml` `TazeEntity`: 2.0 s; the recomp's bursts last 2.0 / 2.1 s). + pub tazer_seconds: f32, +} + +impl Default for PedObjectTuning { + fn default() -> Self { + Self { + body_fall_ids: [1184, 948, 1183], + body_fall_bank: "Skate_Collisions".into(), + body_fall_eq: 0, + ring_bank: "CellPhone_Rings".into(), + ring_id: 5, + ring_answer: 64, + photo_repeat: 1.0, + tazer_seconds: 2.0, } } } @@ -286,29 +343,244 @@ pub struct SpeechRequest { } /// `SFXObj_PedestrianSpeech`'s state. -#[derive(Clone, Debug, Default)] +#[derive(Clone, Debug)] pub struct PedSpeech { - /// `+36`: the speech value last handed on. + /// `+36`: the speech value last handed on (the constructor `sub_824D90D8` sets 68). pub last: i32, + /// `+160`: the photographer's repeat timer (seconds). + pub photo_timer: f32, + /// `+148`: the phone ring of value 49 (a Splice sound). + pub ring: Option, + /// Rings started (diagnostics). + pub rings: u64, +} + +impl Default for PedSpeech { + fn default() -> Self { + Self { last: 68, photo_timer: 0.0, ring: None, rings: 0 } + } } impl PedSpeech { - /// vfunc 9 (`sub_824D9908`): a request when the value changes. Value 49 goes to a separate - /// handler (`sub_824D9C70`, not ported) and 29 (the photographer, `PictureTaking`) has a repeat - /// timer (`+160`) gated by a global flag, not ported. - pub fn process(&mut self, owner: u64, s: &PedState) -> Option { + /// vfunc 9 (`sub_824D9908`): a request when the value changes. + /// - **29** (the photographer, `PictureTaking`): while the game flag `photo` is set (system + /// byte `*(0x830CFDC4)+912`, meaning not traced) the timer `+160` accumulates `dt`; when it + /// has reached [`PedObjectTuning::photo_repeat`] it restarts at 0 and the request repeats. + /// - **49** (the phone, sent by the code, not the state graphs): the old ring is stopped and + /// the ring Splice starts (`sub_824D9C70`; no request now: [`Self::update`] requests the + /// answer when it ends). + /// - 7 / 8 → 30 after a 29, else 51. + pub fn process(&mut self, owner: u64, s: &PedState, dt: f32, photo: bool, t: &PedObjectTuning, host: &mut dyn SpliceHost) -> Option { let value = s.speech_value; - if value == self.last { + let mut repeat = false; + if photo && value == 29 { + if self.photo_timer >= t.photo_repeat { + repeat = true; + self.photo_timer = 0.0; + } else { + self.photo_timer += dt; + } + } + if value == self.last && !repeat { return None; } let previous = self.last; self.last = value; if value == 49 { + if let Some(old) = self.ring.take() { + host.release(old); + } + host.set_route(crate::bus::Route::default()); + self.ring = host.start(&t.ring_bank, t.ring_id, [0.0, 1.0, 0.0, dt, 1.0, 1.0]); + self.rings += u64::from(self.ring.is_some()); return None; } let value = if matches!(value, 7 | 8) { if previous == 29 { 30 } else { 51 } } else { value }; - let flag = if s.speech_measure > s.speech_limit { 1 } else { 2 }; - Some(SpeechRequest { owner, value, flag }) + Some(SpeechRequest { owner, value, flag: Self::flag(s) }) + } + + /// The near / far flag of a request (1 when `S+148` > `S+156`, else 2). + pub fn flag(s: &PedState) -> i32 { + if s.speech_measure > s.speech_limit { 1 } else { 2 } + } + + /// The end of vfunc 10 (`sub_824D9AD8`): the ring follows the speaker's speech outputs (`main` + /// = the main level PedestrianSpeech copies for its lines, `out` the owner's outputs: pitch 1, + /// azimuth raw 0). When the ring has ended it is freed and the ped requests + /// [`PedObjectTuning::ring_answer`] (64, `4402_cell_greet`) with its words. + pub fn update(&mut self, owner: u64, s: &PedState, main: i32, out: &dyn Outputs, dt: f32, t: &PedObjectTuning, host: &mut dyn SpliceHost) -> Option { + let ring = self.ring?; + if host.alive(ring) { + host.update(ring, [main as f32 * LEVEL, out.pitch(1) as f32 * PITCH, out.raw(0) as f32 * DEGREES, dt, 0.0, 1.0]); + return None; + } + host.release(ring); + self.ring = None; + Some(SpeechRequest { owner, value: t.ring_answer, flag: Self::flag(s) }) + } + + pub fn release(&mut self, host: &mut dyn SpliceHost) { + if let Some(ring) = self.ring.take() { + host.release(ring); + } + } +} + +/// `SFXObj_PedBodyFall` (Pedestrian object 2, vtable `0x822FCB18`, factory `sub_824F0880`): process +/// `sub_824F0AB8`, update `sub_824F0E50`. When the ped's `BodyFallType` (`S+76`) changes to a +/// non-zero value, one Skate_Collisions sound starts through the collision Splice object +/// (`sub_82497F48`: a mono submix on the eEQChain bus, env send from the last update; each type +/// follows its own volume / pitch outputs, [`PedBodyFall::update`]): type 8 and +/// type 9 each round-robin two slots (`+40/+44`, index `+60`; `+48/+52`, index `+64`), any other +/// value one slot (`+56`); a slot's old sound is stopped first. The recomp shows the pattern of +/// retail's knock-down animations (sessions 163809 / 164620 / 214002 / 222155 / audiox: 75 starts, +/// 9 → 948, 8 → 1184, other → 1183, e.g. 9, +0.1 s other, +0.5 s 9, +0.16 s 9). +#[derive(Clone, Debug, Default)] +pub struct PedBodyFall { + /// `+36`: the value last seen. + last: f32, + /// Type 8 / type 9 slot pairs and their next index. + pairs: [[Option; 2]; 2], + next: [usize; 2], + /// The other types' slot. + single: Option, + /// level(7) as of the last update (the env send latched at a start). + env: i32, + /// Sounds started and the last container (diagnostics, checks). + pub starts: u64, + pub last_id: u32, +} + +impl PedBodyFall { + /// vfunc 9 (`sub_824F0AB8`). + pub fn process(&mut self, s: &PedState, t: &PedObjectTuning, host: &mut dyn SpliceHost) { + let v = s.body_fall; + if v == self.last || v == 0.0 { + self.last = v; + return; + } + let kind = v as i32; + let (id, sound) = match kind { + 8 => (t.body_fall_ids[0], &mut self.pairs[0][self.next[0]]), + 9 => (t.body_fall_ids[1], &mut self.pairs[1][self.next[1]]), + _ => (t.body_fall_ids[2], &mut self.single), + }; + if let Some(old) = sound.take() { + host.release(old); + } + host.set_route(crate::bus::Route { + output: crate::bus::Output::Eq(t.body_fall_eq), + create: true, + owner_env: self.env.clamp(0, 32767) as f32 * LEVEL, + mono: true, + }); + *sound = host.start(&t.body_fall_bank, id, [0.0, 1.0, 0.0, 0.0, 1.0, 1.0]); + if sound.is_some() { + self.starts += 1; + self.last_id = id; + } + match kind { + 8 => self.next[0] = (self.next[0] + 1) % 2, + 9 => self.next[1] = (self.next[1] + 1) % 2, + _ => {} + } + self.last = v; + } + + /// vfunc 10 (`sub_824F0E50`): every live sound follows the owner with its type's own volume / + /// pitch pair (type 8: out1 / out2, type 9: out3 / out4, other: out5 / out6; MixMap E23 / E24: + /// −2511 / −2155 / −1844 mB at the source): [volume / 32767, pitch / 4096, raw(0) × 360/65535, + /// dt, 0, 1], env send level(7) / 32767; an ended one is freed. + pub fn update(&mut self, out: &dyn Outputs, dt: f32, host: &mut dyn SpliceHost) { + self.env = out.level(7); + let deg = out.raw(0) as f32 * DEGREES; + let block = |v: usize, p: usize| [out.level(v) as f32 * LEVEL, out.pitch(p) as f32 * PITCH, deg, dt, 0.0, 1.0]; + let (a, b) = self.pairs.split_at_mut(1); + let groups: [(&mut [Option], (usize, usize)); 3] = [(&mut a[0][..], (1, 2)), (&mut b[0][..], (3, 4)), (std::slice::from_mut(&mut self.single), (5, 6))]; + for (sounds, (v, p)) in groups { + for sound in sounds.iter_mut() { + let Some(id) = *sound else { continue }; + if host.alive(id) { + host.update(id, block(v, p)); + } else { + host.release(id); + *sound = None; + } + } + } + } + + /// A sound of this object is live (the host takes the outputs only then). + pub fn sounding(&self) -> bool { + self.single.is_some() || self.pairs.iter().flatten().any(Option::is_some) + } + + /// The update without a live sound: only level(7) is kept (the next start's env send). + pub fn latch_env(&mut self, level7: i32) { + self.env = level7; + } + + pub fn release(&mut self, host: &mut dyn SpliceHost) { + for sound in self.pairs.iter_mut().flatten().chain(std::iter::once(&mut self.single)) { + if let Some(id) = sound.take() { + host.release(id); + } + } + } +} + +pub const TAZER_CLASS: &str = "c_tazer"; +pub const TAZER_BANK: &str = "Tazer"; +pub const TAZER_WORDS: usize = 9; + +/// `SFXObj_Tazer` (Pedestrian object 3, vtable `0x822FCB60`, factory `sub_824F12D0`): process +/// `sub_824F1478` posts the `c_tazer` packet (`sub_824B7950`, 9 words) when the ped audio state's +/// tazer byte (`S+80`) is set and none is held, and releases it when the byte clears; update +/// `sub_824F1560` rewrites it from the owner's outputs. The program (`Tazer.abk`) owns a child post +/// to `c_tazer_grn_play` through AEMS op 38 and plays the zap burst while the packet is held: in +/// the recomp (session 164620) every post was followed by Tazer starts 8, 7, then shuffles of 0–6 +/// at ~190 / 190 / 130 / 90–100 ms (49 starts over the session's three zaps). +#[derive(Clone, Debug, Default)] +pub struct PedTazer { + pub packet: Option<[i32; TAZER_WORDS]>, + /// Posts (diagnostics). + pub posts: u64, +} + +impl PedTazer { + /// `sub_824B7950`: [32767, 0, 4096, 0, 25000, 0, 0, 0, the audio game block's G+4 byte (0 or 1)]. + pub fn post_words(game_flag: bool) -> [i32; TAZER_WORDS] { + [32767, 0, 4096, 0, 25000, 0, 0, 0, i32::from(game_flag)] + } + + /// vfunc 9 (`sub_824F1478`). `game_flag` = the audio game block's G+4 byte (`AudioState::global_224`, + /// 0 in free skate). + pub fn process(&mut self, owner: u64, s: &PedState, game_flag: bool) -> Vec { + if s.tazing { + if self.packet.is_none() { + let words = Self::post_words(game_flag); + self.packet = Some(words); + self.posts += 1; + return vec![WorldCommand::Post { owner, slot: WorldSlot::PedTazer, class: TAZER_CLASS, words: words.to_vec() }]; + } + } else if self.packet.take().is_some() { + return vec![WorldCommand::Release { owner, slot: WorldSlot::PedTazer }]; + } + Vec::new() + } + + /// vfunc 10 (`sub_824F1560`): w1 = raw 0, w2 = pitch 1 (≤ 8192), w6 = level 3, w7 = level 2. + pub fn update(&mut self, owner: u64, out: &dyn Outputs) -> Vec { + let Some(w) = self.packet.as_mut() else { return Vec::new() }; + w[1] = out.raw(0).clamp(0, 65535); + w[2] = out.pitch(1).clamp(0, 8192); + w[6] = out.level(3).clamp(0, 32767); + w[7] = out.level(2).clamp(0, 32767); + vec![WorldCommand::Redeliver { owner, slot: WorldSlot::PedTazer, words: w.to_vec() }] + } + + pub fn release(&mut self, owner: u64) -> Vec { + if self.packet.take().is_some() { vec![WorldCommand::Release { owner, slot: WorldSlot::PedTazer }] } else { Vec::new() } } } @@ -322,6 +594,7 @@ mod tests { starts: Vec<(String, u32, [f32; 6])>, released: Vec, next: u32, + dead: bool, } impl SpliceHost for Host { @@ -333,7 +606,7 @@ mod tests { } fn update(&mut self, _: SoundId, _: [f32; 6]) {} fn alive(&self, _: SoundId) -> bool { - true + !self.dead } fn release(&mut self, s: SoundId) { self.released.push(s); @@ -389,16 +662,85 @@ mod tests { #[test] fn speech_requests_on_value_changes_with_the_remap() { + let t = PedObjectTuning::default(); + let mut h = Host::default(); let mut sp = PedSpeech::default(); let mut s = PedState { speech_value: 10, speech_measure: 5.0, speech_limit: 3.0, ..Default::default() }; - assert_eq!(sp.process(1, &s), Some(SpeechRequest { owner: 1, value: 10, flag: 1 })); - assert_eq!(sp.process(1, &s), None); + assert_eq!(sp.process(1, &s, 0.033, false, &t, &mut h), Some(SpeechRequest { owner: 1, value: 10, flag: 1 })); + assert_eq!(sp.process(1, &s, 0.033, false, &t, &mut h), None); s.speech_value = 29; - sp.process(1, &s); + sp.process(1, &s, 0.033, false, &t, &mut h); s.speech_value = 7; s.speech_measure = 1.0; - assert_eq!(sp.process(1, &s), Some(SpeechRequest { owner: 1, value: 30, flag: 2 })); + assert_eq!(sp.process(1, &s, 0.033, false, &t, &mut h), Some(SpeechRequest { owner: 1, value: 30, flag: 2 })); s.speech_value = 8; - assert_eq!(sp.process(1, &s).map(|r| r.value), Some(51)); + assert_eq!(sp.process(1, &s, 0.033, false, &t, &mut h).map(|r| r.value), Some(51)); + // A fresh speaker starts from 68 (the constructor): value 0 is a change. + assert_eq!(PedSpeech::default().process(1, &PedState::default(), 0.033, false, &t, &mut h).map(|r| r.value), Some(0)); + } + + #[test] + fn the_photographer_repeats_while_the_flag_is_set() { + let t = PedObjectTuning::default(); + let mut h = Host::default(); + let mut sp = PedSpeech::default(); + let s = PedState { speech_value: 29, ..Default::default() }; + let fired: Vec = (0..95).filter(|_| sp.process(1, &s, 1.0 / 30.0, true, &t, &mut h).is_some()).collect(); + // The change, then each time 1.0 s has accumulated: the frame that reaches it only adds, + // the next one fires and restarts at 0. + assert_eq!(fired.len(), 4, "{fired:?}"); + assert_eq!(fired[0], 0); + assert!((30..=32).contains(&(fired[1] - fired[0])) && (30..=32).contains(&(fired[2] - fired[1])), "{fired:?}"); + let mut sp = PedSpeech::default(); + let n = (0..95).filter(|_| sp.process(1, &s, 1.0 / 30.0, false, &t, &mut h).is_some()).count(); + assert_eq!(n, 1, "no repeat without the flag"); + } + + #[test] + fn value_49_rings_and_asks_for_the_answer_when_the_ring_ends() { + let t = PedObjectTuning::default(); + let mut h = Host::default(); + let mut sp = PedSpeech::default(); + let s = PedState { speech_value: 49, speech_measure: 30.0, speech_limit: 20.0, ..Default::default() }; + assert_eq!(sp.process(3, &s, 0.033, false, &t, &mut h), None); + assert_eq!(h.starts.len(), 1); + assert_eq!((h.starts[0].0.as_str(), h.starts[0].1), ("CellPhone_Rings", 5)); + assert_eq!(sp.update(3, &s, 9000, &Flat, 0.033, &t, &mut h), None, "ringing"); + h.dead = true; + assert_eq!(sp.update(3, &s, 9000, &Flat, 0.033, &t, &mut h), Some(SpeechRequest { owner: 3, value: 64, flag: 1 })); + assert!(sp.ring.is_none()); + } + + #[test] + fn body_falls_start_one_sound_per_event_and_round_robin_their_slots() { + let t = PedObjectTuning::default(); + let mut h = Host::default(); + let mut b = PedBodyFall::default(); + // A knock-down pattern: 9, other, 9, 9 (with 0 between, or a repeat after a 0). + for v in [9.0, 0.0, 1.0, 0.0, 9.0, 0.0, 9.0, 9.0, 0.0] { + b.process(&PedState { body_fall: v, ..Default::default() }, &t, &mut h); + } + assert_eq!(h.starts.iter().map(|s| s.1).collect::>(), vec![948, 1183, 948, 948]); + assert!(h.starts.iter().all(|s| s.0 == "Skate_Collisions" && s.2 == [0.0, 1.0, 0.0, 0.0, 1.0, 1.0])); + // The third 9 reuses the first type-9 slot: its sound (id 1) was stopped first. + assert_eq!(h.released, vec![1]); + b.update(&Flat, 0.033, &mut h); + assert_eq!(b.env, 25000, "level(7) latched for the next start"); + } + + #[test] + fn the_tazer_holds_its_packet_while_tazing() { + let mut z = PedTazer::default(); + let mut s = PedState::default(); + assert!(z.process(4, &s, false).is_empty()); + s.tazing = true; + let c = z.process(4, &s, false); + assert_eq!(c, vec![WorldCommand::Post { owner: 4, slot: WorldSlot::PedTazer, class: TAZER_CLASS, words: vec![32767, 0, 4096, 0, 25000, 0, 0, 0, 0] }]); + assert!(z.process(4, &s, false).is_empty(), "held"); + let u = z.update(4, &Flat); + let WorldCommand::Redeliver { words, .. } = &u[0] else { panic!() }; + assert_eq!((words[1], words[2], words[6], words[7]), (100, 4096, 3000, 2000)); + s.tazing = false; + assert_eq!(z.process(4, &s, false), vec![WorldCommand::Release { owner: 4, slot: WorldSlot::PedTazer }]); } } diff --git a/crates/skate-audio/src/world/skater_speech.rs b/crates/skate-audio/src/world/skater_speech.rs new file mode 100644 index 00000000..784f7696 --- /dev/null +++ b/crates/skate-audio/src/world/skater_speech.rs @@ -0,0 +1,164 @@ +//! NPC skaters' own speech (`SFXObj_PlayerSpeech`'s non-local process, `sub_824DA1B0`; spec +//! `audio-specs/world-speech.md` "Main cast"): every skater with a speech record runs it, with no +//! distance gate. Its inputs are the skater record's reaction bytes, which the instance bridge +//! `sub_824B6E80` unpacks from the skater entry (`+392` / `+396` / `+388`); the AI sets those bits. +//! +//! - A **living-world voice** (`sub_824DAAC0`): a seen slam (`+102` or `+103`) says `104_spec_slam` +//! in game mode 55, `903_pc_slam_race` in modes 19 / 20, else `404_pc_miss_trick` or `405_pc_slam` +//! (`rand() & 1`, drawn every frame); a seen trick (`+104`) `101_pos`; the system's chase flag +//! (`+1100` bit 6) `105_spec_chase` when nothing else. A request goes out only when the chosen +//! event changes (`+44`). +//! - A **main-cast voice** (a pro, `sub_824DA768`): a seen slam (`+102`, by `+112`) says +//! `104_slam` (event 1; `903_race_slam_pc` = 16 in modes 19 / 20) when the slammer is the player +//! (0) and `151_pro_slam` (288) with the slammer's words when it is a pro (1–29); `+103` (by +//! `+116`) the same without the race case; a seen trick (`+104`, by `+108`) `101_pos` (0) or +//! `150_pro_pos` (287). Each request goes out every frame the byte holds (the manager's timers +//! gate them). +//! - **Its own crash** (`sub_824DB688`, `+131` rising, once until it clears): the message +//! [`super::speech_manager::main_cast::message::CRASH`] (`131_collide_object` / `906_aislm`). +//! (The cameraman's line it can also request is not ported.) +use super::Draw; +use super::speech_manager::main_cast::message; + +/// The game modes the choices test (system `+892`); free skate is none of them. +pub const MODE_RACE_A: i32 = 19; +pub const MODE_RACE_B: i32 = 20; +pub const MODE_55: i32 = 55; + +/// The skater record's reaction bytes for one frame (what the AI sets; `sub_824B6E80`). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Reactions { + /// `+102` (entry `+392` bit 4): a slam was seen; `+112`: by which skater model (0 = the player). + pub slam: bool, + pub slam_by: u32, + /// `+103` (entry `+392` bit 3): a second slam reaction; `+116`: by whom. + pub slam_b: bool, + pub slam_b_by: u32, + /// `+104` (entry `+396` bit 10): a trick was seen; `+108`: by whom. + pub trick: bool, + pub trick_by: u32, + /// `+131` (entry `+388` bit 5): the skater's own crash. + pub crash: bool, + /// The system's chase flag (`+1100` bit 6). + pub chase: bool, +} + +/// What the process asks the speech host for. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Say { + /// A living-world event (`sub_824ABA18`). + Living(u16), + /// A main-cast event (`sub_824AC560`), with the other skater's model for the pro-on-pro lines. + MainCast { event: u16, other: Option }, + /// A skater speech message pair (`sub_824DAC00`): the speaker's kind picks the event. + Message(u16, u16), +} + +/// One skater's process state (`+44` the last living-world event, `+193` the crash latch). +#[derive(Clone, Debug)] +pub struct SkaterSpeech { + last: u16, + crash_latch: bool, +} + +impl Default for SkaterSpeech { + fn default() -> Self { + Self { last: 8318, crash_latch: false } + } +} + +fn pro(model: u32) -> bool { + (1..30).contains(&model) +} + +impl SkaterSpeech { + /// Nothing held: the process would do nothing on a frame without reactions. + pub fn idle(&self) -> bool { + self.last == 8318 && !self.crash_latch + } + + /// One frame. `main_cast` = the speaker has no living-world voice (record `+100 == 0`). + pub fn process(&mut self, main_cast: bool, r: &Reactions, mode: i32, rng: &mut dyn Draw) -> Vec { + let mut out = Vec::new(); + if main_cast { + if r.slam { + let event = if pro(r.slam_by) { + 288 + } else if r.slam_by == 0 && (mode == MODE_RACE_A || mode == MODE_RACE_B) { + 16 + } else { + 1 + }; + out.push(Say::MainCast { event, other: pro(r.slam_by).then_some(r.slam_by) }); + } + if r.slam_b { + let event = if pro(r.slam_b_by) { 288 } else { 1 }; + out.push(Say::MainCast { event, other: pro(r.slam_b_by).then_some(r.slam_b_by) }); + } + if r.trick { + let event = if pro(r.trick_by) { 287 } else { 0 }; + out.push(Say::MainCast { event, other: pro(r.trick_by).then_some(r.trick_by) }); + } + } else { + let mut event = 8318u16; + if r.slam || r.slam_b { + event = match mode { + MODE_55 => 8204, + MODE_RACE_A | MODE_RACE_B => 8239, + _ => 8234 | (rng.draw() & 1) as u16, + }; + } + if r.trick { + event = 8202; + } + if r.chase && event == 8318 { + event = 8205; + } + if event != self.last { + self.last = event; + if event != 8318 { + out.push(Say::Living(event)); + } + } + } + // `sub_824DB688`. + if r.crash { + if !self.crash_latch { + out.push(Say::Message(message::CRASH.0, message::CRASH.1)); + self.crash_latch = true; + } + } else { + self.crash_latch = false; + } + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_pro_reacts_to_the_player_and_to_pros_and_crashes_once() { + let mut s = SkaterSpeech::default(); + let mut rng = || 0u32; + let r = Reactions { slam: true, ..Default::default() }; + assert_eq!(s.process(true, &r, 0, &mut rng), vec![Say::MainCast { event: 1, other: None }]); + assert_eq!(s.process(true, &r, MODE_RACE_A, &mut rng), vec![Say::MainCast { event: 16, other: None }]); + let r = Reactions { trick: true, trick_by: 12, crash: true, ..Default::default() }; + assert_eq!(s.process(true, &r, 0, &mut rng), vec![Say::MainCast { event: 287, other: Some(12) }, Say::Message(8229, 125)]); + assert_eq!(s.process(true, &r, 0, &mut rng), vec![Say::MainCast { event: 287, other: Some(12) }], "the crash latch holds"); + } + + #[test] + fn a_living_world_skater_requests_on_changes() { + let mut s = SkaterSpeech::default(); + let mut one = || 1u32; + let r = Reactions { slam: true, ..Default::default() }; + assert_eq!(s.process(false, &r, 0, &mut one), vec![Say::Living(8235)]); + assert!(s.process(false, &r, 0, &mut one).is_empty(), "unchanged"); + assert_eq!(s.process(false, &Reactions { trick: true, ..r }, 0, &mut one), vec![Say::Living(8202)]); + assert!(s.process(false, &Reactions::default(), 0, &mut one).is_empty()); + assert_eq!(s.process(false, &Reactions { chase: true, ..Default::default() }, 0, &mut one), vec![Say::Living(8205)]); + } +} diff --git a/crates/skate-audio/src/world/skaters.rs b/crates/skate-audio/src/world/skaters.rs index c4a95353..5a546067 100644 --- a/crates/skate-audio/src/world/skaters.rs +++ b/crates/skate-audio/src/world/skaters.rs @@ -51,7 +51,12 @@ //! - **The bail grunt**: the body poster's first message of a bail calls `sub_824BF5F8`, which //! for a non-local skater sends its SkaterSpeech record message 8206 (event `201_grunt`) / 115 //! ([`NpcSkater::take_bail_grunt`]; the speech host plays it). -//! - Board slide (slot 15): reached by neither instance in the runs; not run. +//! - **Board slide** (slot 15, `c_board_slide`): SkateBoard's process `sub_824C6A78` calls +//! `sub_824CB3C8` and its update `sub_824CB4C0` for every instance with no local test; the +//! loose-board state they read (`+780`) is computed per skater entry by the conditioner +//! (`sub_827A1B78`, the per-skater loop) and copied by the instance bridge `sub_824B0DA8`, so an +//! NPC whose board lies loose (bail / on foot, deck contact, upside down or on its side) holds it +//! too ([`NpcSkaterAudioState::loose_board`]). //! //! The granular rolling bed's binds are collected in [`NpcSkater::routed`] for the host's NPC bed. use crate::mixmap::{MixMap, keys}; @@ -60,7 +65,7 @@ use crate::player::components::{Command, FootDrag, Grind, SenseOfSpeed, Skid, Sl use crate::player::contacts::{self, ContactsTuning, SpliceHost}; use crate::player::inputs::{self, Physics}; use crate::player::objpos::{Listener, ObjPos}; -use crate::player::rolling::{Rattle, Rolling, RollingInputs, Routed}; +use crate::player::rolling::{BoardSlide, Rattle, Rolling, RollingInputs, Routed}; use crate::player::seams::{self, SeamCommand, Seams}; use crate::player::clothing::{Clothing, ClothingTuning}; use crate::player::tuning::PlayerTuning; @@ -89,6 +94,11 @@ pub struct NpcSkaterAudioState { /// The skater's speech voice (its `aud_characteristics` model: the AI skaters' 89–96; 0 = none): /// the bail grunt's speaker. pub voice: u32, + /// The conditioner's loose-board state (`+780`: 0 none, 1 upside down, 2 on its side; + /// [`crate::player::rolling::loose_board`]): the board slide holds while it is set. + pub loose_board: u32, + /// The skater record's reaction bytes this frame ([`super::skater_speech`]: its own speech). + pub reactions: super::skater_speech::Reactions, } /// `sub_824B23C8` for a non-local skater: 1 when the local player's record `+84` (= its `+684`, @@ -180,6 +190,8 @@ pub struct Parts { pub rolling: bool, /// The rattle (`Rolling_Rattles`). pub rattle: bool, + /// The loose board's slide (`board_scrapes`). + pub slide: bool, /// SFXObj_Contacts' Splice one-shots (`Skate_Collisions`) and the grind on / off sounds. pub contacts: bool, /// SFXObj_Wheels' spin-down streams (the install's two recordings). @@ -214,6 +226,9 @@ pub struct NpcSkater { seams: Seams, rolling: Rolling, rattle: Rattle, + slide: BoardSlide, + /// The loose-board state of this pass (the host sets it before [`Self::process`]). + pub loose_board: u32, board: contacts::Contacts, wheels: Wheels, clothing: Clothing, @@ -253,6 +268,8 @@ impl NpcSkater { seams, rolling: Rolling::default(), rattle: Rattle::default(), + slide: BoardSlide::default(), + loose_board: 0, board, wheels: Wheels::default(), clothing: Clothing::default(), @@ -306,6 +323,10 @@ impl NpcSkater { if self.parts.rattle { cmds.extend(self.rattle.process(s, &self.rolling, &t.player.rolling)); } + // SkateBoard's process: the routing, the rattle, then the board slide (as the local's). + if self.parts.slide { + cmds.extend(self.slide.process(self.loose_board, &t.player.rolling)); + } let seam_cmds = Self::seam_commands(self.seams.process(s, t.player, m)); let m: &MixMap = m; let mut c = self.grind.process(s, t.player, &Owner { mixmap: m, key: keys::rail(g) }); @@ -365,6 +386,9 @@ impl NpcSkater { if self.parts.rattle { c.extend(self.rattle.update(&board)); } + if self.parts.slide { + c.extend(self.slide.update(s, self.loose_board, &t.player.rolling, &board)); + } if self.parts.contacts { self.grind.sounds(s, t.player, splice); self.grind.update_sounds(s, &rail, splice); diff --git a/crates/skate-audio/src/world/speech.rs b/crates/skate-audio/src/world/speech.rs index 21e2f85d..c2aa01d8 100644 --- a/crates/skate-audio/src/world/speech.rs +++ b/crates/skate-audio/src/world/speech.rs @@ -242,6 +242,8 @@ pub fn choose(index: &SpeechIndex, cues: &[Cue], voice: u32, last: &mut HashMap< /// The mixer bank the decoded takes live in ([`crate::mixer::Mixer::add_bank`]; slot = the take's /// running number over the index, [`SpeechSlots`]). pub const SPEECH_BANK: usize = 1 << 22; +/// The main cast's takes (`maincastspeech.big`), slots as in [`SPEECH_BANK`] over its own index. +pub const MAIN_CAST_BANK: usize = SPEECH_BANK + 1; /// (clip, take) → mixer slot of [`SPEECH_BANK`]. #[derive(Clone, Debug, Default)] diff --git a/crates/skate-audio/src/world/speech_manager.rs b/crates/skate-audio/src/world/speech_manager.rs index ba434557..c86e6da8 100644 --- a/crates/skate-audio/src/world/speech_manager.rs +++ b/crates/skate-audio/src/world/speech_manager.rs @@ -33,10 +33,10 @@ //! The interrupt rules (`sub_824A73F0`: tuning `+13` / `+14` against the playing line's priority) //! and the streams are [`super::speech_player`]'s. //! -//! **Not ported:** -//! - the main-cast path for peds without a living-world speaker (`+116 == 0`: pros, `sub_824AC438`); -//! - the extra main-cast line of value 6 (`+71`); -//! - the speech level (`audio-specs/world-speech.md` "Level"). +//! The main cast (pros, the special cast) uses [`main_cast`] and [`SpeechManager::request_main_cast`] +//! with its own manager instance. +//! +//! **Not ported:** the extra main-cast line of value 6 (`+71`). use std::collections::HashMap; use super::Draw; @@ -116,6 +116,76 @@ pub fn event_for_value(value: i32, speaker_kind: u32, rng: &mut dyn Draw) -> Opt }) } +/// The main-cast (bank 0) speech: pros (and the special cast) speak through the same manager logic +/// with the main cast's tuning (`speech_tuning["0"]`), event table and takes (`sub_824AC560`). +pub mod main_cast { + use super::Draw; + + /// Request words of a main-cast speaker: `w[0]` the cast bit (`aud_characteristics` + /// `6F2933E977CF40DD`, the speaker record's `+84`), `w[1]` the cast word (`14FD437D190677C8`, + /// `+88`: the special cast 30–38), `w[2]` the flag (1 near, 2 far: the main cast's sense is the + /// living world's inverted), `w[3]` the living-world switch (0), `w[4]` / `w[5]` another skater's + /// cast bit / word (`D6EA428C2B43E23A`; the pro-on-pro lines 150 / 151), the rest 0. + pub type Block = [u32; 14]; + + pub const NEAR: u32 = 1; + pub const FAR: u32 = 2; + + /// `sub_824AC898`: the words an event's library request carries, in field order. + pub fn request_words(event: u16, w: &Block) -> Vec { + match event { + 0 | 1 => vec![w[1], w[0], w[2]], + 247 => vec![w[0], w[9], w[1]], + 251 => vec![w[0], w[1], w[10]], + 254 => vec![w[1], w[5], w[0], w[4]], + 268 => vec![w[0], w[1], w[11]], + 271 | 274 | 275 => vec![w[0], w[1], w[12]], + 280 => vec![w[0], w[1], w[13]], + 287 | 288 => vec![w[1], w[0], w[4], w[5]], + _ => vec![w[0], w[1]], + } + } + + /// `sub_824AC438`: a ped's speech value on the main-cast path (a ped without a living-world + /// speaker, `S+116 == 0`), or None. 28 (`601_ai_greet`) only outside a challenge + /// (`sub_82487ED0` / system `+1196`); 30 / 51 also stop the speaker's playing line. + pub fn event_for_value(value: i32, in_challenge: bool, rng: &mut dyn Draw) -> Option { + Some(match value { + 29 => 141, + 28 if !in_challenge => 11, + 30 | 51 => { + if rng.draw() & 1 == 1 { + 115 + } else { + 6 + } + } + 53 | 54 => 77, + _ => return None, + }) + } + + /// The (living-world, main-cast) event pairs of the skater speech messages (`sub_824DAC00`: + /// a speaker with a living-world voice says the first, a main-cast speaker the second; 8318 / + /// 294 = none). Senders read from the code. + pub mod message { + /// The bail grunt (body poster `sub_824BF5F8`). + pub const BAIL_GRUNT: (u16, u16) = (8206, 115); + /// An NPC skater's own crash (`SFXObj_PlayerSpeech` `sub_824DB688`, its record's `+131` + /// rising): `131_collide_object` / `906_aislm`. + pub const CRASH: (u16, u16) = (8229, 125); + /// Collisions between skaters (`sub_824BD358`): `202_impact_react` … + pub const IMPACT: (u16, u16) = (8233, 6); + /// … and the AI's own slam reaction there. + pub const IMPACT_SLAM: (u16, u16) = (8241, 125); + /// `sub_824EFA60`: `344_HitReaction` / `913_RacePunch` pairs. + pub const HIT_REACTION: (u16, u16) = (8309, 253); + pub const RACE_PUNCH: (u16, u16) = (8310, 250); + /// `sub_824F8778`: the gesture (`338_gesture` / `338_gestures`). + pub const GESTURE: (u16, u16) = (8291, 247); + } +} + /// What the ped's audio state holds for its speech. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct Speaker { @@ -360,6 +430,33 @@ impl SpeechManager { self.started(speaker.index, event, inputs.now); Ok(Line { event, picks }) } + + /// A main-cast request (`sub_824AC560`: the same timers / gate with the main cast's tuning, then + /// `sub_824AC898`'s words). `speaker` = the speaker slot the timers are kept for. This manager + /// must hold the main cast's tuning (`speech_tuning["0"]`). Not ported: the repeat time of + /// speaker slots 30 / 31 (the record's `+52` / `+56`). + #[allow(clippy::too_many_arguments)] + pub fn request_main_cast( + &mut self, + library: &mut Library, + table: &EventTable, + event: u16, + speaker: u32, + block: &main_cast::Block, + inputs: &GateInputs, + rng: &mut dyn Draw, + ) -> Result { + if event == 294 { + return Err(Refusal::NoEvent); + } + let default = EventTuning::default(); + let tuning = self.tuning.get(&event).unwrap_or(&default); + self.gate(speaker, event, tuning, inputs, rng)?; + let words = main_cast::request_words(event, block); + let picks = library.start(table, event, &words).map_err(Refusal::Library)?; + self.started(speaker, event, inputs.now); + Ok(Line { event, picks }) + } } /// The (type bit, variant bit) the records give a clip voice id (the number in the clip names), diff --git a/crates/skate-audio/src/world/speech_player.rs b/crates/skate-audio/src/world/speech_player.rs index ab0a3b1a..ea8f0a6a 100644 --- a/crates/skate-audio/src/world/speech_player.rs +++ b/crates/skate-audio/src/world/speech_player.rs @@ -1,4 +1,4 @@ -//! The speech streams of one speech channel (the living world's): the lines the speech manager +//! The speech streams of one speech channel (0 = the main cast, 1 = the living world): the lines the speech manager //! starts ([`super::speech_manager`]), played as stream voices whose level, send, pitch, pan and //! filters follow the speaker's MixMap owner every console frame. Read from the TU3 recompilation //! (reference only; addresses are facts): @@ -12,32 +12,42 @@ //! variant). For a regular ped that is PedestrianSpeech out2 / out3 with out15 as the second; //! the filters are out13 (high pass) and out14 (low pass) ([`ped_outputs`]). For a skater with //! a living-world voice (model ≥ 41) PlayerSpeech out2 / out3, second out10, filters out8 / out9 -//! ([`skater_outputs`]). +//! ([`skater_outputs`]). The block also carries the echo send (ped out21, skater out13), the +//! echo's filters (ped 22 / 23, skater 14 / 15) and, every [`SpeechVoiceTuning::delay_frames`] +//! console frames, the echo delay from the camera distance. +//! - **Block → voice** (the stream system, `sub_82C5CEF0`, every frame): the voice gain = the +//! level / 32767 × the speaker's per-voice float (`S+152`, `aud_characteristics` +//! `2087A3290483BB4F`, 0.8–1.4); the post-filter send (`desc+32`, the second level × the float) +//! goes to the environment bus; the pre-gain send (`desc+16`, the echo send × the float) feeds the +//! stream slot's echo submix (`crate::bus::speech_echo`: HPF → delay → LPF → the environment bus); +//! PEAK = [`SpeechVoiceTuning::peak`] of the azimuth. //! - **The recomp agrees** (sessions 163809 / 164620 / 180430, the local tool `speech_levels.py`, //! skate-game test `speech_levels_follow_the_recomp`, 39 lines rebuilt at their geometry): a //! speech stream's LPF / HPF sit at exactly 24956 / 77 Hz near the speaker and 3489 / 379 Hz far //! away, our out14 / out13; its first GAIN over ours has median 1.005 (p10 0.55, p90 1.43); `_f` //! lines at 30 / 40 m play at 0.092 / 0.044 against our out3's 0.085 / 0.056, where out2 has fallen -//! to 0.030 / 0.001. The SEND matches out15 on some lines (0.035 at 3.6 m, 0.049 at 11 m) but only -//! 10 of 37 overall: open. Note: the level lookups measure the distance to the followed skater -//! (3DObjPos input 0), the near / far flag the camera distance. +//! to 0.030 / 0.001. With the voice float the gain median is 0.988 (p10 0.55, p90 1.36); the +//! pre-gain send (`+0x570`) is out21 within 25 % in 17 of 29 lines (out15: 10), the env send +//! (`+0x7D0`) out15 in 19 of 27; every recomp PEAK lies on the curves. Note: the level lookups +//! measure the distance to the followed skater (3DObjPos input 0), the near / far flag the +//! camera distance. //! - **Two streams per channel** (`sub_824A73F0` indexes the channel's stream records as //! `channel × 2 + k`; the recomp's living-world lines play on two stream players). A request //! takes a free stream. When none is free, the event's tuning decides (`sub_824A73F0`): `+13` //! lets it stop a playing line of lower priority, `+14` the same when the channel is full; //! otherwise it waits in the library's 16-request queue until its event's queue timeout -//! (`.evt` `+2`) runs out. Which of the two streams a request targets (`k`) is not traced: the +//! (`.evt` `+2`) runs out. Its unit: the queue clock is `[[0x830CFD94]+16]`, the game's visual +//! tick (the `GetVisualGameTick` Lua binding, one per rendered frame); the port counts console +//! frames (the console renders at its ~30 fps cadence). Which of the two streams a request targets (`k`) is not traced: the //! lower-priority one is taken (provisional). //! - **The cut** (`sub_824D9370`): while a line plays, a speaker whose main level stays at or below //! 200 (vault `6995C510258C9AF6`) for more than 60 console frames (`3D8CD05C962FF399`) has its //! line stopped. A speaker that loses its MixMap instance stops its line too (deactivation //! `sub_824D92B0` / `sub_824DA110`). //! -//! Not modelled: the speech voice's PEAK filter (≈3.1–4 kHz, gain 0.21–0.26, Q 3 in the recomp; -//! its writer is not found), the per-voice float `aud_characteristics` `2087A3290483BB4F` (0.8–1.15; -//! the recomp's pitch follows out1 alone), the `Obj:Speech` inputs a playing line sets (their -//! ducks), the "focus speaker" levels (out24 / out16 by a game global) and the event queue -//! timeout's unit (taken as console frames). +//! Not modelled: the "focus speaker" levels (out24 / out16 by a game global), the echo graph's +//! two-channel Pn21 / Sen0 routing gains (modelled as a mono unity tap) and which of the two +//! streams a full channel targets. use std::collections::VecDeque; use super::speech::{Line, SpeechIndex}; @@ -106,43 +116,163 @@ pub fn skater_level_ids(model: u32, far: bool) -> (usize, usize) { /// reader). pub const PED_FILTERS: [usize; 2] = [13, 14]; pub const SKATER_FILTERS: [usize; 2] = [8, 9]; +/// The level each owner kind copies as the stream voice's pre-gain send (into the stream slot's +/// echo submix, `crate::bus::speech_echo`): PedestrianSpeech out21 (`sub_824D9370` → block `+80`), +/// PlayerSpeech out13 (`sub_824DA300`). Recomp: the voice's first send module (`+0x570`, before +/// the gain) follows it. The echo's filters: ped 22 / 23, skater 14 / 15. +pub const PED_SEND_A: usize = 21; +pub const SKATER_SEND_A: usize = 13; +pub const PED_ECHO_FILTERS: [usize; 2] = [22, 23]; +pub const SKATER_ECHO_FILTERS: [usize; 2] = [14, 15]; +/// Every filter output a snapshot must read with the filter reader. +pub const PED_SNAPSHOT_FILTERS: [usize; 4] = [13, 14, 22, 23]; +pub const SKATER_SNAPSHOT_FILTERS: [usize; 4] = [8, 9, 14, 15]; + +/// An 8-point vault curve (`Sk8::PointNegGraphData8`: x at `+16`, y at `+48`), evaluated as +/// `sub_82481E10(8, …)`: below x0 → y0, from x7 on → y7, else the linear piece (`fmadds`). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Graph8 { + pub x: [f32; 8], + pub y: [f32; 8], +} + +impl Graph8 { + pub fn eval(&self, v: f32) -> f32 { + let (x, y) = (&self.x, &self.y); + if v < x[0] { + return y[0]; + } + if !(v < x[7]) { + return y[7]; + } + for i in 1..8 { + if v < x[i] { + let dx = x[i] - x[i - 1]; + if dx > 0.0 { + return ((y[i] - y[i - 1]) / dx).mul_add(v - x[i - 1], y[i - 1]); + } + return y[i]; + } + } + y[0] + } +} + +/// The stream voice's PEAK filter by the speaker's azimuth (both speech owners' updates, +/// `sub_824D9370` / `sub_824DA300`): the owner's raw azimuth (output 0, 0..65535) folded to the +/// front / back angle (above 32767 → 65536 − raw), then three curves of the speech record +/// (class `B29C3B2C13D96482` `default`, holder `*(0x830CFDA4)+44`): centre `2C166907CF51DB88` +/// (600 Hz at the front, 4000 at the side, 600 behind), gain `EA2C18D9CE5CBA3A` (0.4 → 0.1), +/// Q `CF8679F540B82B2B` (3). Setup export `world_tuning.speech_voice`; the defaults are the shipped +/// curves. Recomp (163809): every speech voice's PEAK (centre, gain) lies on the two curves at one +/// azimuth. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SpeechVoiceTuning { + pub peak_freq: Graph8, + pub peak_gain: Graph8, + pub peak_q: Graph8, + /// The echo delay (`sub_824D9370`): camera distance × this (field `BF48032DB145C5B4`, 1.0) × + /// [`Self::per_metre`] (the image's 1/344 s at `0x822F940C`), at most [`Self::max_delay`] + /// (0.15 s), recomputed every [`Self::delay_frames`] console frames (field `510BAFA32A76B340`, 4). + pub delay_factor: f32, + pub per_metre: f32, + pub max_delay: f32, + pub delay_frames: u32, +} + +impl Default for SpeechVoiceTuning { + fn default() -> Self { + Self { + peak_freq: Graph8 { + x: [0.0, 2614.956, 5603.478, 8325.166, 15796.47, 21666.78, 25829.36, 32767.0], + y: [600.0, 1195.0, 1802.143, 2336.429, 4000.0, 2676.428, 1838.571, 600.0], + }, + peak_gain: Graph8 { + x: [0.0, 4162.583, 8378.532, 12114.18, 16063.3, 21079.75, 26416.39, 32767.0], + y: [0.4, 0.34, 0.291786, 0.2575, 0.222143, 0.185714, 0.143929, 0.1], + }, + peak_q: Graph8 { x: [0.0, 4095.875, 8191.75, 12287.63, 16383.5, 20479.38, 24575.25, 28671.13], y: [3.0; 8] }, + delay_factor: 1.0, + per_metre: f32::from_bits(0x3B3E_82FA), + max_delay: f32::from_bits(0x3E19_999A), + delay_frames: 4, + } + } +} + +impl SpeechVoiceTuning { + /// (centre Hz, linear gain, Q) for an owner's raw azimuth. + pub fn peak(&self, raw_azimuth: i32) -> [f32; 3] { + let a = raw_azimuth as f32; + let a = if a > 32767.0 { 65536.0 - a } else { a }; + [self.peak_freq.eval(a), self.peak_gain.eval(a), self.peak_q.eval(a)] + } + + /// The echo delay for a speaker `distance` m from the camera (`fsel`: the smaller of the two). + pub fn delay(&self, distance: f32) -> f32 { + let d = self.delay_factor * distance * self.per_metre; + if self.max_delay - d >= 0.0 { d } else { self.max_delay } + } +} /// One frame's stream values. #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct VoiceParams { /// The owner's main level (raw, 0..32767: the cut reads it). pub level: i32, + /// The voice's gain: main level / 32767 × the speaker's per-voice float (`S+152`, + /// `aud_characteristics` `2087A3290483BB4F`; `sub_82C5CEF0` multiplies the gain and both sends). pub gain: f32, - /// The environment (reverb) send. + /// The post-filter send into the environment bus: the owner's second level (ped out15…, skater + /// out10…; recomp send module `+0x7D0`) × the voice float. pub send: f32, + /// The pre-gain send into the stream slot's echo submix ([`PED_SEND_A`] / [`SKATER_SEND_A`]; + /// recomp send module `+0x570`) × the voice float, and the echo's filters (Hz). + pub echo: f32, + pub echo_hpf: f32, + pub echo_lpf: f32, + /// The echo delay (s) when it is due ([`SpeechVoiceTuning::delay`]); None = unchanged. + pub delay: Option, + /// The echo slot (the channel's stream: channel × 2 + k), set by the player. + pub slot: u8, pub pitch: f32, /// Degrees. pub azimuth: f32, pub hpf: f32, pub lpf: f32, + /// The PEAK filter (centre Hz, linear gain, Q) ([`SpeechVoiceTuning::peak`]). + pub peak: [f32; 3], } -fn params(out: &dyn Outputs, ids: (usize, usize), filters: [usize; 2]) -> VoiceParams { +#[allow(clippy::too_many_arguments)] +fn params(out: &dyn Outputs, ids: (usize, usize), send_a: usize, filters: [usize; 2], echo_filters: [usize; 2], voice: &SpeechVoiceTuning, scale: f32) -> VoiceParams { let level = out.level(ids.0).clamp(0, 32767); VoiceParams { level, - gain: level as f32 * INV_32767, - send: out.level(ids.1).clamp(0, 32767) as f32 * INV_32767, + gain: level as f32 * INV_32767 * scale, + send: out.level(ids.1).clamp(0, 32767) as f32 * INV_32767 * scale, + echo: out.level(send_a).clamp(0, 32767) as f32 * INV_32767 * scale, + echo_hpf: out.level(echo_filters[0]) as f32, + echo_lpf: out.level(echo_filters[1]) as f32, + delay: None, + slot: 0, pitch: out.pitch(1).max(1) as f32 * INV_4096, azimuth: out.raw(0) as f32 * DEGREES, hpf: out.level(filters[0]) as f32, lpf: out.level(filters[1]) as f32, + peak: voice.peak(out.raw(0)), } } -/// A ped speaker's values (`out` = a PedestrianSpeech snapshot with [`PED_FILTERS`] read as filters). -pub fn ped_outputs(out: &dyn Outputs, sel: PedLevelSelect, event: u16, far: bool) -> VoiceParams { - params(out, ped_level_ids(sel, event, far), PED_FILTERS) +/// A ped speaker's values (`out` = a PedestrianSpeech snapshot with [`PED_SNAPSHOT_FILTERS`] read +/// as filters; `scale` = the speaker's per-voice float). +pub fn ped_outputs(out: &dyn Outputs, sel: PedLevelSelect, event: u16, far: bool, voice: &SpeechVoiceTuning, scale: f32) -> VoiceParams { + params(out, ped_level_ids(sel, event, far), PED_SEND_A, PED_FILTERS, PED_ECHO_FILTERS, voice, scale) } -/// A skater speaker's values (`out` = a PlayerSpeech snapshot with [`SKATER_FILTERS`] as filters). -pub fn skater_outputs(out: &dyn Outputs, model: u32, far: bool) -> VoiceParams { - params(out, skater_level_ids(model, far), SKATER_FILTERS) +/// A skater speaker's values (`out` = a PlayerSpeech snapshot with [`SKATER_SNAPSHOT_FILTERS`]). +pub fn skater_outputs(out: &dyn Outputs, model: u32, far: bool, voice: &SpeechVoiceTuning, scale: f32) -> VoiceParams { + params(out, skater_level_ids(model, far), SKATER_SEND_A, SKATER_FILTERS, SKATER_ECHO_FILTERS, voice, scale) } /// `sub_824A89E8`: the clip is a far line (its name ends in `_f`). @@ -213,6 +343,9 @@ pub enum Event { /// One speech channel's streams and queue. #[derive(Clone, Debug, Default)] pub struct SpeechPlayer { + /// The speech channel (0 = the main cast, 1 = the living world): its streams' echo slots are + /// channel × 2 + k. + pub channel: u8, streams: [Option; STREAMS], queue: VecDeque<(Request, u32)>, /// Counters (diagnostics). @@ -223,6 +356,11 @@ pub struct SpeechPlayer { } impl SpeechPlayer { + /// A player for speech channel `channel`. + pub fn on_channel(channel: u8) -> Self { + Self { channel, ..Default::default() } + } + /// The speakers whose lines play now. pub fn speakers(&self) -> impl Iterator + '_ { self.streams.iter().flatten().map(|s| s.req.speaker) @@ -307,7 +445,8 @@ impl SpeechPlayer { let Some(mut s) = self.streams[k].take() else { continue }; let line = s.req.lines[s.at]; let far = index.clips.get(line.clip).is_some_and(|c| far_clip(&c.name)); - let Some(p) = speaker(s.req.speaker, far, s.req.event) else { + let slot = self.channel * 2 + k as u8; + let Some(mut p) = speaker(s.req.speaker, far, s.req.event) else { if let Some(v) = s.voice { voices.stop(v); } @@ -328,6 +467,7 @@ impl SpeechPlayer { } else { s.low = 0; } + p.slot = slot; match s.voice { Some(v) if voices.alive(v) => voices.set(v, &p), Some(v) => { @@ -340,7 +480,8 @@ impl SpeechPlayer { } let line = s.req.lines[s.at]; let far = index.clips.get(line.clip).is_some_and(|c| far_clip(&c.name)); - let p = speaker(s.req.speaker, far, s.req.event).unwrap_or(p); + let mut p = speaker(s.req.speaker, far, s.req.event).unwrap_or(p); + p.slot = slot; s.voice = voices.open(line, &p); if s.voice.is_some() { events.push(Event::Started { k, speaker: s.req.speaker, line }); @@ -440,11 +581,36 @@ mod tests { assert_eq!(skater_level_ids(91, false), (2, 10)); assert_eq!(skater_level_ids(12, true), (5, 12)); assert!(far_clip("501_59_busm1_Warn_f.dat") && !far_clip("101_51_GenPos_Grn1_far.dat") && !far_clip("501_59_busm1_Warn_n.dat")); - let p = ped_outputs(&Out(9804), PedLevelSelect::default(), 8210, false); + let p = ped_outputs(&Out(9804), PedLevelSelect::default(), 8210, false, &SpeechVoiceTuning::default(), 1.0); assert_eq!((p.level, p.hpf, p.lpf), (9804, 77.0, 24956.0)); - assert!((p.send - 1196.0 / 32767.0).abs() < 1e-6 && (p.pitch - 4086.0 / 4096.0).abs() < 1e-6); + // The env send = out15 (post-filter), the echo send = out21 (pre-gain). + assert!((p.send - 1196.0 / 32767.0).abs() < 1e-6 && (p.echo - 2100.0 / 32767.0).abs() < 1e-6 && (p.pitch - 4086.0 / 4096.0).abs() < 1e-6); assert!((p.azimuth - 16384.0 * 360.0 / 65535.0).abs() < 1e-2); - assert_eq!(ped_outputs(&Out(9804), PedLevelSelect::default(), 8210, true).level, 2903); + // Raw 16384 = just past the side: the PEAK curves' 4000 Hz / 0.222 region. + assert_eq!(p.peak, SpeechVoiceTuning::default().peak(16384)); + assert!((p.peak[0] - 3867.5).abs() < 1.0 && (p.peak[1] - 0.2198).abs() < 1e-3 && p.peak[2] == 3.0, "{:?}", p.peak); + assert_eq!(ped_outputs(&Out(9804), PedLevelSelect::default(), 8210, true, &SpeechVoiceTuning::default(), 1.0).level, 2903); + } + + #[test] + fn the_echo_delay_is_the_sound_travel_time_up_to_150_ms() { + let t = SpeechVoiceTuning::default(); + assert!((t.delay(34.4) - 0.1).abs() < 1e-5); + assert_eq!(t.delay(100.0), t.max_delay); + assert!((t.max_delay - 0.15).abs() < 1e-7); + assert_eq!(t.delay(0.0), 0.0); + } + + #[test] + fn the_peak_follows_the_folded_azimuth_on_the_curves() { + let t = SpeechVoiceTuning::default(); + // Recomp 163809 at 69.0 s: PEAK 1244.58 Hz, gain 0.3588, Q 3, with our owner's raw azimuth + // 62673 at that line's geometry (folded: 2863). + let p = t.peak(62673); + assert!((p[0] - 1244.58).abs() < 1.5 && (p[1] - 0.3588).abs() < 2e-4 && p[2] == 3.0, "{p:?}"); + assert_eq!(t.peak(65536 - 5000), t.peak(5000), "front / back fold"); + assert_eq!(t.peak(0), [600.0, 0.4, 3.0]); + assert_eq!(t.peak(32767), [600.0, 0.1, 3.0]); } #[test] @@ -457,30 +623,30 @@ mod tests { assert_eq!(p.request(req(3, 0, 510, false), &mut v), Outcome::Queued, "no interrupt byte: it waits"); assert_eq!(p.request(req(4, 0, 510, true), &mut v), Outcome::Interrupted(0), "beats the 500 line"); let mut level = 9000; - let ev = p.frame(&ix, &mut |_, _, _| Some(ped_outputs(&Out(level), PedLevelSelect::default(), 8210, false)), &mut v); + let ev = p.frame(&ix, &mut |_, _, _| Some(ped_outputs(&Out(level), PedLevelSelect::default(), 8210, false, &SpeechVoiceTuning::default(), 1.0)), &mut v); assert_eq!(ev.iter().filter(|e| matches!(e, Event::Started { .. })).count(), 2); assert_eq!(v.opened.len(), 2); // Speaker 2's line is far: out3. assert_eq!(p.speakers().collect::>(), vec![4, 2]); // The queued request expires after its timeout (3 frames). for _ in 0..3 { - p.frame(&ix, &mut |_, far, _| Some(ped_outputs(&Out(level), PedLevelSelect::default(), 8210, far)), &mut v); + p.frame(&ix, &mut |_, far, _| Some(ped_outputs(&Out(level), PedLevelSelect::default(), 8210, far, &SpeechVoiceTuning::default(), 1.0)), &mut v); } assert_eq!(p.queued(), 0); // The cut: 61 frames at or below 200. level = 150; let mut cut = 0; for _ in 0..61 { - let ev = p.frame(&ix, &mut |s, far, _| (s == 4).then(|| ped_outputs(&Out(level), PedLevelSelect::default(), 8210, far)), &mut v); + let ev = p.frame(&ix, &mut |s, far, _| (s == 4).then(|| ped_outputs(&Out(level), PedLevelSelect::default(), 8210, far, &SpeechVoiceTuning::default(), 1.0)), &mut v); cut += ev.iter().filter(|e| matches!(e, Event::Cut { .. })).count(); } assert_eq!(cut, 1, "speaker 4 cut; speaker 2 went away at once"); assert_eq!(p.busy(), 0); // A finished take ends the line. assert_eq!(p.request(req(5, 0, 500, false), &mut v), Outcome::Playing(0)); - p.frame(&ix, &mut |_, _, _| Some(ped_outputs(&Out(9000), PedLevelSelect::default(), 8210, false)), &mut v); + p.frame(&ix, &mut |_, _, _| Some(ped_outputs(&Out(9000), PedLevelSelect::default(), 8210, false, &SpeechVoiceTuning::default(), 1.0)), &mut v); v.live.clear(); - let ev = p.frame(&ix, &mut |_, _, _| Some(ped_outputs(&Out(9000), PedLevelSelect::default(), 8210, false)), &mut v); + let ev = p.frame(&ix, &mut |_, _, _| Some(ped_outputs(&Out(9000), PedLevelSelect::default(), 8210, false, &SpeechVoiceTuning::default(), 1.0)), &mut v); assert_eq!(ev, vec![Event::Finished { k: 0, speaker: 5 }]); } } diff --git a/crates/skate-audio/tests/world_sources.rs b/crates/skate-audio/tests/world_sources.rs index a96ddab4..f8c233bf 100644 --- a/crates/skate-audio/tests/world_sources.rs +++ b/crates/skate-audio/tests/world_sources.rs @@ -406,3 +406,28 @@ fn a_bumped_ped_warns_with_a_line_of_its_voice() { assert!(peak > 0.01); assert!((frames as f32 * DT - seconds).abs() < 0.2, "{} frames for {seconds} s", frames); } + +/// The phone ring of speech value 49 (data-gated: the disc's `CellPhone_Rings.bnk`): the +/// container (`PedObjectTuning::ring_id`, 5) picks one of its records; each plays its members at +/// their delays, so a ring lasts the record's latest member end (delay + length). The recomp's two +/// rings (session 164620) were answered (the `4402_*_HelCel` line's read) 1.00 s and 1.19 s after +/// the ring's start: inside the records' range. +#[test] +#[ignore = "needs the private disc banks"] +fn the_phone_ring_lasts_until_the_recomp_answers() { + let Some(dir) = private_data::splc_banks() else { panic!("missing private data: no SPLC banks") }; + let t = skate_audio::world::peds::PedObjectTuning::default(); + let Ok(bytes) = std::fs::read(dir.join(format!("{}.bnk", t.ring_bank))) else { panic!("missing private data: no {}.bnk", t.ring_bank) }; + let bank = SpliceBank::parse(&bytes).unwrap(); + let id = t.ring_id as usize; + let records: Vec = if id < bank.records.len() { vec![id] } else { bank.containers[id - bank.records.len()].ids.iter().map(|&r| usize::from(r)).collect() }; + let ends: Vec = records + .iter() + .map(|&r| bank.records[r].groups.iter().flat_map(|g| g.members.iter()).map(|m| m.delay + m.length).fold(0.0f32, f32::max)) + .collect(); + let (lo, hi) = (ends.iter().copied().fold(f32::MAX, f32::min), ends.iter().copied().fold(0.0f32, f32::max)); + eprintln!("ring container {id}: records {records:?}, ends {ends:?} s"); + for answered in [1.00f32, 1.19] { + assert!(lo - 0.1 <= answered && answered <= hi + 0.15, "answer at {answered} s outside the rings' {lo:.2}..{hi:.2} s"); + } +} diff --git a/crates/skate-game/src/game_audio/npc_skaters.rs b/crates/skate-game/src/game_audio/npc_skaters.rs index b6458b19..6c460173 100644 --- a/crates/skate-game/src/game_audio/npc_skaters.rs +++ b/crates/skate-game/src/game_audio/npc_skaters.rs @@ -265,7 +265,7 @@ pub(crate) fn pre(host: &mut NpcHost, published: &NpcSkaters, native: &mut Nativ } info!("AUDIO_NPC release skater {id} (instance {g})"); } - let parts = Parts { rolling: player.rolling_on, rattle: player.rattle_on, contacts: player.contacts_on, wheels: player.wheels_on, clothing: player.footsteps_on }; + let parts = Parts { rolling: player.rolling_on, rattle: player.rattle_on, slide: player.slide_on, contacts: player.contacts_on, wheels: player.wheels_on, clothing: player.footsteps_on }; for (id, g) in assignment.claimed { let npc = NpcSkater::new(g as u32, parts, true, true, true); host.objects.insert(id, npc); @@ -280,7 +280,7 @@ pub(crate) fn pre(host: &mut NpcHost, published: &NpcSkaters, native: &mut Nativ let tuning = Tuning { player: &player.tuning, contacts: &player.contact_tuning, wheels: &player.wheels_tuning, clothing: &player.clothing_tuning }; host.speakers.clear(); for p in published.skaters.iter().filter(|p| p.voice != 0) { - host.speakers.push(super::world_speech::SkaterSpeaker { id: p.id, voice: p.voice, position: p.state.com_position, velocity: p.state.com_velocity }); + host.speakers.push(super::world_speech::SkaterSpeaker { id: p.id, voice: p.voice, position: p.state.com_position, velocity: p.state.com_velocity, reactions: p.reactions }); } let mut collisions = Vec::new(); let held: Vec<(u32, u64)> = host.slots.holders().collect(); @@ -297,6 +297,7 @@ pub(crate) fn pre(host: &mut NpcHost, published: &NpcSkaters, native: &mut Nativ // The body / deck posters once per console evaluation, as the local player's. npc.set_body_calls(Some(evaluations)); npc.set_deck_calls(Some(evaluations)); + npc.loose_board = p.loose_board; let cmds = npc.process(m, &s, tuning, &mut rt.splice_host()); host.apply(rt, id, cmds); // The routing's binds wait for the bed's step after the ticks (dropped without a bed). @@ -329,6 +330,7 @@ pub(crate) fn post(host: &mut NpcHost, published: &NpcSkaters, native: &mut Nati let Some(mut npc) = host.objects.remove(&id) else { continue }; let mut s = skaters::component_state(&p.state, local.soft_wheels); s.dt = dt; + npc.loose_board = p.loose_board; let cmds = npc.update(m, &s, tuning, &mut rt.splice_host()); host.apply(rt, id, cmds); npc.update_wheels(m, &s, tuning.wheels, &mut rt.stream_host()); diff --git a/crates/skate-game/src/game_audio/npc_skaters/tests.rs b/crates/skate-game/src/game_audio/npc_skaters/tests.rs index bbf6a82f..d62964f1 100644 --- a/crates/skate-game/src/game_audio/npc_skaters/tests.rs +++ b/crates/skate-game/src/game_audio/npc_skaters/tests.rs @@ -444,7 +444,7 @@ fn a_ghost_claims_instance_1_releases_and_survives_a_map_change() { for w in &mut s.wheel_position { w[0] += shift; } - published.skaters = vec![skate_audio::world::skaters::NpcSkaterAudioState { id, state: s, voice: 0 }]; + published.skaters = vec![skate_audio::world::skaters::NpcSkaterAudioState { id, state: s, voice: 0, loose_board: 0, reactions: Default::default() }]; let m = native.mixmap.as_mut().unwrap(); globals(m); // A pass (process, tick, update) on every other frame: the 30 Hz console cadence. @@ -523,7 +523,7 @@ fn the_npc_instance_runs_wheels_clothing_and_the_bail_grunt() { w[i] -= off[i]; } } - published.skaters = vec![skate_audio::world::skaters::NpcSkaterAudioState { id, state: s, voice: 91 }]; + published.skaters = vec![skate_audio::world::skaters::NpcSkaterAudioState { id, state: s, voice: 91, loose_board: 0, reactions: Default::default() }]; let m = native.mixmap.as_mut().unwrap(); globals(m); if f % 2 == 0 { @@ -555,3 +555,121 @@ fn the_npc_instance_runs_wheels_clothing_and_the_bail_grunt() { assert_eq!(native.player.as_ref().map(|p| p.posts).unwrap_or(0), posts_before, "the local player's components posted nothing"); assert_eq!(host.speakers.first().map(|s| (s.id, s.voice)), Some((id, 91)), "the speaking skater is published for the speech host"); } + +#[derive(serde::Deserialize)] +struct GrecRow { + turn: f32, + speed: f32, + wheels: u32, + air: u32, + material: u32, + #[serde(rename = "I")] + i: f32, + #[serde(rename = "Bk")] + bk: f32, + truck: GrecTruck, + distance: f32, +} + +#[derive(serde::Deserialize)] +struct GrecTruck { + a_gain: f32, + a_pitch: f32, + running: u32, +} + +/// The NPC skater's grain bed against the recomp's own NPC bed (data-gated: the install, and +/// `$SKATE_NPC_GREC/npc_rows_180430.json` from the local tool `npc_grec_rows.py`: session 180430's +/// GREC rows of the NPC instance's SkateBoard object, filtered by owner, each joined with the NPC's +/// board (SKATEB, matched by speed) and its distance to the camera). For the straight-roll rows +/// (|turn| ≤ 0.02, turn intensity and brake slew ~0, wheels down, a truck running) our NPC bed rolls +/// steadily at the row's speed, material and distance (2 s, the local player's wheels hard) and its +/// truck 0 A record gain and pitch are compared with the recomp's, per distance band. +#[test] +#[ignore = "needs the private install data and the NPC GREC export"] +fn npc_bed_follows_the_recomp_rows() { + let Some(dir) = std::env::var_os("SKATE_NPC_GREC").filter(|v| !v.is_empty()) else { + panic!("missing private data: SKATE_NPC_GREC (npc_grec_rows.py --json)"); + }; + let Ok(text) = std::fs::read_to_string(std::path::Path::new(&dir).join("npc_rows_180430.json")) else { panic!("missing private data: npc_rows_180430.json") }; + let rows: Vec = serde_json::from_str(&text).unwrap(); + let straight: Vec<&GrecRow> = rows.iter().filter(|r| r.wheels > 0 && r.air == 0 && r.truck.running != 0 && r.turn.abs() <= 0.02 && r.i.abs() < 0.02 && r.bk < 0.02 && r.material != 143).collect(); + assert!(straight.len() >= 100, "{} straight rows", straight.len()); + let Some((library, mxb)) = install() else { panic!("missing private data: no install with a MixMap") }; + let Some(local_bed) = super::super::grain_bed::Bed::new(&library) else { panic!("missing private data: no grain recordings / tuning") }; + let tuning = library.player_tuning(); + let contact_tuning = library.contacts_tuning(); + let (wheels, clothing) = (Default::default(), Default::default()); + let t = Tuning { player: &tuning, contacts: &contact_tuning, wheels: &wheels, clothing: &clothing }; + // One run per (2 m distance, 1 m/s speed, material) bin. + let mut bins: BTreeMap<(i32, i32, u32), Vec<&GrecRow>> = BTreeMap::new(); + for r in &straight { + bins.entry(((r.distance / 2.0) as i32, r.speed.round() as i32, r.material)).or_default().push(r); + } + let camera = [0.0, 1.8, 0.0]; + let l = Listener { camera, view: [1.0, 0.0, 0.0], camera_velocity: [0.0; 3], followed: [0.0, 1.0, -3.0], facing: [1.0, 0.0, 0.0], followed_velocity: [0.0; 3] }; + let mut by_band: BTreeMap, Vec, Vec, Vec)> = BTreeMap::new(); + for (&(dbin, sbin, material), members) in &bins { + let d = dbin as f32 * 2.0 + 1.0; + let v = (sbin as f32).max(0.3); + let (mut rt, _, parts) = runtime(&library); + let mut m = MixMap::from_bytes(&mxb).unwrap(); + let mut npc = NpcSkater::new(1, parts, true, true, true); + let mut bed = (local_bed.for_instance(1), 0u32); + let dt = 1.0 / 30.0; + let mut out = vec![0.0f32; 2 * 1600]; + let (mut gain, mut pitch) = (0.0f32, 0.0f32); + for f in 0..60 { + // Rolling across the camera's view at distance d (along z, at x = d). + let z = -0.5 * v * 2.0 + v * f as f32 * dt; + let published = AudioState::rolling(&skate_audio::player::state::LiteSkater { position: [d, 0.1, z], velocity: [0.0, 0.0, v], heading: 0.0, material, dt, ..Default::default() }); + let mut s = component_state(&published, false); + s.dt = dt; + globals(&mut m); + let _ = npc.update(&m, &s, t, &mut rt.splice_host()); + bed.1 = bed.1.wrapping_add(u32::from(s.push_trigger)); + let r = super::super::skate_events::Riding { speed: s.ground_speed, grinding: s.grinding, braking: s.brake, wheels: s.wheel_count, pushes: bed.1, audio: s, ..Default::default() }; + let routed = Some((std::mem::take(&mut npc.routed.grains), npc.routed.primary)); + super::super::grain_bed::step_with(&mut bed.0, &library, &m, &r, dt, &tuning, routed, |apply| apply(&mut rt)); + bed.0.write_inputs(&mut m, &s, false); + npc.write_inputs(&mut m, &s, &l, [0.0; 3], &tuning); + let _ = npc.process(&mut m, &s, t, &mut rt.splice_host()); + let _ = npc.take_collisions(); + m.tick(dt); + rt.fill_stereo(&mut out); + if let Some(g) = rt.npc_grains.as_deref() + && f >= 45 + && g.trucks[0].bound().is_some() + { + gain = g.trucks[0].players[0].record.gain; + pitch = g.trucks[0].players[0].record.pitch; + } + } + let band = (d / 10.0) as i32 * 10; + let e = by_band.entry(band).or_default(); + for r in members { + e.0.push(r.truck.a_gain); + e.1.push(gain); + e.2.push(r.truck.a_pitch); + e.3.push(pitch); + } + } + let med = |v: &mut Vec| -> f32 { + v.sort_by(f32::total_cmp); + v.get(v.len() / 2).copied().unwrap_or(0.0) + }; + eprintln!("NPC bed, straight roll ({} rows of session 180430's NPC GREC rows):", straight.len()); + eprintln!(" distance rows A gain recomp / ours (median) A pitch recomp / ours"); + let mut ok = 0; + let mut n = 0; + for (band, (mut a, mut b, mut p, mut q)) in by_band { + let rows = a.len(); + let (a, b, p, q) = (med(&mut a), med(&mut b), med(&mut p), med(&mut q)); + eprintln!(" {band:2}-{:<2} m {rows:5} {a:.4} / {b:.4} {p:.3} / {q:.3}", band + 10); + if rows >= 20 { + n += 1; + ok += usize::from(b > 0.0 && (a / b) > 0.5 && (a / b) < 2.0); + } + } + assert!(n > 0 && ok * 2 >= n, "the NPC bed's level follows the recomp's in most distance bands"); +} diff --git a/crates/skate-game/src/game_audio/state_replay.rs b/crates/skate-game/src/game_audio/state_replay.rs index 8b51a82e..0cb6ed7b 100644 --- a/crates/skate-game/src/game_audio/state_replay.rs +++ b/crates/skate-game/src/game_audio/state_replay.rs @@ -285,7 +285,14 @@ impl Script { /// moved so the window's first board position lands on `anchor`, the velocities from the /// positions' change per row (the log keeps positions, not velocities). Requires every row to /// parse ([`try_read`]). +#[cfg_attr(not(test), allow(dead_code))] pub(crate) fn ghost_states(text: &str, from: f32, seconds: f32, anchor: [f32; 3]) -> Result, String> { + ghost_window(text, from, seconds, anchor).map(|w| w.0) +} + +/// [`ghost_states`] and, per row, the loose-board state (`+780`, the local host's own rule +/// `PlayerAudio::loose_board` on the row's riding values): the NPC's board slide. +pub(crate) fn ghost_window(text: &str, from: f32, seconds: f32, anchor: [f32; 3]) -> Result<(Vec, Vec), String> { let rows = try_read(text)?; let first = (from.max(0.0) * 60.0) as usize; let count = (seconds.max(1.0) * 60.0) as usize; @@ -297,12 +304,15 @@ pub(crate) fn ghost_states(text: &str, from: f32, seconds: f32, anchor: [f32; 3] let origin = [rows[first].f("board_x"), rows[first].f("board_y"), rows[first].f("board_z")]; let place = |p: [f32; 3]| -> [f32; 3] { std::array::from_fn(|i| p[i] - origin[i] + anchor[i]) }; let mut out = Vec::with_capacity(end - first); + let mut loose = Vec::with_capacity(end - first); let mut last: Option<([f32; 3], [f32; 3])> = None; for (i, r) in rows[..end].iter().enumerate() { - let mut s = script.riding(r).audio; + let riding = script.riding(r); + let mut s = riding.audio; if i < first { continue; } + loose.push(super::player_audio::PlayerAudio::loose_board(&s, &riding) as u8); let board = place([r.f("board_x"), r.f("board_y"), r.f("board_z")]); let com = place([r.f("com_x"), r.f("com_y"), r.f("com_z")]); let rate = |now: [f32; 3], then: Option<[f32; 3]>| then.map_or([0.0; 3], |t| std::array::from_fn(|k| (now[k] - t[k]) * 60.0)); @@ -320,7 +330,7 @@ pub(crate) fn ghost_states(text: &str, from: f32, seconds: f32, anchor: [f32; 3] last = Some((board, com)); out.push(s); } - Ok(out) + Ok((out, loose)) } #[cfg(test)] diff --git a/crates/skate-game/src/game_audio/world_bridge.rs b/crates/skate-game/src/game_audio/world_bridge.rs index b1f3c905..3bf7b79a 100644 --- a/crates/skate-game/src/game_audio/world_bridge.rs +++ b/crates/skate-game/src/game_audio/world_bridge.rs @@ -45,6 +45,8 @@ pub(crate) struct WorldAudioPublish; #[derive(Component, Clone, Debug)] pub(crate) struct GhostSkater { pub(crate) states: Vec, + /// The loose-board state per row (the board slide). + pub(crate) loose: Vec, /// Rows played (fractional). pub(crate) at: f64, } @@ -52,11 +54,11 @@ pub(crate) struct GhostSkater { impl GhostSkater { /// Load a state log's window as a ghost (see `ghost_states`). pub(crate) fn from_log(text: &str, from: f32, seconds: f32, anchor: [f32; 3]) -> Result { - let states = super::state_replay::ghost_states(text, from, seconds, anchor)?; + let (states, loose) = super::state_replay::ghost_window(text, from, seconds, anchor)?; if states.is_empty() { return Err("empty window".into()); } - Ok(Self { states, at: 0.0 }) + Ok(Self { states, loose, at: 0.0 }) } } @@ -76,6 +78,7 @@ fn ghost_step(time: Res