Skip to main content

engine/
audio.rs

1/**------------------------------------------------------------------------------------------
2*!  Cross-platform audio manager wrapping Kira.
3*?  Provides lazy initialization (required for WASM where Web Audio API needs a
4*?  user gesture before it can start), named sub-tracks for independent volume
5*?  control, and a simple event-driven API for one-shot SFX.
6*?  Architecture:
7*?  - The `AudioManager` wraps `Option<kira::AudioManager>` for lazy/graceful init.
8*?  - Four sub-tracks: Music, Ambience, SFX, UI each with independent volume.
9*?  - Music/Ambience use handle tracking to prevent overlapping loops.
10*?  - `UiAudioEvent` covers engine-level UI interactions (hover, click, checkbox).
11*?  - `AudioResponse` extension trait auto-wires egui widgets to UI sounds.
12*------------------------------------------------------------------------------------------**/
13use kira::AudioManager as KiraManager;
14use kira::AudioManagerSettings;
15use kira::DefaultBackend;
16use kira::Tween;
17use kira::sound::static_sound::{StaticSoundData, StaticSoundHandle};
18use kira::track::{TrackBuilder, TrackHandle};
19use std::io::Cursor;
20use std::time::Duration;
21
22//? Identifies which sub-track a sound should play on.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum AudioTrack {
25    Music,
26    Ambience,
27    Sfx,
28    Ui,
29}
30
31//? Engine-level UI audio events for egui widget interactions.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
33pub enum UiAudioEvent {
34    Hover,
35    Click,
36    CheckboxOn,
37    CheckboxOff,
38    TabChange,
39}
40
41struct Tracks {
42    music: TrackHandle,
43    ambience: TrackHandle,
44    sfx: TrackHandle,
45    ui: TrackHandle,
46}
47
48//? Active looping sound handles to prevent overlapping playback.
49struct ActiveSounds {
50    current_music: Option<StaticSoundHandle>,
51    current_ambience: Option<StaticSoundHandle>,
52    current_loop_sfx: Option<StaticSoundHandle>,
53}
54
55//? Engine-level audio manager. All methods are no-ops when the backend is unavailable.
56pub struct AudioManager {
57    inner: Option<KiraManager<DefaultBackend>>,
58    tracks: Option<Tracks>,
59    active: ActiveSounds,
60    master_volume: f64,
61    music_volume: f64,
62    ambience_volume: f64,
63    sfx_volume: f64,
64    ui_volume: f64,
65    init_attempted: bool,
66    #[cfg(target_arch = "wasm32")]
67    awaiting_user_gesture: bool,
68}
69
70impl Default for AudioManager {
71    fn default() -> Self {
72        Self::new()
73    }
74}
75
76impl AudioManager {
77    pub fn new() -> Self {
78        let mgr = Self {
79            inner: None,
80            tracks: None,
81            active: ActiveSounds {
82                current_music: None,
83                current_ambience: None,
84                current_loop_sfx: None,
85            },
86            master_volume: 1.0,
87            music_volume: 1.0,
88            ambience_volume: 1.0,
89            sfx_volume: 1.0,
90            ui_volume: 1.0,
91            init_attempted: false,
92            #[cfg(target_arch = "wasm32")]
93            awaiting_user_gesture: true,
94        };
95        #[cfg(not(target_arch = "wasm32"))]
96        {
97            let mut mgr = mgr;
98            mgr.try_init();
99            mgr
100        }
101        #[cfg(target_arch = "wasm32")]
102        {
103            mgr
104        }
105    }
106
107    //? Attempt to initialize the Kira backend. Safe to call multiple times.
108    //? Subsequent calls after a successful init are no-ops.
109    fn try_init(&mut self) {
110        if self.inner.is_some() {
111            return;
112        }
113        self.init_attempted = true;
114
115        match KiraManager::<DefaultBackend>::new(AudioManagerSettings::default()) {
116            Ok(mut manager) => {
117                let music = manager.add_sub_track(TrackBuilder::default());
118                let ambience = manager.add_sub_track(TrackBuilder::default());
119                let sfx = manager.add_sub_track(TrackBuilder::default());
120                let ui = manager.add_sub_track(TrackBuilder::default());
121
122                match (music, ambience, sfx, ui) {
123                    (Ok(music), Ok(ambience), Ok(sfx), Ok(ui)) => {
124                        self.tracks = Some(Tracks {
125                            music,
126                            ambience,
127                            sfx,
128                            ui,
129                        });
130                        self.inner = Some(manager);
131                        log::info!("Audio system initialized successfully");
132                    }
133                    _ => {
134                        log::warn!("Audio: failed to create sub-tracks");
135                    }
136                }
137            }
138            Err(e) => {
139                log::warn!("Audio: backend init failed ({e}), will retry on next play");
140            }
141        }
142    }
143
144    //? Ensure the backend is alive, retrying init if needed (WASM gesture unlock).
145    fn ensure_init(&mut self) -> bool {
146        #[cfg(target_arch = "wasm32")]
147        if self.awaiting_user_gesture {
148            //* Don't create/start AudioContext until a user gesture.
149            return false;
150        }
151
152        if self.inner.is_none() {
153            self.try_init();
154        }
155        self.inner.is_some()
156    }
157
158    //? Notify the audio system that a user gesture occurred.
159    pub fn notify_user_gesture(&mut self) {
160        #[cfg(target_arch = "wasm32")]
161        {
162            if self.awaiting_user_gesture {
163                self.awaiting_user_gesture = false;
164                self.try_init();
165            }
166        }
167    }
168
169    fn track_handle(&mut self, track: AudioTrack) -> Option<&mut TrackHandle> {
170        self.tracks.as_mut().map(|t| match track {
171            AudioTrack::Music => &mut t.music,
172            AudioTrack::Ambience => &mut t.ambience,
173            AudioTrack::Sfx => &mut t.sfx,
174            AudioTrack::Ui => &mut t.ui,
175        })
176    }
177
178    //? Returns the final amplitude for a track.
179    //* Use this when computing live volume targets for ducking/unducking.
180    pub fn effective_volume(&self, track: AudioTrack) -> f64 {
181        let track_vol = match track {
182            AudioTrack::Music => self.music_volume,
183            AudioTrack::Ambience => self.ambience_volume,
184            AudioTrack::Sfx => self.sfx_volume,
185            AudioTrack::Ui => self.ui_volume,
186        };
187        self.master_volume * track_vol
188    }
189
190    //? True when a looping music handle is currently active.
191    pub fn has_active_music(&self) -> bool {
192        self.active.current_music.is_some()
193    }
194
195    //? True when a looping ambience handle is currently active.
196    pub fn has_active_ambience(&self) -> bool {
197        self.active.current_ambience.is_some()
198    }
199
200    //? Convert linear amplitude (0.0-1.0) to kira `Decibels`.
201    fn amplitude_to_db(amp: f64) -> kira::Decibels {
202        if amp <= 0.0 {
203            kira::Decibels::SILENCE
204        } else {
205            kira::Decibels((20.0_f64 * amp.log10()).max(-60.0) as f32)
206        }
207    }
208
209    pub fn play_oneshot(&mut self, data: &StaticSoundData, track: AudioTrack) {
210        if !self.ensure_init() {
211            return;
212        }
213        let vol = self.effective_volume(track);
214        if let Some(track_handle) = self.track_handle(track) {
215            let sound = data.clone().volume(Self::amplitude_to_db(vol));
216            let _ = track_handle.play(sound);
217        }
218    }
219
220    //? Play looping music. If music is already playing, crossfade.
221    pub fn play_music(&mut self, data: &StaticSoundData, fade_in_secs: f32) {
222        if !self.ensure_init() {
223            return;
224        }
225        self.stop_music(fade_in_secs);
226
227        let vol = self.effective_volume(AudioTrack::Music);
228        let sound = data
229            .clone()
230            .loop_region(..)
231            .volume(Self::amplitude_to_db(vol))
232            .fade_in_tween(Tween {
233                duration: Duration::from_secs_f32(fade_in_secs),
234                ..Default::default()
235            });
236
237        if let Some(track_handle) = self.track_handle(AudioTrack::Music) {
238            match track_handle.play(sound) {
239                Ok(handle) => self.active.current_music = Some(handle),
240                Err(e) => log::warn!("Audio: failed to play music: {e}"),
241            }
242        }
243    }
244
245    pub fn stop_music(&mut self, fade_out_secs: f32) {
246        if let Some(ref mut handle) = self.active.current_music {
247            handle.stop(Tween {
248                duration: Duration::from_secs_f32(fade_out_secs),
249                ..Default::default()
250            });
251        }
252        self.active.current_music = None;
253    }
254
255    //? Play looping ambience. If already playing, crossfade.
256    pub fn play_ambience(&mut self, data: &StaticSoundData, fade_in_secs: f32) {
257        if !self.ensure_init() {
258            return;
259        }
260        self.stop_ambience(fade_in_secs);
261
262        let vol = self.effective_volume(AudioTrack::Ambience);
263        let sound = data
264            .clone()
265            .loop_region(..)
266            .volume(Self::amplitude_to_db(vol))
267            .fade_in_tween(Tween {
268                duration: Duration::from_secs_f32(fade_in_secs),
269                ..Default::default()
270            });
271
272        if let Some(track_handle) = self.track_handle(AudioTrack::Ambience) {
273            match track_handle.play(sound) {
274                Ok(handle) => self.active.current_ambience = Some(handle),
275                Err(e) => log::warn!("Audio: failed to play ambience: {e}"),
276            }
277        }
278    }
279
280    pub fn stop_ambience(&mut self, fade_out_secs: f32) {
281        if let Some(ref mut handle) = self.active.current_ambience {
282            handle.stop(Tween {
283                duration: Duration::from_secs_f32(fade_out_secs),
284                ..Default::default()
285            });
286        }
287        self.active.current_ambience = None;
288    }
289
290    pub fn stop_loop_sfx(&mut self, fade_out_secs: f32) {
291        if let Some(ref mut handle) = self.active.current_loop_sfx {
292            handle.stop(Tween {
293                duration: Duration::from_secs_f32(fade_out_secs),
294                ..Default::default()
295            });
296        }
297        self.active.current_loop_sfx = None;
298    }
299
300    //? Play a looping SFX. Stops any existing loop SFX first.
301    pub fn play_loop_sfx(&mut self, data: &StaticSoundData) {
302        if !self.ensure_init() {
303            return;
304        }
305        self.stop_loop_sfx(0.05);
306        let vol = self.effective_volume(AudioTrack::Sfx);
307        let sound = data.clone().loop_region(..);
308        if let Some(track_handle) = self.track_handle(AudioTrack::Sfx) {
309            match track_handle.play(sound.volume(Self::amplitude_to_db(vol))) {
310                Ok(handle) => self.active.current_loop_sfx = Some(handle),
311                Err(e) => log::warn!("Audio: loop sfx failed: {e}"),
312            }
313        }
314    }
315
316    //? Smooth ducking/unducking.
317    pub fn set_music_live_volume(&mut self, amp: f64, fade_secs: f32) {
318        if let Some(ref mut handle) = self.active.current_music {
319            handle.set_volume(
320                Self::amplitude_to_db(amp.clamp(0.0, 1.0)),
321                Tween {
322                    duration: Duration::from_secs_f32(fade_secs),
323                    ..Default::default()
324                },
325            );
326        }
327    }
328
329    //? Live-update the volume of the currently-playing ambience handle.
330    pub fn set_ambience_live_volume(&mut self, amp: f64, fade_secs: f32) {
331        if let Some(ref mut handle) = self.active.current_ambience {
332            handle.set_volume(
333                Self::amplitude_to_db(amp.clamp(0.0, 1.0)),
334                Tween {
335                    duration: Duration::from_secs_f32(fade_secs),
336                    ..Default::default()
337                },
338            );
339        }
340    }
341
342    pub fn set_master_volume(&mut self, volume: f64) {
343        self.master_volume = volume.clamp(0.0, 1.0);
344    }
345
346    pub fn set_music_volume(&mut self, volume: f64) {
347        self.music_volume = volume.clamp(0.0, 1.0);
348    }
349
350    pub fn set_ambience_volume(&mut self, volume: f64) {
351        self.ambience_volume = volume.clamp(0.0, 1.0);
352    }
353
354    pub fn set_sfx_volume(&mut self, volume: f64) {
355        self.sfx_volume = volume.clamp(0.0, 1.0);
356    }
357
358    pub fn set_ui_volume(&mut self, volume: f64) {
359        self.ui_volume = volume.clamp(0.0, 1.0);
360    }
361
362    pub fn master_volume(&self) -> f64 {
363        self.master_volume
364    }
365    pub fn music_volume(&self) -> f64 {
366        self.music_volume
367    }
368    pub fn ambience_volume(&self) -> f64 {
369        self.ambience_volume
370    }
371    pub fn sfx_volume(&self) -> f64 {
372        self.sfx_volume
373    }
374    pub fn ui_volume(&self) -> f64 {
375        self.ui_volume
376    }
377}
378
379//? Load a `StaticSoundData` from embedded bytes (compatible with `include_bytes!`).
380pub fn load_sound_data(bytes: &'static [u8]) -> Option<StaticSoundData> {
381    match StaticSoundData::from_cursor(Cursor::new(bytes)) {
382        Ok(data) => Some(data),
383        Err(e) => {
384            log::warn!("Audio: failed to decode sound: {e}");
385            None
386        }
387    }
388}
389
390//? Extension trait for `egui::Response` that queues UI audio events automatically.
391//* `if ui.button("Play").with_ui_sound(&mut ctx.pending_ui_audio).clicked() { ... }`
392pub trait AudioResponse {
393    fn with_ui_sound(self, pending: &mut Vec<UiAudioEvent>) -> Self;
394    fn with_checkbox_sound(self, checked: bool, pending: &mut Vec<UiAudioEvent>) -> Self;
395    fn with_tab_sound(self, pending: &mut Vec<UiAudioEvent>) -> Self;
396}
397
398impl AudioResponse for egui::Response {
399    fn with_ui_sound(self, pending: &mut Vec<UiAudioEvent>) -> Self {
400        let id = self.id;
401        let was_hovered = self.ctx.data(|d| d.get_temp::<bool>(id).unwrap_or(false));
402        let now_hovered = self.hovered();
403        self.ctx.data_mut(|d| d.insert_temp(id, now_hovered));
404        if now_hovered && !was_hovered {
405            pending.push(UiAudioEvent::Hover);
406        }
407        if self.clicked() {
408            pending.push(UiAudioEvent::Click);
409        }
410        self
411    }
412
413    fn with_checkbox_sound(self, checked: bool, pending: &mut Vec<UiAudioEvent>) -> Self {
414        if self.changed() {
415            pending.push(if checked {
416                UiAudioEvent::CheckboxOn
417            } else {
418                UiAudioEvent::CheckboxOff
419            });
420        }
421        self
422    }
423
424    fn with_tab_sound(self, pending: &mut Vec<UiAudioEvent>) -> Self {
425        if self.clicked() {
426            pending.push(UiAudioEvent::TabChange);
427        }
428        self
429    }
430}