Skip to main content

concinnity_asset/
audio_cue.rs

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