Skip to main content

alice_physics/
audio_physics.rs

1//! Audio-Physics Integration
2//!
3//! Generates physically-based audio parameters from collision events.
4//! Maps impact velocity, material, surface area, and contact duration
5//! to audio parameters (volume, pitch, decay, timbre).
6//!
7//! # Output
8//!
9// LIMITATION(COV-ACOUS-085): This module does NOT generate audio samples. It computes parameters that an audio engine can use to trigger and modulate sounds.
10//! This module does NOT generate audio samples. It computes parameters
11//! that an audio engine can use to trigger and modulate sounds.
12//!
13//! Author: Moroya Sakamoto
14
15use crate::collider::Contact;
16use crate::math::{Fix128, Vec3Fix};
17
18#[cfg(not(feature = "std"))]
19use alloc::vec;
20#[cfg(not(feature = "std"))]
21use alloc::vec::Vec;
22
23// ============================================================================
24// Material
25// ============================================================================
26
27/// Physical material properties for audio synthesis
28#[derive(Clone, Copy, Debug, PartialEq, Eq)]
29pub struct AudioMaterial {
30    /// Material type identifier
31    pub material_type: MaterialType,
32    /// Density (kg/m^3) — affects pitch/resonance
33    pub density: Fix128,
34    /// Hardness (0..1) — affects attack/brightness
35    pub hardness: Fix128,
36    /// Resonance (0..1) — how much the material rings
37    pub resonance: Fix128,
38    /// Damping (0..1) — how quickly sound decays
39    pub damping: Fix128,
40}
41
42/// Material type for sound bank selection
43#[derive(Clone, Copy, Debug, PartialEq, Eq)]
44pub enum MaterialType {
45    /// Metal (bright, ringing)
46    Metal,
47    /// Wood (warm, short decay)
48    Wood,
49    /// Glass (bright, fragile)
50    Glass,
51    /// Stone/Concrete (dull, heavy)
52    Stone,
53    /// Rubber (soft, muffled)
54    Rubber,
55    /// Fabric/Cloth (very soft)
56    Fabric,
57    /// Water/Liquid (splash)
58    Liquid,
59    /// Custom material
60    Custom(u32),
61}
62
63impl AudioMaterial {
64    /// Metal preset
65    pub const METAL: Self = Self {
66        material_type: MaterialType::Metal,
67        density: Fix128 { hi: 7800, lo: 0 },
68        hardness: Fix128 {
69            hi: 0,
70            lo: 0xCCCCCCCCCCCCCCCC,
71        }, // 0.8
72        resonance: Fix128 {
73            hi: 0,
74            lo: 0xCCCCCCCCCCCCCCCC,
75        }, // 0.8
76        damping: Fix128 {
77            hi: 0,
78            lo: 0x3333333333333333,
79        }, // 0.2
80    };
81
82    /// Wood preset
83    pub const WOOD: Self = Self {
84        material_type: MaterialType::Wood,
85        density: Fix128 { hi: 600, lo: 0 },
86        hardness: Fix128 {
87            hi: 0,
88            lo: 0x999999999999999A,
89        }, // 0.6
90        resonance: Fix128 {
91            hi: 0,
92            lo: 0x6666666666666666,
93        }, // 0.4
94        damping: Fix128 {
95            hi: 0,
96            lo: 0x999999999999999A,
97        }, // 0.6
98    };
99
100    /// Stone preset
101    pub const STONE: Self = Self {
102        material_type: MaterialType::Stone,
103        density: Fix128 { hi: 2500, lo: 0 },
104        hardness: Fix128 {
105            hi: 0,
106            lo: 0xE666666666666666,
107        }, // 0.9
108        resonance: Fix128 {
109            hi: 0,
110            lo: 0x3333333333333333,
111        }, // 0.2
112        damping: Fix128 {
113            hi: 0,
114            lo: 0xB333333333333333,
115        }, // 0.7
116    };
117
118    /// Rubber preset
119    pub const RUBBER: Self = Self {
120        material_type: MaterialType::Rubber,
121        density: Fix128 { hi: 1100, lo: 0 },
122        hardness: Fix128 {
123            hi: 0,
124            lo: 0x3333333333333333,
125        }, // 0.2
126        resonance: Fix128 {
127            hi: 0,
128            lo: 0x1999999999999999,
129        }, // 0.1
130        damping: Fix128 {
131            hi: 0,
132            lo: 0xE666666666666666,
133        }, // 0.9
134    };
135}
136
137// ============================================================================
138// Audio Event
139// ============================================================================
140
141/// Audio parameters generated from a physics event
142#[derive(Clone, Copy, Debug, PartialEq, Eq)]
143pub struct AudioEvent {
144    /// World-space position for 3D spatialization
145    pub position: Vec3Fix,
146    /// Volume (0..1, linear)
147    pub volume: Fix128,
148    /// Pitch multiplier (1.0 = normal, 0.5 = octave down, 2.0 = octave up)
149    pub pitch: Fix128,
150    /// Decay time in seconds
151    pub decay: Fix128,
152    /// Brightness (0..1, controls high-frequency content)
153    pub brightness: Fix128,
154    /// Roughness (0..1, 0 = clean, 1 = gritty/textured)
155    pub roughness: Fix128,
156    /// Impact type
157    pub event_type: AudioEventType,
158    /// Material A
159    pub material_a: MaterialType,
160    /// Material B
161    pub material_b: MaterialType,
162}
163
164/// Type of audio event
165#[derive(Clone, Copy, Debug, PartialEq, Eq)]
166pub enum AudioEventType {
167    /// Single impact (collision begin)
168    Impact,
169    /// Sliding contact (persistent)
170    Slide,
171    /// Rolling contact
172    Roll,
173    /// Scraping (high-friction slide)
174    Scrape,
175    /// Breaking/fracture
176    Break,
177    /// Splash (fluid)
178    Splash,
179}
180
181// ============================================================================
182// Audio Generator
183// ============================================================================
184
185/// Audio event generator configuration
186#[derive(Clone, Copy, Debug, PartialEq, Eq)]
187pub struct AudioConfig {
188    /// Minimum impact velocity to generate sound
189    pub min_velocity: Fix128,
190    /// Maximum volume impact velocity (velocities above this = max volume)
191    pub max_velocity: Fix128,
192    /// Velocity to pitch mapping factor
193    pub velocity_pitch_factor: Fix128,
194    /// Minimum volume threshold (events below this are discarded)
195    pub min_volume: Fix128,
196    /// Sliding sound velocity threshold
197    pub slide_velocity_threshold: Fix128,
198    /// Maximum simultaneous audio events per frame
199    pub max_events_per_frame: usize,
200}
201
202impl Default for AudioConfig {
203    fn default() -> Self {
204        Self {
205            min_velocity: Fix128::from_ratio(1, 10),
206            max_velocity: Fix128::from_int(20),
207            velocity_pitch_factor: Fix128::from_ratio(1, 20),
208            min_volume: Fix128::from_ratio(1, 100),
209            slide_velocity_threshold: Fix128::from_ratio(1, 2),
210            max_events_per_frame: 32,
211        }
212    }
213}
214
215/// Audio event generator
216pub struct AudioGenerator {
217    /// Configuration
218    pub config: AudioConfig,
219    /// Per-body material (indexed by body ID)
220    pub materials: Vec<AudioMaterial>,
221    /// Generated audio events this frame
222    pub events: Vec<AudioEvent>,
223}
224
225impl AudioGenerator {
226    /// Create a new audio generator for N bodies
227    #[must_use]
228    pub fn new(num_bodies: usize, config: AudioConfig) -> Self {
229        Self {
230            config,
231            materials: vec![AudioMaterial::WOOD; num_bodies],
232            events: Vec::new(),
233        }
234    }
235
236    /// Set material for a body
237    pub fn set_material(&mut self, body_idx: usize, material: AudioMaterial) {
238        if body_idx >= self.materials.len() {
239            self.materials.resize(body_idx + 1, AudioMaterial::WOOD);
240        }
241        self.materials[body_idx] = material;
242    }
243
244    /// Clear events for new frame
245    pub fn begin_frame(&mut self) {
246        self.events.clear();
247    }
248
249    /// Process a collision contact and generate audio event
250    pub fn process_contact(
251        &mut self,
252        body_a: usize,
253        body_b: usize,
254        contact: &Contact,
255        relative_velocity: Vec3Fix,
256        is_new_contact: bool,
257    ) {
258        if self.events.len() >= self.config.max_events_per_frame {
259            return;
260        }
261
262        let speed = relative_velocity.length();
263        if speed < self.config.min_velocity {
264            return;
265        }
266
267        let mat_a = self.get_material(body_a);
268        let mat_b = self.get_material(body_b);
269
270        // Determine event type
271        let normal_speed = relative_velocity.dot(contact.normal).abs();
272        let tangential_sq = speed * speed - normal_speed * normal_speed;
273        let tangential_speed = if tangential_sq > Fix128::ZERO {
274            tangential_sq.sqrt()
275        } else {
276            Fix128::ZERO
277        };
278
279        let event_type = if is_new_contact {
280            AudioEventType::Impact
281        } else if tangential_speed > self.config.slide_velocity_threshold {
282            AudioEventType::Slide
283        } else {
284            AudioEventType::Roll
285        };
286
287        // Compute audio parameters
288        let volume = self.compute_volume(speed, &mat_a, &mat_b);
289        let pitch = self.compute_pitch(speed, &mat_a, &mat_b);
290        let decay = self.compute_decay(&mat_a, &mat_b);
291        let brightness = self.compute_brightness(speed, &mat_a, &mat_b);
292        let roughness = self.compute_roughness(tangential_speed, &mat_a, &mat_b);
293
294        if volume < self.config.min_volume {
295            return;
296        }
297
298        self.events.push(AudioEvent {
299            position: contact.point_a,
300            volume,
301            pitch,
302            decay,
303            brightness,
304            roughness,
305            event_type,
306            material_a: mat_a.material_type,
307            material_b: mat_b.material_type,
308        });
309    }
310
311    fn get_material(&self, body_idx: usize) -> AudioMaterial {
312        if body_idx < self.materials.len() {
313            self.materials[body_idx]
314        } else {
315            AudioMaterial::WOOD
316        }
317    }
318
319    /// `speed / max_velocity` clamped to `[0, 1]`; a `max_velocity <= 0` puts
320    /// every positive speed above the maximum (ratio 1), as the field's doc
321    /// reads, rather than dividing by zero.
322    fn speed_ratio(&self, speed: Fix128) -> Fix128 {
323        let max = self.config.max_velocity;
324        if max <= Fix128::ZERO {
325            return if speed > Fix128::ZERO {
326                Fix128::ONE
327            } else {
328                Fix128::ZERO
329            };
330        }
331        let ratio = speed / max;
332        if ratio > Fix128::ONE {
333            Fix128::ONE
334        } else {
335            ratio
336        }
337    }
338
339    /// Volume: impact velocity mapped to 0..1 with sqrt curve
340    fn compute_volume(
341        &self,
342        speed: Fix128,
343        _mat_a: &AudioMaterial,
344        _mat_b: &AudioMaterial,
345    ) -> Fix128 {
346        self.speed_ratio(speed).sqrt()
347    }
348
349    /// Pitch: higher velocity = slightly higher pitch, adjusted by material density
350    fn compute_pitch(&self, speed: Fix128, mat_a: &AudioMaterial, mat_b: &AudioMaterial) -> Fix128 {
351        let base = Fix128::ONE + speed * self.config.velocity_pitch_factor;
352
353        // Denser materials = lower pitch
354        let avg_density = (mat_a.density + mat_b.density).half();
355        if avg_density.is_zero() {
356            return base;
357        }
358        let density_factor = Fix128::from_int(1000) / avg_density;
359        let density_factor = if density_factor > Fix128::from_int(2) {
360            Fix128::from_int(2)
361        } else if density_factor < Fix128::from_ratio(1, 2) {
362            Fix128::from_ratio(1, 2)
363        } else {
364            density_factor
365        };
366
367        base * density_factor
368    }
369
370    /// Decay: based on material resonance and damping
371    #[allow(clippy::unused_self)]
372    fn compute_decay(&self, mat_a: &AudioMaterial, mat_b: &AudioMaterial) -> Fix128 {
373        let avg_resonance = (mat_a.resonance + mat_b.resonance).half();
374        let avg_damping = (mat_a.damping + mat_b.damping).half();
375
376        // High resonance + low damping = long decay
377        let base_decay = Fix128::from_ratio(1, 10); // 0.1s minimum
378        let resonance_bonus = avg_resonance * Fix128::from_int(2);
379        let damping_reduction = avg_damping;
380
381        base_decay + resonance_bonus * (Fix128::ONE - damping_reduction)
382    }
383
384    /// Brightness: hardness and impact speed
385    fn compute_brightness(
386        &self,
387        speed: Fix128,
388        mat_a: &AudioMaterial,
389        mat_b: &AudioMaterial,
390    ) -> Fix128 {
391        let avg_hardness = (mat_a.hardness + mat_b.hardness).half();
392        let speed_factor = self.speed_ratio(speed);
393
394        avg_hardness * (Fix128::from_ratio(1, 2) + speed_factor.half())
395    }
396
397    /// Roughness: tangential velocity and surface hardness
398    fn compute_roughness(
399        &self,
400        tangential_speed: Fix128,
401        mat_a: &AudioMaterial,
402        mat_b: &AudioMaterial,
403    ) -> Fix128 {
404        let avg_hardness = (mat_a.hardness + mat_b.hardness).half();
405        let speed_factor = self.speed_ratio(tangential_speed);
406
407        speed_factor * avg_hardness
408    }
409
410    /// Get all audio events for this frame
411    #[must_use]
412    pub fn get_events(&self) -> &[AudioEvent] {
413        &self.events
414    }
415}
416
417// ============================================================================
418// World view
419// ============================================================================
420
421impl crate::solver::PhysicsWorld {
422    /// Feed every contact of the last step to `generator`
423    /// ([`AudioGenerator::process_contact`]), in the order of
424    /// [`contact_events`](Self::contact_events), which is deterministic.
425    ///
426    /// One call per touching pair (a pair is reported once per step): a
427    /// [`Begin`](crate::event::ContactEventType::Begin) event is a new contact, a
428    /// [`Persist`](crate::event::ContactEventType::Persist) event a continuing one,
429    /// and an [`End`](crate::event::ContactEventType::End) event is skipped. The
430    /// relative velocity passed is the event's normal speed
431    /// ([`ContactEvent::relative_velocity`](crate::event::ContactEvent::relative_velocity),
432    /// measured when the contact was detected, before the solver removed it) along
433    /// the event normal, plus the tangential part of the bodies' current linear
434    /// velocity difference `v_a − v_b`. The contact passed has the event's point as
435    /// `point_a` and `point_a + normal·depth` as `point_b`.
436    ///
437    /// The generator's events are appended to; call
438    /// [`AudioGenerator::begin_frame`] first to start a new frame.
439    pub fn emit_contact_audio(&self, generator: &mut AudioGenerator) {
440        use crate::event::ContactEventType;
441        for e in self.contact_events() {
442            let is_new = match e.event_type {
443                ContactEventType::Begin => true,
444                ContactEventType::Persist => false,
445                ContactEventType::End => continue,
446            };
447            let (Some(a), Some(b)) = (self.bodies.get(e.body_a), self.bodies.get(e.body_b)) else {
448                continue;
449            };
450            let rel = a.velocity - b.velocity;
451            let tangential = rel - e.normal * rel.dot(e.normal);
452            let contact = Contact {
453                depth: e.depth,
454                normal: e.normal,
455                point_a: e.point,
456                point_b: e.point + e.normal * e.depth,
457            };
458            generator.process_contact(
459                e.body_a,
460                e.body_b,
461                &contact,
462                e.normal * e.relative_velocity + tangential,
463                is_new,
464            );
465        }
466    }
467}
468
469// ============================================================================
470// Tests
471// ============================================================================
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476
477    #[test]
478    fn test_audio_generator() {
479        let mut gen = AudioGenerator::new(2, AudioConfig::default());
480        gen.set_material(0, AudioMaterial::METAL);
481        gen.set_material(1, AudioMaterial::STONE);
482
483        gen.begin_frame();
484
485        let contact = Contact {
486            depth: Fix128::from_ratio(1, 10),
487            normal: Vec3Fix::UNIT_Y,
488            point_a: Vec3Fix::ZERO,
489            point_b: Vec3Fix::ZERO,
490        };
491
492        gen.process_contact(0, 1, &contact, Vec3Fix::from_f32(0.0, -5.0, 0.0), true);
493
494        assert_eq!(gen.events.len(), 1);
495        let event = &gen.events[0];
496        assert_eq!(event.event_type, AudioEventType::Impact);
497        assert!(event.volume > Fix128::ZERO);
498    }
499
500    #[test]
501    fn test_material_presets() {
502        let metal = AudioMaterial::METAL;
503        let rubber = AudioMaterial::RUBBER;
504
505        // Metal should be harder and more resonant than rubber
506        assert!(metal.hardness > rubber.hardness);
507        assert!(metal.resonance > rubber.resonance);
508    }
509
510    #[test]
511    fn test_volume_scaling() {
512        let gen = AudioGenerator::new(2, AudioConfig::default());
513        let mat = AudioMaterial::WOOD;
514
515        let soft = gen.compute_volume(Fix128::from_int(1), &mat, &mat);
516        let hard = gen.compute_volume(Fix128::from_int(10), &mat, &mat);
517
518        assert!(hard > soft, "Harder impact should be louder");
519    }
520
521    #[test]
522    fn test_below_threshold() {
523        let mut gen = AudioGenerator::new(2, AudioConfig::default());
524        gen.begin_frame();
525
526        let contact = Contact {
527            depth: Fix128::from_ratio(1, 100),
528            normal: Vec3Fix::UNIT_Y,
529            point_a: Vec3Fix::ZERO,
530            point_b: Vec3Fix::ZERO,
531        };
532
533        // Very slow impact (below threshold)
534        gen.process_contact(0, 1, &contact, Vec3Fix::from_f32(0.0, -0.01, 0.0), true);
535
536        assert!(
537            gen.events.is_empty(),
538            "Below-threshold impacts should be silent"
539        );
540    }
541
542    #[test]
543    fn test_slide_detection() {
544        let mut gen = AudioGenerator::new(2, AudioConfig::default());
545        gen.begin_frame();
546
547        let contact = Contact {
548            depth: Fix128::from_ratio(1, 10),
549            normal: Vec3Fix::UNIT_Y,
550            point_a: Vec3Fix::ZERO,
551            point_b: Vec3Fix::ZERO,
552        };
553
554        // Tangential velocity (sliding)
555        gen.process_contact(
556            0,
557            1,
558            &contact,
559            Vec3Fix::from_f32(5.0, 0.0, 0.0), // X velocity, Y normal = sliding
560            false,                            // not new contact
561        );
562
563        assert_eq!(gen.events.len(), 1);
564        assert_eq!(gen.events[0].event_type, AudioEventType::Slide);
565    }
566
567    #[test]
568    fn get_events_returns_this_frames_events_in_order_and_respects_cap() {
569        let config = AudioConfig {
570            max_events_per_frame: 2,
571            ..AudioConfig::default()
572        };
573        let mut gen = AudioGenerator::new(3, config);
574        gen.set_material(1, AudioMaterial::METAL);
575        assert!(gen.get_events().is_empty());
576        gen.begin_frame();
577
578        let contact_at = |x: i64| Contact {
579            depth: Fix128::from_ratio(1, 10),
580            normal: Vec3Fix::UNIT_Y,
581            point_a: Vec3Fix::from_int(x, 0, 0),
582            point_b: Vec3Fix::from_int(x, -1, 0),
583        };
584        // 1: impact、speed 5 → volume = sqrt(5/20) = 1/2
585        gen.process_contact(0, 1, &contact_at(1), Vec3Fix::from_int(0, -5, 0), true);
586        // 2: slide (接線 5 > 1/2)、材質 WOOD × WOOD (body 2 は default)
587        gen.process_contact(0, 2, &contact_at(2), Vec3Fix::from_int(5, 0, 0), false);
588        // 3: cap (2) 超過で捨てられる
589        gen.process_contact(1, 2, &contact_at(3), Vec3Fix::from_int(0, -9, 0), true);
590
591        let events = gen.get_events();
592        assert_eq!(events.len(), 2);
593        assert_eq!(events, gen.events.as_slice());
594        // 挿入順 (position = contact.point_a)
595        assert_eq!(events[0].position, Vec3Fix::from_int(1, 0, 0));
596        assert_eq!(events[1].position, Vec3Fix::from_int(2, 0, 0));
597        assert_eq!(events[0].event_type, AudioEventType::Impact);
598        assert_eq!(events[1].event_type, AudioEventType::Slide);
599        assert_eq!(
600            (events[0].material_a, events[0].material_b),
601            (MaterialType::Wood, MaterialType::Metal)
602        );
603        assert_eq!(
604            (events[1].material_a, events[1].material_b),
605            (MaterialType::Wood, MaterialType::Wood)
606        );
607        let vol_err = (events[0].volume - Fix128::from_ratio(1, 2)).abs();
608        assert!(
609            vol_err < Fix128::from_ratio(1, 1_000_000),
610            "volume {:?}",
611            events[0].volume
612        );
613        // slide の roughness = (5/20) * avg_hardness、impact (接線 0) は 0
614        assert_eq!(events[0].roughness, Fix128::ZERO);
615        let wood = AudioMaterial::WOOD;
616        assert_eq!(
617            events[1].roughness,
618            Fix128::from_ratio(1, 4) * wood.hardness
619        );
620
621        // 次 frame で空になり、再度 process すれば新しい slice
622        gen.begin_frame();
623        assert!(gen.get_events().is_empty());
624        gen.process_contact(1, 2, &contact_at(3), Vec3Fix::from_int(0, -9, 0), true);
625        assert_eq!(gen.get_events().len(), 1);
626        assert_eq!(gen.get_events()[0].position, Vec3Fix::from_int(3, 0, 0));
627    }
628
629    fn custom(density: i64, hardness: Fix128) -> AudioMaterial {
630        AudioMaterial {
631            material_type: MaterialType::Custom(7),
632            density: Fix128::from_int(density),
633            hardness,
634            resonance: Fix128::ZERO,
635            damping: Fix128::ZERO,
636        }
637    }
638
639    /// oracle: `set_material` past the table grows it with wood; a body
640    /// never given a material is wood.
641    #[test]
642    fn set_material_grows_with_wood() {
643        let mut gen = AudioGenerator::new(1, AudioConfig::default());
644        gen.set_material(4, AudioMaterial::METAL);
645        assert_eq!(gen.materials.len(), 5);
646        assert_eq!(gen.materials[2], AudioMaterial::WOOD);
647        assert_eq!(gen.get_material(4), AudioMaterial::METAL);
648        assert_eq!(gen.get_material(99), AudioMaterial::WOOD);
649    }
650
651    /// `v/20` is not a dyadic number, so pitches are compared to 1e-15
652    fn assert_near(got: Fix128, want: Fix128) {
653        assert!(
654            (got - want).abs() < Fix128::from_ratio(1, 1_000_000_000_000_000),
655            "{got:?} vs {want:?}"
656        );
657    }
658
659    /// oracle: pitch is `1 + v/20` times the density factor `1000/ρ̄` clamped
660    /// to `[1/2, 2]`: for `v = 2` that is `11/10` for `ρ̄ = 0` (no factor),
661    /// `11/5` for `ρ̄ = 100` (factor 10 → 2), `11/20` for `ρ̄ = 4000` (factor
662    /// 1/4 → 1/2). Volume, brightness and roughness saturate at
663    /// `v ≥ v_max = 20`: volume 1, brightness `h̄·(1/2 + 1/2) = h̄`,
664    /// roughness `h̄`.
665    #[test]
666    fn pitch_density_clamp_and_saturation() {
667        let gen = AudioGenerator::new(2, AudioConfig::default());
668        let v = Fix128::from_int(2);
669        let h = Fix128::from_ratio(1, 2);
670        let zero = custom(0, h);
671        assert_near(
672            gen.compute_pitch(v, &zero, &zero),
673            Fix128::from_ratio(11, 10),
674        );
675        let light = custom(100, h);
676        assert_near(
677            gen.compute_pitch(v, &light, &light),
678            Fix128::from_ratio(11, 5),
679        );
680        let heavy = custom(4000, h);
681        assert_near(
682            gen.compute_pitch(v, &heavy, &heavy),
683            Fix128::from_ratio(11, 20),
684        );
685        let fast = Fix128::from_int(40);
686        assert_eq!(gen.compute_volume(fast, &light, &light), Fix128::ONE);
687        assert_eq!(gen.compute_brightness(fast, &light, &light), h);
688        assert_eq!(gen.compute_roughness(fast, &light, &light), h);
689    }
690
691    /// oracle: a persisting contact whose relative velocity is along the
692    /// normal (no tangential part) rolls; an event whose volume
693    /// `sqrt(v/20)` is below `min_volume` is dropped (`v = 1`: `0.22 < 1/2`).
694    #[test]
695    fn roll_and_quiet_events() {
696        let contact = Contact {
697            depth: Fix128::from_ratio(1, 10),
698            normal: Vec3Fix::UNIT_Y,
699            point_a: Vec3Fix::ZERO,
700            point_b: Vec3Fix::ZERO,
701        };
702        let mut gen = AudioGenerator::new(2, AudioConfig::default());
703        gen.process_contact(0, 1, &contact, Vec3Fix::from_int(0, -2, 0), false);
704        assert_eq!(gen.events.len(), 1);
705        assert_eq!(gen.events[0].event_type, AudioEventType::Roll);
706        assert_eq!(gen.events[0].roughness, Fix128::ZERO);
707
708        let mut quiet = AudioGenerator::new(
709            2,
710            AudioConfig {
711                min_volume: Fix128::from_ratio(1, 2),
712                ..AudioConfig::default()
713            },
714        );
715        quiet.process_contact(0, 1, &contact, Vec3Fix::from_int(0, -1, 0), true);
716        assert!(quiet.events.is_empty());
717    }
718
719    /// oracle: from the world's contact events, `emit_contact_audio` sends a
720    /// begin as an impact and a persist as a slide (the bodies' tangential
721    /// velocity `(3, 0, 0)` exceeds 1/2), at the event point, with relative
722    /// velocity `n·v_rel + tangential = (3, −5, 0)`: volume
723    /// `sqrt(√34 / 20)`. An end event and an event naming a body that does not
724    /// exist make no sound.
725    #[test]
726    fn world_contact_events_become_audio() {
727        use crate::event::{ContactEvent, ContactEventType};
728        let mut world = crate::solver::PhysicsWorld::new(crate::solver::SolverConfig::default());
729        let mut a = crate::solver::RigidBody::new(Vec3Fix::ZERO, Fix128::ONE);
730        a.velocity = Vec3Fix::from_int(3, 0, 0);
731        world.add_body(a);
732        world.add_body(crate::solver::RigidBody::new_static(Vec3Fix::from_int(
733            0, -1, 0,
734        )));
735        let ev = |event_type, body_b| ContactEvent {
736            body_a: 0,
737            body_b,
738            event_type,
739            normal: Vec3Fix::UNIT_Y,
740            point: Vec3Fix::from_int(1, 2, 3),
741            depth: Fix128::from_ratio(1, 10),
742            relative_velocity: Fix128::from_int(-5),
743        };
744        world.events.contact_events = vec![
745            ev(ContactEventType::Begin, 1),
746            ev(ContactEventType::End, 1),
747            ev(ContactEventType::Begin, 9),
748            ev(ContactEventType::Persist, 1),
749        ];
750        let mut gen = AudioGenerator::new(2, AudioConfig::default());
751        gen.begin_frame();
752        world.emit_contact_audio(&mut gen);
753        assert_eq!(gen.events.len(), 2);
754        assert_eq!(gen.events[0].event_type, AudioEventType::Impact);
755        assert_eq!(gen.events[1].event_type, AudioEventType::Slide);
756        let want = (34.0_f64.sqrt() / 20.0).sqrt();
757        for e in &gen.events {
758            assert_eq!(e.position, Vec3Fix::from_int(1, 2, 3));
759            assert!((e.volume.to_f64() - want).abs() < 1e-9, "{:?}", e.volume);
760        }
761    }
762}