//! Sound-effect playback via `kira`. //! //! Loads five embedded WAVs (`include_bytes!`) at startup and plays them in //! response to gameplay events: //! //! | Event | Sound | //! |---|---| //! | `DrawRequestEvent` | `card_flip.wav` | //! | `MoveRequestEvent` | `card_place.wav` | //! | `MoveRejectedEvent` | `card_invalid.wav` | //! | `NewGameRequestEvent` | `card_deal.wav` | //! | `GameWonEvent` | `win_fanfare.wav` | //! //! If the audio device cannot be opened (e.g. a headless CI machine or a //! Linux box without a running PulseAudio/Pipewire session), the plugin //! logs a warning and degrades gracefully — gameplay continues, just //! silently. use std::io::Cursor; use bevy::prelude::*; use kira::manager::backend::DefaultBackend; use kira::manager::{AudioManager, AudioManagerSettings}; use kira::sound::static_sound::StaticSoundData; use kira::track::{TrackBuilder, TrackHandle}; use kira::tween::Tween; use crate::events::{ DrawRequestEvent, GameWonEvent, MoveRejectedEvent, MoveRequestEvent, NewGameRequestEvent, }; use crate::settings_plugin::{SettingsChangedEvent, SettingsResource}; /// Pre-decoded sound effects. Cheap to clone (frames are an `Arc<[Frame]>`), /// so we hand a fresh handle to `manager.play()` on every event. #[derive(Resource, Clone)] pub struct SoundLibrary { pub deal: StaticSoundData, pub flip: StaticSoundData, pub place: StaticSoundData, pub invalid: StaticSoundData, pub fanfare: StaticSoundData, } /// Wraps the audio backend. `NonSend` because cpal streams are `!Send` on /// some platforms. pub struct AudioState { manager: Option>, /// Dedicated sub-track for sound effects. Volume controlled by `sfx_volume`. sfx_track: Option, /// Dedicated sub-track for ambient music. Volume controlled by `music_volume`. /// No sounds are currently routed here; the track exists so future ambient /// music can be added without changing the volume architecture. music_track: Option, } pub struct AudioPlugin; impl Plugin for AudioPlugin { fn build(&self, app: &mut App) { let mut manager = AudioManager::::new(AudioManagerSettings::default()).ok(); if manager.is_none() { warn!("audio device unavailable; SFX disabled"); } let (sfx_track, music_track) = match manager.as_mut() { Some(mgr) => { let sfx = mgr.add_sub_track(TrackBuilder::default()).ok(); let music = mgr.add_sub_track(TrackBuilder::default()).ok(); (sfx, music) } None => (None, None), }; app.insert_non_send_resource(AudioState { manager, sfx_track, music_track }); let library = build_library(); if let Some(lib) = library { app.insert_resource(lib); } else { warn!("failed to decode embedded SFX assets; SFX disabled"); } app.add_event::() .add_event::() .add_event::() .add_event::() .add_event::() .add_event::() .add_systems( Startup, apply_initial_volume, ) .add_systems( Update, ( play_on_draw, play_on_move, play_on_rejected, play_on_new_game, play_on_win, apply_volume_on_change, ), ); } } fn build_library() -> Option { let deal = decode(include_bytes!("../../assets/audio/card_deal.wav"))?; let flip = decode(include_bytes!("../../assets/audio/card_flip.wav"))?; let place = decode(include_bytes!("../../assets/audio/card_place.wav"))?; let invalid = decode(include_bytes!("../../assets/audio/card_invalid.wav"))?; let fanfare = decode(include_bytes!("../../assets/audio/win_fanfare.wav"))?; Some(SoundLibrary { deal, flip, place, invalid, fanfare, }) } fn decode(bytes: &'static [u8]) -> Option { match StaticSoundData::from_cursor(Cursor::new(bytes.to_vec())) { Ok(data) => Some(data), Err(e) => { warn!("failed to decode SFX: {e}"); None } } } fn play(audio: &mut AudioState, sound: &StaticSoundData) { let Some(manager) = audio.manager.as_mut() else { return; }; // Route SFX through the dedicated sfx_track so its volume is independent // of the music_track volume. let mut data = sound.clone(); if let Some(track) = &audio.sfx_track { data.settings.output_destination = track.id().into(); } if let Err(e) = manager.play(data) { warn!("failed to play SFX: {e}"); } } fn set_sfx_volume(audio: &mut AudioState, volume: f32) { if let Some(track) = audio.sfx_track.as_mut() { track.set_volume(volume.clamp(0.0, 1.0) as f64, Tween::default()); } } fn set_music_volume(audio: &mut AudioState, volume: f32) { if let Some(track) = audio.music_track.as_mut() { track.set_volume(volume.clamp(0.0, 1.0) as f64, Tween::default()); } } fn apply_initial_volume( mut audio: NonSendMut, settings: Option>, ) { let (sfx, music) = settings.map_or((1.0, 0.5), |s| (s.0.sfx_volume, s.0.music_volume)); set_sfx_volume(&mut audio, sfx); set_music_volume(&mut audio, music); } fn apply_volume_on_change( mut events: EventReader, mut audio: NonSendMut, ) { for ev in events.read() { set_sfx_volume(&mut audio, ev.0.sfx_volume); set_music_volume(&mut audio, ev.0.music_volume); } } fn play_on_draw( mut events: EventReader, mut audio: NonSendMut, lib: Option>, ) { let Some(lib) = lib else { return; }; for _ in events.read() { play(&mut audio, &lib.flip); } } fn play_on_move( mut events: EventReader, mut audio: NonSendMut, lib: Option>, ) { let Some(lib) = lib else { return; }; for _ in events.read() { play(&mut audio, &lib.place); } } fn play_on_rejected( mut events: EventReader, mut audio: NonSendMut, lib: Option>, ) { let Some(lib) = lib else { return; }; for _ in events.read() { play(&mut audio, &lib.invalid); } } fn play_on_new_game( mut events: EventReader, mut audio: NonSendMut, lib: Option>, ) { let Some(lib) = lib else { return; }; for _ in events.read() { play(&mut audio, &lib.deal); } } fn play_on_win( mut events: EventReader, mut audio: NonSendMut, lib: Option>, ) { let Some(lib) = lib else { return; }; for _ in events.read() { play(&mut audio, &lib.fanfare); } } #[cfg(test)] mod tests { use super::*; #[test] fn embedded_wavs_decode_successfully() { // Verifies the include_bytes! paths resolve and the bytes are valid // WAV (so the gen_sfx output stays in sync with the loader). let lib = build_library(); assert!(lib.is_some(), "embedded SFX failed to decode"); } }