Skip to main content

concinnity_core/components/
audio_cue.rs

1// Audio-cue 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/// Plays audio when a [Screen](#screen) is shown.
10///
11/// A cue links a [Screen](#screen) to an [AudioClip](#audioclip): whenever UI
12/// navigation makes the screen active (a `screen:show` or `screen:toggle` action, a
13/// [KeyBinding](#keybinding), dismissing an overlay back to it, or being the
14/// world's initial screen), the clip plays. Cues play flat on the main mix with
15/// no 3D position; use an [AudioEmitter](#audioemitter) for positional sound.
16///
17/// The `kind` decides the playback behavior:
18///
19/// - `music`: loops until replaced. Showing a screen whose music cue is already
20///   playing leaves the track running, so navigating between screens that share
21///   a cue is seamless. A screen with a *different* music cue replaces the
22///   track; a screen with *no* music cue leaves the current music playing.
23/// - `sound`: a one-shot effect, played every time the screen is shown.
24#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
25#[serde(default)]
26pub struct AudioCue {
27    /// Asset identity; injected via `inject_name`. Not part of `args`.
28    #[serde(skip)]
29    pub asset_id: AssetId,
30    /// The [Screen](#screen) whose activation triggers this cue.
31    #[serde(deserialize_with = "de_opt_asset_ref")]
32    pub screen: Option<AssetId>,
33    /// The [AudioClip](#audioclip) to play.
34    #[serde(deserialize_with = "de_opt_audio_clip_handle")]
35    pub clip: Option<AudioClipHandle>,
36    /// Playback behavior: a looping `music` track or a one-shot `sound`.
37    pub kind: CueKind,
38    /// Linear gain applied to the clip (1.0 leaves it unchanged).
39    pub volume: f32,
40    /// Mix bus the cue routes through. Defaults to `music` for a music cue
41    /// and `sfx` for a sound cue; set `voice` for dialogue.
42    pub bus: Option<AudioBus>,
43    /// Voice priority for a `sound` cue. When all voice slots are busy, a new
44    /// sound silences the oldest lowest-priority voice; a sound outranked by
45    /// everything playing is skipped. Higher wins; the default is 0.
46    pub priority: i32,
47}
48
49/// How an [AudioCue](#audiocue) plays its clip.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, Default)]
51#[serde(rename_all = "lowercase")]
52pub enum CueKind {
53    /// Loops until a screen with a different music cue is shown. Re-triggering
54    /// the currently playing clip is a no-op, so shared cues are seamless.
55    Music,
56    /// A one-shot effect, played on every activation of the screen.
57    #[default]
58    Sound,
59}
60
61impl Default for AudioCue {
62    fn default() -> Self {
63        Self {
64            asset_id: AssetId::default(),
65            screen: None,
66            clip: None,
67            kind: CueKind::Sound,
68            volume: 1.0,
69            bus: None,
70            priority: 0,
71        }
72    }
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78
79    #[test]
80    fn a_blank_cue_is_a_one_shot_sound_at_unit_gain() {
81        let c = AudioCue::default();
82        assert_eq!(c.kind, CueKind::Sound);
83        assert_eq!(c.volume, 1.0);
84        assert!(c.clip.is_none());
85        assert!(c.screen.is_none());
86        assert!(c.bus.is_none());
87        assert_eq!(c.priority, 0);
88        assert_eq!(CueKind::default(), CueKind::Sound);
89    }
90
91    #[test]
92    fn a_music_cue_parses_its_clip_and_screen_by_name() {
93        crate::test_support::install_resolvers();
94        let c: AudioCue =
95            serde_json::from_str(r#"{"clip":"theme","screen":"menu","kind":"music","volume":0.4}"#)
96                .unwrap();
97        assert_eq!(c.clip, Some(AudioClipHandle(5)));
98        assert_eq!(c.screen, Some(AssetId(4)));
99        assert_eq!(c.kind, CueKind::Music);
100        assert_eq!(c.volume, 0.4);
101        assert_eq!(
102            serde_json::to_string(&CueKind::Music).unwrap(),
103            r#""music""#
104        );
105
106        let bytes = postcard::to_allocvec(&c).unwrap();
107        let back: AudioCue = postcard::from_bytes(&bytes).unwrap();
108        assert_eq!(back.clip, Some(AudioClipHandle(5)));
109        assert_eq!(back.screen, Some(AssetId(4)));
110        assert_eq!(back.kind, CueKind::Music);
111        assert_eq!(back.asset_id, AssetId::default());
112    }
113
114    #[test]
115    fn a_voice_cue_parses_its_bus_and_priority() {
116        crate::test_support::install_resolvers();
117        let c: AudioCue =
118            serde_json::from_str(r#"{"clip":"line","screen":"menu","bus":"voice","priority":5}"#)
119                .unwrap();
120        assert_eq!(c.bus, Some(AudioBus::Voice));
121        assert_eq!(c.priority, 5);
122
123        let bytes = postcard::to_allocvec(&c).unwrap();
124        let back: AudioCue = postcard::from_bytes(&bytes).unwrap();
125        assert_eq!(back.bus, Some(AudioBus::Voice));
126        assert_eq!(back.priority, 5);
127    }
128}