1use 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
32const PREVIEW_MAX: Duration = Duration::from_secs(6);
35
36#[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
51pub 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
61pub 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
76pub struct AudioPlayer {
80 tx: Mutex<Sender<AudioCmd>>,
81}
82
83impl AudioPlayer {
84 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
124fn 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 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
199fn 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
207fn custom_path(path: String) -> Option<PathBuf> {
210 (!path.is_empty()).then(|| PathBuf::from(path))
211}
212
213fn 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
226fn 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 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 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 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}