Skip to main content

concinnity_core/components/
audio_emitter.rs

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