Skip to main content

entracte_lib/
audio.rs

1//! Native audio playback.
2//!
3//! Break sounds used to play through the webview's `HTMLAudioElement`. That
4//! works on macOS (WKWebView) and Windows (WebView2), which decode MP3
5//! natively, but not on Linux: WebKitGTK delegates media to GStreamer and
6//! cannot decode MP3 without system codecs that aren't installed by default,
7//! so every chime and ambient track fell silent (#114).
8//!
9//! Playback now runs in-process through `rodio`, which decodes with
10//! Symphonia regardless of the OS or webview, and plays via CoreAudio /
11//! WASAPI / ALSA. The frontend just tells us *what* to play and *when*.
12//!
13//! `rodio`'s device handle (`MixerDeviceSink`) and `Player`s are `!Send`, so
14//! they live on one dedicated thread fed by a command channel. Everything
15//! the unit tests can reach — the sound catalogue lookup and the volume
16//! clamp — is pulled out as plain functions; the thread that touches the
17//! audio device is the thin, untestable shim.
18
19use std::fs::File;
20use std::io::BufReader;
21use std::path::{Path, PathBuf};
22use std::sync::mpsc::{self, Receiver, Sender};
23use std::sync::{Mutex, OnceLock};
24use std::thread;
25use std::time::Duration;
26
27use rodio::Source;
28use serde::Deserialize;
29use tauri::path::BaseDirectory;
30use tauri::{AppHandle, Manager, State};
31
32/// How long an ambient preview plays before it is cut off. Auditions on the
33/// Settings page shouldn't loop forever.
34const PREVIEW_MAX: Duration = Duration::from_secs(6);
35
36/// The bundled sound catalogue, embedded at compile time. Only `id` and
37/// `file` matter here; the frontend owns the rest (titles, attribution).
38#[derive(Deserialize)]
39struct CatalogEntry {
40    id: String,
41    file: String,
42}
43
44const CATALOG_JSON: &str = include_str!("../../src/assets/sounds/credits.json");
45
46fn catalog() -> &'static [CatalogEntry] {
47    static CATALOG: OnceLock<Vec<CatalogEntry>> = OnceLock::new();
48    CATALOG.get_or_init(|| serde_json::from_str(CATALOG_JSON).unwrap_or_default())
49}
50
51/// Resolve a catalogue `sound_id` to its bundled file name, or `None` when
52/// the id isn't in the catalogue. The file name is then resolved against the
53/// app's resource directory by the Tauri command layer.
54pub fn file_for_id(id: &str) -> Option<&'static str> {
55    catalog()
56        .iter()
57        .find(|e| e.id == id)
58        .map(|e| e.file.as_str())
59}
60
61/// Clamp a volume to the playable `[0, 1]` range. Matches the frontend's
62/// old `clampVolume` so behaviour is identical across the IPC boundary.
63pub fn clamp_volume(volume: f32) -> f32 {
64    volume.clamp(0.0, 1.0)
65}
66
67enum AudioCmd {
68    SetVolume(f32),
69    PlayOnce(PathBuf),
70    StartAmbient(PathBuf),
71    PreviewAmbient(PathBuf),
72    StopAmbient,
73    StopAll,
74}
75
76/// Handle to the audio thread. Cloneable-by-reference through Tauri state;
77/// every method is fire-and-forget — a dropped thread (no output device)
78/// silently swallows commands rather than erroring up the call stack.
79pub struct AudioPlayer {
80    tx: Mutex<Sender<AudioCmd>>,
81}
82
83impl AudioPlayer {
84    /// Spawn the audio thread and return a handle to it.
85    pub fn spawn() -> Self {
86        let (tx, rx) = mpsc::channel();
87        let _ = thread::Builder::new()
88            .name("entracte-audio".into())
89            .spawn(move || run(rx));
90        Self { tx: Mutex::new(tx) }
91    }
92
93    fn send(&self, cmd: AudioCmd) {
94        if let Ok(tx) = self.tx.lock() {
95            let _ = tx.send(cmd);
96        }
97    }
98
99    pub fn set_volume(&self, volume: f32) {
100        self.send(AudioCmd::SetVolume(clamp_volume(volume)));
101    }
102
103    pub fn play_once(&self, path: PathBuf) {
104        self.send(AudioCmd::PlayOnce(path));
105    }
106
107    pub fn start_ambient(&self, path: PathBuf) {
108        self.send(AudioCmd::StartAmbient(path));
109    }
110
111    pub fn preview_ambient(&self, path: PathBuf) {
112        self.send(AudioCmd::PreviewAmbient(path));
113    }
114
115    pub fn stop_ambient(&self) {
116        self.send(AudioCmd::StopAmbient);
117    }
118
119    pub fn stop_all(&self) {
120        self.send(AudioCmd::StopAll);
121    }
122}
123
124/// Decode a sound file into a `rodio` source, logging (not panicking) on a
125/// missing file or an unsupported codec.
126fn decode(path: &Path) -> Option<rodio::Decoder<BufReader<File>>> {
127    let file = match File::open(path) {
128        Ok(f) => f,
129        Err(e) => {
130            log::warn!("audio: cannot open {}: {e}", path.display());
131            return None;
132        }
133    };
134    match rodio::Decoder::try_from(file) {
135        Ok(decoder) => Some(decoder),
136        Err(e) => {
137            log::warn!("audio: cannot decode {}: {e}", path.display());
138            None
139        }
140    }
141}
142
143fn run(rx: Receiver<AudioCmd>) {
144    let handle = match rodio::DeviceSinkBuilder::open_default_sink() {
145        Ok(handle) => handle,
146        Err(e) => {
147            log::warn!("audio: no output device, sounds disabled: {e}");
148            return;
149        }
150    };
151    let mixer = handle.mixer();
152    let mut volume: f32 = 1.0;
153    let mut ambient: Option<rodio::Player> = None;
154    let mut one_shots: Vec<rodio::Player> = Vec::new();
155
156    while let Ok(cmd) = rx.recv() {
157        // Drop finished one-shots so the vector can't grow without bound.
158        one_shots.retain(|p| !p.empty());
159        match cmd {
160            AudioCmd::SetVolume(v) => {
161                volume = v;
162                if let Some(p) = &ambient {
163                    p.set_volume(volume);
164                }
165            }
166            AudioCmd::PlayOnce(path) => {
167                if let Some(source) = decode(&path) {
168                    let player = rodio::Player::connect_new(mixer);
169                    player.set_volume(volume);
170                    player.append(source);
171                    one_shots.push(player);
172                }
173            }
174            AudioCmd::StartAmbient(path) => {
175                ambient = decode(&path).map(|source| {
176                    let player = rodio::Player::connect_new(mixer);
177                    player.set_volume(volume);
178                    player.append(source.repeat_infinite());
179                    player
180                });
181            }
182            AudioCmd::PreviewAmbient(path) => {
183                ambient = decode(&path).map(|source| {
184                    let player = rodio::Player::connect_new(mixer);
185                    player.set_volume(volume);
186                    player.append(source.repeat_infinite().take_duration(PREVIEW_MAX));
187                    player
188                });
189            }
190            AudioCmd::StopAmbient => ambient = None,
191            AudioCmd::StopAll => {
192                ambient = None;
193                one_shots.clear();
194            }
195        }
196    }
197}
198
199/// Resolve a bundled `sound_id` to its file inside the app's resource dir.
200fn resource_sound_path(app: &AppHandle, sound_id: &str) -> Option<PathBuf> {
201    let file = file_for_id(sound_id)?;
202    app.path()
203        .resolve(format!("sounds/{file}"), BaseDirectory::Resource)
204        .ok()
205}
206
207/// A user-supplied path, or `None` for empty input — keeps the empty-string
208/// short-circuit out of every custom-sound command.
209fn custom_path(path: String) -> Option<PathBuf> {
210    (!path.is_empty()).then(|| PathBuf::from(path))
211}
212
213/// Play a one-shot once `path` has been resolved (bundled id or custom file).
214/// `None` path or non-positive volume is a no-op. Shared by the bundled and
215/// custom command shims so the guard logic is tested once.
216fn dispatch_once(audio: &AudioPlayer, path: Option<PathBuf>, volume: f32) {
217    if volume <= 0.0 {
218        return;
219    }
220    if let Some(path) = path {
221        audio.set_volume(volume);
222        audio.play_once(path);
223    }
224}
225
226/// Start (or preview) an ambient loop once `path` has been resolved. `None`
227/// path or non-positive volume is a no-op.
228fn dispatch_ambient(audio: &AudioPlayer, path: Option<PathBuf>, volume: f32, preview: bool) {
229    if volume <= 0.0 {
230        return;
231    }
232    if let Some(path) = path {
233        audio.set_volume(volume);
234        if preview {
235            audio.preview_ambient(path);
236        } else {
237            audio.start_ambient(path);
238        }
239    }
240}
241
242#[tauri::command]
243pub fn play_sound(app: AppHandle, audio: State<'_, AudioPlayer>, sound_id: String, volume: f32) {
244    dispatch_once(&audio, resource_sound_path(&app, &sound_id), volume);
245}
246
247#[tauri::command]
248pub fn play_custom_sound(audio: State<'_, AudioPlayer>, path: String, volume: f32) {
249    dispatch_once(&audio, custom_path(path), volume);
250}
251
252#[tauri::command]
253pub fn start_ambient(app: AppHandle, audio: State<'_, AudioPlayer>, sound_id: String, volume: f32) {
254    dispatch_ambient(&audio, resource_sound_path(&app, &sound_id), volume, false);
255}
256
257#[tauri::command]
258pub fn start_custom_ambient(audio: State<'_, AudioPlayer>, path: String, volume: f32) {
259    dispatch_ambient(&audio, custom_path(path), volume, false);
260}
261
262#[tauri::command]
263pub fn preview_ambient(
264    app: AppHandle,
265    audio: State<'_, AudioPlayer>,
266    sound_id: String,
267    volume: f32,
268) {
269    dispatch_ambient(&audio, resource_sound_path(&app, &sound_id), volume, true);
270}
271
272#[tauri::command]
273pub fn preview_custom_ambient(audio: State<'_, AudioPlayer>, path: String, volume: f32) {
274    dispatch_ambient(&audio, custom_path(path), volume, true);
275}
276
277#[tauri::command]
278pub fn stop_ambient(audio: State<'_, AudioPlayer>) {
279    audio.stop_ambient();
280}
281
282#[tauri::command]
283pub fn stop_all_sounds(audio: State<'_, AudioPlayer>) {
284    audio.stop_all();
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    #[test]
292    fn catalog_parses_and_is_non_empty() {
293        assert!(!catalog().is_empty());
294    }
295
296    #[test]
297    fn file_for_known_id_resolves() {
298        // Temple bell — the default end chime.
299        let file = file_for_id("337048").expect("known id resolves");
300        assert!(file.ends_with(".mp3"), "got {file}");
301    }
302
303    #[test]
304    fn file_for_unknown_id_is_none() {
305        assert_eq!(file_for_id("not-a-real-id"), None);
306        assert_eq!(file_for_id(""), None);
307    }
308
309    #[test]
310    fn clamp_volume_bounds_to_unit_range() {
311        assert_eq!(clamp_volume(-0.5), 0.0);
312        assert_eq!(clamp_volume(0.0), 0.0);
313        assert_eq!(clamp_volume(0.5), 0.5);
314        assert_eq!(clamp_volume(1.0), 1.0);
315        assert_eq!(clamp_volume(2.0), 1.0);
316    }
317
318    #[test]
319    fn decode_missing_file_is_none() {
320        assert!(decode(Path::new("/no/such/sound.mp3")).is_none());
321    }
322
323    #[test]
324    fn decode_bundled_mp3_succeeds() {
325        // Proves the Symphonia MP3 decoder is wired up: decoding never
326        // touches an audio device, so this is safe in headless CI.
327        let file = file_for_id("337048").expect("known id");
328        let path = format!("{}/../src/assets/sounds/{file}", env!("CARGO_MANIFEST_DIR"));
329        assert!(
330            decode(Path::new(&path)).is_some(),
331            "failed to decode bundled mp3 at {path}"
332        );
333    }
334
335    #[test]
336    fn custom_path_empty_is_none_otherwise_some() {
337        assert_eq!(custom_path(String::new()), None);
338        assert_eq!(
339            custom_path("/tmp/a.wav".into()),
340            Some(PathBuf::from("/tmp/a.wav"))
341        );
342    }
343
344    // The command dispatch helpers and the `AudioPlayer` handle only enqueue
345    // commands; they never block on the audio device. In headless CI the
346    // device fails to open and the thread exits, so sends are dropped — but
347    // nothing panics, which is all these guard-coverage tests assert. A
348    // missing path keeps the player untouched.
349    const ABSENT: &str = "/no/such/audio/file.mp3";
350
351    #[test]
352    fn dispatch_once_covers_guard_and_action() {
353        let audio = AudioPlayer::spawn();
354        dispatch_once(&audio, Some(PathBuf::from(ABSENT)), 0.6);
355        dispatch_once(&audio, None, 0.6);
356        dispatch_once(&audio, Some(PathBuf::from(ABSENT)), 0.0);
357    }
358
359    #[test]
360    fn dispatch_ambient_covers_start_and_preview() {
361        let audio = AudioPlayer::spawn();
362        dispatch_ambient(&audio, Some(PathBuf::from(ABSENT)), 0.6, false);
363        dispatch_ambient(&audio, Some(PathBuf::from(ABSENT)), 0.6, true);
364        dispatch_ambient(&audio, None, 0.6, false);
365        dispatch_ambient(&audio, Some(PathBuf::from(ABSENT)), 0.0, true);
366    }
367
368    #[test]
369    fn audio_player_methods_do_not_panic() {
370        let audio = AudioPlayer::spawn();
371        audio.set_volume(0.5);
372        audio.set_volume(5.0);
373        audio.play_once(PathBuf::from(ABSENT));
374        audio.start_ambient(PathBuf::from(ABSENT));
375        audio.preview_ambient(PathBuf::from(ABSENT));
376        audio.stop_ambient();
377        audio.stop_all();
378    }
379}