Skip to main content

opendev_runtime/
sound.rs

1//! Sound utility for task completion notifications.
2//!
3//! Plays a system sound when a task completes. Platform-aware:
4//! - macOS: `afplay` with Glass sound
5//! - Linux: tries `paplay`, `aplay`, `play`, `cvlc` with common sound files
6//! - Other: terminal bell (`\a`)
7//!
8//! Includes a cooldown to prevent rapid repeated sounds.
9
10use std::sync::atomic::{AtomicU64, Ordering};
11use std::time::Instant;
12
13use tracing::debug;
14
15/// Minimum seconds between consecutive sounds.
16const COOLDOWN_SECONDS: u64 = 30;
17
18/// Monotonic timestamp of the last played sound (epoch millis approximation).
19static LAST_PLAYED_MS: AtomicU64 = AtomicU64::new(0);
20
21/// Lazy-initialized start time for monotonic clock.
22static START: std::sync::OnceLock<Instant> = std::sync::OnceLock::new();
23
24fn now_ms() -> u64 {
25    let start = START.get_or_init(Instant::now);
26    start.elapsed().as_millis() as u64
27}
28
29/// Play a sound to indicate task completion.
30///
31/// Fails silently if no sound player is available.
32/// Respects a 30-second cooldown between plays.
33pub fn play_finish_sound() {
34    let now = now_ms();
35    let last = LAST_PLAYED_MS.load(Ordering::Relaxed);
36    if now.saturating_sub(last) < COOLDOWN_SECONDS * 1000 {
37        return;
38    }
39    LAST_PLAYED_MS.store(now, Ordering::Relaxed);
40
41    if let Err(e) = play_platform_sound() {
42        debug!("Failed to play finish sound: {e}");
43    }
44}
45
46fn play_platform_sound() -> Result<(), String> {
47    #[cfg(target_os = "macos")]
48    {
49        std::process::Command::new("afplay")
50            .arg("/System/Library/Sounds/Glass.aiff")
51            .stdout(std::process::Stdio::null())
52            .stderr(std::process::Stdio::null())
53            .spawn()
54            .map_err(|e| e.to_string())?;
55        Ok(())
56    }
57
58    #[cfg(target_os = "linux")]
59    {
60        let players = ["paplay", "aplay", "play", "cvlc"];
61        let sounds = [
62            "/usr/share/sounds/freedesktop/stereo/complete.oga",
63            "/usr/share/sounds/gnome/default/alerts/glass.ogg",
64            "/usr/share/sounds/alsa/Front_Center.wav",
65        ];
66
67        for player in &players {
68            let which = std::process::Command::new("which").arg(player).output();
69            if let Ok(output) = which
70                && output.status.success()
71            {
72                for sound in &sounds {
73                    if std::path::Path::new(sound).exists() {
74                        let mut cmd = std::process::Command::new(player);
75                        if *player == "cvlc" {
76                            cmd.arg("--play-and-exit");
77                        }
78                        cmd.arg(sound)
79                            .stdout(std::process::Stdio::null())
80                            .stderr(std::process::Stdio::null())
81                            .spawn()
82                            .map_err(|e| e.to_string())?;
83                        return Ok(());
84                    }
85                }
86            }
87        }
88
89        // Fallback: terminal bell
90        print!("\x07");
91        Ok(())
92    }
93
94    #[cfg(target_os = "windows")]
95    {
96        let sound_path = r"C:\Windows\Media\notify.wav";
97        let ps_cmd = format!("(New-Object Media.SoundPlayer '{sound_path}').PlaySync()");
98        std::process::Command::new("powershell")
99            .args(["-Command", &ps_cmd])
100            .stdout(std::process::Stdio::null())
101            .stderr(std::process::Stdio::null())
102            .spawn()
103            .map_err(|e| e.to_string())?;
104        Ok(())
105    }
106
107    #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
108    {
109        print!("\x07");
110        Ok(())
111    }
112}
113
114#[cfg(test)]
115#[path = "sound_tests.rs"]
116mod tests;