Skip to main content

audio_code/ui/
sounds.rs

1//! Interface sounds for the virtual UI.
2
3use crate::audio::{Audio, Synth};
4
5/// The moments at which widgets play a sound.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
7pub enum UiSound {
8    /// A widget opened.
9    Open,
10    /// A widget was closed by the player.
11    Close,
12    /// The focus moved to another item.
13    Navigate,
14    /// The focus jumped from the last to the first item or back (menus with `wrap`).
15    /// Falls back to [`UiSound::Navigate`] when not set.
16    Wrap,
17    /// A button was pressed.
18    Activate,
19    /// The focus hit the first or last item.
20    Edge,
21    /// A value changed (slider, checkbox, choice).
22    Change,
23    /// A character was typed or deleted in a text field (silent by default).
24    Typing,
25}
26
27impl UiSound {
28    /// Every kind of interface sound.
29    pub const ALL: [UiSound; 8] = [
30        UiSound::Open,
31        UiSound::Close,
32        UiSound::Navigate,
33        UiSound::Wrap,
34        UiSound::Activate,
35        UiSound::Edge,
36        UiSound::Change,
37        UiSound::Typing,
38    ];
39
40    /// Lower case name, also used as file name by [`UiSounds::from_folder`].
41    pub fn name(self) -> &'static str {
42        match self {
43            UiSound::Open => "open",
44            UiSound::Close => "close",
45            UiSound::Navigate => "navigate",
46            UiSound::Wrap => "wrap",
47            UiSound::Activate => "activate",
48            UiSound::Edge => "edge",
49            UiSound::Change => "change",
50            UiSound::Typing => "typing",
51        }
52    }
53}
54
55/// Which sound to play for each [`UiSound`]. Each entry is a sound name as accepted by
56/// [`Audio::play`]; `None` means silence.
57///
58/// The default is [`UiSounds::builtin`]: small generated clicks that work without any files.
59///
60/// ```
61/// use audio_code::ui::{UiSound, UiSounds};
62///
63/// // Built-in sounds, but your own file for moving through menus and no "edge" sound.
64/// let sounds = UiSounds::builtin()
65///     .with(UiSound::Navigate, "ui/my_click")
66///     .without(UiSound::Edge);
67/// ```
68///
69/// To design a click in code instead of using a file, see
70/// [`Ui::set_sound_data`](super::Ui::set_sound_data).
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct UiSounds {
73    /// Played when a widget opens.
74    pub open: Option<String>,
75    /// Played when the player closes a widget.
76    pub close: Option<String>,
77    /// Played when moving between items.
78    pub navigate: Option<String>,
79    /// Played when wrapping around in a menu (falls back to `navigate`).
80    pub wrap: Option<String>,
81    /// Played when a button is pressed.
82    pub activate: Option<String>,
83    /// Played at the first or last item.
84    pub edge: Option<String>,
85    /// Played when a value changes.
86    pub change: Option<String>,
87    /// Played for each typed or deleted character.
88    pub typing: Option<String>,
89}
90
91impl Default for UiSounds {
92    fn default() -> Self {
93        Self::builtin()
94    }
95}
96
97impl UiSounds {
98    /// Generated sounds that need no files. Typing is silent.
99    pub fn builtin() -> Self {
100        let mut sounds = Self::none();
101        for kind in UiSound::ALL {
102            if kind != UiSound::Typing {
103                sounds.set(kind, Some(format!("ui:builtin:{}", kind.name())));
104            }
105        }
106        sounds
107    }
108
109    /// No sounds at all.
110    pub fn none() -> Self {
111        Self {
112            open: None,
113            close: None,
114            navigate: None,
115            wrap: None,
116            activate: None,
117            edge: None,
118            change: None,
119            typing: None,
120        }
121    }
122
123    /// Loads sounds by convention from a folder: `open`, `close`, `navigate`, `wrap`,
124    /// `activate`, `edge`, `change` and `typing` (any supported extension) inside `folder`.
125    /// Missing files fall back to silence with a warning on first use; use
126    /// [`UiSounds::without`] for slots you do not have a file for.
127    pub fn from_folder(folder: &str) -> Self {
128        let mut sounds = Self::none();
129        for kind in UiSound::ALL {
130            sounds.set(kind, Some(format!("{}/{}", folder.trim_end_matches('/'), kind.name())));
131        }
132        sounds
133    }
134
135    /// Returns a copy that plays sound `name` for `kind`.
136    pub fn with(mut self, kind: UiSound, name: impl Into<String>) -> Self {
137        self.set(kind, Some(name.into()));
138        self
139    }
140
141    /// Returns a copy that is silent for `kind`.
142    pub fn without(mut self, kind: UiSound) -> Self {
143        self.set(kind, None);
144        self
145    }
146
147    /// Changes the sound for `kind` (`None` = silent).
148    pub fn set(&mut self, kind: UiSound, name: Option<String>) {
149        *self.slot(kind) = name;
150    }
151
152    fn slot(&mut self, kind: UiSound) -> &mut Option<String> {
153        match kind {
154            UiSound::Open => &mut self.open,
155            UiSound::Close => &mut self.close,
156            UiSound::Navigate => &mut self.navigate,
157            UiSound::Wrap => &mut self.wrap,
158            UiSound::Activate => &mut self.activate,
159            UiSound::Edge => &mut self.edge,
160            UiSound::Change => &mut self.change,
161            UiSound::Typing => &mut self.typing,
162        }
163    }
164
165    /// The sound name for a moment, if any.
166    pub fn get(&self, kind: UiSound) -> Option<&str> {
167        match kind {
168            UiSound::Open => self.open.as_deref(),
169            UiSound::Close => self.close.as_deref(),
170            UiSound::Navigate => self.navigate.as_deref(),
171            UiSound::Wrap => self.wrap.as_deref().or(self.navigate.as_deref()),
172            UiSound::Activate => self.activate.as_deref(),
173            UiSound::Edge => self.edge.as_deref(),
174            UiSound::Change => self.change.as_deref(),
175            UiSound::Typing => self.typing.as_deref(),
176        }
177    }
178}
179
180/// Generates a built-in sound the first time it is needed.
181pub(crate) fn ensure_builtin(audio: &mut Audio, name: &str) {
182    let Some(kind) = name.strip_prefix("ui:builtin:") else {
183        return;
184    };
185    if audio.is_loaded(name) {
186        return;
187    }
188    let synth = match kind {
189        "navigate" => Synth::sine(1400.0).length(0.035).decay(120.0).volume(0.35),
190        "wrap" => Synth::sine(1400.0).length(0.03).decay(120.0).volume(0.3).then(Synth::sine(1900.0).length(0.04).decay(120.0).volume(0.3)),
191        "change" => Synth::sine(1000.0).length(0.04).decay(90.0).volume(0.35),
192        "activate" => Synth::sine(700.0).length(0.05).decay(40.0).volume(0.35).then(Synth::sine(1050.0).length(0.07).decay(40.0).volume(0.35)),
193        "edge" => Synth::sine(180.0).length(0.08).decay(45.0).volume(0.5),
194        "open" => Synth::sine(500.0).sweep_to(980.0).length(0.12).decay(15.0).volume(0.25),
195        "close" => Synth::sine(950.0).sweep_to(590.0).length(0.12).decay(15.0).volume(0.25),
196        "typing" => Synth::noise().length(0.015).decay(250.0).volume(0.2),
197        _ => return,
198    };
199    audio.register(name, synth);
200}