Skip to main content

gizmo_audio/
lib.rs

1//! `gizmo-audio` is the audio subsystem of the Gizmo engine.
2//!
3//! It is a thin, [`rodio`]-backed layer that exposes a small public surface:
4//!
5//! - [`AudioSource`] — an ECS component describing a 2D or 3D playable sound.
6//! - [`AudioManager`] — a resource that loads sounds into memory and plays,
7//!   updates and stops both global (stereo) and 3D spatial sinks.
8//! - [`AudioError`] — the error type returned when loading or playing sounds fails.
9//!
10//! Sounds are decoded from in-memory byte buffers (loaded once via
11//! [`AudioManager::load_sound`]) to avoid per-play disk I/O. Spatial playback
12//! tracks emitter and listener (ear) positions and attenuates volume by
13//! distance. No `rodio` types appear in the public API, keeping the dependency
14//! contract internal.
15
16use rodio::{Decoder, OutputStream, OutputStreamHandle, Sink, Source, SpatialSink};
17use std::collections::HashMap;
18use std::fs::File;
19use std::io::{Cursor, Read};
20use std::path::Path;
21use std::sync::Arc;
22
23// ======================== ERRORS ========================
24
25/// Errors that can occur while loading or playing a sound with the
26/// [`AudioManager`].
27#[derive(Debug)]
28#[non_exhaustive]
29pub enum AudioError {
30    /// An I/O error occurred while reading the sound file.
31    Io(std::io::Error),
32    /// The requested sound file could not be found at the given path.
33    NotFound(String),
34    /// No usable audio output device/backend could be opened.
35    Backend(String),
36    /// A playback was requested for a sound name that has not been loaded
37    /// into memory via [`AudioManager::load_sound`].
38    NotLoaded(String),
39    /// The in-memory sound bytes could not be decoded into a playable stream.
40    Decode(String),
41}
42
43impl std::fmt::Display for AudioError {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        match self {
46            AudioError::Io(err) => write!(f, "IO Error: {}", err),
47            AudioError::NotFound(path) => write!(f, "File not found: {}", path),
48            AudioError::Backend(msg) => write!(f, "Audio backend error: {}", msg),
49            AudioError::NotLoaded(name) => {
50                write!(f, "Sound '{}' is not loaded into memory", name)
51            }
52            AudioError::Decode(msg) => write!(f, "Failed to decode sound: {}", msg),
53        }
54    }
55}
56
57impl std::error::Error for AudioError {
58    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
59        match self {
60            AudioError::Io(err) => Some(err),
61            _ => None,
62        }
63    }
64}
65
66impl From<std::io::Error> for AudioError {
67    fn from(err: std::io::Error) -> Self {
68        AudioError::Io(err)
69    }
70}
71
72// ======================== ECS COMPONENT ========================
73
74/// ECS component for a sound source that can be played in 2D or 3D.
75#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
76#[non_exhaustive]
77pub struct AudioSource {
78    /// Name of the loaded sound to play (see [`AudioManager::load_sound`]).
79    pub sound_name: String,
80    /// Whether the sound should be played as a 3D spatial source.
81    pub is_3d: bool,
82    /// Playback volume multiplier (1.0 = original volume).
83    pub volume: f32,
84    /// Playback pitch/speed multiplier (1.0 = original pitch).
85    pub pitch: f32,
86    /// Whether the sound should loop indefinitely.
87    pub loop_sound: bool,
88    /// Distance at which the sound is fully attenuated (silent).
89    pub max_distance: f32,
90    /// Internal id of the active sink playing this source, if any.
91    pub _internal_sink_id: Option<u64>,
92    /// Latches once this source has been auto-started, so a finished **one-shot** is not
93    /// restarted every frame. (When a one-shot ends the spatial system clears
94    /// `_internal_sink_id`; without this sentinel the auto-start guard would fire again
95    /// next frame → infinite repeat.) Transient runtime state — not persisted.
96    #[serde(skip)]
97    pub has_played: bool,
98}
99
100impl Default for AudioSource {
101    fn default() -> Self {
102        Self::new("default")
103    }
104}
105
106impl AudioSource {
107    /// Creates a new [`AudioSource`] for the sound with the given name.
108    pub fn new(name: &str) -> Self {
109        Self {
110            sound_name: name.to_string(),
111            is_3d: true,
112            volume: 1.0,
113            pitch: 1.0,
114            loop_sound: false,
115            max_distance: 100.0, // Varsayılan değer
116            _internal_sink_id: None,
117            has_played: false,
118        }
119    }
120
121    /// Sets whether the sound loops, returning the modified source.
122    pub fn with_loop(mut self, l: bool) -> Self {
123        self.loop_sound = l;
124        self
125    }
126
127    /// Sets the attenuation distance, returning the modified source.
128    pub fn with_max_distance(mut self, dist: f32) -> Self {
129        self.max_distance = dist;
130        self
131    }
132}
133
134// ======================== AUDIO MANAGER ========================
135
136/// Resource that owns the audio output device and manages loaded sounds and
137/// active playback sinks (both global and 3D spatial).
138pub struct AudioManager {
139    // OutputStream is kept alive so audio actually plays
140    _stream: OutputStream,
141    stream_handle: OutputStreamHandle,
142
143    // RAM'e (Memory) yüklenmiş ses dosyaları (Disk I/O darboğazını önler)
144    sound_buffers: HashMap<String, Arc<[u8]>>,
145
146    // Aktif SpatialSink'leri veya normal Sink'leri takip edip parametrelerini güncellemek için
147    active_spatial_sinks: HashMap<u64, SpatialSink>,
148    active_sinks: HashMap<u64, Sink>,
149    next_sink_id: u64,
150
151    // Su-altı "boğma" modu: aktifken tüm sesler kısık + hafif düşük pitch (dampening).
152    underwater: bool,
153}
154
155// SAFETY: wasm32'de (atomics/paylaşımlı-bellek OLMADAN) yürütme tek thread'dir —
156// bir değer başka bir thread'e fiilen taşınamayacağı için bu impl'ler
157// gözlemlenemez; cpal'ın WebAudio tipleri yalnızca ham JS handle'ları taşıdığı
158// için !Send'dir. wgpu'nun `fragile-send-sync-non-atomic-wasm` deseninin
159// birebir karşılığı. `not(target_feature = "atomics")` koşulu bilinçli: wasm
160// threads etkinleştirilirse impl kaybolur ve World-resource kullanımı derleme
161// hatasıyla yeniden değerlendirmeye zorlar (sessiz unsoundness yerine).
162#[cfg(all(target_arch = "wasm32", not(target_feature = "atomics")))]
163unsafe impl Send for AudioManager {}
164#[cfg(all(target_arch = "wasm32", not(target_feature = "atomics")))]
165unsafe impl Sync for AudioManager {}
166
167impl std::fmt::Debug for AudioManager {
168    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
169        f.debug_struct("AudioManager")
170            .field("loaded_sounds", &self.sound_buffers.len())
171            .field("active_spatial_sinks", &self.active_spatial_sinks.len())
172            .field("active_sinks", &self.active_sinks.len())
173            .field("next_sink_id", &self.next_sink_id)
174            .finish_non_exhaustive()
175    }
176}
177
178/// Clamp a playback-speed/pitch factor to a value that is safe for rodio's `Speed`
179/// filter. A factor of `0.0` (or negative, or NaN) makes rodio compute a source
180/// sample-rate of `(orig_rate * factor) as u32 == 0`, which trips a `from >= 1`
181/// assert inside `SampleRateConverter::new` and PANICS on the cpal audio callback
182/// thread, killing playback. `pitch = 0` is reachable from a scene-authored /
183/// serde-deserialized `AudioSource.pitch` and from the near-field 3D-audio path.
184pub(crate) fn sanitize_playback_speed(pitch: f32) -> f32 {
185    if pitch.is_finite() {
186        pitch.max(0.01)
187    } else {
188        1.0
189    }
190}
191
192impl AudioManager {
193    /// Creates a new audio manager bound to the default output device.
194    ///
195    /// # Web (WASM) note
196    ///
197    /// On `wasm32` the backend is the browser's `AudioContext` (via cpal's
198    /// WebAudio backend). Browsers suspend an `AudioContext` created before a
199    /// user gesture (autoplay policy): construct the `AudioManager` from an
200    /// input handler (first click/keypress) rather than at startup, or the
201    /// sinks will play silently.
202    ///
203    /// # Errors
204    ///
205    /// Returns [`AudioError::Backend`] if no audio output device is available
206    /// or the default device cannot be opened.
207    pub fn new() -> Result<Self, AudioError> {
208        match OutputStream::try_default() {
209            Ok((stream, stream_handle)) => {
210                log::info!("Gizmo Audio: Ses cihazı başlatıldı! 3D Uzamsal (Spatial) Motor Aktif.");
211                Ok(Self {
212                    _stream: stream,
213                    stream_handle,
214                    sound_buffers: HashMap::new(),
215                    active_spatial_sinks: HashMap::new(),
216                    active_sinks: HashMap::new(),
217                    next_sink_id: 1,
218                    underwater: false,
219                })
220            }
221            Err(e) => {
222                log::error!("Gizmo Audio Başarısız (Cihaz bulunamadı): {}", e);
223                Err(AudioError::Backend(e.to_string()))
224            }
225        }
226    }
227
228    /// Sesi diske gidip okuyarak byte array olarak RAM'e kaydeder
229    pub fn load_sound(&mut self, name: &str, path: &str) -> Result<(), AudioError> {
230        let mut file =
231            File::open(Path::new(path)).map_err(|_| AudioError::NotFound(path.to_string()))?;
232        let mut buffer = Vec::new();
233        file.read_to_end(&mut buffer).map_err(AudioError::Io)?;
234        self.sound_buffers.insert(name.to_string(), buffer.into());
235        Ok(())
236    }
237
238    /// Registers an already-decoded-from-disk (or embedded / fetched) sound
239    /// buffer under `name`. The bytes must be a complete audio file in a
240    /// format rodio can decode (WAV/OGG/FLAC/MP3), exactly as
241    /// [`load_sound`](Self::load_sound) would have read from disk.
242    ///
243    /// This is the loading path for targets without a filesystem (WASM, where
244    /// assets arrive via `fetch`/`include_bytes!`) and for games that embed
245    /// audio in the binary.
246    pub fn load_sound_bytes(&mut self, name: &str, bytes: impl Into<Arc<[u8]>>) {
247        self.sound_buffers.insert(name.to_string(), bytes.into());
248    }
249
250    /// Update çağrıldığında biten sesleri temizler
251    pub fn update(&mut self) {
252        self.clean_dead_sinks();
253    }
254
255    // ── Su-altı ses boğma (underwater muffle) ────────────────────────────────
256    /// Su altındayken hacim çarpanı (kısılır).
257    const UW_VOLUME_MUL: f32 = 0.4;
258    /// Su altındayken oynatma hızı = pitch (hafif düşürülür → "boğuk/uzak" his).
259    const UW_SPEED: f32 = 0.85;
260
261    /// Su-altı "boğma" modunu aç/kapa. Aktifken tüm sesler kısılır + hafif düşük pitch'e iner
262    /// (rodio `Sink` canlı alçak-geçiren filtre desteklemediğinden gerçek low-pass yerine bu
263    /// dampening kullanılır — "muffled" hissi verir). İDEMPOTENT: yalnız durum DEĞİŞİNCE uygular,
264    /// bu yüzden her frame güvenle çağrılabilir. NOT: hacim çarpanla geri alındığından, su
265    /// altındayken oyun tarafı `set_volume` çağırırsa yüzeye çıkışta hafif sapma olabilir
266    /// (sürekli ambient sesler için sorun değil).
267    pub fn set_underwater(&mut self, on: bool) {
268        if on == self.underwater {
269            return;
270        }
271        self.underwater = on;
272        let (vol_mul, speed) = if on {
273            (Self::UW_VOLUME_MUL, Self::UW_SPEED)
274        } else {
275            (1.0 / Self::UW_VOLUME_MUL, 1.0)
276        };
277        for sink in self.active_sinks.values() {
278            sink.set_volume(sink.volume() * vol_mul);
279            sink.set_speed(speed);
280        }
281        for sink in self.active_spatial_sinks.values() {
282            sink.set_volume(sink.volume() * vol_mul);
283            sink.set_speed(speed);
284        }
285    }
286
287    /// Su-altı boğma modu şu an aktif mi.
288    #[inline]
289    pub fn is_underwater(&self) -> bool {
290        self.underwater
291    }
292
293    /// Yeni oluşturulan bir normal `Sink`'e, o an su altındaysak boğmayı uygular.
294    fn apply_underwater_to(sink: &Sink, underwater: bool) {
295        if underwater {
296            sink.set_volume(sink.volume() * Self::UW_VOLUME_MUL);
297            sink.set_speed(Self::UW_SPEED);
298        }
299    }
300
301    /// Normal (Global/Stereo) bir ses oynatır (tek seferlik)
302    ///
303    /// # Errors
304    ///
305    /// Returns [`AudioError::NotLoaded`] if `name` was never loaded,
306    /// [`AudioError::Decode`] if the bytes cannot be decoded, or
307    /// [`AudioError::Backend`] if a playback sink cannot be created.
308    pub fn play(&mut self, name: &str) -> Result<u64, AudioError> {
309        self.play_internal(name, false)
310    }
311
312    /// Normal (Global/Stereo) bir sesi döngüsel oynatır
313    ///
314    /// # Errors
315    ///
316    /// Returns [`AudioError::NotLoaded`] if `name` was never loaded,
317    /// [`AudioError::Decode`] if the bytes cannot be decoded, or
318    /// [`AudioError::Backend`] if a playback sink cannot be created.
319    pub fn play_looped(&mut self, name: &str) -> Result<u64, AudioError> {
320        self.play_internal(name, true)
321    }
322
323    fn play_internal(&mut self, name: &str, looped: bool) -> Result<u64, AudioError> {
324        let bytes = self.sound_buffers.get(name).ok_or_else(|| {
325            log::error!("AudioManager: '{}' adlı ses bellekte yok!", name);
326            AudioError::NotLoaded(name.to_string())
327        })?;
328        let cursor = Cursor::new(Arc::clone(bytes));
329        let decoder = Decoder::new(cursor).map_err(|e| AudioError::Decode(e.to_string()))?;
330        let sink = Sink::try_new(&self.stream_handle).map_err(|e| AudioError::Backend(e.to_string()))?;
331        if looped {
332            sink.append(decoder.repeat_infinite());
333        } else {
334            sink.append(decoder);
335        }
336        let id = self.next_sink_id;
337        self.next_sink_id = self.next_sink_id.wrapping_add(1);
338
339        // Su altındayken başlayan ses de boğuk gelsin.
340        Self::apply_underwater_to(&sink, self.underwater);
341        self.active_sinks.insert(id, sink);
342        Ok(id)
343    }
344
345    /// 3D Uzamsal (Spatial) bir ses oynatır (tek seferlik)
346    ///
347    /// # Errors
348    ///
349    /// Returns [`AudioError::NotLoaded`] if `name` was never loaded,
350    /// [`AudioError::Decode`] if the bytes cannot be decoded, or
351    /// [`AudioError::Backend`] if a spatial sink cannot be created.
352    pub fn play_3d(
353        &mut self,
354        name: &str,
355        emitter_pos: [f32; 3],
356        left_ear: [f32; 3],
357        right_ear: [f32; 3],
358    ) -> Result<u64, AudioError> {
359        self.play_3d_internal(name, emitter_pos, left_ear, right_ear, false)
360    }
361
362    /// 3D Uzamsal bir sesi döngüsel oynatır
363    ///
364    /// # Errors
365    ///
366    /// Returns [`AudioError::NotLoaded`] if `name` was never loaded,
367    /// [`AudioError::Decode`] if the bytes cannot be decoded, or
368    /// [`AudioError::Backend`] if a spatial sink cannot be created.
369    pub fn play_3d_looped(
370        &mut self,
371        name: &str,
372        emitter_pos: [f32; 3],
373        left_ear: [f32; 3],
374        right_ear: [f32; 3],
375    ) -> Result<u64, AudioError> {
376        self.play_3d_internal(name, emitter_pos, left_ear, right_ear, true)
377    }
378
379    fn play_3d_internal(
380        &mut self,
381        name: &str,
382        emitter_pos: [f32; 3],
383        left_ear: [f32; 3],
384        right_ear: [f32; 3],
385        looped: bool,
386    ) -> Result<u64, AudioError> {
387        let bytes = self.sound_buffers.get(name).ok_or_else(|| {
388            log::error!("AudioManager: '{}' adlı 3D ses bellekte yok!", name);
389            AudioError::NotLoaded(name.to_string())
390        })?;
391        let cursor = Cursor::new(Arc::clone(bytes));
392        let decoder = Decoder::new(cursor).map_err(|e| AudioError::Decode(e.to_string()))?;
393        let sink = SpatialSink::try_new(&self.stream_handle, emitter_pos, left_ear, right_ear)
394            .map_err(|e| AudioError::Backend(e.to_string()))?;
395        if looped {
396            sink.append(decoder.repeat_infinite());
397        } else {
398            sink.append(decoder);
399        }
400
401        let id = self.next_sink_id;
402        self.next_sink_id = self.next_sink_id.wrapping_add(1);
403
404        if self.underwater {
405            sink.set_volume(sink.volume() * Self::UW_VOLUME_MUL);
406            sink.set_speed(Self::UW_SPEED);
407        }
408        self.active_spatial_sinks.insert(id, sink);
409        Ok(id)
410    }
411
412    // ========== ECS SINK GÜNCELLEMELERİ ==========
413
414    /// Updates an active spatial sink's emitter/ear positions and recomputes
415    /// its volume based on distance attenuation and `base_volume`.
416    pub fn update_spatial_sink(
417        &mut self,
418        id: u64,
419        emitter_pos: [f32; 3],
420        left_ear: [f32; 3],
421        right_ear: [f32; 3],
422        max_distance: f32,
423        base_volume: f32,
424    ) {
425        if let Some(sink) = self.active_spatial_sinks.get(&id) {
426            sink.set_emitter_position(emitter_pos);
427            sink.set_left_ear_position(left_ear);
428            sink.set_right_ear_position(right_ear);
429
430            let listener_pos = [
431                (left_ear[0] + right_ear[0]) / 2.0,
432                (left_ear[1] + right_ear[1]) / 2.0,
433                (left_ear[2] + right_ear[2]) / 2.0,
434            ];
435            let dx = emitter_pos[0] - listener_pos[0];
436            let dy = emitter_pos[1] - listener_pos[1];
437            let dz = emitter_pos[2] - listener_pos[2];
438            let distance = (dx * dx + dy * dy + dz * dz).sqrt();
439            let mut volume = if max_distance > 0.0 {
440                (1.0 - distance / max_distance).max(0.0)
441            } else {
442                1.0
443            };
444            volume *= base_volume;
445
446            sink.set_volume(volume);
447        }
448    }
449
450    /// Sets the volume of the active sink with the given id.
451    pub fn set_volume(&mut self, id: u64, volume: f32) {
452        if let Some(sink) = self.active_spatial_sinks.get(&id) {
453            sink.set_volume(volume);
454        } else if let Some(sink) = self.active_sinks.get(&id) {
455            sink.set_volume(volume);
456        }
457    }
458
459    /// Sets the pitch/playback speed of the active sink with the given id.
460    pub fn set_pitch(&mut self, id: u64, pitch: f32) {
461        let pitch = sanitize_playback_speed(pitch);
462        if let Some(sink) = self.active_spatial_sinks.get(&id) {
463            sink.set_speed(pitch);
464        } else if let Some(sink) = self.active_sinks.get(&id) {
465            sink.set_speed(pitch);
466        }
467    }
468
469    /// Stops the active sink with the given id.
470    pub fn stop(&mut self, id: u64) {
471        if let Some(sink) = self.active_spatial_sinks.get(&id) {
472            sink.stop();
473        } else if let Some(sink) = self.active_sinks.get(&id) {
474            sink.stop();
475        }
476    }
477
478    /// Pauses the active sink with the given id.
479    pub fn pause(&mut self, id: u64) {
480        if let Some(sink) = self.active_spatial_sinks.get(&id) {
481            sink.pause();
482        } else if let Some(sink) = self.active_sinks.get(&id) {
483            sink.pause();
484        }
485    }
486
487    /// Resumes the (paused) active sink with the given id.
488    pub fn resume(&mut self, id: u64) {
489        if let Some(sink) = self.active_spatial_sinks.get(&id) {
490            sink.play();
491        } else if let Some(sink) = self.active_sinks.get(&id) {
492            sink.play();
493        }
494    }
495
496    /// Çalan bitmiş sesleri (Sinks) Garbage Collector gibi temizler
497    pub fn clean_dead_sinks(&mut self) {
498        self.active_spatial_sinks.retain(|_, sink| !sink.empty());
499        self.active_sinks.retain(|_, sink| !sink.empty());
500    }
501
502    /// Returns whether the sink with the given id is currently playing.
503    pub fn is_playing(&self, id: u64) -> bool {
504        if let Some(sink) = self.active_spatial_sinks.get(&id) {
505            !sink.empty() && !sink.is_paused()
506        } else if let Some(sink) = self.active_sinks.get(&id) {
507            !sink.empty() && !sink.is_paused()
508        } else {
509            false
510        }
511    }
512}
513
514#[cfg(test)]
515mod tests {
516    use super::sanitize_playback_speed;
517
518    #[test]
519    fn playback_speed_never_reaches_zero() {
520        // 0 / negative / NaN would make rodio's SampleRateConverter assert (from >= 1)
521        // and panic the audio thread. All must clamp to a strictly-positive factor
522        // such that `orig_rate * factor >= 1` for any realistic rate (>= ~100 Hz).
523        assert!(sanitize_playback_speed(0.0) >= 0.01);
524        assert!(sanitize_playback_speed(-2.0) >= 0.01);
525        assert_eq!(sanitize_playback_speed(f32::NAN), 1.0);
526        assert_eq!(sanitize_playback_speed(f32::INFINITY), 1.0);
527        // A normal pitch passes through untouched.
528        assert_eq!(sanitize_playback_speed(1.5), 1.5);
529        assert_eq!(sanitize_playback_speed(0.5), 0.5);
530    }
531}
532
533gizmo_core::impl_component!(AudioSource);