Skip to main content

concinnity_asset/
audio_emitter.rs

1// Positional audio-emitter schema.
2
3use crate::{AssetId, AudioBus, AudioClipHandle, de_opt_asset_ref, de_opt_audio_clip_handle};
4
5/// A point source of sound in the world.
6///
7/// Plays its `clip` (an [AudioClip](#audioclip) reference) from `position`,
8/// attenuated and panned relative to the camera. When `prop` names a
9/// [Prop](#prop), the emitter tracks that prop's position every frame, so the
10/// sound follows a moving object.
11///
12/// The sound is at full volume inside `min_distance`, fades according to
13/// `rolloff` between `min_distance` and `max_distance`, and is inaudible
14/// beyond `max_distance`.
15///
16/// ```rust
17/// # use concinnity_asset::AudioEmitter;
18/// AudioEmitter {
19///     position: [6.0, 4.0, -6.0],
20///     ..Default::default()
21/// };
22/// ```
23#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
24#[serde(default)]
25pub struct AudioEmitter {
26    /// The [AudioClip](#audioclip) this emitter plays.
27    #[serde(deserialize_with = "de_opt_audio_clip_handle")]
28    pub clip: Option<AudioClipHandle>,
29    /// World-space position of the sound source.
30    pub position: [f32; 3],
31    /// Linear gain multiplier applied to the clip.
32    pub volume: f32,
33    /// Whether the clip restarts when it ends.
34    pub looping: bool,
35    /// Optional [Prop](#prop) whose position the emitter tracks each frame.
36    #[serde(deserialize_with = "de_opt_asset_ref")]
37    pub prop: Option<AssetId>,
38    /// Distance from the listener at which the sound plays at full volume.
39    pub min_distance: f32,
40    /// Distance from the listener beyond which the sound is inaudible. Must
41    /// exceed `min_distance`.
42    pub max_distance: f32,
43    /// How volume falls between `min_distance` and `max_distance`.
44    pub rolloff: Rolloff,
45    /// Mix bus the emitter routes through. Defaults to `sfx`.
46    pub bus: Option<AudioBus>,
47}
48
49/// How an [AudioEmitter](#audioemitter)'s volume falls with distance.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, Default)]
51#[serde(rename_all = "lowercase")]
52pub enum Rolloff {
53    /// Natural falloff, steep near the source. The default.
54    #[default]
55    Logarithmic,
56    /// Gradual falloff spread evenly across the range.
57    Linear,
58    /// No distance falloff: constant volume everywhere (panning still applies).
59    None,
60}
61
62impl Default for AudioEmitter {
63    fn default() -> Self {
64        Self {
65            clip: None,
66            position: [0.0; 3],
67            volume: 1.0,
68            looping: true,
69            prop: None,
70            min_distance: 1.0,
71            max_distance: 50.0,
72            rolloff: Rolloff::Logarithmic,
73            bus: None,
74        }
75    }
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81
82    #[test]
83    fn a_blank_emitter_loops_at_the_origin() {
84        // A positional emitter is normally ambience, so it loops by default.
85        let e = AudioEmitter::default();
86        assert!(e.looping);
87        assert_eq!(e.volume, 1.0);
88        assert_eq!(e.position, [0.0, 0.0, 0.0]);
89        assert!(e.clip.is_none());
90        assert!(e.prop.is_none());
91        assert_eq!(e.min_distance, 1.0);
92        assert_eq!(e.max_distance, 50.0);
93        assert_eq!(e.rolloff, Rolloff::Logarithmic);
94        assert!(e.bus.is_none());
95    }
96
97    #[test]
98    fn an_emitter_attached_to_a_prop_parses_and_round_trips_through_postcard() {
99        crate::test_support::install_resolvers();
100        let e: AudioEmitter = serde_json::from_str(
101            r#"{"clip":"hum","prop":"lamp","position":[1,2,3],"volume":0.5,"looping":false}"#,
102        )
103        .unwrap();
104        assert_eq!(e.clip, Some(AudioClipHandle(3)));
105        assert_eq!(e.prop, Some(AssetId(4)));
106        assert_eq!(e.position, [1.0, 2.0, 3.0]);
107        assert!(!e.looping);
108
109        let bytes = postcard::to_allocvec(&e).unwrap();
110        let back: AudioEmitter = postcard::from_bytes(&bytes).unwrap();
111        assert_eq!(back.clip, Some(AudioClipHandle(3)));
112        assert_eq!(back.prop, Some(AssetId(4)));
113        assert_eq!(back.volume, 0.5);
114    }
115
116    #[test]
117    fn authored_rolloff_and_bus_parse_and_round_trip() {
118        crate::test_support::install_resolvers();
119        let e: AudioEmitter = serde_json::from_str(
120            r#"{"clip":"hum","min_distance":2.5,"max_distance":80.0,"rolloff":"linear","bus":"voice"}"#,
121        )
122        .unwrap();
123        assert_eq!(e.min_distance, 2.5);
124        assert_eq!(e.max_distance, 80.0);
125        assert_eq!(e.rolloff, Rolloff::Linear);
126        assert_eq!(e.bus, Some(crate::AudioBus::Voice));
127
128        let bytes = postcard::to_allocvec(&e).unwrap();
129        let back: AudioEmitter = postcard::from_bytes(&bytes).unwrap();
130        assert_eq!(back.max_distance, 80.0);
131        assert_eq!(back.rolloff, Rolloff::Linear);
132        assert_eq!(back.bus, Some(crate::AudioBus::Voice));
133
134        let none: AudioEmitter = serde_json::from_str(r#"{"rolloff":"none"}"#).unwrap();
135        assert_eq!(none.rolloff, Rolloff::None);
136    }
137}